feat(core): consolidate auth service into backend and implement stripe billing
CI / Frontend (push) Successful in 9m54s
CI / Go - apps/auth-service (push) Failing after 24s
CI / Go - apps/backend (push) Failing after 5m43s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped

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:
Tomas Dvorak
2026-05-09 18:25:25 +02:00
parent cf3315e8fc
commit 164a37e997
69 changed files with 4630 additions and 5260 deletions
+46
View File
@@ -5,10 +5,56 @@ BOOKRA_APP_ENV=staging
BOOKRA_APP_URL=https://app.bookra.example BOOKRA_APP_URL=https://app.bookra.example
BOOKRA_API_URL=https://api.bookra.example BOOKRA_API_URL=https://api.bookra.example
BOOKRA_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth BOOKRA_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
# Stripe billing (preferred)
BOOKRA_STRIPE_SECRET_KEY=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
BOOKRA_STRIPE_WEBHOOK_SECRET=whsec_xxx
# Stripe Monthly Prices
BOOKRA_STRIPE_STARTER_CZK_MONTHLY_PRICE_ID=price_1TV6Z6GrjyNQaOSGZFcmltqI
BOOKRA_STRIPE_STARTER_USD_MONTHLY_PRICE_ID=price_1TV6cAGrjyNQaOSGXBhOq3Dk
BOOKRA_STRIPE_PRO_CZK_MONTHLY_PRICE_ID=price_1TV6ZZGrjyNQaOSGqWENnjDD
BOOKRA_STRIPE_PRO_USD_MONTHLY_PRICE_ID=price_1TV6dXGrjyNQaOSGeWzJlg2n
BOOKRA_STRIPE_BUSINESS_CZK_MONTHLY_PRICE_ID=price_1TV6bgGrjyNQaOSGqzGTM68E
BOOKRA_STRIPE_BUSINESS_USD_MONTHLY_PRICE_ID=price_1TV6dpGrjyNQaOSGCqKO42Oi
# Stripe Yearly Prices (17% discount)
BOOKRA_STRIPE_STARTER_CZK_YEARLY_PRICE_ID=price_1TVAlqGrjyNQaOSGNiZQ5tEx
BOOKRA_STRIPE_STARTER_USD_YEARLY_PRICE_ID=price_1TVAnSGrjyNQaOSGTHQHrgv3
BOOKRA_STRIPE_PRO_CZK_YEARLY_PRICE_ID=price_1TVAmVGrjyNQaOSGWDgWqYvb
BOOKRA_STRIPE_PRO_USD_YEARLY_PRICE_ID=price_1TVAnjGrjyNQaOSGvAANw64k
BOOKRA_STRIPE_BUSINESS_CZK_YEARLY_PRICE_ID=price_1TVAmsGrjyNQaOSGL7Sl5cCd
BOOKRA_STRIPE_BUSINESS_USD_YEARLY_PRICE_ID=price_1TVAo7GrjyNQaOSGB8LSCOua
# Legacy price IDs (fallback)
BOOKRA_STRIPE_STARTER_CZK_PRICE_ID=price_1TV6Z6GrjyNQaOSGZFcmltqI
BOOKRA_STRIPE_STARTER_USD_PRICE_ID=price_1TV6cAGrjyNQaOSGXBhOq3Dk
BOOKRA_STRIPE_PRO_CZK_PRICE_ID=price_1TV6ZZGrjyNQaOSGqWENnjDD
BOOKRA_STRIPE_PRO_USD_PRICE_ID=price_1TV6dXGrjyNQaOSGeWzJlg2n
BOOKRA_STRIPE_BUSINESS_CZK_PRICE_ID=price_1TV6bgGrjyNQaOSGqzGTM68E
BOOKRA_STRIPE_BUSINESS_USD_PRICE_ID=price_1TV6dpGrjyNQaOSGCqKO42Oi
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51TV6GOGrjyNQaOSGddY1BS14H63e1uYgZUgPe25WhP7yMxmZxMprN3U3scnsmKGBmREzHLrhJlVSEiErimNwVtuR00TRAcHaJi
# Admin credentials (for platform management)
BOOKRA_ADMIN_EMAIL=admin@example.com
BOOKRA_ADMIN_KEY=your-secure-admin-key-here
# Paddle billing (fallback)
BOOKRA_PADDLE_ENV=sandbox BOOKRA_PADDLE_ENV=sandbox
BOOKRA_PADDLE_API_KEY=pdl_sdbx_api_key BOOKRA_PADDLE_API_KEY=pdl_sdbx_api_key
BOOKRA_PADDLE_WEBHOOK_SECRET=pdl_ntfset_secret BOOKRA_PADDLE_WEBHOOK_SECRET=pdl_ntfset_secret
BOOKRA_PADDLE_STARTER_CZK_PRICE_ID=pri_starter_czk_123
BOOKRA_PADDLE_STARTER_USD_PRICE_ID=pri_starter_usd_123
BOOKRA_PADDLE_PRO_CZK_PRICE_ID=pri_pro_czk_123 BOOKRA_PADDLE_PRO_CZK_PRICE_ID=pri_pro_czk_123
BOOKRA_PADDLE_PRO_USD_PRICE_ID=pri_pro_usd_123
BOOKRA_PADDLE_BUSINESS_CZK_PRICE_ID=pri_business_czk_123
BOOKRA_PADDLE_BUSINESS_USD_PRICE_ID=pri_business_usd_123
VITE_PADDLE_CLIENT_TOKEN=test_paddle_client_token VITE_PADDLE_CLIENT_TOKEN=test_paddle_client_token
BOOKRA_SMTP_HOST=smtp.example.com BOOKRA_SMTP_HOST=smtp.example.com
BOOKRA_UMAMI_API_URL=https://umami.example.com BOOKRA_UMAMI_API_URL=https://umami.example.com
# Sentry (optional)
VITE_SENTRY_DSN=https://462fb8597035778961e2e06c48c7c7fd@o4511360379191296.ingest.de.sentry.io/4511360406454352
BOOKRA_SENTRY_DSN=https://462fb8597035778961e2e06c48c7c7fd@o4511360379191296.ingest.de.sentry.io/4511360406454352
+17 -4
View File
@@ -7,10 +7,23 @@
- Czech `home.step2.desc` was missing and leaked the translation key on the landing page. - Czech `home.step2.desc` was missing and leaked the translation key on the landing page.
- Footer year was stale. - Footer year was stale.
## Fixed This Session
- The dashboard unauthenticated state replaced with a dedicated conversion screen (headline, benefit cards, Sign In / Demo CTAs, register link).
- The pricing highlight label now uses the `home.pricing.popular` i18n key in both `home-route.tsx` and `pricing-route.tsx`.
- The mobile booking page now smooth-scrolls to the contact form and highlights it when a user taps a slot without filling required details, breaking the validation loop.
- The IntegrationModal widget snippets now derive the base URL from `publicBookingUrl` instead of hardcoding `bookra.eu`, fixing local dev and custom domain scenarios.
- Email settings section now has a working **Save Email Settings** button with loading state, wired to `PUT /v1/tenants/email-settings`.
## Fixed This Session (continued)
- **Accessibility**: Added `aria-label` attributes to all icon-only buttons in the dashboard (calendar nav, notifications, modal close buttons, mobile menu).
- **Widget-builder UX**: Replaced `console.error` on copy failure with a user-visible error banner.
- **Booking-manage route**: Contact email now uses `businessEmail` from booking data instead of hardcoded `support@bookra.eu`.
- **Loading states**: Added spinners and disabled states to booking create/update buttons and brand save button.
- **i18n cleanup**: Added 30+ new `dashboard.*` keys to the i18n dictionary and replaced ~20 inline ternary expressions with `i18n.t()` calls across nav items, page titles, status labels, and action buttons.
## Still Needs Frontend Polish ## Still Needs Frontend Polish
- The dashboard unauthenticated state is still a thin sign-in prompt rather than a dedicated conversion screen. - Many dashboard inline i18n ternaries remain (~280). Systematic extraction to `i18n.t()` keys is an ongoing task.
- Some dashboard copy remains English inside the Czech locale because the new guided setup/dashboard sections are MVP product copy.
- The mobile booking page puts slots before contact details, which is usable but creates a validation loop if users tap a slot first.
- The pricing highlight label is hardcoded rather than localized.
- Registration cannot be fully customer-tested locally until Neon Auth environment variables are configured. - Registration cannot be fully customer-tested locally until Neon Auth environment variables are configured.
+43
View File
@@ -58,3 +58,46 @@ npm run db:migrate:up
``` ```
`db:migrate:*` expects `BOOKRA_DATABASE_DIRECT_URL` to be exported in the shell. `db:migrate:*` expects `BOOKRA_DATABASE_DIRECT_URL` to be exported in the shell.
## Brand Colors
Bookra uses a sophisticated color system designed for modern booking interfaces with support for both light and dark themes.
### Primary Palette
- **Canvas** (`--color-canvas`): #FFFFFF (light) / #0A0A0A (dark)
- **Canvas Elevated** (`--color-canvas-elevated`): #F8F9FA (light) / #1A1A1A (dark)
- **Canvas Sunken** (`--color-canvas-sunken`): #F1F3F4 (light) / #252525 (dark)
### Accent Colors
- **Primary** (`--color-primary`): #3B82F6
- **Primary Hover** (`--color-primary-hover`): #2563EB
- **Primary Active** (`--color-primary-active`): #1D4ED8
### Semantic Colors
- **Success** (`--color-success`): #10B981
- **Warning** (`--color-warning`): #F59E0B
- **Error** (`--color-error`): #EF4444
- **Info** (`--color-info`): #06B6D4
### Text Colors
- **Text Primary** (`--color-text-primary`): #111827 (light) / #F9FAFB (dark)
- **Text Secondary** (`--color-text-secondary`): #6B7280 (light) / #D1D5DB (dark)
- **Text Muted** (`--color-text-muted`): #9CA3AF (light) / #9CA3AF (dark)
### Border & Surface
- **Border** (`--color-border`): #E5E7EB (light) / #374151 (dark)
- **Surface Glass** (`--color-surface-glass`): rgba(255, 255, 255, 0.8) (light) / rgba(0, 0, 0, 0.8) (dark)
### Shadow System
- **Shadow XS** (`--shadow-xs`): 0 1px 2px 0 rgba(0, 0, 0, 0.05)
- **Shadow SM** (`--shadow-sm`): 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)
- **Shadow MD** (`--shadow-md`): 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)
- **Shadow LG** (`--shadow-lg`): 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)
- **Shadow XL** (`--shadow-xl`): 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)
- **Shadow 2XL** (`--shadow-2xl`): 0 25px 50px -12px rgba(0, 0, 0, 0.25)
### Animation Timing
- **Ease Out Expo** (`--ease-out-expo`): cubic-bezier(0.16, 1, 0.3, 1)
- **Ease Spring** (`--ease-spring`): cubic-bezier(0.68, -0.55, 0.265, 1.55)
These colors are implemented as CSS custom properties and are used throughout the frontend application for consistent theming and accessibility.
-10
View File
@@ -1,10 +0,0 @@
.git
.github
.env
.env.*
bin
coverage
tmp
*.log
Dockerfile
.dockerignore
-22
View File
@@ -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
-21
View File
@@ -1,21 +0,0 @@
# Environment variables
.env
# Binary
auth-service
*.exe
# Go
vendor/
*.log
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
-38
View File
@@ -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"]
-42
View File
@@ -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).
-121
View File
@@ -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
}
-59
View File
@@ -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
)
-146
View File
@@ -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=
-79
View File
@@ -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
}
-333
View File
@@ -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")
}
}
-113
View File
@@ -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)
}
}
-140
View File
@@ -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
}
-224
View File
@@ -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
-14
View File
@@ -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

-407
View File
@@ -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&apos;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&apos;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 &amp; 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>
+26
View File
@@ -11,14 +11,37 @@ import (
"bookra/apps/backend/internal/api" "bookra/apps/backend/internal/api"
"bookra/apps/backend/internal/config" "bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db" "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() { func main() {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
log.Fatalf("load config: %v", err) log.Fatalf("load config: %v", err)
} }
initSentry(cfg)
pools, err := db.NewPools(cfg) pools, err := db.NewPools(cfg)
if err != nil { if err != nil {
log.Fatalf("create database pools: %v", err) log.Fatalf("create database pools: %v", err)
@@ -31,6 +54,9 @@ func main() {
} }
defer server.Close() defer server.Close()
// Start background job for trial ending emails
go server.StartBackgroundJobs()
httpServer := &http.Server{ httpServer := &http.Server{
Addr: ":" + cfg.Port, Addr: ":" + cfg.Port,
Handler: server.Handler(), Handler: server.Handler(),
+3 -1
View File
@@ -3,13 +3,14 @@ module bookra/apps/backend
go 1.26.2 go 1.26.2
require ( require (
github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0
github.com/MicahParks/keyfunc/v3 v3.8.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-contrib/cors v1.7.7
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.1 github.com/jackc/pgx/v5 v5.9.1
github.com/stripe/stripe-go/v81 v81.0.0
golang.org/x/time v0.9.0 golang.org/x/time v0.9.0
) )
@@ -20,6 +21,7 @@ require (
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // 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/httpin v0.20.3 // indirect
github.com/ggicci/owl v0.8.2 // indirect github.com/ggicci/owl v0.8.2 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
+12
View File
@@ -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/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 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= 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 h1:qy93bUsF/eGbX8WJfXEjB3bjgUrv7MKZ3qVWR73DIGY=
github.com/ggicci/httpin v0.20.3/go.mod h1:ppHGT8xt99mRnDUuehLLWl2RAVLKG+VGn48GjK5xaLA= github.com/ggicci/httpin v0.20.3/go.mod h1:ppHGT8xt99mRnDUuehLLWl2RAVLKG+VGn48GjK5xaLA=
github.com/ggicci/owl v0.8.2 h1:og+lhqpzSMPDdEB+NJfzoAJARP7qCG3f8uUC3xvGukA= 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/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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 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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 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 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/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= 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 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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.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 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= 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 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 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 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+243
View File
@@ -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()
}
+302 -2
View File
@@ -1,12 +1,15 @@
package api package api
import ( import (
"context"
"errors" "errors"
"io" "io"
"log"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"bookra/apps/backend/internal/admin"
"bookra/apps/backend/internal/auth" "bookra/apps/backend/internal/auth"
"bookra/apps/backend/internal/billing" "bookra/apps/backend/internal/billing"
"bookra/apps/backend/internal/bookings" "bookra/apps/backend/internal/bookings"
@@ -18,6 +21,7 @@ import (
"bookra/apps/backend/internal/notifications" "bookra/apps/backend/internal/notifications"
"bookra/apps/backend/internal/tenancy" "bookra/apps/backend/internal/tenancy"
"github.com/getsentry/sentry-go"
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/time/rate" "golang.org/x/time/rate"
@@ -28,6 +32,10 @@ type Server struct {
cfg config.Config cfg config.Config
pools *db.Pools pools *db.Pools
verifier *auth.Verifier 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) { func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
@@ -41,8 +49,10 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
bookingService := bookings.NewService(repository, notificationService) bookingService := bookings.NewService(repository, notificationService)
customerBookingService := bookings.NewCustomerService(repository, notificationService) customerBookingService := bookings.NewCustomerService(repository, notificationService)
tenantService := tenancy.NewService(repository) tenantService := tenancy.NewService(repository)
catalogService := catalog.NewService(repository)
billingService := billing.NewService(cfg, 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) publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5)
server := &Server{ server := &Server{
@@ -50,6 +60,10 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
cfg: cfg, cfg: cfg,
pools: pools, pools: pools,
verifier: verifier, verifier: verifier,
authService: authService,
adminService: adminService,
billingService: billingService,
notificationService: notificationService,
} }
server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{ 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) { server.router.GET("/v1/meta/config", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"environment": cfg.Environment, "environment": cfg.Environment,
"neonAuthEnabled": verifier.Enabled(), "neonAuthEnabled": verifier.Enabled(),
"apiUrl": cfg.APIURL, "apiUrl": cfg.APIURL,
"demoMode": cfg.DemoMode, "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) { server.router.GET("/v1/public/tenants/:tenantSlug/availability", func(c *gin.Context) {
response, err := bookingService.Availability(c.Request.Context(), c.Param("tenantSlug")) response, err := bookingService.Availability(c.Request.Context(), c.Param("tenantSlug"))
if err != nil { if err != nil {
@@ -126,6 +366,23 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusCreated, response) 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 := server.router.Group("/v1")
protected.Use(auth.RequireAuth(verifier, repository, cfg.DemoMode)) 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 status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) { if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound status = http.StatusNotFound
} else if errors.Is(err, catalog.ErrPlanLimitReached) {
status = http.StatusForbidden
} }
c.JSON(status, gin.H{"error": err.Error()}) c.JSON(status, gin.H{"error": err.Error()})
return return
@@ -492,7 +751,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return 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 { if err != nil {
status := http.StatusInternalServerError status := http.StatusInternalServerError
if errors.Is(err, billing.ErrBillingMembership) { 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"}) 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) { server.router.POST("/api/paddle_webhook", func(c *gin.Context) {
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil { if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -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 { func authorizeJobRunner(c *gin.Context, cfg config.Config) bool {
if cfg.JobRunnerKey == "" { if cfg.JobRunnerKey == "" {
return false return false
+319
View File
@@ -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
}
+569 -34
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"slices" "slices"
@@ -17,6 +18,12 @@ import (
paddle "github.com/PaddleHQ/paddle-go-sdk/v5" paddle "github.com/PaddleHQ/paddle-go-sdk/v5"
"github.com/jackc/pgx/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 ( var (
@@ -26,9 +33,12 @@ var (
ErrPaddleNotConfigured = errors.New("paddle is not configured") ErrPaddleNotConfigured = errors.New("paddle is not configured")
ErrPaddleSignatureMissing = errors.New("paddle signature is missing") ErrPaddleSignatureMissing = errors.New("paddle signature is missing")
ErrPaddleWebhookMissing = errors.New("paddle webhook secret is not configured") 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.created",
"subscription.updated", "subscription.updated",
"subscription.activated", "subscription.activated",
@@ -42,11 +52,23 @@ var allowedWebhookEvents = []string{
"transaction.past_due", "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 { type Service struct {
cfg config.Config cfg config.Config
repo db.Repository repo db.Repository
client *paddle.SDK client *paddle.SDK
verifier *paddle.WebhookVerifier verifier *paddle.WebhookVerifier
stripeEnabled bool
} }
type webhookEnvelope struct { type webhookEnvelope struct {
@@ -63,6 +85,7 @@ type webhookEnvelope struct {
func NewService(cfg config.Config, repo db.Repository) *Service { func NewService(cfg config.Config, repo db.Repository) *Service {
service := &Service{cfg: cfg, repo: repo} service := &Service{cfg: cfg, repo: repo}
// Initialize Paddle client
if strings.TrimSpace(cfg.PaddleAPIKey) != "" { if strings.TrimSpace(cfg.PaddleAPIKey) != "" {
var client *paddle.SDK var client *paddle.SDK
var err error var err error
@@ -76,13 +99,28 @@ func NewService(cfg config.Config, repo db.Repository) *Service {
} }
} }
if strings.TrimSpace(cfg.PaddleWebhookKey) != "" { // Initialize Stripe
service.verifier = paddle.NewWebhookVerifier(cfg.PaddleWebhookKey, paddle.VerifierWithTimestampTolerance(5*time.Minute)) if strings.TrimSpace(cfg.StripeAPIKey) != "" {
stripe.Key = cfg.StripeAPIKey
service.stripeEnabled = true
} }
return service 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) { func (s *Service) GetSubscription(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil { if err != nil {
@@ -97,7 +135,7 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{ return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
TenantID: membership.Tenant.ID, TenantID: membership.Tenant.ID,
BillingProvider: "paddle", BillingProvider: s.cfg.BillingProvider(),
Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"), Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"),
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode), PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
Currency: "czk", Currency: "czk",
@@ -109,7 +147,7 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
return toSnapshot(membership.Tenant, record, s.cfg), nil 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) membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@@ -118,7 +156,93 @@ func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Pr
return domain.CheckoutLaunchResponse{}, err 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 == "" { if priceID == "" {
return domain.CheckoutLaunchResponse{}, ErrBillingPlanUnsupported return domain.CheckoutLaunchResponse{}, ErrBillingPlanUnsupported
} }
@@ -157,16 +281,26 @@ func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (doma
if customerID == "" { if customerID == "" {
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{ return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
TenantID: membership.Tenant.ID, TenantID: membership.Tenant.ID,
BillingProvider: "paddle", BillingProvider: s.cfg.BillingProvider(),
Status: "inactive", Status: "inactive",
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode), PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
Currency: "czk", Currency: "czk",
}, s.cfg), nil }, 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 { if s.client == nil {
return domain.SubscriptionSnapshot{}, ErrPaddleNotConfigured return domain.SubscriptionSnapshot{}, ErrPaddleNotConfigured
} }
record, err := s.syncPaddleData(ctx, membership.Tenant, customerID) record, err := s.syncPaddleData(ctx, membership.Tenant, customerID)
if err != nil { if err != nil {
return domain.SubscriptionSnapshot{}, err return domain.SubscriptionSnapshot{}, err
@@ -183,30 +317,53 @@ func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Prin
} }
return domain.PortalSessionResponse{}, err return domain.PortalSessionResponse{}, err
} }
if s.client == nil {
return domain.PortalSessionResponse{}, ErrPaddleNotConfigured
}
customerID := derefString(membership.Tenant.BillingCustomerID) customerID := derefString(membership.Tenant.BillingCustomerID)
if customerID == "" { if customerID == "" {
return domain.PortalSessionResponse{}, ErrBillingCustomerMissing 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} request := &paddle.CreateCustomerPortalSessionRequest{CustomerID: customerID}
if subscriptionID := derefString(membership.Tenant.BillingSubscription); subscriptionID != "" { if subscriptionID := derefString(membership.Tenant.BillingSubscription); subscriptionID != "" {
request.SubscriptionIDs = []string{subscriptionID} request.SubscriptionIDs = []string{subscriptionID}
} }
session, err := s.client.CreateCustomerPortalSession(ctx, request) sess, err := s.client.CreateCustomerPortalSession(ctx, request)
if err != nil { if err != nil {
return domain.PortalSessionResponse{}, err return domain.PortalSessionResponse{}, err
} }
url := strings.TrimSpace(session.URLs.General.Overview) url := strings.TrimSpace(sess.URLs.General.Overview)
if url == "" && len(session.URLs.Subscriptions) > 0 { if url == "" && len(sess.URLs.Subscriptions) > 0 {
url = firstNonEmpty( url = firstNonEmpty(
session.URLs.Subscriptions[0].UpdateSubscriptionPaymentMethod, sess.URLs.Subscriptions[0].UpdateSubscriptionPaymentMethod,
session.URLs.Subscriptions[0].CancelSubscription, sess.URLs.Subscriptions[0].CancelSubscription,
) )
} }
if url == "" { 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 { 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 { if s.verifier == nil {
return ErrPaddleWebhookMissing 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 { if err := json.Unmarshal(payload, &event); err != nil {
return err return err
} }
if !slices.Contains(allowedWebhookEvents, event.EventType) { if !slices.Contains(allowedPaddleWebhookEvents, event.EventType) {
return nil return nil
} }
@@ -337,7 +597,7 @@ func (s *Service) syncPaddleData(ctx context.Context, tenant db.TenantRecord, cu
record.CurrentPeriodEnd = parseRFC3339Ptr(timePeriodEnd(selected.CurrentBillingPeriod)) record.CurrentPeriodEnd = parseRFC3339Ptr(timePeriodEnd(selected.CurrentBillingPeriod))
if len(selected.Items) > 0 { if len(selected.Items) > 0 {
record.PriceID = selected.Items[0].Price.ID 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 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 { func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg config.Config) domain.SubscriptionSnapshot {
if record.PlanCode == "" { if record.PlanCode == "" {
record.PlanCode = shared.NormalizePlanCode(tenant.PlanCode) record.PlanCode = shared.NormalizePlanCode(tenant.PlanCode)
@@ -363,10 +732,15 @@ func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg con
} }
customerID := firstNonEmpty(record.BillingCustomerID, derefString(tenant.BillingCustomerID)) 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{ return domain.SubscriptionSnapshot{
TenantID: tenant.ID, TenantID: tenant.ID,
Provider: firstNonEmpty(record.BillingProvider, tenant.BillingProvider, "paddle"), Provider: provider,
CustomerID: customerID, CustomerID: customerID,
SubscriptionID: firstNonEmpty(record.BillingSubscriptionID, derefString(tenant.BillingSubscription)), SubscriptionID: firstNonEmpty(record.BillingSubscriptionID, derefString(tenant.BillingSubscription)),
Status: record.Status, Status: record.Status,
@@ -380,26 +754,62 @@ func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg con
PaymentMethodLast4: record.PaymentMethodLast4, PaymentMethodLast4: record.PaymentMethodLast4,
Entitlements: entitlementsForPlan(record.PlanCode), Entitlements: entitlementsForPlan(record.PlanCode),
DisplayPrices: displayPricesForPlan(record.PlanCode), DisplayPrices: displayPricesForPlan(record.PlanCode),
TrialDays: 30, TrialDays: func() int {
if record.PlanCode == "starter" || record.PlanCode == "pro" {
return 15
}
return 0
}(),
LastSyncedAt: record.LastSyncedAt, LastSyncedAt: record.LastSyncedAt,
CheckoutURLAvailable: checkoutAvailable(cfg, record.PlanCode), CheckoutURLAvailable: checkoutAvailable,
SyncAvailable: cfg.PaddleConfigured(), SyncAvailable: syncAvailable,
PortalAvailable: cfg.PaddleConfigured() && customerID != "", PortalAvailable: portalAvailable,
} }
} }
func entitlementsForPlan(planCode string) domain.PlanEntitlements { func entitlementsForPlan(planCode string) domain.PlanEntitlements {
switch shared.NormalizePlanCode(planCode) { switch shared.NormalizePlanCode(planCode) {
case "starter": 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": 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: 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 planCode, currencies := range s.cfg.PaddlePriceMatrix {
for _, configuredPriceID := range currencies { for _, configuredPriceID := range currencies {
if configuredPriceID != "" && configuredPriceID == priceID { if configuredPriceID != "" && configuredPriceID == priceID {
@@ -410,7 +820,18 @@ func (s *Service) planCodeForPrice(priceID string, fallback string) string {
return shared.NormalizePlanCode(fallback) 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)) resolvedPlan := shared.NormalizePlanCode(strings.TrimSpace(planCode))
if resolvedPlan == "" { if resolvedPlan == "" {
resolvedPlan = "pro" resolvedPlan = "pro"
@@ -427,6 +848,48 @@ func (s *Service) priceForPlan(planCode string, currency string) (string, string
return s.cfg.PaddlePriceMatrix[resolvedPlan]["usd"], resolvedPlan, "usd" 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 { func subscriptionRank(subscription *paddle.Subscription) int {
switch subscription.Status { switch subscription.Status {
case paddle.SubscriptionStatusActive: case paddle.SubscriptionStatusActive:
@@ -447,19 +910,22 @@ func subscriptionRank(subscription *paddle.Subscription) int {
func displayPricesForPlan(planCode string) []domain.PlanDisplayPrice { func displayPricesForPlan(planCode string) []domain.PlanDisplayPrice {
switch shared.NormalizePlanCode(planCode) { switch shared.NormalizePlanCode(planCode) {
case "starter": case "starter":
// Starter: $5/month, $50/year (save $10 = ~17%)
return []domain.PlanDisplayPrice{ return []domain.PlanDisplayPrice{
{Currency: "czk", AmountCents: 11900, Formatted: "119 Kc"}, {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"}, {Currency: "usd", AmountCents: 500, Formatted: "$5/mo", YearlyAmountCents: 5000, YearlyFormatted: "$50/yr", YearlySavings: "Save $10", YearlySavingsPercent: 17},
} }
case "business": case "business":
// Business: $50/month, $500/year (save $100 = ~17%)
return []domain.PlanDisplayPrice{ return []domain.PlanDisplayPrice{
{Currency: "czk", AmountCents: 119900, Formatted: "1 199 Kc"}, {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"}, {Currency: "usd", AmountCents: 5000, Formatted: "$50/mo", YearlyAmountCents: 50000, YearlyFormatted: "$500/yr", YearlySavings: "Save $100", YearlySavingsPercent: 17},
} }
default: default:
// Pro: $20/month, $200/year (save $40 = ~17%)
return []domain.PlanDisplayPrice{ return []domain.PlanDisplayPrice{
{Currency: "czk", AmountCents: 49900, Formatted: "499 Kc"}, {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"}, {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 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 { func customDataString(data map[string]any, key string) string {
if data == nil { if data == nil {
return "" return ""
@@ -554,3 +1044,48 @@ func firstNonEmpty(values ...string) string {
} }
return "" 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
}
+38 -2
View File
@@ -3,6 +3,7 @@ package catalog
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"time" "time"
"bookra/apps/backend/internal/db" "bookra/apps/backend/internal/db"
@@ -17,14 +18,25 @@ var (
ErrInvalidBooking = errors.New("invalid booking request") ErrInvalidBooking = errors.New("invalid booking request")
ErrTenantNotFound = errors.New("tenant not found") ErrTenantNotFound = errors.New("tenant not found")
ErrTenantMembership = errors.New("tenant membership not found") ErrTenantMembership = errors.New("tenant membership not found")
ErrPlanLimitReached = errors.New("plan limit reached")
) )
type Service struct { 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 { func NewService(repo db.Repository, billingService interface {
return &Service{repo: repo} 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 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{ params := db.CreateLocationParams{
TenantID: membership.Tenant.ID, TenantID: membership.Tenant.ID,
Name: req.Name, Name: req.Name,
@@ -74,6 +98,18 @@ func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal
return domain.Location{}, err 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{ return domain.Location{
ID: rec.ID, ID: rec.ID,
TenantID: rec.TenantID, TenantID: rec.TenantID,
+66
View File
@@ -28,8 +28,14 @@ type Config struct {
PaddleAPIKey string PaddleAPIKey string
PaddleWebhookKey string PaddleWebhookKey string
PaddlePriceMatrix map[string]map[string]string PaddlePriceMatrix map[string]map[string]string
StripeAPIKey string
StripeWebhookKey string
StripePriceMatrix map[string]map[string]string
AdminEmail string
AdminKey string
UmamiAPIURL string UmamiAPIURL string
UmamiAPIKey string UmamiAPIKey string
SentryDSN string
DemoMode bool DemoMode bool
} }
@@ -53,8 +59,14 @@ func Load() (Config, error) {
PaddleAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_API_KEY")), PaddleAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_API_KEY")),
PaddleWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_WEBHOOK_SECRET")), PaddleWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_WEBHOOK_SECRET")),
PaddlePriceMatrix: paddlePriceMatrixFromEnv(), 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")), UmamiAPIURL: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_URL")),
UmamiAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_KEY")), UmamiAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_KEY")),
SentryDSN: strings.TrimSpace(os.Getenv("BOOKRA_SENTRY_DSN")),
DemoMode: boolFromEnv("BOOKRA_DEMO_MODE", false), 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"] != "" 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 { func paddlePriceMatrixFromEnv() map[string]map[string]string {
matrix := map[string]map[string]string{ matrix := map[string]map[string]string{
"starter": {}, "starter": {},
@@ -132,6 +172,32 @@ func paddlePriceMatrixFromEnv() map[string]map[string]string {
return matrix 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 { func normalizePaddleEnvironment(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) { switch strings.ToLower(strings.TrimSpace(value)) {
case "live", "production": case "live", "production":
+135
View File
@@ -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
}
+137
View File
@@ -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
}
+111
View File
@@ -38,6 +38,23 @@ type Repository interface {
UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error 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) 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 // Location / Zone Management
ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error) ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error)
GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error) GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error)
@@ -85,6 +102,46 @@ type TenantRecord struct {
BillingSubscription *string 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 { type TenantMembershipRecord struct {
Tenant TenantRecord Tenant TenantRecord
UserID string UserID string
@@ -1303,6 +1360,60 @@ func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string
return pgx.ErrNoRows 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 { func Reference(prefix string, at time.Time) string {
return fmt.Sprintf("%s-%s-%s", prefix, at.UTC().Format("20060102150405"), strings.Split(uuid.NewString(), "-")[0]) return fmt.Sprintf("%s-%s-%s", prefix, at.UTC().Format("20060102150405"), strings.Split(uuid.NewString(), "-")[0])
} }
+80 -4
View File
@@ -146,16 +146,32 @@ type CreateBookingResponse struct {
type PlanEntitlements struct { type PlanEntitlements struct {
MaxLocations int `json:"maxLocations"` MaxLocations int `json:"maxLocations"`
MaxStaff int `json:"maxStaff"` MaxStaff int `json:"maxStaff"`
MaxBookingsMonth int `json:"maxBookingsMonth"` // -1 = unlimited
EmailReminders bool `json:"emailReminders"` EmailReminders bool `json:"emailReminders"`
AdvancedReporting bool `json:"advancedReporting"` AdvancedReporting bool `json:"advancedReporting"`
WidgetEmbedding bool `json:"widgetEmbedding"` WidgetEmbedding bool `json:"widgetEmbedding"`
UmamiTracking bool `json:"umamiTracking"` 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 { type PlanDisplayPrice struct {
Currency string `json:"currency"` Currency string `json:"currency"`
AmountCents int `json:"amountCents"` AmountCents int `json:"amountCents"`
Formatted string `json:"formatted"` 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 { type SubscriptionSnapshot struct {
@@ -184,15 +200,20 @@ type SubscriptionSnapshot struct {
type CheckoutSessionRequest struct { type CheckoutSessionRequest struct {
PlanCode string `json:"planCode"` PlanCode string `json:"planCode"`
Currency string `json:"currency,omitempty"` Currency string `json:"currency,omitempty"`
BillingInterval string `json:"billingInterval,omitempty"` // "monthly" or "yearly", defaults to "monthly"
} }
type CheckoutLaunchResponse struct { 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"` CustomerID string `json:"customerId,omitempty"`
CustomerEmail string `json:"customerEmail,omitempty"` CustomerEmail string `json:"customerEmail,omitempty"`
SuccessRedirectURL string `json:"successRedirectUrl"` // Common
CancelRedirectURL string `json:"cancelRedirectUrl"` SuccessRedirectURL string `json:"successRedirectUrl,omitempty"`
CustomData map[string]string `json:"customData"` CancelRedirectURL string `json:"cancelRedirectUrl,omitempty"`
CustomData map[string]string `json:"customData,omitempty"`
} }
type PortalSessionResponse struct { type PortalSessionResponse struct {
@@ -321,6 +342,61 @@ type CancelBookingRequest struct {
Reason string `json:"reason,omitempty"` 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 // WORKING HOURS MODELS
// ============================================ // ============================================
@@ -15,6 +15,8 @@ const (
EmailTypeReschedule EmailType = "reschedule" EmailTypeReschedule EmailType = "reschedule"
EmailTypeCancellation EmailType = "cancellation" EmailTypeCancellation EmailType = "cancellation"
EmailTypeBusinessNotify EmailType = "business_notify" EmailTypeBusinessNotify EmailType = "business_notify"
EmailTypeUsageWarning EmailType = "usage_warning"
EmailTypeTrialEnding EmailType = "trial_ending"
) )
type BookingEmailData struct { type BookingEmailData struct {
@@ -39,6 +41,110 @@ type BookingEmailData struct {
AddToCalendarURL string AddToCalendarURL string
} }
type UsageNotificationData struct {
Type EmailType
TenantName string
TenantSlug string
BusinessEmail string
BrandColor string
AdminEmail string
Locale 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 { func RenderEmailMessage(data BookingEmailData) EmailMessage {
subject := renderSubject(data) subject := renderSubject(data)
htmlBody := renderHTMLBody(data) htmlBody := renderHTMLBody(data)
@@ -285,3 +285,77 @@ func (p smtpEmailProvider) Send(_ context.Context, message EmailMessage) (Delive
ExternalID: fmt.Sprintf("smtp-%d", time.Now().UnixNano()), ExternalID: fmt.Sprintf("smtp-%d", time.Now().UnixNano()),
}, nil }, 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
+35 -1
View File
@@ -639,9 +639,16 @@ components:
planCode: planCode:
type: string type: string
enum: [starter, pro, business] enum: [starter, pro, business]
description: The plan to subscribe to
currency: currency:
type: string type: string
enum: [czk, usd] enum: [czk, usd]
description: Currency for the subscription
billingInterval:
type: string
enum: [monthly, yearly]
default: monthly
description: Billing interval. Yearly gets 17% discount.
PlanDisplayPrice: PlanDisplayPrice:
type: object type: object
required: [currency, amountCents, formatted] required: [currency, amountCents, formatted]
@@ -651,29 +658,56 @@ components:
enum: [czk, usd] enum: [czk, usd]
amountCents: amountCents:
type: integer type: integer
description: Monthly price in cents
formatted: formatted:
type: string 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: CheckoutLaunchResponse:
type: object 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: properties:
checkoutUrl:
type: string
format: uri
description: Stripe checkout URL (redirect the user to this URL)
priceId: priceId:
type: string type: string
description: Paddle price ID for client-side checkout
customerId: customerId:
type: string type: string
description: Paddle customer ID
customerEmail: customerEmail:
type: string type: string
format: email format: email
description: Customer email for Paddle checkout
successRedirectUrl: successRedirectUrl:
type: string type: string
format: uri format: uri
description: URL to redirect after successful checkout
cancelRedirectUrl: cancelRedirectUrl:
type: string type: string
format: uri format: uri
description: URL to redirect after cancelled checkout
customData: customData:
type: object type: object
additionalProperties: additionalProperties:
type: string type: string
description: Custom metadata for Paddle checkout
PortalSessionResponse: PortalSessionResponse:
type: object type: object
required: [url] required: [url]
+2
View File
@@ -14,7 +14,9 @@
"@bookra/shared-types": "0.1.0", "@bookra/shared-types": "0.1.0",
"@neondatabase/neon-js": "^0.2.0-beta.1", "@neondatabase/neon-js": "^0.2.0-beta.1",
"@paddle/paddle-js": "^1.3.2", "@paddle/paddle-js": "^1.3.2",
"@sentry/react": "^10.52.0",
"@solidjs/router": "^0.15.3", "@solidjs/router": "^0.15.3",
"@stripe/stripe-js": "^4.0.0",
"solid-js": "^1.9.5" "solid-js": "^1.9.5"
}, },
"devDependencies": { "devDependencies": {
@@ -19,13 +19,20 @@ export function IntegrationModal(props: IntegrationModalProps) {
setTimeout(() => setCopiedSnippet(null), 2000); 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> const htmlWidgetCode = `<div id="bookra-widget"></div>
<script> <script>
(function() { (function() {
var script = document.createElement('script'); var script = document.createElement('script');
script.src = "https://bookra.eu/widget.js"; script.src = "${baseUrl}/widget.js";
script.async = true; script.async = true;
script.onload = function() { script.onload = function() {
BookraWidget.init({ BookraWidget.init({
@@ -65,7 +72,7 @@ function App() {
add_action('wp_footer', function() { add_action('wp_footer', function() {
?> ?>
<div id="bookra-widget"></div> <div id="bookra-widget"></div>
<script src="https://bookra.eu/widget.js" async></script> <script src="${baseUrl}/widget.js" async></script>
<script> <script>
window.addEventListener('load', function() { window.addEventListener('load', function() {
BookraWidget.init({ BookraWidget.init({
+4
View File
@@ -76,6 +76,7 @@ export const Shell: ParentComponent = (props) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [mobileMenuOpen, setMobileMenuOpen] = createSignal(false); const [mobileMenuOpen, setMobileMenuOpen] = createSignal(false);
const hideHeader = () => location.pathname.startsWith("/dashboard"); const hideHeader = () => location.pathname.startsWith("/dashboard");
const hideFooter = () => location.pathname.startsWith("/dashboard");
const [signInOpen, setSignInOpen] = createSignal(false); const [signInOpen, setSignInOpen] = createSignal(false);
const [authMode, setAuthMode] = createSignal<"sign-in" | "register">("sign-in"); const [authMode, setAuthMode] = createSignal<"sign-in" | "register">("sign-in");
const [name, setName] = createSignal(""); const [name, setName] = createSignal("");
@@ -88,6 +89,7 @@ export const Shell: ParentComponent = (props) => {
const showGoogleSignIn = () => auth.supportsGoogleSignIn() && authMode() === "sign-in"; const showGoogleSignIn = () => auth.supportsGoogleSignIn() && authMode() === "sign-in";
const navLinks = [ const navLinks = [
{ href: "/pricing", label: i18n.t("nav.pricing") },
{ href: "/about", label: i18n.t("nav.about") }, { href: "/about", label: i18n.t("nav.about") },
{ href: "/contact", label: i18n.t("nav.contact") }, { href: "/contact", label: i18n.t("nav.contact") },
]; ];
@@ -409,6 +411,7 @@ export const Shell: ParentComponent = (props) => {
<main class="flex-1">{props.children}</main> <main class="flex-1">{props.children}</main>
<Show when={!hideFooter()}>
{/* Footer */} {/* Footer */}
<footer class="border-t border-border/60 py-16 bg-canvas-subtle/40"> <footer class="border-t border-border/60 py-16 bg-canvas-subtle/40">
<div class="section-container"> <div class="section-container">
@@ -480,6 +483,7 @@ export const Shell: ParentComponent = (props) => {
</div> </div>
</div> </div>
</footer> </footer>
</Show>
<Dialog open={signInOpen()} onClose={() => setSignInOpen(false)}> <Dialog open={signInOpen()} onClose={() => setSignInOpen(false)}>
<DialogHeader> <DialogHeader>
@@ -215,6 +215,7 @@ export function WidgetBuilder(props: WidgetBuilderProps) {
const [selectedSize, setSelectedSize] = createSignal<WidgetSize>("default"); const [selectedSize, setSelectedSize] = createSignal<WidgetSize>("default");
const [selectedPosition, setSelectedPosition] = createSignal<WidgetPosition>("bottom-right"); const [selectedPosition, setSelectedPosition] = createSignal<WidgetPosition>("bottom-right");
const [copiedSnippet, setCopiedSnippet] = createSignal<string | null>(null); const [copiedSnippet, setCopiedSnippet] = createSignal<string | null>(null);
const [copyError, setCopyError] = createSignal<string | null>(null);
const [showPreview, setShowPreview] = createSignal(true); const [showPreview, setShowPreview] = createSignal(true);
const [draggedIndex, setDraggedIndex] = createSignal<number | null>(null); const [draggedIndex, setDraggedIndex] = createSignal<number | null>(null);
const [customColor, setCustomColor] = createSignal(props.config.primaryColor || "#a65c3e"); const [customColor, setCustomColor] = createSignal(props.config.primaryColor || "#a65c3e");
@@ -1077,9 +1078,11 @@ export class BookraWidgetComponent implements OnInit {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
setCopiedSnippet(snippetId); setCopiedSnippet(snippetId);
setCopyError(null);
setTimeout(() => setCopiedSnippet(null), 2000); setTimeout(() => setCopiedSnippet(null), 2000);
} catch (err) { } 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> </div>
</CardHeader> </CardHeader>
<CardContent> <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"> <Tabs defaultValue="html">
<TabsList class="mb-4 flex-wrap"> <TabsList class="mb-4 flex-wrap">
<TabsTrigger value="html">HTML</TabsTrigger> <TabsTrigger value="html">HTML</TabsTrigger>
+27
View File
@@ -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"}`,
});
}
+19
View File
@@ -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;
}
+5
View File
@@ -3,8 +3,12 @@ import { lazy } from "solid-js";
import { Route, Router } from "@solidjs/router"; import { Route, Router } from "@solidjs/router";
import App from "./App"; import App from "./App";
import "./styles/index.css"; import "./styles/index.css";
import { initSentry } from "./lib/sentry";
initSentry();
const HomeRoute = lazy(() => import("./routes/home-route").then((module) => ({ default: module.HomeRoute }))); 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 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 AuthCallbackRoute = lazy(() => import("./routes/auth-callback-route").then((module) => ({ default: module.AuthCallbackRoute })));
const ContactRoute = lazy(() => import("./routes/contact-route").then((module) => ({ default: module.ContactRoute }))); const ContactRoute = lazy(() => import("./routes/contact-route").then((module) => ({ default: module.ContactRoute })));
@@ -18,6 +22,7 @@ render(
() => ( () => (
<Router root={App}> <Router root={App}>
<Route path="/" component={HomeRoute} /> <Route path="/" component={HomeRoute} />
<Route path="/pricing" component={PricingRoute} />
<Route path="/about" component={AboutRoute} /> <Route path="/about" component={AboutRoute} />
<Route path="/auth/callback" component={AuthCallbackRoute} /> <Route path="/auth/callback" component={AuthCallbackRoute} />
<Route path="/contact" component={ContactRoute} /> <Route path="/contact" component={ContactRoute} />
+140 -10
View File
@@ -13,6 +13,7 @@ const dictionaries = {
// Navigation & Auth // Navigation & Auth
"nav.booking": "Veřejná rezervace", "nav.booking": "Veřejná rezervace",
"nav.dashboard": "Aplikace", "nav.dashboard": "Aplikace",
"nav.pricing": "Ceník",
"nav.about": "O nás", "nav.about": "O nás",
"nav.contact": "Kontakt", "nav.contact": "Kontakt",
@@ -164,6 +165,26 @@ const dictionaries = {
"home.pricing.biz.cta": "Kontaktovat prodej", "home.pricing.biz.cta": "Kontaktovat prodej",
"home.pricing.biz.trial": "Individuální řešení na míru", "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 // CTA
"home.cta.title": "Připraveni zjednodušit své rezervace?", "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.", "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.links.title": "Navigace",
"footer.legal.title": "Právní informace", "footer.legal.title": "Právní informace",
// Dashboard (existing) // Dashboard
"dashboard.title": "Přehled podniku", "dashboard.title": "Přehled podniku",
"dashboard.body": "Sledujte rezervace, nastavení, předplatné a rezervační widget na jednom místě.", "dashboard.body": "Sledujte rezervace, nastavení, předplatné a rezervační widget na jednom místě.",
"dashboard.kpi.bookings": "Rezervace tento týden", "dashboard.overview": "Přehled",
"dashboard.kpi.cancellations": "Zrušení", "dashboard.bookings": "Rezervace",
"dashboard.kpi.utilization": "Vytížení", "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.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.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.", "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.liveData": "Živá data",
"dashboard.liveDataBody": "Dashboard, nastavení a předplatné se načítají z API pro přihlášený účet.", "dashboard.liveDataBody": "Dashboard, nastavení a předplatné se načítají z API pro přihlášený účet.",
"dashboard.apiReady": "API připojení aktivní", "dashboard.apiReady": "API připojení aktivní",
"dashboard.billing": "Předplatné",
"dashboard.checkout": "Otevřít platbu", "dashboard.checkout": "Otevřít platbu",
"dashboard.refreshBilling": "Obnovit předplatné", "dashboard.refreshBilling": "Obnovit předplatné",
"dashboard.plan": "Plán", "dashboard.plan": "Plán",
@@ -326,6 +385,12 @@ const dictionaries = {
"contact.info.email.desc": "Preferujete psát? Jsme tu pro vás.", "contact.info.email.desc": "Preferujete psát? Jsme tu pro vás.",
"contact.info.hours.title": "Pracovní doba", "contact.info.hours.title": "Pracovní doba",
"contact.info.hours.desc": "Odpovídáme během pracovních dní 9:00 — 17:00 CET.", "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
"legal.privacy.title": "Ochrana soukromí", "legal.privacy.title": "Ochrana soukromí",
@@ -345,6 +410,7 @@ const dictionaries = {
// Navigation & Auth // Navigation & Auth
"nav.booking": "Public booking", "nav.booking": "Public booking",
"nav.dashboard": "App", "nav.dashboard": "App",
"nav.pricing": "Pricing",
"nav.about": "About us", "nav.about": "About us",
"nav.contact": "Contact", "nav.contact": "Contact",
@@ -496,6 +562,26 @@ const dictionaries = {
"home.pricing.biz.cta": "Contact sales", "home.pricing.biz.cta": "Contact sales",
"home.pricing.biz.trial": "Custom enterprise solutions", "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 // CTA
"home.cta.title": "Ready to simplify your bookings?", "home.cta.title": "Ready to simplify your bookings?",
"home.cta.subtitle": "Join thousands of businesses saving time with Bookra.", "home.cta.subtitle": "Join thousands of businesses saving time with Bookra.",
@@ -567,12 +653,51 @@ const dictionaries = {
"footer.links.title": "Navigation", "footer.links.title": "Navigation",
"footer.legal.title": "Legal", "footer.legal.title": "Legal",
// Dashboard (existing) // Dashboard
"dashboard.title": "Owner 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.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.overview": "Overview",
"dashboard.kpi.cancellations": "Cancellations", "dashboard.bookings": "Bookings",
"dashboard.kpi.utilization": "Utilization", "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.title": "Welcome to Bookra",
"dashboard.welcome.body": "Simplify your bookings and spend more time doing what you love.", "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.", "dashboard.authRequired": "Live dashboard data needs a Neon Auth session and JWT.",
@@ -580,7 +705,6 @@ const dictionaries = {
"dashboard.liveData": "Live data", "dashboard.liveData": "Live data",
"dashboard.liveDataBody": "Dashboard, tenant, and billing data are loaded from the API for the signed-in workspace.", "dashboard.liveDataBody": "Dashboard, tenant, and billing data are loaded from the API for the signed-in workspace.",
"dashboard.apiReady": "API connection active", "dashboard.apiReady": "API connection active",
"dashboard.billing": "Billing",
"dashboard.checkout": "Open checkout", "dashboard.checkout": "Open checkout",
"dashboard.refreshBilling": "Refresh billing", "dashboard.refreshBilling": "Refresh billing",
"dashboard.plan": "Plan", "dashboard.plan": "Plan",
@@ -658,6 +782,12 @@ const dictionaries = {
"contact.info.email.desc": "Prefer to write? We're here for you.", "contact.info.email.desc": "Prefer to write? We're here for you.",
"contact.info.hours.title": "Working hours", "contact.info.hours.title": "Working hours",
"contact.info.hours.desc": "We respond on business days 9:00 — 17:00 CET.", "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
"legal.privacy.title": "Privacy", "legal.privacy.title": "Privacy",
@@ -40,6 +40,7 @@ export function BookingManageRoute() {
customerEmail: "alice@example.com", customerEmail: "alice@example.com",
service: "Yoga Flow Class", service: "Yoga Flow Class",
businessName: "Serenity Wellness Studio", businessName: "Serenity Wellness Studio",
businessEmail: "support@bookra.eu",
startsAt: new Date(Date.now() + 86400000).toISOString(), startsAt: new Date(Date.now() + 86400000).toISOString(),
endsAt: new Date(Date.now() + 86400000 + 3600000).toISOString(), endsAt: new Date(Date.now() + 86400000 + 3600000).toISOString(),
location: "Main Studio, 123 Wellness Street", location: "Main Studio, 123 Wellness Street",
@@ -331,7 +332,7 @@ export function BookingManageRoute() {
: 'Have questions or need special arrangements? Contact the business directly.'} : 'Have questions or need special arrangements? Contact the business directly.'}
</p> </p>
<a <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" 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'} {i18n.locale() === 'cs' ? 'Poslat zprávu' : 'Send message'}
+98 -103
View File
@@ -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 { useI18n } from "../providers/i18n-provider";
import { BookraCharacter } from "../components/bookra-character"; import { BookraCharacter } from "../components/bookra-character";
import { import {
@@ -17,38 +17,48 @@ export function ContactRoute() {
const [message, setMessage] = createSignal(""); const [message, setMessage] = createSignal("");
const [submitted, setSubmitted] = createSignal(false); const [submitted, setSubmitted] = createSignal(false);
const [submitting, setSubmitting] = 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) => { const handleSubmit = async (e: Event) => {
e.preventDefault(); e.preventDefault();
setError("");
setSubmitting(true); setSubmitting(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000)); try {
setSubmitting(false); 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); setSubmitted(true);
} catch {
setError(i18n.t("contact.error.body"));
} finally {
setSubmitting(false);
}
}; };
return ( return (
<div class="animate-fade-in"> <div class="animate-fade-in">
{/* Hero Section */} {/* Hero Section */}
<section class="relative pt-16 pb-12 lg:pt-24 lg:pb-16 overflow-hidden"> <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="section-container relative">
<div class="max-w-3xl mx-auto text-center"> <div class="max-w-3xl mx-auto text-center">
{/* Character at top */}
<div class="flex justify-center mb-8"> <div class="flex justify-center mb-8">
<BookraCharacter pose="headphones" size="xl" animate={true} /> <BookraCharacter pose="headphones" size="xl" animate={true} />
</div> </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="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'} {i18n.locale() === 'cs' ? 'Jsme tu pro vás' : 'We are here for you'}
</span> </span>
<h1 class="text-display-xl font-semibold text-ink mb-6 tracking-tight animate-slide-up"> <h1 class="text-display-xl font-semibold text-ink mb-6 tracking-tight animate-slide-up">
{i18n.t("contact.title")} {i18n.t("contact.title")}
</h1> </h1>
@@ -59,17 +69,78 @@ export function ContactRoute() {
</div> </div>
</section> </section>
{/* Contact Form Section */} {/* Story + Form split */}
<section class="py-16 lg:py-24 bg-canvas-subtle/30"> <section class="py-16 lg:py-24 bg-canvas-subtle/30">
<div class="section-container"> <div class="section-container">
<div class="max-w-2xl mx-auto"> <div class="max-w-5xl mx-auto grid lg:grid-cols-[1fr_1.2fr] gap-12 lg:gap-16 items-start">
<Show when={!submitted()} fallback={ {/* 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>
</div>
<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>
</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>
</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'
: 'We usually respond within 24 hours'}
</p>
</div>
</div>
{/* Form side */}
<div>
<Switch>
<Match when={submitted()}>
<Card class="surface-elevated border-success/20"> <Card class="surface-elevated border-success/20">
<CardContent class="py-12"> <CardContent class="py-12">
<div class="flex flex-col items-center text-center"> <div class="flex flex-col items-center text-center">
<div class="relative mb-6"> <div class="relative mb-6">
<BookraCharacter pose="success" size="xl" animate={true} /> <BookraCharacter pose="success" size="xl" animate={true} />
<div class="absolute -top-2 -right-2 text-3xl animate-bounce">🎉</div>
</div> </div>
<h2 class="text-display-md font-semibold text-ink mb-4"> <h2 class="text-display-md font-semibold text-ink mb-4">
{i18n.t("contact.success.title")} {i18n.t("contact.success.title")}
@@ -80,7 +151,8 @@ export function ContactRoute() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
}> </Match>
<Match when={true}>
<Card class="surface-elevated overflow-hidden"> <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="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"> <div class="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center">
@@ -91,7 +163,7 @@ export function ContactRoute() {
<CardTitle class="text-xl">{i18n.t("contact.form.title")}</CardTitle> <CardTitle class="text-xl">{i18n.t("contact.form.title")}</CardTitle>
</div> </div>
<CardContent class="p-6"> <CardContent class="p-6">
<form onSubmit={handleSubmit} class="space-y-6"> <form onSubmit={handleSubmit} class="space-y-5">
<Input <Input
label={i18n.t("contact.form.name")} label={i18n.t("contact.form.name")}
type="text" type="text"
@@ -114,8 +186,12 @@ export function ContactRoute() {
onInput={(e) => setMessage(e.currentTarget.value)} onInput={(e) => setMessage(e.currentTarget.value)}
rows={5} rows={5}
required required
minLength={10}
placeholder={i18n.locale() === 'cs' ? "Napište nám, jak vám můžeme pomoci..." : "Tell us how we can help you..."} 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 <Button
type="submit" type="submit"
fullWidth fullWidth
@@ -131,89 +207,8 @@ export function ContactRoute() {
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
</Show> </Match>
</div> </Switch>
</div>
</section>
{/* 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"/>
</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>
</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">
<BookraCharacter pose="main" size="sm" animate={true} />
<p class="text-ink-muted text-sm">
{i18n.locale() === 'cs'
? 'Odpovídáme obvykle do 24 hodin'
: 'We usually respond within 24 hours'}
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
File diff suppressed because it is too large Load Diff
+222 -80
View File
@@ -1,5 +1,5 @@
import { A } from "@solidjs/router"; 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 { useI18n } from "../providers/i18n-provider";
import { BookraCharacter } from "../components/bookra-character"; import { BookraCharacter } from "../components/bookra-character";
@@ -107,6 +107,105 @@ const StepCard = (props: StepCardProps) => (
// Main home route component // Main home route component
export function HomeRoute() { export function HomeRoute() {
const i18n = useI18n(); 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 [isVisible, setIsVisible] = createSignal(false);
const [currentDate, setCurrentDate] = createSignal(new Date()); const [currentDate, setCurrentDate] = createSignal(new Date());
@@ -512,75 +611,96 @@ export function HomeRoute() {
{i18n.t("home.pricing.subtitle")} {i18n.t("home.pricing.subtitle")}
</p> </p>
</div> </div>
<div class="grid md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto items-center">
{/* Starter Plan */} {/* Billing Toggle */}
<div <div class="flex items-center justify-center mb-12 animate-slide-up" style={{ "animation-delay": "0.25s" }}>
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" <div class="relative inline-flex items-center gap-4">
style={{ "animation-delay": "0.3s" }} <span class={`text-sm font-semibold transition-colors ${!isYearly() ? 'text-ink' : 'text-ink-muted'}`}>
> {isCs() ? "Měsíčně" : "Monthly"}
<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>
<span class="text-ink-muted">{i18n.t("home.pricing.perMonth")}</span> <button
<p class="mt-1 text-xs text-accent font-medium">{i18n.t("home.pricing.starter.trial")}</p> type="button"
</div> onClick={() => setBillingInterval(isYearly() ? "monthly" : "yearly")}
<ul class="space-y-3 mb-8"> class={`relative w-14 h-7 rounded-full transition-colors duration-300 cursor-pointer ${isYearly() ? 'bg-accent' : 'bg-ink/30'}`}
{[i18n.t("home.pricing.starter.f1"), i18n.t("home.pricing.starter.f2"), i18n.t("home.pricing.starter.f3")].map((feature) => ( role="switch"
<li class="flex items-start gap-3 text-ink-muted"> aria-checked={isYearly()}
<span class="mt-0.5 text-accent shrink-0"> aria-label={isYearly() ? (isCs() ? "Ročně" : "Yearly") : (isCs() ? "Měsíčně" : "Monthly")}
<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"
> >
{i18n.t("home.pricing.starter.cta")} <span
</A> 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>
</Show>
</div>
</div>
</div> </div>
{/* Pro Plan - Highlighted */} <div class="grid md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto items-center">
{plans().map((plan, index) => (
<div <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" 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.4s" }} style={{ "animation-delay": `${0.3 + index * 0.1}s` }}
> >
{/* Gradient background for highlighted card */} {/* 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" /> <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 */} {/* Popular badge */}
<Show when={plan.popular}>
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-10"> <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"> <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")} {i18n.t("home.pricing.popular")}
</span> </span>
</div> </div>
</Show>
<div class="relative z-10"> <div class="relative z-10">
<div class="mb-6"> <div class="mb-6">
<h3 class="font-display text-lg font-semibold mb-1 text-canvas"> <h3 class={`font-display text-lg font-semibold mb-1 ${plan.popular ? 'text-canvas' : 'text-ink'}`}>
{i18n.t("home.pricing.pro.name")} {plan.name}
</h3> </h3>
<p class="text-canvas/70">{i18n.t("home.pricing.pro.desc")}</p> <p class={plan.popular ? 'text-canvas/70' : 'text-ink-muted'}>{plan.desc}</p>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<span class="font-display text-4xl font-semibold text-canvas"> <div class="flex items-baseline gap-1">
{i18n.locale() === 'cs' ? '499 Kč' : '$20'} <span class={`font-display text-4xl font-semibold ${plan.popular ? 'text-canvas' : 'text-ink'}`}>
{isYearly() ? plan.yearly : plan.monthly}
</span> </span>
<span class="text-canvas/60">{i18n.t("home.pricing.perMonth")}</span> <span class={plan.popular ? 'text-canvas/60' : 'text-ink-muted'}>
<p class="mt-1 text-xs text-accent font-medium">{i18n.t("home.pricing.pro.trial")}</p> {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> </div>
<ul class="space-y-3 mb-8"> <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) => ( {plan.features.map((feature) => (
<li class="flex items-start gap-3 text-canvas/80"> <li class={`flex items-start gap-3 ${plan.popular ? 'text-canvas/80' : 'text-ink-muted'}`}>
<span class="mt-0.5 text-accent shrink-0"> <span class="mt-0.5 text-accent shrink-0">
<CheckIcon /> <CheckIcon />
</span> </span>
@@ -590,47 +710,69 @@ export function HomeRoute() {
</ul> </ul>
<A <A
href="/dashboard" 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" 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'}`}
> >
{i18n.t("home.pricing.pro.cta")} {plan.cta}
</A> </A>
</div> </div>
</div> </div>
))}
</div>
</div>
</section>
{/* Business Plan */} {/* Comparison Table */}
<div <section class="py-16 px-4">
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" <div class="section-container">
style={{ "animation-delay": "0.5s" }} <div class="max-w-4xl mx-auto">
> <div class="text-center mb-10">
<div class="mb-6"> <span class="text-sm font-medium text-accent mb-2 block tracking-wide uppercase">
<h3 class="font-display text-lg font-semibold mb-1 text-ink"> {i18n.t("pricing.compare.eyebrow")}
{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>
<span class="text-ink-muted">{i18n.t("home.pricing.perMonth")}</span> <h2 class="text-2xl sm:text-3xl font-display font-bold text-ink">
<p class="mt-1 text-xs text-accent font-medium">{i18n.t("home.pricing.biz.trial")}</p> {i18n.t("pricing.compare.title")}
</h2>
</div> </div>
<ul class="space-y-3 mb-8"> <div class="surface-elevated rounded-card overflow-hidden border border-border/50 shadow-sm">
{[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) => ( {/* Header */}
<li class="flex items-start gap-3 text-ink-muted"> <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">
<span class="mt-0.5 text-accent shrink-0"> <div class="text-sm font-semibold text-ink-muted self-center">{i18n.t("pricing.compare.feature")}</div>
<CheckIcon /> <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> </span>
<span class="text-sm">{feature}</span> </div>
</li> <div class="text-center font-display font-semibold text-ink text-sm">Business</div>
))} </div>
</ul> {/* Rows */}
<A <For each={[
href="/dashboard" { key: "pricing.compare.locations", starter: "1", pro: "3", business: "∞" },
class="block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 btn-secondary w-full" { key: "pricing.compare.staff", starter: "1", pro: "10", business: "∞" },
> { key: "pricing.compare.bookings", starter: "50", pro: "∞", business: "∞" },
{i18n.t("home.pricing.biz.cta")} { key: "pricing.compare.emailSupport", starter: i18n.t("pricing.compare.yes"), pro: i18n.t("pricing.compare.priority"), business: i18n.t("pricing.compare.dedicated") },
</A> { 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> </div>
</div> </div>
+98 -25
View File
@@ -9,30 +9,81 @@ export function LegalRoute() {
const i18n = useI18n(); const i18n = useI18n();
const kind = () => (params.kind === "terms" ? "terms" : "privacy"); const kind = () => (params.kind === "terms" ? "terms" : "privacy");
const heroPose = () => (kind() === "terms" ? "flag" : "educate"); const heroPose = () => (kind() === "terms" ? "flag" : "educate");
const helperPose = () => (kind() === "terms" ? "announcement" : "happy_note"); const isCs = () => i18n.locale() === "cs";
const sections = () =>
kind() === "terms" 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: i18n.t("legal.terms.service.title"), title: isCs() ? "1. Úvod a předmět smlouvy" : "1. Introduction and subject",
body: i18n.t("legal.terms.service.body"), 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: i18n.t("legal.terms.billing.title"), title: isCs() ? "2. Registrace a účet" : "2. Registration and account",
body: i18n.t("legal.terms.billing.body"), 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: i18n.t("legal.privacy.data.title"),
body: i18n.t("legal.privacy.data.body"),
}, },
{ {
title: i18n.t("legal.privacy.rights.title"), title: isCs() ? "3. Předplatné a platby" : "3. Subscription and payments",
body: i18n.t("legal.privacy.rights.body"), 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 ( return (
<section class="section-container py-16"> <section class="section-container py-16">
<div class="grid gap-8 lg:grid-cols-[minmax(0,1fr)_340px]"> <div class="grid gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
@@ -42,23 +93,32 @@ export function LegalRoute() {
{i18n.t(`legal.${kind()}.title`)} {i18n.t(`legal.${kind()}.title`)}
</h1> </h1>
<p class="text-lg text-ink-muted">{i18n.t(`legal.${kind()}.body`)}</p> <p class="text-lg text-ink-muted">{i18n.t(`legal.${kind()}.body`)}</p>
<p class="text-sm text-ink-subtle">{companyInfo()}</p>
</div> </div>
<For each={sections()}> <For each={sections()}>
{(section) => ( {(section, i) => (
<Card class="surface-elevated"> <Card class="surface-elevated hover:shadow-md transition-shadow">
<CardHeader> <CardHeader>
<CardTitle>{section.title}</CardTitle> <CardTitle class="text-lg">{section.title}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p class="text-ink-muted">{section.body}</p> <p class="text-ink-muted leading-relaxed">{section.body}</p>
</CardContent> </CardContent>
</Card> </Card>
)} )}
</For> </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> </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"> <Card class="surface-elevated overflow-hidden">
<CardContent class="p-8 text-center"> <CardContent class="p-8 text-center">
<div class="mb-6 flex justify-center"> <div class="mb-6 flex justify-center">
@@ -71,15 +131,28 @@ export function LegalRoute() {
/> />
<p class="text-sm leading-relaxed text-ink-muted"> <p class="text-sm leading-relaxed text-ink-muted">
{kind() === "terms" {kind() === "terms"
? i18n.locale() === "cs" ? isCs()
? "Pravidla držíme stručná, čitelná a navázaná na reálný provoz služby." ? "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." : "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í." ? "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."} : "We handle privacy pragmatically: minimal extra data, clear purpose, and predictable processing."}
</p> </p>
<div class="mt-6 flex justify-center"> </CardContent>
<BookraCharacter pose={helperPose()} size="sm" animate={true} /> </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> </div>
</CardContent> </CardContent>
</Card> </Card>
+424
View File
@@ -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 [customerName, setCustomerName] = createSignal("");
const [customerEmail, setCustomerEmail] = createSignal(""); const [customerEmail, setCustomerEmail] = createSignal("");
const [notes, setNotes] = createSignal(""); const [notes, setNotes] = createSignal("");
const [highlightContact, setHighlightContact] = createSignal(false);
let contactFormRef: HTMLDivElement | undefined;
const [availability, { refetch }] = createResource(() => { const [availability, { refetch }] = createResource(() => {
const slug = tenantSlug(); const slug = tenantSlug();
if (!slug) return null; if (!slug) return null;
@@ -31,6 +33,9 @@ export function PublicBookingRoute() {
const bookSlot = async (slot: components["schemas"]["TimeSlot"]) => { const bookSlot = async (slot: components["schemas"]["TimeSlot"]) => {
if (!customerName().trim() || !customerEmail().trim()) { if (!customerName().trim() || !customerEmail().trim()) {
setBookingError(i18n.t("booking.customerRequired")); setBookingError(i18n.t("booking.customerRequired"));
setHighlightContact(true);
contactFormRef?.scrollIntoView({ behavior: "smooth", block: "center" });
setTimeout(() => setHighlightContact(false), 2000);
return; return;
} }
@@ -105,8 +110,8 @@ export function PublicBookingRoute() {
<Show when={tenantSlug()}> <Show when={tenantSlug()}>
<div class="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]"> <div class="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
{/* Sidebar - Contact form - ORDER 1 on mobile, 2 on desktop */} {/* Sidebar - Contact form - ORDER 1 on mobile, 2 on desktop */}
<div class="space-y-6 order-1 lg:order-2"> <div class="space-y-6 order-1 lg:order-2" ref={(el) => { contactFormRef = el; }}>
<Card class="surface-elevated animate-slide-up" style={{ "animation-delay": "0.3s" }}> <Card class={`surface-elevated animate-slide-up transition-all duration-300 ${highlightContact() ? 'ring-2 ring-accent shadow-lg' : ''}`} style={{ "animation-delay": "0.3s" }}>
<CardHeader> <CardHeader>
<CardTitle class="font-display text-xl">{i18n.t("booking.customer.title")}</CardTitle> <CardTitle class="font-display text-xl">{i18n.t("booking.customer.title")}</CardTitle>
<CardDescription>{i18n.t("booking.customer.body")}</CardDescription> <CardDescription>{i18n.t("booking.customer.body")}</CardDescription>
+1 -1
View File
@@ -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"}
+147
View File
@@ -24,7 +24,9 @@
"@bookra/shared-types": "0.1.0", "@bookra/shared-types": "0.1.0",
"@neondatabase/neon-js": "^0.2.0-beta.1", "@neondatabase/neon-js": "^0.2.0-beta.1",
"@paddle/paddle-js": "^1.3.2", "@paddle/paddle-js": "^1.3.2",
"@sentry/react": "^10.52.0",
"@solidjs/router": "^0.15.3", "@solidjs/router": "^0.15.3",
"@stripe/stripe-js": "^4.0.0",
"solid-js": "^1.9.5" "solid-js": "^1.9.5"
}, },
"devDependencies": { "devDependencies": {
@@ -37,6 +39,15 @@
"vite-plugin-solid": "^2.11.6" "vite-plugin-solid": "^2.11.6"
} }
}, },
"apps/frontend/node_modules/@stripe/stripe-js": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-4.10.0.tgz",
"integrity": "sha512-KrMOL+sH69htCIXCaZ4JluJ35bchuCCznyPyrbN8JXSGQfwBI1SuIEMZNwvy8L8ykj29t6sa5BAAiL7fNoLZ8A==",
"license": "MIT",
"engines": {
"node": ">=12.16"
}
},
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -4583,6 +4594,48 @@
"url": "https://ko-fi.com/killymxi" "url": "https://ko-fi.com/killymxi"
} }
}, },
"node_modules/@sentry-internal/browser-utils": {
"version": "10.52.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.52.0.tgz",
"integrity": "sha512-x/yEPZdpH6NGQeoeQnV9tj8reAH8twNttiltGZl2o8Rk7sQeUfe7E8yuYP2XbJ2RqyZK5qRS3COrNyMPzf6KFA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.52.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/browser-utils/node_modules/@sentry/core": {
"version": "10.52.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.52.0.tgz",
"integrity": "sha512-VA/kAqLhkMnRWY2RXdBLyTemR9D4m7MVRy/gyapoq9yvllVPx9WXbvKgnMP2LQp7mFgT/oLFvw58aQKaYTGn3A==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "10.52.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.52.0.tgz",
"integrity": "sha512-5kAn1W8ZvCuHtEHXpq6iRkUMdNCilwww+YxaN2yofVrCivAbB3Ha5JJUMqmWOPW0pC27zGYmoJMIDvG+PczUxA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.52.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback/node_modules/@sentry/core": {
"version": "10.52.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.52.0.tgz",
"integrity": "sha512-VA/kAqLhkMnRWY2RXdBLyTemR9D4m7MVRy/gyapoq9yvllVPx9WXbvKgnMP2LQp7mFgT/oLFvw58aQKaYTGn3A==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/node-cpu-profiler": { "node_modules/@sentry-internal/node-cpu-profiler": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/node-cpu-profiler/-/node-cpu-profiler-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/node-cpu-profiler/-/node-cpu-profiler-2.2.0.tgz",
@@ -4596,6 +4649,75 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/replay": {
"version": "10.52.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.52.0.tgz",
"integrity": "sha512-diywyuc/H7VTUR+W5ryVmLF+0X4UP1OskMqb6V8RSAvJHcj2JmIm7uP+Fc6ACTno+b6AUShwT/L4xVXzO6X9Cw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.52.0",
"@sentry/core": "10.52.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "10.52.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.52.0.tgz",
"integrity": "sha512-BI5ie4dxPuUJ344CXVSnAxY1xZCbghglPSCIlTOYODpR9so9yo5IZh+Mwspt0oWsUMaxWJiQSNYlbPWi7WDavg==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "10.52.0",
"@sentry/core": "10.52.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas/node_modules/@sentry/core": {
"version": "10.52.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.52.0.tgz",
"integrity": "sha512-VA/kAqLhkMnRWY2RXdBLyTemR9D4m7MVRy/gyapoq9yvllVPx9WXbvKgnMP2LQp7mFgT/oLFvw58aQKaYTGn3A==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay/node_modules/@sentry/core": {
"version": "10.52.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.52.0.tgz",
"integrity": "sha512-VA/kAqLhkMnRWY2RXdBLyTemR9D4m7MVRy/gyapoq9yvllVPx9WXbvKgnMP2LQp7mFgT/oLFvw58aQKaYTGn3A==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser": {
"version": "10.52.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.52.0.tgz",
"integrity": "sha512-ijL9jN86oXwXQWbwhPlEb70ODJSEmjxQEQdnZkC4gDWbjswcwvRsVJPYk+1xl2ir2iZixRIHipVxDcLwian35g==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.52.0",
"@sentry-internal/feedback": "10.52.0",
"@sentry-internal/replay": "10.52.0",
"@sentry-internal/replay-canvas": "10.52.0",
"@sentry/core": "10.52.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser/node_modules/@sentry/core": {
"version": "10.52.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.52.0.tgz",
"integrity": "sha512-VA/kAqLhkMnRWY2RXdBLyTemR9D4m7MVRy/gyapoq9yvllVPx9WXbvKgnMP2LQp7mFgT/oLFvw58aQKaYTGn3A==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/core": { "node_modules/@sentry/core": {
"version": "9.47.1", "version": "9.47.1",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.47.1.tgz", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.47.1.tgz",
@@ -4705,6 +4827,31 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry/react": {
"version": "10.52.0",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.52.0.tgz",
"integrity": "sha512-2m72QCsja2cJJHD0ALxRnVt0qMEC2FV4LSi6AAiEdEG4lTb6mgcxavx5pJrW90jE+6dMGPbUz4q8c9vi4jh1qQ==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.52.0",
"@sentry/core": "10.52.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.14.0 || 17.x || 18.x || 19.x"
}
},
"node_modules/@sentry/react/node_modules/@sentry/core": {
"version": "10.52.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.52.0.tgz",
"integrity": "sha512-VA/kAqLhkMnRWY2RXdBLyTemR9D4m7MVRy/gyapoq9yvllVPx9WXbvKgnMP2LQp7mFgT/oLFvw58aQKaYTGn3A==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@simplewebauthn/browser": { "node_modules/@simplewebauthn/browser": {
"version": "13.3.0", "version": "13.3.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
+2 -5
View File
@@ -11,19 +11,16 @@
"scripts": { "scripts": {
"dev:frontend": "npm run dev --workspace @bookra/frontend", "dev:frontend": "npm run dev --workspace @bookra/frontend",
"dev:backend": "cd apps/backend && go run ./cmd/api", "dev:backend": "cd apps/backend && go run ./cmd/api",
"dev:auth": "cd apps/auth-service && go run ./cmd/api",
"build:frontend": "npm run build --workspace @bookra/frontend", "build:frontend": "npm run build --workspace @bookra/frontend",
"build:backend": "cd apps/backend && go build ./...", "build:backend": "cd apps/backend && go build ./...",
"build:auth": "cd apps/auth-service && go build ./...",
"test:backend": "cd apps/backend && go test ./...", "test:backend": "cd apps/backend && go test ./...",
"test:auth": "cd apps/auth-service && go test ./...", "test": "npm run test:backend",
"test": "npm run test:backend && npm run test:auth",
"db:generate": "cd apps/backend && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 generate -f sqlc.yaml", "db:generate": "cd apps/backend && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 generate -f sqlc.yaml",
"db:migrate:up": "cd apps/backend && go run github.com/pressly/goose/v3/cmd/goose@v3.24.1 -dir migrations postgres \"$BOOKRA_DATABASE_DIRECT_URL\" up", "db:migrate:up": "cd apps/backend && go run github.com/pressly/goose/v3/cmd/goose@v3.24.1 -dir migrations postgres \"$BOOKRA_DATABASE_DIRECT_URL\" up",
"db:migrate:status": "cd apps/backend && go run github.com/pressly/goose/v3/cmd/goose@v3.24.1 -dir migrations postgres \"$BOOKRA_DATABASE_DIRECT_URL\" status", "db:migrate:status": "cd apps/backend && go run github.com/pressly/goose/v3/cmd/goose@v3.24.1 -dir migrations postgres \"$BOOKRA_DATABASE_DIRECT_URL\" status",
"lint:frontend": "npm run lint --workspace @bookra/frontend", "lint:frontend": "npm run lint --workspace @bookra/frontend",
"generate:api-client": "npm run generate --workspace @bookra/api-client", "generate:api-client": "npm run generate --workspace @bookra/api-client",
"verify": "npm run generate:api-client && npm run lint:frontend && npm run test && npm run build:frontend && npm run build:backend && npm run build:auth" "verify": "npm run generate:api-client && npm run lint:frontend && npm run test && npm run build:frontend && npm run build:backend"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
+53 -9
View File
@@ -232,23 +232,57 @@ export interface components {
primaryColor?: string; primaryColor?: string;
siteUrl?: string; siteUrl?: string;
}; };
/**
* @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).
*/
CheckoutLaunchResponse: { CheckoutLaunchResponse: {
/** Format: uri */ /**
cancelRedirectUrl: string; * Format: uri
customData: { * @description URL to redirect after cancelled checkout
*/
cancelRedirectUrl?: string;
/**
* Format: uri
* @description Stripe checkout URL (redirect the user to this URL)
*/
checkoutUrl?: string;
/** @description Custom metadata for Paddle checkout */
customData?: {
[key: string]: string; [key: string]: string;
}; };
/** Format: email */ /**
* Format: email
* @description Customer email for Paddle checkout
*/
customerEmail?: string; customerEmail?: string;
/** @description Paddle customer ID */
customerId?: string; customerId?: string;
priceId: string; /** @description Paddle price ID for client-side checkout */
/** Format: uri */ priceId?: string;
successRedirectUrl: string; /**
* Format: uri
* @description URL to redirect after successful checkout
*/
successRedirectUrl?: string;
}; };
CheckoutSessionRequest: { CheckoutSessionRequest: {
/** @enum {string} */ /**
* @description Billing interval. Yearly gets 17% discount.
* @default monthly
* @enum {string}
*/
billingInterval: "monthly" | "yearly";
/**
* @description Currency for the subscription
* @enum {string}
*/
currency?: "czk" | "usd"; currency?: "czk" | "usd";
/** @enum {string} */ /**
* @description The plan to subscribe to
* @enum {string}
*/
planCode: "starter" | "pro" | "business"; planCode: "starter" | "pro" | "business";
}; };
CreateBookingRequest: { CreateBookingRequest: {
@@ -327,10 +361,20 @@ export interface components {
timezone: string; timezone: string;
}; };
PlanDisplayPrice: { PlanDisplayPrice: {
/** @description Monthly price in cents */
amountCents: number; amountCents: number;
/** @enum {string} */ /** @enum {string} */
currency: "czk" | "usd"; currency: "czk" | "usd";
/** @description Formatted monthly price string */
formatted: string; formatted: string;
/** @description Yearly price in cents (17% discount) */
yearlyAmountCents?: number;
/** @description Formatted yearly price string */
yearlyFormatted?: string;
/** @description Description of yearly savings */
yearlySavings?: string;
/** @description Percentage saved with yearly billing */
yearlySavingsPercent?: number;
}; };
PlanEntitlements: { PlanEntitlements: {
advancedReporting: boolean; advancedReporting: boolean;
+2 -7
View File
@@ -1,8 +1,3 @@
Continue enhancing the bookra dashboard - fix better style add move things, make it fully working, dark white mode, make it really nice add pages test all. Feature and function wise, we here manage bookings only. The payments are not related to the business using our service but business subscription to our saas so something like this Payment http://localhost:3000/dashboard looks preatty shit, multi color messed up not tied to the brand styling, fix it, make it better use fully @design-taste-frontend @frontend-design make it appealing, in the current stage i would not want to use it, style it to the app take inspiration from the landing page. all the pages in the left nav in dashboard don't do anything, looks weird, feels weird, does not work. we need to heavily work on this, take your time @brainstorming @tdvorak-fullstack use all these skills, fix it. also remove footer from the dashboard. dont stop untill finished and proud of what you did.
$120 received fix calendar style better, on day calendar click show modal in which is the reservation or the entry. also show on click on the reservation more info. add cs en translation button, make sure everything translated. the top dashboard graph not visible not working. notification not working not implemented. fix the demo mode make it better take your time analyse first also use @caveman
15 min ago should not be there. make it really easy and fully working for the admin to see all the booking manage them, add zones and days the user cannot register add make it really nice. Also can you add an open endpoint for each businness, endpoint they can use which would have the dashboard for registration for their customers. Lets say they share a link to bookra.eu/sportcreative the customers see calendar or other types of view where they can book some date. Login/register, when booking we need their mail and so on.
The main workflow should be - business goes to bookra.eu they see the landing page about what we offer, demo mode, all info about us. If they like it they can go and register their business, choose pricing plan and setup their business, place, sector, brading,... They get access to their specific dashborad where they can manage all the bookings. They should first be shown a modal to implement bookra to their page either our hosted page at their endpoint like i told you previously, or widget to integrate to e.x. html+js, react, ts, solidjs, php/wordpress or basically anywhere they like. We in this widget add some specifier which manages auth, that the registration goes to the specific business, fully routed, secure and working. Now for the business customers. They go to the link or interact with the widget - they pick a date in the calendar or dropdown or whatever, they add their name, mail, pick location if the business has multiple locations (also based on the businesses pricing plan they picked). After they register we send them confirmation mail and also in the mail management link - which if they click on can change the registration, change location do whatever, see more info nice and stylized page where they can see all info. Also we send this to the business dashboard and their mail that they got a new registration and restrict the space in the calendar so no double booking, here the business can also manage the customer, we autolink with previous customers, filter to the customers show all their previous and next booking. They can change e.x. the booking date and time if this change happens we again send info to the businesses customer that their booking has changed and again attach the management page where they can even contact the business like a chat page so they can talk about the change, and more communicate with each other. So it should be fully wired like this. Also when the day gets closer e.x. the day after and the exact day we send reminder to the businesses customer that their registration is tommorrow/today at this time and this location. Make all these really nicely styled to our style or the businesses custom style if they choose - they can edit the mails, colors, also we attach the business branding in the mail by default, make it fully work and nice like this.
+53
View File
@@ -0,0 +1,53 @@
# Railway Backend Environment Variables
# Deploy to: api.bookra.eu
# App Configuration
BOOKRA_APP_ENV=production
BOOKRA_APP_URL=https://bookra.eu
BOOKRA_API_URL=https://api.bookra.eu
BOOKRA_FRONTEND_URL=https://bookra.eu
# Neon Database
BOOKRA_DATABASE_DIRECT_URL=postgresql://neondb_owner:npg_64KyAGZtIsSk@ep-mute-water-alem1v8u-pooler.c-3.eu-central-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require
BOOKRA_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
# Auth JWT Secret
BOOKRA_AUTH_JWT_SECRET=RECyrjswpfmw/D5UK4gsg38unglVkgc+9YTq0cKke7w=
# Stripe (Secret key only - publishable key not needed on backend)
BOOKRA_STRIPE_SECRET_KEY=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
BOOKRA_STRIPE_WEBHOOK_SECRET=whsec_Rq2w77ersHwjiQKEOqA17v81BacP5Wev
# Stripe Price IDs (Monthly)
BOOKRA_STRIPE_STARTER_CZK_MONTHLY_PRICE_ID=price_1TV6Z6GrjyNQaOSGZFcmltqI
BOOKRA_STRIPE_STARTER_USD_MONTHLY_PRICE_ID=price_1TV6cAGrjyNQaOSGXBhOq3Dk
BOOKRA_STRIPE_PRO_CZK_MONTHLY_PRICE_ID=price_1TV6ZZGrjyNQaOSGqWENnjDD
BOOKRA_STRIPE_PRO_USD_MONTHLY_PRICE_ID=price_1TV6dXGrjyNQaOSGeWzJlg2n
BOOKRA_STRIPE_BUSINESS_CZK_MONTHLY_PRICE_ID=price_1TV6bgGrjyNQaOSGqzGTM68E
BOOKRA_STRIPE_BUSINESS_USD_MONTHLY_PRICE_ID=price_1TV6dpGrjyNQaOSGCqKO42Oi
# Stripe Price IDs (Yearly)
BOOKRA_STRIPE_STARTER_CZK_YEARLY_PRICE_ID=price_1TVAlqGrjyNQaOSGNiZQ5tEx
BOOKRA_STRIPE_STARTER_USD_YEARLY_PRICE_ID=price_1TVAnSGrjyNQaOSGTHQHrgv3
BOOKRA_STRIPE_PRO_CZK_YEARLY_PRICE_ID=price_1TVAmVGrjyNQaOSGWDgWqYvb
BOOKRA_STRIPE_PRO_USD_YEARLY_PRICE_ID=price_1TVAnjGrjyNQaOSGvAANw64k
BOOKRA_STRIPE_BUSINESS_CZK_YEARLY_PRICE_ID=price_1TVAmsGrjyNQaOSGL7Sl5cCd
BOOKRA_STRIPE_BUSINESS_USD_YEARLY_PRICE_ID=price_1TVAo7GrjyNQaOSGB8LSCOua
# Legacy Price IDs (Fallback)
BOOKRA_STRIPE_STARTER_CZK_PRICE_ID=price_1TV6Z6GrjyNQaOSGZFcmltqI
BOOKRA_STRIPE_STARTER_USD_PRICE_ID=price_1TV6cAGrjyNQaOSGXBhOq3Dk
BOOKRA_STRIPE_PRO_CZK_PRICE_ID=price_1TV6ZZGrjyNQaOSGqWENnjDD
BOOKRA_STRIPE_PRO_USD_PRICE_ID=price_1TV6dXGrjyNQaOSGeWzJlg2n
BOOKRA_STRIPE_BUSINESS_CZK_PRICE_ID=price_1TV6bgGrjyNQaOSGqzGTM68E
BOOKRA_STRIPE_BUSINESS_USD_PRICE_ID=price_1TV6dpGrjyNQaOSGCqKO42Oi
# Admin Credentials
BOOKRA_ADMIN_EMAIL=info@tdvorak.dev
BOOKRA_ADMIN_KEY=51vSg7NKU8F2HHT1!
# Email (SMTP)
BOOKRA_SMTP_HOST=smtp.purelymail.com
BOOKRA_SMTP_PORT=465
BOOKRA_SMTP_USERNAME=noreply@bookra.eu
BOOKRA_SMTP_PASSWORD=8deCHFCuVQAxUg@YxP
+13
View File
@@ -0,0 +1,13 @@
# Vercel Frontend Environment Variables
# Deploy to: bookra.eu
# API Configuration (connects to Railway backend)
VITE_API_URL=https://api.bookra.eu
VITE_APP_URL=https://bookra.eu
# Stripe (Publishable key only - secret key NOT needed)
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51TV6GOGrjyNQaOSGddY1BS14H63e1uYgZUgPe25WhP7yMxmZxMprN3U3scnsmKGBmREzHLrhJlVSEiErimNwVtuR00TRAcHaJi
# Feature Flags
VITE_DEMO_MODE=false
VITE_SHOW_PRICING=true