mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
feat(core): consolidate auth service into backend and implement stripe billing
This commit performs a major architectural refactor by migrating the standalone `auth-service` into the main `backend` application, enabling a unified codebase and simplified deployment. It also introduces comprehensive Stripe billing support and a new administrative dashboard.
Key changes:
- **Architecture**: Deleted `apps/auth-service` and integrated its functionality (JWT, magic links, OAuth, user management) into `apps/backend`.
- **Billing**: Added Stripe integration to `backend`, supporting both monthly and yearly subscription cycles with automatic plan entitlement enforcement (e.g., location limits).
- **Admin Dashboard**: Implemented a new administrative service and API endpoints to manage tenants, users, and view platform-wide statistics.
- **Frontend**:
- Added a new pricing page with monthly/yearly toggle and comparison table.
- Integrated Stripe and Sentry for payments and error tracking.
- Improved dashboard UX/UI and added i18n support for new features.
- Enhanced the public booking flow with better validation and contact form integration.
- **Database**: Added migrations for users, magic links, password resets, OAuth states, admin audit logs, and refresh tokens.
- **DevOps**: Updated environment configurations for Railway and Vercel, and streamlined the project's `package.json` scripts.
This commit is contained in:
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
.git
|
|
||||||
.github
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
bin
|
|
||||||
coverage
|
|
||||||
tmp
|
|
||||||
*.log
|
|
||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Auth Service Environment Configuration
|
|
||||||
# This service stays active for standalone auth flows and internal admin management.
|
|
||||||
# SaaS billing is handled by apps/backend + Paddle.
|
|
||||||
|
|
||||||
PORT=8081
|
|
||||||
APP_ENV=development
|
|
||||||
|
|
||||||
DATABASE_URL=postgresql://user:password@host/database?sslmode=require
|
|
||||||
FRONTEND_URL=http://localhost:3000
|
|
||||||
|
|
||||||
JWT_SECRET=change-me-in-production
|
|
||||||
NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
|
|
||||||
|
|
||||||
SMTP_HOST=smtp.purelymail.com
|
|
||||||
SMTP_PORT=465
|
|
||||||
SMTP_USERNAME=noreply@example.com
|
|
||||||
SMTP_PASSWORD=
|
|
||||||
EMAIL_FROM=noreply@example.com
|
|
||||||
|
|
||||||
GOOGLE_CLIENT_ID=
|
|
||||||
GOOGLE_CLIENT_SECRET=
|
|
||||||
GOOGLE_REDIRECT_URL=http://localhost:8081/api/auth/oauth/google/callback
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# Environment variables
|
|
||||||
.env
|
|
||||||
|
|
||||||
# Binary
|
|
||||||
auth-service
|
|
||||||
*.exe
|
|
||||||
|
|
||||||
# Go
|
|
||||||
vendor/
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Build stage
|
|
||||||
FROM golang:1.26.2-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install build dependencies
|
|
||||||
RUN apk add --no-cache git
|
|
||||||
|
|
||||||
# Copy go mod files
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/auth-service ./cmd/api
|
|
||||||
|
|
||||||
FROM alpine:3.22
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates \
|
|
||||||
&& addgroup -S bookra \
|
|
||||||
&& adduser -S -D -H -u 10001 -G bookra bookra
|
|
||||||
|
|
||||||
COPY --from=builder --chown=bookra:bookra /app/auth-service /app/
|
|
||||||
COPY --from=builder --chown=bookra:bookra /app/migrations /app/migrations
|
|
||||||
|
|
||||||
ENV PORT=8080
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
USER bookra
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
||||||
CMD wget -qO- "http://127.0.0.1:${PORT:-8080}/health" >/dev/null || exit 1
|
|
||||||
|
|
||||||
CMD ["/app/auth-service"]
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Bookra Auth Service
|
|
||||||
|
|
||||||
Standalone auth + internal admin service for Bookra.
|
|
||||||
|
|
||||||
Primary responsibilities:
|
|
||||||
|
|
||||||
- email/password auth
|
|
||||||
- magic-link auth
|
|
||||||
- Google OAuth when configured
|
|
||||||
- internal admin dashboard / remote service management
|
|
||||||
- optional Neon JWT verification support
|
|
||||||
|
|
||||||
Not primary billing service:
|
|
||||||
|
|
||||||
- SaaS billing lives in `apps/backend`
|
|
||||||
- Paddle config belongs in backend/frontend env
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go run ./cmd/api
|
|
||||||
go test ./...
|
|
||||||
go build ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Core Routes
|
|
||||||
|
|
||||||
- `GET /health`
|
|
||||||
- `POST /api/auth/register`
|
|
||||||
- `POST /api/auth/login`
|
|
||||||
- `POST /api/auth/magic-link`
|
|
||||||
- `POST /api/auth/verify`
|
|
||||||
- `POST /api/auth/refresh`
|
|
||||||
- `GET /api/auth/me`
|
|
||||||
- `GET /api/auth/providers`
|
|
||||||
- `GET /api/auth/oauth/google`
|
|
||||||
- `GET /api/auth/oauth/google/callback`
|
|
||||||
- `GET /admin`
|
|
||||||
- `GET /admin/api/config`
|
|
||||||
- `GET /admin/api/stats`
|
|
||||||
|
|
||||||
See [apps/auth-service/.env.example](/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/.env.example:1).
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bookra/apps/auth-service/internal/config"
|
|
||||||
"bookra/apps/auth-service/internal/db"
|
|
||||||
"bookra/apps/auth-service/internal/email"
|
|
||||||
"bookra/apps/auth-service/internal/handlers"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-contrib/cors"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
"github.com/pressly/goose/v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
_ = godotenv.Load()
|
|
||||||
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to load config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.Getenv("APP_ENV") == "production" {
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
database, err := db.New(cfg.DatabaseURL)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to connect to database: %v", err)
|
|
||||||
}
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
if err := runMigrations(cfg.DatabaseURL); err != nil {
|
|
||||||
log.Printf("Migration warning: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
emailSvc := email.New(email.Config{
|
|
||||||
Host: cfg.SMTPHost,
|
|
||||||
Port: cfg.SMTPPort,
|
|
||||||
Username: cfg.SMTPUsername,
|
|
||||||
Password: cfg.SMTPPassword,
|
|
||||||
From: cfg.EmailFrom,
|
|
||||||
})
|
|
||||||
|
|
||||||
handler, err := handlers.New(database, emailSvc, cfg)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to initialize handlers: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r := gin.Default()
|
|
||||||
|
|
||||||
r.Use(cors.New(cors.Config{
|
|
||||||
AllowOrigins: []string{cfg.FrontendURL, "http://localhost:3000", "http://localhost:5173"},
|
|
||||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
|
||||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
|
|
||||||
ExposeHeaders: []string{"Content-Length"},
|
|
||||||
AllowCredentials: true,
|
|
||||||
MaxAge: 12 * time.Hour,
|
|
||||||
}))
|
|
||||||
|
|
||||||
r.GET("/health", func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "healthy", "service": "auth"})
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.RegisterRoutes(r)
|
|
||||||
|
|
||||||
srv := &http.Server{
|
|
||||||
Addr: ":" + cfg.Port,
|
|
||||||
Handler: r,
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
log.Fatalf("Failed to start server: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
log.Printf("Auth service running on port %s", cfg.Port)
|
|
||||||
|
|
||||||
quit := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
<-quit
|
|
||||||
|
|
||||||
log.Println("Shutting down server...")
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := srv.Shutdown(ctx); err != nil {
|
|
||||||
log.Printf("Server forced to shutdown: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Server exited")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMigrations(databaseURL string) error {
|
|
||||||
db, err := goose.OpenDBWithDriver("pgx", databaseURL)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("goose open db: %w", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
if err := goose.SetDialect("postgres"); err != nil {
|
|
||||||
return fmt.Errorf("goose set dialect: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := goose.Up(db, "migrations"); err != nil {
|
|
||||||
return fmt.Errorf("goose up: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
module bookra/apps/auth-service
|
|
||||||
|
|
||||||
go 1.26.2
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/gin-contrib/cors v1.7.7
|
|
||||||
github.com/gin-gonic/gin v1.12.0
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
|
||||||
github.com/google/uuid v1.6.0
|
|
||||||
github.com/jackc/pgx/v5 v5.9.1
|
|
||||||
github.com/joho/godotenv v1.5.1
|
|
||||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
|
|
||||||
github.com/pressly/goose/v3 v3.27.0
|
|
||||||
github.com/stripe/stripe-go/v83 v83.2.1
|
|
||||||
golang.org/x/crypto v0.50.0
|
|
||||||
golang.org/x/oauth2 v0.36.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
|
||||||
github.com/MicahParks/jwkset v0.11.0 // indirect
|
|
||||||
github.com/MicahParks/keyfunc/v3 v3.8.0 // indirect
|
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
|
||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
|
||||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
|
||||||
golang.org/x/arch v0.23.0 // indirect
|
|
||||||
golang.org/x/net v0.52.0 // indirect
|
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
|
||||||
golang.org/x/text v0.36.0 // indirect
|
|
||||||
golang.org/x/time v0.9.0 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
|
||||||
)
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
|
||||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
|
||||||
github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ=
|
|
||||||
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
|
|
||||||
github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds=
|
|
||||||
github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
|
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
|
||||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
|
||||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
|
||||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
|
||||||
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
|
|
||||||
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
|
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
|
||||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
|
||||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
|
||||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
|
||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
|
||||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
|
||||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
|
||||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
|
|
||||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
|
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
|
||||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
|
|
||||||
github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
|
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
|
||||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
|
||||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
|
||||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
|
||||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
|
||||||
github.com/stripe/stripe-go/v83 v83.2.1 h1:8WPhpMjr8VyMWKUsCMoVvlWxYazuL5edajKX/RulfbA=
|
|
||||||
github.com/stripe/stripe-go/v83 v83.2.1/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE=
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
|
||||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
|
||||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
|
||||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
|
||||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
|
||||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
|
||||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
|
||||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
|
||||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
|
||||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
|
||||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
|
||||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/MicahParks/keyfunc/v3"
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
type NeonVerifier struct {
|
|
||||||
jwks keyfunc.Keyfunc
|
|
||||||
expectedIssuer string
|
|
||||||
enabled bool
|
|
||||||
cancel context.CancelFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewNeonVerifier(neonAuthURL string) (*NeonVerifier, error) {
|
|
||||||
trimmed := strings.TrimRight(strings.TrimSpace(neonAuthURL), "/")
|
|
||||||
if trimmed == "" {
|
|
||||||
return &NeonVerifier{enabled: false}, nil
|
|
||||||
}
|
|
||||||
parsed, err := url.Parse(trimmed)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse neon auth url: %w", err)
|
|
||||||
}
|
|
||||||
expectedIssuer := fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
|
||||||
jwksURL := fmt.Sprintf("%s/.well-known/jwks.json", trimmed)
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
jwks, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
|
|
||||||
if err != nil {
|
|
||||||
cancel()
|
|
||||||
return nil, fmt.Errorf("create neon jwks: %w", err)
|
|
||||||
}
|
|
||||||
return &NeonVerifier{jwks: jwks, expectedIssuer: expectedIssuer, enabled: true, cancel: cancel}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *NeonVerifier) Enabled() bool {
|
|
||||||
return v != nil && v.enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *NeonVerifier) Close() {
|
|
||||||
if v != nil && v.cancel != nil {
|
|
||||||
v.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *NeonVerifier) Verify(tokenString string) (*Claims, error) {
|
|
||||||
if !v.Enabled() {
|
|
||||||
return nil, errors.New("neon auth verifier is disabled")
|
|
||||||
}
|
|
||||||
token, err := jwt.Parse(tokenString, v.jwks.Keyfunc,
|
|
||||||
jwt.WithIssuer(v.expectedIssuer),
|
|
||||||
jwt.WithValidMethods([]string{"EdDSA"}),
|
|
||||||
jwt.WithAudience(v.expectedIssuer),
|
|
||||||
jwt.WithLeeway(15*time.Second),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
claims, ok := token.Claims.(jwt.MapClaims)
|
|
||||||
if !ok || !token.Valid {
|
|
||||||
return nil, errors.New("invalid neon claims")
|
|
||||||
}
|
|
||||||
subject, _ := claims["sub"].(string)
|
|
||||||
email, _ := claims["email"].(string)
|
|
||||||
name, _ := claims["name"].(string)
|
|
||||||
if name == "" {
|
|
||||||
name, _ = claims["display_name"].(string)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(subject) == "" {
|
|
||||||
return nil, errors.New("missing neon subject")
|
|
||||||
}
|
|
||||||
return &Claims{UserID: subject, Email: email, Name: name, Role: "authenticated", Type: "access"}, nil
|
|
||||||
}
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"bookra/apps/auth-service/internal/db"
|
|
||||||
"bookra/apps/auth-service/internal/email"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
accessTokenTTL = 24 * time.Hour
|
|
||||||
refreshTokenTTL = 30 * 24 * time.Hour
|
|
||||||
)
|
|
||||||
|
|
||||||
type Service struct {
|
|
||||||
db *db.DB
|
|
||||||
email *email.Service
|
|
||||||
jwtSecret []byte
|
|
||||||
frontendURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
type TokenPair struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
RefreshToken string `json:"refresh_token,omitempty"`
|
|
||||||
TokenType string `json:"token_type"`
|
|
||||||
ExpiresIn int `json:"expires_in"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Claims struct {
|
|
||||||
UserID string `json:"sub"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
Role string `json:"role,omitempty"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewService(database *db.DB, emailSvc *email.Service, jwtSecret string, frontendURL string) *Service {
|
|
||||||
return &Service{
|
|
||||||
db: database,
|
|
||||||
email: emailSvc,
|
|
||||||
jwtSecret: []byte(jwtSecret),
|
|
||||||
frontendURL: frontendURL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) GenerateMagicLink(ctx context.Context, emailAddr string, locale string) error {
|
|
||||||
user, err := s.db.GetUserByEmail(ctx, emailAddr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if user == nil {
|
|
||||||
user = &db.User{
|
|
||||||
Email: emailAddr,
|
|
||||||
Provider: "email",
|
|
||||||
}
|
|
||||||
user, err = s.db.CreateUser(ctx, user)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create user: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
token := generateRandomToken(32)
|
|
||||||
expiresAt := time.Now().Add(15 * time.Minute)
|
|
||||||
|
|
||||||
if err := s.db.CreateMagicLink(ctx, token, emailAddr, user.ID, expiresAt); err != nil {
|
|
||||||
return fmt.Errorf("create magic link: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
magicURL := fmt.Sprintf("%s/auth/callback?token=%s", s.frontendURL, token)
|
|
||||||
|
|
||||||
var name string
|
|
||||||
if user.Name != nil {
|
|
||||||
name = *user.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.email.SendMagicLink(emailAddr, name, magicURL, locale); err != nil {
|
|
||||||
return fmt.Errorf("send email: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) VerifyMagicLink(ctx context.Context, token string) (*TokenPair, error) {
|
|
||||||
ml, err := s.db.GetMagicLink(ctx, token)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get magic link: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ml == nil || ml.Used {
|
|
||||||
return nil, fmt.Errorf("invalid or used token")
|
|
||||||
}
|
|
||||||
|
|
||||||
if time.Now().After(ml.ExpiresAt) {
|
|
||||||
return nil, fmt.Errorf("token expired")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.MarkMagicLinkUsed(ctx, token); err != nil {
|
|
||||||
return nil, fmt.Errorf("mark used: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.db.GetUserByID(ctx, ml.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if user == nil {
|
|
||||||
return nil, fmt.Errorf("user not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.UpdateLastLogin(ctx, user.ID); err != nil {
|
|
||||||
return nil, fmt.Errorf("update login: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.generateTokens(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) OAuthLoginOrCreate(ctx context.Context, provider, providerID, email, name string) (*TokenPair, error) {
|
|
||||||
user, err := s.db.GetUserByProviderID(ctx, provider, providerID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get user by provider: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if user == nil {
|
|
||||||
existing, err := s.db.GetUserByEmail(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("check existing email: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing != nil {
|
|
||||||
existing.Provider = provider
|
|
||||||
existing.ProviderID = &providerID
|
|
||||||
existing.Name = &name
|
|
||||||
existing.EmailVerified = true
|
|
||||||
if err := s.db.UpdateUser(ctx, existing); err != nil {
|
|
||||||
return nil, fmt.Errorf("link provider: %w", err)
|
|
||||||
}
|
|
||||||
user = existing
|
|
||||||
} else {
|
|
||||||
user = &db.User{
|
|
||||||
Email: email,
|
|
||||||
Name: &name,
|
|
||||||
Provider: provider,
|
|
||||||
ProviderID: &providerID,
|
|
||||||
EmailVerified: true,
|
|
||||||
}
|
|
||||||
user, err = s.db.CreateUser(ctx, user)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create oauth user: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.UpdateLastLogin(ctx, user.ID); err != nil {
|
|
||||||
return nil, fmt.Errorf("update login: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.generateTokens(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) RegisterWithPassword(ctx context.Context, email, password, name string) (*TokenPair, error) {
|
|
||||||
existing, err := s.db.GetUserByEmail(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("check existing: %w", err)
|
|
||||||
}
|
|
||||||
if existing != nil {
|
|
||||||
return nil, fmt.Errorf("email already registered")
|
|
||||||
}
|
|
||||||
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("hash password: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hashStr := string(hash)
|
|
||||||
user := &db.User{
|
|
||||||
Email: email,
|
|
||||||
Name: &name,
|
|
||||||
PasswordHash: &hashStr,
|
|
||||||
Provider: "email",
|
|
||||||
EmailVerified: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err = s.db.CreateUser(ctx, user)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.generateTokens(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) LoginWithPassword(ctx context.Context, email, password string) (*TokenPair, error) {
|
|
||||||
user, err := s.db.GetUserByEmail(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get user: %w", err)
|
|
||||||
}
|
|
||||||
if user == nil || user.PasswordHash == nil {
|
|
||||||
return nil, fmt.Errorf("invalid credentials")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid credentials")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.UpdateLastLogin(ctx, user.ID); err != nil {
|
|
||||||
return nil, fmt.Errorf("update login: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.generateTokens(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) generateTokens(user *db.User) (*TokenPair, error) {
|
|
||||||
now := time.Now()
|
|
||||||
return s.generateTokensAt(user, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) generateTokensAt(user *db.User, now time.Time) (*TokenPair, error) {
|
|
||||||
name := ""
|
|
||||||
if user.Name != nil {
|
|
||||||
name = *user.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
accessTokenString, err := s.signToken(user, name, "access", now, accessTokenTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("sign access token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshTokenString, err := s.signToken(user, name, "refresh", now, refreshTokenTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("sign refresh token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TokenPair{
|
|
||||||
AccessToken: accessTokenString,
|
|
||||||
RefreshToken: refreshTokenString,
|
|
||||||
TokenType: "Bearer",
|
|
||||||
ExpiresIn: int(accessTokenTTL.Seconds()),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) VerifyToken(tokenString string) (*Claims, error) {
|
|
||||||
return s.verifyTokenOfType(tokenString, "access")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) VerifyRefreshToken(tokenString string) (*Claims, error) {
|
|
||||||
return s.verifyTokenOfType(tokenString, "refresh")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) RefreshTokens(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
|
||||||
claims, err := s.VerifyRefreshToken(refreshToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
user := &db.User{
|
|
||||||
ID: uuid.MustParse(claims.UserID),
|
|
||||||
Email: claims.Email,
|
|
||||||
}
|
|
||||||
if claims.Name != "" {
|
|
||||||
user.Name = &claims.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.db != nil {
|
|
||||||
storedUser, err := s.db.GetUserByID(ctx, user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get user: %w", err)
|
|
||||||
}
|
|
||||||
if storedUser == nil {
|
|
||||||
return nil, fmt.Errorf("user not found")
|
|
||||||
}
|
|
||||||
user = storedUser
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.generateTokens(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) verifyTokenOfType(tokenString string, expectedType string) (*Claims, error) {
|
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
|
||||||
}
|
|
||||||
return s.jwtSecret, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
|
||||||
if claims.Type != expectedType {
|
|
||||||
return nil, fmt.Errorf("invalid token type")
|
|
||||||
}
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("invalid token")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) signToken(user *db.User, name string, tokenType string, now time.Time, ttl time.Duration) (string, error) {
|
|
||||||
claims := Claims{
|
|
||||||
UserID: user.ID.String(),
|
|
||||||
Email: user.Email,
|
|
||||||
Name: name,
|
|
||||||
Role: "authenticated",
|
|
||||||
Type: tokenType,
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
Issuer: "bookra-auth",
|
|
||||||
Subject: user.ID.String(),
|
|
||||||
Audience: jwt.ClaimStrings{"bookra"},
|
|
||||||
ID: generateRandomToken(12),
|
|
||||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
return token.SignedString(s.jwtSecret)
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateRandomToken(length int) string {
|
|
||||||
b := make([]byte, length)
|
|
||||||
rand.Read(b)
|
|
||||||
return base64.URLEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"bookra/apps/auth-service/internal/db"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGenerateTokensProducesVerifiableAccessAndRefreshTokens(t *testing.T) {
|
|
||||||
service := NewService(nil, nil, "test-secret", "http://localhost:3000")
|
|
||||||
name := "Token Tester"
|
|
||||||
user := &db.User{
|
|
||||||
ID: uuid.MustParse("019daeaa-bc14-7712-9224-e347a96bd5c3"),
|
|
||||||
Email: "tester@bookra.dev",
|
|
||||||
Name: &name,
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens, err := service.generateTokensAt(user, time.Now().UTC())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("generate tokens: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
accessClaims, err := service.VerifyToken(tokens.AccessToken)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("verify access token: %v", err)
|
|
||||||
}
|
|
||||||
if accessClaims.Type != "access" {
|
|
||||||
t.Fatalf("expected access type, got %s", accessClaims.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshClaims, err := service.VerifyRefreshToken(tokens.RefreshToken)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("verify refresh token: %v", err)
|
|
||||||
}
|
|
||||||
if refreshClaims.Type != "refresh" {
|
|
||||||
t.Fatalf("expected refresh type, got %s", refreshClaims.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := service.VerifyToken(tokens.RefreshToken); err == nil {
|
|
||||||
t.Fatal("expected refresh token to fail access verification")
|
|
||||||
}
|
|
||||||
if _, err := service.VerifyRefreshToken(tokens.AccessToken); err == nil {
|
|
||||||
t.Fatal("expected access token to fail refresh verification")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshTokensReturnsRotatedPair(t *testing.T) {
|
|
||||||
service := NewService(nil, nil, "test-secret", "http://localhost:3000")
|
|
||||||
user := &db.User{
|
|
||||||
ID: uuid.MustParse("019daeaa-bc14-7712-9224-e347a96bd5c3"),
|
|
||||||
Email: "tester@bookra.dev",
|
|
||||||
}
|
|
||||||
|
|
||||||
original, err := service.generateTokens(user)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("generate tokens: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshed, err := service.RefreshTokens(context.Background(), original.RefreshToken)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("refresh tokens: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if refreshed.AccessToken == original.AccessToken {
|
|
||||||
t.Fatal("expected rotated access token")
|
|
||||||
}
|
|
||||||
if refreshed.RefreshToken == original.RefreshToken {
|
|
||||||
t.Fatal("expected rotated refresh token")
|
|
||||||
}
|
|
||||||
if _, err := service.VerifyToken(refreshed.AccessToken); err != nil {
|
|
||||||
t.Fatalf("verify refreshed access token: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := service.VerifyRefreshToken(refreshed.RefreshToken); err != nil {
|
|
||||||
t.Fatalf("verify refreshed refresh token: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshTokensRejectsInvalidToken(t *testing.T) {
|
|
||||||
service := NewService(nil, nil, "test-secret", "http://localhost:3000")
|
|
||||||
|
|
||||||
if _, err := service.RefreshTokens(context.Background(), "bad-token"); err == nil {
|
|
||||||
t.Fatal("expected invalid refresh token error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
package billing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"bookra/apps/auth-service/internal/config"
|
|
||||||
"bookra/apps/auth-service/internal/db"
|
|
||||||
|
|
||||||
"github.com/stripe/stripe-go/v83"
|
|
||||||
"github.com/stripe/stripe-go/v83/checkout/session"
|
|
||||||
"github.com/stripe/stripe-go/v83/customer"
|
|
||||||
"github.com/stripe/stripe-go/v83/subscription"
|
|
||||||
"github.com/stripe/stripe-go/v83/webhook"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrStripeNotConfigured = errors.New("stripe is not configured")
|
|
||||||
ErrStripeWebhookMissing = errors.New("stripe webhook secret is not configured")
|
|
||||||
ErrStripeSignatureMissing = errors.New("stripe signature is missing")
|
|
||||||
ErrPlanNotConfigured = errors.New("stripe plan is not configured")
|
|
||||||
ErrCustomerMappingNotFound = errors.New("stripe customer mapping not found")
|
|
||||||
)
|
|
||||||
|
|
||||||
var allowedWebhookEvents = []string{
|
|
||||||
"checkout.session.completed",
|
|
||||||
"customer.subscription.created",
|
|
||||||
"customer.subscription.updated",
|
|
||||||
"customer.subscription.deleted",
|
|
||||||
"customer.subscription.paused",
|
|
||||||
"customer.subscription.resumed",
|
|
||||||
"invoice.paid",
|
|
||||||
"invoice.payment_failed",
|
|
||||||
"payment_intent.succeeded",
|
|
||||||
"payment_intent.payment_failed",
|
|
||||||
}
|
|
||||||
|
|
||||||
type Service struct {
|
|
||||||
cfg *config.Config
|
|
||||||
db *db.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
type CheckoutSession struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SubscriptionSnapshot struct {
|
|
||||||
CustomerID string `json:"customerId,omitempty"`
|
|
||||||
SubscriptionID string `json:"subscriptionId,omitempty"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
PlanCode string `json:"planCode,omitempty"`
|
|
||||||
Currency string `json:"currency,omitempty"`
|
|
||||||
PriceID string `json:"priceId,omitempty"`
|
|
||||||
CancelAtPeriodEnd bool `json:"cancelAtPeriodEnd"`
|
|
||||||
CurrentPeriodStart *time.Time `json:"currentPeriodStart,omitempty"`
|
|
||||||
CurrentPeriodEnd *time.Time `json:"currentPeriodEnd,omitempty"`
|
|
||||||
PaymentMethod *PaymentMethod `json:"paymentMethod,omitempty"`
|
|
||||||
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
|
|
||||||
CheckoutURLAvailable bool `json:"checkoutUrlAvailable"`
|
|
||||||
SyncAvailable bool `json:"syncAvailable"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaymentMethod struct {
|
|
||||||
Brand string `json:"brand"`
|
|
||||||
Last4 string `json:"last4"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserIdentity struct {
|
|
||||||
ID string
|
|
||||||
Email string
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
type userCustomerMapping struct {
|
|
||||||
CustomerID string `json:"customerId"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewService(cfg *config.Config, database *db.DB) *Service {
|
|
||||||
return &Service{cfg: cfg, db: database}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) GetSubscription(ctx context.Context, userID string) (SubscriptionSnapshot, error) {
|
|
||||||
mapping, ok, err := s.getCustomerMapping(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return SubscriptionSnapshot{}, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return s.noneSnapshot(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot, ok, err := s.getCustomerSnapshot(ctx, mapping.CustomerID)
|
|
||||||
if err != nil {
|
|
||||||
return SubscriptionSnapshot{}, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
snapshot = SubscriptionSnapshot{
|
|
||||||
CustomerID: mapping.CustomerID,
|
|
||||||
Status: "none",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
snapshot.CheckoutURLAvailable = s.checkoutAvailableForPlan(snapshot.PlanCode)
|
|
||||||
snapshot.SyncAvailable = s.cfg.StripeSecretConfigured()
|
|
||||||
return snapshot, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) CreateCheckoutSession(ctx context.Context, user UserIdentity, planCode string, currency string) (CheckoutSession, error) {
|
|
||||||
priceID, resolvedPlanCode, resolvedCurrency, err := s.priceForPlan(planCode, currency)
|
|
||||||
if err != nil {
|
|
||||||
return CheckoutSession{}, err
|
|
||||||
}
|
|
||||||
if s.cfg.StripeSecretKey == "" {
|
|
||||||
return CheckoutSession{}, ErrStripeNotConfigured
|
|
||||||
}
|
|
||||||
|
|
||||||
customerID, err := s.ensureCustomer(ctx, user)
|
|
||||||
if err != nil {
|
|
||||||
return CheckoutSession{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
stripe.Key = s.cfg.StripeSecretKey
|
|
||||||
params := &stripe.CheckoutSessionParams{
|
|
||||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
|
||||||
Customer: stripe.String(customerID),
|
|
||||||
ClientReferenceID: stripe.String(user.ID),
|
|
||||||
SuccessURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=success", strings.TrimRight(s.cfg.FrontendURL, "/"))),
|
|
||||||
CancelURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=cancelled", strings.TrimRight(s.cfg.FrontendURL, "/"))),
|
|
||||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
|
||||||
{
|
|
||||||
Price: stripe.String(priceID),
|
|
||||||
Quantity: stripe.Int64(1),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Metadata: map[string]string{
|
|
||||||
"user_id": user.ID,
|
|
||||||
"plan_code": resolvedPlanCode,
|
|
||||||
"currency": resolvedCurrency,
|
|
||||||
},
|
|
||||||
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
|
|
||||||
TrialPeriodDays: stripe.Int64(30),
|
|
||||||
Metadata: map[string]string{
|
|
||||||
"user_id": user.ID,
|
|
||||||
"plan_code": resolvedPlanCode,
|
|
||||||
"currency": resolvedCurrency,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
checkoutSession, err := session.New(params)
|
|
||||||
if err != nil {
|
|
||||||
return CheckoutSession{}, err
|
|
||||||
}
|
|
||||||
return CheckoutSession{URL: checkoutSession.URL}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) Refresh(ctx context.Context, userID string) (SubscriptionSnapshot, error) {
|
|
||||||
mapping, ok, err := s.getCustomerMapping(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return SubscriptionSnapshot{}, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return s.noneSnapshot(), nil
|
|
||||||
}
|
|
||||||
if s.cfg.StripeSecretKey == "" {
|
|
||||||
return SubscriptionSnapshot{}, ErrStripeNotConfigured
|
|
||||||
}
|
|
||||||
return s.syncStripeDataToKV(ctx, mapping.CustomerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) HandleWebhook(ctx context.Context, signature string, payload []byte) error {
|
|
||||||
if s.cfg.StripeSecretKey == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if s.cfg.StripeWebhookSecret == "" {
|
|
||||||
return ErrStripeWebhookMissing
|
|
||||||
}
|
|
||||||
if signature == "" {
|
|
||||||
return ErrStripeSignatureMissing
|
|
||||||
}
|
|
||||||
|
|
||||||
event, err := webhook.ConstructEvent(payload, signature, s.cfg.StripeWebhookSecret)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !slices.Contains(allowedWebhookEvents, string(event.Type)) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
customerID := extractCustomerID(event)
|
|
||||||
if customerID == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = s.syncStripeDataToKV(ctx, customerID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ensureCustomer(ctx context.Context, user UserIdentity) (string, error) {
|
|
||||||
mapping, ok, err := s.getCustomerMapping(ctx, user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if ok && mapping.CustomerID != "" {
|
|
||||||
return mapping.CustomerID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
stripe.Key = s.cfg.StripeSecretKey
|
|
||||||
params := &stripe.CustomerParams{
|
|
||||||
Email: stripe.String(user.Email),
|
|
||||||
Metadata: map[string]string{
|
|
||||||
"user_id": user.ID,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(user.Name) != "" {
|
|
||||||
params.Name = stripe.String(strings.TrimSpace(user.Name))
|
|
||||||
}
|
|
||||||
|
|
||||||
createdCustomer, err := customer.New(params)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.storeCustomerMapping(ctx, user.ID, createdCustomer.ID); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return createdCustomer.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) syncStripeDataToKV(ctx context.Context, customerID string) (SubscriptionSnapshot, error) {
|
|
||||||
stripe.Key = s.cfg.StripeSecretKey
|
|
||||||
params := &stripe.SubscriptionListParams{Customer: stripe.String(customerID)}
|
|
||||||
params.Status = stripe.String("all")
|
|
||||||
params.AddExpand("data.default_payment_method")
|
|
||||||
params.AddExpand("data.items.data.price")
|
|
||||||
|
|
||||||
iter := subscription.List(params)
|
|
||||||
selected := (*stripe.Subscription)(nil)
|
|
||||||
for iter.Next() {
|
|
||||||
current := iter.Subscription()
|
|
||||||
if selected == nil || subscriptionRank(current) > subscriptionRank(selected) {
|
|
||||||
selected = current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if iter.Err() != nil {
|
|
||||||
return SubscriptionSnapshot{}, iter.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
snapshot := SubscriptionSnapshot{
|
|
||||||
CustomerID: customerID,
|
|
||||||
Status: "none",
|
|
||||||
LastSyncedAt: &now,
|
|
||||||
CheckoutURLAvailable: s.cfg.StripeCheckoutReady(),
|
|
||||||
SyncAvailable: s.cfg.StripeSecretConfigured(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if selected != nil {
|
|
||||||
snapshot.SubscriptionID = selected.ID
|
|
||||||
snapshot.Status = string(selected.Status)
|
|
||||||
snapshot.CancelAtPeriodEnd = selected.CancelAtPeriodEnd
|
|
||||||
if len(selected.Items.Data) > 0 {
|
|
||||||
item := selected.Items.Data[0]
|
|
||||||
if item.Price != nil {
|
|
||||||
snapshot.PriceID = item.Price.ID
|
|
||||||
snapshot.PlanCode = s.planCodeForPrice(snapshot.PriceID)
|
|
||||||
snapshot.Currency = normalizeCurrency(string(item.Price.Currency))
|
|
||||||
}
|
|
||||||
snapshot.CurrentPeriodStart = unixPtr(item.CurrentPeriodStart)
|
|
||||||
snapshot.CurrentPeriodEnd = unixPtr(item.CurrentPeriodEnd)
|
|
||||||
}
|
|
||||||
if selected.DefaultPaymentMethod != nil && selected.DefaultPaymentMethod.Card != nil {
|
|
||||||
snapshot.PaymentMethod = &PaymentMethod{
|
|
||||||
Brand: string(selected.DefaultPaymentMethod.Card.Brand),
|
|
||||||
Last4: selected.DefaultPaymentMethod.Card.Last4,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.PutKV(ctx, customerSnapshotKey(customerID), snapshot); err != nil {
|
|
||||||
return SubscriptionSnapshot{}, err
|
|
||||||
}
|
|
||||||
return snapshot, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) storeCustomerMapping(ctx context.Context, userID string, customerID string) error {
|
|
||||||
mapping := userCustomerMapping{
|
|
||||||
CustomerID: customerID,
|
|
||||||
UpdatedAt: time.Now().UTC(),
|
|
||||||
}
|
|
||||||
return s.db.PutKV(ctx, userCustomerKey(userID), mapping)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) getCustomerMapping(ctx context.Context, userID string) (userCustomerMapping, bool, error) {
|
|
||||||
var mapping userCustomerMapping
|
|
||||||
ok, err := s.db.GetKV(ctx, userCustomerKey(userID), &mapping)
|
|
||||||
if err != nil {
|
|
||||||
return userCustomerMapping{}, false, err
|
|
||||||
}
|
|
||||||
if !ok || mapping.CustomerID == "" {
|
|
||||||
return userCustomerMapping{}, false, nil
|
|
||||||
}
|
|
||||||
return mapping, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) getCustomerSnapshot(ctx context.Context, customerID string) (SubscriptionSnapshot, bool, error) {
|
|
||||||
var snapshot SubscriptionSnapshot
|
|
||||||
ok, err := s.db.GetKV(ctx, customerSnapshotKey(customerID), &snapshot)
|
|
||||||
if err != nil {
|
|
||||||
return SubscriptionSnapshot{}, false, err
|
|
||||||
}
|
|
||||||
return snapshot, ok, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) noneSnapshot() SubscriptionSnapshot {
|
|
||||||
return SubscriptionSnapshot{
|
|
||||||
Status: "none",
|
|
||||||
CheckoutURLAvailable: s.cfg.StripeCheckoutReady(),
|
|
||||||
SyncAvailable: s.cfg.StripeSecretConfigured(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) priceForPlan(planCode string, currency string) (string, string, string, error) {
|
|
||||||
planCode = normalizePlanCode(strings.TrimSpace(planCode))
|
|
||||||
if planCode == "" {
|
|
||||||
planCode = s.defaultPlanCode()
|
|
||||||
}
|
|
||||||
if planCode == "" {
|
|
||||||
return "", "", "", ErrPlanNotConfigured
|
|
||||||
}
|
|
||||||
resolvedCurrency := normalizeCurrency(currency)
|
|
||||||
priceID := strings.TrimSpace(s.cfg.StripePriceIDs[planCode+":"+resolvedCurrency])
|
|
||||||
if priceID == "" && resolvedCurrency != "czk" {
|
|
||||||
priceID = strings.TrimSpace(s.cfg.StripePriceIDs[planCode+":czk"])
|
|
||||||
if priceID != "" {
|
|
||||||
resolvedCurrency = "czk"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if priceID == "" {
|
|
||||||
priceID = strings.TrimSpace(s.cfg.StripePriceIDs[planCode])
|
|
||||||
}
|
|
||||||
if priceID == "" {
|
|
||||||
switch planCode {
|
|
||||||
case "pro":
|
|
||||||
priceID = strings.TrimSpace(s.cfg.StripePriceIDs["growth"])
|
|
||||||
case "business":
|
|
||||||
priceID = strings.TrimSpace(s.cfg.StripePriceIDs["multi-location"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if priceID == "" {
|
|
||||||
return "", "", "", ErrPlanNotConfigured
|
|
||||||
}
|
|
||||||
return priceID, planCode, resolvedCurrency, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) defaultPlanCode() string {
|
|
||||||
for _, planCode := range []string{"pro", "monthly", "growth", "starter", "business", "multi-location"} {
|
|
||||||
if strings.TrimSpace(s.cfg.StripePriceIDs[planCode]) != "" {
|
|
||||||
return normalizePlanCode(planCode)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(s.cfg.StripePriceIDs[normalizePlanCode(planCode)+":czk"]) != "" {
|
|
||||||
return normalizePlanCode(planCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) planCodeForPrice(priceID string) string {
|
|
||||||
for planCode, configuredPriceID := range s.cfg.StripePriceIDs {
|
|
||||||
if strings.TrimSpace(configuredPriceID) == priceID {
|
|
||||||
return normalizePlanCode(strings.Split(planCode, ":")[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) hasConfiguredPrices() bool {
|
|
||||||
return s.defaultPlanCode() != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) checkoutAvailableForPlan(planCode string) bool {
|
|
||||||
if !s.cfg.StripeSecretConfigured() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(planCode) == "" {
|
|
||||||
return s.hasConfiguredPrices()
|
|
||||||
}
|
|
||||||
_, _, _, err := s.priceForPlan(planCode, "czk")
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizePlanCode(planCode string) string {
|
|
||||||
switch planCode {
|
|
||||||
case "growth":
|
|
||||||
return "pro"
|
|
||||||
case "multi-location":
|
|
||||||
return "business"
|
|
||||||
default:
|
|
||||||
return planCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeCurrency(currency string) string {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(currency)) {
|
|
||||||
case "usd":
|
|
||||||
return "usd"
|
|
||||||
default:
|
|
||||||
return "czk"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func userCustomerKey(userID string) string {
|
|
||||||
return "stripe:user:" + userID
|
|
||||||
}
|
|
||||||
|
|
||||||
func customerSnapshotKey(customerID string) string {
|
|
||||||
return "stripe:customer:" + customerID
|
|
||||||
}
|
|
||||||
|
|
||||||
func unixPtr(value int64) *time.Time {
|
|
||||||
if value == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
t := time.Unix(value, 0).UTC()
|
|
||||||
return &t
|
|
||||||
}
|
|
||||||
|
|
||||||
func subscriptionRank(subscription *stripe.Subscription) int {
|
|
||||||
switch subscription.Status {
|
|
||||||
case stripe.SubscriptionStatusActive:
|
|
||||||
return 100
|
|
||||||
case stripe.SubscriptionStatusTrialing:
|
|
||||||
return 90
|
|
||||||
case stripe.SubscriptionStatusPastDue:
|
|
||||||
return 80
|
|
||||||
case stripe.SubscriptionStatusUnpaid:
|
|
||||||
return 70
|
|
||||||
case stripe.SubscriptionStatusIncomplete:
|
|
||||||
return 60
|
|
||||||
case stripe.SubscriptionStatusPaused:
|
|
||||||
return 50
|
|
||||||
case stripe.SubscriptionStatusCanceled:
|
|
||||||
return 10
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractCustomerID(event stripe.Event) string {
|
|
||||||
var payload map[string]any
|
|
||||||
if err := json.Unmarshal(event.Data.Raw, &payload); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
value, ok := payload["customer"]
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
customerID, _ := value.(string)
|
|
||||||
return customerID
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package billing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"bookra/apps/auth-service/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPriceForPlanUsesConfiguredPlanCodesOnly(t *testing.T) {
|
|
||||||
service := NewService(&config.Config{
|
|
||||||
StripePriceIDs: map[string]string{
|
|
||||||
"monthly": "price_monthly",
|
|
||||||
"growth": "price_growth",
|
|
||||||
},
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
priceID, planCode, currency, err := service.priceForPlan("growth", "czk")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("price for plan: %v", err)
|
|
||||||
}
|
|
||||||
if priceID != "price_growth" || planCode != "pro" || currency != "czk" {
|
|
||||||
t.Fatalf("expected pro mapping, got price=%q plan=%q currency=%q", priceID, planCode, currency)
|
|
||||||
}
|
|
||||||
|
|
||||||
priceID, planCode, currency, err = service.priceForPlan("", "usd")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("default price for plan: %v", err)
|
|
||||||
}
|
|
||||||
if priceID != "price_monthly" || planCode != "monthly" || currency != "usd" {
|
|
||||||
t.Fatalf("expected monthly default, got price=%q plan=%q currency=%q", priceID, planCode, currency)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, _, err = service.priceForPlan("price_attacker_controlled", "czk")
|
|
||||||
if !errors.Is(err, ErrPlanNotConfigured) {
|
|
||||||
t.Fatalf("expected ErrPlanNotConfigured, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKVKeyShape(t *testing.T) {
|
|
||||||
if got := userCustomerKey("user_123"); got != "stripe:user:user_123" {
|
|
||||||
t.Fatalf("unexpected user key: %s", got)
|
|
||||||
}
|
|
||||||
if got := customerSnapshotKey("cus_123"); got != "stripe:customer:cus_123" {
|
|
||||||
t.Fatalf("unexpected customer key: %s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckoutAvailableForPlanRequiresSecret(t *testing.T) {
|
|
||||||
service := NewService(&config.Config{
|
|
||||||
StripePriceIDs: map[string]string{
|
|
||||||
"pro:czk": "price_pro_czk",
|
|
||||||
},
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
if service.checkoutAvailableForPlan("pro") {
|
|
||||||
t.Fatal("expected checkout unavailable without stripe secret")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckoutAvailableForPlanRequiresConfiguredPlan(t *testing.T) {
|
|
||||||
service := NewService(&config.Config{
|
|
||||||
StripeSecretKey: "sk_test_123",
|
|
||||||
StripePriceIDs: map[string]string{
|
|
||||||
"pro:czk": "price_pro_czk",
|
|
||||||
},
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
if !service.checkoutAvailableForPlan("pro") {
|
|
||||||
t.Fatal("expected pro checkout available")
|
|
||||||
}
|
|
||||||
if service.checkoutAvailableForPlan("business") {
|
|
||||||
t.Fatal("expected business checkout unavailable without configured price")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
AppEnv string
|
|
||||||
Port string
|
|
||||||
DatabaseURL string
|
|
||||||
FrontendURL string
|
|
||||||
JWTSecret string
|
|
||||||
NeonAuthURL string
|
|
||||||
|
|
||||||
SMTPHost string
|
|
||||||
SMTPPort int
|
|
||||||
SMTPUsername string
|
|
||||||
SMTPPassword string
|
|
||||||
EmailFrom string
|
|
||||||
|
|
||||||
GoogleClientID string
|
|
||||||
GoogleClientSecret string
|
|
||||||
GoogleRedirectURL string
|
|
||||||
|
|
||||||
StripeSecretKey string
|
|
||||||
StripeWebhookSecret string
|
|
||||||
StripePriceIDs map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
|
||||||
port := getEnv("PORT", "8081")
|
|
||||||
|
|
||||||
dbURL := getEnv("DATABASE_URL", "")
|
|
||||||
if dbURL == "" {
|
|
||||||
return nil, fmt.Errorf("DATABASE_URL is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
smtpPort, _ := strconv.Atoi(getEnv("SMTP_PORT", "465"))
|
|
||||||
|
|
||||||
return &Config{
|
|
||||||
AppEnv: getEnv("APP_ENV", "development"),
|
|
||||||
Port: port,
|
|
||||||
DatabaseURL: dbURL,
|
|
||||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
|
||||||
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
|
|
||||||
NeonAuthURL: getEnv("NEON_AUTH_URL", ""),
|
|
||||||
|
|
||||||
SMTPHost: getEnv("SMTP_HOST", "smtp.purelymail.com"),
|
|
||||||
SMTPPort: smtpPort,
|
|
||||||
SMTPUsername: getEnvAllowEmpty("SMTP_USERNAME", "noreply@tdvorak.dev"),
|
|
||||||
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
|
|
||||||
EmailFrom: getEnv("EMAIL_FROM", "noreply@tdvorak.dev"),
|
|
||||||
|
|
||||||
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
|
|
||||||
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
|
|
||||||
GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", ""),
|
|
||||||
|
|
||||||
StripeSecretKey: getEnv("STRIPE_SECRET_KEY", ""),
|
|
||||||
StripeWebhookSecret: getEnv("STRIPE_WEBHOOK_SECRET", ""),
|
|
||||||
StripePriceIDs: map[string]string{
|
|
||||||
"monthly": getEnv("STRIPE_PRICE_ID", ""),
|
|
||||||
"starter": getEnv("STRIPE_STARTER_PRICE_ID", ""),
|
|
||||||
"growth": getEnv("STRIPE_GROWTH_PRICE_ID", ""),
|
|
||||||
"multi-location": getEnv("STRIPE_MULTI_LOCATION_PRICE_ID", ""),
|
|
||||||
"pro": getEnv("STRIPE_PRO_PRICE_ID", ""),
|
|
||||||
"business": getEnv("STRIPE_BUSINESS_PRICE_ID", ""),
|
|
||||||
"starter:czk": getEnv("STRIPE_STARTER_CZK_PRICE_ID", ""),
|
|
||||||
"starter:usd": getEnv("STRIPE_STARTER_USD_PRICE_ID", ""),
|
|
||||||
"pro:czk": getEnv("STRIPE_PRO_CZK_PRICE_ID", ""),
|
|
||||||
"pro:usd": getEnv("STRIPE_PRO_USD_PRICE_ID", ""),
|
|
||||||
"business:czk": getEnv("STRIPE_BUSINESS_CZK_PRICE_ID", ""),
|
|
||||||
"business:usd": getEnv("STRIPE_BUSINESS_USD_PRICE_ID", ""),
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnv(key, defaultVal string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnvAllowEmpty(key, defaultVal string) string {
|
|
||||||
if v, ok := os.LookupEnv(key); ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) StripeSecretConfigured() bool {
|
|
||||||
return strings.TrimSpace(cfg.StripeSecretKey) != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) StripeWebhookConfigured() bool {
|
|
||||||
return strings.TrimSpace(cfg.StripeWebhookSecret) != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) StripeHasAnyPriceConfigured() bool {
|
|
||||||
for _, priceID := range cfg.StripePriceIDs {
|
|
||||||
if strings.TrimSpace(priceID) != "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) StripeCheckoutReady() bool {
|
|
||||||
return cfg.StripeSecretConfigured() && cfg.StripeHasAnyPriceConfigured()
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestStripeReadinessHelpers(t *testing.T) {
|
|
||||||
cfg := &Config{
|
|
||||||
StripeSecretKey: "sk_test_123",
|
|
||||||
StripePriceIDs: map[string]string{
|
|
||||||
"pro:czk": "price_123",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if !cfg.StripeSecretConfigured() {
|
|
||||||
t.Fatal("expected secret configured")
|
|
||||||
}
|
|
||||||
if cfg.StripeWebhookConfigured() {
|
|
||||||
t.Fatal("expected webhook not configured")
|
|
||||||
}
|
|
||||||
if !cfg.StripeHasAnyPriceConfigured() {
|
|
||||||
t.Fatal("expected prices configured")
|
|
||||||
}
|
|
||||||
if !cfg.StripeCheckoutReady() {
|
|
||||||
t.Fatal("expected checkout ready")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStripeCheckoutReadyRequiresSecretAndPrice(t *testing.T) {
|
|
||||||
cfg := &Config{
|
|
||||||
StripePriceIDs: map[string]string{
|
|
||||||
"pro:czk": "price_123",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if cfg.StripeCheckoutReady() {
|
|
||||||
t.Fatal("expected checkout not ready without secret")
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.StripeSecretKey = "sk_test_123"
|
|
||||||
cfg.StripePriceIDs = map[string]string{}
|
|
||||||
if cfg.StripeCheckoutReady() {
|
|
||||||
t.Fatal("expected checkout not ready without price")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadDefaultsAuthServicePortTo8081(t *testing.T) {
|
|
||||||
originals := map[string]string{}
|
|
||||||
for _, key := range []string{
|
|
||||||
"PORT",
|
|
||||||
"DATABASE_URL",
|
|
||||||
} {
|
|
||||||
originals[key] = os.Getenv(key)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
for key, value := range originals {
|
|
||||||
if value == "" {
|
|
||||||
_ = os.Unsetenv(key)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_ = os.Setenv(key, value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
_ = os.Unsetenv("PORT")
|
|
||||||
_ = os.Setenv("DATABASE_URL", "postgresql://localhost/bookra")
|
|
||||||
|
|
||||||
cfg, err := Load()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load config: %v", err)
|
|
||||||
}
|
|
||||||
if cfg.Port != "8081" {
|
|
||||||
t.Fatalf("expected default port 8081, got %s", cfg.Port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DB struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(databaseURL string) (*DB, error) {
|
|
||||||
config, err := pgxpool.ParseConfig(databaseURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse database config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pool, err := pgxpool.NewWithConfig(context.Background(), config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create pool: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := pool.Ping(context.Background()); err != nil {
|
|
||||||
return nil, fmt.Errorf("ping database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &DB{pool: pool}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) Close() {
|
|
||||||
db.pool.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) Pool() *pgxpool.Pool {
|
|
||||||
return db.pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
|
|
||||||
return db.pool.QueryRow(ctx, sql, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
|
|
||||||
return db.pool.Query(ctx, sql, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) Exec(ctx context.Context, sql string, args ...interface{}) error {
|
|
||||||
_, err := db.pool.Exec(ctx, sql, args...)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stats contains database statistics for the admin dashboard
|
|
||||||
type Stats struct {
|
|
||||||
TotalUsers int64 `json:"totalUsers"`
|
|
||||||
UsersToday int64 `json:"usersToday"`
|
|
||||||
UsersThisWeek int64 `json:"usersThisWeek"`
|
|
||||||
UsersThisMonth int64 `json:"usersThisMonth"`
|
|
||||||
ActiveUsers7Days int64 `json:"activeUsers7Days"`
|
|
||||||
ActiveUsers30Days int64 `json:"activeUsers30Days"`
|
|
||||||
MagicLinksSent int64 `json:"magicLinksSent"`
|
|
||||||
MagicLinksUsed int64 `json:"magicLinksUsed"`
|
|
||||||
MagicLinksPending int64 `json:"magicLinksPending"`
|
|
||||||
OAuthUsers int64 `json:"oauthUsers"`
|
|
||||||
PasswordUsers int64 `json:"passwordUsers"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStats returns database statistics for the admin dashboard
|
|
||||||
func (db *DB) GetStats(ctx context.Context) (*Stats, error) {
|
|
||||||
stats := &Stats{}
|
|
||||||
|
|
||||||
// Total users
|
|
||||||
err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Users created today
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE`).Scan(&stats.UsersToday)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Users created this week
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '7 days'`).Scan(&stats.UsersThisWeek)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Users created this month
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'`).Scan(&stats.UsersThisMonth)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active users (logged in) in last 7 days
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE last_login_at >= CURRENT_DATE - INTERVAL '7 days'`).Scan(&stats.ActiveUsers7Days)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active users in last 30 days
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE last_login_at >= CURRENT_DATE - INTERVAL '30 days'`).Scan(&stats.ActiveUsers30Days)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Magic links sent (total)
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM magic_links`).Scan(&stats.MagicLinksSent)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Magic links used
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM magic_links WHERE used = TRUE`).Scan(&stats.MagicLinksUsed)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pending magic links
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM magic_links WHERE used = FALSE AND expires_at > NOW()`).Scan(&stats.MagicLinksPending)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuth users
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE provider != 'email'`).Scan(&stats.OAuthUsers)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Password users
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE password_hash IS NOT NULL`).Scan(&stats.PasswordUsers)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats, nil
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
type User struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Name *string `json:"name,omitempty"`
|
|
||||||
PasswordHash *string `json:"-"`
|
|
||||||
EmailVerified bool `json:"email_verified"`
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
ProviderID *string `json:"provider_id,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MagicLink struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
UserID uuid.UUID `json:"user_id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Used bool `json:"used"`
|
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
|
||||||
var user User
|
|
||||||
var name, passwordHash, providerID *string
|
|
||||||
var lastLoginAt *time.Time
|
|
||||||
|
|
||||||
err := db.QueryRow(ctx, `
|
|
||||||
SELECT id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at, last_login_at
|
|
||||||
FROM users
|
|
||||||
WHERE email = $1
|
|
||||||
`, email).Scan(
|
|
||||||
&user.ID, &user.Email, &name, &passwordHash,
|
|
||||||
&user.EmailVerified, &user.Provider, &providerID,
|
|
||||||
&user.CreatedAt, &user.UpdatedAt, &lastLoginAt,
|
|
||||||
)
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
user.Name = name
|
|
||||||
user.PasswordHash = passwordHash
|
|
||||||
user.ProviderID = providerID
|
|
||||||
user.LastLoginAt = lastLoginAt
|
|
||||||
return &user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) GetUserByID(ctx context.Context, id uuid.UUID) (*User, error) {
|
|
||||||
var user User
|
|
||||||
var name, passwordHash, providerID *string
|
|
||||||
var lastLoginAt *time.Time
|
|
||||||
|
|
||||||
err := db.QueryRow(ctx, `
|
|
||||||
SELECT id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at, last_login_at
|
|
||||||
FROM users
|
|
||||||
WHERE id = $1
|
|
||||||
`, id).Scan(
|
|
||||||
&user.ID, &user.Email, &name, &passwordHash,
|
|
||||||
&user.EmailVerified, &user.Provider, &providerID,
|
|
||||||
&user.CreatedAt, &user.UpdatedAt, &lastLoginAt,
|
|
||||||
)
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
user.Name = name
|
|
||||||
user.PasswordHash = passwordHash
|
|
||||||
user.ProviderID = providerID
|
|
||||||
user.LastLoginAt = lastLoginAt
|
|
||||||
return &user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) GetUserByProviderID(ctx context.Context, provider, providerID string) (*User, error) {
|
|
||||||
var user User
|
|
||||||
var name, passwordHash *string
|
|
||||||
var lastLoginAt *time.Time
|
|
||||||
|
|
||||||
err := db.QueryRow(ctx, `
|
|
||||||
SELECT id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at, last_login_at
|
|
||||||
FROM users
|
|
||||||
WHERE provider = $1 AND provider_id = $2
|
|
||||||
`, provider, providerID).Scan(
|
|
||||||
&user.ID, &user.Email, &name, &passwordHash,
|
|
||||||
&user.EmailVerified, &user.Provider, &user.ProviderID,
|
|
||||||
&user.CreatedAt, &user.UpdatedAt, &lastLoginAt,
|
|
||||||
)
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
user.Name = name
|
|
||||||
user.PasswordHash = passwordHash
|
|
||||||
user.LastLoginAt = lastLoginAt
|
|
||||||
return &user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) CreateUser(ctx context.Context, user *User) (*User, error) {
|
|
||||||
if user.ID == uuid.Nil {
|
|
||||||
user.ID = uuid.Must(uuid.NewV7())
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
user.CreatedAt = now
|
|
||||||
user.UpdatedAt = now
|
|
||||||
|
|
||||||
_, err := db.pool.Exec(ctx, `
|
|
||||||
INSERT INTO users (id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
|
|
||||||
`, user.ID, user.Email, user.Name, user.PasswordHash, user.EmailVerified, user.Provider, user.ProviderID, now)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) UpdateUser(ctx context.Context, user *User) error {
|
|
||||||
user.UpdatedAt = time.Now()
|
|
||||||
|
|
||||||
_, err := db.pool.Exec(ctx, `
|
|
||||||
UPDATE users
|
|
||||||
SET email = $2, name = $3, password_hash = $4, email_verified = $5,
|
|
||||||
provider = $6, provider_id = $7, updated_at = $8, last_login_at = $9
|
|
||||||
WHERE id = $1
|
|
||||||
`, user.ID, user.Email, user.Name, user.PasswordHash, user.EmailVerified,
|
|
||||||
user.Provider, user.ProviderID, user.UpdatedAt, user.LastLoginAt)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) UpdateLastLogin(ctx context.Context, userID uuid.UUID) error {
|
|
||||||
_, err := db.pool.Exec(ctx, `
|
|
||||||
UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1
|
|
||||||
`, userID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) CreateMagicLink(ctx context.Context, token string, email string, userID uuid.UUID, expiresAt time.Time) error {
|
|
||||||
_, err := db.pool.Exec(ctx, `
|
|
||||||
INSERT INTO magic_links (token, user_id, email, expires_at, created_at)
|
|
||||||
VALUES ($1, $2, $3, $4, NOW())
|
|
||||||
`, token, userID, email, expiresAt)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) GetMagicLink(ctx context.Context, token string) (*MagicLink, error) {
|
|
||||||
var ml MagicLink
|
|
||||||
var userID uuid.UUID
|
|
||||||
|
|
||||||
err := db.QueryRow(ctx, `
|
|
||||||
SELECT token, user_id, email, used, expires_at, created_at
|
|
||||||
FROM magic_links
|
|
||||||
WHERE token = $1
|
|
||||||
`, token).Scan(&ml.Token, &userID, &ml.Email, &ml.Used, &ml.ExpiresAt, &ml.CreatedAt)
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ml.UserID = userID
|
|
||||||
return &ml, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) MarkMagicLinkUsed(ctx context.Context, token string) error {
|
|
||||||
_, err := db.pool.Exec(ctx, `UPDATE magic_links SET used = true WHERE token = $1`, token)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) PutKV(ctx context.Context, key string, value any) error {
|
|
||||||
payload, err := json.Marshal(value)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal kv value: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.pool.Exec(ctx, `
|
|
||||||
INSERT INTO stripe_kv (key, value, created_at, updated_at)
|
|
||||||
VALUES ($1, $2, NOW(), NOW())
|
|
||||||
ON CONFLICT (key) DO UPDATE
|
|
||||||
SET value = EXCLUDED.value, updated_at = NOW()
|
|
||||||
`, key, payload)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) GetKV(ctx context.Context, key string, dest any) (bool, error) {
|
|
||||||
var payload []byte
|
|
||||||
err := db.QueryRow(ctx, `
|
|
||||||
SELECT value
|
|
||||||
FROM stripe_kv
|
|
||||||
WHERE key = $1
|
|
||||||
`, key).Scan(&payload)
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(payload, dest); err != nil {
|
|
||||||
return false, fmt.Errorf("unmarshal kv value: %w", err)
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package email
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/tls"
|
|
||||||
"fmt"
|
|
||||||
"net/smtp"
|
|
||||||
|
|
||||||
"github.com/jordan-wright/email"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Host string
|
|
||||||
Port int
|
|
||||||
Username string
|
|
||||||
Password string
|
|
||||||
From string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Service struct {
|
|
||||||
config Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(config Config) *Service {
|
|
||||||
return &Service{config: config}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendMagicLink sends a magic link authentication email with proper branding
|
|
||||||
func (s *Service) SendMagicLink(toEmail, toName, linkURL, locale string) error {
|
|
||||||
template := MagicLinkEmail(toName, linkURL, locale)
|
|
||||||
return s.sendTemplate(toEmail, template)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendWelcomeEmail sends a welcome email to new users
|
|
||||||
func (s *Service) SendWelcomeEmail(toEmail, name, locale string) error {
|
|
||||||
template := WelcomeEmail(name, locale)
|
|
||||||
return s.sendTemplate(toEmail, template)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendBookingConfirmation sends booking confirmation to customers
|
|
||||||
func (s *Service) SendBookingConfirmation(toEmail, customerName, businessName, serviceName, dateTime, location, locale string) error {
|
|
||||||
template := BookingConfirmationEmail(customerName, businessName, serviceName, dateTime, location, locale)
|
|
||||||
return s.sendTemplate(toEmail, template)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendPasswordReset sends password reset email
|
|
||||||
func (s *Service) SendPasswordReset(toEmail, name, resetURL, locale string) error {
|
|
||||||
template := PasswordResetEmail(name, resetURL, locale)
|
|
||||||
return s.sendTemplate(toEmail, template)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendTemplate sends an email using the provided template
|
|
||||||
func (s *Service) sendTemplate(toEmail string, template EmailTemplate) error {
|
|
||||||
e := email.NewEmail()
|
|
||||||
e.From = fmt.Sprintf("Bookra <%s>", s.config.From)
|
|
||||||
e.To = []string{toEmail}
|
|
||||||
e.Subject = template.Subject
|
|
||||||
e.Text = []byte(template.Text)
|
|
||||||
e.HTML = []byte(template.HTML)
|
|
||||||
|
|
||||||
return s.send(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// send delivers the email via SMTP
|
|
||||||
func (s *Service) send(e *email.Email) error {
|
|
||||||
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
|
|
||||||
|
|
||||||
var auth smtp.Auth
|
|
||||||
if s.config.Username != "" {
|
|
||||||
auth = smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.config.Port == 465 {
|
|
||||||
return e.SendWithTLS(addr, auth, &tls.Config{ServerName: s.config.Host})
|
|
||||||
}
|
|
||||||
|
|
||||||
return e.Send(addr, auth)
|
|
||||||
}
|
|
||||||
@@ -1,743 +0,0 @@
|
|||||||
package email
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Bookra Design System - Warm editorial aesthetic
|
|
||||||
// Canvas: warm cream backgrounds (#fbf9f6)
|
|
||||||
// Ink: warm dark brown (#2a221e)
|
|
||||||
// Accent: terracotta (#a65c3e)
|
|
||||||
// Logo bg: #24201d, Logo text: #f7f2e8
|
|
||||||
const (
|
|
||||||
canvas = "#fbf9f6" // Warm cream background
|
|
||||||
canvasSubtle = "#f5f2ed" // Slightly darker cream
|
|
||||||
ink = "#2a221e" // Warm dark brown
|
|
||||||
inkMuted = "#5c514a" // Muted brown
|
|
||||||
inkSubtle = "#8b7f76" // Light muted brown
|
|
||||||
accent = "#a65c3e" // Terracotta
|
|
||||||
accentHover = "#8f4d33" // Darker terracotta
|
|
||||||
accentSubtle = "#f5ebe7" // Light terracotta tint
|
|
||||||
logoBg = "#24201d" // Logo dark brown
|
|
||||||
logoText = "#f7f2e8" // Logo cream
|
|
||||||
border = "#e8e2da" // Warm border
|
|
||||||
white = "#ffffff"
|
|
||||||
)
|
|
||||||
|
|
||||||
type EmailTemplate struct {
|
|
||||||
Subject string
|
|
||||||
HTML string
|
|
||||||
Text string
|
|
||||||
}
|
|
||||||
|
|
||||||
func MagicLinkEmail(toName, magicURL string, locale string) EmailTemplate {
|
|
||||||
if locale == "cs" {
|
|
||||||
return magicLinkEmailCS(toName, magicURL)
|
|
||||||
}
|
|
||||||
return magicLinkEmailEN(toName, magicURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func WelcomeEmail(name string, locale string) EmailTemplate {
|
|
||||||
if locale == "cs" {
|
|
||||||
return welcomeEmailCS(name)
|
|
||||||
}
|
|
||||||
return welcomeEmailEN(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func BookingConfirmationEmail(customerName, businessName, serviceName, dateTime, location string, locale string) EmailTemplate {
|
|
||||||
if locale == "cs" {
|
|
||||||
return bookingConfirmationCS(customerName, businessName, serviceName, dateTime, location)
|
|
||||||
}
|
|
||||||
return bookingConfirmationEN(customerName, businessName, serviceName, dateTime, location)
|
|
||||||
}
|
|
||||||
|
|
||||||
func PasswordResetEmail(name, resetURL string, locale string) EmailTemplate {
|
|
||||||
if locale == "cs" {
|
|
||||||
return passwordResetCS(name, resetURL)
|
|
||||||
}
|
|
||||||
return passwordResetEN(name, resetURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func magicLinkEmailEN(toName, magicURL string) EmailTemplate {
|
|
||||||
subject := "Your sign-in link for Bookra"
|
|
||||||
if toName == "" {
|
|
||||||
toName = "there"
|
|
||||||
}
|
|
||||||
|
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
|
||||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
|
||||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
|
||||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
|
|
||||||
.tagline { color: %s; font-size: 15px; margin-top: 6px; font-style: italic; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
|
||||||
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
|
||||||
.button-wrap { margin: 40px 0; }
|
|
||||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; transition: background 0.2s; }
|
|
||||||
.button:hover { background: %s; }
|
|
||||||
.link-box { background: %s; border: 1px solid %s; border-radius: 8px; padding: 20px; margin: 32px 0; }
|
|
||||||
.link-label { font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: 'Space Grotesk', sans-serif; }
|
|
||||||
.link-url { font-size: 14px; color: %s; word-break: break-all; font-family: 'JetBrains Mono', monospace; }
|
|
||||||
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
|
|
||||||
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
|
||||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
|
||||||
.footer-text { font-size: 14px; color: %s; }
|
|
||||||
.footer-links { margin-top: 12px; }
|
|
||||||
.footer-links a { color: %s; text-decoration: none; font-size: 13px; margin: 0 12px; font-family: 'Space Grotesk', sans-serif; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
<div class="tagline">Calm booking software</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Hi %s,</div>
|
|
||||||
<div class="message">
|
|
||||||
You requested a sign-in link for your Bookra account. Click below to access your account securely — no password needed.
|
|
||||||
</div>
|
|
||||||
<div class="button-wrap">
|
|
||||||
<a href="%s" class="button">Sign In to Bookra</a>
|
|
||||||
</div>
|
|
||||||
<div class="link-box">
|
|
||||||
<div class="link-label">Or copy this link</div>
|
|
||||||
<div class="link-url">%s</div>
|
|
||||||
</div>
|
|
||||||
<div class="expiry">
|
|
||||||
This link expires in <strong>15 minutes</strong> for security.
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
Didn't request this? You can safely ignore it — someone may have entered your email by mistake.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
<div class="footer-links">
|
|
||||||
<a href="https://bookra.tdvorak.dev/privacy">Privacy</a>
|
|
||||||
<a href="https://bookra.tdvorak.dev/terms">Terms</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
subject, canvas, white, canvas, border,
|
|
||||||
logoBg, logoText, ink, inkSubtle,
|
|
||||||
ink, inkMuted,
|
|
||||||
accent, white, accentHover,
|
|
||||||
canvasSubtle, border, inkSubtle, inkMuted,
|
|
||||||
accentSubtle, accent, accent,
|
|
||||||
inkSubtle, border,
|
|
||||||
canvas, border, inkMuted, inkMuted,
|
|
||||||
toName, magicURL, magicURL)
|
|
||||||
|
|
||||||
text := fmt.Sprintf(`Bookra — Sign-in Link
|
|
||||||
|
|
||||||
Hi %s,
|
|
||||||
|
|
||||||
Sign in to Bookra (link expires in 15 minutes):
|
|
||||||
%s
|
|
||||||
|
|
||||||
Didn't request this? You can safely ignore this email.
|
|
||||||
|
|
||||||
© 2024 Bookra`, toName, magicURL)
|
|
||||||
|
|
||||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
|
||||||
}
|
|
||||||
|
|
||||||
func magicLinkEmailCS(toName, magicURL string) EmailTemplate {
|
|
||||||
subject := "Váš přihlašovací odkaz do Bookra"
|
|
||||||
if toName == "" {
|
|
||||||
toName = "vás"
|
|
||||||
}
|
|
||||||
|
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="cs">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
|
||||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
|
||||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
|
||||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
|
|
||||||
.tagline { color: %s; font-size: 15px; margin-top: 6px; font-style: italic; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
|
||||||
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
|
||||||
.button-wrap { margin: 40px 0; }
|
|
||||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
|
||||||
.button:hover { background: %s; }
|
|
||||||
.link-box { background: %s; border: 1px solid %s; border-radius: 8px; padding: 20px; margin: 32px 0; }
|
|
||||||
.link-label { font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: 'Space Grotesk', sans-serif; }
|
|
||||||
.link-url { font-size: 14px; color: %s; word-break: break-all; font-family: 'JetBrains Mono', monospace; }
|
|
||||||
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
|
|
||||||
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
|
||||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
|
||||||
.footer-text { font-size: 14px; color: %s; }
|
|
||||||
.footer-links { margin-top: 12px; }
|
|
||||||
.footer-links a { color: %s; text-decoration: none; font-size: 13px; margin: 0 12px; font-family: 'Space Grotesk', sans-serif; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
<div class="tagline">Klidný rezervační software</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Dobrý den %s,</div>
|
|
||||||
<div class="message">
|
|
||||||
Požádali jste o přihlašovací odkaz k účtu Bookra. Klikněte níže pro bezpečný přístup — heslo není potřeba.
|
|
||||||
</div>
|
|
||||||
<div class="button-wrap">
|
|
||||||
<a href="%s" class="button">Přihlásit se do Bookra</a>
|
|
||||||
</div>
|
|
||||||
<div class="link-box">
|
|
||||||
<div class="link-label">Nebo zkopírujte tento odkaz</div>
|
|
||||||
<div class="link-url">%s</div>
|
|
||||||
</div>
|
|
||||||
<div class="expiry">
|
|
||||||
Tento odkaz vyprší za <strong>15 minut</strong> z bezpečnostních důvodů.
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
Nepožádali jste o tento email? Můžete ho bezpečně ignorovat.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
<div class="footer-links">
|
|
||||||
<a href="https://bookra.tdvorak.dev/privacy">Ochrana soukromí</a>
|
|
||||||
<a href="https://bookra.tdvorak.dev/terms">Podmínky</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
subject, canvas, white, canvas, border,
|
|
||||||
logoBg, logoText, ink, inkSubtle,
|
|
||||||
ink, inkMuted,
|
|
||||||
accent, white, accentHover,
|
|
||||||
canvasSubtle, border, inkSubtle, inkMuted,
|
|
||||||
accentSubtle, accent, accent,
|
|
||||||
inkSubtle, border,
|
|
||||||
canvas, border, inkMuted, inkMuted,
|
|
||||||
toName, magicURL, magicURL)
|
|
||||||
|
|
||||||
text := fmt.Sprintf(`Bookra — Přihlašovací odkaz
|
|
||||||
|
|
||||||
Dobrý den %s,
|
|
||||||
|
|
||||||
Přihlaste se do Bookra (odkaz vyprší za 15 minut):
|
|
||||||
%s
|
|
||||||
|
|
||||||
Nepožádali jste o tento email? Můžete ho ignorovat.
|
|
||||||
|
|
||||||
© 2024 Bookra`, toName, magicURL)
|
|
||||||
|
|
||||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
|
||||||
}
|
|
||||||
|
|
||||||
func welcomeEmailEN(name string) EmailTemplate {
|
|
||||||
subject := "Welcome to Bookra"
|
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap');
|
|
||||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
|
||||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
|
||||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
|
||||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
|
||||||
.message { font-size: 18px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
|
||||||
.features { background: %s; border-radius: 12px; padding: 32px; margin: 32px 0; }
|
|
||||||
.feature { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
|
||||||
.feature:last-child { margin-bottom: 0; }
|
|
||||||
.feature-icon { width: 24px; height: 24px; background: %s; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: %s; font-size: 14px; flex-shrink: 0; font-family: 'Space Grotesk', sans-serif; }
|
|
||||||
.feature-text { font-size: 16px; color: %s; line-height: 1.5; }
|
|
||||||
.button-wrap { margin: 40px 0; text-align: center; }
|
|
||||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
|
||||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
|
||||||
.footer-text { font-size: 14px; color: %s; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Welcome, %s</div>
|
|
||||||
<div class="message">
|
|
||||||
Thanks for joining Bookra. We're here to help you manage bookings with calm and clarity.
|
|
||||||
</div>
|
|
||||||
<div class="features">
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature-icon">✓</div>
|
|
||||||
<div class="feature-text"><strong>Smart scheduling</strong> — Automatic conflict detection and buffer times</div>
|
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature-icon">✓</div>
|
|
||||||
<div class="feature-text"><strong>Customer insights</strong> — History and preferences at your fingertips</div>
|
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature-icon">✓</div>
|
|
||||||
<div class="feature-text"><strong>Reminders</strong> — Reduce no-shows with gentle notifications</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="button-wrap">
|
|
||||||
<a href="https://bookra.tdvorak.dev/dashboard" class="button">Open Dashboard</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
subject, canvas, white, canvas, border,
|
|
||||||
logoBg, logoText, ink,
|
|
||||||
ink, inkMuted, canvasSubtle,
|
|
||||||
accent, white, inkMuted,
|
|
||||||
accent, white,
|
|
||||||
canvas, border, inkMuted,
|
|
||||||
name)
|
|
||||||
|
|
||||||
text := fmt.Sprintf(`Welcome to Bookra, %s
|
|
||||||
|
|
||||||
Thanks for joining. We're here to help you manage bookings with calm and clarity.
|
|
||||||
|
|
||||||
Get started: https://bookra.tdvorak.dev/dashboard
|
|
||||||
|
|
||||||
© 2024 Bookra`, name)
|
|
||||||
|
|
||||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
|
||||||
}
|
|
||||||
|
|
||||||
func welcomeEmailCS(name string) EmailTemplate {
|
|
||||||
subject := "Vítejte v Bookra"
|
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="cs">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap');
|
|
||||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
|
||||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
|
||||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
|
||||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
|
||||||
.message { font-size: 18px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
|
||||||
.features { background: %s; border-radius: 12px; padding: 32px; margin: 32px 0; }
|
|
||||||
.feature { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
|
||||||
.feature:last-child { margin-bottom: 0; }
|
|
||||||
.feature-icon { width: 24px; height: 24px; background: %s; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: %s; font-size: 14px; flex-shrink: 0; }
|
|
||||||
.feature-text { font-size: 16px; color: %s; line-height: 1.5; }
|
|
||||||
.button-wrap { margin: 40px 0; text-align: center; }
|
|
||||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
|
||||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
|
||||||
.footer-text { font-size: 14px; color: %s; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Vítejte, %s</div>
|
|
||||||
<div class="message">
|
|
||||||
Děkujeme za registraci. Pomůžeme vám spravovat rezervace s klidem a přehledem.
|
|
||||||
</div>
|
|
||||||
<div class="features">
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature-icon">✓</div>
|
|
||||||
<div class="feature-text"><strong>Chytré plánování</strong> — Automatická detekce konfliktů</div>
|
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature-icon">✓</div>
|
|
||||||
<div class="feature-text"><strong>Přehled o zákaznících</strong> — Historie a preference</div>
|
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature-icon">✓</div>
|
|
||||||
<div class="feature-text"><strong>Připomenutí</strong> — Méně zapomenutých termínů</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="button-wrap">
|
|
||||||
<a href="https://bookra.tdvorak.dev/dashboard" class="button">Otevřít aplikaci</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
subject, canvas, white, canvas, border,
|
|
||||||
logoBg, logoText, ink,
|
|
||||||
ink, inkMuted, canvasSubtle,
|
|
||||||
accent, white, inkMuted,
|
|
||||||
accent, white,
|
|
||||||
canvas, border, inkMuted,
|
|
||||||
name)
|
|
||||||
|
|
||||||
text := fmt.Sprintf(`Vítejte v Bookra, %s
|
|
||||||
|
|
||||||
Děkujeme za registraci. Pomůžeme vám spravovat rezervace s klidem.
|
|
||||||
|
|
||||||
Otevřít aplikaci: https://bookra.tdvorak.dev/dashboard
|
|
||||||
|
|
||||||
© 2024 Bookra`, name)
|
|
||||||
|
|
||||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
|
||||||
}
|
|
||||||
|
|
||||||
func bookingConfirmationEN(customerName, businessName, serviceName, dateTime, location string) EmailTemplate {
|
|
||||||
subject := fmt.Sprintf("Confirmed: %s with %s", serviceName, businessName)
|
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap');
|
|
||||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
|
||||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
|
||||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
|
||||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.badge { display: inline-block; background: %s; color: %s; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 24px; font-family: 'Space Grotesk', sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
||||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; color: %s; margin-bottom: 8px; }
|
|
||||||
.message { font-size: 17px; color: %s; margin-bottom: 32px; }
|
|
||||||
.details { background: %s; border-radius: 12px; padding: 28px; margin: 32px 0; }
|
|
||||||
.detail-row { display: flex; padding: 14px 0; border-bottom: 1px solid %s; }
|
|
||||||
.detail-row:last-child { border-bottom: none; }
|
|
||||||
.detail-label { width: 120px; font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.05em; font-family: 'Space Grotesk', sans-serif; }
|
|
||||||
.detail-value { flex: 1; font-size: 16px; color: %s; font-weight: 500; }
|
|
||||||
.help { font-size: 15px; color: %s; margin-top: 32px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
|
||||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
|
||||||
.footer-text { font-size: 14px; color: %s; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="badge">Confirmed</div>
|
|
||||||
<div class="greeting">Hello %s,</div>
|
|
||||||
<div class="message">
|
|
||||||
Your booking with <strong>%s</strong> is confirmed.
|
|
||||||
</div>
|
|
||||||
<div class="details">
|
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">Service</div>
|
|
||||||
<div class="detail-value">%s</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">When</div>
|
|
||||||
<div class="detail-value">%s</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">Where</div>
|
|
||||||
<div class="detail-value">%s</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
Need to reschedule? Contact %s directly.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
subject, canvas, white, canvas, border,
|
|
||||||
logoBg, logoText, ink,
|
|
||||||
accentSubtle, accent, ink, inkMuted,
|
|
||||||
canvasSubtle, border, inkSubtle, ink,
|
|
||||||
inkSubtle, border,
|
|
||||||
canvas, border, inkMuted,
|
|
||||||
customerName, businessName, serviceName, dateTime, location, businessName)
|
|
||||||
|
|
||||||
text := fmt.Sprintf(`Booking Confirmed
|
|
||||||
|
|
||||||
Hello %s,
|
|
||||||
|
|
||||||
Your booking with %s is confirmed.
|
|
||||||
|
|
||||||
Service: %s
|
|
||||||
When: %s
|
|
||||||
Where: %s
|
|
||||||
|
|
||||||
Need to reschedule? Contact %s.
|
|
||||||
|
|
||||||
© 2024 Bookra`,
|
|
||||||
customerName, businessName, serviceName, dateTime, location, businessName)
|
|
||||||
|
|
||||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
|
||||||
}
|
|
||||||
|
|
||||||
func bookingConfirmationCS(customerName, businessName, serviceName, dateTime, location string) EmailTemplate {
|
|
||||||
subject := fmt.Sprintf("Potvrzeno: %s v %s", serviceName, businessName)
|
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="cs">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap');
|
|
||||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
|
||||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
|
||||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
|
||||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.badge { display: inline-block; background: %s; color: %s; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 24px; font-family: 'Space Grotesk', sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
||||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; color: %s; margin-bottom: 8px; }
|
|
||||||
.message { font-size: 17px; color: %s; margin-bottom: 32px; }
|
|
||||||
.details { background: %s; border-radius: 12px; padding: 28px; margin: 32px 0; }
|
|
||||||
.detail-row { display: flex; padding: 14px 0; border-bottom: 1px solid %s; }
|
|
||||||
.detail-row:last-child { border-bottom: none; }
|
|
||||||
.detail-label { width: 120px; font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.05em; font-family: 'Space Grotesk', sans-serif; }
|
|
||||||
.detail-value { flex: 1; font-size: 16px; color: %s; font-weight: 500; }
|
|
||||||
.help { font-size: 15px; color: %s; margin-top: 32px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
|
||||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
|
||||||
.footer-text { font-size: 14px; color: %s; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="badge">Potvrzeno</div>
|
|
||||||
<div class="greeting">Dobrý den %s,</div>
|
|
||||||
<div class="message">
|
|
||||||
Vaše rezervace v <strong>%s</strong> je potvrzena.
|
|
||||||
</div>
|
|
||||||
<div class="details">
|
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">Služba</div>
|
|
||||||
<div class="detail-value">%s</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">Termín</div>
|
|
||||||
<div class="detail-value">%s</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">Místo</div>
|
|
||||||
<div class="detail-value">%s</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
Potřebujete přeobjednat? Kontaktujte přímo %s.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
subject, canvas, white, canvas, border,
|
|
||||||
logoBg, logoText, ink,
|
|
||||||
accentSubtle, accent, ink, inkMuted,
|
|
||||||
canvasSubtle, border, inkSubtle, ink,
|
|
||||||
inkSubtle, border,
|
|
||||||
canvas, border, inkMuted,
|
|
||||||
customerName, businessName, serviceName, dateTime, location, businessName)
|
|
||||||
|
|
||||||
text := fmt.Sprintf(`Rezervace potvrzena
|
|
||||||
|
|
||||||
Dobrý den %s,
|
|
||||||
|
|
||||||
Vaše rezervace v %s je potvrzena.
|
|
||||||
|
|
||||||
Služba: %s
|
|
||||||
Termín: %s
|
|
||||||
Místo: %s
|
|
||||||
|
|
||||||
Potřebujete přeobjednat? Kontaktujte %s.
|
|
||||||
|
|
||||||
© 2024 Bookra`,
|
|
||||||
customerName, businessName, serviceName, dateTime, location, businessName)
|
|
||||||
|
|
||||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
|
||||||
}
|
|
||||||
|
|
||||||
func passwordResetEN(name, resetURL string) EmailTemplate {
|
|
||||||
subject := "Reset your Bookra password"
|
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
|
||||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
|
||||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
|
||||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
|
||||||
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
|
||||||
.button-wrap { margin: 40px 0; }
|
|
||||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
|
||||||
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
|
|
||||||
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
|
||||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
|
||||||
.footer-text { font-size: 14px; color: %s; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Hi %s,</div>
|
|
||||||
<div class="message">
|
|
||||||
We received a request to reset your password. Click below to choose a new one.
|
|
||||||
</div>
|
|
||||||
<div class="button-wrap">
|
|
||||||
<a href="%s" class="button">Reset Password</a>
|
|
||||||
</div>
|
|
||||||
<div class="expiry">
|
|
||||||
This link expires in <strong>1 hour</strong>.
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
Didn't request this? You can safely ignore it.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
subject, canvas, white, canvas, border,
|
|
||||||
logoBg, logoText, ink,
|
|
||||||
ink, inkMuted,
|
|
||||||
accent, white,
|
|
||||||
accentSubtle, accent, accent,
|
|
||||||
inkSubtle, border,
|
|
||||||
canvas, border, inkMuted,
|
|
||||||
name, resetURL)
|
|
||||||
|
|
||||||
text := fmt.Sprintf(`Reset Password — Bookra
|
|
||||||
|
|
||||||
Hi %s,
|
|
||||||
|
|
||||||
Reset your password (expires in 1 hour):
|
|
||||||
%s
|
|
||||||
|
|
||||||
Didn't request this? You can safely ignore it.
|
|
||||||
|
|
||||||
© 2024 Bookra`, name, resetURL)
|
|
||||||
|
|
||||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
|
||||||
}
|
|
||||||
|
|
||||||
func passwordResetCS(name, resetURL string) EmailTemplate {
|
|
||||||
subject := "Reset hesla pro Bookra"
|
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="cs">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
|
||||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
|
||||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
|
||||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
|
||||||
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
|
||||||
.button-wrap { margin: 40px 0; }
|
|
||||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
|
||||||
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
|
|
||||||
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
|
||||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
|
||||||
.footer-text { font-size: 14px; color: %s; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Dobrý den %s,</div>
|
|
||||||
<div class="message">
|
|
||||||
Obdrželi jsme žádost o reset hesla. Klikněte níže pro nastavení nového.
|
|
||||||
</div>
|
|
||||||
<div class="button-wrap">
|
|
||||||
<a href="%s" class="button">Resetovat heslo</a>
|
|
||||||
</div>
|
|
||||||
<div class="expiry">
|
|
||||||
Tento odkaz vyprší za <strong>1 hodinu</strong>.
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
Nepožádali jste o tento email? Můžete ho bezpečně ignorovat.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
subject, canvas, white, canvas, border,
|
|
||||||
logoBg, logoText, ink,
|
|
||||||
ink, inkMuted,
|
|
||||||
accent, white,
|
|
||||||
accentSubtle, accent, accent,
|
|
||||||
inkSubtle, border,
|
|
||||||
canvas, border, inkMuted,
|
|
||||||
name, resetURL)
|
|
||||||
|
|
||||||
text := fmt.Sprintf(`Reset hesla — Bookra
|
|
||||||
|
|
||||||
Dobrý den %s,
|
|
||||||
|
|
||||||
Reset hesla (vyprší za 1 hodinu):
|
|
||||||
%s
|
|
||||||
|
|
||||||
Nepožádali jste o tento email? Můžete ho ignorovat.
|
|
||||||
|
|
||||||
© 2024 Bookra`, name, resetURL)
|
|
||||||
|
|
||||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
|
||||||
}
|
|
||||||
@@ -1,680 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bookra/apps/auth-service/internal/config"
|
|
||||||
"bookra/apps/auth-service/internal/db"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AdminDashboard provides a visual management interface for the auth service
|
|
||||||
type AdminDashboard struct {
|
|
||||||
cfg *config.Config
|
|
||||||
db *db.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAdminDashboard(cfg *config.Config, database *db.DB) *AdminDashboard {
|
|
||||||
return &AdminDashboard{cfg: cfg, db: database}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterRoutes registers admin routes
|
|
||||||
func (a *AdminDashboard) RegisterRoutes(r *gin.Engine) {
|
|
||||||
admin := r.Group("/admin")
|
|
||||||
{
|
|
||||||
admin.GET("", a.RenderDashboard)
|
|
||||||
admin.GET("/api/config", a.GetConfig)
|
|
||||||
admin.GET("/api/prices", a.GetPrices)
|
|
||||||
admin.GET("/api/stats", a.GetStats)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConfig returns current configuration (sanitized)
|
|
||||||
func (a *AdminDashboard) GetConfig(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"appEnv": a.cfg.AppEnv,
|
|
||||||
"port": a.cfg.Port,
|
|
||||||
"frontendURL": a.cfg.FrontendURL,
|
|
||||||
"neonAuthURL": a.cfg.NeonAuthURL,
|
|
||||||
"smtpConfigured": gin.H{
|
|
||||||
"host": a.cfg.SMTPHost,
|
|
||||||
"port": a.cfg.SMTPPort,
|
|
||||||
"from": a.cfg.EmailFrom,
|
|
||||||
},
|
|
||||||
"googleOAuthConfigured": a.cfg.GoogleClientID != "",
|
|
||||||
"stripeConfigured": a.cfg.StripeCheckoutReady(),
|
|
||||||
"stripeSecretConfigured": a.cfg.StripeSecretConfigured(),
|
|
||||||
"stripeWebhookConfigured": a.cfg.StripeWebhookConfigured(),
|
|
||||||
"stripePricesConfigured": a.cfg.StripeHasAnyPriceConfigured(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPrices returns configured Stripe prices
|
|
||||||
func (a *AdminDashboard) GetPrices(c *gin.Context) {
|
|
||||||
prices := []gin.H{}
|
|
||||||
|
|
||||||
planNames := map[string]string{
|
|
||||||
"starter": "Starter Plan",
|
|
||||||
"pro": "Pro Plan",
|
|
||||||
"business": "Business Plan",
|
|
||||||
"monthly": "Monthly Plan",
|
|
||||||
"growth": "Growth Plan (Pro alias)",
|
|
||||||
"multi-location": "Multi-Location (Business alias)",
|
|
||||||
}
|
|
||||||
|
|
||||||
currencies := []string{"czk", "usd"}
|
|
||||||
|
|
||||||
for planCode, priceID := range a.cfg.StripePriceIDs {
|
|
||||||
if strings.TrimSpace(priceID) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse plan:currency format
|
|
||||||
parts := strings.Split(planCode, ":")
|
|
||||||
displayName := planNames[planCode]
|
|
||||||
currency := ""
|
|
||||||
|
|
||||||
if len(parts) == 2 {
|
|
||||||
planCode = parts[0]
|
|
||||||
currency = parts[1]
|
|
||||||
displayName = planNames[planCode] + " (" + strings.ToUpper(currency) + ")"
|
|
||||||
}
|
|
||||||
|
|
||||||
if displayName == "" {
|
|
||||||
displayName = planCode
|
|
||||||
}
|
|
||||||
|
|
||||||
prices = append(prices, gin.H{
|
|
||||||
"planCode": planCode,
|
|
||||||
"currency": currency,
|
|
||||||
"priceID": priceID,
|
|
||||||
"displayName": displayName,
|
|
||||||
"configured": true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"prices": prices,
|
|
||||||
"currencies": currencies,
|
|
||||||
"stripeConfigured": a.cfg.StripeCheckoutReady(),
|
|
||||||
"secretConfigured": a.cfg.StripeSecretConfigured(),
|
|
||||||
"webhookConfigured": a.cfg.StripeWebhookConfigured(),
|
|
||||||
"pricesConfigured": len(prices) > 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStats returns database statistics
|
|
||||||
func (a *AdminDashboard) GetStats(c *gin.Context) {
|
|
||||||
if a.db == nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
stats, err := a.db.GetStats(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load stats: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderDashboard renders the HTML admin dashboard
|
|
||||||
func (a *AdminDashboard) RenderDashboard(c *gin.Context) {
|
|
||||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
|
||||||
c.String(http.StatusOK, adminHTML)
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminHTML = `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Bookra Auth Service Admin</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--canvas: 40 25% 97%;
|
|
||||||
--canvas-subtle: 40 20% 94%;
|
|
||||||
--canvas-muted: 40 15% 89%;
|
|
||||||
--ink: 25 15% 12%;
|
|
||||||
--ink-muted: 25 10% 42%;
|
|
||||||
--ink-subtle: 25 8% 58%;
|
|
||||||
--accent: 17 55% 42%;
|
|
||||||
--accent-hover: 17 60% 37%;
|
|
||||||
--accent-subtle: 17 45% 94%;
|
|
||||||
--success: 145 45% 38%;
|
|
||||||
--success-subtle: 145 35% 94%;
|
|
||||||
--error: 0 60% 52%;
|
|
||||||
--error-subtle: 0 50% 96%;
|
|
||||||
--border: 30 12% 86%;
|
|
||||||
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04);
|
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
|
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.06), 0 4px 6px -4px rgb(0 0 0 / 0.04);
|
|
||||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: "Newsreader", Georgia, ui-serif, serif;
|
|
||||||
background: linear-gradient(180deg, hsl(var(--canvas)) 0%, hsl(var(--canvas-subtle)) 100%);
|
|
||||||
color: hsl(var(--ink));
|
|
||||||
line-height: 1.6;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.container { padding: 2rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.container { padding: 3rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
margin-bottom: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo svg {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
color: hsl(var(--accent));
|
|
||||||
}
|
|
||||||
|
|
||||||
header h1 {
|
|
||||||
font-family: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
|
|
||||||
font-size: clamp(1.75rem, 3vw + 0.5rem, 2.5rem);
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
color: hsl(var(--ink));
|
|
||||||
}
|
|
||||||
|
|
||||||
header p {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
color: hsl(var(--ink-muted));
|
|
||||||
max-width: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: linear-gradient(145deg, hsl(40 25% 98%) 0%, hsl(40 20% 96%) 100%);
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: 1rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
transition: box-shadow 0.3s ease, transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card:hover {
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card .icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: hsl(var(--accent-subtle));
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card .icon svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
color: hsl(var(--accent));
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card.success .icon { background: hsl(var(--success-subtle)); }
|
|
||||||
.stat-card.success .icon svg { color: hsl(var(--success)); }
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
|
||||||
font-size: 1.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: hsl(var(--ink));
|
|
||||||
line-height: 1;
|
|
||||||
margin-bottom: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: hsl(var(--ink-muted));
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: linear-gradient(145deg, hsl(40 25% 98%) 0%, hsl(40 20% 96%) 100%);
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: 1rem;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
color: hsl(var(--accent));
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header h2 {
|
|
||||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: hsl(var(--ink));
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.active {
|
|
||||||
background: hsl(var(--success-subtle));
|
|
||||||
color: hsl(var(--success));
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.inactive {
|
|
||||||
background: hsl(var(--error-subtle));
|
|
||||||
color: hsl(var(--error));
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td {
|
|
||||||
text-align: left;
|
|
||||||
padding: 0.875rem 0;
|
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
|
||||||
font-weight: 500;
|
|
||||||
color: hsl(var(--ink-muted));
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:last-child td { border-bottom: none; }
|
|
||||||
|
|
||||||
.env-value {
|
|
||||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
|
||||||
background: hsl(var(--canvas-muted));
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: hsl(var(--ink));
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.25rem 0.625rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
|
||||||
background: hsl(var(--accent-subtle));
|
|
||||||
color: hsl(var(--accent));
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.875rem 0;
|
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row:last-child { border-bottom: none; }
|
|
||||||
|
|
||||||
.info-label {
|
|
||||||
color: hsl(var(--ink-muted));
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 3rem;
|
|
||||||
color: hsl(var(--ink-subtle));
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
background: hsl(var(--error-subtle));
|
|
||||||
color: hsl(var(--error));
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
color: hsl(var(--ink-muted));
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state svg {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
color: hsl(var(--ink-subtle));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<header>
|
|
||||||
<div class="logo">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
|
||||||
<path d="M2 17l10 5 10-5"/>
|
|
||||||
<path d="M2 12l10 5 10-5"/>
|
|
||||||
</svg>
|
|
||||||
<h1>Auth Service Admin</h1>
|
|
||||||
</div>
|
|
||||||
<p>Monitor users, configure billing plans, and manage service health.</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="stats-grid" id="stats-grid">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
|
||||||
<circle cx="9" cy="7" r="4"/>
|
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value">-</div>
|
|
||||||
<div class="stat-label">Total Users</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<circle cx="12" cy="12" r="10"/>
|
|
||||||
<polyline points="12 6 12 12 16 14"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value">-</div>
|
|
||||||
<div class="stat-label">Active (7d)</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value">-</div>
|
|
||||||
<div class="stat-label">Magic Links Sent</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card success">
|
|
||||||
<div class="icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value">-</div>
|
|
||||||
<div class="stat-label">New This Week</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
|
||||||
</svg>
|
|
||||||
<h2>Service Configuration</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body" id="config-content">
|
|
||||||
<div class="loading">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/>
|
|
||||||
</svg>
|
|
||||||
Loading configuration...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
|
||||||
<line x1="2" y1="10" x2="22" y2="10"/>
|
|
||||||
</svg>
|
|
||||||
<h2>Billing Plans</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body" id="prices-content">
|
|
||||||
<div class="loading">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/>
|
|
||||||
</svg>
|
|
||||||
Loading plans...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
|
||||||
</svg>
|
|
||||||
<h2>API Endpoints</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Method</th>
|
|
||||||
<th>Endpoint</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><span class="badge">POST</span></td><td>/api/auth/magic-link</td></tr>
|
|
||||||
<tr><td><span class="badge">POST</span></td><td>/api/auth/verify</td></tr>
|
|
||||||
<tr><td><span class="badge">POST</span></td><td>/api/auth/register</td></tr>
|
|
||||||
<tr><td><span class="badge">POST</span></td><td>/api/auth/login</td></tr>
|
|
||||||
<tr><td><span class="badge">GET</span></td><td>/api/auth/me</td></tr>
|
|
||||||
<tr><td><span class="badge">GET</span></td><td>/api/billing/subscription</td></tr>
|
|
||||||
<tr><td><span class="badge">POST</span></td><td>/api/billing/checkout</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<circle cx="12" cy="12" r="3"/>
|
|
||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
||||||
</svg>
|
|
||||||
<h2>Service Overview</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Authentication</span>
|
|
||||||
<span>Magic links, JWT, OAuth</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Billing</span>
|
|
||||||
<span>Stripe subscriptions</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Database</span>
|
|
||||||
<span>Neon PostgreSQL</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Email</span>
|
|
||||||
<span>SMTP transactional</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Load stats
|
|
||||||
fetch('/admin/api/stats')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
const cards = document.querySelectorAll('.stat-card');
|
|
||||||
cards[0].querySelector('.stat-value').textContent = data.totalUsers.toLocaleString();
|
|
||||||
cards[1].querySelector('.stat-value').textContent = data.activeUsers7Days.toLocaleString();
|
|
||||||
cards[2].querySelector('.stat-value').textContent = data.magicLinksSent.toLocaleString();
|
|
||||||
cards[3].querySelector('.stat-value').textContent = data.usersThisWeek.toLocaleString();
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
document.getElementById('stats-grid').innerHTML =
|
|
||||||
'<div class="error" style="grid-column: 1/-1;">Failed to load statistics</div>';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load configuration
|
|
||||||
fetch('/admin/api/config')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
let html = '<div class="info-row">' +
|
|
||||||
'<span class="info-label">Environment</span>' +
|
|
||||||
'<span class="env-value">' + data.appEnv + '</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="info-row">' +
|
|
||||||
'<span class="info-label">Port</span>' +
|
|
||||||
'<span class="env-value">' + data.port + '</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="info-row">' +
|
|
||||||
'<span class="info-label">Neon Auth</span>' +
|
|
||||||
'<span class="status ' + (data.neonAuthURL ? 'active' : 'inactive') + '">' +
|
|
||||||
'<span class="status-dot"></span>' +
|
|
||||||
(data.neonAuthURL ? 'Configured' : 'Not Configured') +
|
|
||||||
'</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="info-row">' +
|
|
||||||
'<span class="info-label">SMTP</span>' +
|
|
||||||
'<span class="status ' + (data.smtpConfigured.host ? 'active' : 'inactive') + '">' +
|
|
||||||
'<span class="status-dot"></span>' +
|
|
||||||
(data.smtpConfigured.host ? data.smtpConfigured.host : 'Not Configured') +
|
|
||||||
'</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="info-row">' +
|
|
||||||
'<span class="info-label">Google OAuth</span>' +
|
|
||||||
'<span class="status ' + (data.googleOAuthConfigured ? 'active' : 'inactive') + '">' +
|
|
||||||
'<span class="status-dot"></span>' +
|
|
||||||
(data.googleOAuthConfigured ? 'Enabled' : 'Disabled') +
|
|
||||||
'</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="info-row">' +
|
|
||||||
'<span class="info-label">Stripe</span>' +
|
|
||||||
'<span class="status ' + (data.stripeConfigured ? 'active' : 'inactive') + '">' +
|
|
||||||
'<span class="status-dot"></span>' +
|
|
||||||
(data.stripeConfigured ? 'Configured' : 'Not Configured') +
|
|
||||||
'</span>' +
|
|
||||||
'</div>';
|
|
||||||
document.getElementById('config-content').innerHTML = html;
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
document.getElementById('config-content').innerHTML =
|
|
||||||
'<div class="error">Failed to load configuration</div>';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load prices
|
|
||||||
fetch('/admin/api/prices')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (!data.prices || data.prices.length === 0) {
|
|
||||||
document.getElementById('prices-content').innerHTML =
|
|
||||||
'<div class="empty-state">' +
|
|
||||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>' +
|
|
||||||
'<p>No Stripe prices configured</p>' +
|
|
||||||
'</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '<table><thead><tr><th>Plan</th><th>Currency</th><th>Status</th></tr></thead><tbody>';
|
|
||||||
data.prices.forEach(p => {
|
|
||||||
html += '<tr>' +
|
|
||||||
'<td>' + p.displayName + '</td>' +
|
|
||||||
'<td>' + (p.currency ? p.currency.toUpperCase() : 'Default') + '</td>' +
|
|
||||||
'<td><span class="badge">' + p.priceID.substring(0, 12) + '...</span></td>' +
|
|
||||||
'</tr>';
|
|
||||||
});
|
|
||||||
html += '</tbody></table>';
|
|
||||||
document.getElementById('prices-content').innerHTML = html;
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
document.getElementById('prices-content').innerHTML =
|
|
||||||
'<div class="error">Failed to load prices</div>';
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
@@ -1,513 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bookra/apps/auth-service/internal/auth"
|
|
||||||
"bookra/apps/auth-service/internal/billing"
|
|
||||||
"bookra/apps/auth-service/internal/config"
|
|
||||||
"bookra/apps/auth-service/internal/db"
|
|
||||||
"bookra/apps/auth-service/internal/email"
|
|
||||||
"bookra/apps/auth-service/internal/oauth"
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Handler struct {
|
|
||||||
authSvc *auth.Service
|
|
||||||
neon *auth.NeonVerifier
|
|
||||||
billingSvc *billing.Service
|
|
||||||
google *oauth.GoogleProvider
|
|
||||||
cfg *config.Config
|
|
||||||
db *db.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoginRequest struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
Locale string `json:"locale,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type VerifyRequest struct {
|
|
||||||
Token string `json:"token" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RefreshRequest struct {
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PasswordRegisterRequest struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
Password string `json:"password" binding:"required,min=8"`
|
|
||||||
Name string `json:"name" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PasswordLoginRequest struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
Password string `json:"password" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CheckoutRequest struct {
|
|
||||||
PlanCode string `json:"planCode,omitempty"`
|
|
||||||
Currency string `json:"currency,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(db *db.DB, emailSvc *email.Service, cfg *config.Config) (*Handler, error) {
|
|
||||||
neonVerifier, err := auth.NewNeonVerifier(cfg.NeonAuthURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &Handler{
|
|
||||||
authSvc: auth.NewService(db, emailSvc, cfg.JWTSecret, cfg.FrontendURL),
|
|
||||||
neon: neonVerifier,
|
|
||||||
billingSvc: billing.NewService(cfg, db),
|
|
||||||
google: oauth.NewGoogleProvider(cfg),
|
|
||||||
cfg: cfg,
|
|
||||||
db: db,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) RegisterRoutes(r *gin.Engine) {
|
|
||||||
// Auth API
|
|
||||||
api := r.Group("/api/auth")
|
|
||||||
{
|
|
||||||
api.POST("/magic-link", h.SendMagicLink)
|
|
||||||
api.POST("/verify", h.VerifyMagicLink)
|
|
||||||
api.POST("/register", h.RegisterWithPassword)
|
|
||||||
api.POST("/login", h.LoginWithPassword)
|
|
||||||
api.POST("/refresh", h.RefreshToken)
|
|
||||||
api.GET("/me", h.RequireAuth(), h.GetMe)
|
|
||||||
api.POST("/logout", h.RequireAuth(), h.Logout)
|
|
||||||
|
|
||||||
api.GET("/providers", h.ListProviders)
|
|
||||||
api.GET("/oauth/google", h.GoogleAuth)
|
|
||||||
api.GET("/oauth/google/callback", h.GoogleCallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Billing API
|
|
||||||
billingAPI := r.Group("/api/billing")
|
|
||||||
{
|
|
||||||
billingAPI.POST("/webhook", h.StripeWebhook)
|
|
||||||
billingAPI.GET("/subscription", h.RequireAuth(), h.GetSubscription)
|
|
||||||
billingAPI.POST("/checkout", h.RequireAuth(), h.CreateCheckoutSession)
|
|
||||||
billingAPI.POST("/refresh", h.RequireAuth(), h.RefreshSubscription)
|
|
||||||
billingAPI.GET("/plans", h.ListPlans)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin Dashboard (Visual Management)
|
|
||||||
admin := NewAdminDashboard(h.cfg, h.db)
|
|
||||||
admin.RegisterRoutes(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) SendMagicLink(c *gin.Context) {
|
|
||||||
var req LoginRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect locale from request: JSON body > Accept-Language header > default "en"
|
|
||||||
locale := req.Locale
|
|
||||||
if locale == "" {
|
|
||||||
locale = detectLocale(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.authSvc.GenerateMagicLink(c.Request.Context(), req.Email, locale); err != nil {
|
|
||||||
log.Printf("magic link failed for %s: %v", req.Email, err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send magic link"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Magic link sent to your email"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectLocale extracts locale from Accept-Language header
|
|
||||||
func detectLocale(c *gin.Context) string {
|
|
||||||
acceptLang := c.GetHeader("Accept-Language")
|
|
||||||
if strings.HasPrefix(acceptLang, "cs") || strings.Contains(acceptLang, "cs-") {
|
|
||||||
return "cs"
|
|
||||||
}
|
|
||||||
// Default to English
|
|
||||||
return "en"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) VerifyMagicLink(c *gin.Context) {
|
|
||||||
var req VerifyRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens, err := h.authSvc.VerifyMagicLink(c.Request.Context(), req.Token)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) RegisterWithPassword(c *gin.Context) {
|
|
||||||
var req PasswordRegisterRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens, err := h.authSvc.RegisterWithPassword(c.Request.Context(), req.Email, req.Password, req.Name)
|
|
||||||
if err != nil {
|
|
||||||
if strings.Contains(err.Error(), "already registered") {
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) LoginWithPassword(c *gin.Context) {
|
|
||||||
var req PasswordLoginRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens, err := h.authSvc.LoginWithPassword(c.Request.Context(), req.Email, req.Password)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) RefreshToken(c *gin.Context) {
|
|
||||||
var req RefreshRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshToken := strings.TrimSpace(req.RefreshToken)
|
|
||||||
if refreshToken == "" {
|
|
||||||
authHeader := c.GetHeader("Authorization")
|
|
||||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
|
||||||
refreshToken = strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if refreshToken == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing refresh token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens, err := h.authSvc.RefreshTokens(c.Request.Context(), refreshToken)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid refresh token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) GetMe(c *gin.Context) {
|
|
||||||
claims, exists := c.Get("claims")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userClaims := claims.(*auth.Claims)
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"id": userClaims.UserID,
|
|
||||||
"email": userClaims.Email,
|
|
||||||
"name": userClaims.Name,
|
|
||||||
"role": userClaims.Role,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) Logout(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) ListProviders(c *gin.Context) {
|
|
||||||
providers := []gin.H{}
|
|
||||||
|
|
||||||
if h.google.Enabled() {
|
|
||||||
providers = append(providers, gin.H{
|
|
||||||
"id": "google",
|
|
||||||
"name": "Google",
|
|
||||||
"url": "/api/auth/oauth/google",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
providers = append(providers, gin.H{
|
|
||||||
"id": "email",
|
|
||||||
"name": "Email Magic Link",
|
|
||||||
})
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"providers": providers})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) GoogleAuth(c *gin.Context) {
|
|
||||||
if !h.google.Enabled() {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Google OAuth not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
state := generateState()
|
|
||||||
url := h.google.GetAuthURL(state)
|
|
||||||
|
|
||||||
c.SetCookie("oauth_state", state, 600, "/", "", oauthCookieSecure(c, h.cfg), true)
|
|
||||||
c.Redirect(http.StatusTemporaryRedirect, url)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) GoogleCallback(c *gin.Context) {
|
|
||||||
if !h.google.Enabled() {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Google OAuth not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
state := c.Query("state")
|
|
||||||
expectedState, err := c.Cookie("oauth_state")
|
|
||||||
if err != nil || state == "" || state != expectedState {
|
|
||||||
c.SetCookie("oauth_state", "", -1, "/", "", oauthCookieSecure(c, h.cfg), true)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid OAuth state"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.SetCookie("oauth_state", "", -1, "/", "", oauthCookieSecure(c, h.cfg), true)
|
|
||||||
|
|
||||||
code := c.Query("code")
|
|
||||||
if code == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing code"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := h.google.ExchangeCode(c.Request.Context(), code)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "OAuth failed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
providerID, email, name := h.google.ParseUser(user)
|
|
||||||
tokens, err := h.authSvc.OAuthLoginOrCreate(c.Request.Context(), "google", providerID, email, name)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process login"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
redirectURL := h.cfg.FrontendURL + "/auth/callback?token=" + url.QueryEscape(tokens.AccessToken)
|
|
||||||
if tokens.RefreshToken != "" {
|
|
||||||
redirectURL += "&refresh_token=" + url.QueryEscape(tokens.RefreshToken)
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) GetSubscription(c *gin.Context) {
|
|
||||||
claims, ok := h.claimsFromContext(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot, err := h.billingSvc.GetSubscription(c.Request.Context(), claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load subscription"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, snapshot)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) CreateCheckoutSession(c *gin.Context) {
|
|
||||||
claims, ok := h.claimsFromContext(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req CheckoutRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
response, err := h.billingSvc.CreateCheckoutSession(c.Request.Context(), billing.UserIdentity{
|
|
||||||
ID: claims.UserID,
|
|
||||||
Email: claims.Email,
|
|
||||||
Name: claims.Name,
|
|
||||||
}, req.PlanCode, req.Currency)
|
|
||||||
if err != nil {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, billing.ErrPlanNotConfigured):
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Billing plan is not configured"})
|
|
||||||
case errors.Is(err, billing.ErrStripeNotConfigured):
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe is not configured"})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create checkout session"})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) RefreshSubscription(c *gin.Context) {
|
|
||||||
claims, ok := h.claimsFromContext(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot, err := h.billingSvc.Refresh(c.Request.Context(), claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, billing.ErrStripeNotConfigured) {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe is not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to refresh subscription"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, snapshot)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListPlans returns available billing plans and their configuration status
|
|
||||||
func (h *Handler) ListPlans(c *gin.Context) {
|
|
||||||
plans := []gin.H{
|
|
||||||
{"code": "starter", "name": "Starter", "description": "For individuals and small teams"},
|
|
||||||
{"code": "pro", "name": "Pro", "description": "For growing businesses"},
|
|
||||||
{"code": "business", "name": "Business", "description": "For multi-location operations"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check which plans are configured
|
|
||||||
configured := make(map[string]bool)
|
|
||||||
for planCode, priceID := range h.cfg.StripePriceIDs {
|
|
||||||
if priceID != "" {
|
|
||||||
configured[planCode] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, plan := range plans {
|
|
||||||
code := plan["code"].(string)
|
|
||||||
plan["czkConfigured"] = configured[code+":czk"] || configured[code]
|
|
||||||
plan["usdConfigured"] = configured[code+":usd"] || configured[code]
|
|
||||||
plans[i] = plan
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"plans": plans,
|
|
||||||
"stripeConfigured": h.cfg.StripeCheckoutReady(),
|
|
||||||
"secretConfigured": h.cfg.StripeSecretConfigured(),
|
|
||||||
"webhookConfigured": h.cfg.StripeWebhookConfigured(),
|
|
||||||
"pricesConfigured": h.cfg.StripeHasAnyPriceConfigured(),
|
|
||||||
"checkoutReady": h.cfg.StripeCheckoutReady(),
|
|
||||||
"currencies": []string{"czk", "usd"},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) StripeWebhook(c *gin.Context) {
|
|
||||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
|
|
||||||
payload, err := io.ReadAll(c.Request.Body)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "Webhook payload is too large"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.billingSvc.HandleWebhook(c.Request.Context(), c.GetHeader("Stripe-Signature"), payload); err != nil {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, billing.ErrStripeWebhookMissing), errors.Is(err, billing.ErrStripeSignatureMissing):
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Stripe webhook"})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"received": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) RequireAuth() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
authHeader := c.GetHeader("Authorization")
|
|
||||||
tokenString := ""
|
|
||||||
|
|
||||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
|
||||||
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
|
|
||||||
}
|
|
||||||
|
|
||||||
if tokenString == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing token"})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, err := h.verifyBearerToken(tokenString)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Set("claims", claims)
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) verifyBearerToken(tokenString string) (*auth.Claims, error) {
|
|
||||||
if h.neon != nil && h.neon.Enabled() {
|
|
||||||
return h.neon.Verify(tokenString)
|
|
||||||
}
|
|
||||||
if h.cfg.AppEnv == "development" {
|
|
||||||
return h.authSvc.VerifyToken(tokenString)
|
|
||||||
}
|
|
||||||
return nil, errors.New("neon auth is not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) claimsFromContext(c *gin.Context) (*auth.Claims, bool) {
|
|
||||||
claims, exists := c.Get("claims")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims not found"})
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
userClaims, ok := claims.(*auth.Claims)
|
|
||||||
if !ok {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return userClaims, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateState() string {
|
|
||||||
buffer := make([]byte, 24)
|
|
||||||
if _, err := rand.Read(buffer); err != nil {
|
|
||||||
return "state_" + time.Now().Format("20060102150405")
|
|
||||||
}
|
|
||||||
return "state_" + strings.TrimRight(base64.URLEncoding.EncodeToString(buffer), "=")
|
|
||||||
}
|
|
||||||
|
|
||||||
func oauthCookieSecure(c *gin.Context, cfg *config.Config) bool {
|
|
||||||
if c.Request.TLS != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(cfg.FrontendURL)), "https://")
|
|
||||||
}
|
|
||||||
|
|
||||||
func timeoutMiddleware(duration time.Duration) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), duration)
|
|
||||||
defer cancel()
|
|
||||||
c.Request = c.Request.WithContext(ctx)
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
package oauth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bookra/apps/auth-service/internal/config"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
"golang.org/x/oauth2/google"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GoogleUser struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Picture string `json:"picture"`
|
|
||||||
VerifiedEmail bool `json:"verified_email"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GoogleProvider struct {
|
|
||||||
config *oauth2.Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewGoogleProvider(cfg *config.Config) *GoogleProvider {
|
|
||||||
if cfg.GoogleClientID == "" || cfg.GoogleClientSecret == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
redirectURL := cfg.GoogleRedirectURL
|
|
||||||
if redirectURL == "" {
|
|
||||||
redirectURL = cfg.FrontendURL + "/auth/oauth/google/callback"
|
|
||||||
}
|
|
||||||
|
|
||||||
return &GoogleProvider{
|
|
||||||
config: &oauth2.Config{
|
|
||||||
ClientID: cfg.GoogleClientID,
|
|
||||||
ClientSecret: cfg.GoogleClientSecret,
|
|
||||||
RedirectURL: redirectURL,
|
|
||||||
Scopes: []string{
|
|
||||||
"openid",
|
|
||||||
"https://www.googleapis.com/auth/userinfo.email",
|
|
||||||
"https://www.googleapis.com/auth/userinfo.profile",
|
|
||||||
},
|
|
||||||
Endpoint: google.Endpoint,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *GoogleProvider) Enabled() bool {
|
|
||||||
return p != nil && p.config != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *GoogleProvider) GetAuthURL(state string) string {
|
|
||||||
return p.config.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *GoogleProvider) ExchangeCode(ctx context.Context, code string) (*GoogleUser, error) {
|
|
||||||
token, err := p.config.Exchange(ctx, code)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("exchange code: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client := p.config.Client(ctx, token)
|
|
||||||
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("fetch userinfo: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("userinfo returned %d: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var user GoogleUser
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
|
||||||
return nil, fmt.Errorf("decode userinfo: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *GoogleProvider) ParseUser(user *GoogleUser) (providerID, email, name string) {
|
|
||||||
return user.ID, user.Email, user.Name
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- +goose StatementBegin
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
name VARCHAR(255),
|
|
||||||
password_hash VARCHAR(255),
|
|
||||||
email_verified BOOLEAN DEFAULT FALSE,
|
|
||||||
provider VARCHAR(50) NOT NULL DEFAULT 'email',
|
|
||||||
provider_id VARCHAR(255),
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
last_login_at TIMESTAMP WITH TIME ZONE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_provider ON users(provider, provider_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS magic_links (
|
|
||||||
token VARCHAR(255) PRIMARY KEY,
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
email VARCHAR(255) NOT NULL,
|
|
||||||
used BOOLEAN DEFAULT FALSE,
|
|
||||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_magic_links_user_id ON magic_links(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_magic_links_expires ON magic_links(expires_at) WHERE used = FALSE;
|
|
||||||
|
|
||||||
-- +goose StatementEnd
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
-- +goose StatementBegin
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS magic_links;
|
|
||||||
DROP TABLE IF EXISTS users;
|
|
||||||
|
|
||||||
-- +goose StatementEnd
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- +goose StatementBegin
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS stripe_kv (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value JSONB NOT NULL,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_stripe_kv_updated_at ON stripe_kv(updated_at);
|
|
||||||
|
|
||||||
-- +goose StatementEnd
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
-- +goose StatementBegin
|
|
||||||
|
|
||||||
DROP INDEX IF EXISTS idx_stripe_kv_updated_at;
|
|
||||||
DROP TABLE IF EXISTS stripe_kv;
|
|
||||||
|
|
||||||
-- +goose StatementEnd
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://railway.app/railway.schema.json",
|
|
||||||
"build": {
|
|
||||||
"builder": "DOCKERFILE",
|
|
||||||
"dockerfilePath": "Dockerfile"
|
|
||||||
},
|
|
||||||
"deploy": {
|
|
||||||
"restartPolicyType": "ON_FAILURE",
|
|
||||||
"restartPolicyMaxRetries": 10,
|
|
||||||
"healthcheckPath": "/health",
|
|
||||||
"healthcheckTimeout": 30,
|
|
||||||
"numReplicas": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB |
@@ -1,407 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Bookra Email Templates Preview</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
font-family: 'Newsreader', Georgia, serif;
|
|
||||||
background: #fbf9f6;
|
|
||||||
margin: 0;
|
|
||||||
padding: 40px 20px;
|
|
||||||
color: #2a221e;
|
|
||||||
}
|
|
||||||
.container { max-width: 1400px; margin: 0 auto; }
|
|
||||||
h1 {
|
|
||||||
font-family: 'Space Grotesk', sans-serif;
|
|
||||||
text-align: center;
|
|
||||||
color: #2a221e;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
.subtitle {
|
|
||||||
text-align: center;
|
|
||||||
color: #5c514a;
|
|
||||||
margin-bottom: 48px;
|
|
||||||
font-size: 17px;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(600px, 1fr));
|
|
||||||
gap: 32px;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: white;
|
|
||||||
border-radius: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(42, 34, 30, 0.05);
|
|
||||||
border: 1px solid #e8e2da;
|
|
||||||
}
|
|
||||||
.card-header {
|
|
||||||
background: #fbf9f6;
|
|
||||||
padding: 24px 28px;
|
|
||||||
border-bottom: 1px solid #e8e2da;
|
|
||||||
}
|
|
||||||
.card-header h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-family: 'Space Grotesk', sans-serif;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #2a221e;
|
|
||||||
}
|
|
||||||
.card-header p {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
color: #5c514a;
|
|
||||||
font-size: 14px;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.card-body { padding: 0; }
|
|
||||||
iframe {
|
|
||||||
width: 100%;
|
|
||||||
height: 500px;
|
|
||||||
border: none;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
.toggle {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
.toggle button {
|
|
||||||
padding: 12px 28px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #e8e2da;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: 'Space Grotesk', sans-serif;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
background: white;
|
|
||||||
color: #5c514a;
|
|
||||||
}
|
|
||||||
.toggle button.active {
|
|
||||||
background: #a65c3e;
|
|
||||||
color: white;
|
|
||||||
border-color: #a65c3e;
|
|
||||||
}
|
|
||||||
.toggle button:not(.active):hover {
|
|
||||||
background: #f5f2ed;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>Bookra Email Templates</h1>
|
|
||||||
<p class="subtitle">Warm editorial aesthetic with terracotta accents</p>
|
|
||||||
|
|
||||||
<div class="toggle">
|
|
||||||
<button class="active" onclick="showLang('en')">English</button>
|
|
||||||
<button onclick="showLang('cs')">Čeština</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid" id="emailGrid">
|
|
||||||
<!-- Magic Link EN -->
|
|
||||||
<div class="card" data-lang="en">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Magic Link</h2>
|
|
||||||
<p>Passwordless authentication</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<iframe srcdoc='
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: white; }
|
|
||||||
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
|
|
||||||
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
|
|
||||||
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
|
|
||||||
.tagline { color: #8b7f76; font-size: 15px; margin-top: 6px; font-style: italic; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 20px; font-weight: 600; color: #2a221e; margin-bottom: 16px; }
|
|
||||||
.message { font-size: 17px; line-height: 1.6; color: #5c514a; margin-bottom: 32px; }
|
|
||||||
.button-wrap { margin: 40px 0; }
|
|
||||||
.button { display: inline-block; background: #a65c3e; color: white; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: "Space Grotesk", sans-serif; font-weight: 500; font-size: 15px; }
|
|
||||||
.link-box { background: #f5f2ed; border: 1px solid #e8e2da; border-radius: 8px; padding: 20px; margin: 32px 0; }
|
|
||||||
.link-label { font-size: 12px; font-weight: 600; color: #8b7f76; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: "Space Grotesk", sans-serif; }
|
|
||||||
.link-url { font-size: 14px; color: #5c514a; word-break: break-all; font-family: monospace; }
|
|
||||||
.expiry { background: #f5ebe7; border-left: 3px solid #a65c3e; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: #a65c3e; }
|
|
||||||
.help { font-size: 15px; color: #8b7f76; margin-top: 40px; padding-top: 32px; border-top: 1px solid #e8e2da; font-style: italic; }
|
|
||||||
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
|
|
||||||
.footer-text { font-size: 14px; color: #8b7f76; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
<div class="tagline">Calm booking software</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Hi Sarah,</div>
|
|
||||||
<div class="message">
|
|
||||||
You requested a sign-in link for your Bookra account. Click below to access your account securely — no password needed.
|
|
||||||
</div>
|
|
||||||
<div class="button-wrap">
|
|
||||||
<a href="#" class="button">Sign In to Bookra</a>
|
|
||||||
</div>
|
|
||||||
<div class="link-box">
|
|
||||||
<div class="link-label">Or copy this link</div>
|
|
||||||
<div class="link-url">https://bookra.tdvorak.dev/auth/callback?token=xyz123...</div>
|
|
||||||
</div>
|
|
||||||
<div class="expiry">
|
|
||||||
This link expires in <strong>15 minutes</strong> for security.
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
Didn't request this? You can safely ignore it.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>'>
|
|
||||||
</iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Welcome EN -->
|
|
||||||
<div class="card" data-lang="en">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Welcome Email</h2>
|
|
||||||
<p>New user onboarding</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<iframe srcdoc='
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: white; }
|
|
||||||
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
|
|
||||||
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
|
|
||||||
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 600; color: #2a221e; margin-bottom: 16px; }
|
|
||||||
.message { font-size: 18px; line-height: 1.6; color: #5c514a; margin-bottom: 32px; }
|
|
||||||
.features { background: #f5f2ed; border-radius: 12px; padding: 32px; margin: 32px 0; }
|
|
||||||
.feature { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
|
||||||
.feature:last-child { margin-bottom: 0; }
|
|
||||||
.feature-icon { width: 24px; height: 24px; background: #a65c3e; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: white; font-size: 14px; flex-shrink: 0; }
|
|
||||||
.feature-text { font-size: 16px; color: #5c514a; line-height: 1.5; }
|
|
||||||
.button-wrap { margin: 40px 0; text-align: center; }
|
|
||||||
.button { display: inline-block; background: #a65c3e; color: white; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: "Space Grotesk", sans-serif; font-weight: 500; font-size: 15px; }
|
|
||||||
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
|
|
||||||
.footer-text { font-size: 14px; color: #8b7f76; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Welcome, Sarah</div>
|
|
||||||
<div class="message">
|
|
||||||
Thanks for joining Bookra. We're here to help you manage bookings with calm and clarity.
|
|
||||||
</div>
|
|
||||||
<div class="features">
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature-icon">✓</div>
|
|
||||||
<div class="feature-text"><strong>Smart scheduling</strong> — Automatic conflict detection</div>
|
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature-icon">✓</div>
|
|
||||||
<div class="feature-text"><strong>Customer insights</strong> — History and preferences</div>
|
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature-icon">✓</div>
|
|
||||||
<div class="feature-text"><strong>Reminders</strong> — Reduce no-shows</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="button-wrap">
|
|
||||||
<a href="#" class="button">Open Dashboard</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>'>
|
|
||||||
</iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Booking Confirmation EN -->
|
|
||||||
<div class="card" data-lang="en">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Booking Confirmation</h2>
|
|
||||||
<p>Customer confirmation email</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<iframe srcdoc='
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: white; }
|
|
||||||
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
|
|
||||||
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
|
|
||||||
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.badge { display: inline-block; background: #f5ebe7; color: #a65c3e; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 24px; font-family: "Space Grotesk", sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
||||||
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; color: #2a221e; margin-bottom: 8px; }
|
|
||||||
.message { font-size: 17px; color: #5c514a; margin-bottom: 32px; }
|
|
||||||
.details { background: #f5f2ed; border-radius: 12px; padding: 28px; margin: 32px 0; }
|
|
||||||
.detail-row { display: flex; padding: 14px 0; border-bottom: 1px solid #e8e2da; }
|
|
||||||
.detail-row:last-child { border-bottom: none; }
|
|
||||||
.detail-label { width: 120px; font-size: 12px; font-weight: 600; color: #8b7f76; text-transform: uppercase; letter-spacing: 0.05em; font-family: "Space Grotesk", sans-serif; }
|
|
||||||
.detail-value { flex: 1; font-size: 16px; color: #2a221e; font-weight: 500; }
|
|
||||||
.help { font-size: 15px; color: #8b7f76; margin-top: 32px; padding-top: 32px; border-top: 1px solid #e8e2da; font-style: italic; }
|
|
||||||
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
|
|
||||||
.footer-text { font-size: 14px; color: #8b7f76; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="badge">Confirmed</div>
|
|
||||||
<div class="greeting">Hello Sarah,</div>
|
|
||||||
<div class="message">
|
|
||||||
Your booking with <strong>Studio Ella</strong> is confirmed.
|
|
||||||
</div>
|
|
||||||
<div class="details">
|
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">Service</div>
|
|
||||||
<div class="detail-value">Haircut & Styling</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">When</div>
|
|
||||||
<div class="detail-value">Monday, April 22 at 2:00 PM</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">Where</div>
|
|
||||||
<div class="detail-value">123 Main Street, Prague 1</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
Need to reschedule? Contact Studio Ella directly.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>'>
|
|
||||||
</iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Magic Link CS -->
|
|
||||||
<div class="card" data-lang="cs" style="display:none">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Magický Odkaz (CZ)</h2>
|
|
||||||
<p>Přihlášení bez hesla</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<iframe srcdoc='
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="cs">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: white; }
|
|
||||||
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
|
|
||||||
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
|
|
||||||
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
|
|
||||||
.tagline { color: #8b7f76; font-size: 15px; margin-top: 6px; font-style: italic; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 20px; font-weight: 600; color: #2a221e; margin-bottom: 16px; }
|
|
||||||
.message { font-size: 17px; line-height: 1.6; color: #5c514a; margin-bottom: 32px; }
|
|
||||||
.button-wrap { margin: 40px 0; }
|
|
||||||
.button { display: inline-block; background: #a65c3e; color: white; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: "Space Grotesk", sans-serif; font-weight: 500; font-size: 15px; }
|
|
||||||
.link-box { background: #f5f2ed; border: 1px solid #e8e2da; border-radius: 8px; padding: 20px; margin: 32px 0; }
|
|
||||||
.link-label { font-size: 12px; font-weight: 600; color: #8b7f76; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: "Space Grotesk", sans-serif; }
|
|
||||||
.link-url { font-size: 14px; color: #5c514a; word-break: break-all; font-family: monospace; }
|
|
||||||
.expiry { background: #f5ebe7; border-left: 3px solid #a65c3e; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: #a65c3e; }
|
|
||||||
.help { font-size: 15px; color: #8b7f76; margin-top: 40px; padding-top: 32px; border-top: 1px solid #e8e2da; font-style: italic; }
|
|
||||||
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
|
|
||||||
.footer-text { font-size: 14px; color: #8b7f76; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
<div class="tagline">Klidný rezervační software</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Dobrý den Martino,</div>
|
|
||||||
<div class="message">
|
|
||||||
Požádali jste o přihlašovací odkaz k účtu Bookra. Klikněte níže pro bezpečný přístup — heslo není potřeba.
|
|
||||||
</div>
|
|
||||||
<div class="button-wrap">
|
|
||||||
<a href="#" class="button">Přihlásit se do Bookra</a>
|
|
||||||
</div>
|
|
||||||
<div class="link-box">
|
|
||||||
<div class="link-label">Nebo zkopírujte tento odkaz</div>
|
|
||||||
<div class="link-url">https://bookra.tdvorak.dev/auth/callback?token=xyz123...</div>
|
|
||||||
</div>
|
|
||||||
<div class="expiry">
|
|
||||||
Tento odkaz vyprší za <strong>15 minut</strong> z bezpečnostních důvodů.
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
Nepožádali jste o tento email? Můžete ho bezpečně ignorovat.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>'>
|
|
||||||
</iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function showLang(lang) {
|
|
||||||
document.querySelectorAll(".toggle button").forEach(btn => btn.classList.remove("active"));
|
|
||||||
event.target.classList.add("active");
|
|
||||||
document.querySelectorAll("[data-lang]").forEach(card => {
|
|
||||||
card.style.display = card.dataset.lang === lang ? "block" : "none";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -11,14 +11,37 @@ import (
|
|||||||
"bookra/apps/backend/internal/api"
|
"bookra/apps/backend/internal/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
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookra/apps/backend/internal/db"
|
||||||
|
"bookra/apps/backend/internal/domain"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
|
ErrForbidden = errors.New("forbidden: admin access required")
|
||||||
|
ErrInvalidAdminCreds = errors.New("invalid admin credentials")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo db.Repository
|
||||||
|
adminEmail string
|
||||||
|
adminKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo db.Repository, adminEmail, adminKey string) *Service {
|
||||||
|
return &Service{
|
||||||
|
repo: repo,
|
||||||
|
adminEmail: adminEmail,
|
||||||
|
adminKey: adminKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConfigured returns true if admin credentials are set
|
||||||
|
func (s *Service) IsConfigured() bool {
|
||||||
|
return s.adminEmail != "" && s.adminKey != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAdminLogin checks if the provided credentials match the admin credentials
|
||||||
|
// Uses constant-time comparison to prevent timing attacks
|
||||||
|
func (s *Service) ValidateAdminLogin(email, key string) bool {
|
||||||
|
if !s.IsConfigured() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
emailMatch := subtle.ConstantTimeCompare([]byte(email), []byte(s.adminEmail)) == 1
|
||||||
|
keyMatch := subtle.ConstantTimeCompare([]byte(key), []byte(s.adminKey)) == 1
|
||||||
|
|
||||||
|
return emailMatch && keyMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAdmin is middleware that checks for admin authentication
|
||||||
|
// It supports two modes:
|
||||||
|
// 1. Admin credentials via X-Admin-Email and X-Admin-Key headers (for API access)
|
||||||
|
// 2. Session-based auth where the user has role "admin" or "superadmin"
|
||||||
|
func RequireAdmin(adminSvc *Service, authSvc interface{ IsAdmin(ctx context.Context, userID string) (bool, error) }) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Check for admin header credentials (direct admin login)
|
||||||
|
adminEmail := c.GetHeader("X-Admin-Email")
|
||||||
|
adminKey := c.GetHeader("X-Admin-Key")
|
||||||
|
|
||||||
|
if adminEmail != "" && adminKey != "" {
|
||||||
|
if adminSvc.ValidateAdminLogin(adminEmail, adminKey) {
|
||||||
|
c.Set("isAdmin", true)
|
||||||
|
c.Set("adminMode", "credentials")
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Bearer token with admin role
|
||||||
|
auth := c.GetHeader("Authorization")
|
||||||
|
if auth != "" && strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
// The auth middleware should have already validated the token
|
||||||
|
// and set the user info in context
|
||||||
|
userID, exists := c.Get("userID")
|
||||||
|
if !exists {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin, err := authSvc.IsAdmin(c.Request.Context(), userID.(string))
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isAdmin {
|
||||||
|
c.Set("isAdmin", true)
|
||||||
|
c.Set("adminMode", "session")
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDashboardStats returns platform-wide statistics for admin dashboard
|
||||||
|
func (s *Service) GetDashboardStats(ctx context.Context) (domain.AdminDashboardStats, error) {
|
||||||
|
stats, err := s.repo.GetPlatformStats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AdminDashboardStats{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.AdminDashboardStats{
|
||||||
|
TotalTenants: stats.TotalTenants,
|
||||||
|
TotalUsers: stats.TotalUsers,
|
||||||
|
ActiveSubscriptions: stats.ActiveSubscriptions,
|
||||||
|
TrialSubscriptions: stats.TrialSubscriptions,
|
||||||
|
BookingsThisMonth: stats.BookingsThisMonth,
|
||||||
|
RevenueThisMonthCents: stats.RevenueThisMonth,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTenants returns paginated list of all tenants
|
||||||
|
func (s *Service) ListTenants(ctx context.Context, page, pageSize int) (domain.AdminTenantList, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 || pageSize > 100 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
tenants, total, err := s.repo.ListAllTenants(ctx, pageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AdminTenantList{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := domain.AdminTenantList{
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Tenants: make([]domain.AdminTenant, len(tenants)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, t := range tenants {
|
||||||
|
result.Tenants[i] = domain.AdminTenant{
|
||||||
|
ID: t.ID,
|
||||||
|
Slug: t.Slug,
|
||||||
|
Name: t.Name,
|
||||||
|
PlanCode: t.PlanCode,
|
||||||
|
SubscriptionStatus: t.SubscriptionStatus,
|
||||||
|
BillingProvider: t.BillingProvider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns paginated list of all users
|
||||||
|
func (s *Service) ListUsers(ctx context.Context, page, pageSize int) (domain.AdminUserList, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 || pageSize > 100 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
users, total, err := s.repo.ListAllUsers(ctx, pageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AdminUserList{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := domain.AdminUserList{
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Users: make([]domain.AdminUser, len(users)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, u := range users {
|
||||||
|
result.Users[i] = domain.AdminUser{
|
||||||
|
ID: u.ID.String(),
|
||||||
|
Email: u.Email,
|
||||||
|
Name: stringPtrToStr(u.Name),
|
||||||
|
EmailVerified: u.EmailVerified,
|
||||||
|
Provider: u.Provider,
|
||||||
|
Role: u.Role,
|
||||||
|
CreatedAt: u.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserRole changes a user's role
|
||||||
|
func (s *Service) UpdateUserRole(ctx context.Context, adminUserID, targetUserID, newRole string, ip, userAgent string) error {
|
||||||
|
// Validate role
|
||||||
|
validRoles := map[string]bool{
|
||||||
|
"user": true,
|
||||||
|
"admin": true,
|
||||||
|
"superadmin": true,
|
||||||
|
}
|
||||||
|
if !validRoles[newRole] {
|
||||||
|
return errors.New("invalid role")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.UpdateUserRole(ctx, targetUserID, newRole); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the action
|
||||||
|
return s.repo.CreateAdminAuditLog(ctx, db.AdminAuditLogParams{
|
||||||
|
AdminUserID: adminUserID,
|
||||||
|
Action: "update_user_role",
|
||||||
|
ResourceType: "user",
|
||||||
|
ResourceID: targetUserID,
|
||||||
|
Details: map[string]any{
|
||||||
|
"newRole": newRole,
|
||||||
|
},
|
||||||
|
IPAddress: ip,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncTenantSubscription manually syncs a tenant's subscription from Stripe
|
||||||
|
func (s *Service) SyncTenantSubscription(ctx context.Context, tenantID string) error {
|
||||||
|
// This will be called from the billing service
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringPtrToStr(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Ensure time package is imported
|
||||||
|
_ = time.Now()
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
package api
|
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,16 +21,21 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
router *gin.Engine
|
router *gin.Engine
|
||||||
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,15 +49,21 @@ 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{
|
||||||
router: gin.New(),
|
router: gin.New(),
|
||||||
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
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookra/apps/backend/internal/db"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
accessTokenTTL = 24 * time.Hour
|
||||||
|
refreshTokenTTL = 30 * 24 * time.Hour
|
||||||
|
magicLinkTTL = 15 * time.Minute
|
||||||
|
passwordResetTTL = 30 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||||
|
ErrInvalidToken = errors.New("invalid or expired token")
|
||||||
|
ErrUserNotFound = errors.New("user not found")
|
||||||
|
ErrEmailAlreadyExists = errors.New("email already exists")
|
||||||
|
ErrPasswordTooShort = errors.New("password must be at least 8 characters")
|
||||||
|
ErrMagicLinkExpired = errors.New("magic link expired")
|
||||||
|
ErrMagicLinkUsed = errors.New("magic link already used")
|
||||||
|
ErrInvalidResetToken = errors.New("invalid or expired reset token")
|
||||||
|
)
|
||||||
|
|
||||||
|
type TokenPair struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
RefreshToken string `json:"refreshToken,omitempty"`
|
||||||
|
TokenType string `json:"tokenType"`
|
||||||
|
ExpiresIn int `json:"expiresIn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID string `json:"sub"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo db.Repository
|
||||||
|
jwtSecret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo db.Repository, jwtSecret string) *Service {
|
||||||
|
return &Service{
|
||||||
|
repo: repo,
|
||||||
|
jwtSecret: []byte(jwtSecret),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterWithPassword creates a new user with email and password
|
||||||
|
func (s *Service) RegisterWithPassword(ctx context.Context, email, password, name string) (*db.UserRecord, *TokenPair, error) {
|
||||||
|
if len(password) < 8 {
|
||||||
|
return nil, nil, ErrPasswordTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
existing, err := s.repo.GetUserByEmail(ctx, email)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return nil, nil, ErrEmailAlreadyExists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
user, err := s.repo.CreateUser(ctx, email, string(hash), name, "email", "user")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
tokens, err := s.generateTokenPair(user.ID.String(), email, name, "user")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginWithPassword authenticates a user with email and password
|
||||||
|
func (s *Service) LoginWithPassword(ctx context.Context, email, password string) (*db.UserRecord, *TokenPair, error) {
|
||||||
|
user, err := s.repo.GetUserByEmail(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.PasswordHash == nil {
|
||||||
|
return nil, nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil {
|
||||||
|
return nil, nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
if err := s.repo.UpdateLastLogin(ctx, user.ID.String()); err != nil {
|
||||||
|
// Log but don't fail
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMagicLink generates a magic link for passwordless auth
|
||||||
|
func (s *Service) CreateMagicLink(ctx context.Context, email string) (string, error) {
|
||||||
|
// Get or create user
|
||||||
|
user, err := s.repo.GetUserByEmail(ctx, email)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if user == nil {
|
||||||
|
user, err = s.repo.CreateUser(ctx, email, "", "", "email", "user")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
token := generateRandomToken(32)
|
||||||
|
expiresAt := time.Now().Add(magicLinkTTL)
|
||||||
|
|
||||||
|
if err := s.repo.CreateMagicLink(ctx, token, user.ID.String(), email, expiresAt); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyMagicLink validates a magic link and returns tokens
|
||||||
|
func (s *Service) VerifyMagicLink(ctx context.Context, token string) (*db.UserRecord, *TokenPair, error) {
|
||||||
|
ml, err := s.repo.GetMagicLink(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ml.Used {
|
||||||
|
return nil, nil, ErrMagicLinkUsed
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(ml.ExpiresAt) {
|
||||||
|
return nil, nil, ErrMagicLinkExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as used
|
||||||
|
if err := s.repo.MarkMagicLinkUsed(ctx, token); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
user, err := s.repo.GetUserByID(ctx, ml.UserID.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark email as verified
|
||||||
|
if err := s.repo.MarkEmailVerified(ctx, user.ID.String()); err != nil {
|
||||||
|
// Log but don't fail
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
if err := s.repo.UpdateLastLogin(ctx, user.ID.String()); err != nil {
|
||||||
|
// Log but don't fail
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken refreshes an access token using a refresh token
|
||||||
|
func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
||||||
|
claims, err := s.ValidateToken(refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.Type != "refresh" {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.repo.GetUserByID(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken validates a JWT token and returns claims
|
||||||
|
func (s *Service) ValidateToken(tokenString string) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
return s.jwtSecret, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser retrieves a user by ID
|
||||||
|
func (s *Service) GetUser(ctx context.Context, userID string) (*db.UserRecord, error) {
|
||||||
|
return s.repo.GetUserByID(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAdmin checks if the user has admin role
|
||||||
|
func (s *Service) IsAdmin(ctx context.Context, userID string) (bool, error) {
|
||||||
|
user, err := s.repo.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return user.Role == "admin" || user.Role == "superadmin", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTokenPair creates access and refresh tokens
|
||||||
|
func (s *Service) generateTokenPair(userID, email, name, role string) (*TokenPair, error) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Access token
|
||||||
|
accessClaims := Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Email: email,
|
||||||
|
Name: name,
|
||||||
|
Role: role,
|
||||||
|
Type: "access",
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenTTL)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||||
|
accessTokenString, err := accessToken.SignedString(s.jwtSecret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh token
|
||||||
|
refreshClaims := Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Email: email,
|
||||||
|
Role: role,
|
||||||
|
Type: "refresh",
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(refreshTokenTTL)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
|
||||||
|
refreshTokenString, err := refreshToken.SignedString(s.jwtSecret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TokenPair{
|
||||||
|
AccessToken: accessTokenString,
|
||||||
|
RefreshToken: refreshTokenString,
|
||||||
|
TokenType: "Bearer",
|
||||||
|
ExpiresIn: int(accessTokenTTL.Seconds()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRandomToken(length int) string {
|
||||||
|
b := make([]byte, length)
|
||||||
|
rand.Read(b)
|
||||||
|
return base64.URLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefString(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"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,43 +732,84 @@ 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,
|
||||||
PlanCode: record.PlanCode,
|
PlanCode: record.PlanCode,
|
||||||
Currency: record.Currency,
|
Currency: record.Currency,
|
||||||
PriceID: record.PriceID,
|
PriceID: record.PriceID,
|
||||||
CancelAtPeriodEnd: record.CancelAtPeriodEnd,
|
CancelAtPeriodEnd: record.CancelAtPeriodEnd,
|
||||||
CurrentPeriodStart: record.CurrentPeriodStart,
|
CurrentPeriodStart: record.CurrentPeriodStart,
|
||||||
CurrentPeriodEnd: record.CurrentPeriodEnd,
|
CurrentPeriodEnd: record.CurrentPeriodEnd,
|
||||||
PaymentMethodBrand: record.PaymentMethodBrand,
|
PaymentMethodBrand: record.PaymentMethodBrand,
|
||||||
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *PGRepository) ListAllTenants(ctx context.Context, limit, offset int) ([]TenantRecord, int, error) {
|
||||||
|
var total int
|
||||||
|
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants`).Scan(&total)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status,
|
||||||
|
COALESCE(billing_provider, 'stripe'), billing_customer_id, billing_subscription_id
|
||||||
|
FROM tenants
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
`, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tenants []TenantRecord
|
||||||
|
for rows.Next() {
|
||||||
|
var t TenantRecord
|
||||||
|
if err := rows.Scan(
|
||||||
|
&t.ID, &t.Slug, &t.Name, &t.Preset, &t.Locale, &t.Timezone,
|
||||||
|
&t.PlanCode, &t.SubscriptionStatus, &t.BillingProvider,
|
||||||
|
&t.BillingCustomerID, &t.BillingSubscription,
|
||||||
|
); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
tenants = append(tenants, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tenants, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) ListAllUsers(ctx context.Context, limit, offset int) ([]UserRecord, int, error) {
|
||||||
|
var total int
|
||||||
|
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&total)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT id, email, name, email_verified, provider, role, created_at, last_login_at
|
||||||
|
FROM users
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
`, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var users []UserRecord
|
||||||
|
for rows.Next() {
|
||||||
|
var u UserRecord
|
||||||
|
if err := rows.Scan(
|
||||||
|
&u.ID, &u.Email, &u.Name, &u.EmailVerified, &u.Provider, &u.Role,
|
||||||
|
&u.CreatedAt, &u.LastLoginAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
users = append(users, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) GetPlatformStats(ctx context.Context) (PlatformStats, error) {
|
||||||
|
var stats PlatformStats
|
||||||
|
|
||||||
|
// Total tenants
|
||||||
|
r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants`).Scan(&stats.TotalTenants)
|
||||||
|
|
||||||
|
// Total users
|
||||||
|
r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers)
|
||||||
|
|
||||||
|
// Active subscriptions
|
||||||
|
r.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM billing_snapshots
|
||||||
|
WHERE status IN ('active', 'trialing')
|
||||||
|
`).Scan(&stats.ActiveSubscriptions)
|
||||||
|
|
||||||
|
// Trial subscriptions
|
||||||
|
r.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM billing_snapshots
|
||||||
|
WHERE status = 'trialing'
|
||||||
|
`).Scan(&stats.TrialSubscriptions)
|
||||||
|
|
||||||
|
// Bookings this month
|
||||||
|
r.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM bookings
|
||||||
|
WHERE created_at >= date_trunc('month', CURRENT_DATE)
|
||||||
|
`).Scan(&stats.BookingsThisMonth)
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) CreateAdminAuditLog(ctx context.Context, params AdminAuditLogParams) error {
|
||||||
|
var detailsJSON []byte
|
||||||
|
var err error
|
||||||
|
if params.Details != nil {
|
||||||
|
detailsJSON, err = json.Marshal(params.Details)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO admin_audit_log (admin_user_id, action, resource_type, resource_id, details, ip_address, user_agent)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
`, nullableUUID(params.AdminUserID), params.Action, params.ResourceType, params.ResourceID, detailsJSON, params.IPAddress, params.UserAgent)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) UpdateUserRole(ctx context.Context, userID, role string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2
|
||||||
|
`, role, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableUUID(s string) interface{} {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *PGRepository) GetUserByEmail(ctx context.Context, email string) (*UserRecord, error) {
|
||||||
|
var user UserRecord
|
||||||
|
var name, passwordHash *string
|
||||||
|
var lastLoginAt *time.Time
|
||||||
|
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
|
||||||
|
FROM users
|
||||||
|
WHERE email = $1
|
||||||
|
`, email).Scan(
|
||||||
|
&user.ID, &user.Email, &name, &passwordHash,
|
||||||
|
&user.EmailVerified, &user.Provider, &user.Role,
|
||||||
|
&user.CreatedAt, &lastLoginAt,
|
||||||
|
)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Name = name
|
||||||
|
user.PasswordHash = passwordHash
|
||||||
|
user.LastLoginAt = lastLoginAt
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) GetUserByID(ctx context.Context, userID string) (*UserRecord, error) {
|
||||||
|
var user UserRecord
|
||||||
|
var name, passwordHash *string
|
||||||
|
var lastLoginAt *time.Time
|
||||||
|
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1
|
||||||
|
`, userID).Scan(
|
||||||
|
&user.ID, &user.Email, &name, &passwordHash,
|
||||||
|
&user.EmailVerified, &user.Provider, &user.Role,
|
||||||
|
&user.CreatedAt, &lastLoginAt,
|
||||||
|
)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Name = name
|
||||||
|
user.PasswordHash = passwordHash
|
||||||
|
user.LastLoginAt = lastLoginAt
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) CreateUser(ctx context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error) {
|
||||||
|
var user UserRecord
|
||||||
|
var lastLoginAt *time.Time
|
||||||
|
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO users (email, password_hash, name, provider, role, email_verified)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, false)
|
||||||
|
RETURNING id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
|
||||||
|
`, email, nullableString(passwordHash), nullableString(name), provider, role).Scan(
|
||||||
|
&user.ID, &user.Email, &user.Name, &user.PasswordHash,
|
||||||
|
&user.EmailVerified, &user.Provider, &user.Role,
|
||||||
|
&user.CreatedAt, &lastLoginAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.LastLoginAt = lastLoginAt
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) UpdateLastLogin(ctx context.Context, userID string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE users SET last_login_at = NOW() WHERE id = $1
|
||||||
|
`, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) MarkEmailVerified(ctx context.Context, userID string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE users SET email_verified = true WHERE id = $1
|
||||||
|
`, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) CreateMagicLink(ctx context.Context, token, userID, email string, expiresAt time.Time) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO magic_links (token, user_id, email, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
`, token, userID, email, expiresAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) GetMagicLink(ctx context.Context, token string) (*MagicLinkRecord, error) {
|
||||||
|
var ml MagicLinkRecord
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT token, user_id, email, used, expires_at, created_at
|
||||||
|
FROM magic_links
|
||||||
|
WHERE token = $1
|
||||||
|
`, token).Scan(
|
||||||
|
&ml.Token, &ml.UserID, &ml.Email, &ml.Used, &ml.ExpiresAt, &ml.CreatedAt,
|
||||||
|
)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ml, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) MarkMagicLinkUsed(ctx context.Context, token string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE magic_links SET used = true WHERE token = $1
|
||||||
|
`, token)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableString(s string) interface{} {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -38,6 +38,23 @@ type Repository interface {
|
|||||||
UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error
|
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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -182,17 +198,22 @@ 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
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -10,33 +10,139 @@ import (
|
|||||||
type EmailType string
|
type EmailType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
EmailTypeConfirmation EmailType = "confirmation"
|
EmailTypeConfirmation EmailType = "confirmation"
|
||||||
EmailTypeReminder EmailType = "reminder"
|
EmailTypeReminder EmailType = "reminder"
|
||||||
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 {
|
||||||
|
Type EmailType
|
||||||
|
TenantName string
|
||||||
|
TenantSlug string
|
||||||
|
BusinessEmail string
|
||||||
|
BusinessPhone string
|
||||||
|
BusinessAddress string
|
||||||
|
BrandColor string
|
||||||
|
CustomerName string
|
||||||
|
CustomerEmail string
|
||||||
|
Service string
|
||||||
|
Location string
|
||||||
|
Reference string
|
||||||
|
StartsAt time.Time
|
||||||
|
EndsAt time.Time
|
||||||
|
Timezone string
|
||||||
|
Locale string
|
||||||
|
Notes string
|
||||||
|
ManagementURL string
|
||||||
|
AddToCalendarURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UsageNotificationData struct {
|
||||||
Type EmailType
|
Type EmailType
|
||||||
TenantName string
|
TenantName string
|
||||||
TenantSlug string
|
TenantSlug string
|
||||||
BusinessEmail string
|
BusinessEmail string
|
||||||
BusinessPhone string
|
|
||||||
BusinessAddress string
|
|
||||||
BrandColor string
|
BrandColor string
|
||||||
CustomerName string
|
AdminEmail string
|
||||||
CustomerEmail string
|
|
||||||
Service string
|
|
||||||
Location string
|
|
||||||
Reference string
|
|
||||||
StartsAt time.Time
|
|
||||||
EndsAt time.Time
|
|
||||||
Timezone string
|
|
||||||
Locale string
|
Locale string
|
||||||
Notes string
|
PlanCode string
|
||||||
ManagementURL string
|
LocationCount int
|
||||||
AddToCalendarURL string
|
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 {
|
||||||
@@ -347,7 +453,7 @@ func RenderReminderEmail(from string, job db.ReminderJobRecord) EmailMessage {
|
|||||||
StartsAt: job.StartsAt,
|
StartsAt: job.StartsAt,
|
||||||
Timezone: job.Timezone,
|
Timezone: job.Timezone,
|
||||||
Locale: job.Locale,
|
Locale: job.Locale,
|
||||||
Service: "Service", // Legacy
|
Service: "Service", // Legacy
|
||||||
Location: "Location", // Legacy
|
Location: "Location", // Legacy
|
||||||
}
|
}
|
||||||
return RenderEmailMessage(data)
|
return RenderEmailMessage(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
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as Sentry from "@sentry/react";
|
||||||
|
|
||||||
|
export function initSentry() {
|
||||||
|
const dsn = import.meta.env.VITE_SENTRY_DSN;
|
||||||
|
|
||||||
|
if (!dsn) {
|
||||||
|
console.log("Sentry DSN not configured - skipping initialization");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn,
|
||||||
|
integrations: [
|
||||||
|
Sentry.browserTracingIntegration(),
|
||||||
|
Sentry.replayIntegration({
|
||||||
|
maskAllText: false,
|
||||||
|
blockAllMedia: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
replaysSessionSampleRate: 0.1,
|
||||||
|
replaysOnErrorSampleRate: 1.0,
|
||||||
|
enableLogs: true,
|
||||||
|
environment: import.meta.env.MODE,
|
||||||
|
release: `bookra@${import.meta.env.VITE_APP_VERSION || "1.0.0"}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { loadStripe, type Stripe } from "@stripe/stripe-js";
|
||||||
|
|
||||||
|
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
|
||||||
|
|
||||||
|
let stripePromise: Promise<Stripe | null> | null = null;
|
||||||
|
|
||||||
|
export function stripeConfigured() {
|
||||||
|
return stripePublishableKey.trim() !== "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStripe() {
|
||||||
|
if (!stripeConfigured()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!stripePromise) {
|
||||||
|
stripePromise = loadStripe(stripePublishableKey);
|
||||||
|
}
|
||||||
|
return stripePromise;
|
||||||
|
}
|
||||||
@@ -3,8 +3,12 @@ import { lazy } from "solid-js";
|
|||||||
import { Route, Router } from "@solidjs/router";
|
import { 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} />
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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`, {
|
||||||
setSubmitted(true);
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name(),
|
||||||
|
email: email(),
|
||||||
|
message: message(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to send");
|
||||||
|
setSubmitted(true);
|
||||||
|
} catch {
|
||||||
|
setError(i18n.t("contact.error.body"));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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,154 +69,60 @@ 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 */}
|
||||||
<Card class="surface-elevated border-success/20">
|
<div class="space-y-8">
|
||||||
<CardContent class="py-12">
|
<div>
|
||||||
<div class="flex flex-col items-center text-center">
|
<h2 class="text-display-sm font-semibold text-ink mb-4">
|
||||||
<div class="relative mb-6">
|
{i18n.t("contact.story.heading")}
|
||||||
<BookraCharacter pose="success" size="xl" animate={true} />
|
</h2>
|
||||||
<div class="absolute -top-2 -right-2 text-3xl animate-bounce">🎉</div>
|
<div class="space-y-4 text-ink-muted leading-relaxed">
|
||||||
</div>
|
<p>{i18n.t("contact.story.p1")}</p>
|
||||||
<h2 class="text-display-md font-semibold text-ink mb-4">
|
<p>{i18n.t("contact.story.p2")}</p>
|
||||||
{i18n.t("contact.success.title")}
|
|
||||||
</h2>
|
|
||||||
<p class="text-ink-muted max-w-sm">
|
|
||||||
{i18n.t("contact.success.body")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
}>
|
|
||||||
<Card class="surface-elevated overflow-hidden">
|
|
||||||
<div class="flex items-center gap-3 p-6 border-b border-border/50 bg-canvas-subtle/30">
|
|
||||||
<div class="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
|
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<CardTitle class="text-xl">{i18n.t("contact.form.title")}</CardTitle>
|
|
||||||
</div>
|
</div>
|
||||||
<CardContent class="p-6">
|
</div>
|
||||||
<form onSubmit={handleSubmit} class="space-y-6">
|
|
||||||
<Input
|
|
||||||
label={i18n.t("contact.form.name")}
|
|
||||||
type="text"
|
|
||||||
value={name()}
|
|
||||||
onInput={(e) => setName(e.currentTarget.value)}
|
|
||||||
required
|
|
||||||
autocomplete="name"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={i18n.t("contact.form.email")}
|
|
||||||
type="email"
|
|
||||||
value={email()}
|
|
||||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
|
||||||
required
|
|
||||||
autocomplete="email"
|
|
||||||
/>
|
|
||||||
<Textarea
|
|
||||||
label={i18n.t("contact.form.message")}
|
|
||||||
value={message()}
|
|
||||||
onInput={(e) => setMessage(e.currentTarget.value)}
|
|
||||||
rows={5}
|
|
||||||
required
|
|
||||||
placeholder={i18n.locale() === 'cs' ? "Napište nám, jak vám můžeme pomoci..." : "Tell us how we can help you..."}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
fullWidth
|
|
||||||
isLoading={submitting()}
|
|
||||||
class="shadow-lg hover:shadow-xl transition-all"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
|
|
||||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
|
||||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
|
||||||
</svg>
|
|
||||||
{i18n.t("contact.form.submit")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Contact Info Section */}
|
<div class="grid sm:grid-cols-2 gap-4">
|
||||||
<section class="py-16 lg:py-24">
|
<Card class="surface-elevated group hover:shadow-lg transition-all">
|
||||||
<div class="section-container">
|
<CardContent class="p-5">
|
||||||
<div class="max-w-4xl mx-auto">
|
<div class="flex items-start gap-3">
|
||||||
{/* Section title */}
|
<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">
|
||||||
<div class="text-center mb-12">
|
<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">
|
||||||
<h2 class="text-display-md font-semibold text-ink mb-3">
|
<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"/>
|
||||||
{i18n.locale() === 'cs' ? 'Další způsoby kontaktu' : 'Other ways to reach us'}
|
<polyline points="22 9.5 12 14 2 9.5"/>
|
||||||
</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>
|
</svg>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<h3 class="font-display font-semibold text-ink text-sm mb-1">{i18n.t("contact.info.email.title")}</h3>
|
||||||
</CardContent>
|
<a href={`mailto:${i18n.t("contact.email.address")}`} class="text-accent text-sm font-medium hover:underline">
|
||||||
</Card>
|
{i18n.t("contact.email.address")}
|
||||||
|
</a>
|
||||||
{/* 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>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
<Card class="surface-elevated group hover:shadow-lg transition-all">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
{/* Helpful mascot at bottom */}
|
<div class="flex items-center gap-4 surface-elevated px-5 py-4 rounded-2xl">
|
||||||
<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} />
|
<BookraCharacter pose="main" size="sm" animate={true} />
|
||||||
<p class="text-ink-muted text-sm">
|
<p class="text-ink-muted text-sm">
|
||||||
{i18n.locale() === 'cs'
|
{i18n.locale() === 'cs'
|
||||||
@@ -215,6 +131,85 @@ export function ContactRoute() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Form side */}
|
||||||
|
<div>
|
||||||
|
<Switch>
|
||||||
|
<Match when={submitted()}>
|
||||||
|
<Card class="surface-elevated border-success/20">
|
||||||
|
<CardContent class="py-12">
|
||||||
|
<div class="flex flex-col items-center text-center">
|
||||||
|
<div class="relative mb-6">
|
||||||
|
<BookraCharacter pose="success" size="xl" animate={true} />
|
||||||
|
</div>
|
||||||
|
<h2 class="text-display-md font-semibold text-ink mb-4">
|
||||||
|
{i18n.t("contact.success.title")}
|
||||||
|
</h2>
|
||||||
|
<p class="text-ink-muted max-w-sm">
|
||||||
|
{i18n.t("contact.success.body")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<Card class="surface-elevated overflow-hidden">
|
||||||
|
<div class="flex items-center gap-3 p-6 border-b border-border/50 bg-canvas-subtle/30">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<CardTitle class="text-xl">{i18n.t("contact.form.title")}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardContent class="p-6">
|
||||||
|
<form onSubmit={handleSubmit} class="space-y-5">
|
||||||
|
<Input
|
||||||
|
label={i18n.t("contact.form.name")}
|
||||||
|
type="text"
|
||||||
|
value={name()}
|
||||||
|
onInput={(e) => setName(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
autocomplete="name"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={i18n.t("contact.form.email")}
|
||||||
|
type="email"
|
||||||
|
value={email()}
|
||||||
|
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label={i18n.t("contact.form.message")}
|
||||||
|
value={message()}
|
||||||
|
onInput={(e) => setMessage(e.currentTarget.value)}
|
||||||
|
rows={5}
|
||||||
|
required
|
||||||
|
minLength={10}
|
||||||
|
placeholder={i18n.locale() === 'cs' ? "Napište nám, jak vám můžeme pomoci..." : "Tell us how we can help you..."}
|
||||||
|
/>
|
||||||
|
<Show when={error()}>
|
||||||
|
<p class="text-sm text-danger">{error()}</p>
|
||||||
|
</Show>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
isLoading={submitting()}
|
||||||
|
class="shadow-lg hover:shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||||
|
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||||
|
</svg>
|
||||||
|
{i18n.t("contact.form.submit")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,125 +611,168 @@ 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">
|
</span>
|
||||||
<h3 class="font-display text-lg font-semibold mb-1 text-ink">
|
<button
|
||||||
{i18n.t("home.pricing.starter.name")}
|
type="button"
|
||||||
</h3>
|
onClick={() => setBillingInterval(isYearly() ? "monthly" : "yearly")}
|
||||||
<p class="text-ink-muted">{i18n.t("home.pricing.starter.desc")}</p>
|
class={`relative w-14 h-7 rounded-full transition-colors duration-300 cursor-pointer ${isYearly() ? 'bg-accent' : 'bg-ink/30'}`}
|
||||||
</div>
|
role="switch"
|
||||||
<div class="mb-6">
|
aria-checked={isYearly()}
|
||||||
<span class="font-display text-4xl font-semibold text-ink">
|
aria-label={isYearly() ? (isCs() ? "Ročně" : "Yearly") : (isCs() ? "Měsíčně" : "Monthly")}
|
||||||
{i18n.locale() === 'cs' ? '119 Kč' : '$5'}
|
|
||||||
</span>
|
|
||||||
<span class="text-ink-muted">{i18n.t("home.pricing.perMonth")}</span>
|
|
||||||
<p class="mt-1 text-xs text-accent font-medium">{i18n.t("home.pricing.starter.trial")}</p>
|
|
||||||
</div>
|
|
||||||
<ul class="space-y-3 mb-8">
|
|
||||||
{[i18n.t("home.pricing.starter.f1"), i18n.t("home.pricing.starter.f2"), i18n.t("home.pricing.starter.f3")].map((feature) => (
|
|
||||||
<li class="flex items-start gap-3 text-ink-muted">
|
|
||||||
<span class="mt-0.5 text-accent shrink-0">
|
|
||||||
<CheckIcon />
|
|
||||||
</span>
|
|
||||||
<span class="text-sm">{feature}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<A
|
|
||||||
href="/dashboard"
|
|
||||||
class="block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 btn-secondary w-full"
|
|
||||||
>
|
>
|
||||||
{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'}`}
|
||||||
</div>
|
/>
|
||||||
|
</button>
|
||||||
{/* Pro Plan - Highlighted */}
|
<span class={`text-sm font-semibold transition-colors ${isYearly() ? 'text-ink' : 'text-ink-muted'}`}>
|
||||||
<div
|
{isCs() ? "Ročně" : "Yearly"}
|
||||||
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"
|
</span>
|
||||||
style={{ "animation-delay": "0.4s" }}
|
<div class="absolute left-full ml-3 top-1/2 -translate-y-1/2">
|
||||||
>
|
<Show when={isYearly()}>
|
||||||
{/* Gradient background for highlighted card */}
|
<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">
|
||||||
<div class="absolute inset-0 bg-gradient-to-b from-ink to-ink/95 rounded-card" />
|
<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"/>
|
||||||
{/* Popular badge */}
|
</svg>
|
||||||
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
|
{isCs() ? "-17%" : "-17%"}
|
||||||
<span class="px-3 py-1 bg-accent text-white text-xs font-display font-medium rounded-full shadow-lg">
|
|
||||||
{i18n.t("home.pricing.popular")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative z-10">
|
|
||||||
<div class="mb-6">
|
|
||||||
<h3 class="font-display text-lg font-semibold mb-1 text-canvas">
|
|
||||||
{i18n.t("home.pricing.pro.name")}
|
|
||||||
</h3>
|
|
||||||
<p class="text-canvas/70">{i18n.t("home.pricing.pro.desc")}</p>
|
|
||||||
</div>
|
|
||||||
<div class="mb-6">
|
|
||||||
<span class="font-display text-4xl font-semibold text-canvas">
|
|
||||||
{i18n.locale() === 'cs' ? '499 Kč' : '$20'}
|
|
||||||
</span>
|
</span>
|
||||||
<span class="text-canvas/60">{i18n.t("home.pricing.perMonth")}</span>
|
</Show>
|
||||||
<p class="mt-1 text-xs text-accent font-medium">{i18n.t("home.pricing.pro.trial")}</p>
|
|
||||||
</div>
|
|
||||||
<ul class="space-y-3 mb-8">
|
|
||||||
{[i18n.t("home.pricing.pro.f1"), i18n.t("home.pricing.pro.f2"), i18n.t("home.pricing.pro.f3"), i18n.t("home.pricing.pro.f4"), i18n.t("home.pricing.pro.f5")].map((feature) => (
|
|
||||||
<li class="flex items-start gap-3 text-canvas/80">
|
|
||||||
<span class="mt-0.5 text-accent shrink-0">
|
|
||||||
<CheckIcon />
|
|
||||||
</span>
|
|
||||||
<span class="text-sm">{feature}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<A
|
|
||||||
href="/dashboard"
|
|
||||||
class="block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 bg-canvas text-ink hover:bg-canvas-subtle w-full shadow-lg group-hover:shadow-xl"
|
|
||||||
>
|
|
||||||
{i18n.t("home.pricing.pro.cta")}
|
|
||||||
</A>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Business Plan */}
|
<div class="grid md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto items-center">
|
||||||
<div
|
{plans().map((plan, index) => (
|
||||||
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
|
||||||
style={{ "animation-delay": "0.5s" }}
|
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` }}
|
||||||
<div class="mb-6">
|
|
||||||
<h3 class="font-display text-lg font-semibold mb-1 text-ink">
|
|
||||||
{i18n.t("home.pricing.biz.name")}
|
|
||||||
</h3>
|
|
||||||
<p class="text-ink-muted">{i18n.t("home.pricing.biz.desc")}</p>
|
|
||||||
</div>
|
|
||||||
<div class="mb-6">
|
|
||||||
<span class="font-display text-4xl font-semibold text-ink">
|
|
||||||
{i18n.locale() === 'cs' ? '1 199 Kč' : '$50'}
|
|
||||||
</span>
|
|
||||||
<span class="text-ink-muted">{i18n.t("home.pricing.perMonth")}</span>
|
|
||||||
<p class="mt-1 text-xs text-accent font-medium">{i18n.t("home.pricing.biz.trial")}</p>
|
|
||||||
</div>
|
|
||||||
<ul class="space-y-3 mb-8">
|
|
||||||
{[i18n.t("home.pricing.biz.f1"), i18n.t("home.pricing.biz.f2"), i18n.t("home.pricing.biz.f3"), i18n.t("home.pricing.biz.f4")].map((feature) => (
|
|
||||||
<li class="flex items-start gap-3 text-ink-muted">
|
|
||||||
<span class="mt-0.5 text-accent shrink-0">
|
|
||||||
<CheckIcon />
|
|
||||||
</span>
|
|
||||||
<span class="text-sm">{feature}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<A
|
|
||||||
href="/dashboard"
|
|
||||||
class="block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 btn-secondary w-full"
|
|
||||||
>
|
>
|
||||||
{i18n.t("home.pricing.biz.cta")}
|
{/* Gradient background for popular card */}
|
||||||
</A>
|
<Show when={plan.popular}>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-b from-ink to-ink/95 rounded-card" />
|
||||||
|
</Show>
|
||||||
|
<Show when={!plan.popular}>
|
||||||
|
<div class="absolute inset-0 surface-elevated rounded-card" />
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Popular badge */}
|
||||||
|
<Show when={plan.popular}>
|
||||||
|
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
|
||||||
|
<span class="px-3 py-1 bg-accent text-white text-xs font-display font-medium rounded-full shadow-lg">
|
||||||
|
{i18n.t("home.pricing.popular")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="relative z-10">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class={`font-display text-lg font-semibold mb-1 ${plan.popular ? 'text-canvas' : 'text-ink'}`}>
|
||||||
|
{plan.name}
|
||||||
|
</h3>
|
||||||
|
<p class={plan.popular ? 'text-canvas/70' : 'text-ink-muted'}>{plan.desc}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-baseline gap-1">
|
||||||
|
<span class={`font-display text-4xl font-semibold ${plan.popular ? 'text-canvas' : 'text-ink'}`}>
|
||||||
|
{isYearly() ? plan.yearly : plan.monthly}
|
||||||
|
</span>
|
||||||
|
<span class={plan.popular ? 'text-canvas/60' : 'text-ink-muted'}>
|
||||||
|
{isYearly() ? plan.yearlyPeriod : plan.period}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Show when={isYearly()}>
|
||||||
|
<div class="mt-2 flex items-center gap-1.5">
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-accent-subtle text-accent text-xs font-semibold rounded-full border border-accent/10">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
|
||||||
|
{plan.savingsPercent}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-ink-subtle">{isCs() ? 'sleva při roční platbě' : 'discount on yearly billing'}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={!isYearly()}>
|
||||||
|
<p class="mt-1 text-xs text-accent font-medium">{plan.trial}</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-3 mb-8">
|
||||||
|
{plan.features.map((feature) => (
|
||||||
|
<li class={`flex items-start gap-3 ${plan.popular ? 'text-canvas/80' : 'text-ink-muted'}`}>
|
||||||
|
<span class="mt-0.5 text-accent shrink-0">
|
||||||
|
<CheckIcon />
|
||||||
|
</span>
|
||||||
|
<span class="text-sm">{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<A
|
||||||
|
href="/dashboard"
|
||||||
|
class={`block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 w-full ${plan.popular ? 'bg-canvas text-ink hover:bg-canvas-subtle shadow-lg group-hover:shadow-xl' : 'btn-secondary'}`}
|
||||||
|
>
|
||||||
|
{plan.cta}
|
||||||
|
</A>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Comparison Table */}
|
||||||
|
<section class="py-16 px-4">
|
||||||
|
<div class="section-container">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div class="text-center mb-10">
|
||||||
|
<span class="text-sm font-medium text-accent mb-2 block tracking-wide uppercase">
|
||||||
|
{i18n.t("pricing.compare.eyebrow")}
|
||||||
|
</span>
|
||||||
|
<h2 class="text-2xl sm:text-3xl font-display font-bold text-ink">
|
||||||
|
{i18n.t("pricing.compare.title")}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="surface-elevated rounded-card overflow-hidden border border-border/50 shadow-sm">
|
||||||
|
{/* Header */}
|
||||||
|
<div class="grid grid-cols-[1fr_100px_100px_100px] sm:grid-cols-[1.5fr_1fr_1fr_1fr] gap-2 p-4 sm:p-5 bg-canvas-subtle/60 border-b border-border/50">
|
||||||
|
<div class="text-sm font-semibold text-ink-muted self-center">{i18n.t("pricing.compare.feature")}</div>
|
||||||
|
<div class="text-center font-display font-semibold text-ink text-sm">Starter</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<span class="inline-block px-3 py-1 bg-accent/10 text-accent font-display font-semibold text-sm rounded-full">
|
||||||
|
Pro
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-center font-display font-semibold text-ink text-sm">Business</div>
|
||||||
|
</div>
|
||||||
|
{/* Rows */}
|
||||||
|
<For each={[
|
||||||
|
{ key: "pricing.compare.locations", starter: "1", pro: "3", business: "∞" },
|
||||||
|
{ key: "pricing.compare.staff", starter: "1", pro: "10", business: "∞" },
|
||||||
|
{ key: "pricing.compare.bookings", starter: "50", pro: "∞", business: "∞" },
|
||||||
|
{ key: "pricing.compare.emailSupport", starter: i18n.t("pricing.compare.yes"), pro: i18n.t("pricing.compare.priority"), business: i18n.t("pricing.compare.dedicated") },
|
||||||
|
{ key: "pricing.compare.reminders", starter: "no", pro: "yes", business: "yes" },
|
||||||
|
{ key: "pricing.compare.analytics", starter: "no", pro: "yes", business: i18n.t("pricing.compare.advanced") },
|
||||||
|
{ key: "pricing.compare.api", starter: "no", pro: "no", business: "yes" },
|
||||||
|
{ key: "pricing.compare.branding", starter: "no", pro: "yes", business: "yes" },
|
||||||
|
{ key: "pricing.compare.whiteLabel", starter: "no", pro: "no", business: "yes" },
|
||||||
|
{ key: "pricing.compare.manager", starter: "no", pro: "no", business: "yes" },
|
||||||
|
]}>
|
||||||
|
{(feature, i) => (
|
||||||
|
<div class={`grid grid-cols-[1fr_100px_100px_100px] sm:grid-cols-[1.5fr_1fr_1fr_1fr] gap-2 p-4 sm:p-5 items-center border-b border-border/30 last:border-0 transition-colors ${i() % 2 === 0 ? 'bg-canvas/30' : ''} hover:bg-canvas-subtle/40`}>
|
||||||
|
<span class="text-sm text-ink font-medium">{i18n.t(feature.key)}</span>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<ComparisonValue value={feature.starter} />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<ComparisonValue value={feature.pro} highlight />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<ComparisonValue value={feature.business} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,29 +9,80 @@ 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."
|
||||||
title: i18n.t("legal.terms.service.title"),
|
: "Operator: Bookra, Business ID 24330621. Registered in the Czech Republic.";
|
||||||
body: i18n.t("legal.terms.service.body"),
|
|
||||||
},
|
const termsSections = () => [
|
||||||
{
|
{
|
||||||
title: i18n.t("legal.terms.billing.title"),
|
title: isCs() ? "1. Úvod a předmět smlouvy" : "1. Introduction and subject",
|
||||||
body: i18n.t("legal.terms.billing.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.privacy.data.title"),
|
title: isCs() ? "2. Registrace a účet" : "2. Registration and account",
|
||||||
body: i18n.t("legal.privacy.data.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.rights.title"),
|
},
|
||||||
body: i18n.t("legal.privacy.rights.body"),
|
{
|
||||||
},
|
title: isCs() ? "3. Předplatné a platby" : "3. Subscription and payments",
|
||||||
];
|
body: isCs()
|
||||||
|
? "Placené plány se účtují předem prostřednictvím platební brány Paddle nebo Stripe. Aktivní plán určuje dostupné limity, rozšíření a podpůrné funkce. Při roční platbě je poskytována sleva oproti měsíčnímu zúčtování. Uživatel může předplatné kdykoliv zrušit; přístup zůstává do konce zaplaceného období. Neposkytujeme refundace za již zaplacená období."
|
||||||
|
: "Paid plans are billed in advance through Paddle or Stripe. The active plan determines available limits, add-ons, and support features. Annual billing includes a discount compared to monthly billing. Users may cancel anytime; access continues until the end of the paid period. No refunds are provided for already-paid periods.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: isCs() ? "4. Odpovědnost a omezení" : "4. Liability and limitations",
|
||||||
|
body: isCs()
|
||||||
|
? "Bookra se snaží zajistit nepřetržitý provoz, ale nezaručuje 100% dostupnost. Neneseme odpovědnost za přímé ani nepřímé škody způsobené výpadkem služby, ztrátou dat způsobenou uživatelem nebo technickými problémy třetích stran. Doporučujeme pravidelnou zálohu důležitých dat."
|
||||||
|
: "Bookra strives to ensure uninterrupted service but does not guarantee 100% uptime. We are not liable for direct or indirect damages caused by service outages, data loss caused by the user, or technical issues from third parties. We recommend regular backups of important data.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: isCs() ? "5. Ukončení a výpověď" : "5. Termination",
|
||||||
|
body: isCs()
|
||||||
|
? "Uživatel může účet zrušit kdykoliv v nastavení. Při dlouhodobé neaktivitě (12 měsíců bez přihlášení) si vyhrazujeme právo účet deaktivovat po předchozím upozornění. Při porušení podmínek může být účet ukončen okamžitě."
|
||||||
|
: "Users may cancel their account anytime in settings. After prolonged inactivity (12 months without login), we reserve the right to deactivate the account after prior notice. Accounts violating these terms may be terminated immediately.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const privacySections = () => [
|
||||||
|
{
|
||||||
|
title: isCs() ? "1. Jaké údaje zpracováváme a proč" : "1. What data we process and why",
|
||||||
|
body: isCs()
|
||||||
|
? "Zpracováváme minimální množství dat nezbytných pro fungování služby: kontaktní údaje zákazníků (jméno, e-mail) pro potvrzení rezervace, čas rezervace a poznámky zadané při rezervaci, údaje o účtu provozovatele (e-mail, jméno) pro správu účtu, a technické záznamy (IP adresa, čas požadavku) pro zabezpečení. Údaje tenantů jsou oddělené a přístup k nim je omezen podle role uživatele."
|
||||||
|
: "We process the minimum data necessary for the service: customer contact details (name, email) for booking confirmation, booking times and notes, workspace account details (email, name) for account management, and technical records (IP address, request time) for security. Tenant data is isolated and access is limited by user role.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: isCs() ? "2. Cookies a sledování" : "2. Cookies and tracking",
|
||||||
|
body: isCs()
|
||||||
|
? "Bookra nepoužívá žádné sledovací cookies pro marketingové ani analytické účely. Jediné cookies, které ukládáme, jsou technicky nezbytné pro přihlášení a správu relace. Pro anonymní statistiky využíváme Rybbit — nástroj, který pracuje bez cookies a neukládá osobní údaje návštěvníků."
|
||||||
|
: "Bookra does not use any tracking cookies for marketing or analytics purposes. The only cookies we store are technically necessary for login and session management. For anonymous statistics, we use Rybbit — a tool that operates without cookies and does not store visitors' personal data.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: isCs() ? "3. Údaje registrovaných uživatelů" : "3. Registered user data",
|
||||||
|
body: isCs()
|
||||||
|
? "Při registraci shromažďujeme e-mailovou adresu a jméno uživatele. Tato data slouží výhradně k autentizaci, správě účtu a komunikaci ohledně služby (připomenutí, oznámení o změnách). Vaše data neprodáváme, nepronajímáme a nesdílíme s třetími stranami pro marketingové účely. Přístup mají pouze oprávnění zaměstnanci Bookry a to pouze v nezbytném rozsahu pro technickou podporu."
|
||||||
|
: "During registration, we collect the user's email address and name. This data is used solely for authentication, account management, and service-related communication (reminders, change notifications). We do not sell, rent, or share your data with third parties for marketing purposes. Only authorized Bookra employees have access, and only to the extent necessary for technical support.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: isCs() ? "4. Práva a žádosti" : "4. Rights and requests",
|
||||||
|
body: isCs()
|
||||||
|
? "V souladu s GDPR máte právo na přístup ke svým údajům, jejich opravu, výmaz nebo omezení zpracování. Žádosti o přístup, opravu nebo výmaz údajů řeší provozovatel konkrétního účtu. Bookra poskytuje technické prostředky pro bezpečné zpracování. Svá práva můžete uplatnit e-mailem na hello@bookra.eu."
|
||||||
|
: "In accordance with GDPR, you have the right to access, correct, delete, or restrict processing of your data. Access, correction, and deletion requests are handled by the operator of the relevant workspace. Bookra provides the technical system for secure processing. You may exercise your rights by emailing hello@bookra.eu.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: isCs() ? "5. Doba uchování a zabezpečení" : "5. Retention and security",
|
||||||
|
body: isCs()
|
||||||
|
? "Rezervační údaje uchováváme po dobu existence účtu provozovatele, pokud není smazány dříve. Technické záznamy uchováváme po dobu 90 dnů. Všechna data jsou přenášena šifrovaně (TLS), uchovávána v zabezpečených datových centrech v EU a pravidelně zálohována."
|
||||||
|
: "Booking data is retained for the lifetime of the operator's account unless deleted earlier. Technical records are kept for 90 days. All data is transmitted encrypted (TLS), stored in secure EU data centers, and regularly backed up.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const sections = () => (kind() === "terms" ? termsSections() : privacySections());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section class="section-container py-16">
|
<section class="section-container py-16">
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
{"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"}
|
||||||
Generated
+147
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
Reference in New Issue
Block a user