mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-04 04:22:59 +00:00
feat(core): consolidate auth service into backend and implement stripe billing
This commit performs a major architectural refactor by migrating the standalone `auth-service` into the main `backend` application, enabling a unified codebase and simplified deployment. It also introduces comprehensive Stripe billing support and a new administrative dashboard.
Key changes:
- **Architecture**: Deleted `apps/auth-service` and integrated its functionality (JWT, magic links, OAuth, user management) into `apps/backend`.
- **Billing**: Added Stripe integration to `backend`, supporting both monthly and yearly subscription cycles with automatic plan entitlement enforcement (e.g., location limits).
- **Admin Dashboard**: Implemented a new administrative service and API endpoints to manage tenants, users, and view platform-wide statistics.
- **Frontend**:
- Added a new pricing page with monthly/yearly toggle and comparison table.
- Integrated Stripe and Sentry for payments and error tracking.
- Improved dashboard UX/UI and added i18n support for new features.
- Enhanced the public booking flow with better validation and contact form integration.
- **Database**: Added migrations for users, magic links, password resets, OAuth states, admin audit logs, and refresh tokens.
- **DevOps**: Updated environment configurations for Railway and Vercel, and streamlined the project's `package.json` scripts.
This commit is contained in:
@@ -1,10 +0,0 @@
|
||||
.git
|
||||
.github
|
||||
.env
|
||||
.env.*
|
||||
bin
|
||||
coverage
|
||||
tmp
|
||||
*.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -1,22 +0,0 @@
|
||||
# Auth Service Environment Configuration
|
||||
# This service stays active for standalone auth flows and internal admin management.
|
||||
# SaaS billing is handled by apps/backend + Paddle.
|
||||
|
||||
PORT=8081
|
||||
APP_ENV=development
|
||||
|
||||
DATABASE_URL=postgresql://user:password@host/database?sslmode=require
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
JWT_SECRET=change-me-in-production
|
||||
NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
|
||||
|
||||
SMTP_HOST=smtp.purelymail.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USERNAME=noreply@example.com
|
||||
SMTP_PASSWORD=
|
||||
EMAIL_FROM=noreply@example.com
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URL=http://localhost:8081/api/auth/oauth/google/callback
|
||||
@@ -1,21 +0,0 @@
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Binary
|
||||
auth-service
|
||||
*.exe
|
||||
|
||||
# Go
|
||||
vendor/
|
||||
*.log
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -1,38 +0,0 @@
|
||||
# Build stage
|
||||
FROM golang:1.26.2-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/auth-service ./cmd/api
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache ca-certificates \
|
||||
&& addgroup -S bookra \
|
||||
&& adduser -S -D -H -u 10001 -G bookra bookra
|
||||
|
||||
COPY --from=builder --chown=bookra:bookra /app/auth-service /app/
|
||||
COPY --from=builder --chown=bookra:bookra /app/migrations /app/migrations
|
||||
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
USER bookra
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- "http://127.0.0.1:${PORT:-8080}/health" >/dev/null || exit 1
|
||||
|
||||
CMD ["/app/auth-service"]
|
||||
@@ -1,42 +0,0 @@
|
||||
# Bookra Auth Service
|
||||
|
||||
Standalone auth + internal admin service for Bookra.
|
||||
|
||||
Primary responsibilities:
|
||||
|
||||
- email/password auth
|
||||
- magic-link auth
|
||||
- Google OAuth when configured
|
||||
- internal admin dashboard / remote service management
|
||||
- optional Neon JWT verification support
|
||||
|
||||
Not primary billing service:
|
||||
|
||||
- SaaS billing lives in `apps/backend`
|
||||
- Paddle config belongs in backend/frontend env
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
go run ./cmd/api
|
||||
go test ./...
|
||||
go build ./...
|
||||
```
|
||||
|
||||
## Core Routes
|
||||
|
||||
- `GET /health`
|
||||
- `POST /api/auth/register`
|
||||
- `POST /api/auth/login`
|
||||
- `POST /api/auth/magic-link`
|
||||
- `POST /api/auth/verify`
|
||||
- `POST /api/auth/refresh`
|
||||
- `GET /api/auth/me`
|
||||
- `GET /api/auth/providers`
|
||||
- `GET /api/auth/oauth/google`
|
||||
- `GET /api/auth/oauth/google/callback`
|
||||
- `GET /admin`
|
||||
- `GET /admin/api/config`
|
||||
- `GET /admin/api/stats`
|
||||
|
||||
See [apps/auth-service/.env.example](/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/.env.example:1).
|
||||
@@ -1,121 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bookra/apps/auth-service/internal/config"
|
||||
"bookra/apps/auth-service/internal/db"
|
||||
"bookra/apps/auth-service/internal/email"
|
||||
"bookra/apps/auth-service/internal/handlers"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if os.Getenv("APP_ENV") == "production" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
database, err := db.New(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
if err := runMigrations(cfg.DatabaseURL); err != nil {
|
||||
log.Printf("Migration warning: %v", err)
|
||||
}
|
||||
|
||||
emailSvc := email.New(email.Config{
|
||||
Host: cfg.SMTPHost,
|
||||
Port: cfg.SMTPPort,
|
||||
Username: cfg.SMTPUsername,
|
||||
Password: cfg.SMTPPassword,
|
||||
From: cfg.EmailFrom,
|
||||
})
|
||||
|
||||
handler, err := handlers.New(database, emailSvc, cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize handlers: %v", err)
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{cfg.FrontendURL, "http://localhost:3000", "http://localhost:5173"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "healthy", "service": "auth"})
|
||||
})
|
||||
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("Auth service running on port %s", cfg.Port)
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Printf("Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Server exited")
|
||||
}
|
||||
|
||||
func runMigrations(databaseURL string) error {
|
||||
db, err := goose.OpenDBWithDriver("pgx", databaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("goose open db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := goose.SetDialect("postgres"); err != nil {
|
||||
return fmt.Errorf("goose set dialect: %w", err)
|
||||
}
|
||||
|
||||
if err := goose.Up(db, "migrations"); err != nil {
|
||||
return fmt.Errorf("goose up: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
module bookra/apps/auth-service
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.7
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
|
||||
github.com/pressly/goose/v3 v3.27.0
|
||||
github.com/stripe/stripe-go/v83 v83.2.1
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
github.com/MicahParks/jwkset v0.11.0 // indirect
|
||||
github.com/MicahParks/keyfunc/v3 v3.8.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/time v0.9.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
@@ -1,146 +0,0 @@
|
||||
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ=
|
||||
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
|
||||
github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds=
|
||||
github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
|
||||
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
|
||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
|
||||
github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stripe/stripe-go/v83 v83.2.1 h1:8WPhpMjr8VyMWKUsCMoVvlWxYazuL5edajKX/RulfbA=
|
||||
github.com/stripe/stripe-go/v83 v83.2.1/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
@@ -1,79 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/MicahParks/keyfunc/v3"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type NeonVerifier struct {
|
||||
jwks keyfunc.Keyfunc
|
||||
expectedIssuer string
|
||||
enabled bool
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewNeonVerifier(neonAuthURL string) (*NeonVerifier, error) {
|
||||
trimmed := strings.TrimRight(strings.TrimSpace(neonAuthURL), "/")
|
||||
if trimmed == "" {
|
||||
return &NeonVerifier{enabled: false}, nil
|
||||
}
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse neon auth url: %w", err)
|
||||
}
|
||||
expectedIssuer := fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
||||
jwksURL := fmt.Sprintf("%s/.well-known/jwks.json", trimmed)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
jwks, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("create neon jwks: %w", err)
|
||||
}
|
||||
return &NeonVerifier{jwks: jwks, expectedIssuer: expectedIssuer, enabled: true, cancel: cancel}, nil
|
||||
}
|
||||
|
||||
func (v *NeonVerifier) Enabled() bool {
|
||||
return v != nil && v.enabled
|
||||
}
|
||||
|
||||
func (v *NeonVerifier) Close() {
|
||||
if v != nil && v.cancel != nil {
|
||||
v.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (v *NeonVerifier) Verify(tokenString string) (*Claims, error) {
|
||||
if !v.Enabled() {
|
||||
return nil, errors.New("neon auth verifier is disabled")
|
||||
}
|
||||
token, err := jwt.Parse(tokenString, v.jwks.Keyfunc,
|
||||
jwt.WithIssuer(v.expectedIssuer),
|
||||
jwt.WithValidMethods([]string{"EdDSA"}),
|
||||
jwt.WithAudience(v.expectedIssuer),
|
||||
jwt.WithLeeway(15*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("invalid neon claims")
|
||||
}
|
||||
subject, _ := claims["sub"].(string)
|
||||
email, _ := claims["email"].(string)
|
||||
name, _ := claims["name"].(string)
|
||||
if name == "" {
|
||||
name, _ = claims["display_name"].(string)
|
||||
}
|
||||
if strings.TrimSpace(subject) == "" {
|
||||
return nil, errors.New("missing neon subject")
|
||||
}
|
||||
return &Claims{UserID: subject, Email: email, Name: name, Role: "authenticated", Type: "access"}, nil
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"bookra/apps/auth-service/internal/db"
|
||||
"bookra/apps/auth-service/internal/email"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
accessTokenTTL = 24 * time.Hour
|
||||
refreshTokenTTL = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *db.DB
|
||||
email *email.Service
|
||||
jwtSecret []byte
|
||||
frontendURL string
|
||||
}
|
||||
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Type string `json:"type"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func NewService(database *db.DB, emailSvc *email.Service, jwtSecret string, frontendURL string) *Service {
|
||||
return &Service{
|
||||
db: database,
|
||||
email: emailSvc,
|
||||
jwtSecret: []byte(jwtSecret),
|
||||
frontendURL: frontendURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) GenerateMagicLink(ctx context.Context, emailAddr string, locale string) error {
|
||||
user, err := s.db.GetUserByEmail(ctx, emailAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
user = &db.User{
|
||||
Email: emailAddr,
|
||||
Provider: "email",
|
||||
}
|
||||
user, err = s.db.CreateUser(ctx, user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
token := generateRandomToken(32)
|
||||
expiresAt := time.Now().Add(15 * time.Minute)
|
||||
|
||||
if err := s.db.CreateMagicLink(ctx, token, emailAddr, user.ID, expiresAt); err != nil {
|
||||
return fmt.Errorf("create magic link: %w", err)
|
||||
}
|
||||
|
||||
magicURL := fmt.Sprintf("%s/auth/callback?token=%s", s.frontendURL, token)
|
||||
|
||||
var name string
|
||||
if user.Name != nil {
|
||||
name = *user.Name
|
||||
}
|
||||
|
||||
if err := s.email.SendMagicLink(emailAddr, name, magicURL, locale); err != nil {
|
||||
return fmt.Errorf("send email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) VerifyMagicLink(ctx context.Context, token string) (*TokenPair, error) {
|
||||
ml, err := s.db.GetMagicLink(ctx, token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get magic link: %w", err)
|
||||
}
|
||||
|
||||
if ml == nil || ml.Used {
|
||||
return nil, fmt.Errorf("invalid or used token")
|
||||
}
|
||||
|
||||
if time.Now().After(ml.ExpiresAt) {
|
||||
return nil, fmt.Errorf("token expired")
|
||||
}
|
||||
|
||||
if err := s.db.MarkMagicLinkUsed(ctx, token); err != nil {
|
||||
return nil, fmt.Errorf("mark used: %w", err)
|
||||
}
|
||||
|
||||
user, err := s.db.GetUserByID(ctx, ml.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
if err := s.db.UpdateLastLogin(ctx, user.ID); err != nil {
|
||||
return nil, fmt.Errorf("update login: %w", err)
|
||||
}
|
||||
|
||||
return s.generateTokens(user)
|
||||
}
|
||||
|
||||
func (s *Service) OAuthLoginOrCreate(ctx context.Context, provider, providerID, email, name string) (*TokenPair, error) {
|
||||
user, err := s.db.GetUserByProviderID(ctx, provider, providerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user by provider: %w", err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
existing, err := s.db.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check existing email: %w", err)
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
existing.Provider = provider
|
||||
existing.ProviderID = &providerID
|
||||
existing.Name = &name
|
||||
existing.EmailVerified = true
|
||||
if err := s.db.UpdateUser(ctx, existing); err != nil {
|
||||
return nil, fmt.Errorf("link provider: %w", err)
|
||||
}
|
||||
user = existing
|
||||
} else {
|
||||
user = &db.User{
|
||||
Email: email,
|
||||
Name: &name,
|
||||
Provider: provider,
|
||||
ProviderID: &providerID,
|
||||
EmailVerified: true,
|
||||
}
|
||||
user, err = s.db.CreateUser(ctx, user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create oauth user: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.db.UpdateLastLogin(ctx, user.ID); err != nil {
|
||||
return nil, fmt.Errorf("update login: %w", err)
|
||||
}
|
||||
|
||||
return s.generateTokens(user)
|
||||
}
|
||||
|
||||
func (s *Service) RegisterWithPassword(ctx context.Context, email, password, name string) (*TokenPair, error) {
|
||||
existing, err := s.db.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check existing: %w", err)
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, fmt.Errorf("email already registered")
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
hashStr := string(hash)
|
||||
user := &db.User{
|
||||
Email: email,
|
||||
Name: &name,
|
||||
PasswordHash: &hashStr,
|
||||
Provider: "email",
|
||||
EmailVerified: false,
|
||||
}
|
||||
|
||||
user, err = s.db.CreateUser(ctx, user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
|
||||
return s.generateTokens(user)
|
||||
}
|
||||
|
||||
func (s *Service) LoginWithPassword(ctx context.Context, email, password string) (*TokenPair, error) {
|
||||
user, err := s.db.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
if user == nil || user.PasswordHash == nil {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
if err := s.db.UpdateLastLogin(ctx, user.ID); err != nil {
|
||||
return nil, fmt.Errorf("update login: %w", err)
|
||||
}
|
||||
|
||||
return s.generateTokens(user)
|
||||
}
|
||||
|
||||
func (s *Service) generateTokens(user *db.User) (*TokenPair, error) {
|
||||
now := time.Now()
|
||||
return s.generateTokensAt(user, now)
|
||||
}
|
||||
|
||||
func (s *Service) generateTokensAt(user *db.User, now time.Time) (*TokenPair, error) {
|
||||
name := ""
|
||||
if user.Name != nil {
|
||||
name = *user.Name
|
||||
}
|
||||
|
||||
accessTokenString, err := s.signToken(user, name, "access", now, accessTokenTTL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign access token: %w", err)
|
||||
}
|
||||
|
||||
refreshTokenString, err := s.signToken(user, name, "refresh", now, refreshTokenTTL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &TokenPair{
|
||||
AccessToken: accessTokenString,
|
||||
RefreshToken: refreshTokenString,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(accessTokenTTL.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) VerifyToken(tokenString string) (*Claims, error) {
|
||||
return s.verifyTokenOfType(tokenString, "access")
|
||||
}
|
||||
|
||||
func (s *Service) VerifyRefreshToken(tokenString string) (*Claims, error) {
|
||||
return s.verifyTokenOfType(tokenString, "refresh")
|
||||
}
|
||||
|
||||
func (s *Service) RefreshTokens(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
||||
claims, err := s.VerifyRefreshToken(refreshToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := &db.User{
|
||||
ID: uuid.MustParse(claims.UserID),
|
||||
Email: claims.Email,
|
||||
}
|
||||
if claims.Name != "" {
|
||||
user.Name = &claims.Name
|
||||
}
|
||||
|
||||
if s.db != nil {
|
||||
storedUser, err := s.db.GetUserByID(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
if storedUser == nil {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
user = storedUser
|
||||
}
|
||||
|
||||
return s.generateTokens(user)
|
||||
}
|
||||
|
||||
func (s *Service) verifyTokenOfType(tokenString string, expectedType string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return s.jwtSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
if claims.Type != expectedType {
|
||||
return nil, fmt.Errorf("invalid token type")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
func (s *Service) signToken(user *db.User, name string, tokenType string, now time.Time, ttl time.Duration) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: user.ID.String(),
|
||||
Email: user.Email,
|
||||
Name: name,
|
||||
Role: "authenticated",
|
||||
Type: tokenType,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: "bookra-auth",
|
||||
Subject: user.ID.String(),
|
||||
Audience: jwt.ClaimStrings{"bookra"},
|
||||
ID: generateRandomToken(12),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(s.jwtSecret)
|
||||
}
|
||||
|
||||
func generateRandomToken(length int) string {
|
||||
b := make([]byte, length)
|
||||
rand.Read(b)
|
||||
return base64.URLEncoding.EncodeToString(b)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookra/apps/auth-service/internal/db"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestGenerateTokensProducesVerifiableAccessAndRefreshTokens(t *testing.T) {
|
||||
service := NewService(nil, nil, "test-secret", "http://localhost:3000")
|
||||
name := "Token Tester"
|
||||
user := &db.User{
|
||||
ID: uuid.MustParse("019daeaa-bc14-7712-9224-e347a96bd5c3"),
|
||||
Email: "tester@bookra.dev",
|
||||
Name: &name,
|
||||
}
|
||||
|
||||
tokens, err := service.generateTokensAt(user, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("generate tokens: %v", err)
|
||||
}
|
||||
|
||||
accessClaims, err := service.VerifyToken(tokens.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("verify access token: %v", err)
|
||||
}
|
||||
if accessClaims.Type != "access" {
|
||||
t.Fatalf("expected access type, got %s", accessClaims.Type)
|
||||
}
|
||||
|
||||
refreshClaims, err := service.VerifyRefreshToken(tokens.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("verify refresh token: %v", err)
|
||||
}
|
||||
if refreshClaims.Type != "refresh" {
|
||||
t.Fatalf("expected refresh type, got %s", refreshClaims.Type)
|
||||
}
|
||||
|
||||
if _, err := service.VerifyToken(tokens.RefreshToken); err == nil {
|
||||
t.Fatal("expected refresh token to fail access verification")
|
||||
}
|
||||
if _, err := service.VerifyRefreshToken(tokens.AccessToken); err == nil {
|
||||
t.Fatal("expected access token to fail refresh verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokensReturnsRotatedPair(t *testing.T) {
|
||||
service := NewService(nil, nil, "test-secret", "http://localhost:3000")
|
||||
user := &db.User{
|
||||
ID: uuid.MustParse("019daeaa-bc14-7712-9224-e347a96bd5c3"),
|
||||
Email: "tester@bookra.dev",
|
||||
}
|
||||
|
||||
original, err := service.generateTokens(user)
|
||||
if err != nil {
|
||||
t.Fatalf("generate tokens: %v", err)
|
||||
}
|
||||
|
||||
refreshed, err := service.RefreshTokens(context.Background(), original.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("refresh tokens: %v", err)
|
||||
}
|
||||
|
||||
if refreshed.AccessToken == original.AccessToken {
|
||||
t.Fatal("expected rotated access token")
|
||||
}
|
||||
if refreshed.RefreshToken == original.RefreshToken {
|
||||
t.Fatal("expected rotated refresh token")
|
||||
}
|
||||
if _, err := service.VerifyToken(refreshed.AccessToken); err != nil {
|
||||
t.Fatalf("verify refreshed access token: %v", err)
|
||||
}
|
||||
if _, err := service.VerifyRefreshToken(refreshed.RefreshToken); err != nil {
|
||||
t.Fatalf("verify refreshed refresh token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokensRejectsInvalidToken(t *testing.T) {
|
||||
service := NewService(nil, nil, "test-secret", "http://localhost:3000")
|
||||
|
||||
if _, err := service.RefreshTokens(context.Background(), "bad-token"); err == nil {
|
||||
t.Fatal("expected invalid refresh token error")
|
||||
}
|
||||
}
|
||||
@@ -1,464 +0,0 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookra/apps/auth-service/internal/config"
|
||||
"bookra/apps/auth-service/internal/db"
|
||||
|
||||
"github.com/stripe/stripe-go/v83"
|
||||
"github.com/stripe/stripe-go/v83/checkout/session"
|
||||
"github.com/stripe/stripe-go/v83/customer"
|
||||
"github.com/stripe/stripe-go/v83/subscription"
|
||||
"github.com/stripe/stripe-go/v83/webhook"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrStripeNotConfigured = errors.New("stripe is not configured")
|
||||
ErrStripeWebhookMissing = errors.New("stripe webhook secret is not configured")
|
||||
ErrStripeSignatureMissing = errors.New("stripe signature is missing")
|
||||
ErrPlanNotConfigured = errors.New("stripe plan is not configured")
|
||||
ErrCustomerMappingNotFound = errors.New("stripe customer mapping not found")
|
||||
)
|
||||
|
||||
var allowedWebhookEvents = []string{
|
||||
"checkout.session.completed",
|
||||
"customer.subscription.created",
|
||||
"customer.subscription.updated",
|
||||
"customer.subscription.deleted",
|
||||
"customer.subscription.paused",
|
||||
"customer.subscription.resumed",
|
||||
"invoice.paid",
|
||||
"invoice.payment_failed",
|
||||
"payment_intent.succeeded",
|
||||
"payment_intent.payment_failed",
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg *config.Config
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
type CheckoutSession struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type SubscriptionSnapshot struct {
|
||||
CustomerID string `json:"customerId,omitempty"`
|
||||
SubscriptionID string `json:"subscriptionId,omitempty"`
|
||||
Status string `json:"status"`
|
||||
PlanCode string `json:"planCode,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
PriceID string `json:"priceId,omitempty"`
|
||||
CancelAtPeriodEnd bool `json:"cancelAtPeriodEnd"`
|
||||
CurrentPeriodStart *time.Time `json:"currentPeriodStart,omitempty"`
|
||||
CurrentPeriodEnd *time.Time `json:"currentPeriodEnd,omitempty"`
|
||||
PaymentMethod *PaymentMethod `json:"paymentMethod,omitempty"`
|
||||
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
|
||||
CheckoutURLAvailable bool `json:"checkoutUrlAvailable"`
|
||||
SyncAvailable bool `json:"syncAvailable"`
|
||||
}
|
||||
|
||||
type PaymentMethod struct {
|
||||
Brand string `json:"brand"`
|
||||
Last4 string `json:"last4"`
|
||||
}
|
||||
|
||||
type UserIdentity struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
}
|
||||
|
||||
type userCustomerMapping struct {
|
||||
CustomerID string `json:"customerId"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func NewService(cfg *config.Config, database *db.DB) *Service {
|
||||
return &Service{cfg: cfg, db: database}
|
||||
}
|
||||
|
||||
func (s *Service) GetSubscription(ctx context.Context, userID string) (SubscriptionSnapshot, error) {
|
||||
mapping, ok, err := s.getCustomerMapping(ctx, userID)
|
||||
if err != nil {
|
||||
return SubscriptionSnapshot{}, err
|
||||
}
|
||||
if !ok {
|
||||
return s.noneSnapshot(), nil
|
||||
}
|
||||
|
||||
snapshot, ok, err := s.getCustomerSnapshot(ctx, mapping.CustomerID)
|
||||
if err != nil {
|
||||
return SubscriptionSnapshot{}, err
|
||||
}
|
||||
if !ok {
|
||||
snapshot = SubscriptionSnapshot{
|
||||
CustomerID: mapping.CustomerID,
|
||||
Status: "none",
|
||||
}
|
||||
}
|
||||
snapshot.CheckoutURLAvailable = s.checkoutAvailableForPlan(snapshot.PlanCode)
|
||||
snapshot.SyncAvailable = s.cfg.StripeSecretConfigured()
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateCheckoutSession(ctx context.Context, user UserIdentity, planCode string, currency string) (CheckoutSession, error) {
|
||||
priceID, resolvedPlanCode, resolvedCurrency, err := s.priceForPlan(planCode, currency)
|
||||
if err != nil {
|
||||
return CheckoutSession{}, err
|
||||
}
|
||||
if s.cfg.StripeSecretKey == "" {
|
||||
return CheckoutSession{}, ErrStripeNotConfigured
|
||||
}
|
||||
|
||||
customerID, err := s.ensureCustomer(ctx, user)
|
||||
if err != nil {
|
||||
return CheckoutSession{}, err
|
||||
}
|
||||
|
||||
stripe.Key = s.cfg.StripeSecretKey
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||
Customer: stripe.String(customerID),
|
||||
ClientReferenceID: stripe.String(user.ID),
|
||||
SuccessURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=success", strings.TrimRight(s.cfg.FrontendURL, "/"))),
|
||||
CancelURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=cancelled", strings.TrimRight(s.cfg.FrontendURL, "/"))),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(priceID),
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
},
|
||||
Metadata: map[string]string{
|
||||
"user_id": user.ID,
|
||||
"plan_code": resolvedPlanCode,
|
||||
"currency": resolvedCurrency,
|
||||
},
|
||||
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
|
||||
TrialPeriodDays: stripe.Int64(30),
|
||||
Metadata: map[string]string{
|
||||
"user_id": user.ID,
|
||||
"plan_code": resolvedPlanCode,
|
||||
"currency": resolvedCurrency,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
checkoutSession, err := session.New(params)
|
||||
if err != nil {
|
||||
return CheckoutSession{}, err
|
||||
}
|
||||
return CheckoutSession{URL: checkoutSession.URL}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(ctx context.Context, userID string) (SubscriptionSnapshot, error) {
|
||||
mapping, ok, err := s.getCustomerMapping(ctx, userID)
|
||||
if err != nil {
|
||||
return SubscriptionSnapshot{}, err
|
||||
}
|
||||
if !ok {
|
||||
return s.noneSnapshot(), nil
|
||||
}
|
||||
if s.cfg.StripeSecretKey == "" {
|
||||
return SubscriptionSnapshot{}, ErrStripeNotConfigured
|
||||
}
|
||||
return s.syncStripeDataToKV(ctx, mapping.CustomerID)
|
||||
}
|
||||
|
||||
func (s *Service) HandleWebhook(ctx context.Context, signature string, payload []byte) error {
|
||||
if s.cfg.StripeSecretKey == "" {
|
||||
return nil
|
||||
}
|
||||
if s.cfg.StripeWebhookSecret == "" {
|
||||
return ErrStripeWebhookMissing
|
||||
}
|
||||
if signature == "" {
|
||||
return ErrStripeSignatureMissing
|
||||
}
|
||||
|
||||
event, err := webhook.ConstructEvent(payload, signature, s.cfg.StripeWebhookSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !slices.Contains(allowedWebhookEvents, string(event.Type)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
customerID := extractCustomerID(event)
|
||||
if customerID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = s.syncStripeDataToKV(ctx, customerID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) ensureCustomer(ctx context.Context, user UserIdentity) (string, error) {
|
||||
mapping, ok, err := s.getCustomerMapping(ctx, user.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ok && mapping.CustomerID != "" {
|
||||
return mapping.CustomerID, nil
|
||||
}
|
||||
|
||||
stripe.Key = s.cfg.StripeSecretKey
|
||||
params := &stripe.CustomerParams{
|
||||
Email: stripe.String(user.Email),
|
||||
Metadata: map[string]string{
|
||||
"user_id": user.ID,
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(user.Name) != "" {
|
||||
params.Name = stripe.String(strings.TrimSpace(user.Name))
|
||||
}
|
||||
|
||||
createdCustomer, err := customer.New(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := s.storeCustomerMapping(ctx, user.ID, createdCustomer.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return createdCustomer.ID, nil
|
||||
}
|
||||
|
||||
func (s *Service) syncStripeDataToKV(ctx context.Context, customerID string) (SubscriptionSnapshot, error) {
|
||||
stripe.Key = s.cfg.StripeSecretKey
|
||||
params := &stripe.SubscriptionListParams{Customer: stripe.String(customerID)}
|
||||
params.Status = stripe.String("all")
|
||||
params.AddExpand("data.default_payment_method")
|
||||
params.AddExpand("data.items.data.price")
|
||||
|
||||
iter := subscription.List(params)
|
||||
selected := (*stripe.Subscription)(nil)
|
||||
for iter.Next() {
|
||||
current := iter.Subscription()
|
||||
if selected == nil || subscriptionRank(current) > subscriptionRank(selected) {
|
||||
selected = current
|
||||
}
|
||||
}
|
||||
if iter.Err() != nil {
|
||||
return SubscriptionSnapshot{}, iter.Err()
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
snapshot := SubscriptionSnapshot{
|
||||
CustomerID: customerID,
|
||||
Status: "none",
|
||||
LastSyncedAt: &now,
|
||||
CheckoutURLAvailable: s.cfg.StripeCheckoutReady(),
|
||||
SyncAvailable: s.cfg.StripeSecretConfigured(),
|
||||
}
|
||||
|
||||
if selected != nil {
|
||||
snapshot.SubscriptionID = selected.ID
|
||||
snapshot.Status = string(selected.Status)
|
||||
snapshot.CancelAtPeriodEnd = selected.CancelAtPeriodEnd
|
||||
if len(selected.Items.Data) > 0 {
|
||||
item := selected.Items.Data[0]
|
||||
if item.Price != nil {
|
||||
snapshot.PriceID = item.Price.ID
|
||||
snapshot.PlanCode = s.planCodeForPrice(snapshot.PriceID)
|
||||
snapshot.Currency = normalizeCurrency(string(item.Price.Currency))
|
||||
}
|
||||
snapshot.CurrentPeriodStart = unixPtr(item.CurrentPeriodStart)
|
||||
snapshot.CurrentPeriodEnd = unixPtr(item.CurrentPeriodEnd)
|
||||
}
|
||||
if selected.DefaultPaymentMethod != nil && selected.DefaultPaymentMethod.Card != nil {
|
||||
snapshot.PaymentMethod = &PaymentMethod{
|
||||
Brand: string(selected.DefaultPaymentMethod.Card.Brand),
|
||||
Last4: selected.DefaultPaymentMethod.Card.Last4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.db.PutKV(ctx, customerSnapshotKey(customerID), snapshot); err != nil {
|
||||
return SubscriptionSnapshot{}, err
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (s *Service) storeCustomerMapping(ctx context.Context, userID string, customerID string) error {
|
||||
mapping := userCustomerMapping{
|
||||
CustomerID: customerID,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
return s.db.PutKV(ctx, userCustomerKey(userID), mapping)
|
||||
}
|
||||
|
||||
func (s *Service) getCustomerMapping(ctx context.Context, userID string) (userCustomerMapping, bool, error) {
|
||||
var mapping userCustomerMapping
|
||||
ok, err := s.db.GetKV(ctx, userCustomerKey(userID), &mapping)
|
||||
if err != nil {
|
||||
return userCustomerMapping{}, false, err
|
||||
}
|
||||
if !ok || mapping.CustomerID == "" {
|
||||
return userCustomerMapping{}, false, nil
|
||||
}
|
||||
return mapping, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) getCustomerSnapshot(ctx context.Context, customerID string) (SubscriptionSnapshot, bool, error) {
|
||||
var snapshot SubscriptionSnapshot
|
||||
ok, err := s.db.GetKV(ctx, customerSnapshotKey(customerID), &snapshot)
|
||||
if err != nil {
|
||||
return SubscriptionSnapshot{}, false, err
|
||||
}
|
||||
return snapshot, ok, nil
|
||||
}
|
||||
|
||||
func (s *Service) noneSnapshot() SubscriptionSnapshot {
|
||||
return SubscriptionSnapshot{
|
||||
Status: "none",
|
||||
CheckoutURLAvailable: s.cfg.StripeCheckoutReady(),
|
||||
SyncAvailable: s.cfg.StripeSecretConfigured(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) priceForPlan(planCode string, currency string) (string, string, string, error) {
|
||||
planCode = normalizePlanCode(strings.TrimSpace(planCode))
|
||||
if planCode == "" {
|
||||
planCode = s.defaultPlanCode()
|
||||
}
|
||||
if planCode == "" {
|
||||
return "", "", "", ErrPlanNotConfigured
|
||||
}
|
||||
resolvedCurrency := normalizeCurrency(currency)
|
||||
priceID := strings.TrimSpace(s.cfg.StripePriceIDs[planCode+":"+resolvedCurrency])
|
||||
if priceID == "" && resolvedCurrency != "czk" {
|
||||
priceID = strings.TrimSpace(s.cfg.StripePriceIDs[planCode+":czk"])
|
||||
if priceID != "" {
|
||||
resolvedCurrency = "czk"
|
||||
}
|
||||
}
|
||||
if priceID == "" {
|
||||
priceID = strings.TrimSpace(s.cfg.StripePriceIDs[planCode])
|
||||
}
|
||||
if priceID == "" {
|
||||
switch planCode {
|
||||
case "pro":
|
||||
priceID = strings.TrimSpace(s.cfg.StripePriceIDs["growth"])
|
||||
case "business":
|
||||
priceID = strings.TrimSpace(s.cfg.StripePriceIDs["multi-location"])
|
||||
}
|
||||
}
|
||||
if priceID == "" {
|
||||
return "", "", "", ErrPlanNotConfigured
|
||||
}
|
||||
return priceID, planCode, resolvedCurrency, nil
|
||||
}
|
||||
|
||||
func (s *Service) defaultPlanCode() string {
|
||||
for _, planCode := range []string{"pro", "monthly", "growth", "starter", "business", "multi-location"} {
|
||||
if strings.TrimSpace(s.cfg.StripePriceIDs[planCode]) != "" {
|
||||
return normalizePlanCode(planCode)
|
||||
}
|
||||
if strings.TrimSpace(s.cfg.StripePriceIDs[normalizePlanCode(planCode)+":czk"]) != "" {
|
||||
return normalizePlanCode(planCode)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Service) planCodeForPrice(priceID string) string {
|
||||
for planCode, configuredPriceID := range s.cfg.StripePriceIDs {
|
||||
if strings.TrimSpace(configuredPriceID) == priceID {
|
||||
return normalizePlanCode(strings.Split(planCode, ":")[0])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Service) hasConfiguredPrices() bool {
|
||||
return s.defaultPlanCode() != ""
|
||||
}
|
||||
|
||||
func (s *Service) checkoutAvailableForPlan(planCode string) bool {
|
||||
if !s.cfg.StripeSecretConfigured() {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(planCode) == "" {
|
||||
return s.hasConfiguredPrices()
|
||||
}
|
||||
_, _, _, err := s.priceForPlan(planCode, "czk")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func normalizePlanCode(planCode string) string {
|
||||
switch planCode {
|
||||
case "growth":
|
||||
return "pro"
|
||||
case "multi-location":
|
||||
return "business"
|
||||
default:
|
||||
return planCode
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCurrency(currency string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(currency)) {
|
||||
case "usd":
|
||||
return "usd"
|
||||
default:
|
||||
return "czk"
|
||||
}
|
||||
}
|
||||
|
||||
func userCustomerKey(userID string) string {
|
||||
return "stripe:user:" + userID
|
||||
}
|
||||
|
||||
func customerSnapshotKey(customerID string) string {
|
||||
return "stripe:customer:" + customerID
|
||||
}
|
||||
|
||||
func unixPtr(value int64) *time.Time {
|
||||
if value == 0 {
|
||||
return nil
|
||||
}
|
||||
t := time.Unix(value, 0).UTC()
|
||||
return &t
|
||||
}
|
||||
|
||||
func subscriptionRank(subscription *stripe.Subscription) int {
|
||||
switch subscription.Status {
|
||||
case stripe.SubscriptionStatusActive:
|
||||
return 100
|
||||
case stripe.SubscriptionStatusTrialing:
|
||||
return 90
|
||||
case stripe.SubscriptionStatusPastDue:
|
||||
return 80
|
||||
case stripe.SubscriptionStatusUnpaid:
|
||||
return 70
|
||||
case stripe.SubscriptionStatusIncomplete:
|
||||
return 60
|
||||
case stripe.SubscriptionStatusPaused:
|
||||
return 50
|
||||
case stripe.SubscriptionStatusCanceled:
|
||||
return 10
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func extractCustomerID(event stripe.Event) string {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(event.Data.Raw, &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := payload["customer"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
customerID, _ := value.(string)
|
||||
return customerID
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"bookra/apps/auth-service/internal/config"
|
||||
)
|
||||
|
||||
func TestPriceForPlanUsesConfiguredPlanCodesOnly(t *testing.T) {
|
||||
service := NewService(&config.Config{
|
||||
StripePriceIDs: map[string]string{
|
||||
"monthly": "price_monthly",
|
||||
"growth": "price_growth",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
priceID, planCode, currency, err := service.priceForPlan("growth", "czk")
|
||||
if err != nil {
|
||||
t.Fatalf("price for plan: %v", err)
|
||||
}
|
||||
if priceID != "price_growth" || planCode != "pro" || currency != "czk" {
|
||||
t.Fatalf("expected pro mapping, got price=%q plan=%q currency=%q", priceID, planCode, currency)
|
||||
}
|
||||
|
||||
priceID, planCode, currency, err = service.priceForPlan("", "usd")
|
||||
if err != nil {
|
||||
t.Fatalf("default price for plan: %v", err)
|
||||
}
|
||||
if priceID != "price_monthly" || planCode != "monthly" || currency != "usd" {
|
||||
t.Fatalf("expected monthly default, got price=%q plan=%q currency=%q", priceID, planCode, currency)
|
||||
}
|
||||
|
||||
_, _, _, err = service.priceForPlan("price_attacker_controlled", "czk")
|
||||
if !errors.Is(err, ErrPlanNotConfigured) {
|
||||
t.Fatalf("expected ErrPlanNotConfigured, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKVKeyShape(t *testing.T) {
|
||||
if got := userCustomerKey("user_123"); got != "stripe:user:user_123" {
|
||||
t.Fatalf("unexpected user key: %s", got)
|
||||
}
|
||||
if got := customerSnapshotKey("cus_123"); got != "stripe:customer:cus_123" {
|
||||
t.Fatalf("unexpected customer key: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckoutAvailableForPlanRequiresSecret(t *testing.T) {
|
||||
service := NewService(&config.Config{
|
||||
StripePriceIDs: map[string]string{
|
||||
"pro:czk": "price_pro_czk",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
if service.checkoutAvailableForPlan("pro") {
|
||||
t.Fatal("expected checkout unavailable without stripe secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckoutAvailableForPlanRequiresConfiguredPlan(t *testing.T) {
|
||||
service := NewService(&config.Config{
|
||||
StripeSecretKey: "sk_test_123",
|
||||
StripePriceIDs: map[string]string{
|
||||
"pro:czk": "price_pro_czk",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
if !service.checkoutAvailableForPlan("pro") {
|
||||
t.Fatal("expected pro checkout available")
|
||||
}
|
||||
if service.checkoutAvailableForPlan("business") {
|
||||
t.Fatal("expected business checkout unavailable without configured price")
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppEnv string
|
||||
Port string
|
||||
DatabaseURL string
|
||||
FrontendURL string
|
||||
JWTSecret string
|
||||
NeonAuthURL string
|
||||
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
EmailFrom string
|
||||
|
||||
GoogleClientID string
|
||||
GoogleClientSecret string
|
||||
GoogleRedirectURL string
|
||||
|
||||
StripeSecretKey string
|
||||
StripeWebhookSecret string
|
||||
StripePriceIDs map[string]string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
port := getEnv("PORT", "8081")
|
||||
|
||||
dbURL := getEnv("DATABASE_URL", "")
|
||||
if dbURL == "" {
|
||||
return nil, fmt.Errorf("DATABASE_URL is required")
|
||||
}
|
||||
|
||||
smtpPort, _ := strconv.Atoi(getEnv("SMTP_PORT", "465"))
|
||||
|
||||
return &Config{
|
||||
AppEnv: getEnv("APP_ENV", "development"),
|
||||
Port: port,
|
||||
DatabaseURL: dbURL,
|
||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
|
||||
NeonAuthURL: getEnv("NEON_AUTH_URL", ""),
|
||||
|
||||
SMTPHost: getEnv("SMTP_HOST", "smtp.purelymail.com"),
|
||||
SMTPPort: smtpPort,
|
||||
SMTPUsername: getEnvAllowEmpty("SMTP_USERNAME", "noreply@tdvorak.dev"),
|
||||
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
|
||||
EmailFrom: getEnv("EMAIL_FROM", "noreply@tdvorak.dev"),
|
||||
|
||||
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
|
||||
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
|
||||
GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", ""),
|
||||
|
||||
StripeSecretKey: getEnv("STRIPE_SECRET_KEY", ""),
|
||||
StripeWebhookSecret: getEnv("STRIPE_WEBHOOK_SECRET", ""),
|
||||
StripePriceIDs: map[string]string{
|
||||
"monthly": getEnv("STRIPE_PRICE_ID", ""),
|
||||
"starter": getEnv("STRIPE_STARTER_PRICE_ID", ""),
|
||||
"growth": getEnv("STRIPE_GROWTH_PRICE_ID", ""),
|
||||
"multi-location": getEnv("STRIPE_MULTI_LOCATION_PRICE_ID", ""),
|
||||
"pro": getEnv("STRIPE_PRO_PRICE_ID", ""),
|
||||
"business": getEnv("STRIPE_BUSINESS_PRICE_ID", ""),
|
||||
"starter:czk": getEnv("STRIPE_STARTER_CZK_PRICE_ID", ""),
|
||||
"starter:usd": getEnv("STRIPE_STARTER_USD_PRICE_ID", ""),
|
||||
"pro:czk": getEnv("STRIPE_PRO_CZK_PRICE_ID", ""),
|
||||
"pro:usd": getEnv("STRIPE_PRO_USD_PRICE_ID", ""),
|
||||
"business:czk": getEnv("STRIPE_BUSINESS_CZK_PRICE_ID", ""),
|
||||
"business:usd": getEnv("STRIPE_BUSINESS_USD_PRICE_ID", ""),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getEnv(key, defaultVal string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getEnvAllowEmpty(key, defaultVal string) string {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func (cfg *Config) StripeSecretConfigured() bool {
|
||||
return strings.TrimSpace(cfg.StripeSecretKey) != ""
|
||||
}
|
||||
|
||||
func (cfg *Config) StripeWebhookConfigured() bool {
|
||||
return strings.TrimSpace(cfg.StripeWebhookSecret) != ""
|
||||
}
|
||||
|
||||
func (cfg *Config) StripeHasAnyPriceConfigured() bool {
|
||||
for _, priceID := range cfg.StripePriceIDs {
|
||||
if strings.TrimSpace(priceID) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (cfg *Config) StripeCheckoutReady() bool {
|
||||
return cfg.StripeSecretConfigured() && cfg.StripeHasAnyPriceConfigured()
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStripeReadinessHelpers(t *testing.T) {
|
||||
cfg := &Config{
|
||||
StripeSecretKey: "sk_test_123",
|
||||
StripePriceIDs: map[string]string{
|
||||
"pro:czk": "price_123",
|
||||
},
|
||||
}
|
||||
|
||||
if !cfg.StripeSecretConfigured() {
|
||||
t.Fatal("expected secret configured")
|
||||
}
|
||||
if cfg.StripeWebhookConfigured() {
|
||||
t.Fatal("expected webhook not configured")
|
||||
}
|
||||
if !cfg.StripeHasAnyPriceConfigured() {
|
||||
t.Fatal("expected prices configured")
|
||||
}
|
||||
if !cfg.StripeCheckoutReady() {
|
||||
t.Fatal("expected checkout ready")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripeCheckoutReadyRequiresSecretAndPrice(t *testing.T) {
|
||||
cfg := &Config{
|
||||
StripePriceIDs: map[string]string{
|
||||
"pro:czk": "price_123",
|
||||
},
|
||||
}
|
||||
if cfg.StripeCheckoutReady() {
|
||||
t.Fatal("expected checkout not ready without secret")
|
||||
}
|
||||
|
||||
cfg.StripeSecretKey = "sk_test_123"
|
||||
cfg.StripePriceIDs = map[string]string{}
|
||||
if cfg.StripeCheckoutReady() {
|
||||
t.Fatal("expected checkout not ready without price")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultsAuthServicePortTo8081(t *testing.T) {
|
||||
originals := map[string]string{}
|
||||
for _, key := range []string{
|
||||
"PORT",
|
||||
"DATABASE_URL",
|
||||
} {
|
||||
originals[key] = os.Getenv(key)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for key, value := range originals {
|
||||
if value == "" {
|
||||
_ = os.Unsetenv(key)
|
||||
continue
|
||||
}
|
||||
_ = os.Setenv(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
_ = os.Unsetenv("PORT")
|
||||
_ = os.Setenv("DATABASE_URL", "postgresql://localhost/bookra")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
if cfg.Port != "8081" {
|
||||
t.Fatalf("expected default port 8081, got %s", cfg.Port)
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func New(databaseURL string) (*DB, error) {
|
||||
config, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse database config: %w", err)
|
||||
}
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(context.Background(), config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
return &DB{pool: pool}, nil
|
||||
}
|
||||
|
||||
func (db *DB) Close() {
|
||||
db.pool.Close()
|
||||
}
|
||||
|
||||
func (db *DB) Pool() *pgxpool.Pool {
|
||||
return db.pool
|
||||
}
|
||||
|
||||
func (db *DB) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
|
||||
return db.pool.QueryRow(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func (db *DB) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
|
||||
return db.pool.Query(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func (db *DB) Exec(ctx context.Context, sql string, args ...interface{}) error {
|
||||
_, err := db.pool.Exec(ctx, sql, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// Stats contains database statistics for the admin dashboard
|
||||
type Stats struct {
|
||||
TotalUsers int64 `json:"totalUsers"`
|
||||
UsersToday int64 `json:"usersToday"`
|
||||
UsersThisWeek int64 `json:"usersThisWeek"`
|
||||
UsersThisMonth int64 `json:"usersThisMonth"`
|
||||
ActiveUsers7Days int64 `json:"activeUsers7Days"`
|
||||
ActiveUsers30Days int64 `json:"activeUsers30Days"`
|
||||
MagicLinksSent int64 `json:"magicLinksSent"`
|
||||
MagicLinksUsed int64 `json:"magicLinksUsed"`
|
||||
MagicLinksPending int64 `json:"magicLinksPending"`
|
||||
OAuthUsers int64 `json:"oauthUsers"`
|
||||
PasswordUsers int64 `json:"passwordUsers"`
|
||||
}
|
||||
|
||||
// GetStats returns database statistics for the admin dashboard
|
||||
func (db *DB) GetStats(ctx context.Context) (*Stats, error) {
|
||||
stats := &Stats{}
|
||||
|
||||
// Total users
|
||||
err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Users created today
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE`).Scan(&stats.UsersToday)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Users created this week
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '7 days'`).Scan(&stats.UsersThisWeek)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Users created this month
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'`).Scan(&stats.UsersThisMonth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Active users (logged in) in last 7 days
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE last_login_at >= CURRENT_DATE - INTERVAL '7 days'`).Scan(&stats.ActiveUsers7Days)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Active users in last 30 days
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE last_login_at >= CURRENT_DATE - INTERVAL '30 days'`).Scan(&stats.ActiveUsers30Days)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Magic links sent (total)
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM magic_links`).Scan(&stats.MagicLinksSent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Magic links used
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM magic_links WHERE used = TRUE`).Scan(&stats.MagicLinksUsed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pending magic links
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM magic_links WHERE used = FALSE AND expires_at > NOW()`).Scan(&stats.MagicLinksPending)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// OAuth users
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE provider != 'email'`).Scan(&stats.OAuthUsers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Password users
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE password_hash IS NOT NULL`).Scan(&stats.PasswordUsers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
PasswordHash *string `json:"-"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderID *string `json:"provider_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
}
|
||||
|
||||
type MagicLink struct {
|
||||
Token string `json:"token"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Used bool `json:"used"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (db *DB) GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||
var user User
|
||||
var name, passwordHash, providerID *string
|
||||
var lastLoginAt *time.Time
|
||||
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
WHERE email = $1
|
||||
`, email).Scan(
|
||||
&user.ID, &user.Email, &name, &passwordHash,
|
||||
&user.EmailVerified, &user.Provider, &providerID,
|
||||
&user.CreatedAt, &user.UpdatedAt, &lastLoginAt,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user.Name = name
|
||||
user.PasswordHash = passwordHash
|
||||
user.ProviderID = providerID
|
||||
user.LastLoginAt = lastLoginAt
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetUserByID(ctx context.Context, id uuid.UUID) (*User, error) {
|
||||
var user User
|
||||
var name, passwordHash, providerID *string
|
||||
var lastLoginAt *time.Time
|
||||
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, id).Scan(
|
||||
&user.ID, &user.Email, &name, &passwordHash,
|
||||
&user.EmailVerified, &user.Provider, &providerID,
|
||||
&user.CreatedAt, &user.UpdatedAt, &lastLoginAt,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user.Name = name
|
||||
user.PasswordHash = passwordHash
|
||||
user.ProviderID = providerID
|
||||
user.LastLoginAt = lastLoginAt
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetUserByProviderID(ctx context.Context, provider, providerID string) (*User, error) {
|
||||
var user User
|
||||
var name, passwordHash *string
|
||||
var lastLoginAt *time.Time
|
||||
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
WHERE provider = $1 AND provider_id = $2
|
||||
`, provider, providerID).Scan(
|
||||
&user.ID, &user.Email, &name, &passwordHash,
|
||||
&user.EmailVerified, &user.Provider, &user.ProviderID,
|
||||
&user.CreatedAt, &user.UpdatedAt, &lastLoginAt,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user.Name = name
|
||||
user.PasswordHash = passwordHash
|
||||
user.LastLoginAt = lastLoginAt
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateUser(ctx context.Context, user *User) (*User, error) {
|
||||
if user.ID == uuid.Nil {
|
||||
user.ID = uuid.Must(uuid.NewV7())
|
||||
}
|
||||
now := time.Now()
|
||||
user.CreatedAt = now
|
||||
user.UpdatedAt = now
|
||||
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
INSERT INTO users (id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
|
||||
`, user.ID, user.Email, user.Name, user.PasswordHash, user.EmailVerified, user.Provider, user.ProviderID, now)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (db *DB) UpdateUser(ctx context.Context, user *User) error {
|
||||
user.UpdatedAt = time.Now()
|
||||
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET email = $2, name = $3, password_hash = $4, email_verified = $5,
|
||||
provider = $6, provider_id = $7, updated_at = $8, last_login_at = $9
|
||||
WHERE id = $1
|
||||
`, user.ID, user.Email, user.Name, user.PasswordHash, user.EmailVerified,
|
||||
user.Provider, user.ProviderID, user.UpdatedAt, user.LastLoginAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) UpdateLastLogin(ctx context.Context, userID uuid.UUID) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1
|
||||
`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) CreateMagicLink(ctx context.Context, token string, email string, userID uuid.UUID, expiresAt time.Time) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
INSERT INTO magic_links (token, user_id, email, expires_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
`, token, userID, email, expiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetMagicLink(ctx context.Context, token string) (*MagicLink, error) {
|
||||
var ml MagicLink
|
||||
var userID uuid.UUID
|
||||
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT token, user_id, email, used, expires_at, created_at
|
||||
FROM magic_links
|
||||
WHERE token = $1
|
||||
`, token).Scan(&ml.Token, &userID, &ml.Email, &ml.Used, &ml.ExpiresAt, &ml.CreatedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ml.UserID = userID
|
||||
return &ml, nil
|
||||
}
|
||||
|
||||
func (db *DB) MarkMagicLinkUsed(ctx context.Context, token string) error {
|
||||
_, err := db.pool.Exec(ctx, `UPDATE magic_links SET used = true WHERE token = $1`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) PutKV(ctx context.Context, key string, value any) error {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal kv value: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.pool.Exec(ctx, `
|
||||
INSERT INTO stripe_kv (key, value, created_at, updated_at)
|
||||
VALUES ($1, $2, NOW(), NOW())
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`, key, payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetKV(ctx context.Context, key string, dest any) (bool, error) {
|
||||
var payload []byte
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT value
|
||||
FROM stripe_kv
|
||||
WHERE key = $1
|
||||
`, key).Scan(&payload)
|
||||
if err == pgx.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := json.Unmarshal(payload, dest); err != nil {
|
||||
return false, fmt.Errorf("unmarshal kv value: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
|
||||
"github.com/jordan-wright/email"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
config Config
|
||||
}
|
||||
|
||||
func New(config Config) *Service {
|
||||
return &Service{config: config}
|
||||
}
|
||||
|
||||
// SendMagicLink sends a magic link authentication email with proper branding
|
||||
func (s *Service) SendMagicLink(toEmail, toName, linkURL, locale string) error {
|
||||
template := MagicLinkEmail(toName, linkURL, locale)
|
||||
return s.sendTemplate(toEmail, template)
|
||||
}
|
||||
|
||||
// SendWelcomeEmail sends a welcome email to new users
|
||||
func (s *Service) SendWelcomeEmail(toEmail, name, locale string) error {
|
||||
template := WelcomeEmail(name, locale)
|
||||
return s.sendTemplate(toEmail, template)
|
||||
}
|
||||
|
||||
// SendBookingConfirmation sends booking confirmation to customers
|
||||
func (s *Service) SendBookingConfirmation(toEmail, customerName, businessName, serviceName, dateTime, location, locale string) error {
|
||||
template := BookingConfirmationEmail(customerName, businessName, serviceName, dateTime, location, locale)
|
||||
return s.sendTemplate(toEmail, template)
|
||||
}
|
||||
|
||||
// SendPasswordReset sends password reset email
|
||||
func (s *Service) SendPasswordReset(toEmail, name, resetURL, locale string) error {
|
||||
template := PasswordResetEmail(name, resetURL, locale)
|
||||
return s.sendTemplate(toEmail, template)
|
||||
}
|
||||
|
||||
// sendTemplate sends an email using the provided template
|
||||
func (s *Service) sendTemplate(toEmail string, template EmailTemplate) error {
|
||||
e := email.NewEmail()
|
||||
e.From = fmt.Sprintf("Bookra <%s>", s.config.From)
|
||||
e.To = []string{toEmail}
|
||||
e.Subject = template.Subject
|
||||
e.Text = []byte(template.Text)
|
||||
e.HTML = []byte(template.HTML)
|
||||
|
||||
return s.send(e)
|
||||
}
|
||||
|
||||
// send delivers the email via SMTP
|
||||
func (s *Service) send(e *email.Email) error {
|
||||
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
|
||||
|
||||
var auth smtp.Auth
|
||||
if s.config.Username != "" {
|
||||
auth = smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
|
||||
}
|
||||
|
||||
if s.config.Port == 465 {
|
||||
return e.SendWithTLS(addr, auth, &tls.Config{ServerName: s.config.Host})
|
||||
}
|
||||
|
||||
return e.Send(addr, auth)
|
||||
}
|
||||
@@ -1,743 +0,0 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Bookra Design System - Warm editorial aesthetic
|
||||
// Canvas: warm cream backgrounds (#fbf9f6)
|
||||
// Ink: warm dark brown (#2a221e)
|
||||
// Accent: terracotta (#a65c3e)
|
||||
// Logo bg: #24201d, Logo text: #f7f2e8
|
||||
const (
|
||||
canvas = "#fbf9f6" // Warm cream background
|
||||
canvasSubtle = "#f5f2ed" // Slightly darker cream
|
||||
ink = "#2a221e" // Warm dark brown
|
||||
inkMuted = "#5c514a" // Muted brown
|
||||
inkSubtle = "#8b7f76" // Light muted brown
|
||||
accent = "#a65c3e" // Terracotta
|
||||
accentHover = "#8f4d33" // Darker terracotta
|
||||
accentSubtle = "#f5ebe7" // Light terracotta tint
|
||||
logoBg = "#24201d" // Logo dark brown
|
||||
logoText = "#f7f2e8" // Logo cream
|
||||
border = "#e8e2da" // Warm border
|
||||
white = "#ffffff"
|
||||
)
|
||||
|
||||
type EmailTemplate struct {
|
||||
Subject string
|
||||
HTML string
|
||||
Text string
|
||||
}
|
||||
|
||||
func MagicLinkEmail(toName, magicURL string, locale string) EmailTemplate {
|
||||
if locale == "cs" {
|
||||
return magicLinkEmailCS(toName, magicURL)
|
||||
}
|
||||
return magicLinkEmailEN(toName, magicURL)
|
||||
}
|
||||
|
||||
func WelcomeEmail(name string, locale string) EmailTemplate {
|
||||
if locale == "cs" {
|
||||
return welcomeEmailCS(name)
|
||||
}
|
||||
return welcomeEmailEN(name)
|
||||
}
|
||||
|
||||
func BookingConfirmationEmail(customerName, businessName, serviceName, dateTime, location string, locale string) EmailTemplate {
|
||||
if locale == "cs" {
|
||||
return bookingConfirmationCS(customerName, businessName, serviceName, dateTime, location)
|
||||
}
|
||||
return bookingConfirmationEN(customerName, businessName, serviceName, dateTime, location)
|
||||
}
|
||||
|
||||
func PasswordResetEmail(name, resetURL string, locale string) EmailTemplate {
|
||||
if locale == "cs" {
|
||||
return passwordResetCS(name, resetURL)
|
||||
}
|
||||
return passwordResetEN(name, resetURL)
|
||||
}
|
||||
|
||||
func magicLinkEmailEN(toName, magicURL string) EmailTemplate {
|
||||
subject := "Your sign-in link for Bookra"
|
||||
if toName == "" {
|
||||
toName = "there"
|
||||
}
|
||||
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
|
||||
.tagline { color: %s; font-size: 15px; margin-top: 6px; font-style: italic; }
|
||||
.content { padding: 48px 40px; }
|
||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
||||
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
||||
.button-wrap { margin: 40px 0; }
|
||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; transition: background 0.2s; }
|
||||
.button:hover { background: %s; }
|
||||
.link-box { background: %s; border: 1px solid %s; border-radius: 8px; padding: 20px; margin: 32px 0; }
|
||||
.link-label { font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: 'Space Grotesk', sans-serif; }
|
||||
.link-url { font-size: 14px; color: %s; word-break: break-all; font-family: 'JetBrains Mono', monospace; }
|
||||
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
|
||||
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
||||
.footer-text { font-size: 14px; color: %s; }
|
||||
.footer-links { margin-top: 12px; }
|
||||
.footer-links a { color: %s; text-decoration: none; font-size: 13px; margin: 0 12px; font-family: 'Space Grotesk', sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
<div class="tagline">Calm booking software</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Hi %s,</div>
|
||||
<div class="message">
|
||||
You requested a sign-in link for your Bookra account. Click below to access your account securely — no password needed.
|
||||
</div>
|
||||
<div class="button-wrap">
|
||||
<a href="%s" class="button">Sign In to Bookra</a>
|
||||
</div>
|
||||
<div class="link-box">
|
||||
<div class="link-label">Or copy this link</div>
|
||||
<div class="link-url">%s</div>
|
||||
</div>
|
||||
<div class="expiry">
|
||||
This link expires in <strong>15 minutes</strong> for security.
|
||||
</div>
|
||||
<div class="help">
|
||||
Didn't request this? You can safely ignore it — someone may have entered your email by mistake.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
<div class="footer-links">
|
||||
<a href="https://bookra.tdvorak.dev/privacy">Privacy</a>
|
||||
<a href="https://bookra.tdvorak.dev/terms">Terms</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
subject, canvas, white, canvas, border,
|
||||
logoBg, logoText, ink, inkSubtle,
|
||||
ink, inkMuted,
|
||||
accent, white, accentHover,
|
||||
canvasSubtle, border, inkSubtle, inkMuted,
|
||||
accentSubtle, accent, accent,
|
||||
inkSubtle, border,
|
||||
canvas, border, inkMuted, inkMuted,
|
||||
toName, magicURL, magicURL)
|
||||
|
||||
text := fmt.Sprintf(`Bookra — Sign-in Link
|
||||
|
||||
Hi %s,
|
||||
|
||||
Sign in to Bookra (link expires in 15 minutes):
|
||||
%s
|
||||
|
||||
Didn't request this? You can safely ignore this email.
|
||||
|
||||
© 2024 Bookra`, toName, magicURL)
|
||||
|
||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
||||
}
|
||||
|
||||
func magicLinkEmailCS(toName, magicURL string) EmailTemplate {
|
||||
subject := "Váš přihlašovací odkaz do Bookra"
|
||||
if toName == "" {
|
||||
toName = "vás"
|
||||
}
|
||||
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
|
||||
.tagline { color: %s; font-size: 15px; margin-top: 6px; font-style: italic; }
|
||||
.content { padding: 48px 40px; }
|
||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
||||
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
||||
.button-wrap { margin: 40px 0; }
|
||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
||||
.button:hover { background: %s; }
|
||||
.link-box { background: %s; border: 1px solid %s; border-radius: 8px; padding: 20px; margin: 32px 0; }
|
||||
.link-label { font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: 'Space Grotesk', sans-serif; }
|
||||
.link-url { font-size: 14px; color: %s; word-break: break-all; font-family: 'JetBrains Mono', monospace; }
|
||||
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
|
||||
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
||||
.footer-text { font-size: 14px; color: %s; }
|
||||
.footer-links { margin-top: 12px; }
|
||||
.footer-links a { color: %s; text-decoration: none; font-size: 13px; margin: 0 12px; font-family: 'Space Grotesk', sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
<div class="tagline">Klidný rezervační software</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Dobrý den %s,</div>
|
||||
<div class="message">
|
||||
Požádali jste o přihlašovací odkaz k účtu Bookra. Klikněte níže pro bezpečný přístup — heslo není potřeba.
|
||||
</div>
|
||||
<div class="button-wrap">
|
||||
<a href="%s" class="button">Přihlásit se do Bookra</a>
|
||||
</div>
|
||||
<div class="link-box">
|
||||
<div class="link-label">Nebo zkopírujte tento odkaz</div>
|
||||
<div class="link-url">%s</div>
|
||||
</div>
|
||||
<div class="expiry">
|
||||
Tento odkaz vyprší za <strong>15 minut</strong> z bezpečnostních důvodů.
|
||||
</div>
|
||||
<div class="help">
|
||||
Nepožádali jste o tento email? Můžete ho bezpečně ignorovat.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
<div class="footer-links">
|
||||
<a href="https://bookra.tdvorak.dev/privacy">Ochrana soukromí</a>
|
||||
<a href="https://bookra.tdvorak.dev/terms">Podmínky</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
subject, canvas, white, canvas, border,
|
||||
logoBg, logoText, ink, inkSubtle,
|
||||
ink, inkMuted,
|
||||
accent, white, accentHover,
|
||||
canvasSubtle, border, inkSubtle, inkMuted,
|
||||
accentSubtle, accent, accent,
|
||||
inkSubtle, border,
|
||||
canvas, border, inkMuted, inkMuted,
|
||||
toName, magicURL, magicURL)
|
||||
|
||||
text := fmt.Sprintf(`Bookra — Přihlašovací odkaz
|
||||
|
||||
Dobrý den %s,
|
||||
|
||||
Přihlaste se do Bookra (odkaz vyprší za 15 minut):
|
||||
%s
|
||||
|
||||
Nepožádali jste o tento email? Můžete ho ignorovat.
|
||||
|
||||
© 2024 Bookra`, toName, magicURL)
|
||||
|
||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
||||
}
|
||||
|
||||
func welcomeEmailEN(name string) EmailTemplate {
|
||||
subject := "Welcome to Bookra"
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap');
|
||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
||||
.content { padding: 48px 40px; }
|
||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
||||
.message { font-size: 18px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
||||
.features { background: %s; border-radius: 12px; padding: 32px; margin: 32px 0; }
|
||||
.feature { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
||||
.feature:last-child { margin-bottom: 0; }
|
||||
.feature-icon { width: 24px; height: 24px; background: %s; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: %s; font-size: 14px; flex-shrink: 0; font-family: 'Space Grotesk', sans-serif; }
|
||||
.feature-text { font-size: 16px; color: %s; line-height: 1.5; }
|
||||
.button-wrap { margin: 40px 0; text-align: center; }
|
||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
||||
.footer-text { font-size: 14px; color: %s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Welcome, %s</div>
|
||||
<div class="message">
|
||||
Thanks for joining Bookra. We're here to help you manage bookings with calm and clarity.
|
||||
</div>
|
||||
<div class="features">
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<div class="feature-text"><strong>Smart scheduling</strong> — Automatic conflict detection and buffer times</div>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<div class="feature-text"><strong>Customer insights</strong> — History and preferences at your fingertips</div>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<div class="feature-text"><strong>Reminders</strong> — Reduce no-shows with gentle notifications</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-wrap">
|
||||
<a href="https://bookra.tdvorak.dev/dashboard" class="button">Open Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
subject, canvas, white, canvas, border,
|
||||
logoBg, logoText, ink,
|
||||
ink, inkMuted, canvasSubtle,
|
||||
accent, white, inkMuted,
|
||||
accent, white,
|
||||
canvas, border, inkMuted,
|
||||
name)
|
||||
|
||||
text := fmt.Sprintf(`Welcome to Bookra, %s
|
||||
|
||||
Thanks for joining. We're here to help you manage bookings with calm and clarity.
|
||||
|
||||
Get started: https://bookra.tdvorak.dev/dashboard
|
||||
|
||||
© 2024 Bookra`, name)
|
||||
|
||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
||||
}
|
||||
|
||||
func welcomeEmailCS(name string) EmailTemplate {
|
||||
subject := "Vítejte v Bookra"
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap');
|
||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
||||
.content { padding: 48px 40px; }
|
||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
||||
.message { font-size: 18px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
||||
.features { background: %s; border-radius: 12px; padding: 32px; margin: 32px 0; }
|
||||
.feature { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
||||
.feature:last-child { margin-bottom: 0; }
|
||||
.feature-icon { width: 24px; height: 24px; background: %s; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: %s; font-size: 14px; flex-shrink: 0; }
|
||||
.feature-text { font-size: 16px; color: %s; line-height: 1.5; }
|
||||
.button-wrap { margin: 40px 0; text-align: center; }
|
||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
||||
.footer-text { font-size: 14px; color: %s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Vítejte, %s</div>
|
||||
<div class="message">
|
||||
Děkujeme za registraci. Pomůžeme vám spravovat rezervace s klidem a přehledem.
|
||||
</div>
|
||||
<div class="features">
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<div class="feature-text"><strong>Chytré plánování</strong> — Automatická detekce konfliktů</div>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<div class="feature-text"><strong>Přehled o zákaznících</strong> — Historie a preference</div>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<div class="feature-text"><strong>Připomenutí</strong> — Méně zapomenutých termínů</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-wrap">
|
||||
<a href="https://bookra.tdvorak.dev/dashboard" class="button">Otevřít aplikaci</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
subject, canvas, white, canvas, border,
|
||||
logoBg, logoText, ink,
|
||||
ink, inkMuted, canvasSubtle,
|
||||
accent, white, inkMuted,
|
||||
accent, white,
|
||||
canvas, border, inkMuted,
|
||||
name)
|
||||
|
||||
text := fmt.Sprintf(`Vítejte v Bookra, %s
|
||||
|
||||
Děkujeme za registraci. Pomůžeme vám spravovat rezervace s klidem.
|
||||
|
||||
Otevřít aplikaci: https://bookra.tdvorak.dev/dashboard
|
||||
|
||||
© 2024 Bookra`, name)
|
||||
|
||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
||||
}
|
||||
|
||||
func bookingConfirmationEN(customerName, businessName, serviceName, dateTime, location string) EmailTemplate {
|
||||
subject := fmt.Sprintf("Confirmed: %s with %s", serviceName, businessName)
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap');
|
||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
||||
.content { padding: 48px 40px; }
|
||||
.badge { display: inline-block; background: %s; color: %s; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 24px; font-family: 'Space Grotesk', sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; color: %s; margin-bottom: 8px; }
|
||||
.message { font-size: 17px; color: %s; margin-bottom: 32px; }
|
||||
.details { background: %s; border-radius: 12px; padding: 28px; margin: 32px 0; }
|
||||
.detail-row { display: flex; padding: 14px 0; border-bottom: 1px solid %s; }
|
||||
.detail-row:last-child { border-bottom: none; }
|
||||
.detail-label { width: 120px; font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.05em; font-family: 'Space Grotesk', sans-serif; }
|
||||
.detail-value { flex: 1; font-size: 16px; color: %s; font-weight: 500; }
|
||||
.help { font-size: 15px; color: %s; margin-top: 32px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
||||
.footer-text { font-size: 14px; color: %s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="badge">Confirmed</div>
|
||||
<div class="greeting">Hello %s,</div>
|
||||
<div class="message">
|
||||
Your booking with <strong>%s</strong> is confirmed.
|
||||
</div>
|
||||
<div class="details">
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Service</div>
|
||||
<div class="detail-value">%s</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">When</div>
|
||||
<div class="detail-value">%s</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Where</div>
|
||||
<div class="detail-value">%s</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help">
|
||||
Need to reschedule? Contact %s directly.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
subject, canvas, white, canvas, border,
|
||||
logoBg, logoText, ink,
|
||||
accentSubtle, accent, ink, inkMuted,
|
||||
canvasSubtle, border, inkSubtle, ink,
|
||||
inkSubtle, border,
|
||||
canvas, border, inkMuted,
|
||||
customerName, businessName, serviceName, dateTime, location, businessName)
|
||||
|
||||
text := fmt.Sprintf(`Booking Confirmed
|
||||
|
||||
Hello %s,
|
||||
|
||||
Your booking with %s is confirmed.
|
||||
|
||||
Service: %s
|
||||
When: %s
|
||||
Where: %s
|
||||
|
||||
Need to reschedule? Contact %s.
|
||||
|
||||
© 2024 Bookra`,
|
||||
customerName, businessName, serviceName, dateTime, location, businessName)
|
||||
|
||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
||||
}
|
||||
|
||||
func bookingConfirmationCS(customerName, businessName, serviceName, dateTime, location string) EmailTemplate {
|
||||
subject := fmt.Sprintf("Potvrzeno: %s v %s", serviceName, businessName)
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap');
|
||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
||||
.content { padding: 48px 40px; }
|
||||
.badge { display: inline-block; background: %s; color: %s; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 24px; font-family: 'Space Grotesk', sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; color: %s; margin-bottom: 8px; }
|
||||
.message { font-size: 17px; color: %s; margin-bottom: 32px; }
|
||||
.details { background: %s; border-radius: 12px; padding: 28px; margin: 32px 0; }
|
||||
.detail-row { display: flex; padding: 14px 0; border-bottom: 1px solid %s; }
|
||||
.detail-row:last-child { border-bottom: none; }
|
||||
.detail-label { width: 120px; font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.05em; font-family: 'Space Grotesk', sans-serif; }
|
||||
.detail-value { flex: 1; font-size: 16px; color: %s; font-weight: 500; }
|
||||
.help { font-size: 15px; color: %s; margin-top: 32px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
||||
.footer-text { font-size: 14px; color: %s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="badge">Potvrzeno</div>
|
||||
<div class="greeting">Dobrý den %s,</div>
|
||||
<div class="message">
|
||||
Vaše rezervace v <strong>%s</strong> je potvrzena.
|
||||
</div>
|
||||
<div class="details">
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Služba</div>
|
||||
<div class="detail-value">%s</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Termín</div>
|
||||
<div class="detail-value">%s</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Místo</div>
|
||||
<div class="detail-value">%s</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help">
|
||||
Potřebujete přeobjednat? Kontaktujte přímo %s.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
subject, canvas, white, canvas, border,
|
||||
logoBg, logoText, ink,
|
||||
accentSubtle, accent, ink, inkMuted,
|
||||
canvasSubtle, border, inkSubtle, ink,
|
||||
inkSubtle, border,
|
||||
canvas, border, inkMuted,
|
||||
customerName, businessName, serviceName, dateTime, location, businessName)
|
||||
|
||||
text := fmt.Sprintf(`Rezervace potvrzena
|
||||
|
||||
Dobrý den %s,
|
||||
|
||||
Vaše rezervace v %s je potvrzena.
|
||||
|
||||
Služba: %s
|
||||
Termín: %s
|
||||
Místo: %s
|
||||
|
||||
Potřebujete přeobjednat? Kontaktujte %s.
|
||||
|
||||
© 2024 Bookra`,
|
||||
customerName, businessName, serviceName, dateTime, location, businessName)
|
||||
|
||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
||||
}
|
||||
|
||||
func passwordResetEN(name, resetURL string) EmailTemplate {
|
||||
subject := "Reset your Bookra password"
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
||||
.content { padding: 48px 40px; }
|
||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
||||
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
||||
.button-wrap { margin: 40px 0; }
|
||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
||||
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
|
||||
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
||||
.footer-text { font-size: 14px; color: %s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Hi %s,</div>
|
||||
<div class="message">
|
||||
We received a request to reset your password. Click below to choose a new one.
|
||||
</div>
|
||||
<div class="button-wrap">
|
||||
<a href="%s" class="button">Reset Password</a>
|
||||
</div>
|
||||
<div class="expiry">
|
||||
This link expires in <strong>1 hour</strong>.
|
||||
</div>
|
||||
<div class="help">
|
||||
Didn't request this? You can safely ignore it.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
subject, canvas, white, canvas, border,
|
||||
logoBg, logoText, ink,
|
||||
ink, inkMuted,
|
||||
accent, white,
|
||||
accentSubtle, accent, accent,
|
||||
inkSubtle, border,
|
||||
canvas, border, inkMuted,
|
||||
name, resetURL)
|
||||
|
||||
text := fmt.Sprintf(`Reset Password — Bookra
|
||||
|
||||
Hi %s,
|
||||
|
||||
Reset your password (expires in 1 hour):
|
||||
%s
|
||||
|
||||
Didn't request this? You can safely ignore it.
|
||||
|
||||
© 2024 Bookra`, name, resetURL)
|
||||
|
||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
||||
}
|
||||
|
||||
func passwordResetCS(name, resetURL string) EmailTemplate {
|
||||
subject := "Reset hesla pro Bookra"
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
||||
.content { padding: 48px 40px; }
|
||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
||||
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
||||
.button-wrap { margin: 40px 0; }
|
||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
||||
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
|
||||
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
||||
.footer-text { font-size: 14px; color: %s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Dobrý den %s,</div>
|
||||
<div class="message">
|
||||
Obdrželi jsme žádost o reset hesla. Klikněte níže pro nastavení nového.
|
||||
</div>
|
||||
<div class="button-wrap">
|
||||
<a href="%s" class="button">Resetovat heslo</a>
|
||||
</div>
|
||||
<div class="expiry">
|
||||
Tento odkaz vyprší za <strong>1 hodinu</strong>.
|
||||
</div>
|
||||
<div class="help">
|
||||
Nepožádali jste o tento email? Můžete ho bezpečně ignorovat.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
subject, canvas, white, canvas, border,
|
||||
logoBg, logoText, ink,
|
||||
ink, inkMuted,
|
||||
accent, white,
|
||||
accentSubtle, accent, accent,
|
||||
inkSubtle, border,
|
||||
canvas, border, inkMuted,
|
||||
name, resetURL)
|
||||
|
||||
text := fmt.Sprintf(`Reset hesla — Bookra
|
||||
|
||||
Dobrý den %s,
|
||||
|
||||
Reset hesla (vyprší za 1 hodinu):
|
||||
%s
|
||||
|
||||
Nepožádali jste o tento email? Můžete ho ignorovat.
|
||||
|
||||
© 2024 Bookra`, name, resetURL)
|
||||
|
||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
||||
}
|
||||
@@ -1,680 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookra/apps/auth-service/internal/config"
|
||||
"bookra/apps/auth-service/internal/db"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminDashboard provides a visual management interface for the auth service
|
||||
type AdminDashboard struct {
|
||||
cfg *config.Config
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
func NewAdminDashboard(cfg *config.Config, database *db.DB) *AdminDashboard {
|
||||
return &AdminDashboard{cfg: cfg, db: database}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers admin routes
|
||||
func (a *AdminDashboard) RegisterRoutes(r *gin.Engine) {
|
||||
admin := r.Group("/admin")
|
||||
{
|
||||
admin.GET("", a.RenderDashboard)
|
||||
admin.GET("/api/config", a.GetConfig)
|
||||
admin.GET("/api/prices", a.GetPrices)
|
||||
admin.GET("/api/stats", a.GetStats)
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfig returns current configuration (sanitized)
|
||||
func (a *AdminDashboard) GetConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"appEnv": a.cfg.AppEnv,
|
||||
"port": a.cfg.Port,
|
||||
"frontendURL": a.cfg.FrontendURL,
|
||||
"neonAuthURL": a.cfg.NeonAuthURL,
|
||||
"smtpConfigured": gin.H{
|
||||
"host": a.cfg.SMTPHost,
|
||||
"port": a.cfg.SMTPPort,
|
||||
"from": a.cfg.EmailFrom,
|
||||
},
|
||||
"googleOAuthConfigured": a.cfg.GoogleClientID != "",
|
||||
"stripeConfigured": a.cfg.StripeCheckoutReady(),
|
||||
"stripeSecretConfigured": a.cfg.StripeSecretConfigured(),
|
||||
"stripeWebhookConfigured": a.cfg.StripeWebhookConfigured(),
|
||||
"stripePricesConfigured": a.cfg.StripeHasAnyPriceConfigured(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetPrices returns configured Stripe prices
|
||||
func (a *AdminDashboard) GetPrices(c *gin.Context) {
|
||||
prices := []gin.H{}
|
||||
|
||||
planNames := map[string]string{
|
||||
"starter": "Starter Plan",
|
||||
"pro": "Pro Plan",
|
||||
"business": "Business Plan",
|
||||
"monthly": "Monthly Plan",
|
||||
"growth": "Growth Plan (Pro alias)",
|
||||
"multi-location": "Multi-Location (Business alias)",
|
||||
}
|
||||
|
||||
currencies := []string{"czk", "usd"}
|
||||
|
||||
for planCode, priceID := range a.cfg.StripePriceIDs {
|
||||
if strings.TrimSpace(priceID) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse plan:currency format
|
||||
parts := strings.Split(planCode, ":")
|
||||
displayName := planNames[planCode]
|
||||
currency := ""
|
||||
|
||||
if len(parts) == 2 {
|
||||
planCode = parts[0]
|
||||
currency = parts[1]
|
||||
displayName = planNames[planCode] + " (" + strings.ToUpper(currency) + ")"
|
||||
}
|
||||
|
||||
if displayName == "" {
|
||||
displayName = planCode
|
||||
}
|
||||
|
||||
prices = append(prices, gin.H{
|
||||
"planCode": planCode,
|
||||
"currency": currency,
|
||||
"priceID": priceID,
|
||||
"displayName": displayName,
|
||||
"configured": true,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"prices": prices,
|
||||
"currencies": currencies,
|
||||
"stripeConfigured": a.cfg.StripeCheckoutReady(),
|
||||
"secretConfigured": a.cfg.StripeSecretConfigured(),
|
||||
"webhookConfigured": a.cfg.StripeWebhookConfigured(),
|
||||
"pricesConfigured": len(prices) > 0,
|
||||
})
|
||||
}
|
||||
|
||||
// GetStats returns database statistics
|
||||
func (a *AdminDashboard) GetStats(c *gin.Context) {
|
||||
if a.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := a.db.GetStats(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load stats: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// RenderDashboard renders the HTML admin dashboard
|
||||
func (a *AdminDashboard) RenderDashboard(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, adminHTML)
|
||||
}
|
||||
|
||||
const adminHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bookra Auth Service Admin</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--canvas: 40 25% 97%;
|
||||
--canvas-subtle: 40 20% 94%;
|
||||
--canvas-muted: 40 15% 89%;
|
||||
--ink: 25 15% 12%;
|
||||
--ink-muted: 25 10% 42%;
|
||||
--ink-subtle: 25 8% 58%;
|
||||
--accent: 17 55% 42%;
|
||||
--accent-hover: 17 60% 37%;
|
||||
--accent-subtle: 17 45% 94%;
|
||||
--success: 145 45% 38%;
|
||||
--success-subtle: 145 35% 94%;
|
||||
--error: 0 60% 52%;
|
||||
--error-subtle: 0 50% 96%;
|
||||
--border: 30 12% 86%;
|
||||
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.06), 0 4px 6px -4px rgb(0 0 0 / 0.04);
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: "Newsreader", Georgia, ui-serif, serif;
|
||||
background: linear-gradient(180deg, hsl(var(--canvas)) 0%, hsl(var(--canvas-subtle)) 100%);
|
||||
color: hsl(var(--ink));
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container { padding: 2rem; }
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container { padding: 3rem; }
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.logo svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-family: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: clamp(1.75rem, 3vw + 0.5rem, 2.5rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: hsl(var(--ink));
|
||||
}
|
||||
|
||||
header p {
|
||||
font-size: 1.125rem;
|
||||
color: hsl(var(--ink-muted));
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(145deg, hsl(40 25% 98%) 0%, hsl(40 20% 96%) 100%);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-card .icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--accent-subtle));
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat-card .icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.stat-card.success .icon { background: hsl(var(--success-subtle)); }
|
||||
.stat-card.success .icon svg { color: hsl(var(--success)); }
|
||||
|
||||
.stat-value {
|
||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
||||
font-size: 1.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--ink));
|
||||
line-height: 1;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--ink-muted));
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: linear-gradient(145deg, hsl(40 25% 98%) 0%, hsl(40 20% 96%) 100%);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.card-header svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--ink));
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
||||
}
|
||||
|
||||
.status.active {
|
||||
background: hsl(var(--success-subtle));
|
||||
color: hsl(var(--success));
|
||||
}
|
||||
|
||||
.status.inactive {
|
||||
background: hsl(var(--error-subtle));
|
||||
color: hsl(var(--error));
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.875rem 0;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
th {
|
||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--ink-muted));
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
.env-value {
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
background: hsl(var(--canvas-muted));
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--ink));
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
||||
background: hsl(var(--accent-subtle));
|
||||
color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.875rem 0;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
|
||||
.info-label {
|
||||
color: hsl(var(--ink-muted));
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 3rem;
|
||||
color: hsl(var(--ink-subtle));
|
||||
}
|
||||
|
||||
.loading svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error {
|
||||
background: hsl(var(--error-subtle));
|
||||
color: hsl(var(--error));
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: hsl(var(--ink-muted));
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 0.75rem;
|
||||
color: hsl(var(--ink-subtle));
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
<h1>Auth Service Admin</h1>
|
||||
</div>
|
||||
<p>Monitor users, configure billing plans, and manage service health.</p>
|
||||
</header>
|
||||
|
||||
<div class="stats-grid" id="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-value">-</div>
|
||||
<div class="stat-label">Total Users</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="icon">
|
||||
<svg 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>
|
||||
<div class="stat-value">-</div>
|
||||
<div class="stat-label">Active (7d)</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-value">-</div>
|
||||
<div class="stat-label">Magic Links Sent</div>
|
||||
</div>
|
||||
<div class="stat-card success">
|
||||
<div class="icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-value">-</div>
|
||||
<div class="stat-label">New This Week</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
<h2>Service Configuration</h2>
|
||||
</div>
|
||||
<div class="card-body" id="config-content">
|
||||
<div class="loading">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/>
|
||||
</svg>
|
||||
Loading configuration...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
||||
<line x1="2" y1="10" x2="22" y2="10"/>
|
||||
</svg>
|
||||
<h2>Billing Plans</h2>
|
||||
</div>
|
||||
<div class="card-body" id="prices-content">
|
||||
<div class="loading">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/>
|
||||
</svg>
|
||||
Loading plans...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
<h2>API Endpoints</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Method</th>
|
||||
<th>Endpoint</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><span class="badge">POST</span></td><td>/api/auth/magic-link</td></tr>
|
||||
<tr><td><span class="badge">POST</span></td><td>/api/auth/verify</td></tr>
|
||||
<tr><td><span class="badge">POST</span></td><td>/api/auth/register</td></tr>
|
||||
<tr><td><span class="badge">POST</span></td><td>/api/auth/login</td></tr>
|
||||
<tr><td><span class="badge">GET</span></td><td>/api/auth/me</td></tr>
|
||||
<tr><td><span class="badge">GET</span></td><td>/api/billing/subscription</td></tr>
|
||||
<tr><td><span class="badge">POST</span></td><td>/api/billing/checkout</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<h2>Service Overview</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Authentication</span>
|
||||
<span>Magic links, JWT, OAuth</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Billing</span>
|
||||
<span>Stripe subscriptions</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Database</span>
|
||||
<span>Neon PostgreSQL</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Email</span>
|
||||
<span>SMTP transactional</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Load stats
|
||||
fetch('/admin/api/stats')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const cards = document.querySelectorAll('.stat-card');
|
||||
cards[0].querySelector('.stat-value').textContent = data.totalUsers.toLocaleString();
|
||||
cards[1].querySelector('.stat-value').textContent = data.activeUsers7Days.toLocaleString();
|
||||
cards[2].querySelector('.stat-value').textContent = data.magicLinksSent.toLocaleString();
|
||||
cards[3].querySelector('.stat-value').textContent = data.usersThisWeek.toLocaleString();
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('stats-grid').innerHTML =
|
||||
'<div class="error" style="grid-column: 1/-1;">Failed to load statistics</div>';
|
||||
});
|
||||
|
||||
// Load configuration
|
||||
fetch('/admin/api/config')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
let html = '<div class="info-row">' +
|
||||
'<span class="info-label">Environment</span>' +
|
||||
'<span class="env-value">' + data.appEnv + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="info-row">' +
|
||||
'<span class="info-label">Port</span>' +
|
||||
'<span class="env-value">' + data.port + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="info-row">' +
|
||||
'<span class="info-label">Neon Auth</span>' +
|
||||
'<span class="status ' + (data.neonAuthURL ? 'active' : 'inactive') + '">' +
|
||||
'<span class="status-dot"></span>' +
|
||||
(data.neonAuthURL ? 'Configured' : 'Not Configured') +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'<div class="info-row">' +
|
||||
'<span class="info-label">SMTP</span>' +
|
||||
'<span class="status ' + (data.smtpConfigured.host ? 'active' : 'inactive') + '">' +
|
||||
'<span class="status-dot"></span>' +
|
||||
(data.smtpConfigured.host ? data.smtpConfigured.host : 'Not Configured') +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'<div class="info-row">' +
|
||||
'<span class="info-label">Google OAuth</span>' +
|
||||
'<span class="status ' + (data.googleOAuthConfigured ? 'active' : 'inactive') + '">' +
|
||||
'<span class="status-dot"></span>' +
|
||||
(data.googleOAuthConfigured ? 'Enabled' : 'Disabled') +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'<div class="info-row">' +
|
||||
'<span class="info-label">Stripe</span>' +
|
||||
'<span class="status ' + (data.stripeConfigured ? 'active' : 'inactive') + '">' +
|
||||
'<span class="status-dot"></span>' +
|
||||
(data.stripeConfigured ? 'Configured' : 'Not Configured') +
|
||||
'</span>' +
|
||||
'</div>';
|
||||
document.getElementById('config-content').innerHTML = html;
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('config-content').innerHTML =
|
||||
'<div class="error">Failed to load configuration</div>';
|
||||
});
|
||||
|
||||
// Load prices
|
||||
fetch('/admin/api/prices')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data.prices || data.prices.length === 0) {
|
||||
document.getElementById('prices-content').innerHTML =
|
||||
'<div class="empty-state">' +
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>' +
|
||||
'<p>No Stripe prices configured</p>' +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table><thead><tr><th>Plan</th><th>Currency</th><th>Status</th></tr></thead><tbody>';
|
||||
data.prices.forEach(p => {
|
||||
html += '<tr>' +
|
||||
'<td>' + p.displayName + '</td>' +
|
||||
'<td>' + (p.currency ? p.currency.toUpperCase() : 'Default') + '</td>' +
|
||||
'<td><span class="badge">' + p.priceID.substring(0, 12) + '...</span></td>' +
|
||||
'</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('prices-content').innerHTML = html;
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('prices-content').innerHTML =
|
||||
'<div class="error">Failed to load prices</div>';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -1,513 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookra/apps/auth-service/internal/auth"
|
||||
"bookra/apps/auth-service/internal/billing"
|
||||
"bookra/apps/auth-service/internal/config"
|
||||
"bookra/apps/auth-service/internal/db"
|
||||
"bookra/apps/auth-service/internal/email"
|
||||
"bookra/apps/auth-service/internal/oauth"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
authSvc *auth.Service
|
||||
neon *auth.NeonVerifier
|
||||
billingSvc *billing.Service
|
||||
google *oauth.GoogleProvider
|
||||
cfg *config.Config
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
}
|
||||
|
||||
type VerifyRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
type PasswordRegisterRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
type PasswordLoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type CheckoutRequest struct {
|
||||
PlanCode string `json:"planCode,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
func New(db *db.DB, emailSvc *email.Service, cfg *config.Config) (*Handler, error) {
|
||||
neonVerifier, err := auth.NewNeonVerifier(cfg.NeonAuthURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Handler{
|
||||
authSvc: auth.NewService(db, emailSvc, cfg.JWTSecret, cfg.FrontendURL),
|
||||
neon: neonVerifier,
|
||||
billingSvc: billing.NewService(cfg, db),
|
||||
google: oauth.NewGoogleProvider(cfg),
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(r *gin.Engine) {
|
||||
// Auth API
|
||||
api := r.Group("/api/auth")
|
||||
{
|
||||
api.POST("/magic-link", h.SendMagicLink)
|
||||
api.POST("/verify", h.VerifyMagicLink)
|
||||
api.POST("/register", h.RegisterWithPassword)
|
||||
api.POST("/login", h.LoginWithPassword)
|
||||
api.POST("/refresh", h.RefreshToken)
|
||||
api.GET("/me", h.RequireAuth(), h.GetMe)
|
||||
api.POST("/logout", h.RequireAuth(), h.Logout)
|
||||
|
||||
api.GET("/providers", h.ListProviders)
|
||||
api.GET("/oauth/google", h.GoogleAuth)
|
||||
api.GET("/oauth/google/callback", h.GoogleCallback)
|
||||
}
|
||||
|
||||
// Billing API
|
||||
billingAPI := r.Group("/api/billing")
|
||||
{
|
||||
billingAPI.POST("/webhook", h.StripeWebhook)
|
||||
billingAPI.GET("/subscription", h.RequireAuth(), h.GetSubscription)
|
||||
billingAPI.POST("/checkout", h.RequireAuth(), h.CreateCheckoutSession)
|
||||
billingAPI.POST("/refresh", h.RequireAuth(), h.RefreshSubscription)
|
||||
billingAPI.GET("/plans", h.ListPlans)
|
||||
}
|
||||
|
||||
// Admin Dashboard (Visual Management)
|
||||
admin := NewAdminDashboard(h.cfg, h.db)
|
||||
admin.RegisterRoutes(r)
|
||||
}
|
||||
|
||||
func (h *Handler) SendMagicLink(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Detect locale from request: JSON body > Accept-Language header > default "en"
|
||||
locale := req.Locale
|
||||
if locale == "" {
|
||||
locale = detectLocale(c)
|
||||
}
|
||||
|
||||
if err := h.authSvc.GenerateMagicLink(c.Request.Context(), req.Email, locale); err != nil {
|
||||
log.Printf("magic link failed for %s: %v", req.Email, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send magic link"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Magic link sent to your email"})
|
||||
}
|
||||
|
||||
// detectLocale extracts locale from Accept-Language header
|
||||
func detectLocale(c *gin.Context) string {
|
||||
acceptLang := c.GetHeader("Accept-Language")
|
||||
if strings.HasPrefix(acceptLang, "cs") || strings.Contains(acceptLang, "cs-") {
|
||||
return "cs"
|
||||
}
|
||||
// Default to English
|
||||
return "en"
|
||||
}
|
||||
|
||||
func (h *Handler) VerifyMagicLink(c *gin.Context) {
|
||||
var req VerifyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := h.authSvc.VerifyMagicLink(c.Request.Context(), req.Token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterWithPassword(c *gin.Context) {
|
||||
var req PasswordRegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := h.authSvc.RegisterWithPassword(c.Request.Context(), req.Email, req.Password, req.Name)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "already registered") {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, tokens)
|
||||
}
|
||||
|
||||
func (h *Handler) LoginWithPassword(c *gin.Context) {
|
||||
var req PasswordLoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := h.authSvc.LoginWithPassword(c.Request.Context(), req.Email, req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
func (h *Handler) RefreshToken(c *gin.Context) {
|
||||
var req RefreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
refreshToken := strings.TrimSpace(req.RefreshToken)
|
||||
if refreshToken == "" {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
refreshToken = strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
|
||||
}
|
||||
}
|
||||
if refreshToken == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := h.authSvc.RefreshTokens(c.Request.Context(), refreshToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
func (h *Handler) GetMe(c *gin.Context) {
|
||||
claims, exists := c.Get("claims")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims not found"})
|
||||
return
|
||||
}
|
||||
|
||||
userClaims := claims.(*auth.Claims)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": userClaims.UserID,
|
||||
"email": userClaims.Email,
|
||||
"name": userClaims.Name,
|
||||
"role": userClaims.Role,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
|
||||
}
|
||||
|
||||
func (h *Handler) ListProviders(c *gin.Context) {
|
||||
providers := []gin.H{}
|
||||
|
||||
if h.google.Enabled() {
|
||||
providers = append(providers, gin.H{
|
||||
"id": "google",
|
||||
"name": "Google",
|
||||
"url": "/api/auth/oauth/google",
|
||||
})
|
||||
}
|
||||
|
||||
providers = append(providers, gin.H{
|
||||
"id": "email",
|
||||
"name": "Email Magic Link",
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"providers": providers})
|
||||
}
|
||||
|
||||
func (h *Handler) GoogleAuth(c *gin.Context) {
|
||||
if !h.google.Enabled() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Google OAuth not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
state := generateState()
|
||||
url := h.google.GetAuthURL(state)
|
||||
|
||||
c.SetCookie("oauth_state", state, 600, "/", "", oauthCookieSecure(c, h.cfg), true)
|
||||
c.Redirect(http.StatusTemporaryRedirect, url)
|
||||
}
|
||||
|
||||
func (h *Handler) GoogleCallback(c *gin.Context) {
|
||||
if !h.google.Enabled() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Google OAuth not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
state := c.Query("state")
|
||||
expectedState, err := c.Cookie("oauth_state")
|
||||
if err != nil || state == "" || state != expectedState {
|
||||
c.SetCookie("oauth_state", "", -1, "/", "", oauthCookieSecure(c, h.cfg), true)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid OAuth state"})
|
||||
return
|
||||
}
|
||||
c.SetCookie("oauth_state", "", -1, "/", "", oauthCookieSecure(c, h.cfg), true)
|
||||
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing code"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.google.ExchangeCode(c.Request.Context(), code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "OAuth failed"})
|
||||
return
|
||||
}
|
||||
|
||||
providerID, email, name := h.google.ParseUser(user)
|
||||
tokens, err := h.authSvc.OAuthLoginOrCreate(c.Request.Context(), "google", providerID, email, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process login"})
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL := h.cfg.FrontendURL + "/auth/callback?token=" + url.QueryEscape(tokens.AccessToken)
|
||||
if tokens.RefreshToken != "" {
|
||||
redirectURL += "&refresh_token=" + url.QueryEscape(tokens.RefreshToken)
|
||||
}
|
||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||
}
|
||||
|
||||
func (h *Handler) GetSubscription(c *gin.Context) {
|
||||
claims, ok := h.claimsFromContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
snapshot, err := h.billingSvc.GetSubscription(c.Request.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load subscription"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, snapshot)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateCheckoutSession(c *gin.Context) {
|
||||
claims, ok := h.claimsFromContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req CheckoutRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.billingSvc.CreateCheckoutSession(c.Request.Context(), billing.UserIdentity{
|
||||
ID: claims.UserID,
|
||||
Email: claims.Email,
|
||||
Name: claims.Name,
|
||||
}, req.PlanCode, req.Currency)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, billing.ErrPlanNotConfigured):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Billing plan is not configured"})
|
||||
case errors.Is(err, billing.ErrStripeNotConfigured):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe is not configured"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create checkout session"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *Handler) RefreshSubscription(c *gin.Context) {
|
||||
claims, ok := h.claimsFromContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
snapshot, err := h.billingSvc.Refresh(c.Request.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, billing.ErrStripeNotConfigured) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe is not configured"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to refresh subscription"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, snapshot)
|
||||
}
|
||||
|
||||
// ListPlans returns available billing plans and their configuration status
|
||||
func (h *Handler) ListPlans(c *gin.Context) {
|
||||
plans := []gin.H{
|
||||
{"code": "starter", "name": "Starter", "description": "For individuals and small teams"},
|
||||
{"code": "pro", "name": "Pro", "description": "For growing businesses"},
|
||||
{"code": "business", "name": "Business", "description": "For multi-location operations"},
|
||||
}
|
||||
|
||||
// Check which plans are configured
|
||||
configured := make(map[string]bool)
|
||||
for planCode, priceID := range h.cfg.StripePriceIDs {
|
||||
if priceID != "" {
|
||||
configured[planCode] = true
|
||||
}
|
||||
}
|
||||
|
||||
for i, plan := range plans {
|
||||
code := plan["code"].(string)
|
||||
plan["czkConfigured"] = configured[code+":czk"] || configured[code]
|
||||
plan["usdConfigured"] = configured[code+":usd"] || configured[code]
|
||||
plans[i] = plan
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"plans": plans,
|
||||
"stripeConfigured": h.cfg.StripeCheckoutReady(),
|
||||
"secretConfigured": h.cfg.StripeSecretConfigured(),
|
||||
"webhookConfigured": h.cfg.StripeWebhookConfigured(),
|
||||
"pricesConfigured": h.cfg.StripeHasAnyPriceConfigured(),
|
||||
"checkoutReady": h.cfg.StripeCheckoutReady(),
|
||||
"currencies": []string{"czk", "usd"},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) StripeWebhook(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
|
||||
payload, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "Webhook payload is too large"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.billingSvc.HandleWebhook(c.Request.Context(), c.GetHeader("Stripe-Signature"), payload); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, billing.ErrStripeWebhookMissing), errors.Is(err, billing.ErrStripeSignatureMissing):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Stripe webhook"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"received": true})
|
||||
}
|
||||
|
||||
func (h *Handler) RequireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
tokenString := ""
|
||||
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
|
||||
}
|
||||
|
||||
if tokenString == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.verifyBearerToken(tokenString)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("claims", claims)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) verifyBearerToken(tokenString string) (*auth.Claims, error) {
|
||||
if h.neon != nil && h.neon.Enabled() {
|
||||
return h.neon.Verify(tokenString)
|
||||
}
|
||||
if h.cfg.AppEnv == "development" {
|
||||
return h.authSvc.VerifyToken(tokenString)
|
||||
}
|
||||
return nil, errors.New("neon auth is not configured")
|
||||
}
|
||||
|
||||
func (h *Handler) claimsFromContext(c *gin.Context) (*auth.Claims, bool) {
|
||||
claims, exists := c.Get("claims")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims not found"})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
userClaims, ok := claims.(*auth.Claims)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return userClaims, true
|
||||
}
|
||||
|
||||
func generateState() string {
|
||||
buffer := make([]byte, 24)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "state_" + time.Now().Format("20060102150405")
|
||||
}
|
||||
return "state_" + strings.TrimRight(base64.URLEncoding.EncodeToString(buffer), "=")
|
||||
}
|
||||
|
||||
func oauthCookieSecure(c *gin.Context, cfg *config.Config) bool {
|
||||
if c.Request.TLS != nil {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(cfg.FrontendURL)), "https://")
|
||||
}
|
||||
|
||||
func timeoutMiddleware(duration time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), duration)
|
||||
defer cancel()
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"bookra/apps/auth-service/internal/config"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
)
|
||||
|
||||
type GoogleUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
VerifiedEmail bool `json:"verified_email"`
|
||||
}
|
||||
|
||||
type GoogleProvider struct {
|
||||
config *oauth2.Config
|
||||
}
|
||||
|
||||
func NewGoogleProvider(cfg *config.Config) *GoogleProvider {
|
||||
if cfg.GoogleClientID == "" || cfg.GoogleClientSecret == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
redirectURL := cfg.GoogleRedirectURL
|
||||
if redirectURL == "" {
|
||||
redirectURL = cfg.FrontendURL + "/auth/oauth/google/callback"
|
||||
}
|
||||
|
||||
return &GoogleProvider{
|
||||
config: &oauth2.Config{
|
||||
ClientID: cfg.GoogleClientID,
|
||||
ClientSecret: cfg.GoogleClientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: []string{
|
||||
"openid",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
},
|
||||
Endpoint: google.Endpoint,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GoogleProvider) Enabled() bool {
|
||||
return p != nil && p.config != nil
|
||||
}
|
||||
|
||||
func (p *GoogleProvider) GetAuthURL(state string) string {
|
||||
return p.config.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
||||
}
|
||||
|
||||
func (p *GoogleProvider) ExchangeCode(ctx context.Context, code string) (*GoogleUser, error) {
|
||||
token, err := p.config.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exchange code: %w", err)
|
||||
}
|
||||
|
||||
client := p.config.Client(ctx, token)
|
||||
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch userinfo: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("userinfo returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var user GoogleUser
|
||||
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
||||
return nil, fmt.Errorf("decode userinfo: %w", err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (p *GoogleProvider) ParseUser(user *GoogleUser) (providerID, email, name string) {
|
||||
return user.ID, user.Email, user.Name
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
name VARCHAR(255),
|
||||
password_hash VARCHAR(255),
|
||||
email_verified BOOLEAN DEFAULT FALSE,
|
||||
provider VARCHAR(50) NOT NULL DEFAULT 'email',
|
||||
provider_id VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
last_login_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_provider ON users(provider, provider_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS magic_links (
|
||||
token VARCHAR(255) PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
used BOOLEAN DEFAULT FALSE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_magic_links_user_id ON magic_links(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_magic_links_expires ON magic_links(expires_at) WHERE used = FALSE;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
DROP TABLE IF EXISTS magic_links;
|
||||
DROP TABLE IF EXISTS users;
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -1,21 +0,0 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stripe_kv (
|
||||
key TEXT PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_stripe_kv_updated_at ON stripe_kv(updated_at);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
DROP INDEX IF EXISTS idx_stripe_kv_updated_at;
|
||||
DROP TABLE IF EXISTS stripe_kv;
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"$schema": "https://railway.app/railway.schema.json",
|
||||
"build": {
|
||||
"builder": "DOCKERFILE",
|
||||
"dockerfilePath": "Dockerfile"
|
||||
},
|
||||
"deploy": {
|
||||
"restartPolicyType": "ON_FAILURE",
|
||||
"restartPolicyMaxRetries": 10,
|
||||
"healthcheckPath": "/health",
|
||||
"healthcheckTimeout": 30,
|
||||
"numReplicas": 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB |
@@ -1,407 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bookra Email Templates Preview</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Newsreader', Georgia, serif;
|
||||
background: #fbf9f6;
|
||||
margin: 0;
|
||||
padding: 40px 20px;
|
||||
color: #2a221e;
|
||||
}
|
||||
.container { max-width: 1400px; margin: 0 auto; }
|
||||
h1 {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
text-align: center;
|
||||
color: #2a221e;
|
||||
margin-bottom: 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #5c514a;
|
||||
margin-bottom: 48px;
|
||||
font-size: 17px;
|
||||
font-style: italic;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(600px, 1fr));
|
||||
gap: 32px;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(42, 34, 30, 0.05);
|
||||
border: 1px solid #e8e2da;
|
||||
}
|
||||
.card-header {
|
||||
background: #fbf9f6;
|
||||
padding: 24px 28px;
|
||||
border-bottom: 1px solid #e8e2da;
|
||||
}
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #2a221e;
|
||||
}
|
||||
.card-header p {
|
||||
margin: 4px 0 0;
|
||||
color: #5c514a;
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
}
|
||||
.card-body { padding: 0; }
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
border: none;
|
||||
background: white;
|
||||
}
|
||||
.toggle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.toggle button {
|
||||
padding: 12px 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8e2da;
|
||||
cursor: pointer;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
background: white;
|
||||
color: #5c514a;
|
||||
}
|
||||
.toggle button.active {
|
||||
background: #a65c3e;
|
||||
color: white;
|
||||
border-color: #a65c3e;
|
||||
}
|
||||
.toggle button:not(.active):hover {
|
||||
background: #f5f2ed;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Bookra Email Templates</h1>
|
||||
<p class="subtitle">Warm editorial aesthetic with terracotta accents</p>
|
||||
|
||||
<div class="toggle">
|
||||
<button class="active" onclick="showLang('en')">English</button>
|
||||
<button onclick="showLang('cs')">Čeština</button>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="emailGrid">
|
||||
<!-- Magic Link EN -->
|
||||
<div class="card" data-lang="en">
|
||||
<div class="card-header">
|
||||
<h2>Magic Link</h2>
|
||||
<p>Passwordless authentication</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<iframe srcdoc='
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: white; }
|
||||
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
|
||||
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
|
||||
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
|
||||
.tagline { color: #8b7f76; font-size: 15px; margin-top: 6px; font-style: italic; }
|
||||
.content { padding: 48px 40px; }
|
||||
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 20px; font-weight: 600; color: #2a221e; margin-bottom: 16px; }
|
||||
.message { font-size: 17px; line-height: 1.6; color: #5c514a; margin-bottom: 32px; }
|
||||
.button-wrap { margin: 40px 0; }
|
||||
.button { display: inline-block; background: #a65c3e; color: white; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: "Space Grotesk", sans-serif; font-weight: 500; font-size: 15px; }
|
||||
.link-box { background: #f5f2ed; border: 1px solid #e8e2da; border-radius: 8px; padding: 20px; margin: 32px 0; }
|
||||
.link-label { font-size: 12px; font-weight: 600; color: #8b7f76; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: "Space Grotesk", sans-serif; }
|
||||
.link-url { font-size: 14px; color: #5c514a; word-break: break-all; font-family: monospace; }
|
||||
.expiry { background: #f5ebe7; border-left: 3px solid #a65c3e; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: #a65c3e; }
|
||||
.help { font-size: 15px; color: #8b7f76; margin-top: 40px; padding-top: 32px; border-top: 1px solid #e8e2da; font-style: italic; }
|
||||
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
|
||||
.footer-text { font-size: 14px; color: #8b7f76; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
<div class="tagline">Calm booking software</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Hi Sarah,</div>
|
||||
<div class="message">
|
||||
You requested a sign-in link for your Bookra account. Click below to access your account securely — no password needed.
|
||||
</div>
|
||||
<div class="button-wrap">
|
||||
<a href="#" class="button">Sign In to Bookra</a>
|
||||
</div>
|
||||
<div class="link-box">
|
||||
<div class="link-label">Or copy this link</div>
|
||||
<div class="link-url">https://bookra.tdvorak.dev/auth/callback?token=xyz123...</div>
|
||||
</div>
|
||||
<div class="expiry">
|
||||
This link expires in <strong>15 minutes</strong> for security.
|
||||
</div>
|
||||
<div class="help">
|
||||
Didn't request this? You can safely ignore it.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>'>
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Welcome EN -->
|
||||
<div class="card" data-lang="en">
|
||||
<div class="card-header">
|
||||
<h2>Welcome Email</h2>
|
||||
<p>New user onboarding</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<iframe srcdoc='
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: white; }
|
||||
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
|
||||
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
|
||||
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
||||
.content { padding: 48px 40px; }
|
||||
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 600; color: #2a221e; margin-bottom: 16px; }
|
||||
.message { font-size: 18px; line-height: 1.6; color: #5c514a; margin-bottom: 32px; }
|
||||
.features { background: #f5f2ed; border-radius: 12px; padding: 32px; margin: 32px 0; }
|
||||
.feature { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
||||
.feature:last-child { margin-bottom: 0; }
|
||||
.feature-icon { width: 24px; height: 24px; background: #a65c3e; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: white; font-size: 14px; flex-shrink: 0; }
|
||||
.feature-text { font-size: 16px; color: #5c514a; line-height: 1.5; }
|
||||
.button-wrap { margin: 40px 0; text-align: center; }
|
||||
.button { display: inline-block; background: #a65c3e; color: white; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: "Space Grotesk", sans-serif; font-weight: 500; font-size: 15px; }
|
||||
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
|
||||
.footer-text { font-size: 14px; color: #8b7f76; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Welcome, Sarah</div>
|
||||
<div class="message">
|
||||
Thanks for joining Bookra. We're here to help you manage bookings with calm and clarity.
|
||||
</div>
|
||||
<div class="features">
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<div class="feature-text"><strong>Smart scheduling</strong> — Automatic conflict detection</div>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<div class="feature-text"><strong>Customer insights</strong> — History and preferences</div>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<div class="feature-text"><strong>Reminders</strong> — Reduce no-shows</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-wrap">
|
||||
<a href="#" class="button">Open Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>'>
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Booking Confirmation EN -->
|
||||
<div class="card" data-lang="en">
|
||||
<div class="card-header">
|
||||
<h2>Booking Confirmation</h2>
|
||||
<p>Customer confirmation email</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<iframe srcdoc='
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: white; }
|
||||
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
|
||||
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
|
||||
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
||||
.content { padding: 48px 40px; }
|
||||
.badge { display: inline-block; background: #f5ebe7; color: #a65c3e; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 24px; font-family: "Space Grotesk", sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; color: #2a221e; margin-bottom: 8px; }
|
||||
.message { font-size: 17px; color: #5c514a; margin-bottom: 32px; }
|
||||
.details { background: #f5f2ed; border-radius: 12px; padding: 28px; margin: 32px 0; }
|
||||
.detail-row { display: flex; padding: 14px 0; border-bottom: 1px solid #e8e2da; }
|
||||
.detail-row:last-child { border-bottom: none; }
|
||||
.detail-label { width: 120px; font-size: 12px; font-weight: 600; color: #8b7f76; text-transform: uppercase; letter-spacing: 0.05em; font-family: "Space Grotesk", sans-serif; }
|
||||
.detail-value { flex: 1; font-size: 16px; color: #2a221e; font-weight: 500; }
|
||||
.help { font-size: 15px; color: #8b7f76; margin-top: 32px; padding-top: 32px; border-top: 1px solid #e8e2da; font-style: italic; }
|
||||
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
|
||||
.footer-text { font-size: 14px; color: #8b7f76; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="badge">Confirmed</div>
|
||||
<div class="greeting">Hello Sarah,</div>
|
||||
<div class="message">
|
||||
Your booking with <strong>Studio Ella</strong> is confirmed.
|
||||
</div>
|
||||
<div class="details">
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Service</div>
|
||||
<div class="detail-value">Haircut & Styling</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">When</div>
|
||||
<div class="detail-value">Monday, April 22 at 2:00 PM</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Where</div>
|
||||
<div class="detail-value">123 Main Street, Prague 1</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help">
|
||||
Need to reschedule? Contact Studio Ella directly.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>'>
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Magic Link CS -->
|
||||
<div class="card" data-lang="cs" style="display:none">
|
||||
<div class="card-header">
|
||||
<h2>Magický Odkaz (CZ)</h2>
|
||||
<p>Přihlášení bez hesla</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<iframe srcdoc='
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: white; }
|
||||
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
|
||||
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
|
||||
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
|
||||
.tagline { color: #8b7f76; font-size: 15px; margin-top: 6px; font-style: italic; }
|
||||
.content { padding: 48px 40px; }
|
||||
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 20px; font-weight: 600; color: #2a221e; margin-bottom: 16px; }
|
||||
.message { font-size: 17px; line-height: 1.6; color: #5c514a; margin-bottom: 32px; }
|
||||
.button-wrap { margin: 40px 0; }
|
||||
.button { display: inline-block; background: #a65c3e; color: white; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: "Space Grotesk", sans-serif; font-weight: 500; font-size: 15px; }
|
||||
.link-box { background: #f5f2ed; border: 1px solid #e8e2da; border-radius: 8px; padding: 20px; margin: 32px 0; }
|
||||
.link-label { font-size: 12px; font-weight: 600; color: #8b7f76; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: "Space Grotesk", sans-serif; }
|
||||
.link-url { font-size: 14px; color: #5c514a; word-break: break-all; font-family: monospace; }
|
||||
.expiry { background: #f5ebe7; border-left: 3px solid #a65c3e; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: #a65c3e; }
|
||||
.help { font-size: 15px; color: #8b7f76; margin-top: 40px; padding-top: 32px; border-top: 1px solid #e8e2da; font-style: italic; }
|
||||
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
|
||||
.footer-text { font-size: 14px; color: #8b7f76; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
<div class="tagline">Klidný rezervační software</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Dobrý den Martino,</div>
|
||||
<div class="message">
|
||||
Požádali jste o přihlašovací odkaz k účtu Bookra. Klikněte níže pro bezpečný přístup — heslo není potřeba.
|
||||
</div>
|
||||
<div class="button-wrap">
|
||||
<a href="#" class="button">Přihlásit se do Bookra</a>
|
||||
</div>
|
||||
<div class="link-box">
|
||||
<div class="link-label">Nebo zkopírujte tento odkaz</div>
|
||||
<div class="link-url">https://bookra.tdvorak.dev/auth/callback?token=xyz123...</div>
|
||||
</div>
|
||||
<div class="expiry">
|
||||
Tento odkaz vyprší za <strong>15 minut</strong> z bezpečnostních důvodů.
|
||||
</div>
|
||||
<div class="help">
|
||||
Nepožádali jste o tento email? Můžete ho bezpečně ignorovat.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>'>
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showLang(lang) {
|
||||
document.querySelectorAll(".toggle button").forEach(btn => btn.classList.remove("active"));
|
||||
event.target.classList.add("active");
|
||||
document.querySelectorAll("[data-lang]").forEach(card => {
|
||||
card.style.display = card.dataset.lang === lang ? "block" : "none";
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,14 +11,37 @@ import (
|
||||
"bookra/apps/backend/internal/api"
|
||||
"bookra/apps/backend/internal/config"
|
||||
"bookra/apps/backend/internal/db"
|
||||
|
||||
sentry "github.com/getsentry/sentry-go"
|
||||
)
|
||||
|
||||
func initSentry(cfg config.Config) {
|
||||
if cfg.SentryDSN == "" {
|
||||
log.Println("Sentry DSN not configured - skipping initialization")
|
||||
return
|
||||
}
|
||||
|
||||
err := sentry.Init(sentry.ClientOptions{
|
||||
Dsn: cfg.SentryDSN,
|
||||
Environment: cfg.Environment,
|
||||
Release: "bookra@1.0.0",
|
||||
// Set TracesSampleRate to 1.0 to capture 100% of transactions for testing
|
||||
TracesSampleRate: 1.0,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Sentry initialization failed: %v", err)
|
||||
}
|
||||
log.Println("Sentry initialized")
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
initSentry(cfg)
|
||||
|
||||
pools, err := db.NewPools(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("create database pools: %v", err)
|
||||
@@ -31,6 +54,9 @@ func main() {
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
// Start background job for trial ending emails
|
||||
go server.StartBackgroundJobs()
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: server.Handler(),
|
||||
|
||||
+3
-1
@@ -3,13 +3,14 @@ module bookra/apps/backend
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0
|
||||
github.com/MicahParks/keyfunc/v3 v3.8.0
|
||||
github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0
|
||||
github.com/gin-contrib/cors v1.7.7
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
github.com/stripe/stripe-go/v81 v81.0.0
|
||||
golang.org/x/time v0.9.0
|
||||
)
|
||||
|
||||
@@ -20,6 +21,7 @@ require (
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/getsentry/sentry-go v0.46.2 // indirect
|
||||
github.com/ggicci/httpin v0.20.3 // indirect
|
||||
github.com/ggicci/owl v0.8.2 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
|
||||
@@ -17,6 +17,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns=
|
||||
github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
|
||||
github.com/ggicci/httpin v0.20.3 h1:qy93bUsF/eGbX8WJfXEjB3bjgUrv7MKZ3qVWR73DIGY=
|
||||
github.com/ggicci/httpin v0.20.3/go.mod h1:ppHGT8xt99mRnDUuehLLWl2RAVLKG+VGn48GjK5xaLA=
|
||||
github.com/ggicci/owl v0.8.2 h1:og+lhqpzSMPDdEB+NJfzoAJARP7qCG3f8uUC3xvGukA=
|
||||
@@ -58,6 +60,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
|
||||
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
@@ -89,6 +93,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stripe/stripe-go/v81 v81.0.0 h1:7xqKVXIjhFoSEUzXXPON7oYFRupOyhDG5R7tRVyrgeE=
|
||||
github.com/stripe/stripe-go/v81 v81.0.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
@@ -101,17 +107,23 @@ golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrForbidden = errors.New("forbidden: admin access required")
|
||||
ErrInvalidAdminCreds = errors.New("invalid admin credentials")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo db.Repository
|
||||
adminEmail string
|
||||
adminKey string
|
||||
}
|
||||
|
||||
func NewService(repo db.Repository, adminEmail, adminKey string) *Service {
|
||||
return &Service{
|
||||
repo: repo,
|
||||
adminEmail: adminEmail,
|
||||
adminKey: adminKey,
|
||||
}
|
||||
}
|
||||
|
||||
// IsConfigured returns true if admin credentials are set
|
||||
func (s *Service) IsConfigured() bool {
|
||||
return s.adminEmail != "" && s.adminKey != ""
|
||||
}
|
||||
|
||||
// ValidateAdminLogin checks if the provided credentials match the admin credentials
|
||||
// Uses constant-time comparison to prevent timing attacks
|
||||
func (s *Service) ValidateAdminLogin(email, key string) bool {
|
||||
if !s.IsConfigured() {
|
||||
return false
|
||||
}
|
||||
|
||||
emailMatch := subtle.ConstantTimeCompare([]byte(email), []byte(s.adminEmail)) == 1
|
||||
keyMatch := subtle.ConstantTimeCompare([]byte(key), []byte(s.adminKey)) == 1
|
||||
|
||||
return emailMatch && keyMatch
|
||||
}
|
||||
|
||||
// RequireAdmin is middleware that checks for admin authentication
|
||||
// It supports two modes:
|
||||
// 1. Admin credentials via X-Admin-Email and X-Admin-Key headers (for API access)
|
||||
// 2. Session-based auth where the user has role "admin" or "superadmin"
|
||||
func RequireAdmin(adminSvc *Service, authSvc interface{ IsAdmin(ctx context.Context, userID string) (bool, error) }) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Check for admin header credentials (direct admin login)
|
||||
adminEmail := c.GetHeader("X-Admin-Email")
|
||||
adminKey := c.GetHeader("X-Admin-Key")
|
||||
|
||||
if adminEmail != "" && adminKey != "" {
|
||||
if adminSvc.ValidateAdminLogin(adminEmail, adminKey) {
|
||||
c.Set("isAdmin", true)
|
||||
c.Set("adminMode", "credentials")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check for Bearer token with admin role
|
||||
auth := c.GetHeader("Authorization")
|
||||
if auth != "" && strings.HasPrefix(auth, "Bearer ") {
|
||||
// The auth middleware should have already validated the token
|
||||
// and set the user info in context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin, err := authSvc.IsAdmin(c.Request.Context(), userID.(string))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin status"})
|
||||
return
|
||||
}
|
||||
|
||||
if isAdmin {
|
||||
c.Set("isAdmin", true)
|
||||
c.Set("adminMode", "session")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
|
||||
}
|
||||
}
|
||||
|
||||
// GetDashboardStats returns platform-wide statistics for admin dashboard
|
||||
func (s *Service) GetDashboardStats(ctx context.Context) (domain.AdminDashboardStats, error) {
|
||||
stats, err := s.repo.GetPlatformStats(ctx)
|
||||
if err != nil {
|
||||
return domain.AdminDashboardStats{}, err
|
||||
}
|
||||
|
||||
return domain.AdminDashboardStats{
|
||||
TotalTenants: stats.TotalTenants,
|
||||
TotalUsers: stats.TotalUsers,
|
||||
ActiveSubscriptions: stats.ActiveSubscriptions,
|
||||
TrialSubscriptions: stats.TrialSubscriptions,
|
||||
BookingsThisMonth: stats.BookingsThisMonth,
|
||||
RevenueThisMonthCents: stats.RevenueThisMonth,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListTenants returns paginated list of all tenants
|
||||
func (s *Service) ListTenants(ctx context.Context, page, pageSize int) (domain.AdminTenantList, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
tenants, total, err := s.repo.ListAllTenants(ctx, pageSize, offset)
|
||||
if err != nil {
|
||||
return domain.AdminTenantList{}, err
|
||||
}
|
||||
|
||||
result := domain.AdminTenantList{
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Tenants: make([]domain.AdminTenant, len(tenants)),
|
||||
}
|
||||
|
||||
for i, t := range tenants {
|
||||
result.Tenants[i] = domain.AdminTenant{
|
||||
ID: t.ID,
|
||||
Slug: t.Slug,
|
||||
Name: t.Name,
|
||||
PlanCode: t.PlanCode,
|
||||
SubscriptionStatus: t.SubscriptionStatus,
|
||||
BillingProvider: t.BillingProvider,
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListUsers returns paginated list of all users
|
||||
func (s *Service) ListUsers(ctx context.Context, page, pageSize int) (domain.AdminUserList, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
users, total, err := s.repo.ListAllUsers(ctx, pageSize, offset)
|
||||
if err != nil {
|
||||
return domain.AdminUserList{}, err
|
||||
}
|
||||
|
||||
result := domain.AdminUserList{
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Users: make([]domain.AdminUser, len(users)),
|
||||
}
|
||||
|
||||
for i, u := range users {
|
||||
result.Users[i] = domain.AdminUser{
|
||||
ID: u.ID.String(),
|
||||
Email: u.Email,
|
||||
Name: stringPtrToStr(u.Name),
|
||||
EmailVerified: u.EmailVerified,
|
||||
Provider: u.Provider,
|
||||
Role: u.Role,
|
||||
CreatedAt: u.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UpdateUserRole changes a user's role
|
||||
func (s *Service) UpdateUserRole(ctx context.Context, adminUserID, targetUserID, newRole string, ip, userAgent string) error {
|
||||
// Validate role
|
||||
validRoles := map[string]bool{
|
||||
"user": true,
|
||||
"admin": true,
|
||||
"superadmin": true,
|
||||
}
|
||||
if !validRoles[newRole] {
|
||||
return errors.New("invalid role")
|
||||
}
|
||||
|
||||
if err := s.repo.UpdateUserRole(ctx, targetUserID, newRole); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Log the action
|
||||
return s.repo.CreateAdminAuditLog(ctx, db.AdminAuditLogParams{
|
||||
AdminUserID: adminUserID,
|
||||
Action: "update_user_role",
|
||||
ResourceType: "user",
|
||||
ResourceID: targetUserID,
|
||||
Details: map[string]any{
|
||||
"newRole": newRole,
|
||||
},
|
||||
IPAddress: ip,
|
||||
UserAgent: userAgent,
|
||||
})
|
||||
}
|
||||
|
||||
// SyncTenantSubscription manually syncs a tenant's subscription from Stripe
|
||||
func (s *Service) SyncTenantSubscription(ctx context.Context, tenantID string) error {
|
||||
// This will be called from the billing service
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringPtrToStr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Ensure time package is imported
|
||||
_ = time.Now()
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/admin"
|
||||
"bookra/apps/backend/internal/auth"
|
||||
"bookra/apps/backend/internal/billing"
|
||||
"bookra/apps/backend/internal/bookings"
|
||||
@@ -18,16 +21,21 @@ import (
|
||||
"bookra/apps/backend/internal/notifications"
|
||||
"bookra/apps/backend/internal/tenancy"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
router *gin.Engine
|
||||
cfg config.Config
|
||||
pools *db.Pools
|
||||
verifier *auth.Verifier
|
||||
router *gin.Engine
|
||||
cfg config.Config
|
||||
pools *db.Pools
|
||||
verifier *auth.Verifier
|
||||
authService *auth.Service
|
||||
adminService *admin.Service
|
||||
billingService *billing.Service
|
||||
notificationService *notifications.Service
|
||||
}
|
||||
|
||||
func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
@@ -41,15 +49,21 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
bookingService := bookings.NewService(repository, notificationService)
|
||||
customerBookingService := bookings.NewCustomerService(repository, notificationService)
|
||||
tenantService := tenancy.NewService(repository)
|
||||
catalogService := catalog.NewService(repository)
|
||||
billingService := billing.NewService(cfg, repository)
|
||||
catalogService := catalog.NewService(repository, billingService, notificationService)
|
||||
authService := auth.NewService(repository, cfg.AuthJWTSecret)
|
||||
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
|
||||
publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5)
|
||||
|
||||
server := &Server{
|
||||
router: gin.New(),
|
||||
cfg: cfg,
|
||||
pools: pools,
|
||||
verifier: verifier,
|
||||
router: gin.New(),
|
||||
cfg: cfg,
|
||||
pools: pools,
|
||||
verifier: verifier,
|
||||
authService: authService,
|
||||
adminService: adminService,
|
||||
billingService: billingService,
|
||||
notificationService: notificationService,
|
||||
}
|
||||
|
||||
server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{
|
||||
@@ -67,15 +81,241 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
})
|
||||
})
|
||||
|
||||
// Test endpoint for Sentry
|
||||
server.router.GET("/debug/sentry", func(c *gin.Context) {
|
||||
sentry.CaptureMessage("Test message from Bookra API")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "sent", "message": "Test error sent to Sentry"})
|
||||
})
|
||||
|
||||
// Test endpoint for billing
|
||||
server.router.GET("/debug/billing-test", func(c *gin.Context) {
|
||||
if billingService != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"billingService": "initialized",
|
||||
"message": "Billing service is working",
|
||||
})
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "billingService": "not initialized"})
|
||||
}
|
||||
})
|
||||
|
||||
server.router.GET("/v1/meta/config", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"environment": cfg.Environment,
|
||||
"neonAuthEnabled": verifier.Enabled(),
|
||||
"apiUrl": cfg.APIURL,
|
||||
"demoMode": cfg.DemoMode,
|
||||
"environment": cfg.Environment,
|
||||
"neonAuthEnabled": verifier.Enabled(),
|
||||
"apiUrl": cfg.APIURL,
|
||||
"demoMode": cfg.DemoMode,
|
||||
"adminLoginEnabled": adminService.IsConfigured(),
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// AUTH API
|
||||
// ============================================
|
||||
authGroup := server.router.Group("/v1/auth")
|
||||
{
|
||||
authGroup.POST("/register", func(c *gin.Context) {
|
||||
var request struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
user, tokens, err := authService.RegisterWithPassword(c.Request.Context(), request.Email, request.Password, request.Name)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrEmailAlreadyExists) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, auth.ErrPasswordTooShort) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "registration_failed"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
},
|
||||
"accessToken": tokens.AccessToken,
|
||||
"refreshToken": tokens.RefreshToken,
|
||||
"tokenType": tokens.TokenType,
|
||||
"expiresIn": tokens.ExpiresIn,
|
||||
})
|
||||
})
|
||||
|
||||
authGroup.POST("/login", func(c *gin.Context) {
|
||||
var request struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
user, tokens, err := authService.LoginWithPassword(c.Request.Context(), request.Email, request.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "login_failed"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
},
|
||||
"accessToken": tokens.AccessToken,
|
||||
"refreshToken": tokens.RefreshToken,
|
||||
"tokenType": tokens.TokenType,
|
||||
"expiresIn": tokens.ExpiresIn,
|
||||
})
|
||||
})
|
||||
|
||||
authGroup.POST("/magic-link", func(c *gin.Context) {
|
||||
var request struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
_, err := authService.CreateMagicLink(c.Request.Context(), request.Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "magic_link_failed"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "magic_link_sent"})
|
||||
})
|
||||
|
||||
authGroup.POST("/verify", func(c *gin.Context) {
|
||||
var request struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
user, tokens, err := authService.VerifyMagicLink(c.Request.Context(), request.Token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
},
|
||||
"accessToken": tokens.AccessToken,
|
||||
"refreshToken": tokens.RefreshToken,
|
||||
"tokenType": tokens.TokenType,
|
||||
"expiresIn": tokens.ExpiresIn,
|
||||
})
|
||||
})
|
||||
|
||||
authGroup.POST("/refresh", func(c *gin.Context) {
|
||||
var request struct {
|
||||
RefreshToken string `json:"refreshToken" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
tokens, err := authService.RefreshToken(c.Request.Context(), request.RefreshToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"accessToken": tokens.AccessToken,
|
||||
"refreshToken": tokens.RefreshToken,
|
||||
"tokenType": tokens.TokenType,
|
||||
"expiresIn": tokens.ExpiresIn,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ADMIN API
|
||||
// ============================================
|
||||
adminGroup := server.router.Group("/v1/admin")
|
||||
adminGroup.Use(admin.RequireAdmin(adminService, authService))
|
||||
{
|
||||
adminGroup.GET("/stats", func(c *gin.Context) {
|
||||
stats, err := adminService.GetDashboardStats(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, stats)
|
||||
})
|
||||
|
||||
adminGroup.GET("/tenants", func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
result, err := adminService.ListTenants(c.Request.Context(), page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
})
|
||||
|
||||
adminGroup.GET("/users", func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
result, err := adminService.ListUsers(c.Request.Context(), page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
})
|
||||
|
||||
adminGroup.PUT("/users/:userID/role", func(c *gin.Context) {
|
||||
var request domain.UpdateUserRoleRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
adminUserID, _ := c.Get("userID")
|
||||
err := adminService.UpdateUserRole(
|
||||
c.Request.Context(),
|
||||
adminUserID.(string),
|
||||
c.Param("userID"),
|
||||
request.Role,
|
||||
c.ClientIP(),
|
||||
c.GetHeader("User-Agent"),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
})
|
||||
|
||||
// Trigger trial ending email check
|
||||
adminGroup.POST("/trigger-trial-emails", func(c *gin.Context) {
|
||||
err := billingService.CheckAndSendTrialEndingEmails(c.Request.Context(), notificationService)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "completed", "message": "Trial ending emails sent"})
|
||||
})
|
||||
}
|
||||
|
||||
server.router.GET("/v1/public/tenants/:tenantSlug/availability", func(c *gin.Context) {
|
||||
response, err := bookingService.Availability(c.Request.Context(), c.Param("tenantSlug"))
|
||||
if err != nil {
|
||||
@@ -126,6 +366,23 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
c.JSON(http.StatusCreated, response)
|
||||
})
|
||||
|
||||
server.router.POST("/v1/public/contact", publicRateLimiter.Middleware(), func(c *gin.Context) {
|
||||
var request struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Message string `json:"message" binding:"required,min=10"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
if err := notificationService.SendContactEmail(c.Request.Context(), request.Name, request.Email, request.Message); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send message"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "sent"})
|
||||
})
|
||||
|
||||
protected := server.router.Group("/v1")
|
||||
protected.Use(auth.RequireAuth(verifier, repository, cfg.DemoMode))
|
||||
|
||||
@@ -196,6 +453,8 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, catalog.ErrPlanLimitReached) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -492,7 +751,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := billingService.CreateCheckoutSession(c.Request.Context(), auth.PrincipalFromContext(c), request.PlanCode, request.Currency)
|
||||
response, err := billingService.CreateCheckoutSession(c.Request.Context(), auth.PrincipalFromContext(c), request.PlanCode, request.Currency, request.BillingInterval)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, billing.ErrBillingMembership) {
|
||||
@@ -549,6 +808,13 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
|
||||
})
|
||||
server.router.POST("/v1/webhooks/stripe", func(c *gin.Context) {
|
||||
if err := billingService.HandleStripeWebhook(c.Request.Context(), c.Request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
|
||||
})
|
||||
server.router.POST("/api/paddle_webhook", func(c *gin.Context) {
|
||||
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -597,6 +863,40 @@ func (s *Server) Close() {
|
||||
}
|
||||
}
|
||||
|
||||
// StartBackgroundJobs runs periodic background tasks
|
||||
func (s *Server) StartBackgroundJobs() {
|
||||
// Run trial ending check every 6 hours
|
||||
ticker := time.NewTicker(6 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run immediately on startup after a brief delay
|
||||
time.Sleep(30 * time.Second)
|
||||
s.runTrialEndingCheck()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.runTrialEndingCheck()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) runTrialEndingCheck() {
|
||||
if s.billingService == nil || s.notificationService == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := s.billingService.CheckAndSendTrialEndingEmails(ctx, s.notificationService)
|
||||
if err != nil {
|
||||
log.Printf("Background job: trial ending check failed: %v", err)
|
||||
} else {
|
||||
log.Printf("Background job: trial ending check completed")
|
||||
}
|
||||
}
|
||||
|
||||
func authorizeJobRunner(c *gin.Context, cfg config.Config) bool {
|
||||
if cfg.JobRunnerKey == "" {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
accessTokenTTL = 24 * time.Hour
|
||||
refreshTokenTTL = 30 * 24 * time.Hour
|
||||
magicLinkTTL = 15 * time.Minute
|
||||
passwordResetTTL = 30 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrInvalidToken = errors.New("invalid or expired token")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrEmailAlreadyExists = errors.New("email already exists")
|
||||
ErrPasswordTooShort = errors.New("password must be at least 8 characters")
|
||||
ErrMagicLinkExpired = errors.New("magic link expired")
|
||||
ErrMagicLinkUsed = errors.New("magic link already used")
|
||||
ErrInvalidResetToken = errors.New("invalid or expired reset token")
|
||||
)
|
||||
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken,omitempty"`
|
||||
TokenType string `json:"tokenType"`
|
||||
ExpiresIn int `json:"expiresIn"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Type string `json:"type"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo db.Repository
|
||||
jwtSecret []byte
|
||||
}
|
||||
|
||||
func NewService(repo db.Repository, jwtSecret string) *Service {
|
||||
return &Service{
|
||||
repo: repo,
|
||||
jwtSecret: []byte(jwtSecret),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterWithPassword creates a new user with email and password
|
||||
func (s *Service) RegisterWithPassword(ctx context.Context, email, password, name string) (*db.UserRecord, *TokenPair, error) {
|
||||
if len(password) < 8 {
|
||||
return nil, nil, ErrPasswordTooShort
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
existing, err := s.repo.GetUserByEmail(ctx, email)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, nil, ErrEmailAlreadyExists
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Create user
|
||||
user, err := s.repo.CreateUser(ctx, email, string(hash), name, "email", "user")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
tokens, err := s.generateTokenPair(user.ID.String(), email, name, "user")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return user, tokens, nil
|
||||
}
|
||||
|
||||
// LoginWithPassword authenticates a user with email and password
|
||||
func (s *Service) LoginWithPassword(ctx context.Context, email, password string) (*db.UserRecord, *TokenPair, error) {
|
||||
user, err := s.repo.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if user.PasswordHash == nil {
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Update last login
|
||||
if err := s.repo.UpdateLastLogin(ctx, user.ID.String()); err != nil {
|
||||
// Log but don't fail
|
||||
}
|
||||
|
||||
tokens, err := s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return user, tokens, nil
|
||||
}
|
||||
|
||||
// CreateMagicLink generates a magic link for passwordless auth
|
||||
func (s *Service) CreateMagicLink(ctx context.Context, email string) (string, error) {
|
||||
// Get or create user
|
||||
user, err := s.repo.GetUserByEmail(ctx, email)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
user, err = s.repo.CreateUser(ctx, email, "", "", "email", "user")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// Generate token
|
||||
token := generateRandomToken(32)
|
||||
expiresAt := time.Now().Add(magicLinkTTL)
|
||||
|
||||
if err := s.repo.CreateMagicLink(ctx, token, user.ID.String(), email, expiresAt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// VerifyMagicLink validates a magic link and returns tokens
|
||||
func (s *Service) VerifyMagicLink(ctx context.Context, token string) (*db.UserRecord, *TokenPair, error) {
|
||||
ml, err := s.repo.GetMagicLink(ctx, token)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil, ErrInvalidToken
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if ml.Used {
|
||||
return nil, nil, ErrMagicLinkUsed
|
||||
}
|
||||
|
||||
if time.Now().After(ml.ExpiresAt) {
|
||||
return nil, nil, ErrMagicLinkExpired
|
||||
}
|
||||
|
||||
// Mark as used
|
||||
if err := s.repo.MarkMagicLinkUsed(ctx, token); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Get user
|
||||
user, err := s.repo.GetUserByID(ctx, ml.UserID.String())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Mark email as verified
|
||||
if err := s.repo.MarkEmailVerified(ctx, user.ID.String()); err != nil {
|
||||
// Log but don't fail
|
||||
}
|
||||
|
||||
// Update last login
|
||||
if err := s.repo.UpdateLastLogin(ctx, user.ID.String()); err != nil {
|
||||
// Log but don't fail
|
||||
}
|
||||
|
||||
tokens, err := s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return user, tokens, nil
|
||||
}
|
||||
|
||||
// RefreshToken refreshes an access token using a refresh token
|
||||
func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
||||
claims, err := s.ValidateToken(refreshToken)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
if claims.Type != "refresh" {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
user, err := s.repo.GetUserByID(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
return s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role)
|
||||
}
|
||||
|
||||
// ValidateToken validates a JWT token and returns claims
|
||||
func (s *Service) ValidateToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return s.jwtSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// GetUser retrieves a user by ID
|
||||
func (s *Service) GetUser(ctx context.Context, userID string) (*db.UserRecord, error) {
|
||||
return s.repo.GetUserByID(ctx, userID)
|
||||
}
|
||||
|
||||
// IsAdmin checks if the user has admin role
|
||||
func (s *Service) IsAdmin(ctx context.Context, userID string) (bool, error) {
|
||||
user, err := s.repo.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return user.Role == "admin" || user.Role == "superadmin", nil
|
||||
}
|
||||
|
||||
// generateTokenPair creates access and refresh tokens
|
||||
func (s *Service) generateTokenPair(userID, email, name, role string) (*TokenPair, error) {
|
||||
now := time.Now()
|
||||
|
||||
// Access token
|
||||
accessClaims := Claims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Name: name,
|
||||
Role: role,
|
||||
Type: "access",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenTTL)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
|
||||
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||
accessTokenString, err := accessToken.SignedString(s.jwtSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Refresh token
|
||||
refreshClaims := Claims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Role: role,
|
||||
Type: "refresh",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(refreshTokenTTL)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
|
||||
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
|
||||
refreshTokenString, err := refreshToken.SignedString(s.jwtSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TokenPair{
|
||||
AccessToken: accessTokenString,
|
||||
RefreshToken: refreshTokenString,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(accessTokenTTL.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func generateRandomToken(length int) string {
|
||||
b := make([]byte, length)
|
||||
rand.Read(b)
|
||||
return base64.URLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
@@ -17,6 +18,12 @@ import (
|
||||
|
||||
paddle "github.com/PaddleHQ/paddle-go-sdk/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/stripe/stripe-go/v81"
|
||||
portalsession "github.com/stripe/stripe-go/v81/billingportal/session"
|
||||
checkoutsession "github.com/stripe/stripe-go/v81/checkout/session"
|
||||
"github.com/stripe/stripe-go/v81/customer"
|
||||
"github.com/stripe/stripe-go/v81/subscription"
|
||||
"github.com/stripe/stripe-go/v81/webhook"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -26,9 +33,12 @@ var (
|
||||
ErrPaddleNotConfigured = errors.New("paddle is not configured")
|
||||
ErrPaddleSignatureMissing = errors.New("paddle signature is missing")
|
||||
ErrPaddleWebhookMissing = errors.New("paddle webhook secret is not configured")
|
||||
ErrStripeNotConfigured = errors.New("stripe is not configured")
|
||||
ErrStripeSignatureMissing = errors.New("stripe signature is missing")
|
||||
ErrStripeWebhookMissing = errors.New("stripe webhook secret is not configured")
|
||||
)
|
||||
|
||||
var allowedWebhookEvents = []string{
|
||||
var allowedPaddleWebhookEvents = []string{
|
||||
"subscription.created",
|
||||
"subscription.updated",
|
||||
"subscription.activated",
|
||||
@@ -42,11 +52,23 @@ var allowedWebhookEvents = []string{
|
||||
"transaction.past_due",
|
||||
}
|
||||
|
||||
var allowedStripeWebhookEvents = []stripe.EventType{
|
||||
stripe.EventTypeCheckoutSessionCompleted,
|
||||
stripe.EventTypeCustomerSubscriptionCreated,
|
||||
stripe.EventTypeCustomerSubscriptionUpdated,
|
||||
stripe.EventTypeCustomerSubscriptionDeleted,
|
||||
stripe.EventTypeInvoicePaid,
|
||||
stripe.EventTypeInvoicePaymentFailed,
|
||||
stripe.EventTypePaymentIntentSucceeded,
|
||||
stripe.EventTypePaymentIntentPaymentFailed,
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
repo db.Repository
|
||||
client *paddle.SDK
|
||||
verifier *paddle.WebhookVerifier
|
||||
cfg config.Config
|
||||
repo db.Repository
|
||||
client *paddle.SDK
|
||||
verifier *paddle.WebhookVerifier
|
||||
stripeEnabled bool
|
||||
}
|
||||
|
||||
type webhookEnvelope struct {
|
||||
@@ -63,6 +85,7 @@ type webhookEnvelope struct {
|
||||
func NewService(cfg config.Config, repo db.Repository) *Service {
|
||||
service := &Service{cfg: cfg, repo: repo}
|
||||
|
||||
// Initialize Paddle client
|
||||
if strings.TrimSpace(cfg.PaddleAPIKey) != "" {
|
||||
var client *paddle.SDK
|
||||
var err error
|
||||
@@ -76,13 +99,28 @@ func NewService(cfg config.Config, repo db.Repository) *Service {
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(cfg.PaddleWebhookKey) != "" {
|
||||
service.verifier = paddle.NewWebhookVerifier(cfg.PaddleWebhookKey, paddle.VerifierWithTimestampTolerance(5*time.Minute))
|
||||
// Initialize Stripe
|
||||
if strings.TrimSpace(cfg.StripeAPIKey) != "" {
|
||||
stripe.Key = cfg.StripeAPIKey
|
||||
service.stripeEnabled = true
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
// GetEntitlements returns the plan entitlements for a tenant (used by other services for limit enforcement)
|
||||
func (s *Service) GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error) {
|
||||
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Default to Pro entitlements for tenants without billing
|
||||
return entitlementsForPlan("pro"), nil
|
||||
}
|
||||
return domain.PlanEntitlements{}, err
|
||||
}
|
||||
return entitlementsForPlan(tenant.PlanCode), nil
|
||||
}
|
||||
|
||||
func (s *Service) GetSubscription(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
@@ -97,7 +135,7 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
|
||||
TenantID: membership.Tenant.ID,
|
||||
BillingProvider: "paddle",
|
||||
BillingProvider: s.cfg.BillingProvider(),
|
||||
Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"),
|
||||
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
|
||||
Currency: "czk",
|
||||
@@ -109,7 +147,7 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
|
||||
return toSnapshot(membership.Tenant, record, s.cfg), nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Principal, planCode string, currency string) (domain.CheckoutLaunchResponse, error) {
|
||||
func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Principal, planCode string, currency string, billingInterval string) (domain.CheckoutLaunchResponse, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -118,7 +156,93 @@ func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Pr
|
||||
return domain.CheckoutLaunchResponse{}, err
|
||||
}
|
||||
|
||||
priceID, resolvedPlanCode, resolvedCurrency := s.priceForPlan(planCode, currency)
|
||||
// Default to monthly if not specified
|
||||
if billingInterval == "" {
|
||||
billingInterval = "monthly"
|
||||
}
|
||||
|
||||
// Prefer Stripe if configured
|
||||
if s.cfg.StripeConfigured() {
|
||||
return s.createStripeCheckoutSession(ctx, principal, membership, planCode, currency, billingInterval)
|
||||
}
|
||||
|
||||
// Fall back to Paddle
|
||||
return s.createPaddleCheckoutSession(ctx, principal, membership, planCode, currency)
|
||||
}
|
||||
|
||||
func (s *Service) createStripeCheckoutSession(ctx context.Context, principal domain.Principal, membership db.TenantMembershipRecord, planCode string, currency string, billingInterval string) (domain.CheckoutLaunchResponse, error) {
|
||||
priceID, resolvedPlanCode, resolvedCurrency := s.stripePriceForPlan(planCode, currency, billingInterval)
|
||||
if priceID == "" {
|
||||
return domain.CheckoutLaunchResponse{}, ErrBillingPlanUnsupported
|
||||
}
|
||||
|
||||
// Ensure customer exists (KV sync model: always pre-create customers)
|
||||
customerID := derefString(membership.Tenant.BillingCustomerID)
|
||||
if customerID == "" {
|
||||
cust, err := customer.New(&stripe.CustomerParams{
|
||||
Email: stripe.String(strings.TrimSpace(principal.Email)),
|
||||
Metadata: map[string]string{
|
||||
"tenantId": membership.Tenant.ID,
|
||||
"tenantSlug": membership.Tenant.Slug,
|
||||
"userId": principal.Subject,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.CheckoutLaunchResponse{}, fmt.Errorf("failed to create stripe customer: %w", err)
|
||||
}
|
||||
customerID = cust.ID
|
||||
if err := s.repo.UpdateTenantBillingCustomerID(ctx, membership.Tenant.ID, customerID); err != nil {
|
||||
return domain.CheckoutLaunchResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// Create checkout session - 15-day free trial for Starter/Pro only (not Business)
|
||||
// Trial requires credit card to be entered
|
||||
trialDays := int64(0)
|
||||
if resolvedPlanCode == "starter" || resolvedPlanCode == "pro" {
|
||||
trialDays = 15
|
||||
}
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
Customer: stripe.String(customerID),
|
||||
SuccessURL: stripe.String(strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=success&session_id={CHECKOUT_SESSION_ID}"),
|
||||
CancelURL: stripe.String(strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=cancelled"),
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||
PaymentMethodCollection: stripe.String("always"), // Require credit card even for free trial
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(priceID),
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
},
|
||||
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
|
||||
TrialPeriodDays: stripe.Int64(trialDays),
|
||||
},
|
||||
Metadata: map[string]string{
|
||||
"tenantId": membership.Tenant.ID,
|
||||
"tenantSlug": membership.Tenant.Slug,
|
||||
"userId": principal.Subject,
|
||||
"userEmail": strings.TrimSpace(principal.Email),
|
||||
"planCode": resolvedPlanCode,
|
||||
"currency": resolvedCurrency,
|
||||
"billingInterval": billingInterval,
|
||||
"source": "bookra-dashboard",
|
||||
},
|
||||
}
|
||||
sess, err := checkoutsession.New(params)
|
||||
if err != nil {
|
||||
return domain.CheckoutLaunchResponse{}, fmt.Errorf("failed to create stripe checkout session: %w", err)
|
||||
}
|
||||
|
||||
return domain.CheckoutLaunchResponse{
|
||||
CheckoutURL: sess.URL,
|
||||
SuccessRedirectURL: sess.SuccessURL,
|
||||
CancelRedirectURL: sess.CancelURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) createPaddleCheckoutSession(ctx context.Context, principal domain.Principal, membership db.TenantMembershipRecord, planCode string, currency string) (domain.CheckoutLaunchResponse, error) {
|
||||
priceID, resolvedPlanCode, resolvedCurrency := s.paddlePriceForPlan(planCode, currency)
|
||||
if priceID == "" {
|
||||
return domain.CheckoutLaunchResponse{}, ErrBillingPlanUnsupported
|
||||
}
|
||||
@@ -157,16 +281,26 @@ func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (doma
|
||||
if customerID == "" {
|
||||
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
|
||||
TenantID: membership.Tenant.ID,
|
||||
BillingProvider: "paddle",
|
||||
BillingProvider: s.cfg.BillingProvider(),
|
||||
Status: "inactive",
|
||||
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
|
||||
Currency: "czk",
|
||||
}, s.cfg), nil
|
||||
}
|
||||
|
||||
// Prefer Stripe if configured
|
||||
if s.cfg.StripeConfigured() {
|
||||
record, err := s.syncStripeDataToKV(ctx, membership.Tenant, customerID)
|
||||
if err != nil {
|
||||
return domain.SubscriptionSnapshot{}, err
|
||||
}
|
||||
return toSnapshot(membership.Tenant, record, s.cfg), nil
|
||||
}
|
||||
|
||||
// Fall back to Paddle
|
||||
if s.client == nil {
|
||||
return domain.SubscriptionSnapshot{}, ErrPaddleNotConfigured
|
||||
}
|
||||
|
||||
record, err := s.syncPaddleData(ctx, membership.Tenant, customerID)
|
||||
if err != nil {
|
||||
return domain.SubscriptionSnapshot{}, err
|
||||
@@ -183,30 +317,53 @@ func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Prin
|
||||
}
|
||||
return domain.PortalSessionResponse{}, err
|
||||
}
|
||||
if s.client == nil {
|
||||
return domain.PortalSessionResponse{}, ErrPaddleNotConfigured
|
||||
}
|
||||
|
||||
customerID := derefString(membership.Tenant.BillingCustomerID)
|
||||
if customerID == "" {
|
||||
return domain.PortalSessionResponse{}, ErrBillingCustomerMissing
|
||||
}
|
||||
|
||||
// Prefer Stripe if configured
|
||||
if s.cfg.StripeConfigured() {
|
||||
return s.createStripePortalSession(customerID)
|
||||
}
|
||||
|
||||
// Fall back to Paddle
|
||||
return s.createPaddlePortalSession(ctx, membership, customerID)
|
||||
}
|
||||
|
||||
func (s *Service) createStripePortalSession(customerID string) (domain.PortalSessionResponse, error) {
|
||||
params := &stripe.BillingPortalSessionParams{
|
||||
Customer: stripe.String(customerID),
|
||||
ReturnURL: stripe.String(s.cfg.FrontendURL + "/dashboard?billing=refresh"),
|
||||
}
|
||||
sess, err := portalsession.New(params)
|
||||
if err != nil {
|
||||
return domain.PortalSessionResponse{}, fmt.Errorf("failed to create stripe portal session: %w", err)
|
||||
}
|
||||
return domain.PortalSessionResponse{URL: sess.URL}, nil
|
||||
}
|
||||
|
||||
func (s *Service) createPaddlePortalSession(ctx context.Context, membership db.TenantMembershipRecord, customerID string) (domain.PortalSessionResponse, error) {
|
||||
if s.client == nil {
|
||||
return domain.PortalSessionResponse{}, ErrPaddleNotConfigured
|
||||
}
|
||||
|
||||
request := &paddle.CreateCustomerPortalSessionRequest{CustomerID: customerID}
|
||||
if subscriptionID := derefString(membership.Tenant.BillingSubscription); subscriptionID != "" {
|
||||
request.SubscriptionIDs = []string{subscriptionID}
|
||||
}
|
||||
|
||||
session, err := s.client.CreateCustomerPortalSession(ctx, request)
|
||||
sess, err := s.client.CreateCustomerPortalSession(ctx, request)
|
||||
if err != nil {
|
||||
return domain.PortalSessionResponse{}, err
|
||||
}
|
||||
|
||||
url := strings.TrimSpace(session.URLs.General.Overview)
|
||||
if url == "" && len(session.URLs.Subscriptions) > 0 {
|
||||
url := strings.TrimSpace(sess.URLs.General.Overview)
|
||||
if url == "" && len(sess.URLs.Subscriptions) > 0 {
|
||||
url = firstNonEmpty(
|
||||
session.URLs.Subscriptions[0].UpdateSubscriptionPaymentMethod,
|
||||
session.URLs.Subscriptions[0].CancelSubscription,
|
||||
sess.URLs.Subscriptions[0].UpdateSubscriptionPaymentMethod,
|
||||
sess.URLs.Subscriptions[0].CancelSubscription,
|
||||
)
|
||||
}
|
||||
if url == "" {
|
||||
@@ -217,6 +374,109 @@ func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Prin
|
||||
}
|
||||
|
||||
func (s *Service) HandleWebhook(ctx context.Context, req *http.Request) error {
|
||||
// Detect provider based on signature header
|
||||
stripeSig := req.Header.Get("Stripe-Signature")
|
||||
paddleSig := req.Header.Get("Paddle-Signature")
|
||||
|
||||
if stripeSig != "" {
|
||||
return s.handleStripeWebhook(ctx, req)
|
||||
}
|
||||
|
||||
if paddleSig != "" {
|
||||
return s.handlePaddleWebhook(ctx, req)
|
||||
}
|
||||
|
||||
return errors.New("missing webhook signature header")
|
||||
}
|
||||
|
||||
func (s *Service) HandleStripeWebhook(ctx context.Context, req *http.Request) error {
|
||||
return s.handleStripeWebhook(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) handleStripeWebhook(ctx context.Context, req *http.Request) error {
|
||||
if s.cfg.StripeWebhookKey == "" {
|
||||
return ErrStripeWebhookMissing
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event, err := webhook.ConstructEvent(body, req.Header.Get("Stripe-Signature"), s.cfg.StripeWebhookKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid stripe webhook signature: %w", err)
|
||||
}
|
||||
|
||||
if !slices.Contains(allowedStripeWebhookEvents, event.Type) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract customer ID from event data
|
||||
var customerID string
|
||||
var eventID = event.ID
|
||||
|
||||
switch event.Type {
|
||||
case "checkout.session.completed":
|
||||
var sess stripe.CheckoutSession
|
||||
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
|
||||
return err
|
||||
}
|
||||
customerID = sess.Customer.ID
|
||||
if sess.Metadata != nil {
|
||||
if tenantID := sess.Metadata["tenantId"]; tenantID != "" {
|
||||
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if customerID != "" && derefString(tenant.BillingCustomerID) == "" {
|
||||
if err := s.repo.UpdateTenantBillingCustomerID(ctx, tenant.ID, customerID); err != nil {
|
||||
return err
|
||||
}
|
||||
tenant.BillingCustomerID = &customerID
|
||||
}
|
||||
_, err = s.syncStripeDataToKV(ctx, tenant, customerID)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
var data struct {
|
||||
Customer struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"customer"`
|
||||
}
|
||||
if err := json.Unmarshal(event.Data.Raw, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
customerID = data.Customer.ID
|
||||
}
|
||||
|
||||
if customerID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
tenant, err := s.repo.GetTenantByBillingCustomerID(ctx, customerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
inserted, err := s.repo.RecordBillingEvent(ctx, tenant.ID, "stripe", eventID, string(event.Type), event.Data.Raw)
|
||||
if err != nil || !inserted {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.syncStripeDataToKV(ctx, tenant, customerID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) handlePaddleWebhook(ctx context.Context, req *http.Request) error {
|
||||
if s.verifier == nil {
|
||||
return ErrPaddleWebhookMissing
|
||||
}
|
||||
@@ -241,7 +501,7 @@ func (s *Service) HandleWebhook(ctx context.Context, req *http.Request) error {
|
||||
if err := json.Unmarshal(payload, &event); err != nil {
|
||||
return err
|
||||
}
|
||||
if !slices.Contains(allowedWebhookEvents, event.EventType) {
|
||||
if !slices.Contains(allowedPaddleWebhookEvents, event.EventType) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -337,7 +597,7 @@ func (s *Service) syncPaddleData(ctx context.Context, tenant db.TenantRecord, cu
|
||||
record.CurrentPeriodEnd = parseRFC3339Ptr(timePeriodEnd(selected.CurrentBillingPeriod))
|
||||
if len(selected.Items) > 0 {
|
||||
record.PriceID = selected.Items[0].Price.ID
|
||||
record.PlanCode = s.planCodeForPrice(record.PriceID, tenant.PlanCode)
|
||||
record.PlanCode = s.paddlePlanCodeForPrice(record.PriceID, tenant.PlanCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +611,115 @@ func (s *Service) syncPaddleData(ctx context.Context, tenant db.TenantRecord, cu
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// syncStripeDataToKV is the core sync function following the KV sync model.
|
||||
// It fetches full subscription state from Stripe and stores it in the database.
|
||||
// This function is called after checkout success and on every relevant webhook event.
|
||||
func (s *Service) syncStripeDataToKV(ctx context.Context, tenant db.TenantRecord, customerID string) (db.BillingSnapshotRecord, error) {
|
||||
// Fetch all subscriptions for this customer from Stripe
|
||||
iter := subscription.List(&stripe.SubscriptionListParams{
|
||||
Customer: stripe.String(customerID),
|
||||
})
|
||||
|
||||
var selected *stripe.Subscription
|
||||
for iter.Next() {
|
||||
sub := iter.Subscription()
|
||||
if selected == nil || stripeSubscriptionRank(sub) > stripeSubscriptionRank(selected) {
|
||||
selected = sub
|
||||
}
|
||||
}
|
||||
if iter.Err() != nil {
|
||||
return db.BillingSnapshotRecord{}, fmt.Errorf("failed to list stripe subscriptions: %w", iter.Err())
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
record := db.BillingSnapshotRecord{
|
||||
TenantID: tenant.ID,
|
||||
BillingProvider: "stripe",
|
||||
BillingCustomerID: customerID,
|
||||
BillingSubscriptionID: "",
|
||||
Status: "inactive",
|
||||
PlanCode: shared.NormalizePlanCode(tenant.PlanCode),
|
||||
Currency: "czk",
|
||||
PriceID: "",
|
||||
LastSyncedAt: &now,
|
||||
}
|
||||
|
||||
if selected != nil {
|
||||
record.BillingSubscriptionID = selected.ID
|
||||
record.Status = normalizeStripeSubscriptionStatus(selected.Status)
|
||||
record.Currency = strings.ToLower(string(selected.Currency))
|
||||
record.CancelAtPeriodEnd = selected.CancelAtPeriodEnd
|
||||
record.CurrentPeriodStart = stripeTimeToPtr(selected.CurrentPeriodStart)
|
||||
record.CurrentPeriodEnd = stripeTimeToPtr(selected.CurrentPeriodEnd)
|
||||
|
||||
// Extract price ID from subscription items
|
||||
if len(selected.Items.Data) > 0 {
|
||||
record.PriceID = selected.Items.Data[0].Price.ID
|
||||
record.PlanCode = s.stripePlanCodeForPrice(record.PriceID, tenant.PlanCode)
|
||||
}
|
||||
|
||||
// Get payment method info if available
|
||||
if selected.DefaultPaymentMethod != nil && selected.DefaultPaymentMethod.Card != nil {
|
||||
record.PaymentMethodBrand = string(selected.DefaultPaymentMethod.Card.Brand)
|
||||
record.PaymentMethodLast4 = selected.DefaultPaymentMethod.Card.Last4
|
||||
}
|
||||
}
|
||||
|
||||
// Store normalized snapshot in DB (KV cache)
|
||||
if err := s.repo.UpsertSubscriptionSnapshot(ctx, record); err != nil {
|
||||
return db.BillingSnapshotRecord{}, err
|
||||
}
|
||||
if err := s.repo.UpdateTenantBillingState(ctx, tenant.ID, record.PlanCode, record.Status, record.BillingSubscriptionID); err != nil {
|
||||
return db.BillingSnapshotRecord{}, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func stripeSubscriptionRank(sub *stripe.Subscription) int {
|
||||
switch sub.Status {
|
||||
case stripe.SubscriptionStatusActive:
|
||||
return 6
|
||||
case stripe.SubscriptionStatusTrialing:
|
||||
return 5
|
||||
case stripe.SubscriptionStatusPastDue:
|
||||
return 4
|
||||
case stripe.SubscriptionStatusPaused:
|
||||
return 3
|
||||
case stripe.SubscriptionStatusCanceled:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStripeSubscriptionStatus(status stripe.SubscriptionStatus) string {
|
||||
switch status {
|
||||
case stripe.SubscriptionStatusActive:
|
||||
return "active"
|
||||
case stripe.SubscriptionStatusTrialing:
|
||||
return "trialing"
|
||||
case stripe.SubscriptionStatusPastDue:
|
||||
return "past_due"
|
||||
case stripe.SubscriptionStatusPaused:
|
||||
return "paused"
|
||||
case stripe.SubscriptionStatusCanceled:
|
||||
return "canceled"
|
||||
case stripe.SubscriptionStatusUnpaid:
|
||||
return "canceled"
|
||||
default:
|
||||
return "inactive"
|
||||
}
|
||||
}
|
||||
|
||||
func stripeTimeToPtr(t int64) *time.Time {
|
||||
if t == 0 {
|
||||
return nil
|
||||
}
|
||||
ts := time.Unix(t, 0).UTC()
|
||||
return &ts
|
||||
}
|
||||
|
||||
func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg config.Config) domain.SubscriptionSnapshot {
|
||||
if record.PlanCode == "" {
|
||||
record.PlanCode = shared.NormalizePlanCode(tenant.PlanCode)
|
||||
@@ -363,43 +732,84 @@ func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg con
|
||||
}
|
||||
|
||||
customerID := firstNonEmpty(record.BillingCustomerID, derefString(tenant.BillingCustomerID))
|
||||
provider := firstNonEmpty(record.BillingProvider, tenant.BillingProvider, cfg.BillingProvider())
|
||||
|
||||
syncAvailable := cfg.BillingConfigured()
|
||||
portalAvailable := cfg.BillingConfigured() && customerID != ""
|
||||
checkoutAvailable := billingCheckoutAvailable(cfg, record.PlanCode)
|
||||
|
||||
return domain.SubscriptionSnapshot{
|
||||
TenantID: tenant.ID,
|
||||
Provider: firstNonEmpty(record.BillingProvider, tenant.BillingProvider, "paddle"),
|
||||
CustomerID: customerID,
|
||||
SubscriptionID: firstNonEmpty(record.BillingSubscriptionID, derefString(tenant.BillingSubscription)),
|
||||
Status: record.Status,
|
||||
PlanCode: record.PlanCode,
|
||||
Currency: record.Currency,
|
||||
PriceID: record.PriceID,
|
||||
CancelAtPeriodEnd: record.CancelAtPeriodEnd,
|
||||
CurrentPeriodStart: record.CurrentPeriodStart,
|
||||
CurrentPeriodEnd: record.CurrentPeriodEnd,
|
||||
PaymentMethodBrand: record.PaymentMethodBrand,
|
||||
PaymentMethodLast4: record.PaymentMethodLast4,
|
||||
Entitlements: entitlementsForPlan(record.PlanCode),
|
||||
DisplayPrices: displayPricesForPlan(record.PlanCode),
|
||||
TrialDays: 30,
|
||||
TenantID: tenant.ID,
|
||||
Provider: provider,
|
||||
CustomerID: customerID,
|
||||
SubscriptionID: firstNonEmpty(record.BillingSubscriptionID, derefString(tenant.BillingSubscription)),
|
||||
Status: record.Status,
|
||||
PlanCode: record.PlanCode,
|
||||
Currency: record.Currency,
|
||||
PriceID: record.PriceID,
|
||||
CancelAtPeriodEnd: record.CancelAtPeriodEnd,
|
||||
CurrentPeriodStart: record.CurrentPeriodStart,
|
||||
CurrentPeriodEnd: record.CurrentPeriodEnd,
|
||||
PaymentMethodBrand: record.PaymentMethodBrand,
|
||||
PaymentMethodLast4: record.PaymentMethodLast4,
|
||||
Entitlements: entitlementsForPlan(record.PlanCode),
|
||||
DisplayPrices: displayPricesForPlan(record.PlanCode),
|
||||
TrialDays: func() int {
|
||||
if record.PlanCode == "starter" || record.PlanCode == "pro" {
|
||||
return 15
|
||||
}
|
||||
return 0
|
||||
}(),
|
||||
LastSyncedAt: record.LastSyncedAt,
|
||||
CheckoutURLAvailable: checkoutAvailable(cfg, record.PlanCode),
|
||||
SyncAvailable: cfg.PaddleConfigured(),
|
||||
PortalAvailable: cfg.PaddleConfigured() && customerID != "",
|
||||
CheckoutURLAvailable: checkoutAvailable,
|
||||
SyncAvailable: syncAvailable,
|
||||
PortalAvailable: portalAvailable,
|
||||
}
|
||||
}
|
||||
|
||||
func entitlementsForPlan(planCode string) domain.PlanEntitlements {
|
||||
switch shared.NormalizePlanCode(planCode) {
|
||||
case "starter":
|
||||
return domain.PlanEntitlements{MaxLocations: 1, MaxStaff: 3, EmailReminders: true, AdvancedReporting: false, WidgetEmbedding: true, UmamiTracking: false}
|
||||
// Starter: 1 location, 1 staff, 50 bookings/month
|
||||
return domain.PlanEntitlements{
|
||||
MaxLocations: 1,
|
||||
MaxStaff: 1,
|
||||
MaxBookingsMonth: 50,
|
||||
EmailReminders: false,
|
||||
AdvancedReporting: false,
|
||||
WidgetEmbedding: true,
|
||||
UmamiTracking: false,
|
||||
APIAccess: false,
|
||||
}
|
||||
case "business":
|
||||
return domain.PlanEntitlements{MaxLocations: 10, MaxStaff: 30, EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true}
|
||||
// Business: Unlimited everything, API access, dedicated manager
|
||||
return domain.PlanEntitlements{
|
||||
MaxLocations: -1, // Unlimited
|
||||
MaxStaff: -1, // Unlimited
|
||||
MaxBookingsMonth: -1, // Unlimited
|
||||
EmailReminders: true,
|
||||
AdvancedReporting: true,
|
||||
WidgetEmbedding: true,
|
||||
UmamiTracking: true,
|
||||
APIAccess: true,
|
||||
DedicatedManager: true,
|
||||
}
|
||||
default:
|
||||
return domain.PlanEntitlements{MaxLocations: 3, MaxStaff: 10, EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true}
|
||||
// Pro: 3 locations, 10 staff, unlimited bookings, email reminders, analytics
|
||||
return domain.PlanEntitlements{
|
||||
MaxLocations: 3,
|
||||
MaxStaff: 10,
|
||||
MaxBookingsMonth: -1, // Unlimited
|
||||
EmailReminders: true,
|
||||
AdvancedReporting: true,
|
||||
WidgetEmbedding: true,
|
||||
UmamiTracking: true,
|
||||
APIAccess: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) planCodeForPrice(priceID string, fallback string) string {
|
||||
func (s *Service) paddlePlanCodeForPrice(priceID string, fallback string) string {
|
||||
for planCode, currencies := range s.cfg.PaddlePriceMatrix {
|
||||
for _, configuredPriceID := range currencies {
|
||||
if configuredPriceID != "" && configuredPriceID == priceID {
|
||||
@@ -410,7 +820,18 @@ func (s *Service) planCodeForPrice(priceID string, fallback string) string {
|
||||
return shared.NormalizePlanCode(fallback)
|
||||
}
|
||||
|
||||
func (s *Service) priceForPlan(planCode string, currency string) (string, string, string) {
|
||||
func (s *Service) stripePlanCodeForPrice(priceID string, fallback string) string {
|
||||
for planCode, currencies := range s.cfg.StripePriceMatrix {
|
||||
for _, configuredPriceID := range currencies {
|
||||
if configuredPriceID != "" && configuredPriceID == priceID {
|
||||
return shared.NormalizePlanCode(planCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
return shared.NormalizePlanCode(fallback)
|
||||
}
|
||||
|
||||
func (s *Service) paddlePriceForPlan(planCode string, currency string) (string, string, string) {
|
||||
resolvedPlan := shared.NormalizePlanCode(strings.TrimSpace(planCode))
|
||||
if resolvedPlan == "" {
|
||||
resolvedPlan = "pro"
|
||||
@@ -427,6 +848,48 @@ func (s *Service) priceForPlan(planCode string, currency string) (string, string
|
||||
return s.cfg.PaddlePriceMatrix[resolvedPlan]["usd"], resolvedPlan, "usd"
|
||||
}
|
||||
|
||||
func (s *Service) stripePriceForPlan(planCode string, currency string, billingInterval string) (string, string, string) {
|
||||
resolvedPlan := shared.NormalizePlanCode(strings.TrimSpace(planCode))
|
||||
if resolvedPlan == "" {
|
||||
resolvedPlan = "pro"
|
||||
}
|
||||
resolvedCurrency := normalizeCurrency(currency)
|
||||
resolvedInterval := billingInterval
|
||||
if resolvedInterval == "" {
|
||||
resolvedInterval = "monthly"
|
||||
}
|
||||
|
||||
// Build the price key: plan:currency:interval (e.g., "pro:usd:monthly", "pro:usd:yearly")
|
||||
priceKey := resolvedPlan + ":" + resolvedCurrency + ":" + resolvedInterval
|
||||
if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" {
|
||||
return priceID, resolvedPlan, resolvedCurrency
|
||||
}
|
||||
|
||||
// Fall back to plan:currency format (for backwards compatibility)
|
||||
priceKey = resolvedPlan + ":" + resolvedCurrency
|
||||
if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" {
|
||||
return priceID, resolvedPlan, resolvedCurrency
|
||||
}
|
||||
|
||||
// Try just plan code with interval
|
||||
if resolvedInterval != "monthly" {
|
||||
priceKey = resolvedPlan + ":" + resolvedInterval
|
||||
if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" {
|
||||
return priceID, resolvedPlan, resolvedCurrency
|
||||
}
|
||||
}
|
||||
|
||||
// Default currency fallback
|
||||
if resolvedCurrency != "usd" {
|
||||
priceKey = resolvedPlan + ":usd:" + resolvedInterval
|
||||
if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" {
|
||||
return priceID, resolvedPlan, "usd"
|
||||
}
|
||||
}
|
||||
|
||||
return s.cfg.StripePriceMatrix[resolvedPlan][resolvedPlan+":czk"], resolvedPlan, "czk"
|
||||
}
|
||||
|
||||
func subscriptionRank(subscription *paddle.Subscription) int {
|
||||
switch subscription.Status {
|
||||
case paddle.SubscriptionStatusActive:
|
||||
@@ -447,19 +910,22 @@ func subscriptionRank(subscription *paddle.Subscription) int {
|
||||
func displayPricesForPlan(planCode string) []domain.PlanDisplayPrice {
|
||||
switch shared.NormalizePlanCode(planCode) {
|
||||
case "starter":
|
||||
// Starter: $5/month, $50/year (save $10 = ~17%)
|
||||
return []domain.PlanDisplayPrice{
|
||||
{Currency: "czk", AmountCents: 11900, Formatted: "119 Kc"},
|
||||
{Currency: "usd", AmountCents: 500, Formatted: "$5"},
|
||||
{Currency: "czk", AmountCents: 11900, Formatted: "119 Kč/mo", YearlyAmountCents: 119000, YearlyFormatted: "1 190 Kč/yr", YearlySavings: "Save 199 Kč", YearlySavingsPercent: 17},
|
||||
{Currency: "usd", AmountCents: 500, Formatted: "$5/mo", YearlyAmountCents: 5000, YearlyFormatted: "$50/yr", YearlySavings: "Save $10", YearlySavingsPercent: 17},
|
||||
}
|
||||
case "business":
|
||||
// Business: $50/month, $500/year (save $100 = ~17%)
|
||||
return []domain.PlanDisplayPrice{
|
||||
{Currency: "czk", AmountCents: 119900, Formatted: "1 199 Kc"},
|
||||
{Currency: "usd", AmountCents: 5000, Formatted: "$50"},
|
||||
{Currency: "czk", AmountCents: 119900, Formatted: "1 199 Kč/mo", YearlyAmountCents: 1199000, YearlyFormatted: "11 990 Kč/yr", YearlySavings: "Save 1 999 Kč", YearlySavingsPercent: 17},
|
||||
{Currency: "usd", AmountCents: 5000, Formatted: "$50/mo", YearlyAmountCents: 50000, YearlyFormatted: "$500/yr", YearlySavings: "Save $100", YearlySavingsPercent: 17},
|
||||
}
|
||||
default:
|
||||
// Pro: $20/month, $200/year (save $40 = ~17%)
|
||||
return []domain.PlanDisplayPrice{
|
||||
{Currency: "czk", AmountCents: 49900, Formatted: "499 Kc"},
|
||||
{Currency: "usd", AmountCents: 2000, Formatted: "$20"},
|
||||
{Currency: "czk", AmountCents: 49900, Formatted: "499 Kč/mo", YearlyAmountCents: 499000, YearlyFormatted: "4 990 Kč/yr", YearlySavings: "Save 999 Kč", YearlySavingsPercent: 17},
|
||||
{Currency: "usd", AmountCents: 2000, Formatted: "$20/mo", YearlyAmountCents: 20000, YearlyFormatted: "$200/yr", YearlySavings: "Save $40", YearlySavingsPercent: 17},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,6 +963,30 @@ func checkoutAvailable(cfg config.Config, planCode string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func billingCheckoutAvailable(cfg config.Config, planCode string) bool {
|
||||
planCode = shared.NormalizePlanCode(planCode)
|
||||
|
||||
// Prefer Stripe
|
||||
if cfg.StripeConfigured() && cfg.StripeWebhookConfigured() {
|
||||
for _, priceID := range cfg.StripePriceMatrix[planCode] {
|
||||
if strings.TrimSpace(priceID) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to Paddle
|
||||
if cfg.PaddleConfigured() && cfg.PaddleWebhookConfigured() {
|
||||
for _, priceID := range cfg.PaddlePriceMatrix[planCode] {
|
||||
if strings.TrimSpace(priceID) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func customDataString(data map[string]any, key string) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
@@ -554,3 +1044,48 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CheckAndSendTrialEndingEmails checks all tenants with trials and sends emails for those ending soon
|
||||
func (s *Service) CheckAndSendTrialEndingEmails(ctx context.Context, notificationService interface {
|
||||
SendTrialEndingEmail(ctx context.Context, tenantID string, daysRemaining int) error
|
||||
}) error {
|
||||
// Get all tenants with trial status
|
||||
tenants, _, err := s.repo.ListAllTenants(ctx, 1000, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
for _, tenant := range tenants {
|
||||
if tenant.SubscriptionStatus != "trialing" && tenant.SubscriptionStatus != "trial" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get subscription to check trial end date
|
||||
snapshot, err := s.repo.GetSubscriptionSnapshot(ctx, tenant.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate trial end: assume 15-day trial from period start
|
||||
var trialEnd time.Time
|
||||
if snapshot.CurrentPeriodStart != nil {
|
||||
trialEnd = snapshot.CurrentPeriodStart.Add(15 * 24 * time.Hour)
|
||||
} else {
|
||||
// Default to 15 days from now if no start date
|
||||
trialEnd = now.Add(15 * 24 * time.Hour)
|
||||
}
|
||||
|
||||
daysRemaining := int(trialEnd.Sub(now).Hours() / 24)
|
||||
|
||||
// Send email if trial ends in 1-3 days
|
||||
if daysRemaining >= 1 && daysRemaining <= 3 {
|
||||
if err := notificationService.SendTrialEndingEmail(ctx, tenant.ID, daysRemaining); err != nil {
|
||||
// Log but don't fail
|
||||
fmt.Printf("Failed to send trial ending email for tenant %s: %v\n", tenant.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package catalog
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
@@ -17,14 +18,25 @@ var (
|
||||
ErrInvalidBooking = errors.New("invalid booking request")
|
||||
ErrTenantNotFound = errors.New("tenant not found")
|
||||
ErrTenantMembership = errors.New("tenant membership not found")
|
||||
ErrPlanLimitReached = errors.New("plan limit reached")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo db.Repository
|
||||
repo db.Repository
|
||||
billingService interface {
|
||||
GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
|
||||
}
|
||||
notificationService interface {
|
||||
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
|
||||
}
|
||||
}
|
||||
|
||||
func NewService(repo db.Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
func NewService(repo db.Repository, billingService interface {
|
||||
GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
|
||||
}, notificationService interface {
|
||||
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
|
||||
}) *Service {
|
||||
return &Service{repo: repo, billingService: billingService, notificationService: notificationService}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -63,6 +75,18 @@ func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal
|
||||
return domain.Location{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
// Check plan entitlements for location limit
|
||||
if s.billingService != nil {
|
||||
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
|
||||
if err == nil && entitlements.MaxLocations > 0 {
|
||||
// Count existing locations
|
||||
locations, err := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
|
||||
if err == nil && len(locations) >= entitlements.MaxLocations {
|
||||
return domain.Location{}, fmt.Errorf("%w: location limit reached (%d/%d). Upgrade your plan to add more locations.", ErrPlanLimitReached, len(locations), entitlements.MaxLocations)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
params := db.CreateLocationParams{
|
||||
TenantID: membership.Tenant.ID,
|
||||
Name: req.Name,
|
||||
@@ -74,6 +98,18 @@ func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal
|
||||
return domain.Location{}, err
|
||||
}
|
||||
|
||||
// Send usage warning if at 80%+ of limit
|
||||
if s.notificationService != nil && s.billingService != nil {
|
||||
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
|
||||
if err == nil && entitlements.MaxLocations > 0 {
|
||||
locations, _ := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
|
||||
usagePercent := (len(locations) * 100) / entitlements.MaxLocations
|
||||
if usagePercent >= 80 {
|
||||
_ = s.notificationService.SendUsageWarning(ctx, membership.Tenant.ID, len(locations), entitlements.MaxLocations, usagePercent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return domain.Location{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
|
||||
@@ -28,8 +28,14 @@ type Config struct {
|
||||
PaddleAPIKey string
|
||||
PaddleWebhookKey string
|
||||
PaddlePriceMatrix map[string]map[string]string
|
||||
StripeAPIKey string
|
||||
StripeWebhookKey string
|
||||
StripePriceMatrix map[string]map[string]string
|
||||
AdminEmail string
|
||||
AdminKey string
|
||||
UmamiAPIURL string
|
||||
UmamiAPIKey string
|
||||
SentryDSN string
|
||||
DemoMode bool
|
||||
}
|
||||
|
||||
@@ -53,8 +59,14 @@ func Load() (Config, error) {
|
||||
PaddleAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_API_KEY")),
|
||||
PaddleWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_WEBHOOK_SECRET")),
|
||||
PaddlePriceMatrix: paddlePriceMatrixFromEnv(),
|
||||
StripeAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_API_KEY")),
|
||||
StripeWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_WEBHOOK_SECRET")),
|
||||
StripePriceMatrix: stripePriceMatrixFromEnv(),
|
||||
AdminEmail: strings.TrimSpace(os.Getenv("BOOKRA_ADMIN_EMAIL")),
|
||||
AdminKey: strings.TrimSpace(os.Getenv("BOOKRA_ADMIN_KEY")),
|
||||
UmamiAPIURL: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_URL")),
|
||||
UmamiAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_KEY")),
|
||||
SentryDSN: strings.TrimSpace(os.Getenv("BOOKRA_SENTRY_DSN")),
|
||||
DemoMode: boolFromEnv("BOOKRA_DEMO_MODE", false),
|
||||
}
|
||||
|
||||
@@ -118,6 +130,34 @@ func (cfg Config) PaddleCheckoutConfigured(planCode string) bool {
|
||||
return cfg.PaddleConfigured() && cfg.PaddleWebhookConfigured() && cfg.PaddlePriceMatrix[planCode]["czk"] != "" && cfg.PaddlePriceMatrix[planCode]["usd"] != ""
|
||||
}
|
||||
|
||||
func (cfg Config) StripeConfigured() bool {
|
||||
return strings.TrimSpace(cfg.StripeAPIKey) != ""
|
||||
}
|
||||
|
||||
func (cfg Config) StripeWebhookConfigured() bool {
|
||||
return strings.TrimSpace(cfg.StripeWebhookKey) != ""
|
||||
}
|
||||
|
||||
func (cfg Config) StripeCheckoutConfigured(planCode string) bool {
|
||||
planCode = shared.NormalizePlanCode(planCode)
|
||||
return cfg.StripeConfigured() && cfg.StripeWebhookConfigured() && cfg.StripePriceMatrix[planCode]["czk"] != "" && cfg.StripePriceMatrix[planCode]["usd"] != ""
|
||||
}
|
||||
|
||||
func (cfg Config) BillingProvider() string {
|
||||
if cfg.StripeConfigured() {
|
||||
return "stripe"
|
||||
}
|
||||
return "paddle"
|
||||
}
|
||||
|
||||
func (cfg Config) BillingConfigured() bool {
|
||||
return cfg.StripeConfigured() || cfg.PaddleConfigured()
|
||||
}
|
||||
|
||||
func (cfg Config) BillingWebhookConfigured() bool {
|
||||
return cfg.StripeWebhookConfigured() || cfg.PaddleWebhookConfigured()
|
||||
}
|
||||
|
||||
func paddlePriceMatrixFromEnv() map[string]map[string]string {
|
||||
matrix := map[string]map[string]string{
|
||||
"starter": {},
|
||||
@@ -132,6 +172,32 @@ func paddlePriceMatrixFromEnv() map[string]map[string]string {
|
||||
return matrix
|
||||
}
|
||||
|
||||
func stripePriceMatrixFromEnv() map[string]map[string]string {
|
||||
matrix := map[string]map[string]string{
|
||||
"starter": {},
|
||||
"pro": {},
|
||||
"business": {},
|
||||
}
|
||||
for _, planCode := range []string{"starter", "pro", "business"} {
|
||||
envPlan := strings.ToUpper(strings.ReplaceAll(planCode, "-", "_"))
|
||||
|
||||
// Monthly prices
|
||||
matrix[planCode][planCode+":czk:monthly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_MONTHLY_PRICE_ID"))
|
||||
matrix[planCode][planCode+":usd:monthly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_MONTHLY_PRICE_ID"))
|
||||
matrix[planCode][planCode+":czk"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_PRICE_ID"))
|
||||
matrix[planCode][planCode+":usd"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_PRICE_ID"))
|
||||
matrix[planCode]["czk"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_PRICE_ID"))
|
||||
matrix[planCode]["usd"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_PRICE_ID"))
|
||||
|
||||
// Yearly prices
|
||||
matrix[planCode][planCode+":czk:yearly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_YEARLY_PRICE_ID"))
|
||||
matrix[planCode][planCode+":usd:yearly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_YEARLY_PRICE_ID"))
|
||||
matrix[planCode]["yearly:czk"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_YEARLY_PRICE_ID"))
|
||||
matrix[planCode]["yearly:usd"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_YEARLY_PRICE_ID"))
|
||||
}
|
||||
return matrix
|
||||
}
|
||||
|
||||
func normalizePaddleEnvironment(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "live", "production":
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
func (r *PGRepository) ListAllTenants(ctx context.Context, limit, offset int) ([]TenantRecord, int, error) {
|
||||
var total int
|
||||
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants`).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status,
|
||||
COALESCE(billing_provider, 'stripe'), billing_customer_id, billing_subscription_id
|
||||
FROM tenants
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tenants []TenantRecord
|
||||
for rows.Next() {
|
||||
var t TenantRecord
|
||||
if err := rows.Scan(
|
||||
&t.ID, &t.Slug, &t.Name, &t.Preset, &t.Locale, &t.Timezone,
|
||||
&t.PlanCode, &t.SubscriptionStatus, &t.BillingProvider,
|
||||
&t.BillingCustomerID, &t.BillingSubscription,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
tenants = append(tenants, t)
|
||||
}
|
||||
|
||||
return tenants, total, nil
|
||||
}
|
||||
|
||||
func (r *PGRepository) ListAllUsers(ctx context.Context, limit, offset int) ([]UserRecord, int, error) {
|
||||
var total int
|
||||
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, email, name, email_verified, provider, role, created_at, last_login_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []UserRecord
|
||||
for rows.Next() {
|
||||
var u UserRecord
|
||||
if err := rows.Scan(
|
||||
&u.ID, &u.Email, &u.Name, &u.EmailVerified, &u.Provider, &u.Role,
|
||||
&u.CreatedAt, &u.LastLoginAt,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
func (r *PGRepository) GetPlatformStats(ctx context.Context) (PlatformStats, error) {
|
||||
var stats PlatformStats
|
||||
|
||||
// Total tenants
|
||||
r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants`).Scan(&stats.TotalTenants)
|
||||
|
||||
// Total users
|
||||
r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers)
|
||||
|
||||
// Active subscriptions
|
||||
r.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM billing_snapshots
|
||||
WHERE status IN ('active', 'trialing')
|
||||
`).Scan(&stats.ActiveSubscriptions)
|
||||
|
||||
// Trial subscriptions
|
||||
r.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM billing_snapshots
|
||||
WHERE status = 'trialing'
|
||||
`).Scan(&stats.TrialSubscriptions)
|
||||
|
||||
// Bookings this month
|
||||
r.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM bookings
|
||||
WHERE created_at >= date_trunc('month', CURRENT_DATE)
|
||||
`).Scan(&stats.BookingsThisMonth)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (r *PGRepository) CreateAdminAuditLog(ctx context.Context, params AdminAuditLogParams) error {
|
||||
var detailsJSON []byte
|
||||
var err error
|
||||
if params.Details != nil {
|
||||
detailsJSON, err = json.Marshal(params.Details)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = r.pool.Exec(ctx, `
|
||||
INSERT INTO admin_audit_log (admin_user_id, action, resource_type, resource_id, details, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`, nullableUUID(params.AdminUserID), params.Action, params.ResourceType, params.ResourceID, detailsJSON, params.IPAddress, params.UserAgent)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) UpdateUserRole(ctx context.Context, userID, role string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2
|
||||
`, role, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func nullableUUID(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (r *PGRepository) GetUserByEmail(ctx context.Context, email string) (*UserRecord, error) {
|
||||
var user UserRecord
|
||||
var name, passwordHash *string
|
||||
var lastLoginAt *time.Time
|
||||
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
|
||||
FROM users
|
||||
WHERE email = $1
|
||||
`, email).Scan(
|
||||
&user.ID, &user.Email, &name, &passwordHash,
|
||||
&user.EmailVerified, &user.Provider, &user.Role,
|
||||
&user.CreatedAt, &lastLoginAt,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user.Name = name
|
||||
user.PasswordHash = passwordHash
|
||||
user.LastLoginAt = lastLoginAt
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *PGRepository) GetUserByID(ctx context.Context, userID string) (*UserRecord, error) {
|
||||
var user UserRecord
|
||||
var name, passwordHash *string
|
||||
var lastLoginAt *time.Time
|
||||
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, userID).Scan(
|
||||
&user.ID, &user.Email, &name, &passwordHash,
|
||||
&user.EmailVerified, &user.Provider, &user.Role,
|
||||
&user.CreatedAt, &lastLoginAt,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user.Name = name
|
||||
user.PasswordHash = passwordHash
|
||||
user.LastLoginAt = lastLoginAt
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *PGRepository) CreateUser(ctx context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error) {
|
||||
var user UserRecord
|
||||
var lastLoginAt *time.Time
|
||||
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO users (email, password_hash, name, provider, role, email_verified)
|
||||
VALUES ($1, $2, $3, $4, $5, false)
|
||||
RETURNING id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
|
||||
`, email, nullableString(passwordHash), nullableString(name), provider, role).Scan(
|
||||
&user.ID, &user.Email, &user.Name, &user.PasswordHash,
|
||||
&user.EmailVerified, &user.Provider, &user.Role,
|
||||
&user.CreatedAt, &lastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user.LastLoginAt = lastLoginAt
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *PGRepository) UpdateLastLogin(ctx context.Context, userID string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE users SET last_login_at = NOW() WHERE id = $1
|
||||
`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) MarkEmailVerified(ctx context.Context, userID string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE users SET email_verified = true WHERE id = $1
|
||||
`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) CreateMagicLink(ctx context.Context, token, userID, email string, expiresAt time.Time) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO magic_links (token, user_id, email, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, token, userID, email, expiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) GetMagicLink(ctx context.Context, token string) (*MagicLinkRecord, error) {
|
||||
var ml MagicLinkRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT token, user_id, email, used, expires_at, created_at
|
||||
FROM magic_links
|
||||
WHERE token = $1
|
||||
`, token).Scan(
|
||||
&ml.Token, &ml.UserID, &ml.Email, &ml.Used, &ml.ExpiresAt, &ml.CreatedAt,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ml, nil
|
||||
}
|
||||
|
||||
func (r *PGRepository) MarkMagicLinkUsed(ctx context.Context, token string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE magic_links SET used = true WHERE token = $1
|
||||
`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
func nullableString(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -38,6 +38,23 @@ type Repository interface {
|
||||
UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error
|
||||
RecordBillingEvent(ctx context.Context, tenantID string, provider string, eventID string, eventType string, payload []byte) (bool, error)
|
||||
|
||||
// Auth methods
|
||||
GetUserByEmail(ctx context.Context, email string) (*UserRecord, error)
|
||||
GetUserByID(ctx context.Context, userID string) (*UserRecord, error)
|
||||
CreateUser(ctx context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error)
|
||||
UpdateLastLogin(ctx context.Context, userID string) error
|
||||
MarkEmailVerified(ctx context.Context, userID string) error
|
||||
CreateMagicLink(ctx context.Context, token, userID, email string, expiresAt time.Time) error
|
||||
GetMagicLink(ctx context.Context, token string) (*MagicLinkRecord, error)
|
||||
MarkMagicLinkUsed(ctx context.Context, token string) error
|
||||
|
||||
// Admin methods
|
||||
ListAllTenants(ctx context.Context, limit, offset int) ([]TenantRecord, int, error)
|
||||
ListAllUsers(ctx context.Context, limit, offset int) ([]UserRecord, int, error)
|
||||
GetPlatformStats(ctx context.Context) (PlatformStats, error)
|
||||
CreateAdminAuditLog(ctx context.Context, params AdminAuditLogParams) error
|
||||
UpdateUserRole(ctx context.Context, userID, role string) error
|
||||
|
||||
// Location / Zone Management
|
||||
ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error)
|
||||
GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error)
|
||||
@@ -85,6 +102,46 @@ type TenantRecord struct {
|
||||
BillingSubscription *string
|
||||
}
|
||||
|
||||
type UserRecord struct {
|
||||
ID uuid.UUID
|
||||
Email string
|
||||
Name *string
|
||||
PasswordHash *string
|
||||
EmailVerified bool
|
||||
Provider string
|
||||
Role string
|
||||
CreatedAt time.Time
|
||||
LastLoginAt *time.Time
|
||||
}
|
||||
|
||||
type MagicLinkRecord struct {
|
||||
Token string
|
||||
UserID uuid.UUID
|
||||
Email string
|
||||
Used bool
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type PlatformStats struct {
|
||||
TotalTenants int64 `json:"totalTenants"`
|
||||
TotalUsers int64 `json:"totalUsers"`
|
||||
ActiveSubscriptions int64 `json:"activeSubscriptions"`
|
||||
TrialSubscriptions int64 `json:"trialSubscriptions"`
|
||||
BookingsThisMonth int64 `json:"bookingsThisMonth"`
|
||||
RevenueThisMonth int64 `json:"revenueThisMonthCents"`
|
||||
}
|
||||
|
||||
type AdminAuditLogParams struct {
|
||||
AdminUserID string
|
||||
Action string
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
Details map[string]any
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
type TenantMembershipRecord struct {
|
||||
Tenant TenantRecord
|
||||
UserID string
|
||||
@@ -1303,6 +1360,60 @@ func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
|
||||
// Auth methods for MemoryRepository
|
||||
func (r *MemoryRepository) GetUserByEmail(_ context.Context, email string) (*UserRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) GetUserByID(_ context.Context, userID string) (*UserRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) CreateUser(_ context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error) {
|
||||
return &UserRecord{ID: uuid.New(), Email: email, Name: &name, Provider: provider, Role: role}, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) UpdateLastLogin(_ context.Context, userID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) MarkEmailVerified(_ context.Context, userID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) CreateMagicLink(_ context.Context, token, userID, email string, expiresAt time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) GetMagicLink(_ context.Context, token string) (*MagicLinkRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) MarkMagicLinkUsed(_ context.Context, token string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Admin methods for MemoryRepository
|
||||
func (r *MemoryRepository) ListAllTenants(_ context.Context, limit, offset int) ([]TenantRecord, int, error) {
|
||||
return []TenantRecord{r.tenant}, 1, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) ListAllUsers(_ context.Context, limit, offset int) ([]UserRecord, int, error) {
|
||||
return []UserRecord{}, 0, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) GetPlatformStats(_ context.Context) (PlatformStats, error) {
|
||||
return PlatformStats{TotalTenants: 1, TotalUsers: 1, ActiveSubscriptions: 1}, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) CreateAdminAuditLog(_ context.Context, params AdminAuditLogParams) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) UpdateUserRole(_ context.Context, userID, role string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func Reference(prefix string, at time.Time) string {
|
||||
return fmt.Sprintf("%s-%s-%s", prefix, at.UTC().Format("20060102150405"), strings.Split(uuid.NewString(), "-")[0])
|
||||
}
|
||||
|
||||
@@ -146,16 +146,32 @@ type CreateBookingResponse struct {
|
||||
type PlanEntitlements struct {
|
||||
MaxLocations int `json:"maxLocations"`
|
||||
MaxStaff int `json:"maxStaff"`
|
||||
MaxBookingsMonth int `json:"maxBookingsMonth"` // -1 = unlimited
|
||||
EmailReminders bool `json:"emailReminders"`
|
||||
AdvancedReporting bool `json:"advancedReporting"`
|
||||
WidgetEmbedding bool `json:"widgetEmbedding"`
|
||||
UmamiTracking bool `json:"umamiTracking"`
|
||||
APIAccess bool `json:"apiAccess"`
|
||||
DedicatedManager bool `json:"dedicatedManager"`
|
||||
}
|
||||
|
||||
type PlanPricing struct {
|
||||
MonthlyAmountCents int `json:"monthlyAmountCents"`
|
||||
YearlyAmountCents int `json:"yearlyAmountCents"`
|
||||
MonthlyFormatted string `json:"monthlyFormatted"`
|
||||
YearlyFormatted string `json:"yearlyFormatted"`
|
||||
YearlySavings string `json:"yearlySavings"`
|
||||
YearlySavingsPercent int `json:"yearlySavingsPercent"`
|
||||
}
|
||||
|
||||
type PlanDisplayPrice struct {
|
||||
Currency string `json:"currency"`
|
||||
AmountCents int `json:"amountCents"`
|
||||
Formatted string `json:"formatted"`
|
||||
Currency string `json:"currency"`
|
||||
AmountCents int `json:"amountCents"`
|
||||
Formatted string `json:"formatted"`
|
||||
YearlyAmountCents int `json:"yearlyAmountCents,omitempty"`
|
||||
YearlyFormatted string `json:"yearlyFormatted,omitempty"`
|
||||
YearlySavings string `json:"yearlySavings,omitempty"`
|
||||
YearlySavingsPercent int `json:"yearlySavingsPercent,omitempty"`
|
||||
}
|
||||
|
||||
type SubscriptionSnapshot struct {
|
||||
@@ -182,17 +198,22 @@ type SubscriptionSnapshot struct {
|
||||
}
|
||||
|
||||
type CheckoutSessionRequest struct {
|
||||
PlanCode string `json:"planCode"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
PlanCode string `json:"planCode"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
BillingInterval string `json:"billingInterval,omitempty"` // "monthly" or "yearly", defaults to "monthly"
|
||||
}
|
||||
|
||||
type CheckoutLaunchResponse struct {
|
||||
PriceID string `json:"priceId"`
|
||||
// Stripe checkout
|
||||
CheckoutURL string `json:"checkoutUrl,omitempty"`
|
||||
// Paddle checkout
|
||||
PriceID string `json:"priceId,omitempty"`
|
||||
CustomerID string `json:"customerId,omitempty"`
|
||||
CustomerEmail string `json:"customerEmail,omitempty"`
|
||||
SuccessRedirectURL string `json:"successRedirectUrl"`
|
||||
CancelRedirectURL string `json:"cancelRedirectUrl"`
|
||||
CustomData map[string]string `json:"customData"`
|
||||
// Common
|
||||
SuccessRedirectURL string `json:"successRedirectUrl,omitempty"`
|
||||
CancelRedirectURL string `json:"cancelRedirectUrl,omitempty"`
|
||||
CustomData map[string]string `json:"customData,omitempty"`
|
||||
}
|
||||
|
||||
type PortalSessionResponse struct {
|
||||
@@ -321,6 +342,61 @@ type CancelBookingRequest struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ADMIN MODELS
|
||||
// ============================================
|
||||
|
||||
type AdminDashboardStats struct {
|
||||
TotalTenants int64 `json:"totalTenants"`
|
||||
TotalUsers int64 `json:"totalUsers"`
|
||||
ActiveSubscriptions int64 `json:"activeSubscriptions"`
|
||||
TrialSubscriptions int64 `json:"trialSubscriptions"`
|
||||
BookingsThisMonth int64 `json:"bookingsThisMonth"`
|
||||
RevenueThisMonthCents int64 `json:"revenueThisMonthCents"`
|
||||
}
|
||||
|
||||
type AdminTenantList struct {
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Tenants []AdminTenant `json:"tenants"`
|
||||
}
|
||||
|
||||
type AdminTenant struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
PlanCode string `json:"planCode"`
|
||||
SubscriptionStatus string `json:"subscriptionStatus"`
|
||||
BillingProvider string `json:"billingProvider"`
|
||||
}
|
||||
|
||||
type AdminUserList struct {
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Users []AdminUser `json:"users"`
|
||||
}
|
||||
|
||||
type AdminUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name,omitempty"`
|
||||
EmailVerified bool `json:"emailVerified"`
|
||||
Provider string `json:"provider"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type AdminLoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Key string `json:"key" binding:"required"`
|
||||
}
|
||||
|
||||
type UpdateUserRoleRequest struct {
|
||||
Role string `json:"role" binding:"required,oneof=user admin superadmin"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WORKING HOURS MODELS
|
||||
// ============================================
|
||||
|
||||
@@ -10,40 +10,146 @@ import (
|
||||
type EmailType string
|
||||
|
||||
const (
|
||||
EmailTypeConfirmation EmailType = "confirmation"
|
||||
EmailTypeConfirmation EmailType = "confirmation"
|
||||
EmailTypeReminder EmailType = "reminder"
|
||||
EmailTypeReschedule EmailType = "reschedule"
|
||||
EmailTypeCancellation EmailType = "cancellation"
|
||||
EmailTypeBusinessNotify EmailType = "business_notify"
|
||||
EmailTypeUsageWarning EmailType = "usage_warning"
|
||||
EmailTypeTrialEnding EmailType = "trial_ending"
|
||||
)
|
||||
|
||||
type BookingEmailData struct {
|
||||
Type EmailType
|
||||
TenantName string
|
||||
TenantSlug string
|
||||
BusinessEmail string
|
||||
BusinessPhone string
|
||||
BusinessAddress string
|
||||
BrandColor string
|
||||
CustomerName string
|
||||
CustomerEmail string
|
||||
Service string
|
||||
Location string
|
||||
Reference string
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
Timezone string
|
||||
Locale string
|
||||
Notes string
|
||||
ManagementURL string
|
||||
AddToCalendarURL string
|
||||
}
|
||||
|
||||
type UsageNotificationData struct {
|
||||
Type EmailType
|
||||
TenantName string
|
||||
TenantSlug string
|
||||
BusinessEmail string
|
||||
BusinessPhone string
|
||||
BusinessAddress string
|
||||
BrandColor string
|
||||
CustomerName string
|
||||
CustomerEmail string
|
||||
Service string
|
||||
Location string
|
||||
Reference string
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
Timezone string
|
||||
AdminEmail string
|
||||
Locale string
|
||||
Notes string
|
||||
ManagementURL string
|
||||
AddToCalendarURL string
|
||||
PlanCode string
|
||||
LocationCount int
|
||||
LocationLimit int
|
||||
UsagePercent int
|
||||
UpgradeURL string
|
||||
DashboardURL string
|
||||
}
|
||||
|
||||
func RenderUsageNotificationEmail(data UsageNotificationData) EmailMessage {
|
||||
subject := renderUsageSubject(data)
|
||||
htmlBody := renderUsageHTML(data)
|
||||
textBody := renderUsageText(data)
|
||||
|
||||
return EmailMessage{
|
||||
From: data.BusinessEmail,
|
||||
To: data.AdminEmail,
|
||||
Subject: subject,
|
||||
Text: textBody,
|
||||
HTML: htmlBody,
|
||||
}
|
||||
}
|
||||
|
||||
func renderUsageSubject(data UsageNotificationData) string {
|
||||
if data.Locale == "cs" {
|
||||
switch data.Type {
|
||||
case EmailTypeUsageWarning:
|
||||
return "⚠️ Blížíte se limitu lokací - Upgrade na vyšší plán"
|
||||
case EmailTypeTrialEnding:
|
||||
return "⏰ Vaše zkušební období končí - Pokračujte s Bookra"
|
||||
}
|
||||
}
|
||||
switch data.Type {
|
||||
case EmailTypeUsageWarning:
|
||||
return "⚠️ You're nearing your location limit - Upgrade your plan"
|
||||
case EmailTypeTrialEnding:
|
||||
return "⏰ Your trial period is ending - Continue with Bookra"
|
||||
}
|
||||
return "Bookra notification"
|
||||
}
|
||||
|
||||
func renderUsageHTML(data UsageNotificationData) string {
|
||||
cs := data.Locale == "cs"
|
||||
upgradeBtn := `<a href="` + data.UpgradeURL + `" style="display:inline-block;background:#4f46e5;color:#fff;padding:12px 24px;text-decoration:none;border-radius:8px;font-weight:600;margin:8px 0;">` + map[bool]string{true: "Upgradeovat", false: "Upgrade"}[cs] + `</a>`
|
||||
dashboardBtn := `<a href="` + data.DashboardURL + `" style="display:inline-block;background:#f3f4f6;color:#374151;padding:12px 24px;text-decoration:none;border-radius:8px;font-weight:600;margin:8px 0;">` + map[bool]string{true: "Otevřít dashboard", false: "Open dashboard"}[cs] + `</a>`
|
||||
|
||||
if data.Type == EmailTypeUsageWarning {
|
||||
var msg string
|
||||
if cs {
|
||||
msg = fmt.Sprintf("Váš plán %s umožňuje pouze %d lokací. Aktuálně používáte %d (%d%%).", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent)
|
||||
} else {
|
||||
msg = fmt.Sprintf("Your %s plan allows only %d locations. You're currently using %d (%d%%).", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent)
|
||||
}
|
||||
return `<!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;">` + map[bool]string{true: "Blížíte se limitu lokací", false: "You're nearing your location limit"}[cs] + `</h2>
|
||||
<p style="color:#6b7280;text-align:center;margin-bottom:24px;">` + msg + `</p>
|
||||
<div style="text-align:center;margin-bottom:24px;">` + upgradeBtn + `</div>
|
||||
<p style="color:#9ca3af;font-size:14px;text-align:center;">` + map[bool]string{true: "Přidejte další lokace s vyšším plánem", false: "Add more locations with a higher plan"}[cs] + `</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>`
|
||||
}
|
||||
|
||||
// Trial ending email
|
||||
return `<!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;">` + map[bool]string{true: "Děkujeme, že používáte Bookra!", false: "Thank you for using Bookra!"}[cs] + `</h2>
|
||||
<p style="color:#6b7280;text-align:center;margin-bottom:24px;">` + map[bool]string{true: "Vaše zkušební období brzy končí. Pokud se vám naše služba líbí, můžete pokračovat s vybraným plánem.", false: "Your trial period is ending soon. If you like our service, you can continue with your chosen plan."}[cs] + `</p>
|
||||
<div style="text-align:center;margin-bottom:24px;">` + upgradeBtn + `</div>
|
||||
<p style="color:#6b7280;text-align:center;margin-bottom:16px;">` + map[bool]string{true: "Pokud se vám služba nelíbí, můžete ji kdykoliv zrušit. Nechceme vám brát peníze, pokud nejste spokojeni.", false: "If you don't like our service, you can cancel anytime. We don't want to take your money if you're not happy."}[cs] + `</p>
|
||||
<p style="color:#9ca3af;text-align:center;margin-bottom:24px;">` + map[bool]string{true: "Zrušit můžete zde:", false: "Cancel here:"}[cs] + ` ` + dashboardBtn + `</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>`
|
||||
}
|
||||
|
||||
func renderUsageText(data UsageNotificationData) string {
|
||||
cs := data.Locale == "cs"
|
||||
if data.Type == EmailTypeUsageWarning {
|
||||
if cs {
|
||||
return fmt.Sprintf("Blížíte se limitu lokací! Váš plán %s umožňuje pouze %d lokací. Aktuálně používáte %d (%d%%). Upgradeujte na vyšší plán: %s", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent, data.UpgradeURL)
|
||||
}
|
||||
return fmt.Sprintf("You're nearing your location limit! Your %s plan allows only %d locations. You're currently using %d (%d%%). Upgrade: %s", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent, data.UpgradeURL)
|
||||
}
|
||||
if cs {
|
||||
return "Vaše zkušební období končí. Pokud se vám služba líbí, můžete pokračovat. Pokud ne, můžete zrušit. Nechceme vám brát peníze, pokud nejste spokojeni. Dashboard: " + data.DashboardURL
|
||||
}
|
||||
return "Your trial period is ending. If you like our service, you can continue. If not, you can cancel - we don't want your money if you're not happy. Dashboard: " + data.DashboardURL
|
||||
}
|
||||
|
||||
func RenderEmailMessage(data BookingEmailData) EmailMessage {
|
||||
subject := renderSubject(data)
|
||||
htmlBody := renderHTMLBody(data)
|
||||
textBody := renderTextBody(data)
|
||||
|
||||
|
||||
return EmailMessage{
|
||||
From: data.BusinessEmail,
|
||||
To: data.CustomerEmail,
|
||||
@@ -55,7 +161,7 @@ func RenderEmailMessage(data BookingEmailData) EmailMessage {
|
||||
|
||||
func renderSubject(data BookingEmailData) string {
|
||||
localizedTime := formatLocalizedTime(data.StartsAt, data.Timezone, data.Locale)
|
||||
|
||||
|
||||
switch data.Type {
|
||||
case EmailTypeConfirmation:
|
||||
if data.Locale == "cs" {
|
||||
@@ -89,7 +195,7 @@ func renderSubject(data BookingEmailData) string {
|
||||
|
||||
func renderTextBody(data BookingEmailData) string {
|
||||
localizedTime := formatLocalizedDateTime(data.StartsAt, data.Timezone, data.Locale)
|
||||
|
||||
|
||||
switch data.Type {
|
||||
case EmailTypeConfirmation:
|
||||
if data.Locale == "cs" {
|
||||
@@ -124,7 +230,7 @@ Manage your booking at: %s
|
||||
Thank you,
|
||||
%s
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName, data.BusinessEmail)
|
||||
|
||||
|
||||
case EmailTypeReminder:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf(`Dobrý den %s,
|
||||
@@ -152,7 +258,7 @@ This is a reminder for your booking tomorrow.
|
||||
Manage booking: %s
|
||||
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
|
||||
|
||||
|
||||
case EmailTypeReschedule:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf(`Dobrý den %s,
|
||||
@@ -182,7 +288,7 @@ New details:
|
||||
Manage booking: %s
|
||||
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
|
||||
|
||||
|
||||
case EmailTypeCancellation:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf(`Dobrý den %s,
|
||||
@@ -210,7 +316,7 @@ Cancelled booking:
|
||||
If you didn't cancel this, please contact us: %s
|
||||
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Reference, data.BusinessEmail, data.TenantName)
|
||||
|
||||
|
||||
case EmailTypeBusinessNotify:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf(`Nová rezervace od %s
|
||||
@@ -232,7 +338,7 @@ Details:
|
||||
- Email: %s
|
||||
|
||||
Manage in dashboard: https://bookra.eu/dashboard`, data.CustomerName, data.Service, localizedTime, data.Reference, data.CustomerEmail)
|
||||
|
||||
|
||||
default:
|
||||
return "Booking update"
|
||||
}
|
||||
@@ -245,7 +351,7 @@ func renderHTMLBody(data BookingEmailData) string {
|
||||
html := "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
|
||||
html += "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
|
||||
html += fmt.Sprintf("<h2 style='color: %s; margin-bottom: 20px;'>%s</h2>", data.BrandColor, data.TenantName)
|
||||
|
||||
|
||||
// Convert text to simple HTML
|
||||
paragraphs := splitParagraphs(textBody)
|
||||
for _, p := range paragraphs {
|
||||
@@ -253,12 +359,12 @@ func renderHTMLBody(data BookingEmailData) string {
|
||||
html += fmt.Sprintf("<p style='margin-bottom: 10px;'>%s</p>", p)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add management button
|
||||
if data.ManagementURL != "" {
|
||||
html += fmt.Sprintf("<div style='margin-top: 30px;'><a href='%s' style='display: inline-block; background: %s; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;'>Manage Booking</a></div>", data.ManagementURL, data.BrandColor)
|
||||
}
|
||||
|
||||
|
||||
html += "</div></body></html>"
|
||||
return html
|
||||
}
|
||||
@@ -347,7 +453,7 @@ func RenderReminderEmail(from string, job db.ReminderJobRecord) EmailMessage {
|
||||
StartsAt: job.StartsAt,
|
||||
Timezone: job.Timezone,
|
||||
Locale: job.Locale,
|
||||
Service: "Service", // Legacy
|
||||
Service: "Service", // Legacy
|
||||
Location: "Location", // Legacy
|
||||
}
|
||||
return RenderEmailMessage(data)
|
||||
|
||||
@@ -285,3 +285,77 @@ func (p smtpEmailProvider) Send(_ context.Context, message EmailMessage) (Delive
|
||||
ExternalID: fmt.Sprintf("smtp-%d", time.Now().UnixNano()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SendContactEmail sends a contact form submission to the business email
|
||||
func (s *Service) SendContactEmail(ctx context.Context, name, email, message string) error {
|
||||
subject := fmt.Sprintf("Bookra Contact: Message from %s", name)
|
||||
text := fmt.Sprintf("Name: %s\nEmail: %s\n\nMessage:\n%s", name, email, message)
|
||||
html := fmt.Sprintf(
|
||||
"<h2>New contact form submission</h2><p><strong>Name:</strong> %s</p><p><strong>Email:</strong> %s</p><p><strong>Message:</strong></p><p>%s</p>",
|
||||
name, email, message,
|
||||
)
|
||||
msg := EmailMessage{
|
||||
From: s.cfg.EmailFrom,
|
||||
To: s.cfg.EmailFrom,
|
||||
Subject: subject,
|
||||
Text: text,
|
||||
HTML: html,
|
||||
}
|
||||
_, err := s.emailProvider.Send(ctx, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error {
|
||||
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tenant: %w", err)
|
||||
}
|
||||
|
||||
// Use a placeholder admin email - in production, would get from tenant owner
|
||||
adminEmail := "admin@" + tenant.Slug + ".bookra.eu"
|
||||
|
||||
emailData := UsageNotificationData{
|
||||
Type: EmailTypeUsageWarning,
|
||||
TenantName: tenant.Name,
|
||||
TenantSlug: tenant.Slug,
|
||||
BusinessEmail: s.cfg.EmailFrom,
|
||||
AdminEmail: adminEmail,
|
||||
Locale: tenant.Locale,
|
||||
PlanCode: tenant.PlanCode,
|
||||
LocationCount: locationCount,
|
||||
LocationLimit: locationLimit,
|
||||
UsagePercent: usagePercent,
|
||||
UpgradeURL: "https://bookra.eu/pricing",
|
||||
DashboardURL: "https://bookra.eu/dashboard",
|
||||
}
|
||||
|
||||
msg := RenderUsageNotificationEmail(emailData)
|
||||
_, err = s.emailProvider.Send(ctx, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) SendTrialEndingEmail(ctx context.Context, tenantID string, daysRemaining int) error {
|
||||
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tenant: %w", err)
|
||||
}
|
||||
|
||||
// Use a placeholder admin email - in production, would get from tenant owner
|
||||
adminEmail := "admin@" + tenant.Slug + ".bookra.eu"
|
||||
|
||||
emailData := UsageNotificationData{
|
||||
Type: EmailTypeTrialEnding,
|
||||
TenantName: tenant.Name,
|
||||
TenantSlug: tenant.Slug,
|
||||
BusinessEmail: s.cfg.EmailFrom,
|
||||
AdminEmail: adminEmail,
|
||||
Locale: tenant.Locale,
|
||||
PlanCode: tenant.PlanCode,
|
||||
UpgradeURL: "https://bookra.eu/pricing",
|
||||
DashboardURL: "https://bookra.eu/dashboard",
|
||||
}
|
||||
|
||||
msg := RenderUsageNotificationEmail(emailData)
|
||||
_, err = s.emailProvider.Send(ctx, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Users table for authentication (migrated from auth-service)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
name VARCHAR(255),
|
||||
password_hash VARCHAR(255),
|
||||
email_verified BOOLEAN DEFAULT FALSE,
|
||||
provider VARCHAR(50) NOT NULL DEFAULT 'email',
|
||||
provider_id VARCHAR(255),
|
||||
role VARCHAR(50) NOT NULL DEFAULT 'user',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
last_login_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_provider ON users(provider, provider_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||
|
||||
-- Magic links for passwordless auth
|
||||
CREATE TABLE IF NOT EXISTS magic_links (
|
||||
token VARCHAR(255) PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
used BOOLEAN DEFAULT FALSE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_magic_links_user_id ON magic_links(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_magic_links_expires ON magic_links(expires_at) WHERE used = FALSE;
|
||||
|
||||
-- Password reset tokens
|
||||
CREATE TABLE IF NOT EXISTS password_resets (
|
||||
token VARCHAR(255) PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
used BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_password_resets_user_id ON password_resets(user_id);
|
||||
|
||||
-- OAuth state tokens
|
||||
CREATE TABLE IF NOT EXISTS oauth_states (
|
||||
state VARCHAR(255) PRIMARY KEY,
|
||||
redirect_url VARCHAR(500),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
-- Admin audit log
|
||||
CREATE TABLE IF NOT EXISTS admin_audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
admin_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
resource_type VARCHAR(100),
|
||||
resource_id VARCHAR(255),
|
||||
details JSONB,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_admin ON admin_audit_log(admin_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_action ON admin_audit_log(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_created ON admin_audit_log(created_at);
|
||||
|
||||
-- Refresh tokens for JWT
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
revoked BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
DROP TABLE IF EXISTS refresh_tokens;
|
||||
DROP TABLE IF EXISTS admin_audit_log;
|
||||
DROP TABLE IF EXISTS oauth_states;
|
||||
DROP TABLE IF EXISTS password_resets;
|
||||
DROP TABLE IF EXISTS magic_links;
|
||||
DROP TABLE IF EXISTS users;
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -639,9 +639,16 @@ components:
|
||||
planCode:
|
||||
type: string
|
||||
enum: [starter, pro, business]
|
||||
description: The plan to subscribe to
|
||||
currency:
|
||||
type: string
|
||||
enum: [czk, usd]
|
||||
description: Currency for the subscription
|
||||
billingInterval:
|
||||
type: string
|
||||
enum: [monthly, yearly]
|
||||
default: monthly
|
||||
description: Billing interval. Yearly gets 17% discount.
|
||||
PlanDisplayPrice:
|
||||
type: object
|
||||
required: [currency, amountCents, formatted]
|
||||
@@ -651,29 +658,56 @@ components:
|
||||
enum: [czk, usd]
|
||||
amountCents:
|
||||
type: integer
|
||||
description: Monthly price in cents
|
||||
formatted:
|
||||
type: string
|
||||
description: Formatted monthly price string
|
||||
yearlyAmountCents:
|
||||
type: integer
|
||||
description: Yearly price in cents (17% discount)
|
||||
yearlyFormatted:
|
||||
type: string
|
||||
description: Formatted yearly price string
|
||||
yearlySavings:
|
||||
type: string
|
||||
description: Description of yearly savings
|
||||
yearlySavingsPercent:
|
||||
type: integer
|
||||
description: Percentage saved with yearly billing
|
||||
CheckoutLaunchResponse:
|
||||
type: object
|
||||
required: [priceId, successRedirectUrl, cancelRedirectUrl, customData]
|
||||
description: |
|
||||
Checkout launch response supporting both Stripe and Paddle providers.
|
||||
For Stripe: checkoutUrl is returned (redirect-based checkout).
|
||||
For Paddle: priceId, customerId, customerEmail, customData are returned (client-side checkout).
|
||||
properties:
|
||||
checkoutUrl:
|
||||
type: string
|
||||
format: uri
|
||||
description: Stripe checkout URL (redirect the user to this URL)
|
||||
priceId:
|
||||
type: string
|
||||
description: Paddle price ID for client-side checkout
|
||||
customerId:
|
||||
type: string
|
||||
description: Paddle customer ID
|
||||
customerEmail:
|
||||
type: string
|
||||
format: email
|
||||
description: Customer email for Paddle checkout
|
||||
successRedirectUrl:
|
||||
type: string
|
||||
format: uri
|
||||
description: URL to redirect after successful checkout
|
||||
cancelRedirectUrl:
|
||||
type: string
|
||||
format: uri
|
||||
description: URL to redirect after cancelled checkout
|
||||
customData:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Custom metadata for Paddle checkout
|
||||
PortalSessionResponse:
|
||||
type: object
|
||||
required: [url]
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"@bookra/shared-types": "0.1.0",
|
||||
"@neondatabase/neon-js": "^0.2.0-beta.1",
|
||||
"@paddle/paddle-js": "^1.3.2",
|
||||
"@sentry/react": "^10.52.0",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"@stripe/stripe-js": "^4.0.0",
|
||||
"solid-js": "^1.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -19,13 +19,20 @@ export function IntegrationModal(props: IntegrationModalProps) {
|
||||
setTimeout(() => setCopiedSnippet(null), 2000);
|
||||
};
|
||||
|
||||
const hostedPageUrl = `https://bookra.eu/book/${props.tenantSlug}`;
|
||||
const hostedPageUrl = props.publicBookingUrl;
|
||||
const baseUrl = (() => {
|
||||
try {
|
||||
return new URL(props.publicBookingUrl).origin;
|
||||
} catch {
|
||||
return "https://bookra.eu";
|
||||
}
|
||||
})();
|
||||
|
||||
const htmlWidgetCode = `<div id="bookra-widget"></div>
|
||||
<script>
|
||||
(function() {
|
||||
var script = document.createElement('script');
|
||||
script.src = "https://bookra.eu/widget.js";
|
||||
script.src = "${baseUrl}/widget.js";
|
||||
script.async = true;
|
||||
script.onload = function() {
|
||||
BookraWidget.init({
|
||||
@@ -65,7 +72,7 @@ function App() {
|
||||
add_action('wp_footer', function() {
|
||||
?>
|
||||
<div id="bookra-widget"></div>
|
||||
<script src="https://bookra.eu/widget.js" async></script>
|
||||
<script src="${baseUrl}/widget.js" async></script>
|
||||
<script>
|
||||
window.addEventListener('load', function() {
|
||||
BookraWidget.init({
|
||||
|
||||
@@ -76,6 +76,7 @@ export const Shell: ParentComponent = (props) => {
|
||||
const navigate = useNavigate();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = createSignal(false);
|
||||
const hideHeader = () => location.pathname.startsWith("/dashboard");
|
||||
const hideFooter = () => location.pathname.startsWith("/dashboard");
|
||||
const [signInOpen, setSignInOpen] = createSignal(false);
|
||||
const [authMode, setAuthMode] = createSignal<"sign-in" | "register">("sign-in");
|
||||
const [name, setName] = createSignal("");
|
||||
@@ -88,6 +89,7 @@ export const Shell: ParentComponent = (props) => {
|
||||
const showGoogleSignIn = () => auth.supportsGoogleSignIn() && authMode() === "sign-in";
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/pricing", label: i18n.t("nav.pricing") },
|
||||
{ href: "/about", label: i18n.t("nav.about") },
|
||||
{ href: "/contact", label: i18n.t("nav.contact") },
|
||||
];
|
||||
@@ -409,6 +411,7 @@ export const Shell: ParentComponent = (props) => {
|
||||
|
||||
<main class="flex-1">{props.children}</main>
|
||||
|
||||
<Show when={!hideFooter()}>
|
||||
{/* Footer */}
|
||||
<footer class="border-t border-border/60 py-16 bg-canvas-subtle/40">
|
||||
<div class="section-container">
|
||||
@@ -480,6 +483,7 @@ export const Shell: ParentComponent = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</Show>
|
||||
|
||||
<Dialog open={signInOpen()} onClose={() => setSignInOpen(false)}>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -215,6 +215,7 @@ export function WidgetBuilder(props: WidgetBuilderProps) {
|
||||
const [selectedSize, setSelectedSize] = createSignal<WidgetSize>("default");
|
||||
const [selectedPosition, setSelectedPosition] = createSignal<WidgetPosition>("bottom-right");
|
||||
const [copiedSnippet, setCopiedSnippet] = createSignal<string | null>(null);
|
||||
const [copyError, setCopyError] = createSignal<string | null>(null);
|
||||
const [showPreview, setShowPreview] = createSignal(true);
|
||||
const [draggedIndex, setDraggedIndex] = createSignal<number | null>(null);
|
||||
const [customColor, setCustomColor] = createSignal(props.config.primaryColor || "#a65c3e");
|
||||
@@ -1077,9 +1078,11 @@ export class BookraWidgetComponent implements OnInit {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedSnippet(snippetId);
|
||||
setCopyError(null);
|
||||
setTimeout(() => setCopiedSnippet(null), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
setCopyError(i18n.locale() === 'cs' ? 'Kopírování se nezdařilo. Zkuste to znovu.' : 'Copy failed. Please try again.');
|
||||
setTimeout(() => setCopyError(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1445,6 +1448,11 @@ export class BookraWidgetComponent implements OnInit {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Show when={copyError()}>
|
||||
<div class="mb-4 p-3 rounded-xl bg-[hsl(var(--error-soft))] border border-[hsl(var(--error))/20] text-[hsl(var(--error))] text-sm font-medium animate-fade-in">
|
||||
{copyError()}
|
||||
</div>
|
||||
</Show>
|
||||
<Tabs defaultValue="html">
|
||||
<TabsList class="mb-4 flex-wrap">
|
||||
<TabsTrigger value="html">HTML</TabsTrigger>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as Sentry from "@sentry/react";
|
||||
|
||||
export function initSentry() {
|
||||
const dsn = import.meta.env.VITE_SENTRY_DSN;
|
||||
|
||||
if (!dsn) {
|
||||
console.log("Sentry DSN not configured - skipping initialization");
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.init({
|
||||
dsn,
|
||||
integrations: [
|
||||
Sentry.browserTracingIntegration(),
|
||||
Sentry.replayIntegration({
|
||||
maskAllText: false,
|
||||
blockAllMedia: false,
|
||||
}),
|
||||
],
|
||||
tracesSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
enableLogs: true,
|
||||
environment: import.meta.env.MODE,
|
||||
release: `bookra@${import.meta.env.VITE_APP_VERSION || "1.0.0"}`,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { loadStripe, type Stripe } from "@stripe/stripe-js";
|
||||
|
||||
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
|
||||
let stripePromise: Promise<Stripe | null> | null = null;
|
||||
|
||||
export function stripeConfigured() {
|
||||
return stripePublishableKey.trim() !== "";
|
||||
}
|
||||
|
||||
export async function getStripe() {
|
||||
if (!stripeConfigured()) {
|
||||
return null;
|
||||
}
|
||||
if (!stripePromise) {
|
||||
stripePromise = loadStripe(stripePublishableKey);
|
||||
}
|
||||
return stripePromise;
|
||||
}
|
||||
@@ -3,8 +3,12 @@ import { lazy } from "solid-js";
|
||||
import { Route, Router } from "@solidjs/router";
|
||||
import App from "./App";
|
||||
import "./styles/index.css";
|
||||
import { initSentry } from "./lib/sentry";
|
||||
|
||||
initSentry();
|
||||
|
||||
const HomeRoute = lazy(() => import("./routes/home-route").then((module) => ({ default: module.HomeRoute })));
|
||||
const PricingRoute = lazy(() => import("./routes/pricing-route"));
|
||||
const AboutRoute = lazy(() => import("./routes/about-route").then((module) => ({ default: module.AboutRoute })));
|
||||
const AuthCallbackRoute = lazy(() => import("./routes/auth-callback-route").then((module) => ({ default: module.AuthCallbackRoute })));
|
||||
const ContactRoute = lazy(() => import("./routes/contact-route").then((module) => ({ default: module.ContactRoute })));
|
||||
@@ -18,6 +22,7 @@ render(
|
||||
() => (
|
||||
<Router root={App}>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/pricing" component={PricingRoute} />
|
||||
<Route path="/about" component={AboutRoute} />
|
||||
<Route path="/auth/callback" component={AuthCallbackRoute} />
|
||||
<Route path="/contact" component={ContactRoute} />
|
||||
|
||||
@@ -13,6 +13,7 @@ const dictionaries = {
|
||||
// Navigation & Auth
|
||||
"nav.booking": "Veřejná rezervace",
|
||||
"nav.dashboard": "Aplikace",
|
||||
"nav.pricing": "Ceník",
|
||||
"nav.about": "O nás",
|
||||
"nav.contact": "Kontakt",
|
||||
|
||||
@@ -164,6 +165,26 @@ const dictionaries = {
|
||||
"home.pricing.biz.cta": "Kontaktovat prodej",
|
||||
"home.pricing.biz.trial": "Individuální řešení na míru",
|
||||
|
||||
// Comparison
|
||||
"pricing.compare.eyebrow": "Detailní srovnání",
|
||||
"pricing.compare.title": "Porovnání plánů",
|
||||
"pricing.compare.feature": "Funkce",
|
||||
"pricing.compare.locations": "Lokace",
|
||||
"pricing.compare.staff": "Zaměstnanci",
|
||||
"pricing.compare.bookings": "Rezervací/měsíc",
|
||||
"pricing.compare.emailSupport": "E-mailová podpora",
|
||||
"pricing.compare.reminders": "E-mailová připomenutí",
|
||||
"pricing.compare.analytics": "Analytika",
|
||||
"pricing.compare.api": "API přístup",
|
||||
"pricing.compare.branding": "Vlastní branding",
|
||||
"pricing.compare.whiteLabel": "Bílý labeling",
|
||||
"pricing.compare.manager": "Dedikovaný manažer",
|
||||
"pricing.compare.priority": "Prioritní",
|
||||
"pricing.compare.dedicated": "Dedikovaný",
|
||||
"pricing.compare.advanced": "Pokročilá",
|
||||
"pricing.compare.yes": "Ano",
|
||||
"pricing.compare.no": "Ne",
|
||||
|
||||
// CTA
|
||||
"home.cta.title": "Připraveni zjednodušit své rezervace?",
|
||||
"home.cta.subtitle": "Připojte se k tisícům podniků, které šetří čas s Bookra.",
|
||||
@@ -235,12 +256,51 @@ const dictionaries = {
|
||||
"footer.links.title": "Navigace",
|
||||
"footer.legal.title": "Právní informace",
|
||||
|
||||
// Dashboard (existing)
|
||||
// Dashboard
|
||||
"dashboard.title": "Přehled podniku",
|
||||
"dashboard.body": "Sledujte rezervace, nastavení, předplatné a rezervační widget na jednom místě.",
|
||||
"dashboard.kpi.bookings": "Rezervace tento týden",
|
||||
"dashboard.kpi.cancellations": "Zrušení",
|
||||
"dashboard.kpi.utilization": "Vytížení",
|
||||
"dashboard.overview": "Přehled",
|
||||
"dashboard.bookings": "Rezervace",
|
||||
"dashboard.customers": "Zákazníci",
|
||||
"dashboard.zones": "Zóny a dostupnost",
|
||||
"dashboard.billing": "Platby",
|
||||
"dashboard.settings": "Nastavení",
|
||||
"dashboard.welcome": "Dobrý den,",
|
||||
"dashboard.overviewFor": "Přehled pro",
|
||||
"dashboard.kpi.bookings": "Celkem rezervací",
|
||||
"dashboard.kpi.cancelled": "Zrušených",
|
||||
"dashboard.kpi.completed": "Dokončených",
|
||||
"dashboard.kpi.newClients": "Noví klienti",
|
||||
"dashboard.recentActivity": "Nedávná aktivita",
|
||||
"dashboard.upcomingBookings": "Nadcházející rezervace",
|
||||
"dashboard.viewAll": "Zobrazit vše",
|
||||
"dashboard.locationLimitReached": "Dosáhli jste limitu lokací!",
|
||||
"dashboard.nearingLocationLimit": "Blížíte se limitu lokací",
|
||||
"dashboard.locationsUsed": "lokací použito",
|
||||
"dashboard.upgrade": "Upgrade",
|
||||
"dashboard.shareManage": "Sdílet/Spravit",
|
||||
"dashboard.notifications": "Oznámení",
|
||||
"dashboard.bookingManagement": "Správa rezervací",
|
||||
"dashboard.totalBookings": "celkem rezervací",
|
||||
"dashboard.newBooking": "Nová rezervace",
|
||||
"dashboard.bookingDetails": "Detail rezervace",
|
||||
"dashboard.customerDetails": "Detail zákazníka",
|
||||
"dashboard.close": "Zavřít",
|
||||
"dashboard.edit": "Upravit",
|
||||
"dashboard.cancel": "Zrušit",
|
||||
"dashboard.details": "Detail",
|
||||
"dashboard.saveChanges": "Uložit změny",
|
||||
"dashboard.createBooking": "Vytvořit rezervaci",
|
||||
"dashboard.preview": "Zobrazit náhled",
|
||||
"dashboard.saveEmailSettings": "Uložit nastavení emailů",
|
||||
"dashboard.saving": "Ukládání...",
|
||||
"dashboard.creating": "Vytváření...",
|
||||
"dashboard.prevMonth": "Předchozí měsíc",
|
||||
"dashboard.nextMonth": "Další měsíc",
|
||||
"dashboard.confirmed": "Potvrzeno",
|
||||
"dashboard.pending": "Čeká",
|
||||
"dashboard.cancelled": "Zrušeno",
|
||||
"dashboard.completed": "Dokončeno",
|
||||
"dashboard.welcome.title": "Vítejte v Bookra",
|
||||
"dashboard.welcome.body": "Zjednodušte své rezervace a mějte více času na to, co vás baví.",
|
||||
"dashboard.authRequired": "Pro vstup do aplikace se přihlaste nebo si vytvořte účet.",
|
||||
@@ -248,7 +308,6 @@ const dictionaries = {
|
||||
"dashboard.liveData": "Živá data",
|
||||
"dashboard.liveDataBody": "Dashboard, nastavení a předplatné se načítají z API pro přihlášený účet.",
|
||||
"dashboard.apiReady": "API připojení aktivní",
|
||||
"dashboard.billing": "Předplatné",
|
||||
"dashboard.checkout": "Otevřít platbu",
|
||||
"dashboard.refreshBilling": "Obnovit předplatné",
|
||||
"dashboard.plan": "Plán",
|
||||
@@ -326,6 +385,12 @@ const dictionaries = {
|
||||
"contact.info.email.desc": "Preferujete psát? Jsme tu pro vás.",
|
||||
"contact.info.hours.title": "Pracovní doba",
|
||||
"contact.info.hours.desc": "Odpovídáme během pracovních dní 9:00 — 17:00 CET.",
|
||||
"contact.story.heading": "Proč nás kontaktovat?",
|
||||
"contact.story.p1": "Ať už máte dotaz ohledně funkcí, potřebujete pomoci s nastavením, nebo chcete sdílet zpětnou vazbu — rádi vám pomůžeme.",
|
||||
"contact.story.p2": "Naším cílem je, aby správa rezervací byla pro vás bezstarostná. Ozvěte se nám a najdeme řešení společně.",
|
||||
"contact.error.title": "Nepodařilo se odeslat",
|
||||
"contact.error.body": "Zkuste to prosím později, nebo nám napište přímo na hello@bookra.eu.",
|
||||
"contact.email.address": "hello@bookra.eu",
|
||||
|
||||
// Legal
|
||||
"legal.privacy.title": "Ochrana soukromí",
|
||||
@@ -345,6 +410,7 @@ const dictionaries = {
|
||||
// Navigation & Auth
|
||||
"nav.booking": "Public booking",
|
||||
"nav.dashboard": "App",
|
||||
"nav.pricing": "Pricing",
|
||||
"nav.about": "About us",
|
||||
"nav.contact": "Contact",
|
||||
|
||||
@@ -496,6 +562,26 @@ const dictionaries = {
|
||||
"home.pricing.biz.cta": "Contact sales",
|
||||
"home.pricing.biz.trial": "Custom enterprise solutions",
|
||||
|
||||
// Comparison
|
||||
"pricing.compare.eyebrow": "Detailed comparison",
|
||||
"pricing.compare.title": "Compare plans",
|
||||
"pricing.compare.feature": "Feature",
|
||||
"pricing.compare.locations": "Locations",
|
||||
"pricing.compare.staff": "Staff members",
|
||||
"pricing.compare.bookings": "Bookings/month",
|
||||
"pricing.compare.emailSupport": "Email support",
|
||||
"pricing.compare.reminders": "Email reminders",
|
||||
"pricing.compare.analytics": "Analytics",
|
||||
"pricing.compare.api": "API access",
|
||||
"pricing.compare.branding": "Custom branding",
|
||||
"pricing.compare.whiteLabel": "White labeling",
|
||||
"pricing.compare.manager": "Dedicated manager",
|
||||
"pricing.compare.priority": "Priority",
|
||||
"pricing.compare.dedicated": "Dedicated",
|
||||
"pricing.compare.advanced": "Advanced",
|
||||
"pricing.compare.yes": "Yes",
|
||||
"pricing.compare.no": "No",
|
||||
|
||||
// CTA
|
||||
"home.cta.title": "Ready to simplify your bookings?",
|
||||
"home.cta.subtitle": "Join thousands of businesses saving time with Bookra.",
|
||||
@@ -567,12 +653,51 @@ const dictionaries = {
|
||||
"footer.links.title": "Navigation",
|
||||
"footer.legal.title": "Legal",
|
||||
|
||||
// Dashboard (existing)
|
||||
// Dashboard
|
||||
"dashboard.title": "Owner dashboard",
|
||||
"dashboard.body": "Track weekly bookings, cancellations, utilization, and subscription state with a tenant-aware shell ready for Neon-backed data.",
|
||||
"dashboard.kpi.bookings": "Bookings this week",
|
||||
"dashboard.kpi.cancellations": "Cancellations",
|
||||
"dashboard.kpi.utilization": "Utilization",
|
||||
"dashboard.overview": "Overview",
|
||||
"dashboard.bookings": "Bookings",
|
||||
"dashboard.customers": "Customers",
|
||||
"dashboard.zones": "Zones & Availability",
|
||||
"dashboard.billing": "Billing",
|
||||
"dashboard.settings": "Settings",
|
||||
"dashboard.welcome": "Welcome back,",
|
||||
"dashboard.overviewFor": "Overview for",
|
||||
"dashboard.kpi.bookings": "Total Bookings",
|
||||
"dashboard.kpi.cancelled": "Cancelled",
|
||||
"dashboard.kpi.completed": "Completed",
|
||||
"dashboard.kpi.newClients": "New Clients",
|
||||
"dashboard.recentActivity": "Recent Activity",
|
||||
"dashboard.upcomingBookings": "Upcoming Bookings",
|
||||
"dashboard.viewAll": "View all",
|
||||
"dashboard.locationLimitReached": "You've reached your location limit!",
|
||||
"dashboard.nearingLocationLimit": "You're nearing your location limit",
|
||||
"dashboard.locationsUsed": "locations used",
|
||||
"dashboard.upgrade": "Upgrade",
|
||||
"dashboard.shareManage": "Share/Manage",
|
||||
"dashboard.notifications": "Notifications",
|
||||
"dashboard.bookingManagement": "Booking Management",
|
||||
"dashboard.totalBookings": "total bookings",
|
||||
"dashboard.newBooking": "New Booking",
|
||||
"dashboard.bookingDetails": "Booking Details",
|
||||
"dashboard.customerDetails": "Customer Details",
|
||||
"dashboard.close": "Close",
|
||||
"dashboard.edit": "Edit",
|
||||
"dashboard.cancel": "Cancel",
|
||||
"dashboard.details": "Details",
|
||||
"dashboard.saveChanges": "Save Changes",
|
||||
"dashboard.createBooking": "Create Booking",
|
||||
"dashboard.preview": "Preview",
|
||||
"dashboard.saveEmailSettings": "Save Email Settings",
|
||||
"dashboard.saving": "Saving...",
|
||||
"dashboard.creating": "Creating...",
|
||||
"dashboard.prevMonth": "Previous month",
|
||||
"dashboard.nextMonth": "Next month",
|
||||
"dashboard.confirmed": "Confirmed",
|
||||
"dashboard.pending": "Pending",
|
||||
"dashboard.cancelled": "Cancelled",
|
||||
"dashboard.completed": "Completed",
|
||||
"dashboard.welcome.title": "Welcome to Bookra",
|
||||
"dashboard.welcome.body": "Simplify your bookings and spend more time doing what you love.",
|
||||
"dashboard.authRequired": "Live dashboard data needs a Neon Auth session and JWT.",
|
||||
@@ -580,7 +705,6 @@ const dictionaries = {
|
||||
"dashboard.liveData": "Live data",
|
||||
"dashboard.liveDataBody": "Dashboard, tenant, and billing data are loaded from the API for the signed-in workspace.",
|
||||
"dashboard.apiReady": "API connection active",
|
||||
"dashboard.billing": "Billing",
|
||||
"dashboard.checkout": "Open checkout",
|
||||
"dashboard.refreshBilling": "Refresh billing",
|
||||
"dashboard.plan": "Plan",
|
||||
@@ -658,6 +782,12 @@ const dictionaries = {
|
||||
"contact.info.email.desc": "Prefer to write? We're here for you.",
|
||||
"contact.info.hours.title": "Working hours",
|
||||
"contact.info.hours.desc": "We respond on business days 9:00 — 17:00 CET.",
|
||||
"contact.story.heading": "Why reach out?",
|
||||
"contact.story.p1": "Whether you have questions about features, need help with setup, or want to share feedback — we're happy to help.",
|
||||
"contact.story.p2": "Our goal is to make booking management effortless for you. Get in touch and we'll find a solution together.",
|
||||
"contact.error.title": "Failed to send",
|
||||
"contact.error.body": "Please try again later, or email us directly at hello@bookra.eu.",
|
||||
"contact.email.address": "hello@bookra.eu",
|
||||
|
||||
// Legal
|
||||
"legal.privacy.title": "Privacy",
|
||||
|
||||
@@ -40,6 +40,7 @@ export function BookingManageRoute() {
|
||||
customerEmail: "alice@example.com",
|
||||
service: "Yoga Flow Class",
|
||||
businessName: "Serenity Wellness Studio",
|
||||
businessEmail: "support@bookra.eu",
|
||||
startsAt: new Date(Date.now() + 86400000).toISOString(),
|
||||
endsAt: new Date(Date.now() + 86400000 + 3600000).toISOString(),
|
||||
location: "Main Studio, 123 Wellness Street",
|
||||
@@ -331,7 +332,7 @@ export function BookingManageRoute() {
|
||||
: 'Have questions or need special arrangements? Contact the business directly.'}
|
||||
</p>
|
||||
<a
|
||||
href={`mailto:support@bookra.eu?subject=Booking ${b().reference}`}
|
||||
href={`mailto:${b().businessEmail || 'support@bookra.eu'}?subject=Booking ${b().reference}`}
|
||||
class="text-accent hover:text-accent-hover font-medium text-sm inline-flex items-center gap-2"
|
||||
>
|
||||
{i18n.locale() === 'cs' ? 'Poslat zprávu' : 'Send message'}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Show, createSignal } from "solid-js";
|
||||
import { Show, createSignal, Match, Switch } from "solid-js";
|
||||
import { useI18n } from "../providers/i18n-provider";
|
||||
import { BookraCharacter } from "../components/bookra-character";
|
||||
import {
|
||||
@@ -17,38 +17,48 @@ export function ContactRoute() {
|
||||
const [message, setMessage] = createSignal("");
|
||||
const [submitted, setSubmitted] = createSignal(false);
|
||||
const [submitting, setSubmitting] = createSignal(false);
|
||||
const [error, setError] = createSignal("");
|
||||
|
||||
const apiUrl = import.meta.env.VITE_BOOKRA_API_URL ?? "http://localhost:8080";
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSubmitting(true);
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setSubmitting(false);
|
||||
setSubmitted(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/v1/public/contact`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: name(),
|
||||
email: email(),
|
||||
message: message(),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to send");
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
setError(i18n.t("contact.error.body"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="animate-fade-in">
|
||||
{/* Hero Section */}
|
||||
<section class="relative pt-16 pb-12 lg:pt-24 lg:pb-16 overflow-hidden">
|
||||
{/* Background gradient */}
|
||||
<div
|
||||
class="absolute inset-0 pointer-events-none"
|
||||
style={{ background: "var(--gradient-hero)" }}
|
||||
/>
|
||||
|
||||
<div class="absolute inset-0 pointer-events-none" style={{ background: "var(--gradient-hero)" }} />
|
||||
<div class="section-container relative">
|
||||
<div class="max-w-3xl mx-auto text-center">
|
||||
{/* Character at top */}
|
||||
<div class="flex justify-center mb-8">
|
||||
<BookraCharacter pose="headphones" size="xl" animate={true} />
|
||||
</div>
|
||||
|
||||
<span class="inline-flex items-center gap-2 px-4 py-1.5 mb-6 text-sm font-medium tracking-wide text-accent bg-accent-subtle/80 rounded-full border border-accent/10 backdrop-blur-sm">
|
||||
<span class="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
||||
<span class="w-2 h-2 rounded-full bg-success" />
|
||||
{i18n.locale() === 'cs' ? 'Jsme tu pro vás' : 'We are here for you'}
|
||||
</span>
|
||||
|
||||
<h1 class="text-display-xl font-semibold text-ink mb-6 tracking-tight animate-slide-up">
|
||||
{i18n.t("contact.title")}
|
||||
</h1>
|
||||
@@ -59,162 +69,147 @@ export function ContactRoute() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact Form Section */}
|
||||
{/* Story + Form split */}
|
||||
<section class="py-16 lg:py-24 bg-canvas-subtle/30">
|
||||
<div class="section-container">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<Show when={!submitted()} fallback={
|
||||
<Card class="surface-elevated border-success/20">
|
||||
<CardContent class="py-12">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="relative mb-6">
|
||||
<BookraCharacter pose="success" size="xl" animate={true} />
|
||||
<div class="absolute -top-2 -right-2 text-3xl animate-bounce">🎉</div>
|
||||
</div>
|
||||
<h2 class="text-display-md font-semibold text-ink mb-4">
|
||||
{i18n.t("contact.success.title")}
|
||||
</h2>
|
||||
<p class="text-ink-muted max-w-sm">
|
||||
{i18n.t("contact.success.body")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}>
|
||||
<Card class="surface-elevated overflow-hidden">
|
||||
<div class="flex items-center gap-3 p-6 border-b border-border/50 bg-canvas-subtle/30">
|
||||
<div class="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle class="text-xl">{i18n.t("contact.form.title")}</CardTitle>
|
||||
<div class="max-w-5xl mx-auto grid lg:grid-cols-[1fr_1.2fr] gap-12 lg:gap-16 items-start">
|
||||
{/* Story side */}
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h2 class="text-display-sm font-semibold text-ink mb-4">
|
||||
{i18n.t("contact.story.heading")}
|
||||
</h2>
|
||||
<div class="space-y-4 text-ink-muted leading-relaxed">
|
||||
<p>{i18n.t("contact.story.p1")}</p>
|
||||
<p>{i18n.t("contact.story.p2")}</p>
|
||||
</div>
|
||||
<CardContent class="p-6">
|
||||
<form onSubmit={handleSubmit} class="space-y-6">
|
||||
<Input
|
||||
label={i18n.t("contact.form.name")}
|
||||
type="text"
|
||||
value={name()}
|
||||
onInput={(e) => setName(e.currentTarget.value)}
|
||||
required
|
||||
autocomplete="name"
|
||||
/>
|
||||
<Input
|
||||
label={i18n.t("contact.form.email")}
|
||||
type="email"
|
||||
value={email()}
|
||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||
required
|
||||
autocomplete="email"
|
||||
/>
|
||||
<Textarea
|
||||
label={i18n.t("contact.form.message")}
|
||||
value={message()}
|
||||
onInput={(e) => setMessage(e.currentTarget.value)}
|
||||
rows={5}
|
||||
required
|
||||
placeholder={i18n.locale() === 'cs' ? "Napište nám, jak vám můžeme pomoci..." : "Tell us how we can help you..."}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
isLoading={submitting()}
|
||||
class="shadow-lg hover:shadow-xl transition-all"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
{i18n.t("contact.form.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Contact Info Section */}
|
||||
<section class="py-16 lg:py-24">
|
||||
<div class="section-container">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
{/* Section title */}
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-display-md font-semibold text-ink mb-3">
|
||||
{i18n.locale() === 'cs' ? 'Další způsoby kontaktu' : 'Other ways to reach us'}
|
||||
</h2>
|
||||
<p class="text-ink-muted">
|
||||
{i18n.locale() === 'cs' ? 'Vyberte si, co vám nejvíce vyhovuje' : 'Choose what works best for you'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-8">
|
||||
{/* Email Card */}
|
||||
<Card class="surface-elevated group hover:shadow-xl transition-all duration-500">
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-accent/10 flex items-center justify-center shrink-0 group-hover:bg-accent/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
||||
<polyline points="22,6 12,13 2,6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-display font-semibold text-ink mb-1">{i18n.t("contact.info.email.title")}</h3>
|
||||
<p class="text-ink-muted text-sm mb-3">{i18n.t("contact.info.email.desc")}</p>
|
||||
<a
|
||||
href="mailto:hello@bookra.cz"
|
||||
class="inline-flex items-center gap-2 text-accent hover:text-accent/80 font-medium transition-colors group/link"
|
||||
>
|
||||
hello@bookra.cz
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="transition-transform group-hover/link:translate-x-1">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
<polyline points="12 5 19 12 12 19"/>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<Card class="surface-elevated group hover:shadow-lg transition-all">
|
||||
<CardContent class="p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 group-hover:bg-accent/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
|
||||
<path d="M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5A2.5 2.5 0 0 1 4.5 7h15A2.5 2.5 0 0 1 22 9.5z"/>
|
||||
<polyline points="22 9.5 12 14 2 9.5"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Hours Card */}
|
||||
<Card class="surface-elevated group hover:shadow-xl transition-all duration-500">
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-accent/10 flex items-center justify-center shrink-0 group-hover:bg-accent/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-display font-semibold text-ink mb-1">{i18n.t("contact.info.hours.title")}</h3>
|
||||
<p class="text-ink-muted text-sm">{i18n.t("contact.info.hours.desc")}</p>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-success animate-pulse"/>
|
||||
<span class="text-xs text-success font-medium">
|
||||
{i18n.locale() === 'cs' ? 'Aktuálně online' : 'Currently online'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-display font-semibold text-ink text-sm mb-1">{i18n.t("contact.info.email.title")}</h3>
|
||||
<a href={`mailto:${i18n.t("contact.email.address")}`} class="text-accent text-sm font-medium hover:underline">
|
||||
{i18n.t("contact.email.address")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Helpful mascot at bottom */}
|
||||
<div class="mt-16 flex justify-center">
|
||||
<div class="flex items-center gap-4 surface-elevated px-6 py-4 rounded-2xl">
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card class="surface-elevated group hover:shadow-lg transition-all">
|
||||
<CardContent class="p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 group-hover:bg-accent/20 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-display font-semibold text-ink text-sm mb-1">{i18n.t("contact.info.hours.title")}</h3>
|
||||
<p class="text-ink-muted text-xs">{i18n.t("contact.info.hours.desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 surface-elevated px-5 py-4 rounded-2xl">
|
||||
<BookraCharacter pose="main" size="sm" animate={true} />
|
||||
<p class="text-ink-muted text-sm">
|
||||
{i18n.locale() === 'cs'
|
||||
? 'Odpovídáme obvykle do 24 hodin'
|
||||
{i18n.locale() === 'cs'
|
||||
? 'Odpovídáme obvykle do 24 hodin'
|
||||
: 'We usually respond within 24 hours'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form side */}
|
||||
<div>
|
||||
<Switch>
|
||||
<Match when={submitted()}>
|
||||
<Card class="surface-elevated border-success/20">
|
||||
<CardContent class="py-12">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="relative mb-6">
|
||||
<BookraCharacter pose="success" size="xl" animate={true} />
|
||||
</div>
|
||||
<h2 class="text-display-md font-semibold text-ink mb-4">
|
||||
{i18n.t("contact.success.title")}
|
||||
</h2>
|
||||
<p class="text-ink-muted max-w-sm">
|
||||
{i18n.t("contact.success.body")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Card class="surface-elevated overflow-hidden">
|
||||
<div class="flex items-center gap-3 p-6 border-b border-border/50 bg-canvas-subtle/30">
|
||||
<div class="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle class="text-xl">{i18n.t("contact.form.title")}</CardTitle>
|
||||
</div>
|
||||
<CardContent class="p-6">
|
||||
<form onSubmit={handleSubmit} class="space-y-5">
|
||||
<Input
|
||||
label={i18n.t("contact.form.name")}
|
||||
type="text"
|
||||
value={name()}
|
||||
onInput={(e) => setName(e.currentTarget.value)}
|
||||
required
|
||||
autocomplete="name"
|
||||
/>
|
||||
<Input
|
||||
label={i18n.t("contact.form.email")}
|
||||
type="email"
|
||||
value={email()}
|
||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||
required
|
||||
autocomplete="email"
|
||||
/>
|
||||
<Textarea
|
||||
label={i18n.t("contact.form.message")}
|
||||
value={message()}
|
||||
onInput={(e) => setMessage(e.currentTarget.value)}
|
||||
rows={5}
|
||||
required
|
||||
minLength={10}
|
||||
placeholder={i18n.locale() === 'cs' ? "Napište nám, jak vám můžeme pomoci..." : "Tell us how we can help you..."}
|
||||
/>
|
||||
<Show when={error()}>
|
||||
<p class="text-sm text-danger">{error()}</p>
|
||||
</Show>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
isLoading={submitting()}
|
||||
class="shadow-lg hover:shadow-xl transition-all"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
{i18n.t("contact.form.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { A } from "@solidjs/router";
|
||||
import { createSignal, onMount, createMemo } from "solid-js";
|
||||
import { createSignal, onMount, createMemo, Show, For } from "solid-js";
|
||||
import { useI18n } from "../providers/i18n-provider";
|
||||
import { BookraCharacter } from "../components/bookra-character";
|
||||
|
||||
@@ -107,6 +107,105 @@ const StepCard = (props: StepCardProps) => (
|
||||
// Main home route component
|
||||
export function HomeRoute() {
|
||||
const i18n = useI18n();
|
||||
const [billingInterval, setBillingInterval] = createSignal<"monthly" | "yearly">("monthly");
|
||||
|
||||
const isYearly = () => billingInterval() === "yearly";
|
||||
const isCs = () => i18n.locale() === "cs";
|
||||
|
||||
const ComparisonValue = (props: { value: string; highlight?: boolean }) => {
|
||||
const v = props.value.toLowerCase();
|
||||
const yesLabel = i18n.t("pricing.compare.yes").toLowerCase();
|
||||
const noLabel = i18n.t("pricing.compare.no").toLowerCase();
|
||||
if (v === "yes" || v === yesLabel) {
|
||||
return (
|
||||
<span class={`inline-flex items-center justify-center w-6 h-6 rounded-full ${props.highlight ? 'bg-accent/15 text-accent' : 'bg-success/15 text-success'}`}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 6 9 17l-5-5"/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (v === "no" || v === noLabel) {
|
||||
return (
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-ink/5 text-ink-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6 6 18"/>
|
||||
<path d="m6 6 12 12"/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span class={`text-sm font-medium ${props.highlight ? 'text-accent' : 'text-ink'}`}>
|
||||
{props.value}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Pricing data with monthly and yearly options
|
||||
const plans = createMemo(() => [
|
||||
{
|
||||
id: "starter",
|
||||
name: isCs() ? "Starter" : "Starter",
|
||||
desc: isCs() ? "Pro jednotlivce a malé podniky" : "For individuals and small businesses",
|
||||
monthly: isCs() ? "199 Kč" : "$9",
|
||||
yearly: isCs() ? "1 990 Kč" : "$90",
|
||||
period: isCs() ? "/měsíc" : "/mo",
|
||||
yearlyPeriod: isCs() ? "/rok" : "/yr",
|
||||
savings: isCs() ? "Ušetřete 398 Kč" : "Save $18",
|
||||
savingsPercent: "17%",
|
||||
trial: isCs() ? "15 dní zdarma po registraci" : "15 days free after sign-up",
|
||||
features: [
|
||||
isCs() ? "Do 50 rezervací/měsíc" : "Up to 50 bookings/month",
|
||||
isCs() ? "1 lokace, 1 zaměstnanec" : "1 location, 1 staff member",
|
||||
isCs() ? "E-mailová podpora" : "Email support",
|
||||
],
|
||||
cta: isCs() ? "Začít zdarma" : "Start for free",
|
||||
popular: false,
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: isCs() ? "Pro" : "Pro",
|
||||
desc: isCs() ? "Pro rostoucí podniky" : "For growing businesses",
|
||||
monthly: isCs() ? "399 Kč" : "$19",
|
||||
yearly: isCs() ? "3 990 Kč" : "$190",
|
||||
period: isCs() ? "/měsíc" : "/mo",
|
||||
yearlyPeriod: isCs() ? "/rok" : "/yr",
|
||||
savings: isCs() ? "Ušetřete 798 Kč" : "Save $38",
|
||||
savingsPercent: "17%",
|
||||
trial: isCs() ? "15 dní zdarma po registraci" : "15 days free after sign-up",
|
||||
features: [
|
||||
isCs() ? "Neomezené rezervace" : "Unlimited bookings",
|
||||
isCs() ? "3 lokace, 10 zaměstnanců" : "3 locations, 10 staff",
|
||||
isCs() ? "E-mailová připomenutí" : "Email reminders",
|
||||
isCs() ? "Prioritní podpora" : "Priority support",
|
||||
isCs() ? "Analytika a reporty" : "Analytics & reports",
|
||||
],
|
||||
cta: isCs() ? "Začít 15denní zkoušku" : "Start 15-day trial",
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
id: "business",
|
||||
name: isCs() ? "Business" : "Business",
|
||||
desc: isCs() ? "Pro větší týmy a franšízy" : "For larger teams and franchises",
|
||||
monthly: isCs() ? "799 Kč" : "$39",
|
||||
yearly: isCs() ? "7 990 Kč" : "$390",
|
||||
period: isCs() ? "/měsíc" : "/mo",
|
||||
yearlyPeriod: isCs() ? "/rok" : "/yr",
|
||||
savings: isCs() ? "Ušetřete 1 598 Kč" : "Save $78",
|
||||
savingsPercent: "17%",
|
||||
trial: isCs() ? "Individuální řešení na míru" : "Custom enterprise solutions",
|
||||
features: [
|
||||
isCs() ? "Neomezené vše" : "Unlimited everything",
|
||||
isCs() ? "Více lokací" : "Multiple locations",
|
||||
isCs() ? "API přístup" : "API access",
|
||||
isCs() ? "Dedikovaný manažer" : "Dedicated manager",
|
||||
],
|
||||
cta: isCs() ? "Kontaktovat prodej" : "Contact sales",
|
||||
popular: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const [isVisible, setIsVisible] = createSignal(false);
|
||||
const [currentDate, setCurrentDate] = createSignal(new Date());
|
||||
|
||||
@@ -512,125 +611,168 @@ export function HomeRoute() {
|
||||
{i18n.t("home.pricing.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto items-center">
|
||||
{/* Starter Plan */}
|
||||
<div
|
||||
class="group surface-elevated p-6 lg:p-8 rounded-card transition-all duration-500 hover:shadow-xl hover:-translate-y-1 animate-slide-up"
|
||||
style={{ "animation-delay": "0.3s" }}
|
||||
>
|
||||
<div class="mb-6">
|
||||
<h3 class="font-display text-lg font-semibold mb-1 text-ink">
|
||||
{i18n.t("home.pricing.starter.name")}
|
||||
</h3>
|
||||
<p class="text-ink-muted">{i18n.t("home.pricing.starter.desc")}</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<span class="font-display text-4xl font-semibold text-ink">
|
||||
{i18n.locale() === 'cs' ? '119 Kč' : '$5'}
|
||||
</span>
|
||||
<span class="text-ink-muted">{i18n.t("home.pricing.perMonth")}</span>
|
||||
<p class="mt-1 text-xs text-accent font-medium">{i18n.t("home.pricing.starter.trial")}</p>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8">
|
||||
{[i18n.t("home.pricing.starter.f1"), i18n.t("home.pricing.starter.f2"), i18n.t("home.pricing.starter.f3")].map((feature) => (
|
||||
<li class="flex items-start gap-3 text-ink-muted">
|
||||
<span class="mt-0.5 text-accent shrink-0">
|
||||
<CheckIcon />
|
||||
</span>
|
||||
<span class="text-sm">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<A
|
||||
href="/dashboard"
|
||||
class="block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 btn-secondary w-full"
|
||||
|
||||
{/* Billing Toggle */}
|
||||
<div class="flex items-center justify-center mb-12 animate-slide-up" style={{ "animation-delay": "0.25s" }}>
|
||||
<div class="relative inline-flex items-center gap-4">
|
||||
<span class={`text-sm font-semibold transition-colors ${!isYearly() ? 'text-ink' : 'text-ink-muted'}`}>
|
||||
{isCs() ? "Měsíčně" : "Monthly"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingInterval(isYearly() ? "monthly" : "yearly")}
|
||||
class={`relative w-14 h-7 rounded-full transition-colors duration-300 cursor-pointer ${isYearly() ? 'bg-accent' : 'bg-ink/30'}`}
|
||||
role="switch"
|
||||
aria-checked={isYearly()}
|
||||
aria-label={isYearly() ? (isCs() ? "Ročně" : "Yearly") : (isCs() ? "Měsíčně" : "Monthly")}
|
||||
>
|
||||
{i18n.t("home.pricing.starter.cta")}
|
||||
</A>
|
||||
</div>
|
||||
|
||||
{/* Pro Plan - Highlighted */}
|
||||
<div
|
||||
class="group relative p-6 lg:p-8 rounded-card transition-all duration-500 hover:shadow-2xl hover:-translate-y-2 animate-slide-up lg:scale-105"
|
||||
style={{ "animation-delay": "0.4s" }}
|
||||
>
|
||||
{/* Gradient background for highlighted card */}
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-ink to-ink/95 rounded-card" />
|
||||
|
||||
{/* Popular badge */}
|
||||
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
|
||||
<span class="px-3 py-1 bg-accent text-white text-xs font-display font-medium rounded-full shadow-lg">
|
||||
{i18n.t("home.pricing.popular")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10">
|
||||
<div class="mb-6">
|
||||
<h3 class="font-display text-lg font-semibold mb-1 text-canvas">
|
||||
{i18n.t("home.pricing.pro.name")}
|
||||
</h3>
|
||||
<p class="text-canvas/70">{i18n.t("home.pricing.pro.desc")}</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<span class="font-display text-4xl font-semibold text-canvas">
|
||||
{i18n.locale() === 'cs' ? '499 Kč' : '$20'}
|
||||
<span
|
||||
class={`absolute top-1 left-1 w-5 h-5 bg-white rounded-full shadow-md transition-transform duration-300 ease-spring ${isYearly() ? 'translate-x-7' : 'translate-x-0'}`}
|
||||
/>
|
||||
</button>
|
||||
<span class={`text-sm font-semibold transition-colors ${isYearly() ? 'text-ink' : 'text-ink-muted'}`}>
|
||||
{isCs() ? "Ročně" : "Yearly"}
|
||||
</span>
|
||||
<div class="absolute left-full ml-3 top-1/2 -translate-y-1/2">
|
||||
<Show when={isYearly()}>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-accent text-white text-xs font-bold rounded-full shadow-sm whitespace-nowrap">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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>
|
||||
{isCs() ? "-17%" : "-17%"}
|
||||
</span>
|
||||
<span class="text-canvas/60">{i18n.t("home.pricing.perMonth")}</span>
|
||||
<p class="mt-1 text-xs text-accent font-medium">{i18n.t("home.pricing.pro.trial")}</p>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8">
|
||||
{[i18n.t("home.pricing.pro.f1"), i18n.t("home.pricing.pro.f2"), i18n.t("home.pricing.pro.f3"), i18n.t("home.pricing.pro.f4"), i18n.t("home.pricing.pro.f5")].map((feature) => (
|
||||
<li class="flex items-start gap-3 text-canvas/80">
|
||||
<span class="mt-0.5 text-accent shrink-0">
|
||||
<CheckIcon />
|
||||
</span>
|
||||
<span class="text-sm">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<A
|
||||
href="/dashboard"
|
||||
class="block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 bg-canvas text-ink hover:bg-canvas-subtle w-full shadow-lg group-hover:shadow-xl"
|
||||
>
|
||||
{i18n.t("home.pricing.pro.cta")}
|
||||
</A>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Business Plan */}
|
||||
<div
|
||||
class="group surface-elevated p-6 lg:p-8 rounded-card transition-all duration-500 hover:shadow-xl hover:-translate-y-1 animate-slide-up"
|
||||
style={{ "animation-delay": "0.5s" }}
|
||||
>
|
||||
<div class="mb-6">
|
||||
<h3 class="font-display text-lg font-semibold mb-1 text-ink">
|
||||
{i18n.t("home.pricing.biz.name")}
|
||||
</h3>
|
||||
<p class="text-ink-muted">{i18n.t("home.pricing.biz.desc")}</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<span class="font-display text-4xl font-semibold text-ink">
|
||||
{i18n.locale() === 'cs' ? '1 199 Kč' : '$50'}
|
||||
</span>
|
||||
<span class="text-ink-muted">{i18n.t("home.pricing.perMonth")}</span>
|
||||
<p class="mt-1 text-xs text-accent font-medium">{i18n.t("home.pricing.biz.trial")}</p>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8">
|
||||
{[i18n.t("home.pricing.biz.f1"), i18n.t("home.pricing.biz.f2"), i18n.t("home.pricing.biz.f3"), i18n.t("home.pricing.biz.f4")].map((feature) => (
|
||||
<li class="flex items-start gap-3 text-ink-muted">
|
||||
<span class="mt-0.5 text-accent shrink-0">
|
||||
<CheckIcon />
|
||||
</span>
|
||||
<span class="text-sm">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<A
|
||||
href="/dashboard"
|
||||
class="block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 btn-secondary w-full"
|
||||
<div class="grid md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto items-center">
|
||||
{plans().map((plan, index) => (
|
||||
<div
|
||||
class={`group relative p-6 lg:p-8 rounded-card transition-all duration-500 animate-slide-up ${plan.popular ? 'hover:shadow-2xl hover:-translate-y-2 lg:scale-105' : 'hover:shadow-xl hover:-translate-y-1'}`}
|
||||
style={{ "animation-delay": `${0.3 + index * 0.1}s` }}
|
||||
>
|
||||
{i18n.t("home.pricing.biz.cta")}
|
||||
</A>
|
||||
{/* Gradient background for popular card */}
|
||||
<Show when={plan.popular}>
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-ink to-ink/95 rounded-card" />
|
||||
</Show>
|
||||
<Show when={!plan.popular}>
|
||||
<div class="absolute inset-0 surface-elevated rounded-card" />
|
||||
</Show>
|
||||
|
||||
{/* Popular badge */}
|
||||
<Show when={plan.popular}>
|
||||
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
|
||||
<span class="px-3 py-1 bg-accent text-white text-xs font-display font-medium rounded-full shadow-lg">
|
||||
{i18n.t("home.pricing.popular")}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="relative z-10">
|
||||
<div class="mb-6">
|
||||
<h3 class={`font-display text-lg font-semibold mb-1 ${plan.popular ? 'text-canvas' : 'text-ink'}`}>
|
||||
{plan.name}
|
||||
</h3>
|
||||
<p class={plan.popular ? 'text-canvas/70' : 'text-ink-muted'}>{plan.desc}</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class={`font-display text-4xl font-semibold ${plan.popular ? 'text-canvas' : 'text-ink'}`}>
|
||||
{isYearly() ? plan.yearly : plan.monthly}
|
||||
</span>
|
||||
<span class={plan.popular ? 'text-canvas/60' : 'text-ink-muted'}>
|
||||
{isYearly() ? plan.yearlyPeriod : plan.period}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={isYearly()}>
|
||||
<div class="mt-2 flex items-center gap-1.5">
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-accent-subtle text-accent text-xs font-semibold rounded-full border border-accent/10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><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>
|
||||
{plan.savingsPercent}
|
||||
</span>
|
||||
<span class="text-xs text-ink-subtle">{isCs() ? 'sleva při roční platbě' : 'discount on yearly billing'}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!isYearly()}>
|
||||
<p class="mt-1 text-xs text-accent font-medium">{plan.trial}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8">
|
||||
{plan.features.map((feature) => (
|
||||
<li class={`flex items-start gap-3 ${plan.popular ? 'text-canvas/80' : 'text-ink-muted'}`}>
|
||||
<span class="mt-0.5 text-accent shrink-0">
|
||||
<CheckIcon />
|
||||
</span>
|
||||
<span class="text-sm">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<A
|
||||
href="/dashboard"
|
||||
class={`block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 w-full ${plan.popular ? 'bg-canvas text-ink hover:bg-canvas-subtle shadow-lg group-hover:shadow-xl' : 'btn-secondary'}`}
|
||||
>
|
||||
{plan.cta}
|
||||
</A>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Comparison Table */}
|
||||
<section class="py-16 px-4">
|
||||
<div class="section-container">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="text-center mb-10">
|
||||
<span class="text-sm font-medium text-accent mb-2 block tracking-wide uppercase">
|
||||
{i18n.t("pricing.compare.eyebrow")}
|
||||
</span>
|
||||
<h2 class="text-2xl sm:text-3xl font-display font-bold text-ink">
|
||||
{i18n.t("pricing.compare.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="surface-elevated rounded-card overflow-hidden border border-border/50 shadow-sm">
|
||||
{/* Header */}
|
||||
<div class="grid grid-cols-[1fr_100px_100px_100px] sm:grid-cols-[1.5fr_1fr_1fr_1fr] gap-2 p-4 sm:p-5 bg-canvas-subtle/60 border-b border-border/50">
|
||||
<div class="text-sm font-semibold text-ink-muted self-center">{i18n.t("pricing.compare.feature")}</div>
|
||||
<div class="text-center font-display font-semibold text-ink text-sm">Starter</div>
|
||||
<div class="text-center">
|
||||
<span class="inline-block px-3 py-1 bg-accent/10 text-accent font-display font-semibold text-sm rounded-full">
|
||||
Pro
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-center font-display font-semibold text-ink text-sm">Business</div>
|
||||
</div>
|
||||
{/* Rows */}
|
||||
<For each={[
|
||||
{ key: "pricing.compare.locations", starter: "1", pro: "3", business: "∞" },
|
||||
{ key: "pricing.compare.staff", starter: "1", pro: "10", business: "∞" },
|
||||
{ key: "pricing.compare.bookings", starter: "50", pro: "∞", business: "∞" },
|
||||
{ key: "pricing.compare.emailSupport", starter: i18n.t("pricing.compare.yes"), pro: i18n.t("pricing.compare.priority"), business: i18n.t("pricing.compare.dedicated") },
|
||||
{ key: "pricing.compare.reminders", starter: "no", pro: "yes", business: "yes" },
|
||||
{ key: "pricing.compare.analytics", starter: "no", pro: "yes", business: i18n.t("pricing.compare.advanced") },
|
||||
{ key: "pricing.compare.api", starter: "no", pro: "no", business: "yes" },
|
||||
{ key: "pricing.compare.branding", starter: "no", pro: "yes", business: "yes" },
|
||||
{ key: "pricing.compare.whiteLabel", starter: "no", pro: "no", business: "yes" },
|
||||
{ key: "pricing.compare.manager", starter: "no", pro: "no", business: "yes" },
|
||||
]}>
|
||||
{(feature, i) => (
|
||||
<div class={`grid grid-cols-[1fr_100px_100px_100px] sm:grid-cols-[1.5fr_1fr_1fr_1fr] gap-2 p-4 sm:p-5 items-center border-b border-border/30 last:border-0 transition-colors ${i() % 2 === 0 ? 'bg-canvas/30' : ''} hover:bg-canvas-subtle/40`}>
|
||||
<span class="text-sm text-ink font-medium">{i18n.t(feature.key)}</span>
|
||||
<div class="flex justify-center">
|
||||
<ComparisonValue value={feature.starter} />
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<ComparisonValue value={feature.pro} highlight />
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<ComparisonValue value={feature.business} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,29 +9,80 @@ export function LegalRoute() {
|
||||
const i18n = useI18n();
|
||||
const kind = () => (params.kind === "terms" ? "terms" : "privacy");
|
||||
const heroPose = () => (kind() === "terms" ? "flag" : "educate");
|
||||
const helperPose = () => (kind() === "terms" ? "announcement" : "happy_note");
|
||||
const sections = () =>
|
||||
kind() === "terms"
|
||||
? [
|
||||
{
|
||||
title: i18n.t("legal.terms.service.title"),
|
||||
body: i18n.t("legal.terms.service.body"),
|
||||
},
|
||||
{
|
||||
title: i18n.t("legal.terms.billing.title"),
|
||||
body: i18n.t("legal.terms.billing.body"),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: i18n.t("legal.privacy.data.title"),
|
||||
body: i18n.t("legal.privacy.data.body"),
|
||||
},
|
||||
{
|
||||
title: i18n.t("legal.privacy.rights.title"),
|
||||
body: i18n.t("legal.privacy.rights.body"),
|
||||
},
|
||||
];
|
||||
const isCs = () => i18n.locale() === "cs";
|
||||
|
||||
const companyInfo = () =>
|
||||
isCs()
|
||||
? "Provozovatel: Bookra, IČO 24330621. Sídlo: Česká republika."
|
||||
: "Operator: Bookra, Business ID 24330621. Registered in the Czech Republic.";
|
||||
|
||||
const termsSections = () => [
|
||||
{
|
||||
title: isCs() ? "1. Úvod a předmět smlouvy" : "1. Introduction and subject",
|
||||
body: isCs()
|
||||
? "Tyto podmínky upravují používání služby Bookra — online rezervačního systému pro lokální služby. Poskytovatelem je Bookra , IČO 24330621. Službu mohou využívat podnikatelé a právnické osoby k správě rezervací, zákazníků a dostupnosti. Uživatel se zavazuje používat službu v souladu s právními předpisy, bez zneužívání rezervačních formulářů, obcházení zabezpečení nebo ukládání zakázaného obsahu."
|
||||
: "These terms govern the use of Bookra — an online booking system for local services. The provider is Bookra , Business ID 24330621. Entrepreneurs and legal entities may use the service to manage bookings, customers, and availability. The user agrees to use the service lawfully, without abusing booking forms, bypassing security, or storing prohibited content.",
|
||||
},
|
||||
{
|
||||
title: isCs() ? "2. Registrace a účet" : "2. Registration and account",
|
||||
body: isCs()
|
||||
? "Pro plné využití služby je nutná registrace. Uživatel zodpovídá za správnost údajů uvedených při registraci a za bezpečnost přihlašovacích údajů. Provozovatel účtu odpovídá za správnost nabídky, dostupnost termínů a komunikaci se zákazníky. Bookra nezodpovídá za obsah rezervací a komunikaci mezi provozovatelem a zákazníkem."
|
||||
: "Full use of the service requires registration. The user is responsible for the accuracy of registration details and the security of login credentials. The workspace operator is responsible for the accuracy of their offer, availability of times, and customer communication. Bookra is not liable for booking content or communication between operators and customers.",
|
||||
},
|
||||
{
|
||||
title: isCs() ? "3. Předplatné a platby" : "3. Subscription and payments",
|
||||
body: isCs()
|
||||
? "Placené plány se účtují předem prostřednictvím platební brány Paddle nebo Stripe. Aktivní plán určuje dostupné limity, rozšíření a podpůrné funkce. Při roční platbě je poskytována sleva oproti měsíčnímu zúčtování. Uživatel může předplatné kdykoliv zrušit; přístup zůstává do konce zaplaceného období. Neposkytujeme refundace za již zaplacená období."
|
||||
: "Paid plans are billed in advance through Paddle or Stripe. The active plan determines available limits, add-ons, and support features. Annual billing includes a discount compared to monthly billing. Users may cancel anytime; access continues until the end of the paid period. No refunds are provided for already-paid periods.",
|
||||
},
|
||||
{
|
||||
title: isCs() ? "4. Odpovědnost a omezení" : "4. Liability and limitations",
|
||||
body: isCs()
|
||||
? "Bookra se snaží zajistit nepřetržitý provoz, ale nezaručuje 100% dostupnost. Neneseme odpovědnost za přímé ani nepřímé škody způsobené výpadkem služby, ztrátou dat způsobenou uživatelem nebo technickými problémy třetích stran. Doporučujeme pravidelnou zálohu důležitých dat."
|
||||
: "Bookra strives to ensure uninterrupted service but does not guarantee 100% uptime. We are not liable for direct or indirect damages caused by service outages, data loss caused by the user, or technical issues from third parties. We recommend regular backups of important data.",
|
||||
},
|
||||
{
|
||||
title: isCs() ? "5. Ukončení a výpověď" : "5. Termination",
|
||||
body: isCs()
|
||||
? "Uživatel může účet zrušit kdykoliv v nastavení. Při dlouhodobé neaktivitě (12 měsíců bez přihlášení) si vyhrazujeme právo účet deaktivovat po předchozím upozornění. Při porušení podmínek může být účet ukončen okamžitě."
|
||||
: "Users may cancel their account anytime in settings. After prolonged inactivity (12 months without login), we reserve the right to deactivate the account after prior notice. Accounts violating these terms may be terminated immediately.",
|
||||
},
|
||||
];
|
||||
|
||||
const privacySections = () => [
|
||||
{
|
||||
title: isCs() ? "1. Jaké údaje zpracováváme a proč" : "1. What data we process and why",
|
||||
body: isCs()
|
||||
? "Zpracováváme minimální množství dat nezbytných pro fungování služby: kontaktní údaje zákazníků (jméno, e-mail) pro potvrzení rezervace, čas rezervace a poznámky zadané při rezervaci, údaje o účtu provozovatele (e-mail, jméno) pro správu účtu, a technické záznamy (IP adresa, čas požadavku) pro zabezpečení. Údaje tenantů jsou oddělené a přístup k nim je omezen podle role uživatele."
|
||||
: "We process the minimum data necessary for the service: customer contact details (name, email) for booking confirmation, booking times and notes, workspace account details (email, name) for account management, and technical records (IP address, request time) for security. Tenant data is isolated and access is limited by user role.",
|
||||
},
|
||||
{
|
||||
title: isCs() ? "2. Cookies a sledování" : "2. Cookies and tracking",
|
||||
body: isCs()
|
||||
? "Bookra nepoužívá žádné sledovací cookies pro marketingové ani analytické účely. Jediné cookies, které ukládáme, jsou technicky nezbytné pro přihlášení a správu relace. Pro anonymní statistiky využíváme Rybbit — nástroj, který pracuje bez cookies a neukládá osobní údaje návštěvníků."
|
||||
: "Bookra does not use any tracking cookies for marketing or analytics purposes. The only cookies we store are technically necessary for login and session management. For anonymous statistics, we use Rybbit — a tool that operates without cookies and does not store visitors' personal data.",
|
||||
},
|
||||
{
|
||||
title: isCs() ? "3. Údaje registrovaných uživatelů" : "3. Registered user data",
|
||||
body: isCs()
|
||||
? "Při registraci shromažďujeme e-mailovou adresu a jméno uživatele. Tato data slouží výhradně k autentizaci, správě účtu a komunikaci ohledně služby (připomenutí, oznámení o změnách). Vaše data neprodáváme, nepronajímáme a nesdílíme s třetími stranami pro marketingové účely. Přístup mají pouze oprávnění zaměstnanci Bookry a to pouze v nezbytném rozsahu pro technickou podporu."
|
||||
: "During registration, we collect the user's email address and name. This data is used solely for authentication, account management, and service-related communication (reminders, change notifications). We do not sell, rent, or share your data with third parties for marketing purposes. Only authorized Bookra employees have access, and only to the extent necessary for technical support.",
|
||||
},
|
||||
{
|
||||
title: isCs() ? "4. Práva a žádosti" : "4. Rights and requests",
|
||||
body: isCs()
|
||||
? "V souladu s GDPR máte právo na přístup ke svým údajům, jejich opravu, výmaz nebo omezení zpracování. Žádosti o přístup, opravu nebo výmaz údajů řeší provozovatel konkrétního účtu. Bookra poskytuje technické prostředky pro bezpečné zpracování. Svá práva můžete uplatnit e-mailem na hello@bookra.eu."
|
||||
: "In accordance with GDPR, you have the right to access, correct, delete, or restrict processing of your data. Access, correction, and deletion requests are handled by the operator of the relevant workspace. Bookra provides the technical system for secure processing. You may exercise your rights by emailing hello@bookra.eu.",
|
||||
},
|
||||
{
|
||||
title: isCs() ? "5. Doba uchování a zabezpečení" : "5. Retention and security",
|
||||
body: isCs()
|
||||
? "Rezervační údaje uchováváme po dobu existence účtu provozovatele, pokud není smazány dříve. Technické záznamy uchováváme po dobu 90 dnů. Všechna data jsou přenášena šifrovaně (TLS), uchovávána v zabezpečených datových centrech v EU a pravidelně zálohována."
|
||||
: "Booking data is retained for the lifetime of the operator's account unless deleted earlier. Technical records are kept for 90 days. All data is transmitted encrypted (TLS), stored in secure EU data centers, and regularly backed up.",
|
||||
},
|
||||
];
|
||||
|
||||
const sections = () => (kind() === "terms" ? termsSections() : privacySections());
|
||||
|
||||
return (
|
||||
<section class="section-container py-16">
|
||||
@@ -42,23 +93,32 @@ export function LegalRoute() {
|
||||
{i18n.t(`legal.${kind()}.title`)}
|
||||
</h1>
|
||||
<p class="text-lg text-ink-muted">{i18n.t(`legal.${kind()}.body`)}</p>
|
||||
<p class="text-sm text-ink-subtle">{companyInfo()}</p>
|
||||
</div>
|
||||
|
||||
<For each={sections()}>
|
||||
{(section) => (
|
||||
<Card class="surface-elevated">
|
||||
{(section, i) => (
|
||||
<Card class="surface-elevated hover:shadow-md transition-shadow">
|
||||
<CardHeader>
|
||||
<CardTitle>{section.title}</CardTitle>
|
||||
<CardTitle class="text-lg">{section.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-ink-muted">{section.body}</p>
|
||||
<p class="text-ink-muted leading-relaxed">{section.body}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<div class="pt-4 border-t border-border">
|
||||
<p class="text-sm text-ink-subtle">
|
||||
{isCs()
|
||||
? "Poslední aktualizace: květen 2026. V případě dotazů nás kontaktujte na hello@bookra.eu."
|
||||
: "Last updated: May 2026. For questions, contact us at hello@bookra.eu."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="lg:sticky lg:top-24">
|
||||
<aside class="lg:sticky lg:top-24 h-fit space-y-6">
|
||||
<Card class="surface-elevated overflow-hidden">
|
||||
<CardContent class="p-8 text-center">
|
||||
<div class="mb-6 flex justify-center">
|
||||
@@ -71,15 +131,28 @@ export function LegalRoute() {
|
||||
/>
|
||||
<p class="text-sm leading-relaxed text-ink-muted">
|
||||
{kind() === "terms"
|
||||
? i18n.locale() === "cs"
|
||||
? isCs()
|
||||
? "Pravidla držíme stručná, čitelná a navázaná na reálný provoz služby."
|
||||
: "We keep terms short, readable, and tied to real product behavior."
|
||||
: i18n.locale() === "cs"
|
||||
: isCs()
|
||||
? "Soukromí řešíme prakticky: minimum dat navíc, jasný účel a předvídatelné zpracování."
|
||||
: "We handle privacy pragmatically: minimal extra data, clear purpose, and predictable processing."}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-center">
|
||||
<BookraCharacter pose={helperPose()} size="sm" animate={true} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="surface-elevated">
|
||||
<CardContent class="p-6">
|
||||
<h3 class="font-display font-semibold text-ink text-sm mb-3">
|
||||
{isCs() ? "Kontakt" : "Contact"}
|
||||
</h3>
|
||||
<div class="space-y-2 text-sm text-ink-muted">
|
||||
<p>Bookra </p>
|
||||
<p>IČO: 24330621</p>
|
||||
<p>Česká republika</p>
|
||||
<a href="mailto:hello@bookra.eu" class="text-accent hover:underline block mt-2">
|
||||
hello@bookra.eu
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
import { createSignal, createMemo, Show, For } from "solid-js";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { useI18n } from "../providers/i18n-provider";
|
||||
|
||||
const PricingRoute = () => {
|
||||
const navigate = useNavigate();
|
||||
const { locale, toggleLocale } = useI18n();
|
||||
const isCs = () => locale() === "cs";
|
||||
const [billingInterval, setBillingInterval] = createSignal<"monthly" | "yearly">("monthly");
|
||||
const isYearly = () => billingInterval() === "yearly";
|
||||
const [openFaq, setOpenFaq] = createSignal<number | null>(0);
|
||||
|
||||
const plans = createMemo(() => [
|
||||
{
|
||||
id: "starter",
|
||||
name: "Starter",
|
||||
desc: isCs() ? "Pro jednotlivce a malé podniky" : "For individuals and small businesses",
|
||||
monthly: isCs() ? "199 Kč" : "$9",
|
||||
yearly: isCs() ? "1 990 Kč" : "$90",
|
||||
period: isCs() ? "/měsíc" : "/mo",
|
||||
yearlyPeriod: isCs() ? "/rok" : "/yr",
|
||||
savings: isCs() ? "Ušetřete 398 Kč" : "Save $18",
|
||||
savingsPercent: "17%",
|
||||
trial: isCs() ? "15 dní zdarma po registraci" : "15 days free after sign-up",
|
||||
features: [
|
||||
isCs() ? "Do 50 rezervací/měsíc" : "Up to 50 bookings/month",
|
||||
isCs() ? "1 lokace, 1 zaměstnanec" : "1 location, 1 staff member",
|
||||
isCs() ? "E-mailová podpora" : "Email support",
|
||||
isCs() ? "Základní rezervační widget" : "Basic booking widget",
|
||||
isCs() ? "Potvrzení e-mailem" : "Email confirmations",
|
||||
],
|
||||
cta: isCs() ? "Začít zdarma" : "Start for free",
|
||||
popular: false,
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
desc: isCs() ? "Pro rostoucí podniky" : "For growing businesses",
|
||||
monthly: isCs() ? "399 Kč" : "$19",
|
||||
yearly: isCs() ? "3 990 Kč" : "$190",
|
||||
period: isCs() ? "/měsíc" : "/mo",
|
||||
yearlyPeriod: isCs() ? "/rok" : "/yr",
|
||||
savings: isCs() ? "Ušetřete 798 Kč" : "Save $38",
|
||||
savingsPercent: "17%",
|
||||
trial: isCs() ? "15 dní zdarma po registraci" : "15 days free after sign-up",
|
||||
features: [
|
||||
isCs() ? "Neomezené rezervace" : "Unlimited bookings",
|
||||
isCs() ? "3 lokace, 10 zaměstnanců" : "3 locations, 10 staff",
|
||||
isCs() ? "E-mailová připomenutí" : "Email reminders",
|
||||
isCs() ? "Prioritní podpora" : "Priority support",
|
||||
isCs() ? "Analytika a reporty" : "Analytics & reports",
|
||||
isCs() ? "Vlastní branding widgetu" : "Custom widget branding",
|
||||
isCs() ? "Rozšířené nastavení dostupnosti" : "Advanced availability",
|
||||
],
|
||||
cta: isCs() ? "Začít 15denní zkoušku" : "Start 15-day trial",
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
id: "business",
|
||||
name: "Business",
|
||||
desc: isCs() ? "Pro větší týmy a franšízy" : "For larger teams and franchises",
|
||||
monthly: isCs() ? "799 Kč" : "$39",
|
||||
yearly: isCs() ? "7 990 Kč" : "$390",
|
||||
period: isCs() ? "/měsíc" : "/mo",
|
||||
yearlyPeriod: isCs() ? "/rok" : "/yr",
|
||||
savings: isCs() ? "Ušetřete 1 598 Kč" : "Save $78",
|
||||
savingsPercent: "17%",
|
||||
trial: "",
|
||||
features: [
|
||||
isCs() ? "Neomezené vše" : "Unlimited everything",
|
||||
isCs() ? "Neomezené lokace a zaměstnanci" : "Unlimited locations & staff",
|
||||
isCs() ? "API přístup" : "API access",
|
||||
isCs() ? "Dedikovaný manažer" : "Dedicated manager",
|
||||
isCs() ? "Bílý labeling" : "White labeling",
|
||||
isCs() ? "Pokročilá analytika" : "Advanced analytics",
|
||||
isCs() ? "Integrace s externími systémy" : "External system integrations",
|
||||
],
|
||||
cta: isCs() ? "Kontaktovat nás" : "Contact us",
|
||||
popular: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const handleSelectPlan = (planId: string) => {
|
||||
// Redirect to signup with plan selection
|
||||
navigate("/?signup=true&plan=" + planId + "&billing=" + billingInterval());
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const comparisonFeatures = [
|
||||
{ key: "pricing.compare.locations", starter: "1", pro: "3", business: "∞" },
|
||||
{ key: "pricing.compare.staff", starter: "1", pro: "10", business: "∞" },
|
||||
{ key: "pricing.compare.bookings", starter: "50", pro: "∞", business: "∞" },
|
||||
{ key: "pricing.compare.emailSupport", starter: t("pricing.compare.yes"), pro: t("pricing.compare.priority"), business: t("pricing.compare.dedicated") },
|
||||
{ key: "pricing.compare.reminders", starter: "no", pro: "yes", business: "yes" },
|
||||
{ key: "pricing.compare.analytics", starter: "no", pro: "yes", business: t("pricing.compare.advanced") },
|
||||
{ key: "pricing.compare.api", starter: "no", pro: "no", business: "yes" },
|
||||
{ key: "pricing.compare.branding", starter: "no", pro: "yes", business: "yes" },
|
||||
{ key: "pricing.compare.whiteLabel", starter: "no", pro: "no", business: "yes" },
|
||||
{ key: "pricing.compare.manager", starter: "no", pro: "no", business: "yes" },
|
||||
];
|
||||
|
||||
const ComparisonValue = (props: { value: string; highlight?: boolean }) => {
|
||||
const v = props.value.toLowerCase();
|
||||
if (v === "yes" || v === t("pricing.compare.yes").toLowerCase()) {
|
||||
return (
|
||||
<span class={`inline-flex items-center justify-center w-6 h-6 rounded-full ${props.highlight ? 'bg-accent/15 text-accent' : 'bg-success/15 text-success'}`}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 6 9 17l-5-5"/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (v === "no" || v === t("pricing.compare.no").toLowerCase()) {
|
||||
return (
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-ink/5 text-ink-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6 6 18"/>
|
||||
<path d="m6 6 12 12"/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span class={`text-sm font-medium ${props.highlight ? 'text-accent' : 'text-ink'}`}>
|
||||
{props.value}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
enQ: "Can I cancel anytime?",
|
||||
csQ: "Mohu kdykoliv zrušit?",
|
||||
enA: "Yes, you can cancel anytime. If you have an annual subscription, you'll have access until the end of the period. No cancellation fees.",
|
||||
csA: "Ano, můžete zrušit kdykoliv. Pokud máte roční předplatné, budete mít přístup do konce období. Žádné poplatky za zrušení.",
|
||||
},
|
||||
{
|
||||
enQ: "What if I exceed my limits?",
|
||||
csQ: "Co když překročím limity?",
|
||||
enA: "You'll be notified and prompted to upgrade to a higher plan. Your data will be preserved and you can continue using Bookra seamlessly.",
|
||||
csA: "Budete upozorněni a vyzváni k upgradu na vyšší plán. Vaše data zůstanou zachována a můžete dál používat Bookra bez přerušení.",
|
||||
},
|
||||
{
|
||||
enQ: "What payment methods do you accept?",
|
||||
csQ: "Jaké platební metody přijímáte?",
|
||||
enA: "We accept all major credit cards through Stripe. Payments are secure, encrypted, and PCI compliant.",
|
||||
csA: "Přijímáme všechny hlavní kreditní karty přes Stripe. Platby jsou zabezpečené, šifrované a splňují PCI standard.",
|
||||
},
|
||||
{
|
||||
enQ: "Can I switch plans?",
|
||||
csQ: "Můžu změnit plán?",
|
||||
enA: "Yes, you can upgrade or downgrade at any time. When upgrading, we'll prorate the difference. When downgrading, the new rate applies at the next billing cycle.",
|
||||
csA: "Ano, můžete kdykoliv upgradovat nebo downgradovat. Při upgradu doplatíte poměrnou částku. Při downgradu se nová cena aplikuje od dalšího fakturačního období.",
|
||||
},
|
||||
{
|
||||
enQ: "Is there a free trial?",
|
||||
csQ: "Je k dispozici bezplatná zkouška?",
|
||||
enA: "Yes, every plan includes a 15-day free trial. No credit card required to start.",
|
||||
csA: "Ano, každý plán obsahuje 15denní bezplatnou zkoušku. Není potřeba zadávat platební kartu.",
|
||||
},
|
||||
{
|
||||
enQ: "Do you offer support?",
|
||||
csQ: "Poskytujete podporu?",
|
||||
enA: "Absolutely. Starter includes email support, Pro gets priority support, and Business includes a dedicated account manager.",
|
||||
csA: "Samozřejmě. Starter obsahuje e-mailovou podporu, Pro má prioritní podporu a Business zahrnuje dedikovaného account managera.",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div class="min-h-screen bg-gradient-to-br from-canvas-subtle via-canvas to-canvas-subtle">
|
||||
{/* Hero */}
|
||||
<section class="pt-16 pb-12 sm:pt-24 sm:pb-16 text-center px-4">
|
||||
<h1 class="text-4xl sm:text-5xl font-display font-bold text-ink mb-4 animate-slide-up">
|
||||
{isCs() ? "Jednoduché a férové ceny" : "Simple, fair pricing"}
|
||||
</h1>
|
||||
<p class="text-lg text-ink-muted max-w-2xl mx-auto mb-8 animate-slide-up" style={{ "animation-delay": "0.1s" }}>
|
||||
{isCs()
|
||||
? "Vyberte si plán, který vyhovuje vašemu podnikání. Žádné skryté poplatky, žádné překvapení."
|
||||
: "Choose a plan that fits your business. No hidden fees, no surprises."}
|
||||
</p>
|
||||
|
||||
{/* Billing Toggle */}
|
||||
<div class="flex items-center justify-center mb-8 animate-slide-up" style={{ "animation-delay": "0.2s" }}>
|
||||
<div class="relative inline-flex items-center gap-4">
|
||||
<span class={`text-sm font-semibold transition-colors ${!isYearly() ? 'text-ink' : 'text-ink-muted'}`}>
|
||||
{isCs() ? "Měsíčně" : "Monthly"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingInterval(isYearly() ? "monthly" : "yearly")}
|
||||
class={`relative w-14 h-7 rounded-full transition-colors duration-300 cursor-pointer ${isYearly() ? 'bg-accent' : 'bg-ink/30'}`}
|
||||
role="switch"
|
||||
aria-checked={isYearly()}
|
||||
>
|
||||
<span class={`absolute top-1 left-1 w-5 h-5 bg-white rounded-full shadow-md transition-transform duration-300 ${isYearly() ? 'translate-x-7' : 'translate-x-0'}`} />
|
||||
</button>
|
||||
<span class={`text-sm font-semibold transition-colors ${isYearly() ? 'text-ink' : 'text-ink-muted'}`}>
|
||||
{isCs() ? "Ročně" : "Yearly"}
|
||||
</span>
|
||||
<div class="absolute left-full ml-3 top-1/2 -translate-y-1/2">
|
||||
<Show when={isYearly()}>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-accent text-white text-xs font-bold rounded-full shadow-sm whitespace-nowrap">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><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>
|
||||
-17%
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing Cards */}
|
||||
<section class="pb-16 px-4">
|
||||
<div class="grid md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto items-center">
|
||||
<For each={plans()}>
|
||||
{(plan, index) => (
|
||||
<div
|
||||
class={`group relative p-6 lg:p-8 rounded-card transition-all duration-500 animate-slide-up ${plan.popular ? 'hover:shadow-2xl hover:-translate-y-2 lg:scale-105' : 'hover:shadow-xl hover:-translate-y-1'}`}
|
||||
style={{ "animation-delay": `${0.3 + index() * 0.1}s` }}
|
||||
>
|
||||
<Show when={plan.popular}>
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-ink to-ink/95 rounded-card" />
|
||||
</Show>
|
||||
<Show when={!plan.popular}>
|
||||
<div class="absolute inset-0 surface-elevated rounded-card" />
|
||||
</Show>
|
||||
|
||||
<Show when={plan.popular}>
|
||||
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
|
||||
<span class="px-3 py-1 bg-accent text-white text-xs font-display font-medium rounded-full shadow-lg">
|
||||
{t("home.pricing.popular")}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="relative z-10">
|
||||
<div class="mb-6">
|
||||
<h3 class={`font-display text-lg font-semibold mb-1 ${plan.popular ? 'text-canvas' : 'text-ink'}`}>
|
||||
{plan.name}
|
||||
</h3>
|
||||
<p class={plan.popular ? 'text-canvas/70' : 'text-ink-muted'}>{plan.desc}</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class={`font-display text-4xl font-semibold ${plan.popular ? 'text-canvas' : 'text-ink'}`}>
|
||||
{isYearly() ? plan.yearly : plan.monthly}
|
||||
</span>
|
||||
<span class={plan.popular ? 'text-canvas/60' : 'text-ink-muted'}>
|
||||
{isYearly() ? plan.yearlyPeriod : plan.period}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={isYearly()}>
|
||||
<div class="mt-2 flex items-center gap-1.5">
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-accent-subtle text-accent text-xs font-semibold rounded-full border border-accent/10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><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>
|
||||
{plan.savingsPercent}
|
||||
</span>
|
||||
<span class="text-xs text-ink-subtle">{isCs() ? 'sleva při roční platbě' : 'discount on yearly billing'}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!isYearly() && plan.trial}>
|
||||
<p class="mt-1 text-xs text-accent font-medium">{plan.trial}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8">
|
||||
<For each={plan.features}>
|
||||
{(feature) => (
|
||||
<li class={`flex items-start gap-3 ${plan.popular ? 'text-canvas/80' : 'text-ink-muted'}`}>
|
||||
<span class="mt-0.5 text-accent shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 6 9 17l-5-5"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-sm">{feature}</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<button
|
||||
onClick={() => handleSelectPlan(plan.id)}
|
||||
class={`w-full py-3 px-4 rounded-xl font-semibold text-sm transition-all duration-300 ${
|
||||
plan.popular
|
||||
? 'bg-accent text-white hover:bg-accent/90 hover:shadow-lg hover:-translate-y-0.5'
|
||||
: 'bg-ink text-canvas hover:bg-ink/90 hover:shadow-lg hover:-translate-y-0.5'
|
||||
}`}
|
||||
>
|
||||
{plan.cta}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Comparison Table */}
|
||||
<section class="py-16 px-4">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="text-center mb-10">
|
||||
<span class="text-sm font-medium text-accent mb-2 block tracking-wide uppercase">
|
||||
{isCs() ? "Detailní srovnání" : "Detailed comparison"}
|
||||
</span>
|
||||
<h2 class="text-2xl sm:text-3xl font-display font-bold text-ink">
|
||||
{isCs() ? "Porovnání plánů" : "Compare plans"}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="surface-elevated rounded-card overflow-hidden border border-border/50 shadow-sm">
|
||||
{/* Header */}
|
||||
<div class="grid grid-cols-[1fr_100px_100px_100px] sm:grid-cols-[1.5fr_1fr_1fr_1fr] gap-2 p-4 sm:p-5 bg-canvas-subtle/60 border-b border-border/50">
|
||||
<div class="text-sm font-semibold text-ink-muted self-center">{isCs() ? "Funkce" : "Feature"}</div>
|
||||
<div class="text-center font-display font-semibold text-ink text-sm">Starter</div>
|
||||
<div class="text-center">
|
||||
<span class="inline-block px-3 py-1 bg-accent/10 text-accent font-display font-semibold text-sm rounded-full">
|
||||
Pro
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-center font-display font-semibold text-ink text-sm">Business</div>
|
||||
</div>
|
||||
{/* Rows */}
|
||||
<For each={comparisonFeatures}>
|
||||
{(feature, i) => (
|
||||
<div class={`grid grid-cols-[1fr_100px_100px_100px] sm:grid-cols-[1.5fr_1fr_1fr_1fr] gap-2 p-4 sm:p-5 items-center border-b border-border/30 last:border-0 transition-colors ${i() % 2 === 0 ? 'bg-canvas/30' : ''} hover:bg-canvas-subtle/40`}>
|
||||
<span class="text-sm text-ink font-medium">{t(feature.key)}</span>
|
||||
<div class="flex justify-center">
|
||||
<ComparisonValue value={feature.starter} />
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<ComparisonValue value={feature.pro} highlight />
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<ComparisonValue value={feature.business} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<section class="py-16 px-4 bg-canvas-subtle/30">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="text-center mb-10">
|
||||
<span class="text-sm font-medium text-accent mb-2 block tracking-wide uppercase">
|
||||
{isCs() ? "Máte otázky?" : "Got questions?"}
|
||||
</span>
|
||||
<h2 class="text-2xl sm:text-3xl font-display font-bold text-ink">
|
||||
{isCs() ? "Časté dotazy" : "Frequently asked questions"}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<For each={faqs}>
|
||||
{(faq, i) => (
|
||||
<div class="surface-elevated rounded-card border border-border/40 overflow-hidden transition-all duration-300 hover:border-border/70 hover:shadow-md">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenFaq(openFaq() === i() ? null : i())}
|
||||
class="w-full flex items-center justify-between p-5 text-left group"
|
||||
>
|
||||
<span class="font-semibold text-ink text-sm sm:text-base pr-4 group-hover:text-accent transition-colors">
|
||||
{isCs() ? faq.csQ : faq.enQ}
|
||||
</span>
|
||||
<span class={`shrink-0 w-8 h-8 rounded-full bg-canvas-subtle flex items-center justify-center text-ink-muted group-hover:bg-accent/10 group-hover:text-accent transition-all duration-300 ${openFaq() === i() ? 'rotate-180' : ''}`}>
|
||||
<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="m6 9 6 6 6-6"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div class={`grid transition-all duration-300 ${openFaq() === i() ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'}`}>
|
||||
<div class="overflow-hidden">
|
||||
<div class="px-5 pb-5 text-sm text-ink-muted leading-relaxed border-t border-border/30 pt-4">
|
||||
{isCs() ? faq.csA : faq.enA}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section class="py-16 px-4">
|
||||
<div class="max-w-2xl mx-auto text-center">
|
||||
<h2 class="text-2xl font-display font-bold text-ink mb-4">
|
||||
{isCs() ? "Stále si nejste jistí?" : "Still not sure?"}
|
||||
</h2>
|
||||
<p class="text-ink-muted mb-6">
|
||||
{isCs()
|
||||
? "Začněte s bezplatným 15denním trial a rozhodněte se později."
|
||||
: "Start with a free 15-day trial and decide later."}
|
||||
</p>
|
||||
<a
|
||||
href="/dashboard?signup=true"
|
||||
class="inline-block px-8 py-3 bg-accent text-white font-semibold rounded-xl hover:bg-accent/90 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300"
|
||||
>
|
||||
{isCs() ? "Začít zdarma" : "Start for free"}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer class="border-t border-border py-8 px-4">
|
||||
<div class="max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-6 rounded bg-gradient-to-br from-accent to-accent/70 flex items-center justify-center">
|
||||
<span class="text-white font-bold text-xs">B</span>
|
||||
</div>
|
||||
<span class="text-ink-muted text-sm">© 2024 Bookra</span>
|
||||
</div>
|
||||
<nav class="flex items-center gap-6">
|
||||
<a href="/privacy" class="text-ink-muted hover:text-ink text-sm transition-colors">{isCs() ? "Ochrana soukromí" : "Privacy"}</a>
|
||||
<a href="/terms" class="text-ink-muted hover:text-ink text-sm transition-colors">{isCs() ? "Podmínky" : "Terms"}</a>
|
||||
<a href="/contact" class="text-ink-muted hover:text-ink text-sm transition-colors">{isCs() ? "Kontakt" : "Contact"}</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingRoute;
|
||||
@@ -16,6 +16,8 @@ export function PublicBookingRoute() {
|
||||
const [customerName, setCustomerName] = createSignal("");
|
||||
const [customerEmail, setCustomerEmail] = createSignal("");
|
||||
const [notes, setNotes] = createSignal("");
|
||||
const [highlightContact, setHighlightContact] = createSignal(false);
|
||||
let contactFormRef: HTMLDivElement | undefined;
|
||||
const [availability, { refetch }] = createResource(() => {
|
||||
const slug = tenantSlug();
|
||||
if (!slug) return null;
|
||||
@@ -31,6 +33,9 @@ export function PublicBookingRoute() {
|
||||
const bookSlot = async (slot: components["schemas"]["TimeSlot"]) => {
|
||||
if (!customerName().trim() || !customerEmail().trim()) {
|
||||
setBookingError(i18n.t("booking.customerRequired"));
|
||||
setHighlightContact(true);
|
||||
contactFormRef?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
setTimeout(() => setHighlightContact(false), 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -105,8 +110,8 @@ export function PublicBookingRoute() {
|
||||
<Show when={tenantSlug()}>
|
||||
<div class="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
{/* Sidebar - Contact form - ORDER 1 on mobile, 2 on desktop */}
|
||||
<div class="space-y-6 order-1 lg:order-2">
|
||||
<Card class="surface-elevated animate-slide-up" style={{ "animation-delay": "0.3s" }}>
|
||||
<div class="space-y-6 order-1 lg:order-2" ref={(el) => { contactFormRef = el; }}>
|
||||
<Card class={`surface-elevated animate-slide-up transition-all duration-300 ${highlightContact() ? 'ring-2 ring-accent shadow-lg' : ''}`} style={{ "animation-delay": "0.3s" }}>
|
||||
<CardHeader>
|
||||
<CardTitle class="font-display text-xl">{i18n.t("booking.customer.title")}</CardTitle>
|
||||
<CardDescription>{i18n.t("booking.customer.body")}</CardDescription>
|
||||
|
||||
@@ -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/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/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/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"}
|
||||
Reference in New Issue
Block a user