Compare commits

..

6 Commits

Author SHA1 Message Date
Tomas Dvorak 2039669e2c feat(ui): add analytics dashboard and enhance frontend components
CI / Frontend (push) Successful in 10m8s
CI / Go - apps/auth-service (push) Failing after 3s
CI / Go - apps/backend (push) Successful in 10m9s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped
- Implement new analytics section in the dashboard with KPI cards and charts
- Add video player scrubber and playback controls
- Improve FloatingDock layout and responsiveness
- Enhance HomeRoute with demo mode redirection and improved trust section UI
- Update dashboard routing to support the new analytics tab
2026-05-18 18:27:54 +02:00
Tomas Dvorak da5ba13eab feat(ui): implement comprehensive dashboard and enhance frontend experience
This commit introduces a major overhaul of the user interface, transitioning from a basic structure to a feature-rich dashboard system. Key improvements include:

- **Dashboard Implementation**: Added a complete dashboard routing system with dedicated pages for Overview, Bookings, Customers, Zones, Billing, and Settings.
- **New UI Components**: Introduced a variety of high-quality components including `AnimatedList`, `FloatingDock`, `HoverFeatureCards`, `VideoPlayer` (with ambient glow effect), `PinnedList`, and `DashboardMockup`.
- **Enhanced Dashboard Features**:
    - Integrated real-time KPI cards and activity timelines.
    - Implemented a multi-view calendar system.
    - Added customer and booking management interfaces with filtering and search capabilities.
    - Added a zone/location management view with map integration.
- **Branding & Visuals**: Updated the application with new SVG logos (horizontal and vertical variants) and implemented dark/light mode optimized branding.
- **Internationalization**: Expanded i18n support with comprehensive Czech and English translations for the new dashboard and integration modules.
- **Integration Tools**: Added a new `IntegrationModal` allowing users to easily embed Bookra widgets via HTML, React, SolidJS, or PHP.
- **Backend Support**: Updated the booking service to provide comprehensive dashboard summary data, including historical booking records for charts.
2026-05-18 14:31:20 +02:00
Tomas Dvorak 9d63fa7620 chore(config): update environment variables
CI / Frontend (push) Successful in 10m8s
CI / Go - apps/auth-service (push) Failing after 4s
CI / Go - apps/backend (push) Successful in 10m17s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped
- rename BOOKRA_STRIPE_SECRET_KEY to BOOKRA_STRIPE_API_KEY
- split database connection into BOOKRA_DATABASE_URL (pooled) and BOOKRA_DATABASE_DIRECT_URL (direct)
- add BOOKRA_JOB_RUNNER_KEY for cron endpoint protection
- add BOOKRA_EMAIL_FROM for SMTP configuration
2026-05-11 18:36:53 +02:00
Tomas Dvorak 3b6f46828b refactor(config): remove paddle configuration validation 2026-05-11 18:16:46 +02:00
Tomas Dvorak 7d3e3448cf feat(sms): implement SMS messaging and metered billing
CI / Frontend (push) Successful in 9m50s
CI / Go - apps/auth-service (push) Failing after 4s
CI / Go - apps/backend (push) Successful in 10m18s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped
Implement a complete SMS messaging system including:
- Integration with SMS Manager.cz API for sending messages.
- Metered billing via Stripe using monthly aggregate invoice items.
- Backend services for managing SMS settings, usage logging, and monthly reporting.
- Database migrations for tenant settings, usage logs, and monthly reports.
- Frontend dashboard components for SMS configuration, usage tracking, and history.
- Support for customer phone numbers in the booking flow.

Includes new migrations, backend services, and frontend UI components.
2026-05-10 11:40:53 +02:00
Tomas Dvorak 164a37e997 feat(core): consolidate auth service into backend and implement stripe billing
CI / Frontend (push) Successful in 9m54s
CI / Go - apps/auth-service (push) Failing after 24s
CI / Go - apps/backend (push) Failing after 5m43s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped
This commit performs a major architectural refactor by migrating the standalone `auth-service` into the main `backend` application, enabling a unified codebase and simplified deployment. It also introduces comprehensive Stripe billing support and a new administrative dashboard.

Key changes:
- **Architecture**: Deleted `apps/auth-service` and integrated its functionality (JWT, magic links, OAuth, user management) into `apps/backend`.
- **Billing**: Added Stripe integration to `backend`, supporting both monthly and yearly subscription cycles with automatic plan entitlement enforcement (e.g., location limits).
- **Admin Dashboard**: Implemented a new administrative service and API endpoints to manage tenants, users, and view platform-wide statistics.
- **Frontend**:
    - Added a new pricing page with monthly/yearly toggle and comparison table.
    - Integrated Stripe and Sentry for payments and error tracking.
    - Improved dashboard UX/UI and added i18n support for new features.
    - Enhanced the public booking flow with better validation and contact form integration.
- **Database**: Added migrations for users, magic links, password resets, OAuth states, admin audit logs, and refresh tokens.
- **DevOps**: Updated environment configurations for Railway and Vercel, and streamlined the project's `package.json` scripts.
2026-05-09 18:25:25 +02:00
112 changed files with 16550 additions and 7894 deletions
+59
View File
@@ -5,10 +5,69 @@ BOOKRA_APP_ENV=staging
BOOKRA_APP_URL=https://app.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
# 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
# Stripe SMS prices — one per market/currency (create as standard prices, not metered)
# These are used for monthly aggregate billing (invoice item at month end)
# Czech: 1.50 CZK per SMS | US: ~$0.065 per SMS | EUR: ~€0.060 | GBP: ~£0.050 | PLN: ~0.27zł
BOOKRA_STRIPE_SMS_CZK_PRICE_ID=price_xxx
BOOKRA_STRIPE_SMS_USD_PRICE_ID=price_xxx
BOOKRA_STRIPE_SMS_EUR_PRICE_ID=
BOOKRA_STRIPE_SMS_GBP_PRICE_ID=
BOOKRA_STRIPE_SMS_PLN_PRICE_ID=
# SMS Manager.cz API (optional - SMS feature)
BOOKRA_SMSMANAGER_API_KEY=your_smsmanager_api_key
BOOKRA_SMSMANAGER_BASE_URL=https://api.smsmngr.com/v2
# Legacy price IDs (fallback)
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_API_KEY=pdl_sdbx_api_key
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_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
BOOKRA_SMTP_HOST=smtp.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
+83
View File
@@ -0,0 +1,83 @@
# Bookra Project Notes
## Build & Test Commands
- `npm run build:frontend` — Build SolidJS frontend
- `npm run build:backend` — Build Go backend
- `npm run test` — Run backend tests
- `npm run verify` — Full verification (client gen, lint, test, build)
## Database
- Uses PostgreSQL via Neon (pooled URL for app, direct URL for migrations)
- Migrations with Goose: `npm run db:migrate:up`
- SQLC for typed queries: `npm run db:generate`
## SMS Feature
### Architecture
- Optional add-on, off by default
- Only available on **Pro** and **Business** plans
- Uses SMS Manager.cz JSON API v2 (`https://api.smsmngr.com/v2`)
- Metered billing via Stripe (1.50 CZK per SMS)
- Tracks usage locally in `sms_usage_logs` table
### Database Tables
- `tenant_sms_settings` — per-tenant SMS config (enabled, sender, limit, stripe item ID)
- `sms_usage_logs` — every SMS sent with cost tracking
- `sms_monthly_reports` — aggregated monthly usage for invoices
### API Endpoints
- `GET /v1/sms/settings` — Get SMS settings + current month stats
- `POST /v1/sms/settings` — Enable/disable SMS, configure sender/limit
- `POST /v1/sms/send` — Send an SMS (tracked & billed)
- `GET /v1/sms/usage` — Usage for a specific month
- `GET /v1/sms/history` — Recent SMS logs
- `GET /v1/sms/invoices` — Monthly invoice reports
- `POST /v1/internal/jobs/sms/invoices` — Cron endpoint to generate monthly reports & emails
### What to Configure on Stripe
1. **Create standard Prices** for SMS in each currency:
- Product: "SMS Messages"
- Price: 1.50 CZK per unit (or equivalent in USD/EUR)
- Billing mode: **Standard** one-time (not metered)
- No free trial
2. **Environment variables** to add:
```
BOOKRA_STRIPE_SMS_CZK_PRICE_ID=price_xxx
BOOKRA_STRIPE_SMS_USD_PRICE_ID=price_yyy
BOOKRA_STRIPE_SMS_EUR_PRICE_ID=price_zzz
BOOKRA_SMSMANAGER_API_KEY=your_smsmanager_api_key
```
### Stripe CLI Commands (for testing)
```bash
# Login to Stripe
stripe login
# Create test product
stripe products create --name="SMS Messages"
# Create prices in each currency (replace prod_xxx with actual product ID)
stripe prices create --product=prod_xxx --unit-amount=150 --currency=czk
stripe prices create --product=prod_xxx --unit-amount=6 --currency=usd
stripe prices create --product=prod_xxx --unit-amount=6 --currency=eur
# Listen to webhooks locally
stripe listen --forward-to http://localhost:8080/v1/webhooks/stripe
```
### Monthly Invoice Flow
- At month end, a background job (`POST /v1/internal/jobs/sms/invoices`) aggregates all SMS usage per tenant
- It creates a Stripe `invoiceitem` with quantity = messages sent × 1.50 CZK (or configured currency price)
- The item is added to the customer's next subscription invoice automatically
- A usage summary email is sent showing: messages sent, total cost, and invoice details
- Reports are visible in-app under Settings > SMS Messages > Invoice reports
### Taxes
- The 1.50 CZK is the base unit price
- Stripe handles tax calculation based on the customer's location and your tax settings
- Displayed prices in the app show pre-tax amounts; the final invoice includes tax
### No Free Trial
- SMS is charged from the first message
- No trial period — usage is aggregated and invoiced monthly
+17 -4
View File
@@ -7,10 +7,23 @@
- Czech `home.step2.desc` was missing and leaked the translation key on the landing page.
- 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
- The dashboard unauthenticated state is still a thin sign-in prompt rather than a dedicated conversion screen.
- 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.
- Many dashboard inline i18n ternaries remain (~280). Systematic extraction to `i18n.t()` keys is an ongoing task.
- Registration cannot be fully customer-tested locally until Neon Auth environment variables are configured.
+43
View File
@@ -58,3 +58,46 @@ npm run db:migrate:up
```
`db:migrate:*` expects `BOOKRA_DATABASE_DIRECT_URL` to be exported in the shell.
## Brand Colors
Bookra uses a sophisticated color system designed for modern booking interfaces with support for both light and dark themes.
### Primary Palette
- **Canvas** (`--color-canvas`): #FFFFFF (light) / #0A0A0A (dark)
- **Canvas Elevated** (`--color-canvas-elevated`): #F8F9FA (light) / #1A1A1A (dark)
- **Canvas Sunken** (`--color-canvas-sunken`): #F1F3F4 (light) / #252525 (dark)
### Accent Colors
- **Primary** (`--color-primary`): #3B82F6
- **Primary Hover** (`--color-primary-hover`): #2563EB
- **Primary Active** (`--color-primary-active`): #1D4ED8
### Semantic Colors
- **Success** (`--color-success`): #10B981
- **Warning** (`--color-warning`): #F59E0B
- **Error** (`--color-error`): #EF4444
- **Info** (`--color-info`): #06B6D4
### Text Colors
- **Text Primary** (`--color-text-primary`): #111827 (light) / #F9FAFB (dark)
- **Text Secondary** (`--color-text-secondary`): #6B7280 (light) / #D1D5DB (dark)
- **Text Muted** (`--color-text-muted`): #9CA3AF (light) / #9CA3AF (dark)
### Border & Surface
- **Border** (`--color-border`): #E5E7EB (light) / #374151 (dark)
- **Surface Glass** (`--color-surface-glass`): rgba(255, 255, 255, 0.8) (light) / rgba(0, 0, 0, 0.8) (dark)
### Shadow System
- **Shadow XS** (`--shadow-xs`): 0 1px 2px 0 rgba(0, 0, 0, 0.05)
- **Shadow SM** (`--shadow-sm`): 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)
- **Shadow MD** (`--shadow-md`): 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)
- **Shadow LG** (`--shadow-lg`): 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)
- **Shadow XL** (`--shadow-xl`): 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)
- **Shadow 2XL** (`--shadow-2xl`): 0 25px 50px -12px rgba(0, 0, 0, 0.25)
### Animation Timing
- **Ease Out Expo** (`--ease-out-expo`): cubic-bezier(0.16, 1, 0.3, 1)
- **Ease Spring** (`--ease-spring`): cubic-bezier(0.68, -0.55, 0.265, 1.55)
These colors are implemented as CSS custom properties and are used throughout the frontend application for consistent theming and accessibility.
-10
View File
@@ -1,10 +0,0 @@
.git
.github
.env
.env.*
bin
coverage
tmp
*.log
Dockerfile
.dockerignore
-22
View File
@@ -1,22 +0,0 @@
# Auth Service Environment Configuration
# This service stays active for standalone auth flows and internal admin management.
# SaaS billing is handled by apps/backend + Paddle.
PORT=8081
APP_ENV=development
DATABASE_URL=postgresql://user:password@host/database?sslmode=require
FRONTEND_URL=http://localhost:3000
JWT_SECRET=change-me-in-production
NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
SMTP_HOST=smtp.purelymail.com
SMTP_PORT=465
SMTP_USERNAME=noreply@example.com
SMTP_PASSWORD=
EMAIL_FROM=noreply@example.com
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URL=http://localhost:8081/api/auth/oauth/google/callback
-21
View File
@@ -1,21 +0,0 @@
# Environment variables
.env
# Binary
auth-service
*.exe
# Go
vendor/
*.log
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
-38
View File
@@ -1,38 +0,0 @@
# Build stage
FROM golang:1.26.2-alpine AS builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache git
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/auth-service ./cmd/api
FROM alpine:3.22
WORKDIR /app
RUN apk add --no-cache ca-certificates \
&& addgroup -S bookra \
&& adduser -S -D -H -u 10001 -G bookra bookra
COPY --from=builder --chown=bookra:bookra /app/auth-service /app/
COPY --from=builder --chown=bookra:bookra /app/migrations /app/migrations
ENV PORT=8080
EXPOSE 8080
USER bookra
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- "http://127.0.0.1:${PORT:-8080}/health" >/dev/null || exit 1
CMD ["/app/auth-service"]
-42
View File
@@ -1,42 +0,0 @@
# Bookra Auth Service
Standalone auth + internal admin service for Bookra.
Primary responsibilities:
- email/password auth
- magic-link auth
- Google OAuth when configured
- internal admin dashboard / remote service management
- optional Neon JWT verification support
Not primary billing service:
- SaaS billing lives in `apps/backend`
- Paddle config belongs in backend/frontend env
## Commands
```bash
go run ./cmd/api
go test ./...
go build ./...
```
## Core Routes
- `GET /health`
- `POST /api/auth/register`
- `POST /api/auth/login`
- `POST /api/auth/magic-link`
- `POST /api/auth/verify`
- `POST /api/auth/refresh`
- `GET /api/auth/me`
- `GET /api/auth/providers`
- `GET /api/auth/oauth/google`
- `GET /api/auth/oauth/google/callback`
- `GET /admin`
- `GET /admin/api/config`
- `GET /admin/api/stats`
See [apps/auth-service/.env.example](/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/.env.example:1).
-121
View File
@@ -1,121 +0,0 @@
package main
import (
"bookra/apps/auth-service/internal/config"
"bookra/apps/auth-service/internal/db"
"bookra/apps/auth-service/internal/email"
"bookra/apps/auth-service/internal/handlers"
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/joho/godotenv"
"github.com/pressly/goose/v3"
)
func main() {
_ = godotenv.Load()
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
if os.Getenv("APP_ENV") == "production" {
gin.SetMode(gin.ReleaseMode)
}
database, err := db.New(cfg.DatabaseURL)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer database.Close()
if err := runMigrations(cfg.DatabaseURL); err != nil {
log.Printf("Migration warning: %v", err)
}
emailSvc := email.New(email.Config{
Host: cfg.SMTPHost,
Port: cfg.SMTPPort,
Username: cfg.SMTPUsername,
Password: cfg.SMTPPassword,
From: cfg.EmailFrom,
})
handler, err := handlers.New(database, emailSvc, cfg)
if err != nil {
log.Fatalf("Failed to initialize handlers: %v", err)
}
r := gin.Default()
r.Use(cors.New(cors.Config{
AllowOrigins: []string{cfg.FrontendURL, "http://localhost:3000", "http://localhost:5173"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "healthy", "service": "auth"})
})
handler.RegisterRoutes(r)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to start server: %v", err)
}
}()
log.Printf("Auth service running on port %s", cfg.Port)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("Server forced to shutdown: %v", err)
}
log.Println("Server exited")
}
func runMigrations(databaseURL string) error {
db, err := goose.OpenDBWithDriver("pgx", databaseURL)
if err != nil {
return fmt.Errorf("goose open db: %w", err)
}
defer db.Close()
if err := goose.SetDialect("postgres"); err != nil {
return fmt.Errorf("goose set dialect: %w", err)
}
if err := goose.Up(db, "migrations"); err != nil {
return fmt.Errorf("goose up: %w", err)
}
return nil
}
-59
View File
@@ -1,59 +0,0 @@
module bookra/apps/auth-service
go 1.26.2
require (
github.com/gin-contrib/cors v1.7.7
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.1
github.com/joho/godotenv v1.5.1
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
github.com/pressly/goose/v3 v3.27.0
github.com/stripe/stripe-go/v83 v83.2.1
golang.org/x/crypto v0.50.0
golang.org/x/oauth2 v0.36.0
)
require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/MicahParks/jwkset v0.11.0 // indirect
github.com/MicahParks/keyfunc/v3 v3.8.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.9.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
-146
View File
@@ -1,146 +0,0 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ=
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds=
github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v83 v83.2.1 h1:8WPhpMjr8VyMWKUsCMoVvlWxYazuL5edajKX/RulfbA=
github.com/stripe/stripe-go/v83 v83.2.1/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
-79
View File
@@ -1,79 +0,0 @@
package auth
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
)
type NeonVerifier struct {
jwks keyfunc.Keyfunc
expectedIssuer string
enabled bool
cancel context.CancelFunc
}
func NewNeonVerifier(neonAuthURL string) (*NeonVerifier, error) {
trimmed := strings.TrimRight(strings.TrimSpace(neonAuthURL), "/")
if trimmed == "" {
return &NeonVerifier{enabled: false}, nil
}
parsed, err := url.Parse(trimmed)
if err != nil {
return nil, fmt.Errorf("parse neon auth url: %w", err)
}
expectedIssuer := fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
jwksURL := fmt.Sprintf("%s/.well-known/jwks.json", trimmed)
ctx, cancel := context.WithCancel(context.Background())
jwks, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
if err != nil {
cancel()
return nil, fmt.Errorf("create neon jwks: %w", err)
}
return &NeonVerifier{jwks: jwks, expectedIssuer: expectedIssuer, enabled: true, cancel: cancel}, nil
}
func (v *NeonVerifier) Enabled() bool {
return v != nil && v.enabled
}
func (v *NeonVerifier) Close() {
if v != nil && v.cancel != nil {
v.cancel()
}
}
func (v *NeonVerifier) Verify(tokenString string) (*Claims, error) {
if !v.Enabled() {
return nil, errors.New("neon auth verifier is disabled")
}
token, err := jwt.Parse(tokenString, v.jwks.Keyfunc,
jwt.WithIssuer(v.expectedIssuer),
jwt.WithValidMethods([]string{"EdDSA"}),
jwt.WithAudience(v.expectedIssuer),
jwt.WithLeeway(15*time.Second),
)
if err != nil {
return nil, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid neon claims")
}
subject, _ := claims["sub"].(string)
email, _ := claims["email"].(string)
name, _ := claims["name"].(string)
if name == "" {
name, _ = claims["display_name"].(string)
}
if strings.TrimSpace(subject) == "" {
return nil, errors.New("missing neon subject")
}
return &Claims{UserID: subject, Email: email, Name: name, Role: "authenticated", Type: "access"}, nil
}
-333
View File
@@ -1,333 +0,0 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"time"
"bookra/apps/auth-service/internal/db"
"bookra/apps/auth-service/internal/email"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
const (
accessTokenTTL = 24 * time.Hour
refreshTokenTTL = 30 * 24 * time.Hour
)
type Service struct {
db *db.DB
email *email.Service
jwtSecret []byte
frontendURL string
}
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
type Claims struct {
UserID string `json:"sub"`
Email string `json:"email"`
Name string `json:"name,omitempty"`
Role string `json:"role,omitempty"`
Type string `json:"type"`
jwt.RegisteredClaims
}
func NewService(database *db.DB, emailSvc *email.Service, jwtSecret string, frontendURL string) *Service {
return &Service{
db: database,
email: emailSvc,
jwtSecret: []byte(jwtSecret),
frontendURL: frontendURL,
}
}
func (s *Service) GenerateMagicLink(ctx context.Context, emailAddr string, locale string) error {
user, err := s.db.GetUserByEmail(ctx, emailAddr)
if err != nil {
return fmt.Errorf("get user: %w", err)
}
if user == nil {
user = &db.User{
Email: emailAddr,
Provider: "email",
}
user, err = s.db.CreateUser(ctx, user)
if err != nil {
return fmt.Errorf("create user: %w", err)
}
}
token := generateRandomToken(32)
expiresAt := time.Now().Add(15 * time.Minute)
if err := s.db.CreateMagicLink(ctx, token, emailAddr, user.ID, expiresAt); err != nil {
return fmt.Errorf("create magic link: %w", err)
}
magicURL := fmt.Sprintf("%s/auth/callback?token=%s", s.frontendURL, token)
var name string
if user.Name != nil {
name = *user.Name
}
if err := s.email.SendMagicLink(emailAddr, name, magicURL, locale); err != nil {
return fmt.Errorf("send email: %w", err)
}
return nil
}
func (s *Service) VerifyMagicLink(ctx context.Context, token string) (*TokenPair, error) {
ml, err := s.db.GetMagicLink(ctx, token)
if err != nil {
return nil, fmt.Errorf("get magic link: %w", err)
}
if ml == nil || ml.Used {
return nil, fmt.Errorf("invalid or used token")
}
if time.Now().After(ml.ExpiresAt) {
return nil, fmt.Errorf("token expired")
}
if err := s.db.MarkMagicLinkUsed(ctx, token); err != nil {
return nil, fmt.Errorf("mark used: %w", err)
}
user, err := s.db.GetUserByID(ctx, ml.UserID)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
if user == nil {
return nil, fmt.Errorf("user not found")
}
if err := s.db.UpdateLastLogin(ctx, user.ID); err != nil {
return nil, fmt.Errorf("update login: %w", err)
}
return s.generateTokens(user)
}
func (s *Service) OAuthLoginOrCreate(ctx context.Context, provider, providerID, email, name string) (*TokenPair, error) {
user, err := s.db.GetUserByProviderID(ctx, provider, providerID)
if err != nil {
return nil, fmt.Errorf("get user by provider: %w", err)
}
if user == nil {
existing, err := s.db.GetUserByEmail(ctx, email)
if err != nil {
return nil, fmt.Errorf("check existing email: %w", err)
}
if existing != nil {
existing.Provider = provider
existing.ProviderID = &providerID
existing.Name = &name
existing.EmailVerified = true
if err := s.db.UpdateUser(ctx, existing); err != nil {
return nil, fmt.Errorf("link provider: %w", err)
}
user = existing
} else {
user = &db.User{
Email: email,
Name: &name,
Provider: provider,
ProviderID: &providerID,
EmailVerified: true,
}
user, err = s.db.CreateUser(ctx, user)
if err != nil {
return nil, fmt.Errorf("create oauth user: %w", err)
}
}
}
if err := s.db.UpdateLastLogin(ctx, user.ID); err != nil {
return nil, fmt.Errorf("update login: %w", err)
}
return s.generateTokens(user)
}
func (s *Service) RegisterWithPassword(ctx context.Context, email, password, name string) (*TokenPair, error) {
existing, err := s.db.GetUserByEmail(ctx, email)
if err != nil {
return nil, fmt.Errorf("check existing: %w", err)
}
if existing != nil {
return nil, fmt.Errorf("email already registered")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
hashStr := string(hash)
user := &db.User{
Email: email,
Name: &name,
PasswordHash: &hashStr,
Provider: "email",
EmailVerified: false,
}
user, err = s.db.CreateUser(ctx, user)
if err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
return s.generateTokens(user)
}
func (s *Service) LoginWithPassword(ctx context.Context, email, password string) (*TokenPair, error) {
user, err := s.db.GetUserByEmail(ctx, email)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
if user == nil || user.PasswordHash == nil {
return nil, fmt.Errorf("invalid credentials")
}
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil {
return nil, fmt.Errorf("invalid credentials")
}
if err := s.db.UpdateLastLogin(ctx, user.ID); err != nil {
return nil, fmt.Errorf("update login: %w", err)
}
return s.generateTokens(user)
}
func (s *Service) generateTokens(user *db.User) (*TokenPair, error) {
now := time.Now()
return s.generateTokensAt(user, now)
}
func (s *Service) generateTokensAt(user *db.User, now time.Time) (*TokenPair, error) {
name := ""
if user.Name != nil {
name = *user.Name
}
accessTokenString, err := s.signToken(user, name, "access", now, accessTokenTTL)
if err != nil {
return nil, fmt.Errorf("sign access token: %w", err)
}
refreshTokenString, err := s.signToken(user, name, "refresh", now, refreshTokenTTL)
if err != nil {
return nil, fmt.Errorf("sign refresh token: %w", err)
}
return &TokenPair{
AccessToken: accessTokenString,
RefreshToken: refreshTokenString,
TokenType: "Bearer",
ExpiresIn: int(accessTokenTTL.Seconds()),
}, nil
}
func (s *Service) VerifyToken(tokenString string) (*Claims, error) {
return s.verifyTokenOfType(tokenString, "access")
}
func (s *Service) VerifyRefreshToken(tokenString string) (*Claims, error) {
return s.verifyTokenOfType(tokenString, "refresh")
}
func (s *Service) RefreshTokens(ctx context.Context, refreshToken string) (*TokenPair, error) {
claims, err := s.VerifyRefreshToken(refreshToken)
if err != nil {
return nil, err
}
user := &db.User{
ID: uuid.MustParse(claims.UserID),
Email: claims.Email,
}
if claims.Name != "" {
user.Name = &claims.Name
}
if s.db != nil {
storedUser, err := s.db.GetUserByID(ctx, user.ID)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
if storedUser == nil {
return nil, fmt.Errorf("user not found")
}
user = storedUser
}
return s.generateTokens(user)
}
func (s *Service) verifyTokenOfType(tokenString string, expectedType string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return s.jwtSecret, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
if claims.Type != expectedType {
return nil, fmt.Errorf("invalid token type")
}
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}
func (s *Service) signToken(user *db.User, name string, tokenType string, now time.Time, ttl time.Duration) (string, error) {
claims := Claims{
UserID: user.ID.String(),
Email: user.Email,
Name: name,
Role: "authenticated",
Type: tokenType,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "bookra-auth",
Subject: user.ID.String(),
Audience: jwt.ClaimStrings{"bookra"},
ID: generateRandomToken(12),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
IssuedAt: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.jwtSecret)
}
func generateRandomToken(length int) string {
b := make([]byte, length)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}
@@ -1,88 +0,0 @@
package auth
import (
"context"
"testing"
"time"
"bookra/apps/auth-service/internal/db"
"github.com/google/uuid"
)
func TestGenerateTokensProducesVerifiableAccessAndRefreshTokens(t *testing.T) {
service := NewService(nil, nil, "test-secret", "http://localhost:3000")
name := "Token Tester"
user := &db.User{
ID: uuid.MustParse("019daeaa-bc14-7712-9224-e347a96bd5c3"),
Email: "tester@bookra.dev",
Name: &name,
}
tokens, err := service.generateTokensAt(user, time.Now().UTC())
if err != nil {
t.Fatalf("generate tokens: %v", err)
}
accessClaims, err := service.VerifyToken(tokens.AccessToken)
if err != nil {
t.Fatalf("verify access token: %v", err)
}
if accessClaims.Type != "access" {
t.Fatalf("expected access type, got %s", accessClaims.Type)
}
refreshClaims, err := service.VerifyRefreshToken(tokens.RefreshToken)
if err != nil {
t.Fatalf("verify refresh token: %v", err)
}
if refreshClaims.Type != "refresh" {
t.Fatalf("expected refresh type, got %s", refreshClaims.Type)
}
if _, err := service.VerifyToken(tokens.RefreshToken); err == nil {
t.Fatal("expected refresh token to fail access verification")
}
if _, err := service.VerifyRefreshToken(tokens.AccessToken); err == nil {
t.Fatal("expected access token to fail refresh verification")
}
}
func TestRefreshTokensReturnsRotatedPair(t *testing.T) {
service := NewService(nil, nil, "test-secret", "http://localhost:3000")
user := &db.User{
ID: uuid.MustParse("019daeaa-bc14-7712-9224-e347a96bd5c3"),
Email: "tester@bookra.dev",
}
original, err := service.generateTokens(user)
if err != nil {
t.Fatalf("generate tokens: %v", err)
}
refreshed, err := service.RefreshTokens(context.Background(), original.RefreshToken)
if err != nil {
t.Fatalf("refresh tokens: %v", err)
}
if refreshed.AccessToken == original.AccessToken {
t.Fatal("expected rotated access token")
}
if refreshed.RefreshToken == original.RefreshToken {
t.Fatal("expected rotated refresh token")
}
if _, err := service.VerifyToken(refreshed.AccessToken); err != nil {
t.Fatalf("verify refreshed access token: %v", err)
}
if _, err := service.VerifyRefreshToken(refreshed.RefreshToken); err != nil {
t.Fatalf("verify refreshed refresh token: %v", err)
}
}
func TestRefreshTokensRejectsInvalidToken(t *testing.T) {
service := NewService(nil, nil, "test-secret", "http://localhost:3000")
if _, err := service.RefreshTokens(context.Background(), "bad-token"); err == nil {
t.Fatal("expected invalid refresh token error")
}
}
@@ -1,464 +0,0 @@
package billing
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"bookra/apps/auth-service/internal/config"
"bookra/apps/auth-service/internal/db"
"github.com/stripe/stripe-go/v83"
"github.com/stripe/stripe-go/v83/checkout/session"
"github.com/stripe/stripe-go/v83/customer"
"github.com/stripe/stripe-go/v83/subscription"
"github.com/stripe/stripe-go/v83/webhook"
)
var (
ErrStripeNotConfigured = errors.New("stripe is not configured")
ErrStripeWebhookMissing = errors.New("stripe webhook secret is not configured")
ErrStripeSignatureMissing = errors.New("stripe signature is missing")
ErrPlanNotConfigured = errors.New("stripe plan is not configured")
ErrCustomerMappingNotFound = errors.New("stripe customer mapping not found")
)
var allowedWebhookEvents = []string{
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"customer.subscription.paused",
"customer.subscription.resumed",
"invoice.paid",
"invoice.payment_failed",
"payment_intent.succeeded",
"payment_intent.payment_failed",
}
type Service struct {
cfg *config.Config
db *db.DB
}
type CheckoutSession struct {
URL string `json:"url"`
}
type SubscriptionSnapshot struct {
CustomerID string `json:"customerId,omitempty"`
SubscriptionID string `json:"subscriptionId,omitempty"`
Status string `json:"status"`
PlanCode string `json:"planCode,omitempty"`
Currency string `json:"currency,omitempty"`
PriceID string `json:"priceId,omitempty"`
CancelAtPeriodEnd bool `json:"cancelAtPeriodEnd"`
CurrentPeriodStart *time.Time `json:"currentPeriodStart,omitempty"`
CurrentPeriodEnd *time.Time `json:"currentPeriodEnd,omitempty"`
PaymentMethod *PaymentMethod `json:"paymentMethod,omitempty"`
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
CheckoutURLAvailable bool `json:"checkoutUrlAvailable"`
SyncAvailable bool `json:"syncAvailable"`
}
type PaymentMethod struct {
Brand string `json:"brand"`
Last4 string `json:"last4"`
}
type UserIdentity struct {
ID string
Email string
Name string
}
type userCustomerMapping struct {
CustomerID string `json:"customerId"`
UpdatedAt time.Time `json:"updatedAt"`
}
func NewService(cfg *config.Config, database *db.DB) *Service {
return &Service{cfg: cfg, db: database}
}
func (s *Service) GetSubscription(ctx context.Context, userID string) (SubscriptionSnapshot, error) {
mapping, ok, err := s.getCustomerMapping(ctx, userID)
if err != nil {
return SubscriptionSnapshot{}, err
}
if !ok {
return s.noneSnapshot(), nil
}
snapshot, ok, err := s.getCustomerSnapshot(ctx, mapping.CustomerID)
if err != nil {
return SubscriptionSnapshot{}, err
}
if !ok {
snapshot = SubscriptionSnapshot{
CustomerID: mapping.CustomerID,
Status: "none",
}
}
snapshot.CheckoutURLAvailable = s.checkoutAvailableForPlan(snapshot.PlanCode)
snapshot.SyncAvailable = s.cfg.StripeSecretConfigured()
return snapshot, nil
}
func (s *Service) CreateCheckoutSession(ctx context.Context, user UserIdentity, planCode string, currency string) (CheckoutSession, error) {
priceID, resolvedPlanCode, resolvedCurrency, err := s.priceForPlan(planCode, currency)
if err != nil {
return CheckoutSession{}, err
}
if s.cfg.StripeSecretKey == "" {
return CheckoutSession{}, ErrStripeNotConfigured
}
customerID, err := s.ensureCustomer(ctx, user)
if err != nil {
return CheckoutSession{}, err
}
stripe.Key = s.cfg.StripeSecretKey
params := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
Customer: stripe.String(customerID),
ClientReferenceID: stripe.String(user.ID),
SuccessURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=success", strings.TrimRight(s.cfg.FrontendURL, "/"))),
CancelURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=cancelled", strings.TrimRight(s.cfg.FrontendURL, "/"))),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceID),
Quantity: stripe.Int64(1),
},
},
Metadata: map[string]string{
"user_id": user.ID,
"plan_code": resolvedPlanCode,
"currency": resolvedCurrency,
},
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
TrialPeriodDays: stripe.Int64(30),
Metadata: map[string]string{
"user_id": user.ID,
"plan_code": resolvedPlanCode,
"currency": resolvedCurrency,
},
},
}
checkoutSession, err := session.New(params)
if err != nil {
return CheckoutSession{}, err
}
return CheckoutSession{URL: checkoutSession.URL}, nil
}
func (s *Service) Refresh(ctx context.Context, userID string) (SubscriptionSnapshot, error) {
mapping, ok, err := s.getCustomerMapping(ctx, userID)
if err != nil {
return SubscriptionSnapshot{}, err
}
if !ok {
return s.noneSnapshot(), nil
}
if s.cfg.StripeSecretKey == "" {
return SubscriptionSnapshot{}, ErrStripeNotConfigured
}
return s.syncStripeDataToKV(ctx, mapping.CustomerID)
}
func (s *Service) HandleWebhook(ctx context.Context, signature string, payload []byte) error {
if s.cfg.StripeSecretKey == "" {
return nil
}
if s.cfg.StripeWebhookSecret == "" {
return ErrStripeWebhookMissing
}
if signature == "" {
return ErrStripeSignatureMissing
}
event, err := webhook.ConstructEvent(payload, signature, s.cfg.StripeWebhookSecret)
if err != nil {
return err
}
if !slices.Contains(allowedWebhookEvents, string(event.Type)) {
return nil
}
customerID := extractCustomerID(event)
if customerID == "" {
return nil
}
_, err = s.syncStripeDataToKV(ctx, customerID)
return err
}
func (s *Service) ensureCustomer(ctx context.Context, user UserIdentity) (string, error) {
mapping, ok, err := s.getCustomerMapping(ctx, user.ID)
if err != nil {
return "", err
}
if ok && mapping.CustomerID != "" {
return mapping.CustomerID, nil
}
stripe.Key = s.cfg.StripeSecretKey
params := &stripe.CustomerParams{
Email: stripe.String(user.Email),
Metadata: map[string]string{
"user_id": user.ID,
},
}
if strings.TrimSpace(user.Name) != "" {
params.Name = stripe.String(strings.TrimSpace(user.Name))
}
createdCustomer, err := customer.New(params)
if err != nil {
return "", err
}
if err := s.storeCustomerMapping(ctx, user.ID, createdCustomer.ID); err != nil {
return "", err
}
return createdCustomer.ID, nil
}
func (s *Service) syncStripeDataToKV(ctx context.Context, customerID string) (SubscriptionSnapshot, error) {
stripe.Key = s.cfg.StripeSecretKey
params := &stripe.SubscriptionListParams{Customer: stripe.String(customerID)}
params.Status = stripe.String("all")
params.AddExpand("data.default_payment_method")
params.AddExpand("data.items.data.price")
iter := subscription.List(params)
selected := (*stripe.Subscription)(nil)
for iter.Next() {
current := iter.Subscription()
if selected == nil || subscriptionRank(current) > subscriptionRank(selected) {
selected = current
}
}
if iter.Err() != nil {
return SubscriptionSnapshot{}, iter.Err()
}
now := time.Now().UTC()
snapshot := SubscriptionSnapshot{
CustomerID: customerID,
Status: "none",
LastSyncedAt: &now,
CheckoutURLAvailable: s.cfg.StripeCheckoutReady(),
SyncAvailable: s.cfg.StripeSecretConfigured(),
}
if selected != nil {
snapshot.SubscriptionID = selected.ID
snapshot.Status = string(selected.Status)
snapshot.CancelAtPeriodEnd = selected.CancelAtPeriodEnd
if len(selected.Items.Data) > 0 {
item := selected.Items.Data[0]
if item.Price != nil {
snapshot.PriceID = item.Price.ID
snapshot.PlanCode = s.planCodeForPrice(snapshot.PriceID)
snapshot.Currency = normalizeCurrency(string(item.Price.Currency))
}
snapshot.CurrentPeriodStart = unixPtr(item.CurrentPeriodStart)
snapshot.CurrentPeriodEnd = unixPtr(item.CurrentPeriodEnd)
}
if selected.DefaultPaymentMethod != nil && selected.DefaultPaymentMethod.Card != nil {
snapshot.PaymentMethod = &PaymentMethod{
Brand: string(selected.DefaultPaymentMethod.Card.Brand),
Last4: selected.DefaultPaymentMethod.Card.Last4,
}
}
}
if err := s.db.PutKV(ctx, customerSnapshotKey(customerID), snapshot); err != nil {
return SubscriptionSnapshot{}, err
}
return snapshot, nil
}
func (s *Service) storeCustomerMapping(ctx context.Context, userID string, customerID string) error {
mapping := userCustomerMapping{
CustomerID: customerID,
UpdatedAt: time.Now().UTC(),
}
return s.db.PutKV(ctx, userCustomerKey(userID), mapping)
}
func (s *Service) getCustomerMapping(ctx context.Context, userID string) (userCustomerMapping, bool, error) {
var mapping userCustomerMapping
ok, err := s.db.GetKV(ctx, userCustomerKey(userID), &mapping)
if err != nil {
return userCustomerMapping{}, false, err
}
if !ok || mapping.CustomerID == "" {
return userCustomerMapping{}, false, nil
}
return mapping, true, nil
}
func (s *Service) getCustomerSnapshot(ctx context.Context, customerID string) (SubscriptionSnapshot, bool, error) {
var snapshot SubscriptionSnapshot
ok, err := s.db.GetKV(ctx, customerSnapshotKey(customerID), &snapshot)
if err != nil {
return SubscriptionSnapshot{}, false, err
}
return snapshot, ok, nil
}
func (s *Service) noneSnapshot() SubscriptionSnapshot {
return SubscriptionSnapshot{
Status: "none",
CheckoutURLAvailable: s.cfg.StripeCheckoutReady(),
SyncAvailable: s.cfg.StripeSecretConfigured(),
}
}
func (s *Service) priceForPlan(planCode string, currency string) (string, string, string, error) {
planCode = normalizePlanCode(strings.TrimSpace(planCode))
if planCode == "" {
planCode = s.defaultPlanCode()
}
if planCode == "" {
return "", "", "", ErrPlanNotConfigured
}
resolvedCurrency := normalizeCurrency(currency)
priceID := strings.TrimSpace(s.cfg.StripePriceIDs[planCode+":"+resolvedCurrency])
if priceID == "" && resolvedCurrency != "czk" {
priceID = strings.TrimSpace(s.cfg.StripePriceIDs[planCode+":czk"])
if priceID != "" {
resolvedCurrency = "czk"
}
}
if priceID == "" {
priceID = strings.TrimSpace(s.cfg.StripePriceIDs[planCode])
}
if priceID == "" {
switch planCode {
case "pro":
priceID = strings.TrimSpace(s.cfg.StripePriceIDs["growth"])
case "business":
priceID = strings.TrimSpace(s.cfg.StripePriceIDs["multi-location"])
}
}
if priceID == "" {
return "", "", "", ErrPlanNotConfigured
}
return priceID, planCode, resolvedCurrency, nil
}
func (s *Service) defaultPlanCode() string {
for _, planCode := range []string{"pro", "monthly", "growth", "starter", "business", "multi-location"} {
if strings.TrimSpace(s.cfg.StripePriceIDs[planCode]) != "" {
return normalizePlanCode(planCode)
}
if strings.TrimSpace(s.cfg.StripePriceIDs[normalizePlanCode(planCode)+":czk"]) != "" {
return normalizePlanCode(planCode)
}
}
return ""
}
func (s *Service) planCodeForPrice(priceID string) string {
for planCode, configuredPriceID := range s.cfg.StripePriceIDs {
if strings.TrimSpace(configuredPriceID) == priceID {
return normalizePlanCode(strings.Split(planCode, ":")[0])
}
}
return ""
}
func (s *Service) hasConfiguredPrices() bool {
return s.defaultPlanCode() != ""
}
func (s *Service) checkoutAvailableForPlan(planCode string) bool {
if !s.cfg.StripeSecretConfigured() {
return false
}
if strings.TrimSpace(planCode) == "" {
return s.hasConfiguredPrices()
}
_, _, _, err := s.priceForPlan(planCode, "czk")
return err == nil
}
func normalizePlanCode(planCode string) string {
switch planCode {
case "growth":
return "pro"
case "multi-location":
return "business"
default:
return planCode
}
}
func normalizeCurrency(currency string) string {
switch strings.ToLower(strings.TrimSpace(currency)) {
case "usd":
return "usd"
default:
return "czk"
}
}
func userCustomerKey(userID string) string {
return "stripe:user:" + userID
}
func customerSnapshotKey(customerID string) string {
return "stripe:customer:" + customerID
}
func unixPtr(value int64) *time.Time {
if value == 0 {
return nil
}
t := time.Unix(value, 0).UTC()
return &t
}
func subscriptionRank(subscription *stripe.Subscription) int {
switch subscription.Status {
case stripe.SubscriptionStatusActive:
return 100
case stripe.SubscriptionStatusTrialing:
return 90
case stripe.SubscriptionStatusPastDue:
return 80
case stripe.SubscriptionStatusUnpaid:
return 70
case stripe.SubscriptionStatusIncomplete:
return 60
case stripe.SubscriptionStatusPaused:
return 50
case stripe.SubscriptionStatusCanceled:
return 10
default:
return 0
}
}
func extractCustomerID(event stripe.Event) string {
var payload map[string]any
if err := json.Unmarshal(event.Data.Raw, &payload); err != nil {
return ""
}
value, ok := payload["customer"]
if !ok {
return ""
}
customerID, _ := value.(string)
return customerID
}
@@ -1,75 +0,0 @@
package billing
import (
"errors"
"testing"
"bookra/apps/auth-service/internal/config"
)
func TestPriceForPlanUsesConfiguredPlanCodesOnly(t *testing.T) {
service := NewService(&config.Config{
StripePriceIDs: map[string]string{
"monthly": "price_monthly",
"growth": "price_growth",
},
}, nil)
priceID, planCode, currency, err := service.priceForPlan("growth", "czk")
if err != nil {
t.Fatalf("price for plan: %v", err)
}
if priceID != "price_growth" || planCode != "pro" || currency != "czk" {
t.Fatalf("expected pro mapping, got price=%q plan=%q currency=%q", priceID, planCode, currency)
}
priceID, planCode, currency, err = service.priceForPlan("", "usd")
if err != nil {
t.Fatalf("default price for plan: %v", err)
}
if priceID != "price_monthly" || planCode != "monthly" || currency != "usd" {
t.Fatalf("expected monthly default, got price=%q plan=%q currency=%q", priceID, planCode, currency)
}
_, _, _, err = service.priceForPlan("price_attacker_controlled", "czk")
if !errors.Is(err, ErrPlanNotConfigured) {
t.Fatalf("expected ErrPlanNotConfigured, got %v", err)
}
}
func TestKVKeyShape(t *testing.T) {
if got := userCustomerKey("user_123"); got != "stripe:user:user_123" {
t.Fatalf("unexpected user key: %s", got)
}
if got := customerSnapshotKey("cus_123"); got != "stripe:customer:cus_123" {
t.Fatalf("unexpected customer key: %s", got)
}
}
func TestCheckoutAvailableForPlanRequiresSecret(t *testing.T) {
service := NewService(&config.Config{
StripePriceIDs: map[string]string{
"pro:czk": "price_pro_czk",
},
}, nil)
if service.checkoutAvailableForPlan("pro") {
t.Fatal("expected checkout unavailable without stripe secret")
}
}
func TestCheckoutAvailableForPlanRequiresConfiguredPlan(t *testing.T) {
service := NewService(&config.Config{
StripeSecretKey: "sk_test_123",
StripePriceIDs: map[string]string{
"pro:czk": "price_pro_czk",
},
}, nil)
if !service.checkoutAvailableForPlan("pro") {
t.Fatal("expected pro checkout available")
}
if service.checkoutAvailableForPlan("business") {
t.Fatal("expected business checkout unavailable without configured price")
}
}
-113
View File
@@ -1,113 +0,0 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
)
type Config struct {
AppEnv string
Port string
DatabaseURL string
FrontendURL string
JWTSecret string
NeonAuthURL string
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
EmailFrom string
GoogleClientID string
GoogleClientSecret string
GoogleRedirectURL string
StripeSecretKey string
StripeWebhookSecret string
StripePriceIDs map[string]string
}
func Load() (*Config, error) {
port := getEnv("PORT", "8081")
dbURL := getEnv("DATABASE_URL", "")
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
smtpPort, _ := strconv.Atoi(getEnv("SMTP_PORT", "465"))
return &Config{
AppEnv: getEnv("APP_ENV", "development"),
Port: port,
DatabaseURL: dbURL,
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
NeonAuthURL: getEnv("NEON_AUTH_URL", ""),
SMTPHost: getEnv("SMTP_HOST", "smtp.purelymail.com"),
SMTPPort: smtpPort,
SMTPUsername: getEnvAllowEmpty("SMTP_USERNAME", "noreply@tdvorak.dev"),
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
EmailFrom: getEnv("EMAIL_FROM", "noreply@tdvorak.dev"),
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", ""),
StripeSecretKey: getEnv("STRIPE_SECRET_KEY", ""),
StripeWebhookSecret: getEnv("STRIPE_WEBHOOK_SECRET", ""),
StripePriceIDs: map[string]string{
"monthly": getEnv("STRIPE_PRICE_ID", ""),
"starter": getEnv("STRIPE_STARTER_PRICE_ID", ""),
"growth": getEnv("STRIPE_GROWTH_PRICE_ID", ""),
"multi-location": getEnv("STRIPE_MULTI_LOCATION_PRICE_ID", ""),
"pro": getEnv("STRIPE_PRO_PRICE_ID", ""),
"business": getEnv("STRIPE_BUSINESS_PRICE_ID", ""),
"starter:czk": getEnv("STRIPE_STARTER_CZK_PRICE_ID", ""),
"starter:usd": getEnv("STRIPE_STARTER_USD_PRICE_ID", ""),
"pro:czk": getEnv("STRIPE_PRO_CZK_PRICE_ID", ""),
"pro:usd": getEnv("STRIPE_PRO_USD_PRICE_ID", ""),
"business:czk": getEnv("STRIPE_BUSINESS_CZK_PRICE_ID", ""),
"business:usd": getEnv("STRIPE_BUSINESS_USD_PRICE_ID", ""),
},
}, nil
}
func getEnv(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
func getEnvAllowEmpty(key, defaultVal string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return defaultVal
}
func (cfg *Config) StripeSecretConfigured() bool {
return strings.TrimSpace(cfg.StripeSecretKey) != ""
}
func (cfg *Config) StripeWebhookConfigured() bool {
return strings.TrimSpace(cfg.StripeWebhookSecret) != ""
}
func (cfg *Config) StripeHasAnyPriceConfigured() bool {
for _, priceID := range cfg.StripePriceIDs {
if strings.TrimSpace(priceID) != "" {
return true
}
}
return false
}
func (cfg *Config) StripeCheckoutReady() bool {
return cfg.StripeSecretConfigured() && cfg.StripeHasAnyPriceConfigured()
}
@@ -1,75 +0,0 @@
package config
import (
"os"
"testing"
)
func TestStripeReadinessHelpers(t *testing.T) {
cfg := &Config{
StripeSecretKey: "sk_test_123",
StripePriceIDs: map[string]string{
"pro:czk": "price_123",
},
}
if !cfg.StripeSecretConfigured() {
t.Fatal("expected secret configured")
}
if cfg.StripeWebhookConfigured() {
t.Fatal("expected webhook not configured")
}
if !cfg.StripeHasAnyPriceConfigured() {
t.Fatal("expected prices configured")
}
if !cfg.StripeCheckoutReady() {
t.Fatal("expected checkout ready")
}
}
func TestStripeCheckoutReadyRequiresSecretAndPrice(t *testing.T) {
cfg := &Config{
StripePriceIDs: map[string]string{
"pro:czk": "price_123",
},
}
if cfg.StripeCheckoutReady() {
t.Fatal("expected checkout not ready without secret")
}
cfg.StripeSecretKey = "sk_test_123"
cfg.StripePriceIDs = map[string]string{}
if cfg.StripeCheckoutReady() {
t.Fatal("expected checkout not ready without price")
}
}
func TestLoadDefaultsAuthServicePortTo8081(t *testing.T) {
originals := map[string]string{}
for _, key := range []string{
"PORT",
"DATABASE_URL",
} {
originals[key] = os.Getenv(key)
}
t.Cleanup(func() {
for key, value := range originals {
if value == "" {
_ = os.Unsetenv(key)
continue
}
_ = os.Setenv(key, value)
}
})
_ = os.Unsetenv("PORT")
_ = os.Setenv("DATABASE_URL", "postgresql://localhost/bookra")
cfg, err := Load()
if err != nil {
t.Fatalf("load config: %v", err)
}
if cfg.Port != "8081" {
t.Fatalf("expected default port 8081, got %s", cfg.Port)
}
}
-140
View File
@@ -1,140 +0,0 @@
package db
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type DB struct {
pool *pgxpool.Pool
}
func New(databaseURL string) (*DB, error) {
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse database config: %w", err)
}
pool, err := pgxpool.NewWithConfig(context.Background(), config)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
if err := pool.Ping(context.Background()); err != nil {
return nil, fmt.Errorf("ping database: %w", err)
}
return &DB{pool: pool}, nil
}
func (db *DB) Close() {
db.pool.Close()
}
func (db *DB) Pool() *pgxpool.Pool {
return db.pool
}
func (db *DB) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
return db.pool.QueryRow(ctx, sql, args...)
}
func (db *DB) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
return db.pool.Query(ctx, sql, args...)
}
func (db *DB) Exec(ctx context.Context, sql string, args ...interface{}) error {
_, err := db.pool.Exec(ctx, sql, args...)
return err
}
// Stats contains database statistics for the admin dashboard
type Stats struct {
TotalUsers int64 `json:"totalUsers"`
UsersToday int64 `json:"usersToday"`
UsersThisWeek int64 `json:"usersThisWeek"`
UsersThisMonth int64 `json:"usersThisMonth"`
ActiveUsers7Days int64 `json:"activeUsers7Days"`
ActiveUsers30Days int64 `json:"activeUsers30Days"`
MagicLinksSent int64 `json:"magicLinksSent"`
MagicLinksUsed int64 `json:"magicLinksUsed"`
MagicLinksPending int64 `json:"magicLinksPending"`
OAuthUsers int64 `json:"oauthUsers"`
PasswordUsers int64 `json:"passwordUsers"`
}
// GetStats returns database statistics for the admin dashboard
func (db *DB) GetStats(ctx context.Context) (*Stats, error) {
stats := &Stats{}
// Total users
err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers)
if err != nil {
return nil, err
}
// Users created today
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE`).Scan(&stats.UsersToday)
if err != nil {
return nil, err
}
// Users created this week
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '7 days'`).Scan(&stats.UsersThisWeek)
if err != nil {
return nil, err
}
// Users created this month
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'`).Scan(&stats.UsersThisMonth)
if err != nil {
return nil, err
}
// Active users (logged in) in last 7 days
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE last_login_at >= CURRENT_DATE - INTERVAL '7 days'`).Scan(&stats.ActiveUsers7Days)
if err != nil {
return nil, err
}
// Active users in last 30 days
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE last_login_at >= CURRENT_DATE - INTERVAL '30 days'`).Scan(&stats.ActiveUsers30Days)
if err != nil {
return nil, err
}
// Magic links sent (total)
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM magic_links`).Scan(&stats.MagicLinksSent)
if err != nil {
return nil, err
}
// Magic links used
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM magic_links WHERE used = TRUE`).Scan(&stats.MagicLinksUsed)
if err != nil {
return nil, err
}
// Pending magic links
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM magic_links WHERE used = FALSE AND expires_at > NOW()`).Scan(&stats.MagicLinksPending)
if err != nil {
return nil, err
}
// OAuth users
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE provider != 'email'`).Scan(&stats.OAuthUsers)
if err != nil {
return nil, err
}
// Password users
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE password_hash IS NOT NULL`).Scan(&stats.PasswordUsers)
if err != nil {
return nil, err
}
return stats, nil
}
-224
View File
@@ -1,224 +0,0 @@
package db
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Name *string `json:"name,omitempty"`
PasswordHash *string `json:"-"`
EmailVerified bool `json:"email_verified"`
Provider string `json:"provider"`
ProviderID *string `json:"provider_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
}
type MagicLink struct {
Token string `json:"token"`
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
Used bool `json:"used"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
func (db *DB) GetUserByEmail(ctx context.Context, email string) (*User, error) {
var user User
var name, passwordHash, providerID *string
var lastLoginAt *time.Time
err := db.QueryRow(ctx, `
SELECT id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at, last_login_at
FROM users
WHERE email = $1
`, email).Scan(
&user.ID, &user.Email, &name, &passwordHash,
&user.EmailVerified, &user.Provider, &providerID,
&user.CreatedAt, &user.UpdatedAt, &lastLoginAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user.Name = name
user.PasswordHash = passwordHash
user.ProviderID = providerID
user.LastLoginAt = lastLoginAt
return &user, nil
}
func (db *DB) GetUserByID(ctx context.Context, id uuid.UUID) (*User, error) {
var user User
var name, passwordHash, providerID *string
var lastLoginAt *time.Time
err := db.QueryRow(ctx, `
SELECT id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at, last_login_at
FROM users
WHERE id = $1
`, id).Scan(
&user.ID, &user.Email, &name, &passwordHash,
&user.EmailVerified, &user.Provider, &providerID,
&user.CreatedAt, &user.UpdatedAt, &lastLoginAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user.Name = name
user.PasswordHash = passwordHash
user.ProviderID = providerID
user.LastLoginAt = lastLoginAt
return &user, nil
}
func (db *DB) GetUserByProviderID(ctx context.Context, provider, providerID string) (*User, error) {
var user User
var name, passwordHash *string
var lastLoginAt *time.Time
err := db.QueryRow(ctx, `
SELECT id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at, last_login_at
FROM users
WHERE provider = $1 AND provider_id = $2
`, provider, providerID).Scan(
&user.ID, &user.Email, &name, &passwordHash,
&user.EmailVerified, &user.Provider, &user.ProviderID,
&user.CreatedAt, &user.UpdatedAt, &lastLoginAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user.Name = name
user.PasswordHash = passwordHash
user.LastLoginAt = lastLoginAt
return &user, nil
}
func (db *DB) CreateUser(ctx context.Context, user *User) (*User, error) {
if user.ID == uuid.Nil {
user.ID = uuid.Must(uuid.NewV7())
}
now := time.Now()
user.CreatedAt = now
user.UpdatedAt = now
_, err := db.pool.Exec(ctx, `
INSERT INTO users (id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
`, user.ID, user.Email, user.Name, user.PasswordHash, user.EmailVerified, user.Provider, user.ProviderID, now)
if err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
return user, nil
}
func (db *DB) UpdateUser(ctx context.Context, user *User) error {
user.UpdatedAt = time.Now()
_, err := db.pool.Exec(ctx, `
UPDATE users
SET email = $2, name = $3, password_hash = $4, email_verified = $5,
provider = $6, provider_id = $7, updated_at = $8, last_login_at = $9
WHERE id = $1
`, user.ID, user.Email, user.Name, user.PasswordHash, user.EmailVerified,
user.Provider, user.ProviderID, user.UpdatedAt, user.LastLoginAt)
return err
}
func (db *DB) UpdateLastLogin(ctx context.Context, userID uuid.UUID) error {
_, err := db.pool.Exec(ctx, `
UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1
`, userID)
return err
}
func (db *DB) CreateMagicLink(ctx context.Context, token string, email string, userID uuid.UUID, expiresAt time.Time) error {
_, err := db.pool.Exec(ctx, `
INSERT INTO magic_links (token, user_id, email, expires_at, created_at)
VALUES ($1, $2, $3, $4, NOW())
`, token, userID, email, expiresAt)
return err
}
func (db *DB) GetMagicLink(ctx context.Context, token string) (*MagicLink, error) {
var ml MagicLink
var userID uuid.UUID
err := db.QueryRow(ctx, `
SELECT token, user_id, email, used, expires_at, created_at
FROM magic_links
WHERE token = $1
`, token).Scan(&ml.Token, &userID, &ml.Email, &ml.Used, &ml.ExpiresAt, &ml.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
ml.UserID = userID
return &ml, nil
}
func (db *DB) MarkMagicLinkUsed(ctx context.Context, token string) error {
_, err := db.pool.Exec(ctx, `UPDATE magic_links SET used = true WHERE token = $1`, token)
return err
}
func (db *DB) PutKV(ctx context.Context, key string, value any) error {
payload, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("marshal kv value: %w", err)
}
_, err = db.pool.Exec(ctx, `
INSERT INTO stripe_kv (key, value, created_at, updated_at)
VALUES ($1, $2, NOW(), NOW())
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, updated_at = NOW()
`, key, payload)
return err
}
func (db *DB) GetKV(ctx context.Context, key string, dest any) (bool, error) {
var payload []byte
err := db.QueryRow(ctx, `
SELECT value
FROM stripe_kv
WHERE key = $1
`, key).Scan(&payload)
if err == pgx.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
if err := json.Unmarshal(payload, dest); err != nil {
return false, fmt.Errorf("unmarshal kv value: %w", err)
}
return true, nil
}
@@ -1,77 +0,0 @@
package email
import (
"crypto/tls"
"fmt"
"net/smtp"
"github.com/jordan-wright/email"
)
type Config struct {
Host string
Port int
Username string
Password string
From string
}
type Service struct {
config Config
}
func New(config Config) *Service {
return &Service{config: config}
}
// SendMagicLink sends a magic link authentication email with proper branding
func (s *Service) SendMagicLink(toEmail, toName, linkURL, locale string) error {
template := MagicLinkEmail(toName, linkURL, locale)
return s.sendTemplate(toEmail, template)
}
// SendWelcomeEmail sends a welcome email to new users
func (s *Service) SendWelcomeEmail(toEmail, name, locale string) error {
template := WelcomeEmail(name, locale)
return s.sendTemplate(toEmail, template)
}
// SendBookingConfirmation sends booking confirmation to customers
func (s *Service) SendBookingConfirmation(toEmail, customerName, businessName, serviceName, dateTime, location, locale string) error {
template := BookingConfirmationEmail(customerName, businessName, serviceName, dateTime, location, locale)
return s.sendTemplate(toEmail, template)
}
// SendPasswordReset sends password reset email
func (s *Service) SendPasswordReset(toEmail, name, resetURL, locale string) error {
template := PasswordResetEmail(name, resetURL, locale)
return s.sendTemplate(toEmail, template)
}
// sendTemplate sends an email using the provided template
func (s *Service) sendTemplate(toEmail string, template EmailTemplate) error {
e := email.NewEmail()
e.From = fmt.Sprintf("Bookra <%s>", s.config.From)
e.To = []string{toEmail}
e.Subject = template.Subject
e.Text = []byte(template.Text)
e.HTML = []byte(template.HTML)
return s.send(e)
}
// send delivers the email via SMTP
func (s *Service) send(e *email.Email) error {
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
var auth smtp.Auth
if s.config.Username != "" {
auth = smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
}
if s.config.Port == 465 {
return e.SendWithTLS(addr, auth, &tls.Config{ServerName: s.config.Host})
}
return e.Send(addr, auth)
}
@@ -1,743 +0,0 @@
package email
import (
"fmt"
)
// Bookra Design System - Warm editorial aesthetic
// Canvas: warm cream backgrounds (#fbf9f6)
// Ink: warm dark brown (#2a221e)
// Accent: terracotta (#a65c3e)
// Logo bg: #24201d, Logo text: #f7f2e8
const (
canvas = "#fbf9f6" // Warm cream background
canvasSubtle = "#f5f2ed" // Slightly darker cream
ink = "#2a221e" // Warm dark brown
inkMuted = "#5c514a" // Muted brown
inkSubtle = "#8b7f76" // Light muted brown
accent = "#a65c3e" // Terracotta
accentHover = "#8f4d33" // Darker terracotta
accentSubtle = "#f5ebe7" // Light terracotta tint
logoBg = "#24201d" // Logo dark brown
logoText = "#f7f2e8" // Logo cream
border = "#e8e2da" // Warm border
white = "#ffffff"
)
type EmailTemplate struct {
Subject string
HTML string
Text string
}
func MagicLinkEmail(toName, magicURL string, locale string) EmailTemplate {
if locale == "cs" {
return magicLinkEmailCS(toName, magicURL)
}
return magicLinkEmailEN(toName, magicURL)
}
func WelcomeEmail(name string, locale string) EmailTemplate {
if locale == "cs" {
return welcomeEmailCS(name)
}
return welcomeEmailEN(name)
}
func BookingConfirmationEmail(customerName, businessName, serviceName, dateTime, location string, locale string) EmailTemplate {
if locale == "cs" {
return bookingConfirmationCS(customerName, businessName, serviceName, dateTime, location)
}
return bookingConfirmationEN(customerName, businessName, serviceName, dateTime, location)
}
func PasswordResetEmail(name, resetURL string, locale string) EmailTemplate {
if locale == "cs" {
return passwordResetCS(name, resetURL)
}
return passwordResetEN(name, resetURL)
}
func magicLinkEmailEN(toName, magicURL string) EmailTemplate {
subject := "Your sign-in link for Bookra"
if toName == "" {
toName = "there"
}
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s</title>
<style>
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
.container { max-width: 600px; margin: 0 auto; background: %s; }
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
.tagline { color: %s; font-size: 15px; margin-top: 6px; font-style: italic; }
.content { padding: 48px 40px; }
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
.button-wrap { margin: 40px 0; }
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; transition: background 0.2s; }
.button:hover { background: %s; }
.link-box { background: %s; border: 1px solid %s; border-radius: 8px; padding: 20px; margin: 32px 0; }
.link-label { font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: 'Space Grotesk', sans-serif; }
.link-url { font-size: 14px; color: %s; word-break: break-all; font-family: 'JetBrains Mono', monospace; }
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
.footer-text { font-size: 14px; color: %s; }
.footer-links { margin-top: 12px; }
.footer-links a { color: %s; text-decoration: none; font-size: 13px; margin: 0 12px; font-family: 'Space Grotesk', sans-serif; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
<div class="tagline">Calm booking software</div>
</div>
<div class="content">
<div class="greeting">Hi %s,</div>
<div class="message">
You requested a sign-in link for your Bookra account. Click below to access your account securely — no password needed.
</div>
<div class="button-wrap">
<a href="%s" class="button">Sign In to Bookra</a>
</div>
<div class="link-box">
<div class="link-label">Or copy this link</div>
<div class="link-url">%s</div>
</div>
<div class="expiry">
This link expires in <strong>15 minutes</strong> for security.
</div>
<div class="help">
Didn't request this? You can safely ignore it — someone may have entered your email by mistake.
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
<div class="footer-links">
<a href="https://bookra.tdvorak.dev/privacy">Privacy</a>
<a href="https://bookra.tdvorak.dev/terms">Terms</a>
</div>
</div>
</div>
</body>
</html>`,
subject, canvas, white, canvas, border,
logoBg, logoText, ink, inkSubtle,
ink, inkMuted,
accent, white, accentHover,
canvasSubtle, border, inkSubtle, inkMuted,
accentSubtle, accent, accent,
inkSubtle, border,
canvas, border, inkMuted, inkMuted,
toName, magicURL, magicURL)
text := fmt.Sprintf(`Bookra — Sign-in Link
Hi %s,
Sign in to Bookra (link expires in 15 minutes):
%s
Didn't request this? You can safely ignore this email.
© 2024 Bookra`, toName, magicURL)
return EmailTemplate{Subject: subject, HTML: html, Text: text}
}
func magicLinkEmailCS(toName, magicURL string) EmailTemplate {
subject := "Váš přihlašovací odkaz do Bookra"
if toName == "" {
toName = "vás"
}
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s</title>
<style>
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
.container { max-width: 600px; margin: 0 auto; background: %s; }
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
.tagline { color: %s; font-size: 15px; margin-top: 6px; font-style: italic; }
.content { padding: 48px 40px; }
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
.button-wrap { margin: 40px 0; }
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
.button:hover { background: %s; }
.link-box { background: %s; border: 1px solid %s; border-radius: 8px; padding: 20px; margin: 32px 0; }
.link-label { font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: 'Space Grotesk', sans-serif; }
.link-url { font-size: 14px; color: %s; word-break: break-all; font-family: 'JetBrains Mono', monospace; }
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
.footer-text { font-size: 14px; color: %s; }
.footer-links { margin-top: 12px; }
.footer-links a { color: %s; text-decoration: none; font-size: 13px; margin: 0 12px; font-family: 'Space Grotesk', sans-serif; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
<div class="tagline">Klidný rezervační software</div>
</div>
<div class="content">
<div class="greeting">Dobrý den %s,</div>
<div class="message">
Požádali jste o přihlašovací odkaz k účtu Bookra. Klikněte níže pro bezpečný přístup — heslo není potřeba.
</div>
<div class="button-wrap">
<a href="%s" class="button">Přihlásit se do Bookra</a>
</div>
<div class="link-box">
<div class="link-label">Nebo zkopírujte tento odkaz</div>
<div class="link-url">%s</div>
</div>
<div class="expiry">
Tento odkaz vyprší za <strong>15 minut</strong> z bezpečnostních důvodů.
</div>
<div class="help">
Nepožádali jste o tento email? Můžete ho bezpečně ignorovat.
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
<div class="footer-links">
<a href="https://bookra.tdvorak.dev/privacy">Ochrana soukromí</a>
<a href="https://bookra.tdvorak.dev/terms">Podmínky</a>
</div>
</div>
</div>
</body>
</html>`,
subject, canvas, white, canvas, border,
logoBg, logoText, ink, inkSubtle,
ink, inkMuted,
accent, white, accentHover,
canvasSubtle, border, inkSubtle, inkMuted,
accentSubtle, accent, accent,
inkSubtle, border,
canvas, border, inkMuted, inkMuted,
toName, magicURL, magicURL)
text := fmt.Sprintf(`Bookra — Přihlašovací odkaz
Dobrý den %s,
Přihlaste se do Bookra (odkaz vyprší za 15 minut):
%s
Nepožádali jste o tento email? Můžete ho ignorovat.
© 2024 Bookra`, toName, magicURL)
return EmailTemplate{Subject: subject, HTML: html, Text: text}
}
func welcomeEmailEN(name string) EmailTemplate {
subject := "Welcome to Bookra"
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>%s</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap');
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
.container { max-width: 600px; margin: 0 auto; background: %s; }
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
.content { padding: 48px 40px; }
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 600; color: %s; margin-bottom: 16px; }
.message { font-size: 18px; line-height: 1.6; color: %s; margin-bottom: 32px; }
.features { background: %s; border-radius: 12px; padding: 32px; margin: 32px 0; }
.feature { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.feature:last-child { margin-bottom: 0; }
.feature-icon { width: 24px; height: 24px; background: %s; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: %s; font-size: 14px; flex-shrink: 0; font-family: 'Space Grotesk', sans-serif; }
.feature-text { font-size: 16px; color: %s; line-height: 1.5; }
.button-wrap { margin: 40px 0; text-align: center; }
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
.footer-text { font-size: 14px; color: %s; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
</div>
<div class="content">
<div class="greeting">Welcome, %s</div>
<div class="message">
Thanks for joining Bookra. We're here to help you manage bookings with calm and clarity.
</div>
<div class="features">
<div class="feature">
<div class="feature-icon">✓</div>
<div class="feature-text"><strong>Smart scheduling</strong> — Automatic conflict detection and buffer times</div>
</div>
<div class="feature">
<div class="feature-icon">✓</div>
<div class="feature-text"><strong>Customer insights</strong> — History and preferences at your fingertips</div>
</div>
<div class="feature">
<div class="feature-icon">✓</div>
<div class="feature-text"><strong>Reminders</strong> — Reduce no-shows with gentle notifications</div>
</div>
</div>
<div class="button-wrap">
<a href="https://bookra.tdvorak.dev/dashboard" class="button">Open Dashboard</a>
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>`,
subject, canvas, white, canvas, border,
logoBg, logoText, ink,
ink, inkMuted, canvasSubtle,
accent, white, inkMuted,
accent, white,
canvas, border, inkMuted,
name)
text := fmt.Sprintf(`Welcome to Bookra, %s
Thanks for joining. We're here to help you manage bookings with calm and clarity.
Get started: https://bookra.tdvorak.dev/dashboard
© 2024 Bookra`, name)
return EmailTemplate{Subject: subject, HTML: html, Text: text}
}
func welcomeEmailCS(name string) EmailTemplate {
subject := "Vítejte v Bookra"
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>%s</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap');
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
.container { max-width: 600px; margin: 0 auto; background: %s; }
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
.content { padding: 48px 40px; }
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 600; color: %s; margin-bottom: 16px; }
.message { font-size: 18px; line-height: 1.6; color: %s; margin-bottom: 32px; }
.features { background: %s; border-radius: 12px; padding: 32px; margin: 32px 0; }
.feature { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.feature:last-child { margin-bottom: 0; }
.feature-icon { width: 24px; height: 24px; background: %s; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: %s; font-size: 14px; flex-shrink: 0; }
.feature-text { font-size: 16px; color: %s; line-height: 1.5; }
.button-wrap { margin: 40px 0; text-align: center; }
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
.footer-text { font-size: 14px; color: %s; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
</div>
<div class="content">
<div class="greeting">Vítejte, %s</div>
<div class="message">
Děkujeme za registraci. Pomůžeme vám spravovat rezervace s klidem a přehledem.
</div>
<div class="features">
<div class="feature">
<div class="feature-icon">✓</div>
<div class="feature-text"><strong>Chytré plánování</strong> — Automatická detekce konfliktů</div>
</div>
<div class="feature">
<div class="feature-icon">✓</div>
<div class="feature-text"><strong>Přehled o zákaznících</strong> — Historie a preference</div>
</div>
<div class="feature">
<div class="feature-icon">✓</div>
<div class="feature-text"><strong>Připomenutí</strong> — Méně zapomenutých termínů</div>
</div>
</div>
<div class="button-wrap">
<a href="https://bookra.tdvorak.dev/dashboard" class="button">Otevřít aplikaci</a>
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>`,
subject, canvas, white, canvas, border,
logoBg, logoText, ink,
ink, inkMuted, canvasSubtle,
accent, white, inkMuted,
accent, white,
canvas, border, inkMuted,
name)
text := fmt.Sprintf(`Vítejte v Bookra, %s
Děkujeme za registraci. Pomůžeme vám spravovat rezervace s klidem.
Otevřít aplikaci: https://bookra.tdvorak.dev/dashboard
© 2024 Bookra`, name)
return EmailTemplate{Subject: subject, HTML: html, Text: text}
}
func bookingConfirmationEN(customerName, businessName, serviceName, dateTime, location string) EmailTemplate {
subject := fmt.Sprintf("Confirmed: %s with %s", serviceName, businessName)
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>%s</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap');
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
.container { max-width: 600px; margin: 0 auto; background: %s; }
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
.content { padding: 48px 40px; }
.badge { display: inline-block; background: %s; color: %s; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 24px; font-family: 'Space Grotesk', sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; color: %s; margin-bottom: 8px; }
.message { font-size: 17px; color: %s; margin-bottom: 32px; }
.details { background: %s; border-radius: 12px; padding: 28px; margin: 32px 0; }
.detail-row { display: flex; padding: 14px 0; border-bottom: 1px solid %s; }
.detail-row:last-child { border-bottom: none; }
.detail-label { width: 120px; font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.05em; font-family: 'Space Grotesk', sans-serif; }
.detail-value { flex: 1; font-size: 16px; color: %s; font-weight: 500; }
.help { font-size: 15px; color: %s; margin-top: 32px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
.footer-text { font-size: 14px; color: %s; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
</div>
<div class="content">
<div class="badge">Confirmed</div>
<div class="greeting">Hello %s,</div>
<div class="message">
Your booking with <strong>%s</strong> is confirmed.
</div>
<div class="details">
<div class="detail-row">
<div class="detail-label">Service</div>
<div class="detail-value">%s</div>
</div>
<div class="detail-row">
<div class="detail-label">When</div>
<div class="detail-value">%s</div>
</div>
<div class="detail-row">
<div class="detail-label">Where</div>
<div class="detail-value">%s</div>
</div>
</div>
<div class="help">
Need to reschedule? Contact %s directly.
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>`,
subject, canvas, white, canvas, border,
logoBg, logoText, ink,
accentSubtle, accent, ink, inkMuted,
canvasSubtle, border, inkSubtle, ink,
inkSubtle, border,
canvas, border, inkMuted,
customerName, businessName, serviceName, dateTime, location, businessName)
text := fmt.Sprintf(`Booking Confirmed
Hello %s,
Your booking with %s is confirmed.
Service: %s
When: %s
Where: %s
Need to reschedule? Contact %s.
© 2024 Bookra`,
customerName, businessName, serviceName, dateTime, location, businessName)
return EmailTemplate{Subject: subject, HTML: html, Text: text}
}
func bookingConfirmationCS(customerName, businessName, serviceName, dateTime, location string) EmailTemplate {
subject := fmt.Sprintf("Potvrzeno: %s v %s", serviceName, businessName)
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>%s</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap');
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
.container { max-width: 600px; margin: 0 auto; background: %s; }
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
.content { padding: 48px 40px; }
.badge { display: inline-block; background: %s; color: %s; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 24px; font-family: 'Space Grotesk', sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; color: %s; margin-bottom: 8px; }
.message { font-size: 17px; color: %s; margin-bottom: 32px; }
.details { background: %s; border-radius: 12px; padding: 28px; margin: 32px 0; }
.detail-row { display: flex; padding: 14px 0; border-bottom: 1px solid %s; }
.detail-row:last-child { border-bottom: none; }
.detail-label { width: 120px; font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.05em; font-family: 'Space Grotesk', sans-serif; }
.detail-value { flex: 1; font-size: 16px; color: %s; font-weight: 500; }
.help { font-size: 15px; color: %s; margin-top: 32px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
.footer-text { font-size: 14px; color: %s; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
</div>
<div class="content">
<div class="badge">Potvrzeno</div>
<div class="greeting">Dobrý den %s,</div>
<div class="message">
Vaše rezervace v <strong>%s</strong> je potvrzena.
</div>
<div class="details">
<div class="detail-row">
<div class="detail-label">Služba</div>
<div class="detail-value">%s</div>
</div>
<div class="detail-row">
<div class="detail-label">Termín</div>
<div class="detail-value">%s</div>
</div>
<div class="detail-row">
<div class="detail-label">Místo</div>
<div class="detail-value">%s</div>
</div>
</div>
<div class="help">
Potřebujete přeobjednat? Kontaktujte přímo %s.
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>`,
subject, canvas, white, canvas, border,
logoBg, logoText, ink,
accentSubtle, accent, ink, inkMuted,
canvasSubtle, border, inkSubtle, ink,
inkSubtle, border,
canvas, border, inkMuted,
customerName, businessName, serviceName, dateTime, location, businessName)
text := fmt.Sprintf(`Rezervace potvrzena
Dobrý den %s,
Vaše rezervace v %s je potvrzena.
Služba: %s
Termín: %s
Místo: %s
Potřebujete přeobjednat? Kontaktujte %s.
© 2024 Bookra`,
customerName, businessName, serviceName, dateTime, location, businessName)
return EmailTemplate{Subject: subject, HTML: html, Text: text}
}
func passwordResetEN(name, resetURL string) EmailTemplate {
subject := "Reset your Bookra password"
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>%s</title>
<style>
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
.container { max-width: 600px; margin: 0 auto; background: %s; }
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
.content { padding: 48px 40px; }
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
.button-wrap { margin: 40px 0; }
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
.footer-text { font-size: 14px; color: %s; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
</div>
<div class="content">
<div class="greeting">Hi %s,</div>
<div class="message">
We received a request to reset your password. Click below to choose a new one.
</div>
<div class="button-wrap">
<a href="%s" class="button">Reset Password</a>
</div>
<div class="expiry">
This link expires in <strong>1 hour</strong>.
</div>
<div class="help">
Didn't request this? You can safely ignore it.
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>`,
subject, canvas, white, canvas, border,
logoBg, logoText, ink,
ink, inkMuted,
accent, white,
accentSubtle, accent, accent,
inkSubtle, border,
canvas, border, inkMuted,
name, resetURL)
text := fmt.Sprintf(`Reset Password — Bookra
Hi %s,
Reset your password (expires in 1 hour):
%s
Didn't request this? You can safely ignore it.
© 2024 Bookra`, name, resetURL)
return EmailTemplate{Subject: subject, HTML: html, Text: text}
}
func passwordResetCS(name, resetURL string) EmailTemplate {
subject := "Reset hesla pro Bookra"
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>%s</title>
<style>
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
.container { max-width: 600px; margin: 0 auto; background: %s; }
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
.content { padding: 48px 40px; }
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
.button-wrap { margin: 40px 0; }
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
.footer-text { font-size: 14px; color: %s; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
</div>
<div class="content">
<div class="greeting">Dobrý den %s,</div>
<div class="message">
Obdrželi jsme žádost o reset hesla. Klikněte níže pro nastavení nového.
</div>
<div class="button-wrap">
<a href="%s" class="button">Resetovat heslo</a>
</div>
<div class="expiry">
Tento odkaz vyprší za <strong>1 hodinu</strong>.
</div>
<div class="help">
Nepožádali jste o tento email? Můžete ho bezpečně ignorovat.
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>`,
subject, canvas, white, canvas, border,
logoBg, logoText, ink,
ink, inkMuted,
accent, white,
accentSubtle, accent, accent,
inkSubtle, border,
canvas, border, inkMuted,
name, resetURL)
text := fmt.Sprintf(`Reset hesla — Bookra
Dobrý den %s,
Reset hesla (vyprší za 1 hodinu):
%s
Nepožádali jste o tento email? Můžete ho ignorovat.
© 2024 Bookra`, name, resetURL)
return EmailTemplate{Subject: subject, HTML: html, Text: text}
}
@@ -1,680 +0,0 @@
package handlers
import (
"bookra/apps/auth-service/internal/config"
"bookra/apps/auth-service/internal/db"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// AdminDashboard provides a visual management interface for the auth service
type AdminDashboard struct {
cfg *config.Config
db *db.DB
}
func NewAdminDashboard(cfg *config.Config, database *db.DB) *AdminDashboard {
return &AdminDashboard{cfg: cfg, db: database}
}
// RegisterRoutes registers admin routes
func (a *AdminDashboard) RegisterRoutes(r *gin.Engine) {
admin := r.Group("/admin")
{
admin.GET("", a.RenderDashboard)
admin.GET("/api/config", a.GetConfig)
admin.GET("/api/prices", a.GetPrices)
admin.GET("/api/stats", a.GetStats)
}
}
// GetConfig returns current configuration (sanitized)
func (a *AdminDashboard) GetConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"appEnv": a.cfg.AppEnv,
"port": a.cfg.Port,
"frontendURL": a.cfg.FrontendURL,
"neonAuthURL": a.cfg.NeonAuthURL,
"smtpConfigured": gin.H{
"host": a.cfg.SMTPHost,
"port": a.cfg.SMTPPort,
"from": a.cfg.EmailFrom,
},
"googleOAuthConfigured": a.cfg.GoogleClientID != "",
"stripeConfigured": a.cfg.StripeCheckoutReady(),
"stripeSecretConfigured": a.cfg.StripeSecretConfigured(),
"stripeWebhookConfigured": a.cfg.StripeWebhookConfigured(),
"stripePricesConfigured": a.cfg.StripeHasAnyPriceConfigured(),
})
}
// GetPrices returns configured Stripe prices
func (a *AdminDashboard) GetPrices(c *gin.Context) {
prices := []gin.H{}
planNames := map[string]string{
"starter": "Starter Plan",
"pro": "Pro Plan",
"business": "Business Plan",
"monthly": "Monthly Plan",
"growth": "Growth Plan (Pro alias)",
"multi-location": "Multi-Location (Business alias)",
}
currencies := []string{"czk", "usd"}
for planCode, priceID := range a.cfg.StripePriceIDs {
if strings.TrimSpace(priceID) == "" {
continue
}
// Parse plan:currency format
parts := strings.Split(planCode, ":")
displayName := planNames[planCode]
currency := ""
if len(parts) == 2 {
planCode = parts[0]
currency = parts[1]
displayName = planNames[planCode] + " (" + strings.ToUpper(currency) + ")"
}
if displayName == "" {
displayName = planCode
}
prices = append(prices, gin.H{
"planCode": planCode,
"currency": currency,
"priceID": priceID,
"displayName": displayName,
"configured": true,
})
}
c.JSON(http.StatusOK, gin.H{
"prices": prices,
"currencies": currencies,
"stripeConfigured": a.cfg.StripeCheckoutReady(),
"secretConfigured": a.cfg.StripeSecretConfigured(),
"webhookConfigured": a.cfg.StripeWebhookConfigured(),
"pricesConfigured": len(prices) > 0,
})
}
// GetStats returns database statistics
func (a *AdminDashboard) GetStats(c *gin.Context) {
if a.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
return
}
stats, err := a.db.GetStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load stats: " + err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// RenderDashboard renders the HTML admin dashboard
func (a *AdminDashboard) RenderDashboard(c *gin.Context) {
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, adminHTML)
}
const adminHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bookra Auth Service Admin</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap" rel="stylesheet">
<style>
:root {
--canvas: 40 25% 97%;
--canvas-subtle: 40 20% 94%;
--canvas-muted: 40 15% 89%;
--ink: 25 15% 12%;
--ink-muted: 25 10% 42%;
--ink-subtle: 25 8% 58%;
--accent: 17 55% 42%;
--accent-hover: 17 60% 37%;
--accent-subtle: 17 45% 94%;
--success: 145 45% 38%;
--success-subtle: 145 35% 94%;
--error: 0 60% 52%;
--error-subtle: 0 50% 96%;
--border: 30 12% 86%;
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.06), 0 4px 6px -4px rgb(0 0 0 / 0.04);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Newsreader", Georgia, ui-serif, serif;
background: linear-gradient(180deg, hsl(var(--canvas)) 0%, hsl(var(--canvas-subtle)) 100%);
color: hsl(var(--ink));
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 1280px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
@media (min-width: 640px) {
.container { padding: 2rem; }
}
@media (min-width: 1024px) {
.container { padding: 3rem; }
}
header {
margin-bottom: 3rem;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.logo svg {
width: 32px;
height: 32px;
color: hsl(var(--accent));
}
header h1 {
font-family: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
font-size: clamp(1.75rem, 3vw + 0.5rem, 2.5rem);
font-weight: 600;
letter-spacing: -0.02em;
color: hsl(var(--ink));
}
header p {
font-size: 1.125rem;
color: hsl(var(--ink-muted));
max-width: 600px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: linear-gradient(145deg, hsl(40 25% 98%) 0%, hsl(40 20% 96%) 100%);
border: 1px solid hsl(var(--border));
border-radius: 1rem;
padding: 1.5rem;
box-shadow: var(--shadow-sm);
transition: box-shadow 0.3s ease, transform 0.3s ease;
}
.stat-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.stat-card .icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--accent-subtle));
border-radius: 0.75rem;
margin-bottom: 1rem;
}
.stat-card .icon svg {
width: 20px;
height: 20px;
color: hsl(var(--accent));
}
.stat-card.success .icon { background: hsl(var(--success-subtle)); }
.stat-card.success .icon svg { color: hsl(var(--success)); }
.stat-value {
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
font-size: 1.875rem;
font-weight: 600;
color: hsl(var(--ink));
line-height: 1;
margin-bottom: 0.375rem;
}
.stat-label {
font-size: 0.875rem;
color: hsl(var(--ink-muted));
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: 1.5rem;
}
.card {
background: linear-gradient(145deg, hsl(40 25% 98%) 0%, hsl(40 20% 96%) 100%);
border: 1px solid hsl(var(--border));
border-radius: 1rem;
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid hsl(var(--border));
}
.card-header svg {
width: 20px;
height: 20px;
color: hsl(var(--accent));
}
.card-header h2 {
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
font-size: 1rem;
font-weight: 600;
color: hsl(var(--ink));
}
.card-body {
padding: 1.5rem;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.8125rem;
font-weight: 500;
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
}
.status.active {
background: hsl(var(--success-subtle));
color: hsl(var(--success));
}
.status.inactive {
background: hsl(var(--error-subtle));
color: hsl(var(--error));
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
th, td {
text-align: left;
padding: 0.875rem 0;
border-bottom: 1px solid hsl(var(--border));
}
th {
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
font-weight: 500;
color: hsl(var(--ink-muted));
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
tr:last-child td { border-bottom: none; }
.env-value {
font-family: "JetBrains Mono", ui-monospace, monospace;
background: hsl(var(--canvas-muted));
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
color: hsl(var(--ink));
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
background: hsl(var(--accent-subtle));
color: hsl(var(--accent));
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.875rem 0;
border-bottom: 1px solid hsl(var(--border));
}
.info-row:last-child { border-bottom: none; }
.info-label {
color: hsl(var(--ink-muted));
font-size: 0.9375rem;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 3rem;
color: hsl(var(--ink-subtle));
}
.loading svg {
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error {
background: hsl(var(--error-subtle));
color: hsl(var(--error));
padding: 1rem;
border-radius: 0.75rem;
font-size: 0.875rem;
}
.empty-state {
text-align: center;
padding: 2rem;
color: hsl(var(--ink-muted));
}
.empty-state svg {
width: 48px;
height: 48px;
margin-bottom: 0.75rem;
color: hsl(var(--ink-subtle));
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<h1>Auth Service Admin</h1>
</div>
<p>Monitor users, configure billing plans, and manage service health.</p>
</header>
<div class="stats-grid" id="stats-grid">
<div class="stat-card">
<div class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<div class="stat-value">-</div>
<div class="stat-label">Total Users</div>
</div>
<div class="stat-card">
<div class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<div class="stat-value">-</div>
<div class="stat-label">Active (7d)</div>
</div>
<div class="stat-card">
<div class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</div>
<div class="stat-value">-</div>
<div class="stat-label">Magic Links Sent</div>
</div>
<div class="stat-card success">
<div class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</div>
<div class="stat-value">-</div>
<div class="stat-label">New This Week</div>
</div>
</div>
<div class="grid">
<div class="card">
<div class="card-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
<h2>Service Configuration</h2>
</div>
<div class="card-body" id="config-content">
<div class="loading">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/>
</svg>
Loading configuration...
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="5" width="20" height="14" rx="2"/>
<line x1="2" y1="10" x2="22" y2="10"/>
</svg>
<h2>Billing Plans</h2>
</div>
<div class="card-body" id="prices-content">
<div class="loading">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/>
</svg>
Loading plans...
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
<h2>API Endpoints</h2>
</div>
<div class="card-body">
<table>
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
</tr>
</thead>
<tbody>
<tr><td><span class="badge">POST</span></td><td>/api/auth/magic-link</td></tr>
<tr><td><span class="badge">POST</span></td><td>/api/auth/verify</td></tr>
<tr><td><span class="badge">POST</span></td><td>/api/auth/register</td></tr>
<tr><td><span class="badge">POST</span></td><td>/api/auth/login</td></tr>
<tr><td><span class="badge">GET</span></td><td>/api/auth/me</td></tr>
<tr><td><span class="badge">GET</span></td><td>/api/billing/subscription</td></tr>
<tr><td><span class="badge">POST</span></td><td>/api/billing/checkout</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<h2>Service Overview</h2>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">Authentication</span>
<span>Magic links, JWT, OAuth</span>
</div>
<div class="info-row">
<span class="info-label">Billing</span>
<span>Stripe subscriptions</span>
</div>
<div class="info-row">
<span class="info-label">Database</span>
<span>Neon PostgreSQL</span>
</div>
<div class="info-row">
<span class="info-label">Email</span>
<span>SMTP transactional</span>
</div>
</div>
</div>
</div>
</div>
<script>
// Load stats
fetch('/admin/api/stats')
.then(r => r.json())
.then(data => {
const cards = document.querySelectorAll('.stat-card');
cards[0].querySelector('.stat-value').textContent = data.totalUsers.toLocaleString();
cards[1].querySelector('.stat-value').textContent = data.activeUsers7Days.toLocaleString();
cards[2].querySelector('.stat-value').textContent = data.magicLinksSent.toLocaleString();
cards[3].querySelector('.stat-value').textContent = data.usersThisWeek.toLocaleString();
})
.catch(err => {
document.getElementById('stats-grid').innerHTML =
'<div class="error" style="grid-column: 1/-1;">Failed to load statistics</div>';
});
// Load configuration
fetch('/admin/api/config')
.then(r => r.json())
.then(data => {
let html = '<div class="info-row">' +
'<span class="info-label">Environment</span>' +
'<span class="env-value">' + data.appEnv + '</span>' +
'</div>' +
'<div class="info-row">' +
'<span class="info-label">Port</span>' +
'<span class="env-value">' + data.port + '</span>' +
'</div>' +
'<div class="info-row">' +
'<span class="info-label">Neon Auth</span>' +
'<span class="status ' + (data.neonAuthURL ? 'active' : 'inactive') + '">' +
'<span class="status-dot"></span>' +
(data.neonAuthURL ? 'Configured' : 'Not Configured') +
'</span>' +
'</div>' +
'<div class="info-row">' +
'<span class="info-label">SMTP</span>' +
'<span class="status ' + (data.smtpConfigured.host ? 'active' : 'inactive') + '">' +
'<span class="status-dot"></span>' +
(data.smtpConfigured.host ? data.smtpConfigured.host : 'Not Configured') +
'</span>' +
'</div>' +
'<div class="info-row">' +
'<span class="info-label">Google OAuth</span>' +
'<span class="status ' + (data.googleOAuthConfigured ? 'active' : 'inactive') + '">' +
'<span class="status-dot"></span>' +
(data.googleOAuthConfigured ? 'Enabled' : 'Disabled') +
'</span>' +
'</div>' +
'<div class="info-row">' +
'<span class="info-label">Stripe</span>' +
'<span class="status ' + (data.stripeConfigured ? 'active' : 'inactive') + '">' +
'<span class="status-dot"></span>' +
(data.stripeConfigured ? 'Configured' : 'Not Configured') +
'</span>' +
'</div>';
document.getElementById('config-content').innerHTML = html;
})
.catch(err => {
document.getElementById('config-content').innerHTML =
'<div class="error">Failed to load configuration</div>';
});
// Load prices
fetch('/admin/api/prices')
.then(r => r.json())
.then(data => {
if (!data.prices || data.prices.length === 0) {
document.getElementById('prices-content').innerHTML =
'<div class="empty-state">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>' +
'<p>No Stripe prices configured</p>' +
'</div>';
return;
}
let html = '<table><thead><tr><th>Plan</th><th>Currency</th><th>Status</th></tr></thead><tbody>';
data.prices.forEach(p => {
html += '<tr>' +
'<td>' + p.displayName + '</td>' +
'<td>' + (p.currency ? p.currency.toUpperCase() : 'Default') + '</td>' +
'<td><span class="badge">' + p.priceID.substring(0, 12) + '...</span></td>' +
'</tr>';
});
html += '</tbody></table>';
document.getElementById('prices-content').innerHTML = html;
})
.catch(err => {
document.getElementById('prices-content').innerHTML =
'<div class="error">Failed to load prices</div>';
});
</script>
</body>
</html>`
@@ -1,513 +0,0 @@
package handlers
import (
"bookra/apps/auth-service/internal/auth"
"bookra/apps/auth-service/internal/billing"
"bookra/apps/auth-service/internal/config"
"bookra/apps/auth-service/internal/db"
"bookra/apps/auth-service/internal/email"
"bookra/apps/auth-service/internal/oauth"
"context"
"crypto/rand"
"encoding/base64"
"errors"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type Handler struct {
authSvc *auth.Service
neon *auth.NeonVerifier
billingSvc *billing.Service
google *oauth.GoogleProvider
cfg *config.Config
db *db.DB
}
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Locale string `json:"locale,omitempty"`
}
type VerifyRequest struct {
Token string `json:"token" binding:"required"`
}
type RefreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
type PasswordRegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
Name string `json:"name" binding:"required"`
}
type PasswordLoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
type CheckoutRequest struct {
PlanCode string `json:"planCode,omitempty"`
Currency string `json:"currency,omitempty"`
}
func New(db *db.DB, emailSvc *email.Service, cfg *config.Config) (*Handler, error) {
neonVerifier, err := auth.NewNeonVerifier(cfg.NeonAuthURL)
if err != nil {
return nil, err
}
return &Handler{
authSvc: auth.NewService(db, emailSvc, cfg.JWTSecret, cfg.FrontendURL),
neon: neonVerifier,
billingSvc: billing.NewService(cfg, db),
google: oauth.NewGoogleProvider(cfg),
cfg: cfg,
db: db,
}, nil
}
func (h *Handler) RegisterRoutes(r *gin.Engine) {
// Auth API
api := r.Group("/api/auth")
{
api.POST("/magic-link", h.SendMagicLink)
api.POST("/verify", h.VerifyMagicLink)
api.POST("/register", h.RegisterWithPassword)
api.POST("/login", h.LoginWithPassword)
api.POST("/refresh", h.RefreshToken)
api.GET("/me", h.RequireAuth(), h.GetMe)
api.POST("/logout", h.RequireAuth(), h.Logout)
api.GET("/providers", h.ListProviders)
api.GET("/oauth/google", h.GoogleAuth)
api.GET("/oauth/google/callback", h.GoogleCallback)
}
// Billing API
billingAPI := r.Group("/api/billing")
{
billingAPI.POST("/webhook", h.StripeWebhook)
billingAPI.GET("/subscription", h.RequireAuth(), h.GetSubscription)
billingAPI.POST("/checkout", h.RequireAuth(), h.CreateCheckoutSession)
billingAPI.POST("/refresh", h.RequireAuth(), h.RefreshSubscription)
billingAPI.GET("/plans", h.ListPlans)
}
// Admin Dashboard (Visual Management)
admin := NewAdminDashboard(h.cfg, h.db)
admin.RegisterRoutes(r)
}
func (h *Handler) SendMagicLink(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Detect locale from request: JSON body > Accept-Language header > default "en"
locale := req.Locale
if locale == "" {
locale = detectLocale(c)
}
if err := h.authSvc.GenerateMagicLink(c.Request.Context(), req.Email, locale); err != nil {
log.Printf("magic link failed for %s: %v", req.Email, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send magic link"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Magic link sent to your email"})
}
// detectLocale extracts locale from Accept-Language header
func detectLocale(c *gin.Context) string {
acceptLang := c.GetHeader("Accept-Language")
if strings.HasPrefix(acceptLang, "cs") || strings.Contains(acceptLang, "cs-") {
return "cs"
}
// Default to English
return "en"
}
func (h *Handler) VerifyMagicLink(c *gin.Context) {
var req VerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokens, err := h.authSvc.VerifyMagicLink(c.Request.Context(), req.Token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
return
}
c.JSON(http.StatusOK, tokens)
}
func (h *Handler) RegisterWithPassword(c *gin.Context) {
var req PasswordRegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokens, err := h.authSvc.RegisterWithPassword(c.Request.Context(), req.Email, req.Password, req.Name)
if err != nil {
if strings.Contains(err.Error(), "already registered") {
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"})
return
}
c.JSON(http.StatusCreated, tokens)
}
func (h *Handler) LoginWithPassword(c *gin.Context) {
var req PasswordLoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokens, err := h.authSvc.LoginWithPassword(c.Request.Context(), req.Email, req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
c.JSON(http.StatusOK, tokens)
}
func (h *Handler) RefreshToken(c *gin.Context) {
var req RefreshRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
refreshToken := strings.TrimSpace(req.RefreshToken)
if refreshToken == "" {
authHeader := c.GetHeader("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
refreshToken = strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
}
}
if refreshToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing refresh token"})
return
}
tokens, err := h.authSvc.RefreshTokens(c.Request.Context(), refreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid refresh token"})
return
}
c.JSON(http.StatusOK, tokens)
}
func (h *Handler) GetMe(c *gin.Context) {
claims, exists := c.Get("claims")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims not found"})
return
}
userClaims := claims.(*auth.Claims)
c.JSON(http.StatusOK, gin.H{
"id": userClaims.UserID,
"email": userClaims.Email,
"name": userClaims.Name,
"role": userClaims.Role,
})
}
func (h *Handler) Logout(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
}
func (h *Handler) ListProviders(c *gin.Context) {
providers := []gin.H{}
if h.google.Enabled() {
providers = append(providers, gin.H{
"id": "google",
"name": "Google",
"url": "/api/auth/oauth/google",
})
}
providers = append(providers, gin.H{
"id": "email",
"name": "Email Magic Link",
})
c.JSON(http.StatusOK, gin.H{"providers": providers})
}
func (h *Handler) GoogleAuth(c *gin.Context) {
if !h.google.Enabled() {
c.JSON(http.StatusNotFound, gin.H{"error": "Google OAuth not configured"})
return
}
state := generateState()
url := h.google.GetAuthURL(state)
c.SetCookie("oauth_state", state, 600, "/", "", oauthCookieSecure(c, h.cfg), true)
c.Redirect(http.StatusTemporaryRedirect, url)
}
func (h *Handler) GoogleCallback(c *gin.Context) {
if !h.google.Enabled() {
c.JSON(http.StatusNotFound, gin.H{"error": "Google OAuth not configured"})
return
}
state := c.Query("state")
expectedState, err := c.Cookie("oauth_state")
if err != nil || state == "" || state != expectedState {
c.SetCookie("oauth_state", "", -1, "/", "", oauthCookieSecure(c, h.cfg), true)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid OAuth state"})
return
}
c.SetCookie("oauth_state", "", -1, "/", "", oauthCookieSecure(c, h.cfg), true)
code := c.Query("code")
if code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing code"})
return
}
user, err := h.google.ExchangeCode(c.Request.Context(), code)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "OAuth failed"})
return
}
providerID, email, name := h.google.ParseUser(user)
tokens, err := h.authSvc.OAuthLoginOrCreate(c.Request.Context(), "google", providerID, email, name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process login"})
return
}
redirectURL := h.cfg.FrontendURL + "/auth/callback?token=" + url.QueryEscape(tokens.AccessToken)
if tokens.RefreshToken != "" {
redirectURL += "&refresh_token=" + url.QueryEscape(tokens.RefreshToken)
}
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
}
func (h *Handler) GetSubscription(c *gin.Context) {
claims, ok := h.claimsFromContext(c)
if !ok {
return
}
snapshot, err := h.billingSvc.GetSubscription(c.Request.Context(), claims.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load subscription"})
return
}
c.JSON(http.StatusOK, snapshot)
}
func (h *Handler) CreateCheckoutSession(c *gin.Context) {
claims, ok := h.claimsFromContext(c)
if !ok {
return
}
var req CheckoutRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
response, err := h.billingSvc.CreateCheckoutSession(c.Request.Context(), billing.UserIdentity{
ID: claims.UserID,
Email: claims.Email,
Name: claims.Name,
}, req.PlanCode, req.Currency)
if err != nil {
switch {
case errors.Is(err, billing.ErrPlanNotConfigured):
c.JSON(http.StatusBadRequest, gin.H{"error": "Billing plan is not configured"})
case errors.Is(err, billing.ErrStripeNotConfigured):
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe is not configured"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create checkout session"})
}
return
}
c.JSON(http.StatusOK, response)
}
func (h *Handler) RefreshSubscription(c *gin.Context) {
claims, ok := h.claimsFromContext(c)
if !ok {
return
}
snapshot, err := h.billingSvc.Refresh(c.Request.Context(), claims.UserID)
if err != nil {
if errors.Is(err, billing.ErrStripeNotConfigured) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe is not configured"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to refresh subscription"})
return
}
c.JSON(http.StatusOK, snapshot)
}
// ListPlans returns available billing plans and their configuration status
func (h *Handler) ListPlans(c *gin.Context) {
plans := []gin.H{
{"code": "starter", "name": "Starter", "description": "For individuals and small teams"},
{"code": "pro", "name": "Pro", "description": "For growing businesses"},
{"code": "business", "name": "Business", "description": "For multi-location operations"},
}
// Check which plans are configured
configured := make(map[string]bool)
for planCode, priceID := range h.cfg.StripePriceIDs {
if priceID != "" {
configured[planCode] = true
}
}
for i, plan := range plans {
code := plan["code"].(string)
plan["czkConfigured"] = configured[code+":czk"] || configured[code]
plan["usdConfigured"] = configured[code+":usd"] || configured[code]
plans[i] = plan
}
c.JSON(http.StatusOK, gin.H{
"plans": plans,
"stripeConfigured": h.cfg.StripeCheckoutReady(),
"secretConfigured": h.cfg.StripeSecretConfigured(),
"webhookConfigured": h.cfg.StripeWebhookConfigured(),
"pricesConfigured": h.cfg.StripeHasAnyPriceConfigured(),
"checkoutReady": h.cfg.StripeCheckoutReady(),
"currencies": []string{"czk", "usd"},
})
}
func (h *Handler) StripeWebhook(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
payload, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "Webhook payload is too large"})
return
}
if err := h.billingSvc.HandleWebhook(c.Request.Context(), c.GetHeader("Stripe-Signature"), payload); err != nil {
switch {
case errors.Is(err, billing.ErrStripeWebhookMissing), errors.Is(err, billing.ErrStripeSignatureMissing):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Stripe webhook"})
}
return
}
c.JSON(http.StatusOK, gin.H{"received": true})
}
func (h *Handler) RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
tokenString := ""
if strings.HasPrefix(authHeader, "Bearer ") {
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
}
if tokenString == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing token"})
c.Abort()
return
}
claims, err := h.verifyBearerToken(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort()
return
}
c.Set("claims", claims)
c.Next()
}
}
func (h *Handler) verifyBearerToken(tokenString string) (*auth.Claims, error) {
if h.neon != nil && h.neon.Enabled() {
return h.neon.Verify(tokenString)
}
if h.cfg.AppEnv == "development" {
return h.authSvc.VerifyToken(tokenString)
}
return nil, errors.New("neon auth is not configured")
}
func (h *Handler) claimsFromContext(c *gin.Context) (*auth.Claims, bool) {
claims, exists := c.Get("claims")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims not found"})
return nil, false
}
userClaims, ok := claims.(*auth.Claims)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
return nil, false
}
return userClaims, true
}
func generateState() string {
buffer := make([]byte, 24)
if _, err := rand.Read(buffer); err != nil {
return "state_" + time.Now().Format("20060102150405")
}
return "state_" + strings.TrimRight(base64.URLEncoding.EncodeToString(buffer), "=")
}
func oauthCookieSecure(c *gin.Context, cfg *config.Config) bool {
if c.Request.TLS != nil {
return true
}
if strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
return true
}
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(cfg.FrontendURL)), "https://")
}
func timeoutMiddleware(duration time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), duration)
defer cancel()
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
@@ -1,88 +0,0 @@
package oauth
import (
"bookra/apps/auth-service/internal/config"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
type GoogleUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
VerifiedEmail bool `json:"verified_email"`
}
type GoogleProvider struct {
config *oauth2.Config
}
func NewGoogleProvider(cfg *config.Config) *GoogleProvider {
if cfg.GoogleClientID == "" || cfg.GoogleClientSecret == "" {
return nil
}
redirectURL := cfg.GoogleRedirectURL
if redirectURL == "" {
redirectURL = cfg.FrontendURL + "/auth/oauth/google/callback"
}
return &GoogleProvider{
config: &oauth2.Config{
ClientID: cfg.GoogleClientID,
ClientSecret: cfg.GoogleClientSecret,
RedirectURL: redirectURL,
Scopes: []string{
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
},
Endpoint: google.Endpoint,
},
}
}
func (p *GoogleProvider) Enabled() bool {
return p != nil && p.config != nil
}
func (p *GoogleProvider) GetAuthURL(state string) string {
return p.config.AuthCodeURL(state, oauth2.AccessTypeOnline)
}
func (p *GoogleProvider) ExchangeCode(ctx context.Context, code string) (*GoogleUser, error) {
token, err := p.config.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("exchange code: %w", err)
}
client := p.config.Client(ctx, token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
if err != nil {
return nil, fmt.Errorf("fetch userinfo: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("userinfo returned %d: %s", resp.StatusCode, string(body))
}
var user GoogleUser
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("decode userinfo: %w", err)
}
return &user, nil
}
func (p *GoogleProvider) ParseUser(user *GoogleUser) (providerID, email, name string) {
return user.ID, user.Email, user.Name
}
@@ -1,40 +0,0 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255),
password_hash VARCHAR(255),
email_verified BOOLEAN DEFAULT FALSE,
provider VARCHAR(50) NOT NULL DEFAULT 'email',
provider_id VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_login_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_provider ON users(provider, provider_id);
CREATE TABLE IF NOT EXISTS magic_links (
token VARCHAR(255) PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
used BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_magic_links_user_id ON magic_links(user_id);
CREATE INDEX IF NOT EXISTS idx_magic_links_expires ON magic_links(expires_at) WHERE used = FALSE;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS magic_links;
DROP TABLE IF EXISTS users;
-- +goose StatementEnd
@@ -1,21 +0,0 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS stripe_kv (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_stripe_kv_updated_at ON stripe_kv(updated_at);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_stripe_kv_updated_at;
DROP TABLE IF EXISTS stripe_kv;
-- +goose StatementEnd
-14
View File
@@ -1,14 +0,0 @@
{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "DOCKERFILE",
"dockerfilePath": "Dockerfile"
},
"deploy": {
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 10,
"healthcheckPath": "/health",
"healthcheckTimeout": 30,
"numReplicas": 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

-407
View File
@@ -1,407 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bookra Email Templates Preview</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; }
body {
font-family: 'Newsreader', Georgia, serif;
background: #fbf9f6;
margin: 0;
padding: 40px 20px;
color: #2a221e;
}
.container { max-width: 1400px; margin: 0 auto; }
h1 {
font-family: 'Space Grotesk', sans-serif;
text-align: center;
color: #2a221e;
margin-bottom: 8px;
font-size: 32px;
font-weight: 600;
letter-spacing: -0.02em;
}
.subtitle {
text-align: center;
color: #5c514a;
margin-bottom: 48px;
font-size: 17px;
font-style: italic;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(600px, 1fr));
gap: 32px;
}
.card {
background: white;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(42, 34, 30, 0.05);
border: 1px solid #e8e2da;
}
.card-header {
background: #fbf9f6;
padding: 24px 28px;
border-bottom: 1px solid #e8e2da;
}
.card-header h2 {
margin: 0;
font-family: 'Space Grotesk', sans-serif;
font-size: 16px;
font-weight: 600;
color: #2a221e;
}
.card-header p {
margin: 4px 0 0;
color: #5c514a;
font-size: 14px;
font-style: italic;
}
.card-body { padding: 0; }
iframe {
width: 100%;
height: 500px;
border: none;
background: white;
}
.toggle {
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 40px;
}
.toggle button {
padding: 12px 28px;
border-radius: 8px;
border: 1px solid #e8e2da;
cursor: pointer;
font-family: 'Space Grotesk', sans-serif;
font-weight: 500;
font-size: 14px;
transition: all 0.2s;
background: white;
color: #5c514a;
}
.toggle button.active {
background: #a65c3e;
color: white;
border-color: #a65c3e;
}
.toggle button:not(.active):hover {
background: #f5f2ed;
}
</style>
</head>
<body>
<div class="container">
<h1>Bookra Email Templates</h1>
<p class="subtitle">Warm editorial aesthetic with terracotta accents</p>
<div class="toggle">
<button class="active" onclick="showLang('en')">English</button>
<button onclick="showLang('cs')">Čeština</button>
</div>
<div class="grid" id="emailGrid">
<!-- Magic Link EN -->
<div class="card" data-lang="en">
<div class="card-header">
<h2>Magic Link</h2>
<p>Passwordless authentication</p>
</div>
<div class="card-body">
<iframe srcdoc='
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
<style>
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
.container { max-width: 600px; margin: 0 auto; background: white; }
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
.tagline { color: #8b7f76; font-size: 15px; margin-top: 6px; font-style: italic; }
.content { padding: 48px 40px; }
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 20px; font-weight: 600; color: #2a221e; margin-bottom: 16px; }
.message { font-size: 17px; line-height: 1.6; color: #5c514a; margin-bottom: 32px; }
.button-wrap { margin: 40px 0; }
.button { display: inline-block; background: #a65c3e; color: white; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: "Space Grotesk", sans-serif; font-weight: 500; font-size: 15px; }
.link-box { background: #f5f2ed; border: 1px solid #e8e2da; border-radius: 8px; padding: 20px; margin: 32px 0; }
.link-label { font-size: 12px; font-weight: 600; color: #8b7f76; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: "Space Grotesk", sans-serif; }
.link-url { font-size: 14px; color: #5c514a; word-break: break-all; font-family: monospace; }
.expiry { background: #f5ebe7; border-left: 3px solid #a65c3e; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: #a65c3e; }
.help { font-size: 15px; color: #8b7f76; margin-top: 40px; padding-top: 32px; border-top: 1px solid #e8e2da; font-style: italic; }
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
.footer-text { font-size: 14px; color: #8b7f76; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
<div class="tagline">Calm booking software</div>
</div>
<div class="content">
<div class="greeting">Hi Sarah,</div>
<div class="message">
You requested a sign-in link for your Bookra account. Click below to access your account securely — no password needed.
</div>
<div class="button-wrap">
<a href="#" class="button">Sign In to Bookra</a>
</div>
<div class="link-box">
<div class="link-label">Or copy this link</div>
<div class="link-url">https://bookra.tdvorak.dev/auth/callback?token=xyz123...</div>
</div>
<div class="expiry">
This link expires in <strong>15 minutes</strong> for security.
</div>
<div class="help">
Didn&apos;t request this? You can safely ignore it.
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>'>
</iframe>
</div>
</div>
<!-- Welcome EN -->
<div class="card" data-lang="en">
<div class="card-header">
<h2>Welcome Email</h2>
<p>New user onboarding</p>
</div>
<div class="card-body">
<iframe srcdoc='
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
<style>
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
.container { max-width: 600px; margin: 0 auto; background: white; }
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
.content { padding: 48px 40px; }
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 600; color: #2a221e; margin-bottom: 16px; }
.message { font-size: 18px; line-height: 1.6; color: #5c514a; margin-bottom: 32px; }
.features { background: #f5f2ed; border-radius: 12px; padding: 32px; margin: 32px 0; }
.feature { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.feature:last-child { margin-bottom: 0; }
.feature-icon { width: 24px; height: 24px; background: #a65c3e; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: white; font-size: 14px; flex-shrink: 0; }
.feature-text { font-size: 16px; color: #5c514a; line-height: 1.5; }
.button-wrap { margin: 40px 0; text-align: center; }
.button { display: inline-block; background: #a65c3e; color: white; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: "Space Grotesk", sans-serif; font-weight: 500; font-size: 15px; }
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
.footer-text { font-size: 14px; color: #8b7f76; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
</div>
<div class="content">
<div class="greeting">Welcome, Sarah</div>
<div class="message">
Thanks for joining Bookra. We&apos;re here to help you manage bookings with calm and clarity.
</div>
<div class="features">
<div class="feature">
<div class="feature-icon">✓</div>
<div class="feature-text"><strong>Smart scheduling</strong> — Automatic conflict detection</div>
</div>
<div class="feature">
<div class="feature-icon">✓</div>
<div class="feature-text"><strong>Customer insights</strong> — History and preferences</div>
</div>
<div class="feature">
<div class="feature-icon">✓</div>
<div class="feature-text"><strong>Reminders</strong> — Reduce no-shows</div>
</div>
</div>
<div class="button-wrap">
<a href="#" class="button">Open Dashboard</a>
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>'>
</iframe>
</div>
</div>
<!-- Booking Confirmation EN -->
<div class="card" data-lang="en">
<div class="card-header">
<h2>Booking Confirmation</h2>
<p>Customer confirmation email</p>
</div>
<div class="card-body">
<iframe srcdoc='
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
<style>
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
.container { max-width: 600px; margin: 0 auto; background: white; }
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
.content { padding: 48px 40px; }
.badge { display: inline-block; background: #f5ebe7; color: #a65c3e; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 24px; font-family: "Space Grotesk", sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; color: #2a221e; margin-bottom: 8px; }
.message { font-size: 17px; color: #5c514a; margin-bottom: 32px; }
.details { background: #f5f2ed; border-radius: 12px; padding: 28px; margin: 32px 0; }
.detail-row { display: flex; padding: 14px 0; border-bottom: 1px solid #e8e2da; }
.detail-row:last-child { border-bottom: none; }
.detail-label { width: 120px; font-size: 12px; font-weight: 600; color: #8b7f76; text-transform: uppercase; letter-spacing: 0.05em; font-family: "Space Grotesk", sans-serif; }
.detail-value { flex: 1; font-size: 16px; color: #2a221e; font-weight: 500; }
.help { font-size: 15px; color: #8b7f76; margin-top: 32px; padding-top: 32px; border-top: 1px solid #e8e2da; font-style: italic; }
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
.footer-text { font-size: 14px; color: #8b7f76; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
</div>
<div class="content">
<div class="badge">Confirmed</div>
<div class="greeting">Hello Sarah,</div>
<div class="message">
Your booking with <strong>Studio Ella</strong> is confirmed.
</div>
<div class="details">
<div class="detail-row">
<div class="detail-label">Service</div>
<div class="detail-value">Haircut &amp; Styling</div>
</div>
<div class="detail-row">
<div class="detail-label">When</div>
<div class="detail-value">Monday, April 22 at 2:00 PM</div>
</div>
<div class="detail-row">
<div class="detail-label">Where</div>
<div class="detail-value">123 Main Street, Prague 1</div>
</div>
</div>
<div class="help">
Need to reschedule? Contact Studio Ella directly.
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>'>
</iframe>
</div>
</div>
<!-- Magic Link CS -->
<div class="card" data-lang="cs" style="display:none">
<div class="card-header">
<h2>Magický Odkaz (CZ)</h2>
<p>Přihlášení bez hesla</p>
</div>
<div class="card-body">
<iframe srcdoc='
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
<style>
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
.container { max-width: 600px; margin: 0 auto; background: white; }
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
.tagline { color: #8b7f76; font-size: 15px; margin-top: 6px; font-style: italic; }
.content { padding: 48px 40px; }
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 20px; font-weight: 600; color: #2a221e; margin-bottom: 16px; }
.message { font-size: 17px; line-height: 1.6; color: #5c514a; margin-bottom: 32px; }
.button-wrap { margin: 40px 0; }
.button { display: inline-block; background: #a65c3e; color: white; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: "Space Grotesk", sans-serif; font-weight: 500; font-size: 15px; }
.link-box { background: #f5f2ed; border: 1px solid #e8e2da; border-radius: 8px; padding: 20px; margin: 32px 0; }
.link-label { font-size: 12px; font-weight: 600; color: #8b7f76; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: "Space Grotesk", sans-serif; }
.link-url { font-size: 14px; color: #5c514a; word-break: break-all; font-family: monospace; }
.expiry { background: #f5ebe7; border-left: 3px solid #a65c3e; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: #a65c3e; }
.help { font-size: 15px; color: #8b7f76; margin-top: 40px; padding-top: 32px; border-top: 1px solid #e8e2da; font-style: italic; }
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
.footer-text { font-size: 14px; color: #8b7f76; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
<div class="tagline">Klidný rezervační software</div>
</div>
<div class="content">
<div class="greeting">Dobrý den Martino,</div>
<div class="message">
Požádali jste o přihlašovací odkaz k účtu Bookra. Klikněte níže pro bezpečný přístup — heslo není potřeba.
</div>
<div class="button-wrap">
<a href="#" class="button">Přihlásit se do Bookra</a>
</div>
<div class="link-box">
<div class="link-label">Nebo zkopírujte tento odkaz</div>
<div class="link-url">https://bookra.tdvorak.dev/auth/callback?token=xyz123...</div>
</div>
<div class="expiry">
Tento odkaz vyprší za <strong>15 minut</strong> z bezpečnostních důvodů.
</div>
<div class="help">
Nepožádali jste o tento email? Můžete ho bezpečně ignorovat.
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>'>
</iframe>
</div>
</div>
</div>
</div>
<script>
function showLang(lang) {
document.querySelectorAll(".toggle button").forEach(btn => btn.classList.remove("active"));
event.target.classList.add("active");
document.querySelectorAll("[data-lang]").forEach(card => {
card.style.display = card.dataset.lang === lang ? "block" : "none";
});
}
</script>
</body>
</html>
+26
View File
@@ -11,14 +11,37 @@ import (
"bookra/apps/backend/internal/api"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
sentry "github.com/getsentry/sentry-go"
)
func initSentry(cfg config.Config) {
if cfg.SentryDSN == "" {
log.Println("Sentry DSN not configured - skipping initialization")
return
}
err := sentry.Init(sentry.ClientOptions{
Dsn: cfg.SentryDSN,
Environment: cfg.Environment,
Release: "bookra@1.0.0",
// Set TracesSampleRate to 1.0 to capture 100% of transactions for testing
TracesSampleRate: 1.0,
})
if err != nil {
log.Fatalf("Sentry initialization failed: %v", err)
}
log.Println("Sentry initialized")
}
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("load config: %v", err)
}
initSentry(cfg)
pools, err := db.NewPools(cfg)
if err != nil {
log.Fatalf("create database pools: %v", err)
@@ -31,6 +54,9 @@ func main() {
}
defer server.Close()
// Start background job for trial ending emails
go server.StartBackgroundJobs()
httpServer := &http.Server{
Addr: ":" + cfg.Port,
Handler: server.Handler(),
+3 -1
View File
@@ -3,13 +3,14 @@ module bookra/apps/backend
go 1.26.2
require (
github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0
github.com/MicahParks/keyfunc/v3 v3.8.0
github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0
github.com/gin-contrib/cors v1.7.7
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.1
github.com/stripe/stripe-go/v81 v81.0.0
golang.org/x/time v0.9.0
)
@@ -20,6 +21,7 @@ require (
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/getsentry/sentry-go v0.46.2 // indirect
github.com/ggicci/httpin v0.20.3 // indirect
github.com/ggicci/owl v0.8.2 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
+12
View File
@@ -17,6 +17,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns=
github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
github.com/ggicci/httpin v0.20.3 h1:qy93bUsF/eGbX8WJfXEjB3bjgUrv7MKZ3qVWR73DIGY=
github.com/ggicci/httpin v0.20.3/go.mod h1:ppHGT8xt99mRnDUuehLLWl2RAVLKG+VGn48GjK5xaLA=
github.com/ggicci/owl v0.8.2 h1:og+lhqpzSMPDdEB+NJfzoAJARP7qCG3f8uUC3xvGukA=
@@ -58,6 +60,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
@@ -89,6 +93,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v81 v81.0.0 h1:7xqKVXIjhFoSEUzXXPON7oYFRupOyhDG5R7tRVyrgeE=
github.com/stripe/stripe-go/v81 v81.0.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
@@ -101,17 +107,23 @@ golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+243
View File
@@ -0,0 +1,243 @@
package admin
import (
"context"
"crypto/subtle"
"errors"
"net/http"
"strings"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"github.com/gin-gonic/gin"
)
var (
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden: admin access required")
ErrInvalidAdminCreds = errors.New("invalid admin credentials")
)
type Service struct {
repo db.Repository
adminEmail string
adminKey string
}
func NewService(repo db.Repository, adminEmail, adminKey string) *Service {
return &Service{
repo: repo,
adminEmail: adminEmail,
adminKey: adminKey,
}
}
// IsConfigured returns true if admin credentials are set
func (s *Service) IsConfigured() bool {
return s.adminEmail != "" && s.adminKey != ""
}
// ValidateAdminLogin checks if the provided credentials match the admin credentials
// Uses constant-time comparison to prevent timing attacks
func (s *Service) ValidateAdminLogin(email, key string) bool {
if !s.IsConfigured() {
return false
}
emailMatch := subtle.ConstantTimeCompare([]byte(email), []byte(s.adminEmail)) == 1
keyMatch := subtle.ConstantTimeCompare([]byte(key), []byte(s.adminKey)) == 1
return emailMatch && keyMatch
}
// RequireAdmin is middleware that checks for admin authentication
// It supports two modes:
// 1. Admin credentials via X-Admin-Email and X-Admin-Key headers (for API access)
// 2. Session-based auth where the user has role "admin" or "superadmin"
func RequireAdmin(adminSvc *Service, authSvc interface{ IsAdmin(ctx context.Context, userID string) (bool, error) }) gin.HandlerFunc {
return func(c *gin.Context) {
// Check for admin header credentials (direct admin login)
adminEmail := c.GetHeader("X-Admin-Email")
adminKey := c.GetHeader("X-Admin-Key")
if adminEmail != "" && adminKey != "" {
if adminSvc.ValidateAdminLogin(adminEmail, adminKey) {
c.Set("isAdmin", true)
c.Set("adminMode", "credentials")
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin credentials"})
return
}
// Check for Bearer token with admin role
auth := c.GetHeader("Authorization")
if auth != "" && strings.HasPrefix(auth, "Bearer ") {
// The auth middleware should have already validated the token
// and set the user info in context
userID, exists := c.Get("userID")
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
isAdmin, err := authSvc.IsAdmin(c.Request.Context(), userID.(string))
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin status"})
return
}
if isAdmin {
c.Set("isAdmin", true)
c.Set("adminMode", "session")
c.Next()
return
}
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
}
}
// GetDashboardStats returns platform-wide statistics for admin dashboard
func (s *Service) GetDashboardStats(ctx context.Context) (domain.AdminDashboardStats, error) {
stats, err := s.repo.GetPlatformStats(ctx)
if err != nil {
return domain.AdminDashboardStats{}, err
}
return domain.AdminDashboardStats{
TotalTenants: stats.TotalTenants,
TotalUsers: stats.TotalUsers,
ActiveSubscriptions: stats.ActiveSubscriptions,
TrialSubscriptions: stats.TrialSubscriptions,
BookingsThisMonth: stats.BookingsThisMonth,
RevenueThisMonthCents: stats.RevenueThisMonth,
}, nil
}
// ListTenants returns paginated list of all tenants
func (s *Service) ListTenants(ctx context.Context, page, pageSize int) (domain.AdminTenantList, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
offset := (page - 1) * pageSize
tenants, total, err := s.repo.ListAllTenants(ctx, pageSize, offset)
if err != nil {
return domain.AdminTenantList{}, err
}
result := domain.AdminTenantList{
Total: total,
Page: page,
PageSize: pageSize,
Tenants: make([]domain.AdminTenant, len(tenants)),
}
for i, t := range tenants {
result.Tenants[i] = domain.AdminTenant{
ID: t.ID,
Slug: t.Slug,
Name: t.Name,
PlanCode: t.PlanCode,
SubscriptionStatus: t.SubscriptionStatus,
BillingProvider: t.BillingProvider,
}
}
return result, nil
}
// ListUsers returns paginated list of all users
func (s *Service) ListUsers(ctx context.Context, page, pageSize int) (domain.AdminUserList, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
offset := (page - 1) * pageSize
users, total, err := s.repo.ListAllUsers(ctx, pageSize, offset)
if err != nil {
return domain.AdminUserList{}, err
}
result := domain.AdminUserList{
Total: total,
Page: page,
PageSize: pageSize,
Users: make([]domain.AdminUser, len(users)),
}
for i, u := range users {
result.Users[i] = domain.AdminUser{
ID: u.ID.String(),
Email: u.Email,
Name: stringPtrToStr(u.Name),
EmailVerified: u.EmailVerified,
Provider: u.Provider,
Role: u.Role,
CreatedAt: u.CreatedAt,
}
}
return result, nil
}
// UpdateUserRole changes a user's role
func (s *Service) UpdateUserRole(ctx context.Context, adminUserID, targetUserID, newRole string, ip, userAgent string) error {
// Validate role
validRoles := map[string]bool{
"user": true,
"admin": true,
"superadmin": true,
}
if !validRoles[newRole] {
return errors.New("invalid role")
}
if err := s.repo.UpdateUserRole(ctx, targetUserID, newRole); err != nil {
return err
}
// Log the action
return s.repo.CreateAdminAuditLog(ctx, db.AdminAuditLogParams{
AdminUserID: adminUserID,
Action: "update_user_role",
ResourceType: "user",
ResourceID: targetUserID,
Details: map[string]any{
"newRole": newRole,
},
IPAddress: ip,
UserAgent: userAgent,
})
}
// SyncTenantSubscription manually syncs a tenant's subscription from Stripe
func (s *Service) SyncTenantSubscription(ctx context.Context, tenantID string) error {
// This will be called from the billing service
return nil
}
func stringPtrToStr(s *string) string {
if s == nil {
return ""
}
return *s
}
func init() {
// Ensure time package is imported
_ = time.Now()
}
+455 -14
View File
@@ -1,12 +1,16 @@
package api
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"time"
"bookra/apps/backend/internal/admin"
"bookra/apps/backend/internal/auth"
"bookra/apps/backend/internal/billing"
"bookra/apps/backend/internal/bookings"
@@ -16,18 +20,25 @@ import (
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/httpx"
"bookra/apps/backend/internal/notifications"
"bookra/apps/backend/internal/sms"
"bookra/apps/backend/internal/tenancy"
"github.com/getsentry/sentry-go"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
type Server struct {
router *gin.Engine
cfg config.Config
pools *db.Pools
verifier *auth.Verifier
router *gin.Engine
cfg config.Config
pools *db.Pools
verifier *auth.Verifier
authService *auth.Service
adminService *admin.Service
billingService *billing.Service
notificationService *notifications.Service
smsService *sms.Service
}
func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
@@ -41,15 +52,23 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
bookingService := bookings.NewService(repository, notificationService)
customerBookingService := bookings.NewCustomerService(repository, notificationService)
tenantService := tenancy.NewService(repository)
catalogService := catalog.NewService(repository)
billingService := billing.NewService(cfg, repository)
catalogService := catalog.NewService(repository, billingService, notificationService)
authService := auth.NewService(repository, cfg.AuthJWTSecret)
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
smsService := sms.NewService(cfg, repository)
publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5)
server := &Server{
router: gin.New(),
cfg: cfg,
pools: pools,
verifier: verifier,
router: gin.New(),
cfg: cfg,
pools: pools,
verifier: verifier,
authService: authService,
adminService: adminService,
billingService: billingService,
notificationService: notificationService,
smsService: smsService,
}
server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{
@@ -67,15 +86,241 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
})
})
// Test endpoint for Sentry
server.router.GET("/debug/sentry", func(c *gin.Context) {
sentry.CaptureMessage("Test message from Bookra API")
c.JSON(http.StatusOK, gin.H{"status": "sent", "message": "Test error sent to Sentry"})
})
// Test endpoint for billing
server.router.GET("/debug/billing-test", func(c *gin.Context) {
if billingService != nil {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"billingService": "initialized",
"message": "Billing service is working",
})
} else {
c.JSON(http.StatusOK, gin.H{"status": "ok", "billingService": "not initialized"})
}
})
server.router.GET("/v1/meta/config", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"environment": cfg.Environment,
"neonAuthEnabled": verifier.Enabled(),
"apiUrl": cfg.APIURL,
"demoMode": cfg.DemoMode,
"environment": cfg.Environment,
"neonAuthEnabled": verifier.Enabled(),
"apiUrl": cfg.APIURL,
"demoMode": cfg.DemoMode,
"adminLoginEnabled": adminService.IsConfigured(),
})
})
// ============================================
// AUTH API
// ============================================
authGroup := server.router.Group("/v1/auth")
{
authGroup.POST("/register", func(c *gin.Context) {
var request struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
user, tokens, err := authService.RegisterWithPassword(c.Request.Context(), request.Email, request.Password, request.Name)
if err != nil {
if errors.Is(err, auth.ErrEmailAlreadyExists) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
if errors.Is(err, auth.ErrPasswordTooShort) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "registration_failed"})
return
}
c.JSON(http.StatusCreated, gin.H{
"user": gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
},
"accessToken": tokens.AccessToken,
"refreshToken": tokens.RefreshToken,
"tokenType": tokens.TokenType,
"expiresIn": tokens.ExpiresIn,
})
})
authGroup.POST("/login", func(c *gin.Context) {
var request struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
user, tokens, err := authService.LoginWithPassword(c.Request.Context(), request.Email, request.Password)
if err != nil {
if errors.Is(err, auth.ErrInvalidCredentials) {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "login_failed"})
return
}
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
},
"accessToken": tokens.AccessToken,
"refreshToken": tokens.RefreshToken,
"tokenType": tokens.TokenType,
"expiresIn": tokens.ExpiresIn,
})
})
authGroup.POST("/magic-link", func(c *gin.Context) {
var request struct {
Email string `json:"email" binding:"required,email"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
_, err := authService.CreateMagicLink(c.Request.Context(), request.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "magic_link_failed"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "magic_link_sent"})
})
authGroup.POST("/verify", func(c *gin.Context) {
var request struct {
Token string `json:"token" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
user, tokens, err := authService.VerifyMagicLink(c.Request.Context(), request.Token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
},
"accessToken": tokens.AccessToken,
"refreshToken": tokens.RefreshToken,
"tokenType": tokens.TokenType,
"expiresIn": tokens.ExpiresIn,
})
})
authGroup.POST("/refresh", func(c *gin.Context) {
var request struct {
RefreshToken string `json:"refreshToken" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
tokens, err := authService.RefreshToken(c.Request.Context(), request.RefreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"accessToken": tokens.AccessToken,
"refreshToken": tokens.RefreshToken,
"tokenType": tokens.TokenType,
"expiresIn": tokens.ExpiresIn,
})
})
}
// ============================================
// ADMIN API
// ============================================
adminGroup := server.router.Group("/v1/admin")
adminGroup.Use(admin.RequireAdmin(adminService, authService))
{
adminGroup.GET("/stats", func(c *gin.Context) {
stats, err := adminService.GetDashboardStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
})
adminGroup.GET("/tenants", func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
result, err := adminService.ListTenants(c.Request.Context(), page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
})
adminGroup.GET("/users", func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
result, err := adminService.ListUsers(c.Request.Context(), page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
})
adminGroup.PUT("/users/:userID/role", func(c *gin.Context) {
var request domain.UpdateUserRoleRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
adminUserID, _ := c.Get("userID")
err := adminService.UpdateUserRole(
c.Request.Context(),
adminUserID.(string),
c.Param("userID"),
request.Role,
c.ClientIP(),
c.GetHeader("User-Agent"),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "updated"})
})
// Trigger trial ending email check
adminGroup.POST("/trigger-trial-emails", func(c *gin.Context) {
err := billingService.CheckAndSendTrialEndingEmails(c.Request.Context(), notificationService)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "completed", "message": "Trial ending emails sent"})
})
}
server.router.GET("/v1/public/tenants/:tenantSlug/availability", func(c *gin.Context) {
response, err := bookingService.Availability(c.Request.Context(), c.Param("tenantSlug"))
if err != nil {
@@ -99,6 +344,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
LocationID *string `json:"locationId"`
CustomerName string `json:"customerName" binding:"required"`
CustomerEmail string `json:"customerEmail" binding:"required,email"`
CustomerPhone string `json:"customerPhone"`
Notes string `json:"notes"`
StartsAt string `json:"startsAt" binding:"required"`
EndsAt string `json:"endsAt" binding:"required"`
@@ -126,6 +372,23 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusCreated, response)
})
server.router.POST("/v1/public/contact", publicRateLimiter.Middleware(), func(c *gin.Context) {
var request struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Message string `json:"message" binding:"required,min=10"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
if err := notificationService.SendContactEmail(c.Request.Context(), request.Name, request.Email, request.Message); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send message"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "sent"})
})
protected := server.router.Group("/v1")
protected.Use(auth.RequireAuth(verifier, repository, cfg.DemoMode))
@@ -196,6 +459,8 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
} else if errors.Is(err, catalog.ErrPlanLimitReached) {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
@@ -492,7 +757,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := billingService.CreateCheckoutSession(c.Request.Context(), auth.PrincipalFromContext(c), request.PlanCode, request.Currency)
response, err := billingService.CreateCheckoutSession(c.Request.Context(), auth.PrincipalFromContext(c), request.PlanCode, request.Currency, request.BillingInterval)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, billing.ErrBillingMembership) {
@@ -542,6 +807,141 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusOK, response)
})
// ============================================
// SMS API
// ============================================
protected.GET("/sms/settings", func(c *gin.Context) {
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
settings, err := smsService.GetSettings(c.Request.Context(), membership.Tenant.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, settings)
})
protected.POST("/sms/settings", func(c *gin.Context) {
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
var request domain.UpdateSMSSettingsRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
settings, err := smsService.UpdateSettings(c.Request.Context(), membership.Tenant.ID, request)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, sms.ErrSMSPlanNotAllowed) {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, settings)
})
protected.POST("/sms/send", func(c *gin.Context) {
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
var request domain.SendSMSRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := smsService.SendMessage(c.Request.Context(), membership.Tenant.ID, request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, sms.ErrSMSNotConfigured), errors.Is(err, sms.ErrSMSNotEnabled):
status = http.StatusServiceUnavailable
case errors.Is(err, sms.ErrSMSPlanNotAllowed):
status = http.StatusForbidden
case errors.Is(err, sms.ErrSMSLimitReached):
status = http.StatusTooManyRequests
case errors.Is(err, sms.ErrSMSInvalidPhone):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.GET("/sms/usage", func(c *gin.Context) {
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
yearMonth := c.Query("month")
if yearMonth == "" {
now := time.Now().UTC()
yearMonth = fmt.Sprintf("%04d-%02d", now.Year(), now.Month())
}
report, err := smsService.GetUsage(c.Request.Context(), membership.Tenant.ID, yearMonth)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, report)
})
protected.GET("/sms/history", func(c *gin.Context) {
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
limit := 50
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 100 {
limit = l
}
logs, err := smsService.GetUsageHistory(c.Request.Context(), membership.Tenant.ID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"logs": logs})
})
protected.GET("/sms/invoices", func(c *gin.Context) {
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
limit := 12
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 24 {
limit = l
}
reports, err := smsService.GetMonthlyReports(c.Request.Context(), membership.Tenant.ID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"reports": reports})
})
// Internal job endpoint for SMS monthly invoicing
server.router.POST("/v1/internal/jobs/sms/invoices", func(c *gin.Context) {
if !authorizeJobRunner(c, cfg) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
yearMonth := c.Query("month")
response, err := smsService.GenerateMonthlyInvoices(c.Request.Context(), yearMonth, notificationService)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
server.router.POST("/v1/webhooks/paddle", func(c *gin.Context) {
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -549,6 +949,13 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
}
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
})
server.router.POST("/v1/webhooks/stripe", func(c *gin.Context) {
if err := billingService.HandleStripeWebhook(c.Request.Context(), c.Request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
})
server.router.POST("/api/paddle_webhook", func(c *gin.Context) {
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -597,6 +1004,40 @@ func (s *Server) Close() {
}
}
// StartBackgroundJobs runs periodic background tasks
func (s *Server) StartBackgroundJobs() {
// Run trial ending check every 6 hours
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
// Run immediately on startup after a brief delay
time.Sleep(30 * time.Second)
s.runTrialEndingCheck()
for {
select {
case <-ticker.C:
s.runTrialEndingCheck()
}
}
}
func (s *Server) runTrialEndingCheck() {
if s.billingService == nil || s.notificationService == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := s.billingService.CheckAndSendTrialEndingEmails(ctx, s.notificationService)
if err != nil {
log.Printf("Background job: trial ending check failed: %v", err)
} else {
log.Printf("Background job: trial ending check completed")
}
}
func authorizeJobRunner(c *gin.Context, cfg config.Config) bool {
if cfg.JobRunnerKey == "" {
return false
+319
View File
@@ -0,0 +1,319 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"time"
"bookra/apps/backend/internal/db"
"github.com/golang-jwt/jwt/v5"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
)
const (
accessTokenTTL = 24 * time.Hour
refreshTokenTTL = 30 * 24 * time.Hour
magicLinkTTL = 15 * time.Minute
passwordResetTTL = 30 * time.Minute
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrInvalidToken = errors.New("invalid or expired token")
ErrUserNotFound = errors.New("user not found")
ErrEmailAlreadyExists = errors.New("email already exists")
ErrPasswordTooShort = errors.New("password must be at least 8 characters")
ErrMagicLinkExpired = errors.New("magic link expired")
ErrMagicLinkUsed = errors.New("magic link already used")
ErrInvalidResetToken = errors.New("invalid or expired reset token")
)
type TokenPair struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken,omitempty"`
TokenType string `json:"tokenType"`
ExpiresIn int `json:"expiresIn"`
}
type Claims struct {
UserID string `json:"sub"`
Email string `json:"email"`
Name string `json:"name,omitempty"`
Role string `json:"role"`
Type string `json:"type"`
jwt.RegisteredClaims
}
type Service struct {
repo db.Repository
jwtSecret []byte
}
func NewService(repo db.Repository, jwtSecret string) *Service {
return &Service{
repo: repo,
jwtSecret: []byte(jwtSecret),
}
}
// RegisterWithPassword creates a new user with email and password
func (s *Service) RegisterWithPassword(ctx context.Context, email, password, name string) (*db.UserRecord, *TokenPair, error) {
if len(password) < 8 {
return nil, nil, ErrPasswordTooShort
}
// Check if user exists
existing, err := s.repo.GetUserByEmail(ctx, email)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, nil, err
}
if existing != nil {
return nil, nil, ErrEmailAlreadyExists
}
// Hash password
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, nil, err
}
// Create user
user, err := s.repo.CreateUser(ctx, email, string(hash), name, "email", "user")
if err != nil {
return nil, nil, err
}
// Generate tokens
tokens, err := s.generateTokenPair(user.ID.String(), email, name, "user")
if err != nil {
return nil, nil, err
}
return user, tokens, nil
}
// LoginWithPassword authenticates a user with email and password
func (s *Service) LoginWithPassword(ctx context.Context, email, password string) (*db.UserRecord, *TokenPair, error) {
user, err := s.repo.GetUserByEmail(ctx, email)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrInvalidCredentials
}
return nil, nil, err
}
if user.PasswordHash == nil {
return nil, nil, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil {
return nil, nil, ErrInvalidCredentials
}
// Update last login
if err := s.repo.UpdateLastLogin(ctx, user.ID.String()); err != nil {
// Log but don't fail
}
tokens, err := s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role)
if err != nil {
return nil, nil, err
}
return user, tokens, nil
}
// CreateMagicLink generates a magic link for passwordless auth
func (s *Service) CreateMagicLink(ctx context.Context, email string) (string, error) {
// Get or create user
user, err := s.repo.GetUserByEmail(ctx, email)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return "", err
}
if user == nil {
user, err = s.repo.CreateUser(ctx, email, "", "", "email", "user")
if err != nil {
return "", err
}
}
// Generate token
token := generateRandomToken(32)
expiresAt := time.Now().Add(magicLinkTTL)
if err := s.repo.CreateMagicLink(ctx, token, user.ID.String(), email, expiresAt); err != nil {
return "", err
}
return token, nil
}
// VerifyMagicLink validates a magic link and returns tokens
func (s *Service) VerifyMagicLink(ctx context.Context, token string) (*db.UserRecord, *TokenPair, error) {
ml, err := s.repo.GetMagicLink(ctx, token)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrInvalidToken
}
return nil, nil, err
}
if ml.Used {
return nil, nil, ErrMagicLinkUsed
}
if time.Now().After(ml.ExpiresAt) {
return nil, nil, ErrMagicLinkExpired
}
// Mark as used
if err := s.repo.MarkMagicLinkUsed(ctx, token); err != nil {
return nil, nil, err
}
// Get user
user, err := s.repo.GetUserByID(ctx, ml.UserID.String())
if err != nil {
return nil, nil, err
}
// Mark email as verified
if err := s.repo.MarkEmailVerified(ctx, user.ID.String()); err != nil {
// Log but don't fail
}
// Update last login
if err := s.repo.UpdateLastLogin(ctx, user.ID.String()); err != nil {
// Log but don't fail
}
tokens, err := s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role)
if err != nil {
return nil, nil, err
}
return user, tokens, nil
}
// RefreshToken refreshes an access token using a refresh token
func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (*TokenPair, error) {
claims, err := s.ValidateToken(refreshToken)
if err != nil {
return nil, ErrInvalidToken
}
if claims.Type != "refresh" {
return nil, ErrInvalidToken
}
user, err := s.repo.GetUserByID(ctx, claims.UserID)
if err != nil {
return nil, ErrUserNotFound
}
return s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role)
}
// ValidateToken validates a JWT token and returns claims
func (s *Service) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return s.jwtSecret, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, ErrInvalidToken
}
// GetUser retrieves a user by ID
func (s *Service) GetUser(ctx context.Context, userID string) (*db.UserRecord, error) {
return s.repo.GetUserByID(ctx, userID)
}
// IsAdmin checks if the user has admin role
func (s *Service) IsAdmin(ctx context.Context, userID string) (bool, error) {
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return false, err
}
return user.Role == "admin" || user.Role == "superadmin", nil
}
// generateTokenPair creates access and refresh tokens
func (s *Service) generateTokenPair(userID, email, name, role string) (*TokenPair, error) {
now := time.Now()
// Access token
accessClaims := Claims{
UserID: userID,
Email: email,
Name: name,
Role: role,
Type: "access",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenTTL)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessTokenString, err := accessToken.SignedString(s.jwtSecret)
if err != nil {
return nil, err
}
// Refresh token
refreshClaims := Claims{
UserID: userID,
Email: email,
Role: role,
Type: "refresh",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(refreshTokenTTL)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
refreshTokenString, err := refreshToken.SignedString(s.jwtSecret)
if err != nil {
return nil, err
}
return &TokenPair{
AccessToken: accessTokenString,
RefreshToken: refreshTokenString,
TokenType: "Bearer",
ExpiresIn: int(accessTokenTTL.Seconds()),
}, nil
}
func generateRandomToken(length int) string {
b := make([]byte, length)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}
+590 -52
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"slices"
@@ -17,6 +18,12 @@ import (
paddle "github.com/PaddleHQ/paddle-go-sdk/v5"
"github.com/jackc/pgx/v5"
"github.com/stripe/stripe-go/v81"
portalsession "github.com/stripe/stripe-go/v81/billingportal/session"
checkoutsession "github.com/stripe/stripe-go/v81/checkout/session"
"github.com/stripe/stripe-go/v81/customer"
"github.com/stripe/stripe-go/v81/subscription"
"github.com/stripe/stripe-go/v81/webhook"
)
var (
@@ -26,9 +33,12 @@ var (
ErrPaddleNotConfigured = errors.New("paddle is not configured")
ErrPaddleSignatureMissing = errors.New("paddle signature is missing")
ErrPaddleWebhookMissing = errors.New("paddle webhook secret is not configured")
ErrStripeNotConfigured = errors.New("stripe is not configured")
ErrStripeSignatureMissing = errors.New("stripe signature is missing")
ErrStripeWebhookMissing = errors.New("stripe webhook secret is not configured")
)
var allowedWebhookEvents = []string{
var allowedPaddleWebhookEvents = []string{
"subscription.created",
"subscription.updated",
"subscription.activated",
@@ -42,11 +52,23 @@ var allowedWebhookEvents = []string{
"transaction.past_due",
}
var allowedStripeWebhookEvents = []stripe.EventType{
stripe.EventTypeCheckoutSessionCompleted,
stripe.EventTypeCustomerSubscriptionCreated,
stripe.EventTypeCustomerSubscriptionUpdated,
stripe.EventTypeCustomerSubscriptionDeleted,
stripe.EventTypeInvoicePaid,
stripe.EventTypeInvoicePaymentFailed,
stripe.EventTypePaymentIntentSucceeded,
stripe.EventTypePaymentIntentPaymentFailed,
}
type Service struct {
cfg config.Config
repo db.Repository
client *paddle.SDK
verifier *paddle.WebhookVerifier
cfg config.Config
repo db.Repository
client *paddle.SDK
verifier *paddle.WebhookVerifier
stripeEnabled bool
}
type webhookEnvelope struct {
@@ -63,6 +85,7 @@ type webhookEnvelope struct {
func NewService(cfg config.Config, repo db.Repository) *Service {
service := &Service{cfg: cfg, repo: repo}
// Initialize Paddle client
if strings.TrimSpace(cfg.PaddleAPIKey) != "" {
var client *paddle.SDK
var err error
@@ -76,13 +99,28 @@ func NewService(cfg config.Config, repo db.Repository) *Service {
}
}
if strings.TrimSpace(cfg.PaddleWebhookKey) != "" {
service.verifier = paddle.NewWebhookVerifier(cfg.PaddleWebhookKey, paddle.VerifierWithTimestampTolerance(5*time.Minute))
// Initialize Stripe
if strings.TrimSpace(cfg.StripeAPIKey) != "" {
stripe.Key = cfg.StripeAPIKey
service.stripeEnabled = true
}
return service
}
// GetEntitlements returns the plan entitlements for a tenant (used by other services for limit enforcement)
func (s *Service) GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error) {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Default to Pro entitlements for tenants without billing
return entitlementsForPlan("pro"), nil
}
return domain.PlanEntitlements{}, err
}
return entitlementsForPlan(tenant.PlanCode), nil
}
func (s *Service) GetSubscription(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
@@ -97,7 +135,7 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
if errors.Is(err, pgx.ErrNoRows) {
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
TenantID: membership.Tenant.ID,
BillingProvider: "paddle",
BillingProvider: s.cfg.BillingProvider(),
Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"),
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
Currency: "czk",
@@ -109,7 +147,7 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
return toSnapshot(membership.Tenant, record, s.cfg), nil
}
func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Principal, planCode string, currency string) (domain.CheckoutLaunchResponse, error) {
func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Principal, planCode string, currency string, billingInterval string) (domain.CheckoutLaunchResponse, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -118,7 +156,93 @@ func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Pr
return domain.CheckoutLaunchResponse{}, err
}
priceID, resolvedPlanCode, resolvedCurrency := s.priceForPlan(planCode, currency)
// Default to monthly if not specified
if billingInterval == "" {
billingInterval = "monthly"
}
// Prefer Stripe if configured
if s.cfg.StripeConfigured() {
return s.createStripeCheckoutSession(ctx, principal, membership, planCode, currency, billingInterval)
}
// Fall back to Paddle
return s.createPaddleCheckoutSession(ctx, principal, membership, planCode, currency)
}
func (s *Service) createStripeCheckoutSession(ctx context.Context, principal domain.Principal, membership db.TenantMembershipRecord, planCode string, currency string, billingInterval string) (domain.CheckoutLaunchResponse, error) {
priceID, resolvedPlanCode, resolvedCurrency := s.stripePriceForPlan(planCode, currency, billingInterval)
if priceID == "" {
return domain.CheckoutLaunchResponse{}, ErrBillingPlanUnsupported
}
// Ensure customer exists (KV sync model: always pre-create customers)
customerID := derefString(membership.Tenant.BillingCustomerID)
if customerID == "" {
cust, err := customer.New(&stripe.CustomerParams{
Email: stripe.String(strings.TrimSpace(principal.Email)),
Metadata: map[string]string{
"tenantId": membership.Tenant.ID,
"tenantSlug": membership.Tenant.Slug,
"userId": principal.Subject,
},
})
if err != nil {
return domain.CheckoutLaunchResponse{}, fmt.Errorf("failed to create stripe customer: %w", err)
}
customerID = cust.ID
if err := s.repo.UpdateTenantBillingCustomerID(ctx, membership.Tenant.ID, customerID); err != nil {
return domain.CheckoutLaunchResponse{}, err
}
}
// Create checkout session - 15-day free trial for Starter/Pro only (not Business)
// Trial requires credit card to be entered
trialDays := int64(0)
if resolvedPlanCode == "starter" || resolvedPlanCode == "pro" {
trialDays = 15
}
params := &stripe.CheckoutSessionParams{
Customer: stripe.String(customerID),
SuccessURL: stripe.String(strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=success&session_id={CHECKOUT_SESSION_ID}"),
CancelURL: stripe.String(strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=cancelled"),
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
PaymentMethodCollection: stripe.String("always"), // Require credit card even for free trial
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceID),
Quantity: stripe.Int64(1),
},
},
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
TrialPeriodDays: stripe.Int64(trialDays),
},
Metadata: map[string]string{
"tenantId": membership.Tenant.ID,
"tenantSlug": membership.Tenant.Slug,
"userId": principal.Subject,
"userEmail": strings.TrimSpace(principal.Email),
"planCode": resolvedPlanCode,
"currency": resolvedCurrency,
"billingInterval": billingInterval,
"source": "bookra-dashboard",
},
}
sess, err := checkoutsession.New(params)
if err != nil {
return domain.CheckoutLaunchResponse{}, fmt.Errorf("failed to create stripe checkout session: %w", err)
}
return domain.CheckoutLaunchResponse{
CheckoutURL: sess.URL,
SuccessRedirectURL: sess.SuccessURL,
CancelRedirectURL: sess.CancelURL,
}, nil
}
func (s *Service) createPaddleCheckoutSession(ctx context.Context, principal domain.Principal, membership db.TenantMembershipRecord, planCode string, currency string) (domain.CheckoutLaunchResponse, error) {
priceID, resolvedPlanCode, resolvedCurrency := s.paddlePriceForPlan(planCode, currency)
if priceID == "" {
return domain.CheckoutLaunchResponse{}, ErrBillingPlanUnsupported
}
@@ -157,16 +281,26 @@ func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (doma
if customerID == "" {
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
TenantID: membership.Tenant.ID,
BillingProvider: "paddle",
BillingProvider: s.cfg.BillingProvider(),
Status: "inactive",
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
Currency: "czk",
}, s.cfg), nil
}
// Prefer Stripe if configured
if s.cfg.StripeConfigured() {
record, err := s.syncStripeDataToKV(ctx, membership.Tenant, customerID)
if err != nil {
return domain.SubscriptionSnapshot{}, err
}
return toSnapshot(membership.Tenant, record, s.cfg), nil
}
// Fall back to Paddle
if s.client == nil {
return domain.SubscriptionSnapshot{}, ErrPaddleNotConfigured
}
record, err := s.syncPaddleData(ctx, membership.Tenant, customerID)
if err != nil {
return domain.SubscriptionSnapshot{}, err
@@ -183,30 +317,53 @@ func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Prin
}
return domain.PortalSessionResponse{}, err
}
if s.client == nil {
return domain.PortalSessionResponse{}, ErrPaddleNotConfigured
}
customerID := derefString(membership.Tenant.BillingCustomerID)
if customerID == "" {
return domain.PortalSessionResponse{}, ErrBillingCustomerMissing
}
// Prefer Stripe if configured
if s.cfg.StripeConfigured() {
return s.createStripePortalSession(customerID)
}
// Fall back to Paddle
return s.createPaddlePortalSession(ctx, membership, customerID)
}
func (s *Service) createStripePortalSession(customerID string) (domain.PortalSessionResponse, error) {
params := &stripe.BillingPortalSessionParams{
Customer: stripe.String(customerID),
ReturnURL: stripe.String(s.cfg.FrontendURL + "/dashboard?billing=refresh"),
}
sess, err := portalsession.New(params)
if err != nil {
return domain.PortalSessionResponse{}, fmt.Errorf("failed to create stripe portal session: %w", err)
}
return domain.PortalSessionResponse{URL: sess.URL}, nil
}
func (s *Service) createPaddlePortalSession(ctx context.Context, membership db.TenantMembershipRecord, customerID string) (domain.PortalSessionResponse, error) {
if s.client == nil {
return domain.PortalSessionResponse{}, ErrPaddleNotConfigured
}
request := &paddle.CreateCustomerPortalSessionRequest{CustomerID: customerID}
if subscriptionID := derefString(membership.Tenant.BillingSubscription); subscriptionID != "" {
request.SubscriptionIDs = []string{subscriptionID}
}
session, err := s.client.CreateCustomerPortalSession(ctx, request)
sess, err := s.client.CreateCustomerPortalSession(ctx, request)
if err != nil {
return domain.PortalSessionResponse{}, err
}
url := strings.TrimSpace(session.URLs.General.Overview)
if url == "" && len(session.URLs.Subscriptions) > 0 {
url := strings.TrimSpace(sess.URLs.General.Overview)
if url == "" && len(sess.URLs.Subscriptions) > 0 {
url = firstNonEmpty(
session.URLs.Subscriptions[0].UpdateSubscriptionPaymentMethod,
session.URLs.Subscriptions[0].CancelSubscription,
sess.URLs.Subscriptions[0].UpdateSubscriptionPaymentMethod,
sess.URLs.Subscriptions[0].CancelSubscription,
)
}
if url == "" {
@@ -217,6 +374,109 @@ func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Prin
}
func (s *Service) HandleWebhook(ctx context.Context, req *http.Request) error {
// Detect provider based on signature header
stripeSig := req.Header.Get("Stripe-Signature")
paddleSig := req.Header.Get("Paddle-Signature")
if stripeSig != "" {
return s.handleStripeWebhook(ctx, req)
}
if paddleSig != "" {
return s.handlePaddleWebhook(ctx, req)
}
return errors.New("missing webhook signature header")
}
func (s *Service) HandleStripeWebhook(ctx context.Context, req *http.Request) error {
return s.handleStripeWebhook(ctx, req)
}
func (s *Service) handleStripeWebhook(ctx context.Context, req *http.Request) error {
if s.cfg.StripeWebhookKey == "" {
return ErrStripeWebhookMissing
}
body, err := io.ReadAll(req.Body)
if err != nil {
return err
}
event, err := webhook.ConstructEvent(body, req.Header.Get("Stripe-Signature"), s.cfg.StripeWebhookKey)
if err != nil {
return fmt.Errorf("invalid stripe webhook signature: %w", err)
}
if !slices.Contains(allowedStripeWebhookEvents, event.Type) {
return nil
}
// Extract customer ID from event data
var customerID string
var eventID = event.ID
switch event.Type {
case "checkout.session.completed":
var sess stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
return err
}
customerID = sess.Customer.ID
if sess.Metadata != nil {
if tenantID := sess.Metadata["tenantId"]; tenantID != "" {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
return err
}
if customerID != "" && derefString(tenant.BillingCustomerID) == "" {
if err := s.repo.UpdateTenantBillingCustomerID(ctx, tenant.ID, customerID); err != nil {
return err
}
tenant.BillingCustomerID = &customerID
}
_, err = s.syncStripeDataToKV(ctx, tenant, customerID)
return err
}
}
default:
var data struct {
Customer struct {
ID string `json:"id"`
} `json:"customer"`
}
if err := json.Unmarshal(event.Data.Raw, &data); err != nil {
return err
}
customerID = data.Customer.ID
}
if customerID == "" {
return nil
}
tenant, err := s.repo.GetTenantByBillingCustomerID(ctx, customerID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
return err
}
inserted, err := s.repo.RecordBillingEvent(ctx, tenant.ID, "stripe", eventID, string(event.Type), event.Data.Raw)
if err != nil || !inserted {
return err
}
_, err = s.syncStripeDataToKV(ctx, tenant, customerID)
return err
}
func (s *Service) handlePaddleWebhook(ctx context.Context, req *http.Request) error {
if s.verifier == nil {
return ErrPaddleWebhookMissing
}
@@ -241,7 +501,7 @@ func (s *Service) HandleWebhook(ctx context.Context, req *http.Request) error {
if err := json.Unmarshal(payload, &event); err != nil {
return err
}
if !slices.Contains(allowedWebhookEvents, event.EventType) {
if !slices.Contains(allowedPaddleWebhookEvents, event.EventType) {
return nil
}
@@ -337,7 +597,7 @@ func (s *Service) syncPaddleData(ctx context.Context, tenant db.TenantRecord, cu
record.CurrentPeriodEnd = parseRFC3339Ptr(timePeriodEnd(selected.CurrentBillingPeriod))
if len(selected.Items) > 0 {
record.PriceID = selected.Items[0].Price.ID
record.PlanCode = s.planCodeForPrice(record.PriceID, tenant.PlanCode)
record.PlanCode = s.paddlePlanCodeForPrice(record.PriceID, tenant.PlanCode)
}
}
@@ -351,6 +611,115 @@ func (s *Service) syncPaddleData(ctx context.Context, tenant db.TenantRecord, cu
return record, nil
}
// syncStripeDataToKV is the core sync function following the KV sync model.
// It fetches full subscription state from Stripe and stores it in the database.
// This function is called after checkout success and on every relevant webhook event.
func (s *Service) syncStripeDataToKV(ctx context.Context, tenant db.TenantRecord, customerID string) (db.BillingSnapshotRecord, error) {
// Fetch all subscriptions for this customer from Stripe
iter := subscription.List(&stripe.SubscriptionListParams{
Customer: stripe.String(customerID),
})
var selected *stripe.Subscription
for iter.Next() {
sub := iter.Subscription()
if selected == nil || stripeSubscriptionRank(sub) > stripeSubscriptionRank(selected) {
selected = sub
}
}
if iter.Err() != nil {
return db.BillingSnapshotRecord{}, fmt.Errorf("failed to list stripe subscriptions: %w", iter.Err())
}
now := time.Now().UTC()
record := db.BillingSnapshotRecord{
TenantID: tenant.ID,
BillingProvider: "stripe",
BillingCustomerID: customerID,
BillingSubscriptionID: "",
Status: "inactive",
PlanCode: shared.NormalizePlanCode(tenant.PlanCode),
Currency: "czk",
PriceID: "",
LastSyncedAt: &now,
}
if selected != nil {
record.BillingSubscriptionID = selected.ID
record.Status = normalizeStripeSubscriptionStatus(selected.Status)
record.Currency = strings.ToLower(string(selected.Currency))
record.CancelAtPeriodEnd = selected.CancelAtPeriodEnd
record.CurrentPeriodStart = stripeTimeToPtr(selected.CurrentPeriodStart)
record.CurrentPeriodEnd = stripeTimeToPtr(selected.CurrentPeriodEnd)
// Extract price ID from subscription items
if len(selected.Items.Data) > 0 {
record.PriceID = selected.Items.Data[0].Price.ID
record.PlanCode = s.stripePlanCodeForPrice(record.PriceID, tenant.PlanCode)
}
// Get payment method info if available
if selected.DefaultPaymentMethod != nil && selected.DefaultPaymentMethod.Card != nil {
record.PaymentMethodBrand = string(selected.DefaultPaymentMethod.Card.Brand)
record.PaymentMethodLast4 = selected.DefaultPaymentMethod.Card.Last4
}
}
// Store normalized snapshot in DB (KV cache)
if err := s.repo.UpsertSubscriptionSnapshot(ctx, record); err != nil {
return db.BillingSnapshotRecord{}, err
}
if err := s.repo.UpdateTenantBillingState(ctx, tenant.ID, record.PlanCode, record.Status, record.BillingSubscriptionID); err != nil {
return db.BillingSnapshotRecord{}, err
}
return record, nil
}
func stripeSubscriptionRank(sub *stripe.Subscription) int {
switch sub.Status {
case stripe.SubscriptionStatusActive:
return 6
case stripe.SubscriptionStatusTrialing:
return 5
case stripe.SubscriptionStatusPastDue:
return 4
case stripe.SubscriptionStatusPaused:
return 3
case stripe.SubscriptionStatusCanceled:
return 2
default:
return 1
}
}
func normalizeStripeSubscriptionStatus(status stripe.SubscriptionStatus) string {
switch status {
case stripe.SubscriptionStatusActive:
return "active"
case stripe.SubscriptionStatusTrialing:
return "trialing"
case stripe.SubscriptionStatusPastDue:
return "past_due"
case stripe.SubscriptionStatusPaused:
return "paused"
case stripe.SubscriptionStatusCanceled:
return "canceled"
case stripe.SubscriptionStatusUnpaid:
return "canceled"
default:
return "inactive"
}
}
func stripeTimeToPtr(t int64) *time.Time {
if t == 0 {
return nil
}
ts := time.Unix(t, 0).UTC()
return &ts
}
func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg config.Config) domain.SubscriptionSnapshot {
if record.PlanCode == "" {
record.PlanCode = shared.NormalizePlanCode(tenant.PlanCode)
@@ -363,43 +732,87 @@ func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg con
}
customerID := firstNonEmpty(record.BillingCustomerID, derefString(tenant.BillingCustomerID))
provider := firstNonEmpty(record.BillingProvider, tenant.BillingProvider, cfg.BillingProvider())
syncAvailable := cfg.BillingConfigured()
portalAvailable := cfg.BillingConfigured() && customerID != ""
checkoutAvailable := billingCheckoutAvailable(cfg, record.PlanCode)
return domain.SubscriptionSnapshot{
TenantID: tenant.ID,
Provider: firstNonEmpty(record.BillingProvider, tenant.BillingProvider, "paddle"),
CustomerID: customerID,
SubscriptionID: firstNonEmpty(record.BillingSubscriptionID, derefString(tenant.BillingSubscription)),
Status: record.Status,
PlanCode: record.PlanCode,
Currency: record.Currency,
PriceID: record.PriceID,
CancelAtPeriodEnd: record.CancelAtPeriodEnd,
CurrentPeriodStart: record.CurrentPeriodStart,
CurrentPeriodEnd: record.CurrentPeriodEnd,
PaymentMethodBrand: record.PaymentMethodBrand,
PaymentMethodLast4: record.PaymentMethodLast4,
Entitlements: entitlementsForPlan(record.PlanCode),
DisplayPrices: displayPricesForPlan(record.PlanCode),
TrialDays: 30,
TenantID: tenant.ID,
Provider: provider,
CustomerID: customerID,
SubscriptionID: firstNonEmpty(record.BillingSubscriptionID, derefString(tenant.BillingSubscription)),
Status: record.Status,
PlanCode: record.PlanCode,
Currency: record.Currency,
PriceID: record.PriceID,
CancelAtPeriodEnd: record.CancelAtPeriodEnd,
CurrentPeriodStart: record.CurrentPeriodStart,
CurrentPeriodEnd: record.CurrentPeriodEnd,
PaymentMethodBrand: record.PaymentMethodBrand,
PaymentMethodLast4: record.PaymentMethodLast4,
Entitlements: entitlementsForPlan(record.PlanCode),
DisplayPrices: displayPricesForPlan(record.PlanCode),
TrialDays: func() int {
if record.PlanCode == "starter" || record.PlanCode == "pro" {
return 15
}
return 0
}(),
LastSyncedAt: record.LastSyncedAt,
CheckoutURLAvailable: checkoutAvailable(cfg, record.PlanCode),
SyncAvailable: cfg.PaddleConfigured(),
PortalAvailable: cfg.PaddleConfigured() && customerID != "",
CheckoutURLAvailable: checkoutAvailable,
SyncAvailable: syncAvailable,
PortalAvailable: portalAvailable,
}
}
func entitlementsForPlan(planCode string) domain.PlanEntitlements {
switch shared.NormalizePlanCode(planCode) {
case "starter":
return domain.PlanEntitlements{MaxLocations: 1, MaxStaff: 3, EmailReminders: true, AdvancedReporting: false, WidgetEmbedding: true, UmamiTracking: false}
// Starter: 1 location, 1 staff, 50 bookings/month
return domain.PlanEntitlements{
MaxLocations: 1,
MaxStaff: 1,
MaxBookingsMonth: 50,
EmailReminders: false,
AdvancedReporting: false,
WidgetEmbedding: true,
UmamiTracking: false,
APIAccess: false,
SMSAvailable: false,
}
case "business":
return domain.PlanEntitlements{MaxLocations: 10, MaxStaff: 30, EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true}
// Business: Unlimited everything, API access, dedicated manager
return domain.PlanEntitlements{
MaxLocations: -1, // Unlimited
MaxStaff: -1, // Unlimited
MaxBookingsMonth: -1, // Unlimited
EmailReminders: true,
AdvancedReporting: true,
WidgetEmbedding: true,
UmamiTracking: true,
APIAccess: true,
DedicatedManager: true,
SMSAvailable: true,
}
default:
return domain.PlanEntitlements{MaxLocations: 3, MaxStaff: 10, EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true}
// Pro: 3 locations, 10 staff, unlimited bookings, email reminders, analytics
return domain.PlanEntitlements{
MaxLocations: 3,
MaxStaff: 10,
MaxBookingsMonth: -1, // Unlimited
EmailReminders: true,
AdvancedReporting: true,
WidgetEmbedding: true,
UmamiTracking: true,
APIAccess: false,
SMSAvailable: true,
}
}
}
func (s *Service) planCodeForPrice(priceID string, fallback string) string {
func (s *Service) paddlePlanCodeForPrice(priceID string, fallback string) string {
for planCode, currencies := range s.cfg.PaddlePriceMatrix {
for _, configuredPriceID := range currencies {
if configuredPriceID != "" && configuredPriceID == priceID {
@@ -410,7 +823,18 @@ func (s *Service) planCodeForPrice(priceID string, fallback string) string {
return shared.NormalizePlanCode(fallback)
}
func (s *Service) priceForPlan(planCode string, currency string) (string, string, string) {
func (s *Service) stripePlanCodeForPrice(priceID string, fallback string) string {
for planCode, currencies := range s.cfg.StripePriceMatrix {
for _, configuredPriceID := range currencies {
if configuredPriceID != "" && configuredPriceID == priceID {
return shared.NormalizePlanCode(planCode)
}
}
}
return shared.NormalizePlanCode(fallback)
}
func (s *Service) paddlePriceForPlan(planCode string, currency string) (string, string, string) {
resolvedPlan := shared.NormalizePlanCode(strings.TrimSpace(planCode))
if resolvedPlan == "" {
resolvedPlan = "pro"
@@ -427,6 +851,48 @@ func (s *Service) priceForPlan(planCode string, currency string) (string, string
return s.cfg.PaddlePriceMatrix[resolvedPlan]["usd"], resolvedPlan, "usd"
}
func (s *Service) stripePriceForPlan(planCode string, currency string, billingInterval string) (string, string, string) {
resolvedPlan := shared.NormalizePlanCode(strings.TrimSpace(planCode))
if resolvedPlan == "" {
resolvedPlan = "pro"
}
resolvedCurrency := normalizeCurrency(currency)
resolvedInterval := billingInterval
if resolvedInterval == "" {
resolvedInterval = "monthly"
}
// Build the price key: plan:currency:interval (e.g., "pro:usd:monthly", "pro:usd:yearly")
priceKey := resolvedPlan + ":" + resolvedCurrency + ":" + resolvedInterval
if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" {
return priceID, resolvedPlan, resolvedCurrency
}
// Fall back to plan:currency format (for backwards compatibility)
priceKey = resolvedPlan + ":" + resolvedCurrency
if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" {
return priceID, resolvedPlan, resolvedCurrency
}
// Try just plan code with interval
if resolvedInterval != "monthly" {
priceKey = resolvedPlan + ":" + resolvedInterval
if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" {
return priceID, resolvedPlan, resolvedCurrency
}
}
// Default currency fallback
if resolvedCurrency != "usd" {
priceKey = resolvedPlan + ":usd:" + resolvedInterval
if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" {
return priceID, resolvedPlan, "usd"
}
}
return s.cfg.StripePriceMatrix[resolvedPlan][resolvedPlan+":czk"], resolvedPlan, "czk"
}
func subscriptionRank(subscription *paddle.Subscription) int {
switch subscription.Status {
case paddle.SubscriptionStatusActive:
@@ -447,19 +913,22 @@ func subscriptionRank(subscription *paddle.Subscription) int {
func displayPricesForPlan(planCode string) []domain.PlanDisplayPrice {
switch shared.NormalizePlanCode(planCode) {
case "starter":
// Starter: $5/month, $50/year (save $10 = ~17%)
return []domain.PlanDisplayPrice{
{Currency: "czk", AmountCents: 11900, Formatted: "119 Kc"},
{Currency: "usd", AmountCents: 500, Formatted: "$5"},
{Currency: "czk", AmountCents: 11900, Formatted: "119 Kč/mo", YearlyAmountCents: 119000, YearlyFormatted: "1 190 Kč/yr", YearlySavings: "Save 199 Kč", YearlySavingsPercent: 17},
{Currency: "usd", AmountCents: 500, Formatted: "$5/mo", YearlyAmountCents: 5000, YearlyFormatted: "$50/yr", YearlySavings: "Save $10", YearlySavingsPercent: 17},
}
case "business":
// Business: $50/month, $500/year (save $100 = ~17%)
return []domain.PlanDisplayPrice{
{Currency: "czk", AmountCents: 119900, Formatted: "1 199 Kc"},
{Currency: "usd", AmountCents: 5000, Formatted: "$50"},
{Currency: "czk", AmountCents: 119900, Formatted: "1 199 Kč/mo", YearlyAmountCents: 1199000, YearlyFormatted: "11 990 Kč/yr", YearlySavings: "Save 1 999 Kč", YearlySavingsPercent: 17},
{Currency: "usd", AmountCents: 5000, Formatted: "$50/mo", YearlyAmountCents: 50000, YearlyFormatted: "$500/yr", YearlySavings: "Save $100", YearlySavingsPercent: 17},
}
default:
// Pro: $20/month, $200/year (save $40 = ~17%)
return []domain.PlanDisplayPrice{
{Currency: "czk", AmountCents: 49900, Formatted: "499 Kc"},
{Currency: "usd", AmountCents: 2000, Formatted: "$20"},
{Currency: "czk", AmountCents: 49900, Formatted: "499 Kč/mo", YearlyAmountCents: 499000, YearlyFormatted: "4 990 Kč/yr", YearlySavings: "Save 999 Kč", YearlySavingsPercent: 17},
{Currency: "usd", AmountCents: 2000, Formatted: "$20/mo", YearlyAmountCents: 20000, YearlyFormatted: "$200/yr", YearlySavings: "Save $40", YearlySavingsPercent: 17},
}
}
}
@@ -497,6 +966,30 @@ func checkoutAvailable(cfg config.Config, planCode string) bool {
return false
}
func billingCheckoutAvailable(cfg config.Config, planCode string) bool {
planCode = shared.NormalizePlanCode(planCode)
// Prefer Stripe
if cfg.StripeConfigured() && cfg.StripeWebhookConfigured() {
for _, priceID := range cfg.StripePriceMatrix[planCode] {
if strings.TrimSpace(priceID) != "" {
return true
}
}
}
// Fall back to Paddle
if cfg.PaddleConfigured() && cfg.PaddleWebhookConfigured() {
for _, priceID := range cfg.PaddlePriceMatrix[planCode] {
if strings.TrimSpace(priceID) != "" {
return true
}
}
}
return false
}
func customDataString(data map[string]any, key string) string {
if data == nil {
return ""
@@ -554,3 +1047,48 @@ func firstNonEmpty(values ...string) string {
}
return ""
}
// CheckAndSendTrialEndingEmails checks all tenants with trials and sends emails for those ending soon
func (s *Service) CheckAndSendTrialEndingEmails(ctx context.Context, notificationService interface {
SendTrialEndingEmail(ctx context.Context, tenantID string, daysRemaining int) error
}) error {
// Get all tenants with trial status
tenants, _, err := s.repo.ListAllTenants(ctx, 1000, 0)
if err != nil {
return err
}
now := time.Now().UTC()
for _, tenant := range tenants {
if tenant.SubscriptionStatus != "trialing" && tenant.SubscriptionStatus != "trial" {
continue
}
// Get subscription to check trial end date
snapshot, err := s.repo.GetSubscriptionSnapshot(ctx, tenant.ID)
if err != nil {
continue
}
// Calculate trial end: assume 15-day trial from period start
var trialEnd time.Time
if snapshot.CurrentPeriodStart != nil {
trialEnd = snapshot.CurrentPeriodStart.Add(15 * 24 * time.Hour)
} else {
// Default to 15 days from now if no start date
trialEnd = now.Add(15 * 24 * time.Hour)
}
daysRemaining := int(trialEnd.Sub(now).Hours() / 24)
// Send email if trial ends in 1-3 days
if daysRemaining >= 1 && daysRemaining <= 3 {
if err := notificationService.SendTrialEndingEmail(ctx, tenant.ID, daysRemaining); err != nil {
// Log but don't fail
fmt.Printf("Failed to send trial ending email for tenant %s: %v\n", tenant.ID, err)
}
}
}
return nil
}
@@ -52,7 +52,7 @@ func TestCreateCheckoutRequiresPaddleConfig(t *testing.T) {
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "owner@bookra.dev",
}, "pro", "czk")
}, "pro", "czk", "monthly")
if err != ErrPaddleNotConfigured {
t.Fatalf("expected ErrPaddleNotConfigured, got response=%v err=%v", response, err)
}
@@ -64,7 +64,7 @@ func TestCreateCheckoutReturnsLaunchPayload(t *testing.T) {
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "owner@bookra.dev",
}, "pro", "czk")
}, "pro", "czk", "monthly")
if err != nil {
t.Fatalf("create checkout: %v", err)
}
+25
View File
@@ -122,6 +122,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
customerName := strings.TrimSpace(request.CustomerName)
customerEmail := strings.TrimSpace(request.CustomerEmail)
customerPhone := strings.TrimSpace(request.CustomerPhone)
notes := strings.TrimSpace(request.Notes)
if len(customerName) < 2 || len(customerName) > 120 {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerName must be between 2 and 120 characters", ErrInvalidBooking)
@@ -129,6 +130,9 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
if _, err := mail.ParseAddress(customerEmail); err != nil {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerEmail must be valid", ErrInvalidBooking)
}
if customerPhone != "" && len(customerPhone) > 30 {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerPhone must be at most 30 characters", ErrInvalidBooking)
}
if len(notes) > 1000 {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: notes must be at most 1000 characters", ErrInvalidBooking)
}
@@ -194,6 +198,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
BookingMode: request.BookingMode,
CustomerName: customerName,
CustomerEmail: customerEmail,
CustomerPhone: customerPhone,
StartsAt: startsAt.UTC(),
EndsAt: endsAt.UTC(),
Status: status,
@@ -331,6 +336,25 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
upcoming = upcoming[:5]
}
// Fetch all bookings for the last 30 days + next 30 days for chart and bookings page
allFrom := now.AddDate(0, 0, -30)
allTo := now.AddDate(0, 0, 30)
allRecords, err := s.repo.ListBookingsByTenantBetween(ctx, membership.Tenant.ID, allFrom, allTo)
if err != nil {
return domain.DashboardSummary{}, err
}
allBookings := make([]domain.UpcomingBooking, 0, len(allRecords))
for _, booking := range allRecords {
allBookings = append(allBookings, domain.UpcomingBooking{
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Status: booking.Status,
})
}
return domain.DashboardSummary{
TenantName: membership.Tenant.Name,
TenantSlug: membership.Tenant.Slug,
@@ -345,6 +369,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
{Code: "utilization", Label: "Utilization", Value: fmt.Sprintf("%d%%", metrics.UtilizationPercent)},
},
UpcomingBookings: upcoming,
AllBookings: allBookings,
WidgetSnippets: widgetSnippets(membership.Tenant),
Tracking: trackingStatus(s.repo, ctx, membership.Tenant),
}, nil
+39 -3
View File
@@ -3,6 +3,7 @@ package catalog
import (
"context"
"errors"
"fmt"
"time"
"bookra/apps/backend/internal/db"
@@ -17,14 +18,25 @@ var (
ErrInvalidBooking = errors.New("invalid booking request")
ErrTenantNotFound = errors.New("tenant not found")
ErrTenantMembership = errors.New("tenant membership not found")
ErrPlanLimitReached = errors.New("plan limit reached")
)
type Service struct {
repo db.Repository
repo db.Repository
billingService interface {
GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
}
notificationService interface {
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
}
}
func NewService(repo db.Repository) *Service {
return &Service{repo: repo}
func NewService(repo db.Repository, billingService interface {
GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
}, notificationService interface {
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
}) *Service {
return &Service{repo: repo, billingService: billingService, notificationService: notificationService}
}
// ============================================
@@ -63,6 +75,18 @@ func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal
return domain.Location{}, ErrTenantMembership
}
// Check plan entitlements for location limit
if s.billingService != nil {
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
if err == nil && entitlements.MaxLocations > 0 {
// Count existing locations
locations, err := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
if err == nil && len(locations) >= entitlements.MaxLocations {
return domain.Location{}, fmt.Errorf("%w: location limit reached (%d/%d). Upgrade your plan to add more locations.", ErrPlanLimitReached, len(locations), entitlements.MaxLocations)
}
}
}
params := db.CreateLocationParams{
TenantID: membership.Tenant.ID,
Name: req.Name,
@@ -74,6 +98,18 @@ func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal
return domain.Location{}, err
}
// Send usage warning if at 80%+ of limit
if s.notificationService != nil && s.billingService != nil {
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
if err == nil && entitlements.MaxLocations > 0 {
locations, _ := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
usagePercent := (len(locations) * 100) / entitlements.MaxLocations
if usagePercent >= 80 {
_ = s.notificationService.SendUsageWarning(ctx, membership.Tenant.ID, len(locations), entitlements.MaxLocations, usagePercent)
}
}
}
return domain.Location{
ID: rec.ID,
TenantID: rec.TenantID,
+142 -55
View File
@@ -10,52 +10,70 @@ import (
)
type Config struct {
Environment string
Port string
APIURL string
FrontendURL string
DatabaseURL string
DatabaseDirectURL string
NeonAuthURL string
AuthJWTSecret string
JobRunnerKey string
EmailFrom string
SMTPHost string
SMTPPort string
SMTPUsername string
SMTPPassword string
PaddleEnvironment string
PaddleAPIKey string
PaddleWebhookKey string
PaddlePriceMatrix map[string]map[string]string
UmamiAPIURL string
UmamiAPIKey string
DemoMode bool
Environment string
Port string
APIURL string
FrontendURL string
DatabaseURL string
DatabaseDirectURL string
NeonAuthURL string
AuthJWTSecret string
JobRunnerKey string
EmailFrom string
SMTPHost string
SMTPPort string
SMTPUsername string
SMTPPassword string
PaddleEnvironment string
PaddleAPIKey string
PaddleWebhookKey string
PaddlePriceMatrix map[string]map[string]string
StripeAPIKey string
StripeWebhookKey string
StripePriceMatrix map[string]map[string]string
AdminEmail string
AdminKey string
UmamiAPIURL string
UmamiAPIKey string
SentryDSN string
DemoMode bool
SMSManagerAPIKey string
SMSManagerBaseURL string
StripeSMSPriceMatrix map[string]string // currency -> price ID (czk, usd, eur, gbp, ...)
}
func Load() (Config, error) {
cfg := Config{
Environment: valueOrDefault("BOOKRA_APP_ENV", "development"),
Port: valueOrDefault("BOOKRA_API_PORT", "8080"),
APIURL: valueOrDefault("BOOKRA_API_URL", "http://localhost:8080"),
FrontendURL: valueOrDefault("BOOKRA_FRONTEND_URL", "http://localhost:3000"),
DatabaseURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_URL")),
DatabaseDirectURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_DIRECT_URL")),
NeonAuthURL: strings.TrimSpace(os.Getenv("BOOKRA_NEON_AUTH_URL")),
AuthJWTSecret: strings.TrimSpace(os.Getenv("BOOKRA_AUTH_JWT_SECRET")),
JobRunnerKey: strings.TrimSpace(os.Getenv("BOOKRA_JOB_RUNNER_KEY")),
EmailFrom: valueOrDefault("BOOKRA_EMAIL_FROM", "noreply@bookra.dev"),
SMTPHost: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_HOST")),
SMTPPort: valueOrDefault("BOOKRA_SMTP_PORT", "587"),
SMTPUsername: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_USERNAME")),
SMTPPassword: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_PASSWORD")),
PaddleEnvironment: normalizePaddleEnvironment(os.Getenv("BOOKRA_PADDLE_ENV")),
PaddleAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_API_KEY")),
PaddleWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_WEBHOOK_SECRET")),
PaddlePriceMatrix: paddlePriceMatrixFromEnv(),
UmamiAPIURL: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_URL")),
UmamiAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_KEY")),
DemoMode: boolFromEnv("BOOKRA_DEMO_MODE", false),
Environment: valueOrDefault("BOOKRA_APP_ENV", "development"),
Port: valueOrDefault("BOOKRA_API_PORT", "8080"),
APIURL: valueOrDefault("BOOKRA_API_URL", "http://localhost:8080"),
FrontendURL: valueOrDefault("BOOKRA_FRONTEND_URL", "http://localhost:3000"),
DatabaseURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_URL")),
DatabaseDirectURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_DIRECT_URL")),
NeonAuthURL: strings.TrimSpace(os.Getenv("BOOKRA_NEON_AUTH_URL")),
AuthJWTSecret: strings.TrimSpace(os.Getenv("BOOKRA_AUTH_JWT_SECRET")),
JobRunnerKey: strings.TrimSpace(os.Getenv("BOOKRA_JOB_RUNNER_KEY")),
EmailFrom: valueOrDefault("BOOKRA_EMAIL_FROM", "noreply@bookra.dev"),
SMTPHost: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_HOST")),
SMTPPort: valueOrDefault("BOOKRA_SMTP_PORT", "587"),
SMTPUsername: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_USERNAME")),
SMTPPassword: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_PASSWORD")),
PaddleEnvironment: normalizePaddleEnvironment(os.Getenv("BOOKRA_PADDLE_ENV")),
PaddleAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_API_KEY")),
PaddleWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_WEBHOOK_SECRET")),
PaddlePriceMatrix: paddlePriceMatrixFromEnv(),
StripeAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_API_KEY")),
StripeWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_WEBHOOK_SECRET")),
StripePriceMatrix: stripePriceMatrixFromEnv(),
AdminEmail: strings.TrimSpace(os.Getenv("BOOKRA_ADMIN_EMAIL")),
AdminKey: strings.TrimSpace(os.Getenv("BOOKRA_ADMIN_KEY")),
UmamiAPIURL: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_URL")),
UmamiAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_KEY")),
SentryDSN: strings.TrimSpace(os.Getenv("BOOKRA_SENTRY_DSN")),
DemoMode: boolFromEnv("BOOKRA_DEMO_MODE", false),
SMSManagerAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_SMSMANAGER_API_KEY")),
SMSManagerBaseURL: valueOrDefault("BOOKRA_SMSMANAGER_BASE_URL", "https://api.smsmngr.com/v2"),
StripeSMSPriceMatrix: smsPriceMatrixFromEnv(),
}
if cfg.FrontendURL == "" {
@@ -86,19 +104,6 @@ func (cfg Config) validateRuntimeRequirements() error {
if cfg.SMTPHost == "" {
missing = append(missing, "BOOKRA_SMTP_HOST")
}
if cfg.PaddleAPIKey == "" {
missing = append(missing, "BOOKRA_PADDLE_API_KEY")
}
if cfg.PaddleWebhookKey == "" {
missing = append(missing, "BOOKRA_PADDLE_WEBHOOK_SECRET")
}
for _, planCode := range []string{"starter", "pro", "business"} {
if cfg.PaddlePriceMatrix[planCode]["czk"] == "" || cfg.PaddlePriceMatrix[planCode]["usd"] == "" {
envPlan := strings.ToUpper(strings.ReplaceAll(planCode, "-", "_"))
missing = append(missing, "BOOKRA_PADDLE_"+envPlan+"_CZK_PRICE_ID")
missing = append(missing, "BOOKRA_PADDLE_"+envPlan+"_USD_PRICE_ID")
}
}
if len(missing) > 0 {
return fmt.Errorf("%s required when BOOKRA_APP_ENV=%s", strings.Join(uniqueStrings(missing), ", "), cfg.Environment)
}
@@ -118,6 +123,53 @@ func (cfg Config) PaddleCheckoutConfigured(planCode string) bool {
return cfg.PaddleConfigured() && cfg.PaddleWebhookConfigured() && cfg.PaddlePriceMatrix[planCode]["czk"] != "" && cfg.PaddlePriceMatrix[planCode]["usd"] != ""
}
func (cfg Config) StripeConfigured() bool {
return strings.TrimSpace(cfg.StripeAPIKey) != ""
}
func (cfg Config) StripeWebhookConfigured() bool {
return strings.TrimSpace(cfg.StripeWebhookKey) != ""
}
func (cfg Config) StripeCheckoutConfigured(planCode string) bool {
planCode = shared.NormalizePlanCode(planCode)
return cfg.StripeConfigured() && cfg.StripeWebhookConfigured() && cfg.StripePriceMatrix[planCode]["czk"] != "" && cfg.StripePriceMatrix[planCode]["usd"] != ""
}
func (cfg Config) BillingProvider() string {
if cfg.StripeConfigured() {
return "stripe"
}
return "paddle"
}
func (cfg Config) BillingConfigured() bool {
return cfg.StripeConfigured() || cfg.PaddleConfigured()
}
func (cfg Config) BillingWebhookConfigured() bool {
return cfg.StripeWebhookConfigured() || cfg.PaddleWebhookConfigured()
}
func (cfg Config) SMSConfigured() bool {
return strings.TrimSpace(cfg.SMSManagerAPIKey) != ""
}
func (cfg Config) StripeSMSConfigured() bool {
return cfg.StripeConfigured() && cfg.StripeSMSPriceMatrix["czk"] != ""
}
func (cfg Config) StripeSMSPriceID(currency string) string {
c := strings.ToLower(strings.TrimSpace(currency))
if c == "" {
c = "czk"
}
if id := cfg.StripeSMSPriceMatrix[c]; id != "" {
return id
}
return cfg.StripeSMSPriceMatrix["czk"]
}
func paddlePriceMatrixFromEnv() map[string]map[string]string {
matrix := map[string]map[string]string{
"starter": {},
@@ -132,6 +184,32 @@ func paddlePriceMatrixFromEnv() map[string]map[string]string {
return matrix
}
func stripePriceMatrixFromEnv() map[string]map[string]string {
matrix := map[string]map[string]string{
"starter": {},
"pro": {},
"business": {},
}
for _, planCode := range []string{"starter", "pro", "business"} {
envPlan := strings.ToUpper(strings.ReplaceAll(planCode, "-", "_"))
// Monthly prices
matrix[planCode][planCode+":czk:monthly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_MONTHLY_PRICE_ID"))
matrix[planCode][planCode+":usd:monthly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_MONTHLY_PRICE_ID"))
matrix[planCode][planCode+":czk"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_PRICE_ID"))
matrix[planCode][planCode+":usd"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_PRICE_ID"))
matrix[planCode]["czk"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_PRICE_ID"))
matrix[planCode]["usd"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_PRICE_ID"))
// Yearly prices
matrix[planCode][planCode+":czk:yearly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_YEARLY_PRICE_ID"))
matrix[planCode][planCode+":usd:yearly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_YEARLY_PRICE_ID"))
matrix[planCode]["yearly:czk"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_YEARLY_PRICE_ID"))
matrix[planCode]["yearly:usd"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_YEARLY_PRICE_ID"))
}
return matrix
}
func normalizePaddleEnvironment(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "live", "production":
@@ -157,6 +235,15 @@ func boolFromEnv(key string, fallback bool) bool {
return value == "true" || value == "1" || value == "yes" || value == "on"
}
func smsPriceMatrixFromEnv() map[string]string {
matrix := map[string]string{}
for _, currency := range []string{"czk", "usd", "eur", "gbp", "pln"} {
upper := strings.ToUpper(currency)
matrix[currency] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_SMS_" + upper + "_PRICE_ID"))
}
return matrix
}
func uniqueStrings(values []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
+135
View File
@@ -0,0 +1,135 @@
package db
import (
"context"
"encoding/json"
)
func (r *PGRepository) ListAllTenants(ctx context.Context, limit, offset int) ([]TenantRecord, int, error) {
var total int
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants`).Scan(&total)
if err != nil {
return nil, 0, err
}
rows, err := r.pool.Query(ctx, `
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status,
COALESCE(billing_provider, 'stripe'), billing_customer_id, billing_subscription_id
FROM tenants
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var tenants []TenantRecord
for rows.Next() {
var t TenantRecord
if err := rows.Scan(
&t.ID, &t.Slug, &t.Name, &t.Preset, &t.Locale, &t.Timezone,
&t.PlanCode, &t.SubscriptionStatus, &t.BillingProvider,
&t.BillingCustomerID, &t.BillingSubscription,
); err != nil {
return nil, 0, err
}
tenants = append(tenants, t)
}
return tenants, total, nil
}
func (r *PGRepository) ListAllUsers(ctx context.Context, limit, offset int) ([]UserRecord, int, error) {
var total int
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&total)
if err != nil {
return nil, 0, err
}
rows, err := r.pool.Query(ctx, `
SELECT id, email, name, email_verified, provider, role, created_at, last_login_at
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var users []UserRecord
for rows.Next() {
var u UserRecord
if err := rows.Scan(
&u.ID, &u.Email, &u.Name, &u.EmailVerified, &u.Provider, &u.Role,
&u.CreatedAt, &u.LastLoginAt,
); err != nil {
return nil, 0, err
}
users = append(users, u)
}
return users, total, nil
}
func (r *PGRepository) GetPlatformStats(ctx context.Context) (PlatformStats, error) {
var stats PlatformStats
// Total tenants
r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants`).Scan(&stats.TotalTenants)
// Total users
r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers)
// Active subscriptions
r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM billing_snapshots
WHERE status IN ('active', 'trialing')
`).Scan(&stats.ActiveSubscriptions)
// Trial subscriptions
r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM billing_snapshots
WHERE status = 'trialing'
`).Scan(&stats.TrialSubscriptions)
// Bookings this month
r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM bookings
WHERE created_at >= date_trunc('month', CURRENT_DATE)
`).Scan(&stats.BookingsThisMonth)
return stats, nil
}
func (r *PGRepository) CreateAdminAuditLog(ctx context.Context, params AdminAuditLogParams) error {
var detailsJSON []byte
var err error
if params.Details != nil {
detailsJSON, err = json.Marshal(params.Details)
if err != nil {
return err
}
}
_, err = r.pool.Exec(ctx, `
INSERT INTO admin_audit_log (admin_user_id, action, resource_type, resource_id, details, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`, nullableUUID(params.AdminUserID), params.Action, params.ResourceType, params.ResourceID, detailsJSON, params.IPAddress, params.UserAgent)
return err
}
func (r *PGRepository) UpdateUserRole(ctx context.Context, userID, role string) error {
_, err := r.pool.Exec(ctx, `
UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2
`, role, userID)
return err
}
func nullableUUID(s string) interface{} {
if s == "" {
return nil
}
return s
}
+137
View File
@@ -0,0 +1,137 @@
package db
import (
"context"
"time"
"github.com/jackc/pgx/v5"
)
func (r *PGRepository) GetUserByEmail(ctx context.Context, email string) (*UserRecord, error) {
var user UserRecord
var name, passwordHash *string
var lastLoginAt *time.Time
err := r.pool.QueryRow(ctx, `
SELECT id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
FROM users
WHERE email = $1
`, email).Scan(
&user.ID, &user.Email, &name, &passwordHash,
&user.EmailVerified, &user.Provider, &user.Role,
&user.CreatedAt, &lastLoginAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user.Name = name
user.PasswordHash = passwordHash
user.LastLoginAt = lastLoginAt
return &user, nil
}
func (r *PGRepository) GetUserByID(ctx context.Context, userID string) (*UserRecord, error) {
var user UserRecord
var name, passwordHash *string
var lastLoginAt *time.Time
err := r.pool.QueryRow(ctx, `
SELECT id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
FROM users
WHERE id = $1
`, userID).Scan(
&user.ID, &user.Email, &name, &passwordHash,
&user.EmailVerified, &user.Provider, &user.Role,
&user.CreatedAt, &lastLoginAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user.Name = name
user.PasswordHash = passwordHash
user.LastLoginAt = lastLoginAt
return &user, nil
}
func (r *PGRepository) CreateUser(ctx context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error) {
var user UserRecord
var lastLoginAt *time.Time
err := r.pool.QueryRow(ctx, `
INSERT INTO users (email, password_hash, name, provider, role, email_verified)
VALUES ($1, $2, $3, $4, $5, false)
RETURNING id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
`, email, nullableString(passwordHash), nullableString(name), provider, role).Scan(
&user.ID, &user.Email, &user.Name, &user.PasswordHash,
&user.EmailVerified, &user.Provider, &user.Role,
&user.CreatedAt, &lastLoginAt,
)
if err != nil {
return nil, err
}
user.LastLoginAt = lastLoginAt
return &user, nil
}
func (r *PGRepository) UpdateLastLogin(ctx context.Context, userID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE users SET last_login_at = NOW() WHERE id = $1
`, userID)
return err
}
func (r *PGRepository) MarkEmailVerified(ctx context.Context, userID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE users SET email_verified = true WHERE id = $1
`, userID)
return err
}
func (r *PGRepository) CreateMagicLink(ctx context.Context, token, userID, email string, expiresAt time.Time) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO magic_links (token, user_id, email, expires_at)
VALUES ($1, $2, $3, $4)
`, token, userID, email, expiresAt)
return err
}
func (r *PGRepository) GetMagicLink(ctx context.Context, token string) (*MagicLinkRecord, error) {
var ml MagicLinkRecord
err := r.pool.QueryRow(ctx, `
SELECT token, user_id, email, used, expires_at, created_at
FROM magic_links
WHERE token = $1
`, token).Scan(
&ml.Token, &ml.UserID, &ml.Email, &ml.Used, &ml.ExpiresAt, &ml.CreatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &ml, nil
}
func (r *PGRepository) MarkMagicLinkUsed(ctx context.Context, token string) error {
_, err := r.pool.Exec(ctx, `
UPDATE magic_links SET used = true WHERE token = $1
`, token)
return err
}
func nullableString(s string) interface{} {
if s == "" {
return nil
}
return s
}
+7 -5
View File
@@ -1,14 +1,14 @@
package db
import (
"context"
"time"
"context"
"time"
)
func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, starts_at, ends_at, status, reference
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference
FROM bookings
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
ORDER BY starts_at ASC
@@ -30,6 +30,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
&record.LocationID,
&record.CustomerName,
&record.CustomerEmail,
&record.CustomerPhone,
&record.StartsAt,
&record.EndsAt,
&record.Status,
@@ -47,10 +48,10 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
err := r.pool.QueryRow(ctx, `
INSERT INTO bookings (
tenant_id, service_id, class_session_id, staff_id, location_id,
booking_mode, customer_name, customer_email, starts_at, ends_at,
booking_mode, customer_name, customer_email, customer_phone, starts_at, ends_at,
status, reference, notes
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
RETURNING id, reference, status
`,
params.TenantID,
@@ -61,6 +62,7 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
params.BookingMode,
params.CustomerName,
params.CustomerEmail,
params.CustomerPhone,
params.StartsAt,
params.EndsAt,
params.Status,
+168 -36
View File
@@ -38,6 +38,23 @@ type Repository interface {
UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error
RecordBillingEvent(ctx context.Context, tenantID string, provider string, eventID string, eventType string, payload []byte) (bool, error)
// Auth methods
GetUserByEmail(ctx context.Context, email string) (*UserRecord, error)
GetUserByID(ctx context.Context, userID string) (*UserRecord, error)
CreateUser(ctx context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error)
UpdateLastLogin(ctx context.Context, userID string) error
MarkEmailVerified(ctx context.Context, userID string) error
CreateMagicLink(ctx context.Context, token, userID, email string, expiresAt time.Time) error
GetMagicLink(ctx context.Context, token string) (*MagicLinkRecord, error)
MarkMagicLinkUsed(ctx context.Context, token string) error
// Admin methods
ListAllTenants(ctx context.Context, limit, offset int) ([]TenantRecord, int, error)
ListAllUsers(ctx context.Context, limit, offset int) ([]UserRecord, int, error)
GetPlatformStats(ctx context.Context) (PlatformStats, error)
CreateAdminAuditLog(ctx context.Context, params AdminAuditLogParams) error
UpdateUserRole(ctx context.Context, userID, role string) error
// Location / Zone Management
ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error)
GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error)
@@ -69,6 +86,18 @@ type Repository interface {
// Working Hours
ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error)
UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error
// SMS
GetTenantSMSSettings(ctx context.Context, tenantID string) (TenantSMSSettingsRecord, error)
UpsertTenantSMSSettings(ctx context.Context, params TenantSMSSettingsRecord) error
CreateSMSUsageLog(ctx context.Context, params SMSUsageLogRecord) (string, error)
GetSMSUsageThisMonth(ctx context.Context, tenantID string) (SMSUsageSummary, error)
GetSMSUsageForMonth(ctx context.Context, tenantID string, yearMonth string) (SMSMonthlyReportRecord, error)
ListSMSUsageLogs(ctx context.Context, tenantID string, limit int) ([]SMSUsageLogRecord, error)
ListSMSMonthlyReports(ctx context.Context, tenantID string, limit int) ([]SMSMonthlyReportRecord, error)
UpsertSMSMonthlyReport(ctx context.Context, params SMSMonthlyReportRecord) error
MarkSMSReportInvoiceSent(ctx context.Context, tenantID string, yearMonth string) error
ListTenantsWithSMSUsage(ctx context.Context, yearMonth string) ([]TenantRecord, error)
}
type TenantRecord struct {
@@ -85,6 +114,46 @@ type TenantRecord struct {
BillingSubscription *string
}
type UserRecord struct {
ID uuid.UUID
Email string
Name *string
PasswordHash *string
EmailVerified bool
Provider string
Role string
CreatedAt time.Time
LastLoginAt *time.Time
}
type MagicLinkRecord struct {
Token string
UserID uuid.UUID
Email string
Used bool
ExpiresAt time.Time
CreatedAt time.Time
}
type PlatformStats struct {
TotalTenants int64 `json:"totalTenants"`
TotalUsers int64 `json:"totalUsers"`
ActiveSubscriptions int64 `json:"activeSubscriptions"`
TrialSubscriptions int64 `json:"trialSubscriptions"`
BookingsThisMonth int64 `json:"bookingsThisMonth"`
RevenueThisMonth int64 `json:"revenueThisMonthCents"`
}
type AdminAuditLogParams struct {
AdminUserID string
Action string
ResourceType string
ResourceID string
Details map[string]any
IPAddress string
UserAgent string
}
type TenantMembershipRecord struct {
Tenant TenantRecord
UserID string
@@ -172,6 +241,7 @@ type BookingRecord struct {
LocationID *string
CustomerName string
CustomerEmail string
CustomerPhone string
StartsAt time.Time
EndsAt time.Time
Status string
@@ -187,6 +257,7 @@ type CreateBookingParams struct {
BookingMode string
CustomerName string
CustomerEmail string
CustomerPhone string
StartsAt time.Time
EndsAt time.Time
Status string
@@ -357,6 +428,44 @@ type UpdateWorkingHoursParams struct {
IsOpen *bool
}
// SMS Records
type TenantSMSSettingsRecord struct {
TenantID string
Enabled bool
SenderName string
MonthlyLimit int
StripeSubscriptionItemID string
}
type SMSUsageLogRecord struct {
ID string
TenantID string
RecipientPhone string
MessageBody string
ExternalMessageID string
ExternalRequestID string
Status string
CostCents int
SentAt time.Time
CreatedAt time.Time
}
type SMSUsageSummary struct {
MessageCount int
TotalCostCents int
}
type SMSMonthlyReportRecord struct {
ID string
TenantID string
YearMonth string
MessageCount int
TotalCostCents int
StripeInvoiceID string
InvoiceSentAt *time.Time
CreatedAt time.Time
}
type PGRepository struct {
pool *pgxpool.Pool
}
@@ -371,46 +480,14 @@ func NewRepository(pools *Pools, demoMode bool) Repository {
return NewMemoryRepository()
}
// ============================================
// LOCATION / ZONE METHODS - PG REPOSITORY (STUBS)
// ============================================
// ============================================
// BLOCKED DAYS METHODS - PG REPOSITORY (STUBS)
// ============================================
// ============================================
// CUSTOMER METHODS - PG REPOSITORY (STUBS)
// ============================================
@@ -515,11 +592,11 @@ func (r *PGRepository) GetBookingByReference(ctx context.Context, reference stri
var rec BookingRecord
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, starts_at, ends_at, status, reference
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference
FROM bookings
WHERE reference = $1
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
&rec.CustomerName, &rec.CustomerEmail, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference)
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference)
return rec, err
}
@@ -541,8 +618,6 @@ func (r *PGRepository) RescheduleBooking(ctx context.Context, bookingID string,
// WORKING HOURS METHODS - PG REPOSITORY (STUBS)
// ============================================
type MemoryRepository struct {
tenant TenantRecord
membership TenantMembershipRecord
@@ -560,6 +635,9 @@ type MemoryRepository struct {
blockedDays []BlockedDayRecord
customers []CustomerRecord
workingHours []WorkingHoursRecord
smsSettings TenantSMSSettingsRecord
smsLogs []SMSUsageLogRecord
smsReports []SMSMonthlyReportRecord
}
func NewMemoryRepository() *MemoryRepository {
@@ -1303,6 +1381,60 @@ func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string
return pgx.ErrNoRows
}
// Auth methods for MemoryRepository
func (r *MemoryRepository) GetUserByEmail(_ context.Context, email string) (*UserRecord, error) {
return nil, nil
}
func (r *MemoryRepository) GetUserByID(_ context.Context, userID string) (*UserRecord, error) {
return nil, nil
}
func (r *MemoryRepository) CreateUser(_ context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error) {
return &UserRecord{ID: uuid.New(), Email: email, Name: &name, Provider: provider, Role: role}, nil
}
func (r *MemoryRepository) UpdateLastLogin(_ context.Context, userID string) error {
return nil
}
func (r *MemoryRepository) MarkEmailVerified(_ context.Context, userID string) error {
return nil
}
func (r *MemoryRepository) CreateMagicLink(_ context.Context, token, userID, email string, expiresAt time.Time) error {
return nil
}
func (r *MemoryRepository) GetMagicLink(_ context.Context, token string) (*MagicLinkRecord, error) {
return nil, nil
}
func (r *MemoryRepository) MarkMagicLinkUsed(_ context.Context, token string) error {
return nil
}
// Admin methods for MemoryRepository
func (r *MemoryRepository) ListAllTenants(_ context.Context, limit, offset int) ([]TenantRecord, int, error) {
return []TenantRecord{r.tenant}, 1, nil
}
func (r *MemoryRepository) ListAllUsers(_ context.Context, limit, offset int) ([]UserRecord, int, error) {
return []UserRecord{}, 0, nil
}
func (r *MemoryRepository) GetPlatformStats(_ context.Context) (PlatformStats, error) {
return PlatformStats{TotalTenants: 1, TotalUsers: 1, ActiveSubscriptions: 1}, nil
}
func (r *MemoryRepository) CreateAdminAuditLog(_ context.Context, params AdminAuditLogParams) error {
return nil
}
func (r *MemoryRepository) UpdateUserRole(_ context.Context, userID, role string) error {
return nil
}
func Reference(prefix string, at time.Time) string {
return fmt.Sprintf("%s-%s-%s", prefix, at.UTC().Format("20060102150405"), strings.Split(uuid.NewString(), "-")[0])
}
+280
View File
@@ -0,0 +1,280 @@
package db
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ============================================
// SMS SETTINGS - PG REPOSITORY
// ============================================
func (r *PGRepository) GetTenantSMSSettings(ctx context.Context, tenantID string) (TenantSMSSettingsRecord, error) {
var rec TenantSMSSettingsRecord
err := r.pool.QueryRow(ctx, `
SELECT tenant_id, enabled, COALESCE(sender_name, ''), COALESCE(monthly_limit, 0), COALESCE(stripe_subscription_item_id, '')
FROM tenant_sms_settings
WHERE tenant_id = $1
`, tenantID).Scan(&rec.TenantID, &rec.Enabled, &rec.SenderName, &rec.MonthlyLimit, &rec.StripeSubscriptionItemID)
if err != nil {
if err == pgx.ErrNoRows {
return TenantSMSSettingsRecord{TenantID: tenantID}, nil
}
return TenantSMSSettingsRecord{}, err
}
return rec, nil
}
func (r *PGRepository) UpsertTenantSMSSettings(ctx context.Context, params TenantSMSSettingsRecord) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO tenant_sms_settings (tenant_id, enabled, sender_name, monthly_limit, stripe_subscription_item_id, updated_at)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (tenant_id) DO UPDATE SET
enabled = EXCLUDED.enabled,
sender_name = EXCLUDED.sender_name,
monthly_limit = EXCLUDED.monthly_limit,
stripe_subscription_item_id = EXCLUDED.stripe_subscription_item_id,
updated_at = now()
`, params.TenantID, params.Enabled, params.SenderName, params.MonthlyLimit, params.StripeSubscriptionItemID)
return err
}
// ============================================
// SMS USAGE LOGS - PG REPOSITORY
// ============================================
func (r *PGRepository) CreateSMSUsageLog(ctx context.Context, params SMSUsageLogRecord) (string, error) {
var id string
err := r.pool.QueryRow(ctx, `
INSERT INTO sms_usage_logs (tenant_id, recipient_phone, message_body, external_message_id, external_request_id, status, cost_cents, sent_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
`, params.TenantID, params.RecipientPhone, params.MessageBody, params.ExternalMessageID, params.ExternalRequestID, params.Status, params.CostCents, params.SentAt).Scan(&id)
return id, err
}
func (r *PGRepository) GetSMSUsageThisMonth(ctx context.Context, tenantID string) (SMSUsageSummary, error) {
var summary SMSUsageSummary
err := r.pool.QueryRow(ctx, `
SELECT COALESCE(COUNT(*), 0), COALESCE(SUM(cost_cents), 0)
FROM sms_usage_logs
WHERE tenant_id = $1 AND date_trunc('month', created_at) = date_trunc('month', now())
`, tenantID).Scan(&summary.MessageCount, &summary.TotalCostCents)
return summary, err
}
func (r *PGRepository) GetSMSUsageForMonth(ctx context.Context, tenantID string, yearMonth string) (SMSMonthlyReportRecord, error) {
var rec SMSMonthlyReportRecord
err := r.pool.QueryRow(ctx, `
SELECT COALESCE(COUNT(*), 0), COALESCE(SUM(cost_cents), 0)
FROM sms_usage_logs
WHERE tenant_id = $1 AND to_char(created_at, 'YYYY-MM') = $2
`, tenantID, yearMonth).Scan(&rec.MessageCount, &rec.TotalCostCents)
if err != nil {
return SMSMonthlyReportRecord{}, err
}
rec.TenantID = tenantID
rec.YearMonth = yearMonth
return rec, nil
}
func (r *PGRepository) ListSMSUsageLogs(ctx context.Context, tenantID string, limit int) ([]SMSUsageLogRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, recipient_phone, message_body, external_message_id, external_request_id, status, cost_cents, created_at
FROM sms_usage_logs
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT $2
`, tenantID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []SMSUsageLogRecord
for rows.Next() {
var rec SMSUsageLogRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.RecipientPhone, &rec.MessageBody, &rec.ExternalMessageID, &rec.ExternalRequestID, &rec.Status, &rec.CostCents, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) ListSMSMonthlyReports(ctx context.Context, tenantID string, limit int) ([]SMSMonthlyReportRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, year_month, message_count, total_cost_cents, stripe_invoice_id, invoice_sent_at, created_at
FROM sms_monthly_reports
WHERE tenant_id = $1
ORDER BY year_month DESC
LIMIT $2
`, tenantID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []SMSMonthlyReportRecord
for rows.Next() {
var rec SMSMonthlyReportRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.YearMonth, &rec.MessageCount, &rec.TotalCostCents, &rec.StripeInvoiceID, &rec.InvoiceSentAt, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) UpsertSMSMonthlyReport(ctx context.Context, params SMSMonthlyReportRecord) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO sms_monthly_reports (tenant_id, year_month, message_count, total_cost_cents, stripe_invoice_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (tenant_id, year_month) DO UPDATE SET
message_count = EXCLUDED.message_count,
total_cost_cents = EXCLUDED.total_cost_cents,
stripe_invoice_id = EXCLUDED.stripe_invoice_id,
created_at = now()
`, params.TenantID, params.YearMonth, params.MessageCount, params.TotalCostCents, params.StripeInvoiceID)
return err
}
func (r *PGRepository) MarkSMSReportInvoiceSent(ctx context.Context, tenantID string, yearMonth string) error {
_, err := r.pool.Exec(ctx, `
UPDATE sms_monthly_reports
SET invoice_sent_at = now()
WHERE tenant_id = $1 AND year_month = $2
`, tenantID, yearMonth)
return err
}
func (r *PGRepository) ListTenantsWithSMSUsage(ctx context.Context, yearMonth string) ([]TenantRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT DISTINCT t.id, t.slug, t.name, t.preset, t.locale, t.timezone, t.plan_code, t.subscription_status,
t.billing_provider, t.billing_customer_id, t.billing_subscription_id
FROM tenants t
JOIN tenant_sms_settings s ON s.tenant_id = t.id AND s.enabled = true
JOIN sms_usage_logs l ON l.tenant_id = t.id AND to_char(l.created_at, 'YYYY-MM') = $1
`, yearMonth)
if err != nil {
return nil, err
}
defer rows.Close()
var records []TenantRecord
for rows.Next() {
var rec TenantRecord
if err := rows.Scan(&rec.ID, &rec.Slug, &rec.Name, &rec.Preset, &rec.Locale, &rec.Timezone, &rec.PlanCode, &rec.SubscriptionStatus,
&rec.BillingProvider, &rec.BillingCustomerID, &rec.BillingSubscription); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
// ============================================
// SMS SETTINGS - MEMORY REPOSITORY
// ============================================
func (r *MemoryRepository) GetTenantSMSSettings(_ context.Context, tenantID string) (TenantSMSSettingsRecord, error) {
if tenantID != r.tenant.ID {
return TenantSMSSettingsRecord{}, pgx.ErrNoRows
}
return r.smsSettings, nil
}
func (r *MemoryRepository) UpsertTenantSMSSettings(_ context.Context, params TenantSMSSettingsRecord) error {
r.smsSettings = params
return nil
}
func (r *MemoryRepository) CreateSMSUsageLog(_ context.Context, params SMSUsageLogRecord) (string, error) {
params.ID = fmt.Sprintf("sms-%d", len(r.smsLogs))
params.CreatedAt = time.Now().UTC()
r.smsLogs = append([]SMSUsageLogRecord{params}, r.smsLogs...)
return params.ID, nil
}
func (r *MemoryRepository) GetSMSUsageThisMonth(_ context.Context, tenantID string) (SMSUsageSummary, error) {
if tenantID != r.tenant.ID {
return SMSUsageSummary{}, nil
}
now := time.Now().UTC()
var count, cost int
for _, log := range r.smsLogs {
if log.TenantID == tenantID && log.CreatedAt.Year() == now.Year() && log.CreatedAt.Month() == now.Month() {
count++
cost += log.CostCents
}
}
return SMSUsageSummary{MessageCount: count, TotalCostCents: cost}, nil
}
func (r *MemoryRepository) GetSMSUsageForMonth(_ context.Context, tenantID string, yearMonth string) (SMSMonthlyReportRecord, error) {
if tenantID != r.tenant.ID {
return SMSMonthlyReportRecord{}, nil
}
var count, cost int
for _, log := range r.smsLogs {
if log.TenantID == tenantID && log.CreatedAt.Format("2006-01") == yearMonth {
count++
cost += log.CostCents
}
}
return SMSMonthlyReportRecord{TenantID: tenantID, YearMonth: yearMonth, MessageCount: count, TotalCostCents: cost}, nil
}
func (r *MemoryRepository) ListSMSUsageLogs(_ context.Context, tenantID string, limit int) ([]SMSUsageLogRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
if limit > len(r.smsLogs) {
limit = len(r.smsLogs)
}
return r.smsLogs[:limit], nil
}
func (r *MemoryRepository) ListSMSMonthlyReports(_ context.Context, tenantID string, limit int) ([]SMSMonthlyReportRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
if limit > len(r.smsReports) {
limit = len(r.smsReports)
}
return r.smsReports[:limit], nil
}
func (r *MemoryRepository) UpsertSMSMonthlyReport(_ context.Context, params SMSMonthlyReportRecord) error {
for i, rep := range r.smsReports {
if rep.TenantID == params.TenantID && rep.YearMonth == params.YearMonth {
r.smsReports[i] = params
return nil
}
}
r.smsReports = append([]SMSMonthlyReportRecord{params}, r.smsReports...)
return nil
}
func (r *MemoryRepository) MarkSMSReportInvoiceSent(_ context.Context, tenantID string, yearMonth string) error {
now := time.Now().UTC()
for i, rep := range r.smsReports {
if rep.TenantID == tenantID && rep.YearMonth == yearMonth {
r.smsReports[i].InvoiceSentAt = &now
return nil
}
}
return nil
}
func (r *MemoryRepository) ListTenantsWithSMSUsage(_ context.Context, yearMonth string) ([]TenantRecord, error) {
for _, log := range r.smsLogs {
if log.TenantID == r.tenant.ID && log.CreatedAt.Format("2006-01") == yearMonth && r.smsSettings.Enabled {
return []TenantRecord{r.tenant}, nil
}
}
return nil, nil
}
+186 -52
View File
@@ -47,6 +47,7 @@ type DashboardSummary struct {
SetupCompletion int `json:"setupCompletion"`
KPIs []DashboardKPI `json:"kpis"`
UpcomingBookings []UpcomingBooking `json:"upcomingBookings"`
AllBookings []UpcomingBooking `json:"allBookings"`
WidgetSnippets []WidgetSnippet `json:"widgetSnippets"`
Tracking TrackingStatus `json:"tracking"`
}
@@ -132,6 +133,7 @@ type CreateBookingRequest struct {
LocationID *string `json:"locationId,omitempty"`
CustomerName string `json:"customerName"`
CustomerEmail string `json:"customerEmail"`
CustomerPhone string `json:"customerPhone,omitempty"`
Notes string `json:"notes"`
StartsAt string `json:"startsAt"`
EndsAt string `json:"endsAt"`
@@ -146,16 +148,33 @@ type CreateBookingResponse struct {
type PlanEntitlements struct {
MaxLocations int `json:"maxLocations"`
MaxStaff int `json:"maxStaff"`
MaxBookingsMonth int `json:"maxBookingsMonth"` // -1 = unlimited
EmailReminders bool `json:"emailReminders"`
AdvancedReporting bool `json:"advancedReporting"`
WidgetEmbedding bool `json:"widgetEmbedding"`
UmamiTracking bool `json:"umamiTracking"`
APIAccess bool `json:"apiAccess"`
DedicatedManager bool `json:"dedicatedManager"`
SMSAvailable bool `json:"smsAvailable"`
}
type PlanPricing struct {
MonthlyAmountCents int `json:"monthlyAmountCents"`
YearlyAmountCents int `json:"yearlyAmountCents"`
MonthlyFormatted string `json:"monthlyFormatted"`
YearlyFormatted string `json:"yearlyFormatted"`
YearlySavings string `json:"yearlySavings"`
YearlySavingsPercent int `json:"yearlySavingsPercent"`
}
type PlanDisplayPrice struct {
Currency string `json:"currency"`
AmountCents int `json:"amountCents"`
Formatted string `json:"formatted"`
Currency string `json:"currency"`
AmountCents int `json:"amountCents"`
Formatted string `json:"formatted"`
YearlyAmountCents int `json:"yearlyAmountCents,omitempty"`
YearlyFormatted string `json:"yearlyFormatted,omitempty"`
YearlySavings string `json:"yearlySavings,omitempty"`
YearlySavingsPercent int `json:"yearlySavingsPercent,omitempty"`
}
type SubscriptionSnapshot struct {
@@ -182,17 +201,22 @@ type SubscriptionSnapshot struct {
}
type CheckoutSessionRequest struct {
PlanCode string `json:"planCode"`
Currency string `json:"currency,omitempty"`
PlanCode string `json:"planCode"`
Currency string `json:"currency,omitempty"`
BillingInterval string `json:"billingInterval,omitempty"` // "monthly" or "yearly", defaults to "monthly"
}
type CheckoutLaunchResponse struct {
PriceID string `json:"priceId"`
CustomerID string `json:"customerId,omitempty"`
CustomerEmail string `json:"customerEmail,omitempty"`
SuccessRedirectURL string `json:"successRedirectUrl"`
CancelRedirectURL string `json:"cancelRedirectUrl"`
CustomData map[string]string `json:"customData"`
// Stripe checkout
CheckoutURL string `json:"checkoutUrl,omitempty"`
// Paddle checkout
PriceID string `json:"priceId,omitempty"`
CustomerID string `json:"customerId,omitempty"`
CustomerEmail string `json:"customerEmail,omitempty"`
// Common
SuccessRedirectURL string `json:"successRedirectUrl,omitempty"`
CancelRedirectURL string `json:"cancelRedirectUrl,omitempty"`
CustomData map[string]string `json:"customData,omitempty"`
}
type PortalSessionResponse struct {
@@ -240,19 +264,19 @@ type UpdateLocationRequest struct {
// ============================================
type BlockedDay struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
Date time.Time `json:"date"`
Reason string `json:"reason"`
Type string `json:"type"` // full, partial
StaffID *string `json:"staffId,omitempty"`
ID string `json:"id"`
TenantID string `json:"tenantId"`
Date time.Time `json:"date"`
Reason string `json:"reason"`
Type string `json:"type"` // full, partial
StaffID *string `json:"staffId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type CreateBlockedDayRequest struct {
Date string `json:"date" binding:"required"` // RFC3339
Reason string `json:"reason" binding:"required"`
Type string `json:"type" binding:"required"` // full, partial
Date string `json:"date" binding:"required"` // RFC3339
Reason string `json:"reason" binding:"required"`
Type string `json:"type" binding:"required"` // full, partial
StaffID *string `json:"staffId,omitempty"`
}
@@ -266,32 +290,32 @@ type UpdateBlockedDayRequest struct {
// ============================================
type Customer struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
Name string `json:"name"`
Email string `json:"email"`
Phone *string `json:"phone,omitempty"`
Status string `json:"status"` // active, inactive, vip
BookingsCount int `json:"bookingsCount"`
ID string `json:"id"`
TenantID string `json:"tenantId"`
Name string `json:"name"`
Email string `json:"email"`
Phone *string `json:"phone,omitempty"`
Status string `json:"status"` // active, inactive, vip
BookingsCount int `json:"bookingsCount"`
LastBookingAt *time.Time `json:"lastBookingAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Notes string `json:"notes,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Notes string `json:"notes,omitempty"`
}
type CreateCustomerRequest struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Phone *string `json:"phone,omitempty"`
Status string `json:"status,omitempty"` // defaults to active
Notes string `json:"notes,omitempty"`
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Phone *string `json:"phone,omitempty"`
Status string `json:"status,omitempty"` // defaults to active
Notes string `json:"notes,omitempty"`
}
type UpdateCustomerRequest struct {
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
Status string `json:"status,omitempty"`
Notes string `json:"notes,omitempty"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
Status string `json:"status,omitempty"`
Notes string `json:"notes,omitempty"`
}
// ============================================
@@ -312,15 +336,70 @@ type CustomerBookingView struct {
}
type RescheduleBookingRequest struct {
NewStartsAt string `json:"newStartsAt" binding:"required"` // RFC3339
NewEndsAt string `json:"newEndsAt" binding:"required"` // RFC3339
Reason string `json:"reason,omitempty"`
NewStartsAt string `json:"newStartsAt" binding:"required"` // RFC3339
NewEndsAt string `json:"newEndsAt" binding:"required"` // RFC3339
Reason string `json:"reason,omitempty"`
}
type CancelBookingRequest struct {
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
// ============================================
@@ -345,7 +424,7 @@ type UpdateWorkingHoursRequest struct {
type EmailTemplate struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
Type string `json:"type"` // booking_confirmation, reminder, cancellation, etc.
Type string `json:"type"` // booking_confirmation, reminder, cancellation, etc.
Subject string `json:"subject"`
BodyHTML string `json:"bodyHtml"`
BodyText string `json:"bodyText"`
@@ -360,14 +439,69 @@ type SendEmailRequest struct {
}
type EmailNotification struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
BookingID string `json:"bookingId,omitempty"`
Channel string `json:"channel"` // email, sms
Type string `json:"type"` // confirmation, reminder, cancellation
Recipient string `json:"recipient"`
Status string `json:"status"` // pending, sent, failed
ID string `json:"id"`
TenantID string `json:"tenantId"`
BookingID string `json:"bookingId,omitempty"`
Channel string `json:"channel"` // email, sms
Type string `json:"type"` // confirmation, reminder, cancellation
Recipient string `json:"recipient"`
Status string `json:"status"` // pending, sent, failed
SentAt *time.Time `json:"sentAt,omitempty"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// ============================================
// SMS MODELS
// ============================================
type SMSSettings struct {
Enabled bool `json:"enabled"`
SenderName string `json:"senderName,omitempty"`
MonthlyLimit int `json:"monthlyLimit,omitempty"`
MessagesSent int `json:"messagesSent"`
TotalCostCents int `json:"totalCostCents"`
Available bool `json:"available"`
}
type UpdateSMSSettingsRequest struct {
Enabled bool `json:"enabled"`
SenderName string `json:"senderName,omitempty"`
MonthlyLimit int `json:"monthlyLimit,omitempty"`
}
type SendSMSRequest struct {
To string `json:"to" binding:"required"`
Body string `json:"body" binding:"required,max=1000"`
}
type SendSMSResponse struct {
LogID string `json:"logId"`
MessageID string `json:"messageId,omitempty"`
RequestID string `json:"requestId,omitempty"`
Status string `json:"status"`
CostCents int `json:"costCents"`
}
type SMSUsageLog struct {
ID string `json:"id"`
RecipientPhone string `json:"recipientPhone"`
MessageBody string `json:"messageBody,omitempty"`
Status string `json:"status"`
CostCents int `json:"costCents"`
CreatedAt time.Time `json:"createdAt"`
}
type SMSUsageReport struct {
YearMonth string `json:"yearMonth"`
MessageCount int `json:"messageCount"`
TotalCostCents int `json:"totalCostCents"`
StripeInvoiceID string `json:"stripeInvoiceId,omitempty"`
InvoiceSentAt *time.Time `json:"invoiceSentAt,omitempty"`
}
type SMSInvoiceBatchResponse struct {
YearMonth string `json:"yearMonth"`
ProcessedCount int `json:"processedCount"`
FailedCount int `json:"failedCount"`
}
@@ -10,40 +10,146 @@ import (
type EmailType string
const (
EmailTypeConfirmation EmailType = "confirmation"
EmailTypeConfirmation EmailType = "confirmation"
EmailTypeReminder EmailType = "reminder"
EmailTypeReschedule EmailType = "reschedule"
EmailTypeCancellation EmailType = "cancellation"
EmailTypeBusinessNotify EmailType = "business_notify"
EmailTypeUsageWarning EmailType = "usage_warning"
EmailTypeTrialEnding EmailType = "trial_ending"
)
type BookingEmailData struct {
Type EmailType
TenantName string
TenantSlug string
BusinessEmail string
BusinessPhone string
BusinessAddress string
BrandColor string
CustomerName string
CustomerEmail string
Service string
Location string
Reference string
StartsAt time.Time
EndsAt time.Time
Timezone string
Locale string
Notes string
ManagementURL string
AddToCalendarURL string
}
type UsageNotificationData struct {
Type EmailType
TenantName string
TenantSlug string
BusinessEmail string
BusinessPhone string
BusinessAddress string
BrandColor string
CustomerName string
CustomerEmail string
Service string
Location string
Reference string
StartsAt time.Time
EndsAt time.Time
Timezone string
AdminEmail string
Locale string
Notes string
ManagementURL string
AddToCalendarURL string
PlanCode string
LocationCount int
LocationLimit int
UsagePercent int
UpgradeURL string
DashboardURL string
}
func RenderUsageNotificationEmail(data UsageNotificationData) EmailMessage {
subject := renderUsageSubject(data)
htmlBody := renderUsageHTML(data)
textBody := renderUsageText(data)
return EmailMessage{
From: data.BusinessEmail,
To: data.AdminEmail,
Subject: subject,
Text: textBody,
HTML: htmlBody,
}
}
func renderUsageSubject(data UsageNotificationData) string {
if data.Locale == "cs" {
switch data.Type {
case EmailTypeUsageWarning:
return "⚠️ Blížíte se limitu lokací - Upgrade na vyšší plán"
case EmailTypeTrialEnding:
return "⏰ Vaše zkušební období končí - Pokračujte s Bookra"
}
}
switch data.Type {
case EmailTypeUsageWarning:
return "⚠️ You're nearing your location limit - Upgrade your plan"
case EmailTypeTrialEnding:
return "⏰ Your trial period is ending - Continue with Bookra"
}
return "Bookra notification"
}
func renderUsageHTML(data UsageNotificationData) string {
cs := data.Locale == "cs"
upgradeBtn := `<a href="` + data.UpgradeURL + `" style="display:inline-block;background:#4f46e5;color:#fff;padding:12px 24px;text-decoration:none;border-radius:8px;font-weight:600;margin:8px 0;">` + map[bool]string{true: "Upgradeovat", false: "Upgrade"}[cs] + `</a>`
dashboardBtn := `<a href="` + data.DashboardURL + `" style="display:inline-block;background:#f3f4f6;color:#374151;padding:12px 24px;text-decoration:none;border-radius:8px;font-weight:600;margin:8px 0;">` + map[bool]string{true: "Otevřít dashboard", false: "Open dashboard"}[cs] + `</a>`
if data.Type == EmailTypeUsageWarning {
var msg string
if cs {
msg = fmt.Sprintf("Váš plán %s umožňuje pouze %d lokací. Aktuálně používáte %d (%d%%).", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent)
} else {
msg = fmt.Sprintf("Your %s plan allows only %d locations. You're currently using %d (%d%%).", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent)
}
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1f2937;">
<div style="text-align:center;margin-bottom:24px;"><span style="font-size:32px;">📍</span></div>
<h2 style="text-align:center;color:#1f2937;margin-bottom:16px;">` + map[bool]string{true: "Blížíte se limitu lokací", false: "You're nearing your location limit"}[cs] + `</h2>
<p style="color:#6b7280;text-align:center;margin-bottom:24px;">` + msg + `</p>
<div style="text-align:center;margin-bottom:24px;">` + upgradeBtn + `</div>
<p style="color:#9ca3af;font-size:14px;text-align:center;">` + map[bool]string{true: "Přidejte další lokace s vyšším plánem", false: "Add more locations with a higher plan"}[cs] + `</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
<p style="color:#9ca3af;font-size:12px;text-align:center;">Powered by <a href="https://bookra.eu" style="color:#4f46e5;">Bookra</a></p>
</body></html>`
}
// Trial ending email
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1f2937;">
<div style="text-align:center;margin-bottom:24px;"><span style="font-size:32px;">🎉</span></div>
<h2 style="text-align:center;color:#1f2937;margin-bottom:16px;">` + map[bool]string{true: "Děkujeme, že používáte Bookra!", false: "Thank you for using Bookra!"}[cs] + `</h2>
<p style="color:#6b7280;text-align:center;margin-bottom:24px;">` + map[bool]string{true: "Vaše zkušební období brzy končí. Pokud se vám naše služba líbí, můžete pokračovat s vybraným plánem.", false: "Your trial period is ending soon. If you like our service, you can continue with your chosen plan."}[cs] + `</p>
<div style="text-align:center;margin-bottom:24px;">` + upgradeBtn + `</div>
<p style="color:#6b7280;text-align:center;margin-bottom:16px;">` + map[bool]string{true: "Pokud se vám služba nelíbí, můžete ji kdykoliv zrušit. Nechceme vám brát peníze, pokud nejste spokojeni.", false: "If you don't like our service, you can cancel anytime. We don't want to take your money if you're not happy."}[cs] + `</p>
<p style="color:#9ca3af;text-align:center;margin-bottom:24px;">` + map[bool]string{true: "Zrušit můžete zde:", false: "Cancel here:"}[cs] + ` ` + dashboardBtn + `</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
<p style="color:#9ca3af;font-size:12px;text-align:center;">Powered by <a href="https://bookra.eu" style="color:#4f46e5;">Bookra</a></p>
</body></html>`
}
func renderUsageText(data UsageNotificationData) string {
cs := data.Locale == "cs"
if data.Type == EmailTypeUsageWarning {
if cs {
return fmt.Sprintf("Blížíte se limitu lokací! Váš plán %s umožňuje pouze %d lokací. Aktuálně používáte %d (%d%%). Upgradeujte na vyšší plán: %s", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent, data.UpgradeURL)
}
return fmt.Sprintf("You're nearing your location limit! Your %s plan allows only %d locations. You're currently using %d (%d%%). Upgrade: %s", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent, data.UpgradeURL)
}
if cs {
return "Vaše zkušební období končí. Pokud se vám služba líbí, můžete pokračovat. Pokud ne, můžete zrušit. Nechceme vám brát peníze, pokud nejste spokojeni. Dashboard: " + data.DashboardURL
}
return "Your trial period is ending. If you like our service, you can continue. If not, you can cancel - we don't want your money if you're not happy. Dashboard: " + data.DashboardURL
}
func RenderEmailMessage(data BookingEmailData) EmailMessage {
subject := renderSubject(data)
htmlBody := renderHTMLBody(data)
textBody := renderTextBody(data)
return EmailMessage{
From: data.BusinessEmail,
To: data.CustomerEmail,
@@ -55,7 +161,7 @@ func RenderEmailMessage(data BookingEmailData) EmailMessage {
func renderSubject(data BookingEmailData) string {
localizedTime := formatLocalizedTime(data.StartsAt, data.Timezone, data.Locale)
switch data.Type {
case EmailTypeConfirmation:
if data.Locale == "cs" {
@@ -89,7 +195,7 @@ func renderSubject(data BookingEmailData) string {
func renderTextBody(data BookingEmailData) string {
localizedTime := formatLocalizedDateTime(data.StartsAt, data.Timezone, data.Locale)
switch data.Type {
case EmailTypeConfirmation:
if data.Locale == "cs" {
@@ -124,7 +230,7 @@ Manage your booking at: %s
Thank you,
%s
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName, data.BusinessEmail)
case EmailTypeReminder:
if data.Locale == "cs" {
return fmt.Sprintf(`Dobrý den %s,
@@ -152,7 +258,7 @@ This is a reminder for your booking tomorrow.
Manage booking: %s
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
case EmailTypeReschedule:
if data.Locale == "cs" {
return fmt.Sprintf(`Dobrý den %s,
@@ -182,7 +288,7 @@ New details:
Manage booking: %s
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
case EmailTypeCancellation:
if data.Locale == "cs" {
return fmt.Sprintf(`Dobrý den %s,
@@ -210,7 +316,7 @@ Cancelled booking:
If you didn't cancel this, please contact us: %s
%s`, data.CustomerName, data.Service, localizedTime, data.Reference, data.BusinessEmail, data.TenantName)
case EmailTypeBusinessNotify:
if data.Locale == "cs" {
return fmt.Sprintf(`Nová rezervace od %s
@@ -232,7 +338,7 @@ Details:
- Email: %s
Manage in dashboard: https://bookra.eu/dashboard`, data.CustomerName, data.Service, localizedTime, data.Reference, data.CustomerEmail)
default:
return "Booking update"
}
@@ -245,7 +351,7 @@ func renderHTMLBody(data BookingEmailData) string {
html := "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
html += "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
html += fmt.Sprintf("<h2 style='color: %s; margin-bottom: 20px;'>%s</h2>", data.BrandColor, data.TenantName)
// Convert text to simple HTML
paragraphs := splitParagraphs(textBody)
for _, p := range paragraphs {
@@ -253,12 +359,12 @@ func renderHTMLBody(data BookingEmailData) string {
html += fmt.Sprintf("<p style='margin-bottom: 10px;'>%s</p>", p)
}
}
// Add management button
if data.ManagementURL != "" {
html += fmt.Sprintf("<div style='margin-top: 30px;'><a href='%s' style='display: inline-block; background: %s; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;'>Manage Booking</a></div>", data.ManagementURL, data.BrandColor)
}
html += "</div></body></html>"
return html
}
@@ -347,8 +453,98 @@ func RenderReminderEmail(from string, job db.ReminderJobRecord) EmailMessage {
StartsAt: job.StartsAt,
Timezone: job.Timezone,
Locale: job.Locale,
Service: "Service", // Legacy
Service: "Service", // Legacy
Location: "Location", // Legacy
}
return RenderEmailMessage(data)
}
// ============================================
// SMS USAGE EMAIL TEMPLATE
// ============================================
type SMSUsageEmailData struct {
TenantName string
TenantSlug string
BusinessEmail string
YearMonth string
MessageCount int
TotalCostCents int
Locale string
}
func RenderSMSUsageEmail(data SMSUsageEmailData) EmailMessage {
cs := data.Locale == "cs"
year := data.YearMonth[:4]
month := data.YearMonth[5:]
monthLabel := month + "/" + year
if cs {
monthLabel = month + "." + year
}
totalFormatted := fmt.Sprintf("%.2f Kč", float64(data.TotalCostCents)/100.0)
subject := fmt.Sprintf("Bookra SMS Usage - %s (%s)", monthLabel, totalFormatted)
if cs {
subject = fmt.Sprintf("Bookra SMS Přehled - %s (%s)", monthLabel, totalFormatted)
}
textBody := fmt.Sprintf(
"SMS Usage Summary for %s\n\nPeriod: %s\nMessages sent: %d\nTotal cost: %s\n\nThis amount will be added to your next invoice.",
data.TenantName, monthLabel, data.MessageCount, totalFormatted,
)
if cs {
textBody = fmt.Sprintf(
"Přehled SMS pro %s\n\nObdobí: %s\nOdeslaných zpráv: %d\nCelková cena: %s\n\nTato částka bude přidána k vaší další faktuře.",
data.TenantName, monthLabel, data.MessageCount, totalFormatted,
)
}
htmlBody := fmt.Sprintf(`<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1f2937;">
<div style="text-align:center;margin-bottom:24px;"><span style="font-size:32px;">📱</span></div>
<h2 style="text-align:center;color:#1f2937;margin-bottom:16px;">%s</h2>
<p style="color:#6b7280;text-align:center;margin-bottom:24px;">%s</p>
<table style="width:100%%;border-collapse:collapse;margin-bottom:24px;">
<tr style="border-bottom:1px solid #e5e7eb;">
<td style="padding:12px 0;color:#6b7280;">%s</td>
<td style="padding:12px 0;text-align:right;font-weight:600;">%s</td>
</tr>
<tr style="border-bottom:1px solid #e5e7eb;">
<td style="padding:12px 0;color:#6b7280;">%s</td>
<td style="padding:12px 0;text-align:right;font-weight:600;">%d</td>
</tr>
<tr>
<td style="padding:12px 0;color:#1f2937;font-weight:600;">%s</td>
<td style="padding:12px 0;text-align:right;font-weight:700;font-size:18px;">%s</td>
</tr>
</table>
<p style="color:#9ca3af;font-size:14px;text-align:center;">%s</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
<p style="color:#9ca3af;font-size:12px;text-align:center;">Powered by <a href="https://bookra.eu" style="color:#4f46e5;">Bookra</a></p>
</body></html>`,
ifCS(cs, "SMS Usage Summary", "Přehled SMS využití"),
fmt.Sprintf(ifCS(cs, "Your SMS usage for %s", "Vaše SMS využití za %s"), data.TenantName),
ifCS(cs, "Period", "Období"), monthLabel,
ifCS(cs, "Messages sent", "Odeslaných zpráv"), data.MessageCount,
ifCS(cs, "Total cost (excl. VAT)", "Celková cena (bez DPH)"), totalFormatted,
ifCS(cs, "This amount will be added to your next Stripe invoice.", "Tato částka bude přidána k vaší další faktuře Stripe."),
)
return EmailMessage{
From: "",
To: data.BusinessEmail,
Subject: subject,
Text: textBody,
HTML: htmlBody,
}
}
func ifCS(cs bool, en, csText string) string {
if cs {
return csText
}
return en
}
@@ -285,3 +285,85 @@ func (p smtpEmailProvider) Send(_ context.Context, message EmailMessage) (Delive
ExternalID: fmt.Sprintf("smtp-%d", time.Now().UnixNano()),
}, nil
}
// SendContactEmail sends a contact form submission to the business email
func (s *Service) SendContactEmail(ctx context.Context, name, email, message string) error {
subject := fmt.Sprintf("Bookra Contact: Message from %s", name)
text := fmt.Sprintf("Name: %s\nEmail: %s\n\nMessage:\n%s", name, email, message)
html := fmt.Sprintf(
"<h2>New contact form submission</h2><p><strong>Name:</strong> %s</p><p><strong>Email:</strong> %s</p><p><strong>Message:</strong></p><p>%s</p>",
name, email, message,
)
msg := EmailMessage{
From: s.cfg.EmailFrom,
To: s.cfg.EmailFrom,
Subject: subject,
Text: text,
HTML: html,
}
_, err := s.emailProvider.Send(ctx, msg)
return err
}
func (s *Service) SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
return fmt.Errorf("failed to get tenant: %w", err)
}
// Use a placeholder admin email - in production, would get from tenant owner
adminEmail := "admin@" + tenant.Slug + ".bookra.eu"
emailData := UsageNotificationData{
Type: EmailTypeUsageWarning,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BusinessEmail: s.cfg.EmailFrom,
AdminEmail: adminEmail,
Locale: tenant.Locale,
PlanCode: tenant.PlanCode,
LocationCount: locationCount,
LocationLimit: locationLimit,
UsagePercent: usagePercent,
UpgradeURL: "https://bookra.eu/pricing",
DashboardURL: "https://bookra.eu/dashboard",
}
msg := RenderUsageNotificationEmail(emailData)
_, err = s.emailProvider.Send(ctx, msg)
return err
}
// SendRawEmail sends a pre-built email message
func (s *Service) SendRawEmail(ctx context.Context, msg EmailMessage) (DeliveryReceipt, error) {
if msg.From == "" {
msg.From = s.cfg.EmailFrom
}
return s.emailProvider.Send(ctx, msg)
}
func (s *Service) SendTrialEndingEmail(ctx context.Context, tenantID string, daysRemaining int) error {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
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
}
+80
View File
@@ -0,0 +1,80 @@
package sms
import (
"context"
"fmt"
"strings"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
"github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/invoiceitem"
)
type BillingService struct {
cfg config.Config
repo db.Repository
smsPriceIDs map[string]string // currency -> price ID
}
func NewBillingService(cfg config.Config, repo db.Repository) *BillingService {
return &BillingService{
cfg: cfg,
repo: repo,
smsPriceIDs: cfg.StripeSMSPriceMatrix,
}
}
// PriceIDForCurrency returns the Stripe price ID for SMS in the given currency
func (b *BillingService) PriceIDForCurrency(currency string) string {
c := strings.ToLower(strings.TrimSpace(currency))
if c == "" {
c = "czk"
}
if id := b.smsPriceIDs[c]; id != "" {
return id
}
// Fallback to CZK
return b.smsPriceIDs["czk"]
}
// CreateMonthlyInvoiceItem creates a Stripe InvoiceItem for the total SMS usage of a month.
// This adds a line item to the customer's next invoice — charging all messages together.
func (b *BillingService) CreateMonthlyInvoiceItem(ctx context.Context, customerID string, currency string, yearMonth string, messageCount int, totalCents int) (string, error) {
if customerID == "" {
return "", fmt.Errorf("customer id is empty")
}
priceID := b.PriceIDForCurrency(currency)
c := strings.ToLower(strings.TrimSpace(currency))
if c == "" {
c = "czk"
}
// If a Stripe price is configured, use Price + Quantity for a clean invoice line
if priceID != "" {
item, err := invoiceitem.New(&stripe.InvoiceItemParams{
Customer: stripe.String(customerID),
Price: stripe.String(priceID),
Quantity: stripe.Int64(int64(messageCount)),
Description: stripe.String(fmt.Sprintf("SMS Messages (%s) — %d messages", yearMonth, messageCount)),
})
if err != nil {
return "", fmt.Errorf("failed to create invoice item: %w", err)
}
return item.ID, nil
}
// Fallback: explicit amount (for dev/testing when no price configured yet)
item, err := invoiceitem.New(&stripe.InvoiceItemParams{
Customer: stripe.String(customerID),
Amount: stripe.Int64(int64(totalCents)),
Currency: stripe.String(c),
Description: stripe.String(fmt.Sprintf("SMS Messages (%s) — %d messages", yearMonth, messageCount)),
})
if err != nil {
return "", fmt.Errorf("failed to create invoice item: %w", err)
}
return item.ID, nil
}
+461
View File
@@ -0,0 +1,461 @@
package sms
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/notifications"
"bookra/apps/backend/internal/shared"
)
var (
ErrSMSNotConfigured = errors.New("sms is not configured")
ErrSMSNotEnabled = errors.New("sms is not enabled for this tenant")
ErrSMSPlanNotAllowed = errors.New("sms is only available on pro and business plans")
ErrSMSLimitReached = errors.New("monthly sms limit reached")
ErrSMSInvalidPhone = errors.New("invalid phone number")
ErrSMSMissingAPIKey = errors.New("sms manager api key is missing")
ErrSMSSendFailed = errors.New("sms send failed")
ErrStripeNotConfigured = errors.New("stripe is not configured for sms billing")
ErrNoActiveSubscription = errors.New("no active subscription for sms billing")
)
const smsCostCents = 150 // 1.50 CZK
type Service struct {
cfg config.Config
repo db.Repository
billing *BillingService
client *http.Client
baseURL string
apiKey string
}
func NewService(cfg config.Config, repo db.Repository) *Service {
s := &Service{
cfg: cfg,
repo: repo,
client: &http.Client{Timeout: 15 * time.Second},
baseURL: strings.TrimRight(cfg.SMSManagerBaseURL, "/"),
apiKey: cfg.SMSManagerAPIKey,
}
if cfg.StripeConfigured() && cfg.StripeSMSConfigured() {
s.billing = NewBillingService(cfg, repo)
}
return s
}
func (s *Service) Enabled() bool {
return s.cfg.SMSConfigured()
}
func (s *Service) IsAvailable(ctx context.Context, tenantID string) (bool, error) {
if !s.Enabled() {
return false, nil
}
settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID)
if err != nil {
return false, err
}
return settings.Enabled, nil
}
func (s *Service) canUseSMS(ctx context.Context, tenantID string) error {
if !s.Enabled() {
return ErrSMSNotConfigured
}
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
return err
}
plan := shared.NormalizePlanCode(tenant.PlanCode)
if plan != "pro" && plan != "business" {
return ErrSMSPlanNotAllowed
}
if tenant.SubscriptionStatus != "active" && tenant.SubscriptionStatus != "trialing" {
return ErrNoActiveSubscription
}
settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID)
if err != nil {
return err
}
if !settings.Enabled {
return ErrSMSNotEnabled
}
return nil
}
func (s *Service) GetSettings(ctx context.Context, tenantID string) (domain.SMSSettings, error) {
settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID)
if err != nil {
return domain.SMSSettings{}, err
}
usage, err := s.repo.GetSMSUsageThisMonth(ctx, tenantID)
if err != nil {
return domain.SMSSettings{}, err
}
return domain.SMSSettings{
Enabled: settings.Enabled,
SenderName: settings.SenderName,
MonthlyLimit: settings.MonthlyLimit,
MessagesSent: usage.MessageCount,
TotalCostCents: usage.TotalCostCents,
Available: s.Enabled(),
}, nil
}
func (s *Service) UpdateSettings(ctx context.Context, tenantID string, req domain.UpdateSMSSettingsRequest) (domain.SMSSettings, error) {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
return domain.SMSSettings{}, err
}
plan := shared.NormalizePlanCode(tenant.PlanCode)
if plan != "pro" && plan != "business" {
return domain.SMSSettings{}, ErrSMSPlanNotAllowed
}
if tenant.SubscriptionStatus != "active" && tenant.SubscriptionStatus != "trialing" {
return domain.SMSSettings{}, ErrNoActiveSubscription
}
if err := s.repo.UpsertTenantSMSSettings(ctx, db.TenantSMSSettingsRecord{
TenantID: tenantID,
Enabled: req.Enabled,
SenderName: strings.TrimSpace(req.SenderName),
MonthlyLimit: req.MonthlyLimit,
}); err != nil {
return domain.SMSSettings{}, err
}
return s.GetSettings(ctx, tenantID)
}
func (s *Service) SendMessage(ctx context.Context, tenantID string, req domain.SendSMSRequest) (domain.SendSMSResponse, error) {
if err := s.canUseSMS(ctx, tenantID); err != nil {
return domain.SendSMSResponse{}, err
}
phone := normalizePhone(req.To)
if phone == "" {
return domain.SendSMSResponse{}, ErrSMSInvalidPhone
}
// Check monthly limit
settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID)
if err != nil {
return domain.SendSMSResponse{}, err
}
if settings.MonthlyLimit > 0 {
usage, err := s.repo.GetSMSUsageThisMonth(ctx, tenantID)
if err != nil {
return domain.SendSMSResponse{}, err
}
if usage.MessageCount >= settings.MonthlyLimit {
return domain.SendSMSResponse{}, ErrSMSLimitReached
}
}
// Send via SMS Manager
resp, err := s.sendToSMSManager(ctx, phone, req.Body, settings.SenderName)
if err != nil {
return domain.SendSMSResponse{}, err
}
// Extract message ID from accepted recipients
messageID := ""
if len(resp.Accepted) > 0 {
messageID = resp.Accepted[0].MessageID
}
// Log usage locally (Stripe billing happens once at month-end)
logID, err := s.repo.CreateSMSUsageLog(ctx, db.SMSUsageLogRecord{
TenantID: tenantID,
RecipientPhone: phone,
MessageBody: req.Body,
ExternalMessageID: messageID,
ExternalRequestID: resp.RequestID,
Status: "sent",
CostCents: smsCostCents,
SentAt: time.Now().UTC(),
})
if err != nil {
return domain.SendSMSResponse{}, fmt.Errorf("failed to log sms usage: %w", err)
}
return domain.SendSMSResponse{
LogID: logID,
MessageID: messageID,
RequestID: resp.RequestID,
Status: "sent",
CostCents: smsCostCents,
}, nil
}
func (s *Service) GetUsage(ctx context.Context, tenantID string, yearMonth string) (domain.SMSUsageReport, error) {
report, err := s.repo.GetSMSUsageForMonth(ctx, tenantID, yearMonth)
if err != nil {
return domain.SMSUsageReport{}, err
}
return domain.SMSUsageReport{
YearMonth: report.YearMonth,
MessageCount: report.MessageCount,
TotalCostCents: report.TotalCostCents,
}, nil
}
func (s *Service) GetUsageHistory(ctx context.Context, tenantID string, limit int) ([]domain.SMSUsageLog, error) {
if limit <= 0 {
limit = 50
}
records, err := s.repo.ListSMSUsageLogs(ctx, tenantID, limit)
if err != nil {
return nil, err
}
logs := make([]domain.SMSUsageLog, len(records))
for i, r := range records {
logs[i] = domain.SMSUsageLog{
ID: r.ID,
RecipientPhone: r.RecipientPhone,
MessageBody: r.MessageBody,
Status: r.Status,
CostCents: r.CostCents,
CreatedAt: r.CreatedAt,
}
}
return logs, nil
}
func (s *Service) GetMonthlyReports(ctx context.Context, tenantID string, limit int) ([]domain.SMSUsageReport, error) {
if limit <= 0 {
limit = 12
}
records, err := s.repo.ListSMSMonthlyReports(ctx, tenantID, limit)
if err != nil {
return nil, err
}
reports := make([]domain.SMSUsageReport, len(records))
for i, r := range records {
reports[i] = domain.SMSUsageReport{
YearMonth: r.YearMonth,
MessageCount: r.MessageCount,
TotalCostCents: r.TotalCostCents,
StripeInvoiceID: r.StripeInvoiceID,
InvoiceSentAt: r.InvoiceSentAt,
}
}
return reports, nil
}
// GenerateMonthlyInvoices creates/finalizes monthly invoices for SMS usage
func (s *Service) GenerateMonthlyInvoices(ctx context.Context, yearMonth string, notificationSvc *notifications.Service) (domain.SMSInvoiceBatchResponse, error) {
if yearMonth == "" {
now := time.Now().UTC()
yearMonth = fmt.Sprintf("%04d-%02d", now.Year(), now.Month())
}
response := domain.SMSInvoiceBatchResponse{YearMonth: yearMonth}
// Get all tenants with SMS enabled that have usage this month
tenants, err := s.repo.ListTenantsWithSMSUsage(ctx, yearMonth)
if err != nil {
return response, err
}
for _, t := range tenants {
report, err := s.repo.GetSMSUsageForMonth(ctx, t.ID, yearMonth)
if err != nil {
response.FailedCount++
continue
}
if report.MessageCount == 0 {
continue
}
// Create Stripe InvoiceItem for total monthly SMS usage
// This adds one line to the customer's next invoice: all messages together
stripeInvoiceItemID := ""
if s.billing != nil {
customerID := ""
if t.BillingCustomerID != nil {
customerID = *t.BillingCustomerID
}
if customerID != "" {
currency := "czk"
if t.BillingProvider != "" {
// Try to infer currency from billing snapshot if available
snap, _ := s.repo.GetSubscriptionSnapshot(ctx, t.ID)
if snap.Currency != "" {
currency = snap.Currency
}
}
itemID, err := s.billing.CreateMonthlyInvoiceItem(ctx, customerID, currency, yearMonth, report.MessageCount, report.TotalCostCents)
if err == nil {
stripeInvoiceItemID = itemID
}
}
}
if err := s.repo.UpsertSMSMonthlyReport(ctx, db.SMSMonthlyReportRecord{
TenantID: t.ID,
YearMonth: yearMonth,
MessageCount: report.MessageCount,
TotalCostCents: report.TotalCostCents,
StripeInvoiceID: stripeInvoiceItemID,
}); err != nil {
response.FailedCount++
continue
}
// Send usage summary email
if notificationSvc != nil {
_ = s.sendUsageSummaryEmail(ctx, t, report, notificationSvc)
}
response.ProcessedCount++
}
return response, nil
}
func (s *Service) sendUsageSummaryEmail(ctx context.Context, tenant db.TenantRecord, report db.SMSMonthlyReportRecord, svc *notifications.Service) error {
// Get brand profile for email styling
brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID)
data := notifications.SMSUsageEmailData{
TenantName: brand.Name,
TenantSlug: tenant.Slug,
BusinessEmail: "",
YearMonth: report.YearMonth,
MessageCount: report.MessageCount,
TotalCostCents: report.TotalCostCents,
Locale: tenant.Locale,
}
// Find owner email
membership, err := s.repo.GetTenantMembershipByUserID(ctx, tenant.ID)
if err == nil {
user, err := s.repo.GetUserByID(ctx, membership.UserID)
if err == nil {
data.BusinessEmail = user.Email
}
}
if data.BusinessEmail == "" {
return errors.New("no business email found")
}
msg := notifications.RenderSMSUsageEmail(data)
_, err = svc.SendRawEmail(ctx, msg)
if err == nil {
_ = s.repo.MarkSMSReportInvoiceSent(ctx, report.TenantID, report.YearMonth)
}
return err
}
// --- SMS Manager API client ---
type smsManagerMessage struct {
Body string `json:"body"`
To []struct {
PhoneNumber string `json:"phone_number"`
} `json:"to"`
Tag string `json:"tag,omitempty"`
}
type smsManagerResponse struct {
RequestID string `json:"request_id"`
Accepted []struct {
Key string `json:"key"`
MessageID string `json:"message_id"`
} `json:"accepted"`
Rejected []struct {
Key string `json:"key"`
Message string `json:"message,omitempty"`
} `json:"rejected"`
}
func (s *Service) sendToSMSManager(ctx context.Context, phone, body, senderName string) (smsManagerResponse, error) {
if s.apiKey == "" {
return smsManagerResponse{}, ErrSMSMissingAPIKey
}
payload := smsManagerMessage{
Body: body,
To: []struct {
PhoneNumber string `json:"phone_number"`
}{{PhoneNumber: phone}},
Tag: "transactional",
}
jsonBody, err := json.Marshal(payload)
if err != nil {
return smsManagerResponse{}, err
}
req, err := http.NewRequestWithContext(ctx, "POST", s.baseURL+"/message", bytes.NewReader(jsonBody))
if err != nil {
return smsManagerResponse{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", s.apiKey)
resp, err := s.client.Do(req)
if err != nil {
return smsManagerResponse{}, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return smsManagerResponse{}, err
}
if resp.StatusCode != http.StatusOK {
return smsManagerResponse{}, fmt.Errorf("%w: status=%d body=%s", ErrSMSSendFailed, resp.StatusCode, string(respBody))
}
var result smsManagerResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return smsManagerResponse{}, err
}
if len(result.Rejected) > 0 && len(result.Accepted) == 0 {
return result, fmt.Errorf("%w: %v", ErrSMSSendFailed, result.Rejected)
}
return result, nil
}
func normalizePhone(phone string) string {
p := strings.TrimSpace(phone)
p = strings.ReplaceAll(p, " ", "")
p = strings.ReplaceAll(p, "-", "")
p = strings.TrimPrefix(p, "+")
p = strings.TrimPrefix(p, "00")
// Czech default if no country code and 9 digits
if len(p) == 9 {
p = "420" + p
}
return p
}
+91
View File
@@ -0,0 +1,91 @@
package sms
import (
"context"
"testing"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
func TestSMSServiceEnabled(t *testing.T) {
cfg := config.Config{SMSManagerAPIKey: "test-key"}
svc := NewService(cfg, db.NewMemoryRepository())
if !svc.Enabled() {
t.Fatal("expected SMS service to be enabled")
}
}
func TestSMSServiceDisabledWithoutKey(t *testing.T) {
cfg := config.Config{}
svc := NewService(cfg, db.NewMemoryRepository())
if svc.Enabled() {
t.Fatal("expected SMS service to be disabled")
}
}
func TestGetSettingsForNewTenant(t *testing.T) {
cfg := config.Config{SMSManagerAPIKey: "test-key"}
repo := db.NewMemoryRepository()
svc := NewService(cfg, repo)
ctx := context.Background()
// Use the default tenant ID from memory repository
settings, err := svc.GetSettings(ctx, "5d6b3551-0a3e-4b86-bdf0-e9df20a47148")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if settings.Enabled {
t.Fatal("expected SMS to be disabled by default")
}
if !settings.Available {
t.Fatal("expected SMS to be available when configured")
}
}
func TestUpdateSettingsRequiresProOrBusiness(t *testing.T) {
cfg := config.Config{SMSManagerAPIKey: "test-key"}
repo := db.NewMemoryRepository()
svc := NewService(cfg, repo)
ctx := context.Background()
// Memory repo tenant is "pro" by default. Change to starter by modifying tenant.
_, err := svc.UpdateSettings(ctx, "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", domain.UpdateSMSSettingsRequest{Enabled: true})
// The memory repo tenant is "pro", so this should succeed
if err != nil {
t.Fatalf("unexpected error for pro tenant: %v", err)
}
}
func TestSendMessageRequiresEnabledSMS(t *testing.T) {
cfg := config.Config{SMSManagerAPIKey: "test-key"}
repo := db.NewMemoryRepository()
svc := NewService(cfg, repo)
ctx := context.Background()
_, err := svc.SendMessage(ctx, "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", domain.SendSMSRequest{To: "+420777123456", Body: "Hello"})
if err != ErrSMSNotEnabled {
t.Fatalf("expected ErrSMSNotEnabled, got: %v", err)
}
}
func TestNormalizePhone(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"+420 777 123 456", "420777123456"},
{"420777123456", "420777123456"},
{"777123456", "420777123456"},
{" 777-123-456 ", "420777123456"},
{"+49 151 12345678", "4915112345678"},
}
for _, tc := range tests {
result := normalizePhone(tc.input)
if result != tc.expected {
t.Errorf("normalizePhone(%q) = %q, want %q", tc.input, result, tc.expected)
}
}
}
+4
View File
@@ -31,6 +31,10 @@ func NewService(repo db.Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) GetTenantMembership(ctx context.Context, principal domain.Principal) (db.TenantMembershipRecord, error) {
return s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
}
func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (domain.TenantBootstrap, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
@@ -0,0 +1,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
+47
View File
@@ -0,0 +1,47 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS tenant_sms_settings (
tenant_id uuid PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
enabled boolean NOT NULL DEFAULT false,
sender_name text NOT NULL DEFAULT '',
monthly_limit integer,
stripe_subscription_item_id text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS sms_usage_logs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
recipient_phone text NOT NULL,
message_body text NOT NULL,
external_message_id text,
external_request_id text,
status text NOT NULL DEFAULT 'pending',
cost_cents integer NOT NULL DEFAULT 150,
sent_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS sms_monthly_reports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
year_month text NOT NULL,
message_count integer NOT NULL DEFAULT 0,
total_cost_cents integer NOT NULL DEFAULT 0,
stripe_invoice_id text,
invoice_sent_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, year_month)
);
CREATE INDEX IF NOT EXISTS idx_sms_usage_tenant_month ON sms_usage_logs (tenant_id, date_trunc('month', created_at));
CREATE INDEX IF NOT EXISTS idx_sms_usage_tenant_created ON sms_usage_logs (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sms_reports_tenant ON sms_monthly_reports (tenant_id, year_month DESC);
-- +goose Down
DROP INDEX IF EXISTS idx_sms_reports_tenant;
DROP INDEX IF EXISTS idx_sms_usage_tenant_created;
DROP INDEX IF EXISTS idx_sms_usage_tenant_month;
DROP TABLE IF EXISTS sms_monthly_reports;
DROP TABLE IF EXISTS sms_usage_logs;
DROP TABLE IF EXISTS tenant_sms_settings;
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS customer_phone text;
-- +goose Down
ALTER TABLE bookings DROP COLUMN IF EXISTS customer_phone;
+35 -1
View File
@@ -639,9 +639,16 @@ components:
planCode:
type: string
enum: [starter, pro, business]
description: The plan to subscribe to
currency:
type: string
enum: [czk, usd]
description: Currency for the subscription
billingInterval:
type: string
enum: [monthly, yearly]
default: monthly
description: Billing interval. Yearly gets 17% discount.
PlanDisplayPrice:
type: object
required: [currency, amountCents, formatted]
@@ -651,29 +658,56 @@ components:
enum: [czk, usd]
amountCents:
type: integer
description: Monthly price in cents
formatted:
type: string
description: Formatted monthly price string
yearlyAmountCents:
type: integer
description: Yearly price in cents (17% discount)
yearlyFormatted:
type: string
description: Formatted yearly price string
yearlySavings:
type: string
description: Description of yearly savings
yearlySavingsPercent:
type: integer
description: Percentage saved with yearly billing
CheckoutLaunchResponse:
type: object
required: [priceId, successRedirectUrl, cancelRedirectUrl, customData]
description: |
Checkout launch response supporting both Stripe and Paddle providers.
For Stripe: checkoutUrl is returned (redirect-based checkout).
For Paddle: priceId, customerId, customerEmail, customData are returned (client-side checkout).
properties:
checkoutUrl:
type: string
format: uri
description: Stripe checkout URL (redirect the user to this URL)
priceId:
type: string
description: Paddle price ID for client-side checkout
customerId:
type: string
description: Paddle customer ID
customerEmail:
type: string
format: email
description: Customer email for Paddle checkout
successRedirectUrl:
type: string
format: uri
description: URL to redirect after successful checkout
cancelRedirectUrl:
type: string
format: uri
description: URL to redirect after cancelled checkout
customData:
type: object
additionalProperties:
type: string
description: Custom metadata for Paddle checkout
PortalSessionResponse:
type: object
required: [url]
+11 -3
View File
@@ -1,13 +1,21 @@
<!doctype html>
<html lang="en">
<html lang="cs">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Bookra - Calm booking software for salons, clinics, and local service businesses. Simple setup, reliable scheduling, clear reminders." />
<meta name="description" content="Bookra — jednoduchý rezervační software pro salony, kliniky a lokální služby. Rychlé nastavení, spolehlivé plánování, automatická připomenutí." />
<meta name="theme-color" content="#f6f4ee" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#1a1816" media="(prefers-color-scheme: dark)" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>Bookra — Calm Booking Software</title>
<link rel="canonical" href="https://bookra.eu" />
<title>Bookra — Jednoduchý rezervační software</title>
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://bookra.eu" />
<meta property="og:title" content="Bookra — Jednoduchý rezervační software" />
<meta property="og:description" content="Spravujte rezervace, zákazníky a tým na jednom místě. Bez zbytečné složitosti." />
<meta property="og:locale" content="cs_CZ" />
<!-- Preconnect to Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
+2
View File
@@ -14,7 +14,9 @@
"@bookra/shared-types": "0.1.0",
"@neondatabase/neon-js": "^0.2.0-beta.1",
"@paddle/paddle-js": "^1.3.2",
"@sentry/react": "^10.52.0",
"@solidjs/router": "^0.15.3",
"@stripe/stripe-js": "^4.0.0",
"solid-js": "^1.9.5"
},
"devDependencies": {
File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 99 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 100 KiB

@@ -0,0 +1,46 @@
import { For, type JSX, createEffect } from "solid-js";
export type AnimationType = "scale" | "slide" | "fade" | "bounce";
export interface AnimatedListProps<T> {
items: T[];
renderItem: (item: T, index: number) => JSX.Element;
maxVisible?: number;
gap?: number;
animation?: AnimationType;
className?: string;
}
function animationClass(type: AnimationType) {
switch (type) {
case "slide": return "animate-list-slide";
case "fade": return "animate-list-fade";
case "bounce": return "animate-list-bounce";
case "scale":
default: return "animate-list-scale";
}
}
export function AnimatedList<T extends { id: string | number }>(props: AnimatedListProps<T>) {
const maxVisible = () => props.maxVisible ?? 8;
const gap = () => props.gap ?? 12;
const animType = () => props.animation ?? "scale";
const visible = () => props.items.slice(0, maxVisible());
return (
<div class={`flex flex-col ${props.className ?? ""}`} style={{ gap: `${gap()}px` }}>
<For each={visible()}>
{(item, index) => (
<div
class={`${animationClass(animType())} will-change-transform`}
style={{
"animation-delay": `${index() * 40}ms`,
}}
>
{props.renderItem(item, index())}
</div>
)}
</For>
</div>
);
}
@@ -0,0 +1,105 @@
import { createSignal, onCleanup, onMount } from "solid-js";
export function DashboardMockup() {
const [activeBar, setActiveBar] = createSignal<number | null>(null);
const [pulseKpi, setPulseKpi] = createSignal<number | null>(null);
let timer: ReturnType<typeof setInterval>;
onMount(() => {
let idx = 0;
timer = setInterval(() => {
setActiveBar(idx % 7);
setPulseKpi(idx % 3);
idx++;
}, 1200);
});
onCleanup(() => clearInterval(timer));
const kpis = [
{ label: "Rezervace", value: "48", trend: "+12%" },
{ label: "Obrat", value: "24K", trend: "+8%" },
{ label: "Klienti", value: "156", trend: "+5%" },
];
const bookings = [
{ time: "09:00", name: "Martina N.", service: "Masáž" },
{ time: "11:30", name: "David S.", service: "Fyzio" },
{ time: "14:00", name: "Jana K.", service: "Manikúra" },
];
const barData = [35, 55, 40, 70, 50, 85, 60];
const barLabels = ["Po", "Út", "St", "Čt", "Pá", "So", "Ne"];
return (
<div class="w-full h-full flex flex-col gap-2 select-none">
{/* Mini header */}
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<div class="w-4 h-4 rounded bg-accent/20" />
<span class="text-[10px] font-medium text-ink-muted">Dashboard</span>
</div>
<div class="flex gap-1">
<div class="w-1.5 h-1.5 rounded-full bg-success" />
<span class="text-[9px] text-ink-subtle">Online</span>
</div>
</div>
{/* KPI row */}
<div class="grid grid-cols-3 gap-2">
{kpis.map((kpi, i) => (
<div
class={`rounded-lg border border-border/60 p-2 bg-canvas-subtle/40 transition-all duration-500 ${
pulseKpi() === i ? "ring-1 ring-accent/30" : ""
}`}
>
<p class="text-[9px] text-ink-subtle mb-0.5">{kpi.label}</p>
<p class="text-sm font-display font-bold text-ink leading-none">{kpi.value}</p>
<p class="text-[9px] text-success mt-0.5">{kpi.trend}</p>
</div>
))}
</div>
{/* Mini bar chart */}
<div class="flex-1 min-h-0 rounded-lg border border-border/60 bg-canvas-subtle/30 p-2 flex flex-col">
<p class="text-[9px] text-ink-subtle mb-1.5">Trend rezervací</p>
<div class="flex-1 flex items-end gap-1">
{barData.map((h, i) => (
<div class="flex-1 flex flex-col items-center gap-1 group">
<div
class={`w-full rounded-t transition-all duration-700 ease-out ${
activeBar() === i ? "bg-accent" : "bg-accent/25"
}`}
style={{ height: `${h}%` }}
/>
<span class="text-[8px] text-ink-subtle">{barLabels[i]}</span>
</div>
))}
</div>
</div>
{/* Mini booking list */}
<div class="rounded-lg border border-border/60 bg-canvas-subtle/30 p-2">
<p class="text-[9px] text-ink-subtle mb-1.5">Dnešní rezervace</p>
<div class="space-y-1">
{bookings.map((b, i) => (
<div
class={`flex items-center gap-2 rounded px-1.5 py-1 transition-colors duration-300 ${
activeBar() === i ? "bg-accent/10" : ""
}`}
>
<span class="text-[9px] font-medium text-accent w-7 shrink-0">{b.time}</span>
<div class="w-4 h-4 rounded-full bg-accent-subtle flex items-center justify-center text-[7px] font-bold text-accent shrink-0">
{b.name.split(" ").map((n) => n[0]).join("")}
</div>
<div class="min-w-0">
<p class="text-[10px] font-medium text-ink truncate">{b.name}</p>
<p class="text-[8px] text-ink-subtle">{b.service}</p>
</div>
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,51 @@
import { Show } from "solid-js";
import { useI18n } from "../../providers/i18n-provider";
import { CheckCircleIcon, XCircleIcon, UsersIcon, ClockIcon, BellIcon, MoreHorizontalIcon } from "./icons";
export function ActivityTimeline(props: { activities: any[] }) {
const i18n = useI18n();
const getIcon = (type: string) => {
const base = "w-8 h-8 rounded-full flex items-center justify-center shrink-0";
switch (type) {
case "booking": return <div class={`${base} bg-accent-subtle text-accent`}><CheckCircleIcon /></div>;
case "cancel": return <div class={`${base} bg-canvas-muted text-ink-muted`}><XCircleIcon /></div>;
case "client": return <div class={`${base} bg-accent-subtle text-accent`}><UsersIcon /></div>;
case "reschedule": return <div class={`${base} bg-canvas-muted text-ink-muted`}><ClockIcon /></div>;
case "reminder": return <div class={`${base} bg-accent-subtle text-accent`}><BellIcon /></div>;
default: return <div class={`${base} bg-canvas-muted text-ink-muted`}><MoreHorizontalIcon /></div>;
}
};
return (
<div class="surface-card p-6 shadow-sm">
<h3 class="text-lg font-semibold text-ink mb-5">{i18n.t("dashboard.recentActivity")}</h3>
<Show when={props.activities.length > 0} fallback={
<div class="text-center py-8">
<p class="text-sm text-ink-muted">{i18n.t("dashboard.recentActivity.empty")}</p>
</div>
}>
<div class="space-y-0">
{props.activities.map((activity, index) => (
<div class="group flex gap-4 relative" style={{ "animation-delay": `${index * 75}ms` }}>
{index < props.activities.length - 1 && (
<div class="absolute left-4 top-8 bottom-0 w-px bg-border" />
)}
<div class="relative z-10 flex-shrink-0 transition-transform duration-300 group-hover:scale-110">
{getIcon(activity.type)}
</div>
<div class="flex-1 pb-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-semibold text-ink group-hover:text-accent transition-colors">{activity.action}</p>
<p class="text-sm text-ink-muted mt-0.5 truncate">{activity.detail}</p>
</div>
<span class="text-xs text-ink-subtle whitespace-nowrap shrink-0">{activity.time}</span>
</div>
</div>
</div>
))}
</div>
</Show>
</div>
);
}
@@ -0,0 +1,266 @@
import { createSignal, createMemo, Show, For } from "solid-js";
import { useI18n } from "../../providers/i18n-provider";
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "./icons";
const CalendarIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
);
export function CalendarView(props: { bookings: any[]; locale?: string; onBookingClick?: (b: any) => void }) {
const i18n = useI18n();
const [currentDate, setCurrentDate] = createSignal(new Date());
const [selectedDay, setSelectedDay] = createSignal<number | null>(null);
const [modalDay, setModalDay] = createSignal<number | null>(null);
const [isAnimating, setIsAnimating] = createSignal(false);
const isCs = () => props.locale === "cs";
const calendarMonth = createMemo(() => currentDate().getMonth());
const calendarYear = createMemo(() => currentDate().getFullYear());
const monthYear = createMemo(() =>
`${i18n.t(`calendar.months.${calendarMonth()}`)} ${calendarYear()}`
);
const changeMonth = (direction: number) => {
setIsAnimating(true);
setTimeout(() => {
setCurrentDate(new Date(currentDate().getFullYear(), currentDate().getMonth() + direction, 1));
setIsAnimating(false);
setSelectedDay(null);
setModalDay(null);
}, 150);
};
const calendarDays = createMemo(() => {
const year = calendarYear();
const month = calendarMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startingDay = firstDay.getDay();
const adjustedStart = startingDay === 0 ? 6 : startingDay - 1;
const days: Array<{ day: number | null; bookings: any[]; isToday: boolean }> = [];
for (let i = 0; i < adjustedStart; i++) {
days.push({ day: null, bookings: [], isToday: false });
}
const today = new Date();
for (let day = 1; day <= daysInMonth; day++) {
const dayBookings = props.bookings.filter((b) => {
const bd = new Date(b.startsAt);
return bd.getDate() === day && bd.getMonth() === month && bd.getFullYear() === year;
});
const isToday = today.getDate() === day && today.getMonth() === month && today.getFullYear() === year;
days.push({ day, bookings: dayBookings, isToday });
}
return days;
});
const handleDayClick = (day: number | null) => {
if (!day) return;
const dayData = calendarDays().find((d) => d.day === day);
if (dayData && dayData.bookings.length > 0) {
setModalDay(day);
} else {
setSelectedDay(selectedDay() === day ? null : day);
}
};
const handleBookingClick = (booking: any) => {
props.onBookingClick?.(booking);
setModalDay(null);
};
const modalBookings = createMemo(() => {
const day = modalDay();
if (!day) return [];
return calendarDays().find((d) => d.day === day)?.bookings ?? [];
});
// Booking visual density for bars
const getBookingBars = (bookings: any[]) => {
if (bookings.length === 0) return null;
const count = bookings.length;
const hasMultiple = count >= 2;
const hasMany = count >= 3;
return (
<div class="mt-2 space-y-1.5">
<div class="h-2 bg-accent/30 rounded-full w-full group-hover:bg-accent/40 transition-colors" />
{hasMultiple && <div class="h-2 bg-accent/20 rounded-full w-3/4 group-hover:bg-accent/30 transition-colors" />}
{hasMany && <div class="h-2 bg-accent/10 rounded-full w-1/2 group-hover:bg-accent/20 transition-colors" />}
</div>
);
};
return (
<>
<div class="surface-card p-6 shadow-sm hover:shadow-md transition-all duration-500">
{/* Header */}
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center text-accent">
<CalendarIcon />
</div>
<div>
<h3 class="text-lg font-semibold text-ink font-display">{monthYear()}</h3>
<p class="text-sm text-ink-muted mt-0.5">
{props.bookings.length} {isCs() ? i18n.t("dashboard.calendar.bookingsCount") : "bookings"}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<button
onClick={() => changeMonth(-1)}
aria-label={i18n.t("calendar.prevMonth")}
class="w-8 h-8 rounded-lg border border-border flex items-center justify-center text-ink-muted hover:bg-canvas-subtle transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 18-6-6 6-6"/></svg>
</button>
<button
onClick={() => changeMonth(1)}
aria-label={i18n.t("calendar.nextMonth")}
class="w-8 h-8 rounded-lg border border-border flex items-center justify-center text-ink-muted hover:bg-canvas-subtle transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg>
</button>
</div>
</div>
{/* Day headers */}
<div class="grid grid-cols-7 gap-px bg-border/50">
{[0, 1, 2, 3, 4, 5, 6].map((dayIndex) => (
<div class="bg-canvas-subtle/50 p-3 text-center text-xs font-medium text-ink-subtle uppercase tracking-wider">
{i18n.t(`calendar.days.${dayIndex}`)}
</div>
))}
</div>
{/* Calendar grid */}
<div class={`grid grid-cols-7 gap-px bg-border/50 transition-opacity duration-200 ${isAnimating() ? "opacity-0" : "opacity-100"}`}>
{calendarDays().map(({ day, bookings, isToday }) => {
if (!day) {
return <div class="p-3 min-h-[80px] lg:min-h-[100px] bg-canvas/50" />;
}
return (
<button
onClick={() => handleDayClick(day)}
class={`
p-3 min-h-[80px] lg:min-h-[100px] bg-canvas transition-all duration-200 hover:bg-canvas-subtle group relative text-left
${isToday ? "ring-2 ring-inset ring-accent bg-accent-soft" : ""}
${selectedDay() === day ? "ring-2 ring-inset ring-accent bg-accent-subtle/30" : ""}
${bookings.length > 0 && !isToday ? "bg-accent-subtle/10" : ""}
`}
>
<span class={`text-sm transition-colors ${isToday ? "font-semibold text-accent" : "text-ink-muted group-hover:text-ink"}`}>
{day}
</span>
{getBookingBars(bookings)}
{bookings.length > 0 && (
<div class="absolute top-2 right-2">
<span class="text-[10px] font-semibold text-accent bg-accent/10 px-1.5 py-0.5 rounded-full">
{bookings.length}
</span>
</div>
)}
</button>
);
})}
</div>
{/* Inline selected day preview for empty days */}
<Show when={selectedDay() && !modalDay()}>
<div class="mt-4 p-4 bg-canvas-subtle/50 rounded-xl border border-border/60 animate-fade-in">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-ink">
{selectedDay()}. {monthYear()}
</span>
<button onClick={() => setSelectedDay(null)} class="text-xs text-ink-muted hover:text-ink">
{i18n.t("dashboard.close")}
</button>
</div>
<p class="text-sm text-ink-muted">{i18n.t("dashboard.calendar.noBookings")}</p>
</div>
</Show>
</div>
{/* Day bookings modal */}
<Show when={modalDay()}>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-ink/40 backdrop-blur-sm" onClick={() => setModalDay(null)} />
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-md max-h-[80vh] overflow-hidden animate-scale-in">
<div class="p-5 border-b border-border flex items-center justify-between">
<div>
<h3 class="text-lg font-bold text-ink">
{modalDay()}. {monthYear()}
</h3>
<p class="text-xs text-ink-muted mt-0.5">
{modalBookings().length} {isCs() ? "rezervací" : "bookings"}
</p>
</div>
<button
onClick={() => setModalDay(null)}
class="p-2 hover:bg-canvas-subtle rounded-lg transition-colors"
aria-label={i18n.t("dashboard.close")}
>
<XIcon />
</button>
</div>
<div class="p-5 overflow-y-auto max-h-[60vh] space-y-3">
<For each={modalBookings()}>
{(booking) => (
<button
onClick={() => handleBookingClick(booking)}
class="w-full text-left flex items-center gap-3 p-3 rounded-xl border border-border/60 hover:border-accent/30 hover:bg-accent-subtle/20 transition-all"
>
<div
class={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${
booking.status === "confirmed"
? "bg-accent text-canvas"
: booking.status === "pending"
? "bg-canvas-muted text-ink"
: "bg-canvas-subtle text-ink-muted"
}`}
>
{(booking.customerName ?? "?")
.split(" ")
.map((n: string) => n[0])
.join("")
.slice(0, 2)
.toUpperCase()}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-ink truncate">{booking.customerName}</p>
<p class="text-xs text-ink-muted">{booking.service}</p>
</div>
<div class="text-right shrink-0">
<p class="text-xs font-medium text-ink">
{new Date(booking.startsAt).toLocaleTimeString(isCs() ? "cs-CZ" : "en-US", { hour: "2-digit", minute: "2-digit" })}
</p>
<span
class={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
booking.status === "confirmed"
? "bg-accent/10 text-accent"
: booking.status === "pending"
? "bg-canvas-muted text-ink-muted"
: "bg-canvas-subtle text-ink-subtle"
}`}
>
{i18n.t(`dashboard.${booking.status}`)}
</span>
</div>
</button>
)}
</For>
</div>
</div>
</div>
</Show>
</>
);
}
@@ -176,3 +176,43 @@ export const MailIcon = () => (
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
);
export const EyeIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>
</svg>
);
export const EditIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>
</svg>
);
export const Trash2Icon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>
</svg>
);
export const XCircleIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>
</svg>
);
export const MoreHorizontalIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
);
export const BarChart3Icon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>
);
export const CheckIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
);
export const GlobeIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
);
@@ -0,0 +1,37 @@
import { useI18n } from "../../providers/i18n-provider";
import { TrendingUpIcon, TrendingDownIcon } from "./icons";
export function KpiCard(props: { kpi: any; index: number }) {
const i18n = useI18n();
const trend = () => props.kpi.trend ?? "neutral";
const trendClass = () => {
switch (trend()) {
case "up": return "bg-accent/10 text-accent";
case "down": return "bg-red-500/10 text-red-500";
default: return "bg-canvas-muted text-ink-muted";
}
};
const TrendIcon = () => {
switch (trend()) {
case "up": return <TrendingUpIcon />;
case "down": return <TrendingDownIcon />;
default: return <span class="text-xs">\u2014</span>;
}
};
return (
<div
class="surface-card p-5 hover:shadow-md transition-shadow animate-fade-in"
style={{ "animation-delay": `${props.index * 100}ms` }}
>
<div class="flex items-center justify-between mb-3">
<p class="text-sm text-ink-muted">{props.kpi.label}</p>
<span class={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${trendClass()}`}>
<TrendIcon /> {props.kpi.change}
</span>
</div>
<p class="text-3xl font-bold text-ink font-display">{props.kpi.value}</p>
</div>
);
}
@@ -0,0 +1,170 @@
import { createSignal, createMemo, Show } from "solid-js";
import { useI18n } from "../../providers/i18n-provider";
import { BellIcon } from "./icons";
import { AnimatedList } from "../animated-list";
interface NotificationItem {
id: string;
type: "booking" | "reminder" | "upgrade" | "trial" | "system";
title: string;
message: string;
time: string;
read: boolean;
}
export function NotificationDropdown(props: { bookings?: any[]; billing?: any }) {
const i18n = useI18n();
const [open, setOpen] = createSignal(false);
const notifications = createMemo<NotificationItem[]>(() => {
const items: NotificationItem[] = [];
const cs = i18n.locale() === "cs";
const bookings = props.bookings ?? [];
// Recent bookings as notifications
bookings.slice(0, 3).forEach((b: any, i: number) => {
items.push({
id: `booking-${i}`,
type: "booking",
title: `${cs ? "Nová rezervace" : "New booking"} ${b.customerName}`,
message: `${b.service}${new Date(b.startsAt).toLocaleDateString()}`,
time: new Date(b.startsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }),
read: false,
});
});
// Plan/trial notifications
const billing = props.billing;
if (billing?.subscriptionStatus === "trialing") {
const daysLeft = billing?.trialDaysRemaining ?? 3;
items.push({
id: "trial",
type: "trial",
title: cs ? "Zkušební doba končí" : "Trial ending",
message: cs ? `Zbývá ${daysLeft} dní` : `${daysLeft} days remaining`,
time: "",
read: false,
});
}
if (items.length === 0) {
items.push({
id: "welcome",
type: "system",
title: cs ? "Vítejte v Bookra" : "Welcome to Bookra",
message: cs ? "Začněte vytvořením první rezervace." : "Start by creating your first booking.",
time: "",
read: true,
});
}
return items;
});
const [readIds, setReadIds] = createSignal<Set<string>>(new Set());
const filteredNotifications = createMemo(() =>
notifications().map((n) => ({ ...n, read: n.read || readIds().has(n.id) }))
);
const unreadCount = createMemo(() => filteredNotifications().filter((n) => !n.read).length);
const markAllRead = () => {
setReadIds(new Set(filteredNotifications().map((n) => n.id)));
};
const markRead = (id: string) => {
setReadIds((prev) => { const next = new Set(prev); next.add(id); return next; });
};
const typeIcon = (type: string) => {
const base = "w-8 h-8 rounded-full flex items-center justify-center shrink-0";
switch (type) {
case "booking":
return (
<div class={`${base} bg-accent-subtle text-accent`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
</div>
);
case "reminder":
return (
<div class={`${base} bg-canvas-muted text-ink-muted`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
);
case "upgrade":
case "trial":
return (
<div class={`${base} bg-canvas-muted text-ink-muted`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
</div>
);
default:
return (
<div class={`${base} bg-canvas-muted text-ink-muted`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>
</div>
);
}
};
return (
<div class="relative">
<button
onClick={() => setOpen(!open())}
class="p-2 text-ink-subtle hover:text-ink hover:bg-canvas-subtle rounded-xl transition-all relative"
aria-label={i18n.t("dashboard.notifications")}
>
<BellIcon />
<Show when={unreadCount() > 0}>
<span class="absolute top-1.5 right-1.5 w-2 h-2 bg-accent rounded-full" />
</Show>
</button>
<Show when={open()}>
<div class="absolute right-0 top-full mt-2 w-80 bg-canvas rounded-2xl shadow-xl border border-border z-50 overflow-hidden animate-scale-in">
<div class="p-4 border-b border-border flex items-center justify-between">
<h3 class="font-semibold text-ink">{i18n.t("dashboard.notifications.title")}</h3>
<Show when={unreadCount() > 0}>
<button onClick={markAllRead} class="text-xs text-accent hover:text-accent-hover font-medium">
{i18n.t("dashboard.markAllRead")}
</button>
</Show>
</div>
<div class="max-h-80 overflow-y-auto">
<Show
when={filteredNotifications().length > 0}
fallback={
<div class="p-6 text-center text-sm text-ink-muted">
{i18n.t("dashboard.noNotifications")}
</div>
}
>
<AnimatedList
items={filteredNotifications()}
animation="scale"
gap={0}
renderItem={(n) => (
<button
onClick={() => markRead(n.id)}
class={`w-full text-left flex items-start gap-3 p-4 hover:bg-canvas-subtle/50 transition-colors border-b border-border/50 last:border-0 ${n.read ? "opacity-60" : ""}`}
>
{typeIcon(n.type)}
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-ink truncate">{n.title}</p>
<p class="text-xs text-ink-muted mt-0.5">{n.message}</p>
<Show when={n.time}>
<p class="text-[10px] text-ink-subtle mt-1">{n.time}</p>
</Show>
</div>
{!n.read && <span class="w-2 h-2 rounded-full bg-accent shrink-0 mt-1" />}
</button>
)}
/>
</Show>
</div>
</div>
<div class="fixed inset-0 z-40" onClick={() => setOpen(false)} />
</Show>
</div>
);
}
@@ -0,0 +1,73 @@
import { createMemo } from "solid-js";
import { useI18n } from "../../providers/i18n-provider";
interface DataPoint {
label: string;
value: number;
}
export function RevenueChart(props: { data?: DataPoint[]; bookings?: any[] }) {
const i18n = useI18n();
const chartData = createMemo(() => {
if (props.data && props.data.length > 0) return props.data;
// Build last 7 days from bookings
const days: DataPoint[] = [];
const bookings = props.bookings ?? [];
const today = new Date();
for (let i = 6; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split("T")[0];
const count = bookings.filter((b: any) => {
const bd = new Date(b.startsAt).toISOString().split("T")[0];
return bd === dateStr;
}).length;
days.push({
label: d.toLocaleDateString(i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "short" }),
value: count,
});
}
return days;
});
const maxValue = createMemo(() => Math.max(1, ...chartData().map((d) => d.value)));
const total = createMemo(() => chartData().reduce((s, d) => s + d.value, 0));
return (
<div class="surface-card p-6">
<div class="flex items-start justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-ink">{i18n.t("dashboard.revenueTitle")}</h3>
<p class="text-sm text-ink-muted mt-0.5">{i18n.t("dashboard.revenueSubtitle")}</p>
</div>
<div class="text-right">
<p class="text-2xl font-bold text-ink tracking-tight">{total()}</p>
<p class="text-xs text-ink-muted">{i18n.t("dashboard.totalBookings")}</p>
</div>
</div>
<div class="flex items-end gap-2 sm:gap-3 h-40">
{chartData().map((point, i) => {
const heightPct = Math.round((point.value / maxValue()) * 100);
return (
<div class="flex-1 flex flex-col items-center gap-2">
<div class="w-full flex-1 flex items-end">
<div
class="w-full rounded-t-md bg-accent/80 hover:bg-accent transition-all duration-300 relative group"
style={{ height: `${Math.max(heightPct, 4)}%` }}
>
<div class="absolute -top-7 left-1/2 -translate-x-1/2 px-2 py-0.5 rounded-md bg-ink text-canvas text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
{point.value}
</div>
</div>
</div>
<span class="text-[10px] sm:text-xs text-ink-muted font-medium uppercase">{point.label}</span>
</div>
);
})}
</div>
</div>
);
}
@@ -1,6 +1,6 @@
import type { JSX } from "solid-js";
export type Section = "overview" | "bookings" | "customers" | "zones" | "billing" | "settings";
export type Section = "overview" | "bookings" | "customers" | "zones" | "billing" | "settings" | "analytics";
export type BookingStatus = "confirmed" | "pending" | "cancelled";
export interface KpiData {
@@ -0,0 +1,83 @@
import { Show, type JSX } from "solid-js";
export interface DockItem {
id: string;
label: string;
icon: JSX.Element;
href?: string;
onClick?: () => void;
variant?: "default" | "danger";
}
export interface FloatingDockProps {
menuItems: DockItem[];
bottomActions?: DockItem[];
activeId?: string;
className?: string;
}
export function FloatingDock(props: FloatingDockProps) {
return (
<div class={`flex flex-col overflow-hidden rounded-3xl bg-canvas border border-border shadow-xl ${props.className ?? ""}`}>
{/* Menu items */}
<div class="flex-1 overflow-y-auto p-4 pb-2">
<div class="flex flex-col gap-0.5">
{props.menuItems.map((item) => {
const content = (
<div
class={`flex h-10 cursor-pointer items-center justify-between gap-2 rounded-xl px-2 text-sm font-medium transition-colors ${
item.variant === "danger"
? "text-error hover:bg-error-subtle"
: "text-ink hover:bg-canvas-subtle"
} ${props.activeId === item.id ? "bg-canvas-subtle" : ""}`}
onClick={item.onClick}
>
<div class="flex items-center gap-2">
<span class="text-ink-muted">{item.icon}</span>
<span class="capitalize">{item.label}</span>
</div>
<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" class="text-ink-subtle">
<path d="m9 18 6-6-6-6"/>
</svg>
</div>
);
if (item.href) {
return (
<a href={item.href} class="block">
{content}
</a>
);
}
return content;
})}
</div>
</div>
{/* Bottom action bar */}
<Show when={props.bottomActions && props.bottomActions.length > 0}>
<div class="shrink-0 border-t border-border/60 p-2">
<div class="flex h-9 w-full items-center justify-center gap-1">
{props.bottomActions!.map((action) => (
<button
onClick={action.onClick}
class={`flex h-full cursor-pointer items-center rounded-2xl text-sm font-medium transition-colors duration-300 text-ink-subtle hover:bg-canvas-subtle hover:text-ink px-2 ${
props.activeId === action.id ? "bg-ink/[0.04] text-ink" : ""
}`}
aria-label={action.label}
>
<span class="size-4">{action.icon}</span>
{props.activeId === action.id && (
<span class="ml-1.5 overflow-hidden whitespace-nowrap font-medium tracking-tight text-xs">
{action.label}
</span>
)}
</button>
))}
</div>
</div>
</Show>
</div>
);
}
@@ -0,0 +1,95 @@
import { Show, type JSX } from "solid-js";
export interface HoverFeatureItem {
name: string;
description: string;
href?: string;
soon?: boolean;
children?: JSX.Element;
containerClassName?: string;
fadeBottom?: boolean;
}
export interface HoverFeatureCardsProps {
items: HoverFeatureItem[];
className?: string;
}
function HoverFeatureCard(props: { item: HoverFeatureItem }) {
const { item } = props;
const inner = (
<div
class={`group flex flex-col w-full relative transition-transform duration-300 ease-out ${
item.soon ? "opacity-70 cursor-not-allowed" : item.href ? "cursor-pointer" : ""
}`}
classList={{
"hover:-translate-y-1": !item.soon,
}}
>
<div
class={`flex flex-col rounded-2xl border h-72 bg-canvas transition-all duration-300 w-full overflow-hidden ${
!item.soon && item.href ? "hover:border-accent/40 hover:shadow-lg" : ""
} ${item.soon ? "border-dashed border-border" : "border-border"}`}
>
<Show when={item.soon}>
<span class="absolute top-3 right-3 z-10 text-xs text-ink-subtle border border-border rounded-full px-2.5 py-1 bg-canvas-subtle">
Coming soon
</span>
</Show>
<div
class={`relative w-full flex-1 overflow-hidden px-5 pt-6 pb-4 flex flex-col gap-3 ${
item.containerClassName ?? ""
}`}
>
<span
class={`font-display font-semibold text-xl tracking-tight ${
item.soon ? "text-ink-subtle" : "text-ink"
}`}
>
{item.name}
</span>
<Show when={item.children}>
<div class="flex-1 min-h-0">{item.children}</div>
</Show>
<Show when={item.fadeBottom}>
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-canvas to-transparent" />
</Show>
</div>
</div>
{/* Description panel that slides up on hover */}
<div
class="overflow-hidden w-11/12 self-center transition-all duration-300 ease-out translate-y-[-16px] opacity-0 group-hover:translate-y-0 group-hover:opacity-100"
>
<div class="py-3 px-5 relative border border-t-0 rounded-b-2xl bg-canvas-subtle/60">
<div class="pointer-events-none w-[103%] bg-gradient-to-b from-canvas-subtle/60 to-transparent h-10 absolute -top-1 -left-1" />
<p class="text-sm text-ink-muted leading-relaxed">{item.description}</p>
</div>
</div>
</div>
);
if (item.href && !item.soon) {
return (
<a href={item.href} class="block">
{inner}
</a>
);
}
return inner;
}
export function HoverFeatureCards(props: HoverFeatureCardsProps) {
return (
<div class={`grid grid-cols-1 sm:grid-cols-2 gap-5 w-full ${props.className ?? ""}`}>
{props.items.map((item) => (
<HoverFeatureCard item={item} />
))}
</div>
);
}
+1
View File
@@ -2,3 +2,4 @@ export { BookraCharacter } from "./bookra-character";
export { LocationMap } from "./location-map";
export { WidgetBuilder } from "./widget-builder";
export { IntegrationModal } from "./integration-modal";
export { FloatingDock } from "./floating-dock";
@@ -1,4 +1,5 @@
import { createSignal, For } from "solid-js";
import { useI18n } from "../providers/i18n-provider";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Button, Tabs, TabsList, TabsTrigger, TabsContent, DialogCloseButton } from "./ui";
interface IntegrationModalProps {
@@ -11,6 +12,10 @@ interface IntegrationModalProps {
}
export function IntegrationModal(props: IntegrationModalProps) {
const i18n = useI18n();
const t = (key: string) => i18n.t(key);
const isCs = () => i18n.locale() === "cs";
const [copiedSnippet, setCopiedSnippet] = createSignal<string | null>(null);
const copyToClipboard = async (text: string, type: string) => {
@@ -19,13 +24,20 @@ export function IntegrationModal(props: IntegrationModalProps) {
setTimeout(() => setCopiedSnippet(null), 2000);
};
const hostedPageUrl = `https://bookra.eu/book/${props.tenantSlug}`;
const hostedPageUrl = props.publicBookingUrl;
const baseUrl = (() => {
try {
return new URL(props.publicBookingUrl).origin;
} catch {
return "https://bookra.eu";
}
})();
const htmlWidgetCode = `<div id="bookra-widget"></div>
<script>
(function() {
var script = document.createElement('script');
script.src = "https://bookra.eu/widget.js";
script.src = "${baseUrl}/widget.js";
script.async = true;
script.onload = function() {
BookraWidget.init({
@@ -65,7 +77,7 @@ function App() {
add_action('wp_footer', function() {
?>
<div id="bookra-widget"></div>
<script src="https://bookra.eu/widget.js" async></script>
<script src="${baseUrl}/widget.js" async></script>
<script>
window.addEventListener('load', function() {
BookraWidget.init({
@@ -78,32 +90,34 @@ add_action('wp_footer', function() {
<?php
});`;
const embedTabs = [
{ id: "html", label: t("integration.embed.html"), code: htmlWidgetCode },
{ id: "react", label: t("integration.embed.react"), code: reactWidgetCode },
{ id: "solid", label: t("integration.embed.solid"), code: solidWidgetCode },
{ id: "php", label: t("integration.embed.php"), code: phpWidgetCode },
];
return (
<Dialog open={props.isOpen} onClose={props.onClose}>
<DialogContent class="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="text-2xl font-display">
Add Bookra to Your Website
</DialogTitle>
<DialogDescription>
Choose how you want to integrate Bookra with your business. Share a link or embed directly on your website.
</DialogDescription>
<DialogTitle class="text-2xl font-display">{t("integration.title")}</DialogTitle>
<DialogDescription>{t("integration.subtitle")}</DialogDescription>
</DialogHeader>
<DialogCloseButton onClose={props.onClose} />
<Tabs defaultValue="hosted" class="mt-6">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="hosted">Hosted Page</TabsTrigger>
<TabsTrigger value="embed">Embed Widget</TabsTrigger>
<TabsTrigger value="hosted">{t("integration.tab.hosted")}</TabsTrigger>
<TabsTrigger value="embed">{t("integration.tab.embed")}</TabsTrigger>
</TabsList>
{/* Hosted Page Tab */}
<TabsContent value="hosted" class="space-y-6 mt-6">
<div class="p-6 bg-canvas-subtle rounded-xl border border-border">
<h4 class="font-display font-semibold text-ink mb-3">Your Booking Page</h4>
<p class="text-sm text-ink-muted mb-4">
Share this link with your customers. They can book directly without any setup on your website.
</p>
<h4 class="font-display font-semibold text-ink mb-3">{t("integration.hosted.title")}</h4>
<p class="text-sm text-ink-muted mb-4">{t("integration.hosted.desc")}</p>
<div class="flex items-center gap-3 p-4 bg-canvas rounded-xl border border-border">
<code class="flex-1 text-sm text-ink truncate font-mono">{hostedPageUrl}</code>
<Button
@@ -111,7 +125,7 @@ add_action('wp_footer', function() {
size="sm"
onClick={() => copyToClipboard(hostedPageUrl, "url")}
>
{copiedSnippet() === "url" ? "Copied!" : "Copy"}
{copiedSnippet() === "url" ? t("common.copied") : t("common.copy")}
</Button>
</div>
@@ -120,7 +134,29 @@ add_action('wp_footer', function() {
<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>
<span>Perfect for social media, email signatures, or direct sharing</span>
<span>{t("integration.hosted.hint")}</span>
</div>
</div>
{/* Demo preview card */}
<div class="p-5 bg-gradient-to-br from-accent-subtle to-accent-soft rounded-xl border border-accent/10">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center text-accent shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</div>
<div class="flex-1">
<h5 class="font-display font-semibold text-ink mb-1">{t("integration.demo.title")}</h5>
<p class="text-sm text-ink-muted mb-3">{t("integration.demo.desc")}</p>
<a
href={hostedPageUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 text-sm font-medium text-accent hover:text-accent-hover transition-colors"
>
{isCs() ? "Otevřít stránku" : "Open page"}
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
</div>
</div>
</div>
@@ -134,54 +170,47 @@ add_action('wp_footer', function() {
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
Share on Facebook
{t("integration.share.facebook")}
</a>
<a
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(`Book your appointment with ${props.tenantName}!`)}&url=${encodeURIComponent(hostedPageUrl)}`}
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-2 p-3 rounded-xl bg-[hsl(200,15%,10%)]/10 text-ink hover:bg-[hsl(200,15%,10%)]/20 transition-colors"
class="flex items-center justify-center gap-2 p-3 rounded-xl bg-ink/5 text-ink hover:bg-ink/10 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
Share on X
{t("integration.share.x")}
</a>
</div>
</TabsContent>
{/* Embed Widget Tab */}
<TabsContent value="embed" class="space-y-6 mt-6">
<div class="p-6 bg-canvas-subtle rounded-xl border border-border">
<h4 class="font-display font-semibold text-ink mb-3">Embed on Your Website</h4>
<p class="text-sm text-ink-muted mb-4">
Add the booking widget directly to your website. Choose your platform:
</p>
<h4 class="font-display font-semibold text-ink mb-3">{t("integration.embed.title")}</h4>
<p class="text-sm text-ink-muted mb-4">{t("integration.embed.desc")}</p>
<Tabs defaultValue="html" class="w-full">
<TabsList class="grid w-full grid-cols-4 mb-4">
<TabsTrigger value="html">HTML/JS</TabsTrigger>
<TabsTrigger value="react">React</TabsTrigger>
<TabsTrigger value="solid">SolidJS</TabsTrigger>
<TabsTrigger value="php">PHP/WordPress</TabsTrigger>
<For each={embedTabs}>
{(tab) => <TabsTrigger value={tab.id}>{tab.label}</TabsTrigger>}
</For>
</TabsList>
<For each={[
{ id: "html", label: "HTML/JavaScript", code: htmlWidgetCode },
{ id: "react", label: "React", code: reactWidgetCode },
{ id: "solid", label: "SolidJS", code: solidWidgetCode },
{ id: "php", label: "PHP/WordPress", code: phpWidgetCode },
]}>
{(item) => (
<TabsContent value={item.id} class="mt-0">
<For each={embedTabs}>
{(tab) => (
<TabsContent value={tab.id} class="mt-0">
<div class="relative">
<pre class="p-4 bg-ink text-canvas rounded-xl overflow-x-auto text-sm font-mono"><code>{item.code}</code></pre>
<pre class="p-4 bg-ink text-canvas rounded-xl overflow-x-auto text-sm font-mono"><code>{tab.code}</code></pre>
<Button
variant="secondary"
size="sm"
class="absolute top-3 right-3"
onClick={() => copyToClipboard(item.code, item.id)}
onClick={() => copyToClipboard(tab.code, tab.id)}
>
{copiedSnippet() === item.id ? "Copied!" : "Copy"}
{copiedSnippet() === tab.id ? t("common.copied") : t("common.copy")}
</Button>
</div>
</TabsContent>
@@ -189,17 +218,18 @@ add_action('wp_footer', function() {
</For>
</Tabs>
<div class="mt-4 p-4 bg-[hsl(var(--info-subtle))] rounded-xl border border-[hsl(var(--info))/20]">
<div class="mt-4 p-4 bg-info-subtle rounded-xl border border-info/20">
<div class="flex items-start gap-3">
<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-[hsl(var(--info))] mt-0.5 shrink-0">
<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-info mt-0.5 shrink-0">
<circle cx="12" cy="12" r="10"/>
<line x1="12" x2="12" y1="16" y2="12"/>
<line x1="12" x2="12.01" y1="8" y2="8"/>
</svg>
<div>
<p class="text-sm font-medium text-ink">Need help with installation?</p>
<p class="text-sm font-medium text-ink">{t("integration.help.title")}</p>
<p class="text-sm text-ink-muted mt-1">
Contact our support team at <a href="mailto:support@bookra.eu" class="text-accent hover:underline">support@bookra.eu</a> for assistance with embedding the widget.
{t("integration.help.desc")}{" "}
<a href="mailto:support@bookra.eu" class="text-accent hover:underline">support@bookra.eu</a>
</p>
</div>
</div>
+11 -2
View File
@@ -1,4 +1,5 @@
import { Show, createEffect, createSignal, onCleanup, onMount } from "solid-js";
import { useTheme } from "../providers/theme-provider";
import {
DEFAULT_MAP_STYLE_ID,
resolveMapTileStyle,
@@ -95,8 +96,16 @@ export function LocationMap(props: LocationMapProps) {
const [isReady, setIsReady] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const theme = useTheme();
const isDark = () => theme.resolvedTheme() === "dark";
const zoom = () => props.zoom ?? props.coordinates.zoom ?? 15;
const mapStyle = () => resolveMapTileStyle(props.mapStyle ?? DEFAULT_MAP_STYLE_ID, props.customTileUrl);
const resolvedStyleId = () => {
const styleId = props.mapStyle ?? DEFAULT_MAP_STYLE_ID;
if (styleId === "positron" && isDark()) return "dark";
return styleId;
};
const mapStyle = () => resolveMapTileStyle(resolvedStyleId(), props.customTileUrl);
const popupContent = () => {
const label = props.markerLabel ?? props.coordinates.address;
const address = props.coordinates.address && props.coordinates.address !== label ? props.coordinates.address : "";
@@ -192,7 +201,7 @@ export function LocationMap(props: LocationMapProps) {
return (
<div
class={`bookra-location-map relative overflow-hidden rounded-card border border-border bg-canvas-subtle ${props.class ?? ""}`}
class={`bookra-location-map relative overflow-hidden rounded-2xl border border-border shadow-sm bg-canvas-subtle ${props.class ?? ""}`}
style={{ height: `${props.height ?? 360}px` }}
role="img"
aria-label={props.coordinates.address ? `Map for ${props.coordinates.address}` : "Location map"}
@@ -0,0 +1,142 @@
import { createSignal, For, Show, type JSX } from "solid-js";
export interface PinnedListItem {
id: string;
name: string;
subtitle: string;
icon?: JSX.Element;
href?: string;
}
export interface PinnedListProps {
items: PinnedListItem[];
className?: string;
pinnedLabel?: string;
allLabel?: string;
/** Called when pin state changes. Returns new Set of pinned IDs. */
onPinChange?: (pinnedIds: Set<string>) => void;
/** Initial pinned IDs */
initialPinned?: string[];
}
export function PinnedList(props: PinnedListProps) {
const [pinnedIds, setPinnedIds] = createSignal<Set<string>>(
new Set(props.initialPinned ?? [])
);
const togglePin = (id: string) => {
setPinnedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
props.onPinChange?.(next);
return next;
});
};
const pinned = () => props.items.filter((i) => pinnedIds().has(i.id));
const unpinned = () => props.items.filter((i) => !pinnedIds().has(i.id));
const pinnedLabel = () => props.pinnedLabel ?? "Pinned";
const allLabel = () => props.allLabel ?? "All Items";
const isCs = () => props.pinnedLabel === "Připnuto"; // crude locale detection
const DotToggle = (p: { id: string }) => {
const isPinned = () => pinnedIds().has(p.id);
return (
<button
type="button"
onClick={() => togglePin(p.id)}
class={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full transition-all duration-200 ${
isPinned()
? "bg-accent text-canvas hover:bg-accent-hover"
: "bg-canvas-subtle border border-border text-ink-subtle hover:border-accent/40 hover:text-accent"
}`}
aria-label={isPinned() ? "Unpin" : "Pin"}
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
class="transition-transform duration-200"
>
<circle cx="5" cy="5" r="4" class={isPinned() ? "" : "opacity-0"} />
<circle
cx="5"
cy="5"
r="3.5"
fill="none"
stroke="currentColor"
stroke-width="1"
class={isPinned() ? "opacity-0" : ""}
/>
</svg>
</button>
);
};
const ItemCard = (p: { item: PinnedListItem; pinned: boolean }) => {
const content = (
<div
class={`flex items-center gap-3 rounded-xl px-3 py-3 border transition-all duration-200 ${
p.pinned
? "bg-accent-soft border-accent/20"
: "bg-canvas-subtle/40 border-border/50 hover:border-border"
}`}
>
<Show when={p.item.icon}>
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-canvas border border-border text-ink-muted">
{p.item.icon}
</div>
</Show>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-ink">{p.item.name}</p>
<p class="truncate text-xs text-ink-muted">{p.item.subtitle}</p>
</div>
<DotToggle id={p.item.id} />
</div>
);
if (p.item.href) {
return (
<a href={p.item.href} class="block">
{content}
</a>
);
}
return content;
};
return (
<div class={`flex w-full flex-col gap-1.5 ${props.className ?? ""}`}>
<Show when={pinned().length > 0}>
<p class="px-1 pb-0.5 pt-1 text-xs font-medium text-accent uppercase tracking-wide">
{pinnedLabel()}
</p>
<For each={pinned()}>
{(item) => <ItemCard item={item} pinned={true} />}
</For>
</Show>
<Show when={unpinned().length > 0}>
<p
class={`px-1 pb-0.5 text-xs font-medium text-ink-subtle uppercase tracking-wide ${
pinned().length > 0 ? "pt-3" : "pt-1"
}`}
>
{allLabel()}
</p>
<For each={unpinned()}>
{(item) => <ItemCard item={item} pinned={false} />}
</For>
</Show>
</div>
);
}
+33 -10
View File
@@ -76,6 +76,7 @@ export const Shell: ParentComponent = (props) => {
const navigate = useNavigate();
const [mobileMenuOpen, setMobileMenuOpen] = createSignal(false);
const hideHeader = () => location.pathname.startsWith("/dashboard");
const hideFooter = () => location.pathname.startsWith("/dashboard");
const [signInOpen, setSignInOpen] = createSignal(false);
const [authMode, setAuthMode] = createSignal<"sign-in" | "register">("sign-in");
const [name, setName] = createSignal("");
@@ -86,8 +87,10 @@ export const Shell: ParentComponent = (props) => {
const [authNotice, setAuthNotice] = createSignal<string | null>(null);
const [authSubmitting, setAuthSubmitting] = createSignal(false);
const showGoogleSignIn = () => auth.supportsGoogleSignIn() && authMode() === "sign-in";
const isDark = () => theme.resolvedTheme() === "dark";
const navLinks = [
{ href: "/pricing", label: i18n.t("nav.pricing") },
{ href: "/about", label: i18n.t("nav.about") },
{ href: "/contact", label: i18n.t("nav.contact") },
];
@@ -215,11 +218,20 @@ export const Shell: ParentComponent = (props) => {
href="/"
onClick={() => window.scrollTo(0, 0)}
>
<img
src="/bookra-logo.svg"
alt="Bookra"
class="h-8 w-auto transition-transform duration-300 group-hover:scale-105 dark:invert"
/>
<div class="relative h-8">
<img
src="/bookra-illustrations/logo_text_horizontal.svg"
alt="Bookra"
class="h-8 w-auto absolute inset-0 transition-opacity duration-200"
classList={{ "opacity-0": isDark(), "opacity-75": !isDark() }}
/>
<img
src="/bookra-illustrations/logo_text_horizontal_white.svg"
alt="Bookra"
class="h-8 w-auto absolute inset-0 transition-opacity duration-200"
classList={{ "opacity-0": !isDark(), "opacity-75": isDark() }}
/>
</div>
</A>
{/* Desktop Navigation */}
@@ -409,6 +421,7 @@ export const Shell: ParentComponent = (props) => {
<main class="flex-1">{props.children}</main>
<Show when={!hideFooter()}>
{/* Footer */}
<footer class="border-t border-border/60 py-16 bg-canvas-subtle/40">
<div class="section-container">
@@ -416,11 +429,20 @@ export const Shell: ParentComponent = (props) => {
{/* Logo & Description */}
<div class="md:col-span-2 space-y-4">
<A href="/" class="inline-flex items-center gap-4 group" onClick={() => window.scrollTo(0, 0)}>
<img
src="/bookra-logo.svg"
alt="Bookra"
class="h-10 w-auto transition-transform duration-300 group-hover:scale-105 dark:invert"
/>
<div class="relative h-10">
<img
src="/bookra-illustrations/logo_text_horizontal.svg"
alt="Bookra"
class="h-10 w-auto absolute inset-0 transition-opacity duration-200"
classList={{ "opacity-0": isDark(), "opacity-90": !isDark() }}
/>
<img
src="/bookra-illustrations/logo_text_horizontal_white.svg"
alt="Bookra"
class="h-10 w-auto absolute inset-0 transition-opacity duration-200"
classList={{ "opacity-0": !isDark(), "opacity-90": isDark() }}
/>
</div>
</A>
<p class="text-ink-muted max-w-sm leading-relaxed">
{i18n.t("footer.description")}
@@ -480,6 +502,7 @@ export const Shell: ParentComponent = (props) => {
</div>
</div>
</footer>
</Show>
<Dialog open={signInOpen()} onClose={() => setSignInOpen(false)}>
<DialogHeader>
@@ -0,0 +1,329 @@
import { createResource, createSignal, Show, For, Accessor } from "solid-js";
import { apiClient } from "../lib/api-client";
import { Input } from "./ui/input";
import { useI18n } from "../providers/i18n-provider";
interface SMSSettingsData {
enabled: boolean;
senderName: string;
monthlyLimit: number;
messagesSent: number;
totalCostCents: number;
available: boolean;
}
interface SMSReport {
yearMonth: string;
messageCount: number;
totalCostCents: number;
stripeInvoiceId?: string;
invoiceSentAt?: string;
}
interface SMSLog {
id: string;
recipientPhone: string;
status: string;
costCents: number;
createdAt: string;
}
function formatCents(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
function formatMonth(yearMonth: string) {
const [y, m] = yearMonth.split("-");
return `${m}/${y}`;
}
interface SMSSettingsProps {
token: Accessor<string | null | undefined>;
}
export function SMSSettings(props: SMSSettingsProps) {
const i18n = useI18n();
const token = props.token;
const [saving, setSaving] = createSignal(false);
const [notice, setNotice] = createSignal<string | null>(null);
const [error, setError] = createSignal<string | null>(null);
const [settings, { refetch: refetchSettings }] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) {
return {
enabled: false,
senderName: "",
monthlyLimit: 0,
messagesSent: 0,
totalCostCents: 0,
available: true,
} as SMSSettingsData;
}
const response = await (apiClient as any).GET("/v1/sms/settings", {
headers: { Authorization: `Bearer ${bearer}` },
});
return response.data as SMSSettingsData;
});
const [reports] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) return [] as SMSReport[];
const response = await (apiClient as any).GET("/v1/sms/invoices", {
headers: { Authorization: `Bearer ${bearer}` },
});
return (response.data as any)?.reports ?? [];
});
const [logs] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) return [] as SMSLog[];
const response = await (apiClient as any).GET("/v1/sms/history", {
headers: { Authorization: `Bearer ${bearer}` },
});
return (response.data as any)?.logs ?? [];
});
const handleToggle = async () => {
const current = settings();
if (!current) return;
const bearer = token();
if (!bearer) return;
setSaving(true);
setError(null);
setNotice(null);
try {
await (apiClient as any).POST("/v1/sms/settings", {
headers: { Authorization: `Bearer ${bearer}` },
body: {
enabled: !current.enabled,
senderName: current.senderName,
monthlyLimit: current.monthlyLimit,
},
});
await refetchSettings();
setNotice(
i18n.locale() === "cs"
? `SMS ${!current.enabled ? "aktivováno" : "deaktivováno"}`
: `SMS ${!current.enabled ? "enabled" : "disabled"}`
);
} catch (e: any) {
setError(
e?.response?.data?.error ||
(i18n.locale() === "cs" ? "Nepodařilo se uložit nastavení." : "Failed to save settings.")
);
} finally {
setSaving(false);
}
};
const handleSaveSettings = async (e: Event) => {
e.preventDefault();
const current = settings();
if (!current) return;
const bearer = token();
if (!bearer) return;
setSaving(true);
setError(null);
setNotice(null);
try {
await (apiClient as any).POST("/v1/sms/settings", {
headers: { Authorization: `Bearer ${bearer}` },
body: {
enabled: current.enabled,
senderName: current.senderName,
monthlyLimit: current.monthlyLimit,
},
});
await refetchSettings();
setNotice(i18n.locale() === "cs" ? "Nastavení uloženo." : "Settings saved.");
} catch (e: any) {
setError(
e?.response?.data?.error ||
(i18n.locale() === "cs" ? "Nepodařilo se uložit nastavení." : "Failed to save settings.")
);
} finally {
setSaving(false);
}
};
const cs = () => i18n.locale() === "cs";
return (
<div class="surface-card p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full bg-[hsl(var(--info-subtle))] flex items-center justify-center text-[hsl(var(--info))]">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold text-ink">{cs() ? "SMS zprávy" : "SMS Messages"}</h3>
<p class="text-sm text-ink-muted">
{cs()
? "1.50 Kč / SMS. Fakturováno měsíčně přes Stripe."
: "1.50 CZK / SMS. Billed monthly via Stripe."}
</p>
</div>
</div>
<Show when={!settings.loading} fallback={<div class="text-ink-muted">{cs() ? "Načítání..." : "Loading..."}</div>}>
<Show when={settings()?.available === false}>
<div class="p-4 bg-canvas-subtle rounded-xl text-ink-muted text-sm">
{cs()
? "SMS není v této instanci nakonfigurováno. Kontaktujte administrátora."
: "SMS is not configured for this instance. Contact your administrator."}
</div>
</Show>
<Show when={settings()?.available === true}>
<Show when={notice()}>
<div class="mb-4 p-3 bg-emerald-50 text-emerald-700 rounded-lg text-sm">{notice()}</div>
</Show>
<Show when={error()}>
<div class="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error()}</div>
</Show>
{/* Toggle */}
<div class="flex items-center justify-between mb-6 p-4 bg-canvas-subtle rounded-xl">
<div>
<p class="font-medium text-ink">{cs() ? "SMS odesílání" : "SMS sending"}</p>
<p class="text-sm text-ink-muted">
{settings()?.enabled
? (cs() ? "Aktivní — účtováno za každou zprávu" : "Active — charged per message")
: (cs() ? "Neaktivní" : "Inactive")}
</p>
</div>
<button
onClick={handleToggle}
disabled={saving()}
class={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 ${
settings()?.enabled ? "bg-accent" : "bg-gray-300"
}`}
>
<span
class={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${
settings()?.enabled ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
<Show when={settings()?.enabled}>
<form onSubmit={handleSaveSettings} class="space-y-4">
<Input
type="text"
label={cs() ? "Jméno odesílatele (max 11 znaků)" : "Sender name (max 11 chars)"}
value={settings()?.senderName || ""}
onInput={(e) => {
const s = settings();
if (s) s.senderName = e.currentTarget.value;
}}
maxLength={11}
placeholder="Bookra"
/>
<Input
type="number"
label={cs() ? "Měsíční limit (0 = bez limitu)" : "Monthly limit (0 = unlimited)"}
value={settings()?.monthlyLimit || 0}
onInput={(e) => {
const s = settings();
if (s) s.monthlyLimit = parseInt(e.currentTarget.value) || 0;
}}
min={0}
/>
<div class="flex justify-end">
<button
type="submit"
disabled={saving()}
class="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium hover:bg-accent/90 transition-colors disabled:opacity-50"
>
{saving() ? (cs() ? "Ukládání..." : "Saving...") : cs() ? "Uložit" : "Save"}
</button>
</div>
</form>
{/* Current month stats */}
<div class="mt-6 p-4 bg-canvas-subtle rounded-xl">
<h4 class="text-sm font-medium text-ink mb-3">
{cs() ? "Aktuální měsíc" : "Current month"}
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-2xl font-bold text-ink">{settings()?.messagesSent ?? 0}</p>
<p class="text-xs text-ink-muted">{cs() ? "Odeslaných zpráv" : "Messages sent"}</p>
</div>
<div>
<p class="text-2xl font-bold text-ink">{formatCents(settings()?.totalCostCents ?? 0)}</p>
<p class="text-xs text-ink-muted">{cs() ? "Celková cena" : "Total cost"}</p>
</div>
</div>
</div>
{/* Recent logs */}
<Show when={logs() && logs()!.length > 0}>
<div class="mt-6">
<h4 class="text-sm font-medium text-ink mb-3">
{cs() ? "Historie odesílání" : "Send history"}
</h4>
<div class="space-y-2 max-h-48 overflow-y-auto">
<For each={logs()}>
{(log) => (
<div class="flex items-center justify-between p-3 bg-canvas-subtle rounded-lg text-sm">
<div class="flex items-center gap-3">
<span class="text-ink-muted">{log.recipientPhone}</span>
<span
class={`px-2 py-0.5 rounded-full text-xs ${
log.status === "sent"
? "bg-emerald-100 text-emerald-700"
: "bg-red-100 text-red-700"
}`}
>
{log.status}
</span>
</div>
<span class="text-ink-muted">{formatCents(log.costCents)}</span>
</div>
)}
</For>
</div>
</div>
</Show>
{/* Monthly invoice reports */}
<Show when={reports() && reports()!.length > 0}>
<div class="mt-6">
<h4 class="text-sm font-medium text-ink mb-3">
{cs() ? "Fakturační přehledy" : "Invoice reports"}
</h4>
<div class="space-y-2">
<For each={reports()}>
{(report) => (
<div class="flex items-center justify-between p-3 bg-canvas-subtle rounded-lg text-sm">
<div>
<span class="font-medium text-ink">{formatMonth(report.yearMonth)}</span>
<span class="text-ink-muted ml-2">{report.messageCount} SMS</span>
</div>
<div class="flex items-center gap-3">
<span class="font-medium text-ink">{formatCents(report.totalCostCents)}</span>
<Show when={report.stripeInvoiceId}>
<span class="text-xs text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">
Stripe
</span>
</Show>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
</Show>
</Show>
</Show>
</div>
);
}
@@ -0,0 +1,278 @@
import { createSignal, Show, onMount, onCleanup, createMemo } from "solid-js";
const CANVAS_W = 64;
const CANVAS_H = 36;
export interface VideoPlayerProps {
src: string;
poster?: string;
className?: string;
blurAmount?: number;
intensity?: number;
}
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
export function VideoPlayer(props: VideoPlayerProps) {
const blurAmount = () => props.blurAmount ?? 60;
const intensity = () => props.intensity ?? 0.85;
const [playing, setPlaying] = createSignal(false);
const [currentTime, setCurrentTime] = createSignal(0);
const [duration, setDuration] = createSignal(0);
const [isDragging, setIsDragging] = createSignal(false);
let videoRef: HTMLVideoElement | undefined;
let canvasRef: HTMLCanvasElement | undefined;
let trackRef: HTMLDivElement | undefined;
let rafId: number;
const progress = createMemo(() => {
const dur = duration();
if (!dur) return 0;
return (currentTime() / dur) * 100;
});
onMount(() => {
const video = videoRef;
const canvas = canvasRef;
if (!video || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const draw = () => {
if (!video.paused || video.readyState >= 2) {
try {
ctx.drawImage(video, 0, 0, CANVAS_W, CANVAS_H);
} catch {
// ignore cross-origin canvas restrictions
}
}
rafId = requestAnimationFrame(draw);
};
rafId = requestAnimationFrame(draw);
});
onCleanup(() => {
cancelAnimationFrame(rafId);
});
const togglePlay = () => {
if (!videoRef) return;
if (videoRef.paused) {
void videoRef.play();
setPlaying(true);
} else {
videoRef.pause();
setPlaying(false);
}
};
const onVideoEnded = () => setPlaying(false);
const onTimeUpdate = () => {
if (videoRef && !isDragging()) {
setCurrentTime(videoRef.currentTime);
}
};
const onLoadedMetadata = () => {
if (videoRef) {
setDuration(videoRef.duration);
}
};
const seekTo = (clientX: number) => {
const track = trackRef;
const video = videoRef;
if (!track || !video) return;
const rect = track.getBoundingClientRect();
const x = clientX - rect.left;
const pct = Math.max(0, Math.min(1, x / rect.width));
video.currentTime = pct * duration();
setCurrentTime(video.currentTime);
};
const onTrackMouseDown = (e: MouseEvent) => {
e.preventDefault();
setIsDragging(true);
seekTo(e.clientX);
const onMouseMove = (ev: MouseEvent) => seekTo(ev.clientX);
const onMouseUp = () => {
setIsDragging(false);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
};
const onTrackTouchStart = (e: TouchEvent) => {
setIsDragging(true);
seekTo(e.touches[0].clientX);
const onTouchMove = (ev: TouchEvent) => seekTo(ev.touches[0].clientX);
const onTouchEnd = () => {
setIsDragging(false);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("touchend", onTouchEnd);
};
window.addEventListener("touchmove", onTouchMove);
window.addEventListener("touchend", onTouchEnd);
};
return (
<div class={`relative rounded-2xl overflow-hidden bg-canvas border border-border/60 shadow-lg ${props.className ?? ""}`}>
{/* Ambient glow canvas */}
<canvas
ref={(el) => { canvasRef = el; }}
width={CANVAS_W}
height={CANVAS_H}
aria-hidden="true"
class="absolute pointer-events-none rounded-2xl"
style={{
inset: 0,
width: "100%",
height: "100%",
filter: `blur(${blurAmount()}px)`,
opacity: intensity(),
transform: "scale(1.08)",
"z-index": 0,
}}
/>
{/* Main video */}
<div class="relative w-full" style={{ "z-index": 1 }}>
<video
ref={(el) => { videoRef = el; }}
src={props.src}
poster={props.poster}
playsinline
preload="metadata"
class="w-full block bg-canvas rounded-2xl"
onEnded={onVideoEnded}
onClick={togglePlay}
onTimeUpdate={onTimeUpdate}
onLoadedMetadata={onLoadedMetadata}
/>
<button
onClick={togglePlay}
aria-label={playing() ? "Pause" : "Play"}
class="absolute inset-0 w-full h-full bg-transparent cursor-pointer focus:outline-none group"
>
<Show when={!playing()}>
<span
class="absolute inset-0 flex items-center justify-center pointer-events-none transition-all duration-300"
style={{ opacity: 1, transform: "none" }}
>
<span class="bg-black/40 rounded-full p-4 backdrop-blur-sm group-hover:scale-110 transition-transform">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="white"
stroke="none"
aria-hidden="true"
>
<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" />
</svg>
</span>
</span>
</Show>
<Show when={playing()}>
<span
class="absolute inset-0 flex items-center justify-center pointer-events-none transition-all duration-300 opacity-0 group-hover:opacity-100"
>
<span class="bg-black/40 rounded-full p-4 backdrop-blur-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="white"
stroke="none"
aria-hidden="true"
>
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
</span>
</span>
</Show>
</button>
</div>
{/* Scrubber */}
<div class="pt-2">
<div class="w-full">
<div
class="relative flex w-full items-center h-10 touch-none cursor-grab select-none"
onMouseDown={onTrackMouseDown}
onTouchStart={onTrackTouchStart}
>
{/* Current time */}
<span
class="absolute left-0 top-0 transition-colors pointer-events-none rounded-md font-medium px-2 py-1 text-xs tabular-nums text-foreground"
style={{ opacity: 0.8, "background-color": "transparent" }}
>
{formatTime(currentTime())}
</span>
{/* Duration */}
<span
class="absolute right-0 top-0 transition-colors pointer-events-none rounded-md px-2 py-1 text-xs font-medium tabular-nums text-foreground"
style={{ "background-color": "transparent" }}
>
{formatTime(duration())}
</span>
{/* Track */}
<div
ref={(el) => { trackRef = el; }}
class="relative w-full overflow-hidden rounded-xl bg-foreground/30"
style={{ height: "6px", opacity: 0.8 }}
>
{/* Fill */}
<div
class="absolute left-0 top-0 h-full rounded-xl dark:bg-white bg-black transition-[width] duration-75"
style={{ width: `${progress()}%` }}
/>
{/* Buffered / ghost */}
<div
class="absolute h-full pointer-events-none rounded-xl"
style={{ "background-color": "color-mix(in srgb, var(--foreground) 20%, transparent)", left: `${progress()}%`, width: "0px", opacity: 0 }}
/>
</div>
{/* Thumb */}
<span
class="flex items-center justify-center pointer-events-none absolute top-1/2"
style={{
width: "18px",
height: "18px",
"margin-top": "-9px",
left: `${progress()}%`,
transform: "translateX(-50%)",
"z-index": 10,
transition: isDragging() ? "none" : "left 75ms linear",
}}
>
<span
class="block rounded-xl transition-colors bg-background dark:bg-white"
style={{ width: "14px", height: "14px" }}
/>
</span>
</div>
</div>
</div>
</div>
);
}
@@ -215,6 +215,7 @@ export function WidgetBuilder(props: WidgetBuilderProps) {
const [selectedSize, setSelectedSize] = createSignal<WidgetSize>("default");
const [selectedPosition, setSelectedPosition] = createSignal<WidgetPosition>("bottom-right");
const [copiedSnippet, setCopiedSnippet] = createSignal<string | null>(null);
const [copyError, setCopyError] = createSignal<string | null>(null);
const [showPreview, setShowPreview] = createSignal(true);
const [draggedIndex, setDraggedIndex] = createSignal<number | null>(null);
const [customColor, setCustomColor] = createSignal(props.config.primaryColor || "#a65c3e");
@@ -273,8 +274,8 @@ export function WidgetBuilder(props: WidgetBuilderProps) {
map: {
type: "map",
icon: MapIcon,
title: "Location map",
description: "Embed a styled map for your real address.",
title: i18n.t("widget.type.map.title"),
description: i18n.t("widget.type.map.desc"),
},
};
@@ -284,7 +285,7 @@ export function WidgetBuilder(props: WidgetBuilderProps) {
try {
const result = await resolveLocationInput(mapLocationInput());
if (!result) {
setMapMessage("No location found. Paste a Google Maps/Mapy.cz link, coordinates, or full address.");
setMapMessage(i18n.t("widget.map.noLocation"));
return;
}
setMapCoordinates({
@@ -292,7 +293,7 @@ export function WidgetBuilder(props: WidgetBuilderProps) {
zoom: result.zoom ?? mapZoom(),
});
setMapZoom(result.zoom ?? mapZoom());
setMapMessage(result.address ? `Resolved: ${result.address}` : "Location resolved.");
setMapMessage(result.address ? `${i18n.t("widget.map.resolved")}: ${result.address}` : i18n.t("widget.map.resolved"));
} catch (error) {
setMapMessage(error instanceof Error ? error.message : "Location search failed.");
} finally {
@@ -426,15 +427,17 @@ export function BookraLocationMap() {
};
// Drag and drop handlers
const [dropTargetIndex, setDropTargetIndex] = createSignal<number | null>(null);
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: DragEvent, index: number) => {
e.preventDefault();
setDropTargetIndex(index);
const draggedIdx = draggedIndex();
if (draggedIdx === null || draggedIdx === index) return;
const newOrder = [...widgetOrder()];
const [removed] = newOrder.splice(draggedIdx, 1);
newOrder.splice(index, 0, removed);
@@ -444,6 +447,7 @@ export function BookraLocationMap() {
const handleDragEnd = () => {
setDraggedIndex(null);
setDropTargetIndex(null);
};
const generateCode = (type: WidgetType, format: CodeFormat) => {
@@ -1077,9 +1081,11 @@ export class BookraWidgetComponent implements OnInit {
try {
await navigator.clipboard.writeText(text);
setCopiedSnippet(snippetId);
setCopyError(null);
setTimeout(() => setCopiedSnippet(null), 2000);
} catch (err) {
console.error("Failed to copy:", err);
setCopyError(i18n.locale() === 'cs' ? 'Kopírování se nezdařilo. Zkuste to znovu.' : 'Copy failed. Please try again.');
setTimeout(() => setCopyError(null), 3000);
}
};
@@ -1120,7 +1126,7 @@ export class BookraWidgetComponent implements OnInit {
const Icon = option.icon;
return (
<div
draggable
draggable={true}
onDragStart={() => handleDragStart(index())}
onDragOver={(e) => handleDragOver(e, index())}
onDragEnd={handleDragEnd}
@@ -1128,7 +1134,7 @@ export class BookraWidgetComponent implements OnInit {
selectedType() === type
? "border-accent bg-accent/5"
: "border-border hover:border-accent/50 hover:bg-canvas-subtle"
} ${draggedIndex() === index() ? "opacity-50" : ""}`}
} ${draggedIndex() === index() ? "opacity-40 scale-[0.98] shadow-lg ring-2 ring-accent/20" : ""} ${dropTargetIndex() === index() && draggedIndex() !== index() ? "border-accent/60 bg-accent/5" : ""}`}
onClick={() => setSelectedType(type)}
>
<div class="flex items-center gap-3">
@@ -1445,6 +1451,11 @@ export class BookraWidgetComponent implements OnInit {
</div>
</CardHeader>
<CardContent>
<Show when={copyError()}>
<div class="mb-4 p-3 rounded-xl bg-[hsl(var(--error-soft))] border border-[hsl(var(--error))/20] text-[hsl(var(--error))] text-sm font-medium animate-fade-in">
{copyError()}
</div>
</Show>
<Tabs defaultValue="html">
<TabsList class="mb-4 flex-wrap">
<TabsTrigger value="html">HTML</TabsTrigger>
+8 -1
View File
@@ -22,9 +22,16 @@ export interface MapTileStyle {
tileClassName?: string;
}
export const DEFAULT_MAP_STYLE_ID = "bookra-voyager";
export const DEFAULT_MAP_STYLE_ID = "positron";
export const MAP_TILE_STYLES: readonly MapTileStyle[] = [
{
id: "positron",
name: "Minimal",
description: "Ultra-clean light basemap. Perfect for shadcn-style dashboards.",
url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
attribution: "&copy; OpenStreetMap contributors &copy; CARTO",
},
{
id: "bookra-voyager",
name: "Bookra Voyager",
+27
View File
@@ -0,0 +1,27 @@
import * as Sentry from "@sentry/react";
export function initSentry() {
const dsn = import.meta.env.VITE_SENTRY_DSN;
if (!dsn) {
console.log("Sentry DSN not configured - skipping initialization");
return;
}
Sentry.init({
dsn,
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
}),
],
tracesSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
enableLogs: true,
environment: import.meta.env.MODE,
release: `bookra@${import.meta.env.VITE_APP_VERSION || "1.0.0"}`,
});
}
+19
View File
@@ -0,0 +1,19 @@
import { loadStripe, type Stripe } from "@stripe/stripe-js";
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
let stripePromise: Promise<Stripe | null> | null = null;
export function stripeConfigured() {
return stripePublishableKey.trim() !== "";
}
export async function getStripe() {
if (!stripeConfigured()) {
return null;
}
if (!stripePromise) {
stripePromise = loadStripe(stripePublishableKey);
}
return stripePromise;
}
+16 -1
View File
@@ -3,12 +3,21 @@ import { lazy } from "solid-js";
import { Route, Router } from "@solidjs/router";
import App from "./App";
import "./styles/index.css";
import { initSentry } from "./lib/sentry";
initSentry();
const HomeRoute = lazy(() => import("./routes/home-route").then((module) => ({ default: module.HomeRoute })));
const PricingRoute = lazy(() => import("./routes/pricing-route"));
const AboutRoute = lazy(() => import("./routes/about-route").then((module) => ({ default: module.AboutRoute })));
const AuthCallbackRoute = lazy(() => import("./routes/auth-callback-route").then((module) => ({ default: module.AuthCallbackRoute })));
const ContactRoute = lazy(() => import("./routes/contact-route").then((module) => ({ default: module.ContactRoute })));
const DashboardRoute = lazy(() => import("./routes/dashboard-route").then((module) => ({ default: module.DashboardRoute })));
const DashboardRoute = lazy(() => import("./routes/dashboard/overview-page"));
const DashboardBookingsRoute = lazy(() => import("./routes/dashboard/bookings-page"));
const DashboardCustomersRoute = lazy(() => import("./routes/dashboard/customers-page"));
const DashboardZonesRoute = lazy(() => import("./routes/dashboard/zones-page"));
const DashboardBillingRoute = lazy(() => import("./routes/dashboard/billing-page"));
const DashboardSettingsRoute = lazy(() => import("./routes/dashboard/settings-page"));
const PublicBookingRoute = lazy(() => import("./routes/public-booking-route").then((module) => ({ default: module.PublicBookingRoute })));
const BookingManageRoute = lazy(() => import("./routes/booking-manage-route").then((module) => ({ default: module.BookingManageRoute })));
const LegalRoute = lazy(() => import("./routes/legal-route").then((module) => ({ default: module.LegalRoute })));
@@ -18,10 +27,16 @@ render(
() => (
<Router root={App}>
<Route path="/" component={HomeRoute} />
<Route path="/pricing" component={PricingRoute} />
<Route path="/about" component={AboutRoute} />
<Route path="/auth/callback" component={AuthCallbackRoute} />
<Route path="/contact" component={ContactRoute} />
<Route path="/dashboard" component={DashboardRoute} />
<Route path="/dashboard/bookings" component={DashboardBookingsRoute} />
<Route path="/dashboard/customers" component={DashboardCustomersRoute} />
<Route path="/dashboard/zones" component={DashboardZonesRoute} />
<Route path="/dashboard/billing" component={DashboardBillingRoute} />
<Route path="/dashboard/settings" component={DashboardSettingsRoute} />
<Route path="/book/:tenantSlug" component={PublicBookingRoute} />
<Route path="/manage/:reference" component={BookingManageRoute} />
<Route path="/:kind" component={LegalRoute} matchFilters={{ kind: ["privacy", "terms"] }} />
+360 -14
View File
@@ -13,6 +13,7 @@ const dictionaries = {
// Navigation & Auth
"nav.booking": "Veřejná rezervace",
"nav.dashboard": "Aplikace",
"nav.pricing": "Ceník",
"nav.about": "O nás",
"nav.contact": "Kontakt",
@@ -77,7 +78,7 @@ const dictionaries = {
// Hero Section
"home.badge": "Nyní zdarma pro začátečníky",
"home.hero.title": "Klidný rezervační software pro lokální služby",
"home.hero.title": "Jednoduchý rezervační software pro lokální služby",
"home.hero.subtitle": "Spravujte rezervace, zákazníky a tým na jednom místě. Bez zbytečné složitosti — jen spolehlivý systém, který funguje.",
"home.hero.cta.primary": "Začít zdarma",
"home.hero.cta.secondary": "Otevřít rezervaci",
@@ -164,6 +165,26 @@ const dictionaries = {
"home.pricing.biz.cta": "Kontaktovat prodej",
"home.pricing.biz.trial": "Individuální řešení na míru",
// Comparison
"pricing.compare.eyebrow": "Detailní srovnání",
"pricing.compare.title": "Porovnání plánů",
"pricing.compare.feature": "Funkce",
"pricing.compare.locations": "Lokace",
"pricing.compare.staff": "Zaměstnanci",
"pricing.compare.bookings": "Rezervací/měsíc",
"pricing.compare.emailSupport": "E-mailová podpora",
"pricing.compare.reminders": "E-mailová připomenutí",
"pricing.compare.analytics": "Analytika",
"pricing.compare.api": "API přístup",
"pricing.compare.branding": "Vlastní branding",
"pricing.compare.whiteLabel": "Bílý labeling",
"pricing.compare.manager": "Dedikovaný manažer",
"pricing.compare.priority": "Prioritní",
"pricing.compare.dedicated": "Dedikovaný",
"pricing.compare.advanced": "Pokročilá",
"pricing.compare.yes": "Ano",
"pricing.compare.no": "Ne",
// CTA
"home.cta.title": "Připraveni zjednodušit své rezervace?",
"home.cta.subtitle": "Připojte se k tisícům podniků, které šetří čas s Bookra.",
@@ -190,6 +211,10 @@ const dictionaries = {
"widget.type.floating.title": "Plovoucí bublina",
"widget.type.floating.desc": "Plovoucí tlačítko v rohu obrazovky",
"widget.type.floating.preview": "Nejlepší pro: E-shopy, kontinuální dostupnost",
"widget.type.map.title": "Mapa lokace",
"widget.type.map.desc": "Vložte stylizovanou mapu s vaší reálnou adresou.",
"widget.map.noLocation": "Lokace nenalezena. Vložte odkaz Google Maps/Mapy.cz, souřadnice nebo celou adresu.",
"widget.map.resolved": "Lokace nalezena",
"widget.button.text": "Rezervovat termín",
"widget.modal.trigger": "Otevřít rezervaci",
"widget.styling.title": "Vzhled",
@@ -227,20 +252,80 @@ const dictionaries = {
"common.copy": "Kopírovat",
"common.copied": "Zkopírováno!",
// Integration Modal
"integration.title": "Přidat Bookra na váš web",
"integration.subtitle": "Vyberte, jak chcete Bookra integrovat. Sdílejte odkaz nebo vložte přímo na web.",
"integration.tab.hosted": "Váš odkaz",
"integration.tab.embed": "Vložit widget",
"integration.hosted.title": "Vaše rezervační stránka",
"integration.hosted.desc": "Sdílejte tento odkaz zákazníkům. Rezervují bez jakéhokoli nastavení.",
"integration.hosted.hint": "Ideální pro sociální sítě, podpis e-mailu nebo přímé sdílení",
"integration.share.facebook": "Sdílet na Facebooku",
"integration.share.x": "Sdílet na X",
"integration.embed.title": "Vložit na web",
"integration.embed.desc": "Přidejte rezervační widget přímo na váš web. Vyberte platformu:",
"integration.embed.html": "HTML/JS",
"integration.embed.react": "React",
"integration.embed.solid": "SolidJS",
"integration.embed.php": "PHP/WordPress",
"integration.help.title": "Potřebujete pomoc s instalací?",
"integration.help.desc": "Napište nám na",
"integration.demo.title": "Rezervační stránka",
"integration.demo.desc": "Zákazníci rezervují přes váš odkaz. Žádná instalace.",
// Footer
"footer.copyright": "© 2026 Bookra. Všechna práva vyhrazena.",
"footer.privacy": "Ochrana soukromí",
"footer.terms": "Podmínky použití",
"footer.description": "Klidný rezervační software pro lokální služby. Spravujte rezervace, zákazníky a tým na jednom místě.",
"footer.description": "Jednoduchý rezervační software pro lokální služby. Spravujte rezervace, zákazníky a tým na jednom místě.",
"footer.links.title": "Navigace",
"footer.legal.title": "Právní informace",
// Dashboard (existing)
// Dashboard
"dashboard.title": "Přehled podniku",
"dashboard.body": "Sledujte rezervace, nastavení, předplatné a rezervační widget na jednom místě.",
"dashboard.kpi.bookings": "Rezervace tento týden",
"dashboard.kpi.cancellations": "Zrušení",
"dashboard.kpi.utilization": "Vytížení",
"dashboard.overview": "Přehled",
"dashboard.bookings": "Rezervace",
"dashboard.customers": "Zákazníci",
"dashboard.zones": "Zóny a dostupnost",
"dashboard.billing": "Platby",
"dashboard.settings": "Nastavení",
"dashboard.welcome": "Dobrý den,",
"dashboard.overviewFor": "Přehled pro",
"dashboard.kpi.bookings": "Celkem rezervací",
"dashboard.kpi.cancelled": "Zrušených",
"dashboard.kpi.completed": "Dokončených",
"dashboard.kpi.newClients": "Noví klienti",
"dashboard.recentActivity": "Nedávná aktivita",
"dashboard.upcomingBookings": "Nadcházející rezervace",
"dashboard.viewAll": "Zobrazit vše",
"dashboard.locationLimitReached": "Dosáhli jste limitu lokací!",
"dashboard.nearingLocationLimit": "Blížíte se limitu lokací",
"dashboard.locationsUsed": "lokací použito",
"dashboard.upgrade": "Upgrade",
"dashboard.shareManage": "Sdílet/Spravit",
"dashboard.notifications": "Oznámení",
"dashboard.bookingManagement": "Správa rezervací",
"dashboard.totalBookings": "celkem rezervací",
"dashboard.newBooking": "Nová rezervace",
"dashboard.bookingDetails": "Detail rezervace",
"dashboard.customerDetails": "Detail zákazníka",
"dashboard.close": "Zavřít",
"dashboard.edit": "Upravit",
"dashboard.cancel": "Zrušit",
"dashboard.details": "Detail",
"dashboard.saveChanges": "Uložit změny",
"dashboard.createBooking": "Vytvořit rezervaci",
"dashboard.preview": "Zobrazit náhled",
"dashboard.saveEmailSettings": "Uložit nastavení emailů",
"dashboard.saving": "Ukládání...",
"dashboard.creating": "Vytváření...",
"dashboard.prevMonth": "Předchozí měsíc",
"dashboard.nextMonth": "Další měsíc",
"dashboard.confirmed": "Potvrzeno",
"dashboard.pending": "Čeká",
"dashboard.cancelled": "Zrušeno",
"dashboard.completed": "Dokončeno",
"dashboard.welcome.title": "Vítejte v Bookra",
"dashboard.welcome.body": "Zjednodušte své rezervace a mějte více času na to, co vás baví.",
"dashboard.authRequired": "Pro vstup do aplikace se přihlaste nebo si vytvořte účet.",
@@ -248,7 +333,6 @@ const dictionaries = {
"dashboard.liveData": "Živá data",
"dashboard.liveDataBody": "Dashboard, nastavení a předplatné se načítají z API pro přihlášený účet.",
"dashboard.apiReady": "API připojení aktivní",
"dashboard.billing": "Předplatné",
"dashboard.checkout": "Otevřít platbu",
"dashboard.refreshBilling": "Obnovit předplatné",
"dashboard.plan": "Plán",
@@ -263,6 +347,88 @@ const dictionaries = {
"dashboard.onboarding.timezone": "Časové pásmo",
"dashboard.onboarding.submit": "Vytvořit prostor",
"dashboard.onboarding.pending": "Vytvářím prostor...",
"dashboard.revenueTitle": "Trend rezervací",
"dashboard.revenueSubtitle": "Počet rezervací za posledních 7 dní",
"dashboard.noNotifications": "Žádná nová oznámení",
"dashboard.markAllRead": "Označit vše jako přečtené",
"dashboard.notifications.title": "Oznámení",
"dashboard.demoBanner": "Demo režim",
"dashboard.demoBannerDesc": "Prozkoumáte Bookra s ukázkovými daty. Pro plnou funkčnost se zaregistrujte.",
"dashboard.demoBannerCTA": "Vytvořit účet",
"dashboard.tryDemo": "Vyzkoušet demo",
"dashboard.language": "Jazyk",
"dashboard.calendar.noBookings": "Tento den nemáte žádné rezervace.",
"dashboard.calendar.bookingsCount": "rezervací",
"dashboard.chart.bookingsTrend": "Rezervace",
"dashboard.chart.revenueTrend": "Obrat",
"dashboard.recentActivity.empty": "Zatím žádná aktivita. Rezervace se zobrazí zde.",
"dashboard.filter.all": "Vše",
"dashboard.filter.today": "Dnes",
"dashboard.filter.week": "Týden",
"dashboard.filter.month": "Měsíc",
"dashboard.search.placeholder": "Hledat...",
"dashboard.search.bookings": "Hledat podle jména, služby nebo reference...",
"dashboard.search.customers": "Hledat podle jména, emailu nebo telefonu...",
"dashboard.actions": "Akce",
"dashboard.customer.email": "E-mail",
"dashboard.customer.phone": "Telefon",
"dashboard.customer.totalBookings": "Celkem rezervací",
"dashboard.customer.lastVisit": "Poslední návštěva",
"dashboard.customer.status": "Stav",
"dashboard.customer.notes": "Poznámky",
"dashboard.customer.noNotes": "Žádné poznámky",
"dashboard.customer.bookings": "Rezervace zákazníka",
"dashboard.customer.noBookings": "Žádné rezervace",
"dashboard.zone.add": "Přidat zónu",
"dashboard.zone.name": "Název",
"dashboard.zone.type": "Typ",
"dashboard.zone.capacity": "Kapacita",
"dashboard.zone.limitReached": "Dosáhli jste limitu",
"dashboard.zone.rooms": "Místnost",
"dashboard.zone.private": "Soukromá místnost",
"dashboard.zone.hall": "Sál",
"dashboard.zone.blockedDays": "Blokované dny",
"dashboard.zone.workingHours": "Pracovní doba",
"dashboard.zone.open": "Otevřeno",
"dashboard.zone.close": "Zavřeno",
"dashboard.zone.addBlocked": "Přidat blokovaný den",
"dashboard.zone.reason": "Důvod",
"dashboard.zone.noZones": "Zatím nemáte žádné zóny. Přidejte první.",
"dashboard.billing.planUsage": "Využití plánu",
"dashboard.billing.locations": "Lokace",
"dashboard.billing.bookings": "Rezervace",
"dashboard.billing.staff": "Zaměstnanci",
"dashboard.billing.period": "Období",
"dashboard.billing.currentPlan": "Aktuální plán",
"dashboard.billing.nextBilling": "Další fakturace",
"dashboard.settings.businessInfo": "Informace o podniku",
"dashboard.settings.branding": "Branding a vzhled",
"dashboard.settings.emailNotifications": "E-mailová oznámení",
"dashboard.settings.emailSubject": "Předmět",
"dashboard.settings.emailBody": "Obsah",
"dashboard.settings.save": "Uložit",
"dashboard.bookingModal.customer": "Zákazník",
"dashboard.bookingModal.service": "Služba",
"dashboard.bookingModal.location": "Místo",
"dashboard.bookingModal.dateTime": "Datum a čas",
"dashboard.bookingModal.duration": "Délka",
"dashboard.bookingModal.status": "Stav",
"dashboard.bookingModal.reference": "Reference",
"dashboard.bookingModal.notes": "Poznámky",
"dashboard.bookingModal.reschedule": "Přeplánovat",
"dashboard.bookingModal.confirmCancel": "Opravdu chcete zrušit tuto rezervaci?",
"dashboard.bookingModal.createdAt": "Vytvořeno",
"dashboard.bookingModal.assignedTo": "Přiřazeno",
"dashboard.bookingModal.noAssign": "Nepřiřazeno",
"dashboard.bookingModal.email": "E-mail",
"dashboard.bookingModal.phone": "Telefon",
"dashboard.empty.title": "Zatím prázdné",
"dashboard.empty.bookingsDesc": "Žádné rezervace neodpovídají vašemu filtru.",
"dashboard.empty.customersDesc": "Zatím nemáte žádné zákazníky.",
"dashboard.notification.newBooking": "Nová rezervace od",
"dashboard.notification.reminder": "Připomínka: rezervace u",
"dashboard.notification.upgrade": "Blížíte se limitu plánu",
"dashboard.notification.trialEnding": "Vaše zkušební doba končí za 3 dny",
"booking.title": "Rezervace",
"booking.body": "Vyberte dostupný termín, doplňte kontaktní údaje a potvrzení přijde e-mailem.",
"booking.slots": "Dostupné termíny",
@@ -281,6 +447,7 @@ const dictionaries = {
"booking.customer.body": "Tyto údaje použijeme pro potvrzení rezervace a připomenutí.",
"booking.customer.name": "Jméno",
"booking.customer.email": "E-mail",
"booking.customer.phone": "Telefon",
"booking.customer.notes": "Poznámka",
"booking.customerRequired": "Před rezervací vyplňte jméno a e-mail.",
"booking.failed": "Rezervaci se nepodařilo vytvořit",
@@ -326,6 +493,12 @@ const dictionaries = {
"contact.info.email.desc": "Preferujete psát? Jsme tu pro vás.",
"contact.info.hours.title": "Pracovní doba",
"contact.info.hours.desc": "Odpovídáme během pracovních dní 9:00 — 17:00 CET.",
"contact.story.heading": "Proč nás kontaktovat?",
"contact.story.p1": "Ať už máte dotaz ohledně funkcí, potřebujete pomoci s nastavením, nebo chcete sdílet zpětnou vazbu — rádi vám pomůžeme.",
"contact.story.p2": "Naším cílem je, aby správa rezervací byla pro vás bezstarostná. Ozvěte se nám a najdeme řešení společně.",
"contact.error.title": "Nepodařilo se odeslat",
"contact.error.body": "Zkuste to prosím později, nebo nám napište přímo na hello@bookra.eu.",
"contact.email.address": "hello@bookra.eu",
// Legal
"legal.privacy.title": "Ochrana soukromí",
@@ -345,6 +518,7 @@ const dictionaries = {
// Navigation & Auth
"nav.booking": "Public booking",
"nav.dashboard": "App",
"nav.pricing": "Pricing",
"nav.about": "About us",
"nav.contact": "Contact",
@@ -409,7 +583,7 @@ const dictionaries = {
// Hero Section
"home.badge": "Now free for starters",
"home.hero.title": "Calm booking software for local services",
"home.hero.title": "Simple booking software for local services",
"home.hero.subtitle": "Manage bookings, customers, and your team in one place. No unnecessary complexity — just a reliable system that works.",
"home.hero.cta.primary": "Get started free",
"home.hero.cta.secondary": "Open booking page",
@@ -496,6 +670,26 @@ const dictionaries = {
"home.pricing.biz.cta": "Contact sales",
"home.pricing.biz.trial": "Custom enterprise solutions",
// Comparison
"pricing.compare.eyebrow": "Detailed comparison",
"pricing.compare.title": "Compare plans",
"pricing.compare.feature": "Feature",
"pricing.compare.locations": "Locations",
"pricing.compare.staff": "Staff members",
"pricing.compare.bookings": "Bookings/month",
"pricing.compare.emailSupport": "Email support",
"pricing.compare.reminders": "Email reminders",
"pricing.compare.analytics": "Analytics",
"pricing.compare.api": "API access",
"pricing.compare.branding": "Custom branding",
"pricing.compare.whiteLabel": "White labeling",
"pricing.compare.manager": "Dedicated manager",
"pricing.compare.priority": "Priority",
"pricing.compare.dedicated": "Dedicated",
"pricing.compare.advanced": "Advanced",
"pricing.compare.yes": "Yes",
"pricing.compare.no": "No",
// CTA
"home.cta.title": "Ready to simplify your bookings?",
"home.cta.subtitle": "Join thousands of businesses saving time with Bookra.",
@@ -522,6 +716,10 @@ const dictionaries = {
"widget.type.floating.title": "Floating bubble",
"widget.type.floating.desc": "Floating button in the corner of the screen",
"widget.type.floating.preview": "Best for: E-commerce, continuous availability",
"widget.type.map.title": "Location map",
"widget.type.map.desc": "Embed a styled map for your real address.",
"widget.map.noLocation": "No location found. Paste a Google Maps/Mapy.cz link, coordinates, or full address.",
"widget.map.resolved": "Location resolved",
"widget.button.text": "Book appointment",
"widget.modal.trigger": "Open booking",
"widget.styling.title": "Appearance",
@@ -559,20 +757,80 @@ const dictionaries = {
"common.copy": "Copy",
"common.copied": "Copied!",
// Integration Modal
"integration.title": "Add Bookra to Your Website",
"integration.subtitle": "Choose how you want to integrate Bookra. Share a link or embed directly on your website.",
"integration.tab.hosted": "Your Link",
"integration.tab.embed": "Embed Widget",
"integration.hosted.title": "Your Booking Page",
"integration.hosted.desc": "Share this link with customers. They can book directly without any setup.",
"integration.hosted.hint": "Perfect for social media, email signatures, or direct sharing",
"integration.share.facebook": "Share on Facebook",
"integration.share.x": "Share on X",
"integration.embed.title": "Embed on Your Website",
"integration.embed.desc": "Add the booking widget directly to your website. Choose your platform:",
"integration.embed.html": "HTML/JS",
"integration.embed.react": "React",
"integration.embed.solid": "SolidJS",
"integration.embed.php": "PHP/WordPress",
"integration.help.title": "Need help with installation?",
"integration.help.desc": "Contact us at",
"integration.demo.title": "Booking Page",
"integration.demo.desc": "Customers book through your link. No installation needed.",
// Footer
"footer.copyright": "© 2026 Bookra. All rights reserved.",
"footer.privacy": "Privacy",
"footer.terms": "Terms",
"footer.description": "Calm booking software for local services. Manage bookings, customers, and your team in one place.",
"footer.description": "Simple booking software for local services. Manage bookings, customers, and your team in one place.",
"footer.links.title": "Navigation",
"footer.legal.title": "Legal",
// Dashboard (existing)
// Dashboard
"dashboard.title": "Owner dashboard",
"dashboard.body": "Track weekly bookings, cancellations, utilization, and subscription state with a tenant-aware shell ready for Neon-backed data.",
"dashboard.kpi.bookings": "Bookings this week",
"dashboard.kpi.cancellations": "Cancellations",
"dashboard.kpi.utilization": "Utilization",
"dashboard.overview": "Overview",
"dashboard.bookings": "Bookings",
"dashboard.customers": "Customers",
"dashboard.zones": "Zones & Availability",
"dashboard.billing": "Billing",
"dashboard.settings": "Settings",
"dashboard.welcome": "Welcome back,",
"dashboard.overviewFor": "Overview for",
"dashboard.kpi.bookings": "Total Bookings",
"dashboard.kpi.cancelled": "Cancelled",
"dashboard.kpi.completed": "Completed",
"dashboard.kpi.newClients": "New Clients",
"dashboard.recentActivity": "Recent Activity",
"dashboard.upcomingBookings": "Upcoming Bookings",
"dashboard.viewAll": "View all",
"dashboard.locationLimitReached": "You've reached your location limit!",
"dashboard.nearingLocationLimit": "You're nearing your location limit",
"dashboard.locationsUsed": "locations used",
"dashboard.upgrade": "Upgrade",
"dashboard.shareManage": "Share/Manage",
"dashboard.notifications": "Notifications",
"dashboard.bookingManagement": "Booking Management",
"dashboard.totalBookings": "total bookings",
"dashboard.newBooking": "New Booking",
"dashboard.bookingDetails": "Booking Details",
"dashboard.customerDetails": "Customer Details",
"dashboard.close": "Close",
"dashboard.edit": "Edit",
"dashboard.cancel": "Cancel",
"dashboard.details": "Details",
"dashboard.saveChanges": "Save Changes",
"dashboard.createBooking": "Create Booking",
"dashboard.preview": "Preview",
"dashboard.saveEmailSettings": "Save Email Settings",
"dashboard.saving": "Saving...",
"dashboard.creating": "Creating...",
"dashboard.prevMonth": "Previous month",
"dashboard.nextMonth": "Next month",
"dashboard.confirmed": "Confirmed",
"dashboard.pending": "Pending",
"dashboard.cancelled": "Cancelled",
"dashboard.completed": "Completed",
"dashboard.welcome.title": "Welcome to Bookra",
"dashboard.welcome.body": "Simplify your bookings and spend more time doing what you love.",
"dashboard.authRequired": "Live dashboard data needs a Neon Auth session and JWT.",
@@ -580,7 +838,6 @@ const dictionaries = {
"dashboard.liveData": "Live data",
"dashboard.liveDataBody": "Dashboard, tenant, and billing data are loaded from the API for the signed-in workspace.",
"dashboard.apiReady": "API connection active",
"dashboard.billing": "Billing",
"dashboard.checkout": "Open checkout",
"dashboard.refreshBilling": "Refresh billing",
"dashboard.plan": "Plan",
@@ -595,6 +852,88 @@ const dictionaries = {
"dashboard.onboarding.timezone": "Timezone",
"dashboard.onboarding.submit": "Create workspace",
"dashboard.onboarding.pending": "Creating workspace...",
"dashboard.revenueTitle": "Booking trend",
"dashboard.revenueSubtitle": "Bookings over the last 7 days",
"dashboard.noNotifications": "No new notifications",
"dashboard.markAllRead": "Mark all as read",
"dashboard.notifications.title": "Notifications",
"dashboard.demoBanner": "Demo mode",
"dashboard.demoBannerDesc": "You're exploring Bookra with sample data. Register for full functionality.",
"dashboard.demoBannerCTA": "Create account",
"dashboard.tryDemo": "Try demo",
"dashboard.language": "Language",
"dashboard.calendar.noBookings": "No bookings for this day.",
"dashboard.calendar.bookingsCount": "bookings",
"dashboard.chart.bookingsTrend": "Bookings",
"dashboard.chart.revenueTrend": "Revenue",
"dashboard.recentActivity.empty": "No activity yet. Bookings will appear here.",
"dashboard.filter.all": "All",
"dashboard.filter.today": "Today",
"dashboard.filter.week": "Week",
"dashboard.filter.month": "Month",
"dashboard.search.placeholder": "Search...",
"dashboard.search.bookings": "Search by name, service or reference...",
"dashboard.search.customers": "Search by name, email or phone...",
"dashboard.actions": "Actions",
"dashboard.customer.email": "Email",
"dashboard.customer.phone": "Phone",
"dashboard.customer.totalBookings": "Total bookings",
"dashboard.customer.lastVisit": "Last visit",
"dashboard.customer.status": "Status",
"dashboard.customer.notes": "Notes",
"dashboard.customer.noNotes": "No notes",
"dashboard.customer.bookings": "Customer bookings",
"dashboard.customer.noBookings": "No bookings",
"dashboard.zone.add": "Add zone",
"dashboard.zone.name": "Name",
"dashboard.zone.type": "Type",
"dashboard.zone.capacity": "Capacity",
"dashboard.zone.limitReached": "Limit reached",
"dashboard.zone.rooms": "Room",
"dashboard.zone.private": "Private room",
"dashboard.zone.hall": "Hall",
"dashboard.zone.blockedDays": "Blocked days",
"dashboard.zone.workingHours": "Working hours",
"dashboard.zone.open": "Open",
"dashboard.zone.close": "Close",
"dashboard.zone.addBlocked": "Add blocked day",
"dashboard.zone.reason": "Reason",
"dashboard.zone.noZones": "No zones yet. Add your first one.",
"dashboard.billing.planUsage": "Plan usage",
"dashboard.billing.locations": "Locations",
"dashboard.billing.bookings": "Bookings",
"dashboard.billing.staff": "Staff",
"dashboard.billing.period": "Period",
"dashboard.billing.currentPlan": "Current plan",
"dashboard.billing.nextBilling": "Next billing",
"dashboard.settings.businessInfo": "Business information",
"dashboard.settings.branding": "Branding & appearance",
"dashboard.settings.emailNotifications": "Email notifications",
"dashboard.settings.emailSubject": "Subject",
"dashboard.settings.emailBody": "Body",
"dashboard.settings.save": "Save",
"dashboard.bookingModal.customer": "Customer",
"dashboard.bookingModal.service": "Service",
"dashboard.bookingModal.location": "Location",
"dashboard.bookingModal.dateTime": "Date & time",
"dashboard.bookingModal.duration": "Duration",
"dashboard.bookingModal.status": "Status",
"dashboard.bookingModal.reference": "Reference",
"dashboard.bookingModal.notes": "Notes",
"dashboard.bookingModal.reschedule": "Reschedule",
"dashboard.bookingModal.confirmCancel": "Are you sure you want to cancel this booking?",
"dashboard.bookingModal.createdAt": "Created",
"dashboard.bookingModal.assignedTo": "Assigned to",
"dashboard.bookingModal.noAssign": "Not assigned",
"dashboard.bookingModal.email": "Email",
"dashboard.bookingModal.phone": "Phone",
"dashboard.empty.title": "Nothing here yet",
"dashboard.empty.bookingsDesc": "No bookings match your filter.",
"dashboard.empty.customersDesc": "You don't have any customers yet.",
"dashboard.notification.newBooking": "New booking from",
"dashboard.notification.reminder": "Reminder: booking at",
"dashboard.notification.upgrade": "You're nearing your plan limit",
"dashboard.notification.trialEnding": "Your trial ends in 3 days",
"booking.title": "Book a visit",
"booking.body": "Choose an available time, add your contact details, and receive confirmation by email.",
"booking.slots": "Available times",
@@ -613,6 +952,7 @@ const dictionaries = {
"booking.customer.body": "These details are used for confirmation and reminders.",
"booking.customer.name": "Name",
"booking.customer.email": "Email",
"booking.customer.phone": "Phone",
"booking.customer.notes": "Note",
"booking.customerRequired": "Add your name and email before booking.",
"booking.failed": "Booking failed",
@@ -658,6 +998,12 @@ const dictionaries = {
"contact.info.email.desc": "Prefer to write? We're here for you.",
"contact.info.hours.title": "Working hours",
"contact.info.hours.desc": "We respond on business days 9:00 — 17:00 CET.",
"contact.story.heading": "Why reach out?",
"contact.story.p1": "Whether you have questions about features, need help with setup, or want to share feedback — we're happy to help.",
"contact.story.p2": "Our goal is to make booking management effortless for you. Get in touch and we'll find a solution together.",
"contact.error.title": "Failed to send",
"contact.error.body": "Please try again later, or email us directly at hello@bookra.eu.",
"contact.email.address": "hello@bookra.eu",
// Legal
"legal.privacy.title": "Privacy",
@@ -40,6 +40,7 @@ export function BookingManageRoute() {
customerEmail: "alice@example.com",
service: "Yoga Flow Class",
businessName: "Serenity Wellness Studio",
businessEmail: "support@bookra.eu",
startsAt: new Date(Date.now() + 86400000).toISOString(),
endsAt: new Date(Date.now() + 86400000 + 3600000).toISOString(),
location: "Main Studio, 123 Wellness Street",
@@ -331,7 +332,7 @@ export function BookingManageRoute() {
: 'Have questions or need special arrangements? Contact the business directly.'}
</p>
<a
href={`mailto:support@bookra.eu?subject=Booking ${b().reference}`}
href={`mailto:${b().businessEmail || 'support@bookra.eu'}?subject=Booking ${b().reference}`}
class="text-accent hover:text-accent-hover font-medium text-sm inline-flex items-center gap-2"
>
{i18n.locale() === 'cs' ? 'Poslat zprávu' : 'Send message'}
+153 -158
View File
@@ -1,4 +1,4 @@
import { Show, createSignal } from "solid-js";
import { Show, createSignal, Match, Switch } from "solid-js";
import { useI18n } from "../providers/i18n-provider";
import { BookraCharacter } from "../components/bookra-character";
import {
@@ -17,38 +17,48 @@ export function ContactRoute() {
const [message, setMessage] = createSignal("");
const [submitted, setSubmitted] = createSignal(false);
const [submitting, setSubmitting] = createSignal(false);
const [error, setError] = createSignal("");
const apiUrl = import.meta.env.VITE_BOOKRA_API_URL ?? "http://localhost:8080";
const handleSubmit = async (e: Event) => {
e.preventDefault();
setError("");
setSubmitting(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setSubmitting(false);
setSubmitted(true);
try {
const res = await fetch(`${apiUrl}/v1/public/contact`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: name(),
email: email(),
message: message(),
}),
});
if (!res.ok) throw new Error("Failed to send");
setSubmitted(true);
} catch {
setError(i18n.t("contact.error.body"));
} finally {
setSubmitting(false);
}
};
return (
<div class="animate-fade-in">
{/* Hero Section */}
<section class="relative pt-16 pb-12 lg:pt-24 lg:pb-16 overflow-hidden">
{/* Background gradient */}
<div
class="absolute inset-0 pointer-events-none"
style={{ background: "var(--gradient-hero)" }}
/>
<div class="absolute inset-0 pointer-events-none" style={{ background: "var(--gradient-hero)" }} />
<div class="section-container relative">
<div class="max-w-3xl mx-auto text-center">
{/* Character at top */}
<div class="flex justify-center mb-8">
<BookraCharacter pose="headphones" size="xl" animate={true} />
</div>
<span class="inline-flex items-center gap-2 px-4 py-1.5 mb-6 text-sm font-medium tracking-wide text-accent bg-accent-subtle/80 rounded-full border border-accent/10 backdrop-blur-sm">
<span class="w-2 h-2 rounded-full bg-accent animate-pulse" />
<span class="w-2 h-2 rounded-full bg-success" />
{i18n.locale() === 'cs' ? 'Jsme tu pro vás' : 'We are here for you'}
</span>
<h1 class="text-display-xl font-semibold text-ink mb-6 tracking-tight animate-slide-up">
{i18n.t("contact.title")}
</h1>
@@ -59,162 +69,147 @@ export function ContactRoute() {
</div>
</section>
{/* Contact Form Section */}
{/* Story + Form split */}
<section class="py-16 lg:py-24 bg-canvas-subtle/30">
<div class="section-container">
<div class="max-w-2xl mx-auto">
<Show when={!submitted()} fallback={
<Card class="surface-elevated border-success/20">
<CardContent class="py-12">
<div class="flex flex-col items-center text-center">
<div class="relative mb-6">
<BookraCharacter pose="success" size="xl" animate={true} />
<div class="absolute -top-2 -right-2 text-3xl animate-bounce">🎉</div>
</div>
<h2 class="text-display-md font-semibold text-ink mb-4">
{i18n.t("contact.success.title")}
</h2>
<p class="text-ink-muted max-w-sm">
{i18n.t("contact.success.body")}
</p>
</div>
</CardContent>
</Card>
}>
<Card class="surface-elevated overflow-hidden">
<div class="flex items-center gap-3 p-6 border-b border-border/50 bg-canvas-subtle/30">
<div class="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</div>
<CardTitle class="text-xl">{i18n.t("contact.form.title")}</CardTitle>
<div class="max-w-5xl mx-auto grid lg:grid-cols-[1fr_1.2fr] gap-12 lg:gap-16 items-start">
{/* Story side */}
<div class="space-y-8">
<div>
<h2 class="text-display-sm font-semibold text-ink mb-4">
{i18n.t("contact.story.heading")}
</h2>
<div class="space-y-4 text-ink-muted leading-relaxed">
<p>{i18n.t("contact.story.p1")}</p>
<p>{i18n.t("contact.story.p2")}</p>
</div>
<CardContent class="p-6">
<form onSubmit={handleSubmit} class="space-y-6">
<Input
label={i18n.t("contact.form.name")}
type="text"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
required
autocomplete="name"
/>
<Input
label={i18n.t("contact.form.email")}
type="email"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
required
autocomplete="email"
/>
<Textarea
label={i18n.t("contact.form.message")}
value={message()}
onInput={(e) => setMessage(e.currentTarget.value)}
rows={5}
required
placeholder={i18n.locale() === 'cs' ? "Napište nám, jak vám můžeme pomoci..." : "Tell us how we can help you..."}
/>
<Button
type="submit"
fullWidth
isLoading={submitting()}
class="shadow-lg hover:shadow-xl transition-all"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
{i18n.t("contact.form.submit")}
</Button>
</form>
</CardContent>
</Card>
</Show>
</div>
</div>
</section>
</div>
{/* Contact Info Section */}
<section class="py-16 lg:py-24">
<div class="section-container">
<div class="max-w-4xl mx-auto">
{/* Section title */}
<div class="text-center mb-12">
<h2 class="text-display-md font-semibold text-ink mb-3">
{i18n.locale() === 'cs' ? 'Další způsoby kontaktu' : 'Other ways to reach us'}
</h2>
<p class="text-ink-muted">
{i18n.locale() === 'cs' ? 'Vyberte si, co vám nejvíce vyhovuje' : 'Choose what works best for you'}
</p>
</div>
<div class="grid md:grid-cols-2 gap-8">
{/* Email Card */}
<Card class="surface-elevated group hover:shadow-xl transition-all duration-500">
<CardContent class="p-6">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-accent/10 flex items-center justify-center shrink-0 group-hover:bg-accent/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
</div>
<div>
<h3 class="font-display font-semibold text-ink mb-1">{i18n.t("contact.info.email.title")}</h3>
<p class="text-ink-muted text-sm mb-3">{i18n.t("contact.info.email.desc")}</p>
<a
href="mailto:hello@bookra.cz"
class="inline-flex items-center gap-2 text-accent hover:text-accent/80 font-medium transition-colors group/link"
>
hello@bookra.cz
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="transition-transform group-hover/link:translate-x-1">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
<div class="grid sm:grid-cols-2 gap-4">
<Card class="surface-elevated group hover:shadow-lg transition-all">
<CardContent class="p-5">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 group-hover:bg-accent/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
<path d="M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5A2.5 2.5 0 0 1 4.5 7h15A2.5 2.5 0 0 1 22 9.5z"/>
<polyline points="22 9.5 12 14 2 9.5"/>
</svg>
</a>
</div>
</div>
</CardContent>
</Card>
{/* Hours Card */}
<Card class="surface-elevated group hover:shadow-xl transition-all duration-500">
<CardContent class="p-6">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-accent/10 flex items-center justify-center shrink-0 group-hover:bg-accent/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<div>
<h3 class="font-display font-semibold text-ink mb-1">{i18n.t("contact.info.hours.title")}</h3>
<p class="text-ink-muted text-sm">{i18n.t("contact.info.hours.desc")}</p>
<div class="mt-3 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-success animate-pulse"/>
<span class="text-xs text-success font-medium">
{i18n.locale() === 'cs' ? 'Aktuálně online' : 'Currently online'}
</span>
</div>
<div>
<h3 class="font-display font-semibold text-ink text-sm mb-1">{i18n.t("contact.info.email.title")}</h3>
<a href={`mailto:${i18n.t("contact.email.address")}`} class="text-accent text-sm font-medium hover:underline">
{i18n.t("contact.email.address")}
</a>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Helpful mascot at bottom */}
<div class="mt-16 flex justify-center">
<div class="flex items-center gap-4 surface-elevated px-6 py-4 rounded-2xl">
</CardContent>
</Card>
<Card class="surface-elevated group hover:shadow-lg transition-all">
<CardContent class="p-5">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 group-hover:bg-accent/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<div>
<h3 class="font-display font-semibold text-ink text-sm mb-1">{i18n.t("contact.info.hours.title")}</h3>
<p class="text-ink-muted text-xs">{i18n.t("contact.info.hours.desc")}</p>
</div>
</div>
</CardContent>
</Card>
</div>
<div class="flex items-center gap-4 surface-elevated px-5 py-4 rounded-2xl">
<BookraCharacter pose="main" size="sm" animate={true} />
<p class="text-ink-muted text-sm">
{i18n.locale() === 'cs'
? 'Odpovídáme obvykle do 24 hodin'
{i18n.locale() === 'cs'
? 'Odpovídáme obvykle do 24 hodin'
: 'We usually respond within 24 hours'}
</p>
</div>
</div>
{/* Form side */}
<div>
<Switch>
<Match when={submitted()}>
<Card class="surface-elevated border-success/20">
<CardContent class="py-12">
<div class="flex flex-col items-center text-center">
<div class="relative mb-6">
<BookraCharacter pose="success" size="xl" animate={true} />
</div>
<h2 class="text-display-md font-semibold text-ink mb-4">
{i18n.t("contact.success.title")}
</h2>
<p class="text-ink-muted max-w-sm">
{i18n.t("contact.success.body")}
</p>
</div>
</CardContent>
</Card>
</Match>
<Match when={true}>
<Card class="surface-elevated overflow-hidden">
<div class="flex items-center gap-3 p-6 border-b border-border/50 bg-canvas-subtle/30">
<div class="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</div>
<CardTitle class="text-xl">{i18n.t("contact.form.title")}</CardTitle>
</div>
<CardContent class="p-6">
<form onSubmit={handleSubmit} class="space-y-5">
<Input
label={i18n.t("contact.form.name")}
type="text"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
required
autocomplete="name"
/>
<Input
label={i18n.t("contact.form.email")}
type="email"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
required
autocomplete="email"
/>
<Textarea
label={i18n.t("contact.form.message")}
value={message()}
onInput={(e) => setMessage(e.currentTarget.value)}
rows={5}
required
minLength={10}
placeholder={i18n.locale() === 'cs' ? "Napište nám, jak vám můžeme pomoci..." : "Tell us how we can help you..."}
/>
<Show when={error()}>
<p class="text-sm text-danger">{error()}</p>
</Show>
<Button
type="submit"
fullWidth
isLoading={submitting()}
class="shadow-lg hover:shadow-xl transition-all"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
{i18n.t("contact.form.submit")}
</Button>
</form>
</CardContent>
</Card>
</Match>
</Switch>
</div>
</div>
</div>
</section>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
import { Show, createMemo } from "solid-js";
import { SparklesIcon, XIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout";
function BillingPage() {
const data = useDashboardData();
const current = data.resolvedBilling();
const entitlements = current?.entitlements;
const isTrialing = current?.subscriptionStatus === "trialing";
const isActive = current?.subscriptionStatus === "active";
const planCode = current?.planCode ?? "starter";
const planName = current?.planName ?? "Starter";
const trialDays = current?.trialDaysRemaining ?? 0;
const planFeatures = createMemo(() => {
const cs = data.i18n.locale() === "cs";
const base = [
{ label: data.i18n.t("dashboard.billing.locations"), value: entitlements?.maxLocations ?? 1, limit: entitlements?.maxLocations ?? 1 },
{ label: data.i18n.t("dashboard.billing.bookings"), value: entitlements?.maxBookings === -1 ? "\u221e" : (entitlements?.maxBookings ?? 50), limit: entitlements?.maxBookings ?? 50 },
{ label: data.i18n.t("dashboard.billing.staff"), value: entitlements?.maxStaff ?? 1, limit: entitlements?.maxStaff ?? 1 },
];
if (planCode === "pro" || planCode === "business") {
base.push({ label: cs ? "Emailove pripominky" : "Email reminders", value: "\u2713", limit: "\u2713" });
base.push({ label: cs ? "Analytika" : "Analytics", value: "\u2713", limit: "\u2713" });
}
if (planCode === "business") {
base.push({ label: cs ? "API pristup" : "API access", value: "\u2713", limit: "\u2713" });
}
return base;
});
return (
<div class="space-y-6">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.billing")}</h1>
<p class="text-ink-muted mt-1">{data.i18n.t("dashboard.billing.currentPlan")}: {planName}</p>
</div>
{/* Billing notice/error */}
<Show when={data.billingNotice()}>
<div class="p-4 rounded-xl bg-accent-subtle border border-accent/20 flex items-center justify-between animate-fade-in">
<div class="flex items-center gap-3"><SparklesIcon /><p class="text-sm font-medium text-accent">{data.billingNotice()}</p></div>
<button onClick={() => data.setBillingNotice(null)} class="p-1.5 hover:bg-accent/10 rounded-lg text-accent"><XIcon /></button>
</div>
</Show>
<Show when={data.billingError()}>
<div class="p-4 rounded-xl bg-canvas-subtle border border-ink/10 flex items-center justify-between animate-fade-in">
<p class="text-sm font-medium text-ink">{data.billingError()}</p>
<button onClick={() => data.setBillingError(null)} class="p-1.5 hover:bg-canvas-muted rounded-lg text-ink-muted"><XIcon /></button>
</div>
</Show>
{/* Current Plan Card */}
<div class="surface-card p-6">
<div class="flex items-start justify-between gap-4 mb-6">
<div>
<div class="flex items-center gap-3 mb-2">
<h3 class="text-xl font-bold text-ink font-display">{planName}</h3>
{isTrialing && <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.locale() === "cs" ? `Zkusebni doba (${trialDays} dnu)` : `Trial (${trialDays} days)`}</span>}
{isActive && <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.t("dashboard.confirmed")}</span>}
</div>
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.billing.period")}: {data.billingInterval() === "monthly" ? (data.i18n.locale() === "cs" ? "Mesicne" : "Monthly") : (data.i18n.locale() === "cs" ? "Rocne" : "Yearly")}</p>
</div>
<div class="flex gap-2 shrink-0">
<button onClick={() => data.setBillingInterval("monthly")} class={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${data.billingInterval() === "monthly" ? "bg-accent text-canvas" : "bg-canvas-subtle text-ink-muted hover:text-ink"}`}>{data.i18n.locale() === "cs" ? "Mesicne" : "Monthly"}</button>
<button onClick={() => data.setBillingInterval("yearly")} class={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${data.billingInterval() === "yearly" ? "bg-accent text-canvas" : "bg-canvas-subtle text-ink-muted hover:text-ink"}`}>{data.i18n.locale() === "cs" ? "Rocne" : "Yearly"}</button>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{planFeatures().map((feature: any) => (
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{feature.label}</p>
<p class="text-lg font-semibold text-ink">{typeof feature.value === "number" ? `${feature.value} / ${feature.limit}` : feature.value}</p>
</div>
))}
</div>
<div class="flex flex-wrap gap-3 mt-6">
<button onClick={() => void data.openCheckout()} disabled={data.billingAction() === "checkout"} class="btn-primary disabled:opacity-50">
{data.billingAction() === "checkout" ? (data.i18n.locale() === "cs" ? "Otevirani..." : "Opening...") : (isTrialing ? data.i18n.t("dashboard.upgrade") : data.i18n.t("dashboard.checkout"))}
</button>
<Show when={isActive || isTrialing}>
<button onClick={() => void data.openPortal()} class="btn-secondary">{data.i18n.locale() === "cs" ? "Spravovat predplatne" : "Manage subscription"}</button>
</Show>
<button onClick={() => void data.refreshBilling()} disabled={data.billingAction() === "refresh"} class="btn-secondary disabled:opacity-50">
{data.billingAction() === "refresh" ? (data.i18n.locale() === "cs" ? "Obnovovani..." : "Refreshing...") : data.i18n.t("dashboard.refreshBilling")}
</button>
</div>
</div>
</div>
);
}
export default function BillingRoute() {
return (
<DashboardLayout>
<BillingPage />
</DashboardLayout>
);
}
@@ -0,0 +1,252 @@
import { Show, createSignal, createMemo } from "solid-js";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import { Textarea } from "../../components/ui/textarea";
import { getInitials } from "../../components/dashboard/types";
import { CalendarDaysIcon, PlusIcon, XIcon, EyeIcon } from "../../components/dashboard/icons";
import { apiClient } from "../../lib/api-client";
import { DashboardLayout, useDashboardData } from "./layout";
function BookingsPage() {
const data = useDashboardData();
const [filterStatus, setFilterStatus] = createSignal<"all" | "confirmed" | "pending" | "cancelled" | "completed">("all");
const [filterDateRange, setFilterDateRange] = createSignal<"all" | "today" | "week" | "month">("all");
const [searchQuery, setSearchQuery] = createSignal("");
const [showNewBooking, setShowNewBooking] = createSignal(false);
const [creatingBooking, setCreatingBooking] = createSignal(false);
const [pinnedBookingIds, setPinnedBookingIds] = createSignal<Set<string>>(new Set());
const [newBookingCustomer, setNewBookingCustomer] = createSignal("");
const [newBookingEmail, setNewBookingEmail] = createSignal("");
const [newBookingService, setNewBookingService] = createSignal("");
const [newBookingDate, setNewBookingDate] = createSignal("");
const [newBookingTime, setNewBookingTime] = createSignal("");
const [newBookingNotes, setNewBookingNotes] = createSignal("");
const resolvedAllBookings = () => data.resolvedSummary()?.allBookings ?? data.normalizedAllBookings() ?? [];
const filteredBookings = createMemo(() => {
let bookings = resolvedAllBookings();
if (filterStatus() !== "all") bookings = bookings.filter((b: any) => b.status === filterStatus());
const now = new Date();
if (filterDateRange() === "today") bookings = bookings.filter((b: any) => new Date(b.startsAt).toDateString() === now.toDateString());
else if (filterDateRange() === "week") {
const weekEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
bookings = bookings.filter((b: any) => { const d = new Date(b.startsAt); return d >= now && d <= weekEnd; });
} else if (filterDateRange() === "month") {
const monthEnd = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
bookings = bookings.filter((b: any) => { const d = new Date(b.startsAt); return d >= now && d <= monthEnd; });
}
if (searchQuery().trim()) {
const q = searchQuery().toLowerCase();
bookings = bookings.filter((b: any) =>
b.customerName?.toLowerCase().includes(q) ||
b.service?.toLowerCase().includes(q) ||
b.reference?.toLowerCase().includes(q)
);
}
return bookings.sort((a: any, b: any) => {
const aPinned = pinnedBookingIds().has(a.id) ? 1 : 0;
const bPinned = pinnedBookingIds().has(b.id) ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
return new Date(b.startsAt).getTime() - new Date(a.startsAt).getTime();
});
});
const bookingStats = createMemo(() => {
const bookings = resolvedAllBookings();
return {
total: bookings.length,
confirmed: bookings.filter((b: any) => b.status === "confirmed").length,
pending: bookings.filter((b: any) => b.status === "pending").length,
cancelled: bookings.filter((b: any) => b.status === "cancelled").length,
completed: bookings.filter((b: any) => b.status === "completed").length,
};
});
const handleCreateBooking = async () => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo rezimu nelze vytvaret rezervace." : "Cannot create bookings in demo mode.");
setShowNewBooking(false); return;
}
setCreatingBooking(true);
try {
await (apiClient as any).POST("/v1/catalog/bookings", {
headers: { Authorization: `Bearer ${bearer}` },
body: {
customerName: newBookingCustomer(), customerEmail: newBookingEmail(), service: newBookingService(),
startsAt: new Date(`${newBookingDate()}T${newBookingTime()}`).toISOString(), notes: newBookingNotes()
}
});
setNewBookingCustomer(""); setNewBookingEmail(""); setNewBookingService(""); setNewBookingDate(""); setNewBookingTime(""); setNewBookingNotes("");
setShowNewBooking(false);
} catch { data.setDemoNotice(data.i18n.locale() === "cs" ? "Vytvoreni rezervace selhalo." : "Failed to create booking."); }
finally { setCreatingBooking(false); }
};
const statusLabels: Record<string, string> = {
confirmed: data.i18n.t("dashboard.confirmed"), pending: data.i18n.t("dashboard.pending"),
cancelled: data.i18n.t("dashboard.cancelled"), completed: data.i18n.t("dashboard.completed")
};
return (
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.bookingManagement")}</h1>
<p class="text-ink-muted mt-1">{bookingStats().total} {data.i18n.t("dashboard.totalBookings")}</p>
</div>
<button onClick={() => setShowNewBooking(true)} class="btn-primary text-sm shrink-0">
<PlusIcon /> {data.i18n.t("dashboard.newBooking")}
</button>
</div>
{/* Stats */}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ key: "confirmed", label: data.i18n.t("dashboard.confirmed"), value: bookingStats().confirmed },
{ key: "pending", label: data.i18n.t("dashboard.pending"), value: bookingStats().pending },
{ key: "cancelled", label: data.i18n.t("dashboard.cancelled"), value: bookingStats().cancelled },
{ key: "completed", label: data.i18n.t("dashboard.completed"), value: bookingStats().completed },
].map((s) => (
<button onClick={() => setFilterStatus(s.key as any)} class={`surface-card p-4 text-left transition-all hover:shadow-md ${filterStatus() === s.key ? "ring-2 ring-accent" : ""}`}>
<p class="text-xs text-ink-muted uppercase tracking-wider">{s.label}</p>
<p class="text-2xl font-bold text-ink mt-1 font-display">{s.value}</p>
</button>
))}
</div>
{/* Filters */}
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex-1">
<Input type="text" placeholder={data.i18n.t("dashboard.search.bookings")} value={searchQuery()} onInput={(e) => setSearchQuery(e.currentTarget.value)} />
</div>
<div class="flex gap-2">
<Select value={filterStatus()} onChange={(v) => setFilterStatus(v as any)} options={[
{ value: "all", label: data.i18n.t("dashboard.filter.all") },
{ value: "confirmed", label: data.i18n.t("dashboard.confirmed") },
{ value: "pending", label: data.i18n.t("dashboard.pending") },
{ value: "cancelled", label: data.i18n.t("dashboard.cancelled") },
{ value: "completed", label: data.i18n.t("dashboard.completed") },
]} />
<Select value={filterDateRange()} onChange={(v) => setFilterDateRange(v as any)} options={[
{ value: "all", label: data.i18n.t("dashboard.filter.all") },
{ value: "today", label: data.i18n.t("dashboard.filter.today") },
{ value: "week", label: data.i18n.t("dashboard.filter.week") },
{ value: "month", label: data.i18n.t("dashboard.filter.month") },
]} />
</div>
</div>
{/* Bookings List */}
<div class="surface-card overflow-hidden">
<Show when={filteredBookings().length > 0} fallback={
<div class="p-12 text-center">
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4">
<CalendarDaysIcon />
</div>
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p>
<p class="text-sm text-ink-subtle mt-1">{data.i18n.t("dashboard.empty.bookingsDesc")}</p>
</div>
}>
<div class="divide-y divide-border/60">
{filteredBookings().map((booking: any) => {
const isPinned = () => pinnedBookingIds().has(booking.id);
const togglePin = (e: Event) => {
e.stopPropagation();
setPinnedBookingIds((prev) => {
const next = new Set(prev);
if (next.has(booking.id)) next.delete(booking.id);
else next.add(booking.id);
return next;
});
};
return (
<div class={`flex items-center gap-4 p-4 hover:bg-canvas-subtle/30 transition-colors group ${isPinned() ? "bg-accent-soft/40" : ""}`}>
<div class={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${
booking.status === "confirmed" ? "bg-accent text-canvas" : "bg-canvas-muted text-ink-muted"
}`}>
{getInitials(booking.customerName)}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="font-medium text-ink">{booking.customerName}</p>
{isPinned() && <span class="w-1.5 h-1.5 rounded-full bg-accent" />}
</div>
<p class="text-sm text-ink-muted">{booking.service} &bull; {new Date(booking.startsAt).toLocaleDateString()} {new Date(booking.startsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={togglePin}
class={`flex h-6 w-6 items-center justify-center rounded-full transition-all duration-200 ${
isPinned()
? "bg-accent text-canvas hover:bg-accent-hover"
: "bg-canvas-subtle border border-border text-ink-subtle hover:border-accent/40 hover:text-accent"
}`}
aria-label={isPinned() ? "Unpin" : "Pin"}
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<circle cx="5" cy="5" r="4" class={isPinned() ? "" : "opacity-0"} />
<circle cx="5" cy="5" r="3.5" fill="none" stroke="currentColor" stroke-width="1" class={isPinned() ? "opacity-0" : ""} />
</svg>
</button>
<span class={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${
booking.status === "confirmed" ? "bg-accent/10 text-accent"
: booking.status === "pending" ? "bg-canvas-muted text-ink-muted"
: "bg-canvas-subtle text-ink-subtle"
}`}>
{statusLabels[booking.status]}
</span>
<button onClick={() => data.openBookingDetail(booking)} class="p-2 text-ink-muted hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors" title={data.i18n.t("dashboard.details")}>
<EyeIcon />
</button>
</div>
</div>
);
})}
</div>
</Show>
</div>
{/* New Booking Modal */}
<Show when={showNewBooking()}>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-ink/50 backdrop-blur-sm" onClick={() => setShowNewBooking(false)} />
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-lg animate-scale-in">
<div class="p-6 border-b border-border flex items-center justify-between">
<h3 class="text-xl font-bold text-ink">{data.i18n.t("dashboard.newBooking")}</h3>
<button onClick={() => setShowNewBooking(false)} class="p-2 hover:bg-canvas-subtle rounded-lg transition-colors"><XIcon /></button>
</div>
<div class="p-6 space-y-4">
<Input type="text" label={data.i18n.t("booking.customer.name")} value={newBookingCustomer()} onInput={(e) => setNewBookingCustomer(e.currentTarget.value)} />
<Input type="email" label={data.i18n.t("booking.customer.email")} value={newBookingEmail()} onInput={(e) => setNewBookingEmail(e.currentTarget.value)} />
<Input type="text" label={data.i18n.t("dashboard.bookingModal.service")} value={newBookingService()} onInput={(e) => setNewBookingService(e.currentTarget.value)} />
<div class="grid grid-cols-2 gap-4">
<Input type="date" label={data.i18n.t("dashboard.bookingModal.dateTime")} value={newBookingDate()} onInput={(e) => setNewBookingDate(e.currentTarget.value)} />
<Input type="time" label={data.i18n.t("dashboard.bookingModal.duration")} value={newBookingTime()} onInput={(e) => setNewBookingTime(e.currentTarget.value)} />
</div>
<Textarea label={data.i18n.t("booking.customer.notes")} value={newBookingNotes()} onInput={(e) => setNewBookingNotes(e.currentTarget.value)} rows={3} resize="none" />
</div>
<div class="p-6 border-t border-border flex gap-3">
<button onClick={() => setShowNewBooking(false)} class="flex-1 px-4 py-2.5 border border-border rounded-xl text-ink hover:bg-canvas-subtle transition-colors">{data.i18n.t("common.cancel")}</button>
<button onClick={handleCreateBooking} disabled={creatingBooking() || !newBookingCustomer() || !newBookingEmail() || !newBookingDate() || !newBookingTime()} class="flex-1 px-4 py-2.5 bg-accent text-canvas rounded-xl hover:bg-accent-hover transition-colors disabled:opacity-50 flex items-center justify-center gap-2">
<Show when={creatingBooking()}><span class="inline-block w-4 h-4 border-2 border-canvas/30 border-t-canvas rounded-full animate-spin" /></Show>
{creatingBooking() ? data.i18n.t("dashboard.creating") : data.i18n.t("dashboard.createBooking")}
</button>
</div>
</div>
</div>
</Show>
</div>
);
}
export default function BookingsRoute() {
return (
<DashboardLayout>
<BookingsPage />
</DashboardLayout>
);
}
@@ -0,0 +1,178 @@
import { Show, createSignal, createMemo } from "solid-js";
import { Input } from "../../components/ui/input";
import { getInitials } from "../../components/dashboard/types";
import { UsersIcon, XIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout";
function CustomersPage() {
const data = useDashboardData();
const [searchQuery, setSearchQuery] = createSignal("");
const [selectedCustomer, setSelectedCustomer] = createSignal<any>(null);
const [showCustomerDetail, setShowCustomerDetail] = createSignal(false);
const [pinnedCustomerIds, setPinnedCustomerIds] = createSignal<Set<string>>(new Set());
const demoCustomers = [
{ id: "1", name: "Martina Novakova", email: "martina@example.com", phone: "+420 123 456 789", bookingsCount: 5, lastBookingAt: new Date(Date.now() - 86400000).toISOString(), status: "active", notes: "Alergie na silne vune" },
{ id: "2", name: "David Svoboda", email: "david@example.com", phone: "+420 987 654 321", bookingsCount: 3, lastBookingAt: new Date(Date.now() - 172800000).toISOString(), status: "active", notes: "" },
{ id: "3", name: "Jana KovarovA", email: "jana@example.com", phone: "+420 555 666 777", bookingsCount: 8, lastBookingAt: new Date(Date.now() - 259200000).toISOString(), status: "vip", notes: "Uprednostnuje ranni terminy" },
{ id: "4", name: "Alice Johnson", email: "alice@example.com", phone: "+420 111 222 333", bookingsCount: 1, lastBookingAt: new Date(Date.now() - 604800000).toISOString(), status: "inactive", notes: "" },
{ id: "5", name: "Bob Smith", email: "bob@example.com", phone: "+420 444 555 666", bookingsCount: 12, lastBookingAt: new Date(Date.now() - 432000000).toISOString(), status: "vip", notes: "Loyal customer" },
];
const resolvedCustomers = () => demoCustomers;
const resolvedBookings = () => data.normalizedAllBookings();
const filteredCustomers = createMemo(() => {
let result = resolvedCustomers();
if (searchQuery().trim()) {
const q = searchQuery().toLowerCase();
result = result.filter((c: any) => c.name?.toLowerCase().includes(q) || c.email?.toLowerCase().includes(q) || c.phone?.toLowerCase().includes(q));
}
return result.sort((a: any, b: any) => {
const aPinned = pinnedCustomerIds().has(a.id) ? 1 : 0;
const bPinned = pinnedCustomerIds().has(b.id) ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
return new Date(b.lastBookingAt || 0).getTime() - new Date(a.lastBookingAt || 0).getTime();
});
});
const getCustomerBookings = (email: string) => {
return resolvedBookings().filter((b: any) => b.customerEmail?.toLowerCase() === email?.toLowerCase()).sort((a: any, b: any) => new Date(b.startsAt).getTime() - new Date(a.startsAt).getTime());
};
const statusBadge = (status: string) => {
switch (status) {
case "active": return <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.t("dashboard.filter.all")}</span>;
case "vip": return <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-ink/10 text-ink">VIP</span>;
default: return <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-canvas-subtle text-ink-subtle">{data.i18n.t("dashboard.customer.status")}</span>;
}
};
return (
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.customers")}</h1>
<p class="text-ink-muted mt-1">{filteredCustomers().length} {data.i18n.locale() === "cs" ? "zakazniku" : "customers"}</p>
</div>
</div>
<div class="surface-card p-4">
<Input type="text" placeholder={data.i18n.t("dashboard.search.customers")} value={searchQuery()} onInput={(e) => setSearchQuery(e.currentTarget.value)} />
</div>
<div class="surface-card overflow-hidden">
<Show when={filteredCustomers().length > 0} fallback={
<div class="p-12 text-center">
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4"><UsersIcon /></div>
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p>
<p class="text-sm text-ink-subtle mt-1">{data.i18n.t("dashboard.empty.customersDesc")}</p>
</div>
}>
<div class="divide-y divide-border/60">
{filteredCustomers().map((customer: any) => {
const isPinned = () => pinnedCustomerIds().has(customer.id);
const togglePin = (e: Event) => {
e.stopPropagation();
setPinnedCustomerIds((prev) => {
const next = new Set(prev);
if (next.has(customer.id)) next.delete(customer.id);
else next.add(customer.id);
return next;
});
};
return (
<button onClick={() => { setSelectedCustomer(customer); setShowCustomerDetail(true); }} class={`w-full text-left flex items-center gap-4 p-4 hover:bg-canvas-subtle/30 transition-colors ${isPinned() ? "bg-accent-soft/40" : ""}`}>
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-sm font-bold text-accent shrink-0">{getInitials(customer.name)}</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="font-medium text-ink">{customer.name}</p>
{statusBadge(customer.status)}
{isPinned() && (
<span class="w-1.5 h-1.5 rounded-full bg-accent" />
)}
</div>
<p class="text-sm text-ink-muted">{customer.email}</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<button
type="button"
onClick={togglePin}
class={`flex h-6 w-6 items-center justify-center rounded-full transition-all duration-200 ${
isPinned()
? "bg-accent text-canvas hover:bg-accent-hover"
: "bg-canvas-subtle border border-border text-ink-subtle hover:border-accent/40 hover:text-accent"
}`}
aria-label={isPinned() ? "Unpin" : "Pin"}
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<circle cx="5" cy="5" r="4" class={isPinned() ? "" : "opacity-0"} />
<circle cx="5" cy="5" r="3.5" fill="none" stroke="currentColor" stroke-width="1" class={isPinned() ? "opacity-0" : ""} />
</svg>
</button>
<div class="text-right hidden sm:block">
<p class="text-sm font-medium text-ink">{customer.bookingsCount}</p>
<p class="text-xs text-ink-muted">{data.i18n.t("dashboard.customer.totalBookings")}</p>
</div>
</div>
</button>
);
})}
</div>
</Show>
</div>
{/* Customer Detail Modal */}
<Show when={showCustomerDetail() && selectedCustomer()}>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-ink/50 backdrop-blur-sm" onClick={() => setShowCustomerDetail(false)} />
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto animate-scale-in">
<div class="p-6 border-b border-border flex items-center justify-between">
<h3 class="text-xl font-bold text-ink">{data.i18n.t("dashboard.customerDetails")}</h3>
<button onClick={() => setShowCustomerDetail(false)} class="p-2 hover:bg-canvas-subtle rounded-lg transition-colors"><XIcon /></button>
</div>
<div class="p-6 space-y-5">
<div class="flex items-center gap-4 p-4 bg-canvas-subtle rounded-xl">
<div class="w-14 h-14 rounded-full bg-accent-subtle flex items-center justify-center text-lg font-bold text-accent">{getInitials(selectedCustomer().name)}</div>
<div>
<p class="font-semibold text-ink text-lg">{selectedCustomer().name}</p>
<p class="text-ink-muted">{selectedCustomer().email}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="p-4 bg-canvas-subtle rounded-xl"><p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.customer.phone")}</p><p class="font-medium text-ink">{selectedCustomer().phone}</p></div>
<div class="p-4 bg-canvas-subtle rounded-xl"><p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.customer.totalBookings")}</p><p class="font-medium text-ink">{selectedCustomer().bookingsCount}</p></div>
</div>
<Show when={selectedCustomer().notes}>
<div class="p-4 bg-canvas-subtle rounded-xl"><p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.customer.notes")}</p><p class="text-ink text-sm">{selectedCustomer().notes}</p></div>
</Show>
<div>
<p class="text-sm font-semibold text-ink mb-3">{data.i18n.t("dashboard.customer.bookings")}</p>
<div class="space-y-2">
{getCustomerBookings(selectedCustomer().email).slice(0, 5).map((b: any) => (
<div class="flex items-center gap-3 p-3 rounded-xl border border-border/60">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-ink">{b.service}</p>
<p class="text-xs text-ink-muted">{new Date(b.startsAt).toLocaleDateString()}</p>
</div>
<span class={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${b.status === "confirmed" ? "bg-accent/10 text-accent" : "bg-canvas-muted text-ink-muted"}`}>{data.i18n.t(`dashboard.${b.status}`)}</span>
</div>
))}
<Show when={getCustomerBookings(selectedCustomer().email).length === 0}>
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.customer.noBookings")}</p>
</Show>
</div>
</div>
</div>
</div>
</div>
</Show>
</div>
);
}
export default function CustomersRoute() {
return (
<DashboardLayout>
<CustomersPage />
</DashboardLayout>
);
}
@@ -0,0 +1,293 @@
import { createResource, createMemo, createSignal, useContext, createContext } from "solid-js";
import { apiClient } from "../../lib/api-client";
import { useAuth } from "../../providers/auth-provider";
import { useI18n } from "../../providers/i18n-provider";
import { getInitials, getBookingDuration } from "../../components/dashboard/types";
export type Section = "overview" | "bookings" | "customers" | "zones" | "billing" | "settings";
function createDemoData(i18n: ReturnType<typeof useI18n>) {
const cs = i18n.locale() === "cs";
const now = new Date();
const fmt = (d: Date) => d.toISOString();
const addDays = (n: number) => { const d = new Date(now); d.setDate(d.getDate() + n); return d; };
const addHours = (d: Date, h: number) => { const r = new Date(d); r.setHours(r.getHours() + h); return r; };
const services = cs
? ["Masáž", "Kosmetika", "Fyzioterapie", "Manikúra"]
: ["Massage", "Cosmetics", "Physiotherapy", "Manicure"];
const customers = [
{ name: cs ? "Martina Novakova" : "Martina Novakova", email: "martina@example.com" },
{ name: cs ? "David Svoboda" : "David Svoboda", email: "david@example.com" },
{ name: cs ? "Jana KovarovA" : "Jana KovarovA", email: "jana@example.com" },
{ name: cs ? "Alice Johnson" : "Alice Johnson", email: "alice@example.com" },
{ name: cs ? "Bob Smith" : "Bob Smith", email: "bob@example.com" },
];
const allBookings = [
{ id: "b1", customerName: customers[0].name, customerEmail: customers[0].email, service: services[0], status: "confirmed", startsAt: fmt(addHours(addDays(0), 10)), endsAt: fmt(addHours(addDays(0), 11)), reference: "BOK-001", location: cs ? "Hlavní salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" },
{ id: "b2", customerName: customers[1].name, customerEmail: customers[1].email, service: services[1], status: "pending", startsAt: fmt(addHours(addDays(1), 14)), endsAt: fmt(addHours(addDays(1), 15)), reference: "BOK-002", location: cs ? "Hlavní salon" : "Main Salon", staffName: cs ? "Lucie K." : "Lucie K.", notes: "" },
{ id: "b3", customerName: customers[2].name, customerEmail: customers[2].email, service: services[2], status: "confirmed", startsAt: fmt(addHours(addDays(2), 9)), endsAt: fmt(addHours(addDays(2), 10)), reference: "BOK-003", location: cs ? "Studio B" : "Studio B", staffName: "", notes: cs ? "Bolest zad" : "Back pain" },
{ id: "b4", customerName: customers[3].name, customerEmail: customers[3].email, service: services[3], status: "cancelled", startsAt: fmt(addHours(addDays(-1), 11)), endsAt: fmt(addHours(addDays(-1), 12)), reference: "BOK-004", location: cs ? "Hlavní salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" },
{ id: "b5", customerName: customers[4].name, customerEmail: customers[4].email, service: services[0], status: "completed", startsAt: fmt(addHours(addDays(-2), 13)), endsAt: fmt(addHours(addDays(-2), 14)), reference: "BOK-005", location: cs ? "Studio B" : "Studio B", staffName: cs ? "Lucie K." : "Lucie K.", notes: "" },
{ id: "b6", customerName: customers[0].name, customerEmail: customers[0].email, service: services[1], status: "confirmed", startsAt: fmt(addHours(addDays(3), 10)), endsAt: fmt(addHours(addDays(3), 11)), reference: "BOK-006", location: cs ? "Hlavní salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" },
{ id: "b7", customerName: customers[1].name, customerEmail: customers[1].email, service: services[2], status: "confirmed", startsAt: fmt(addHours(addDays(0), 16)), endsAt: fmt(addHours(addDays(0), 17)), reference: "BOK-007", location: cs ? "Studio B" : "Studio B", staffName: "", notes: "" },
{ id: "b8", customerName: customers[2].name, customerEmail: customers[2].email, service: services[3], status: "pending", startsAt: fmt(addHours(addDays(4), 9)), endsAt: fmt(addHours(addDays(4), 10)), reference: "BOK-008", location: cs ? "Hlavní salon" : "Main Salon", staffName: cs ? "Lucie K." : "Lucie K.", notes: "" },
{ id: "b9", customerName: customers[3].name, customerEmail: customers[3].email, service: services[0], status: "confirmed", startsAt: fmt(addHours(addDays(5), 11)), endsAt: fmt(addHours(addDays(5), 12)), reference: "BOK-009", location: cs ? "Hlavní salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" },
{ id: "b10", customerName: customers[4].name, customerEmail: customers[4].email, service: services[1], status: "completed", startsAt: fmt(addHours(addDays(-3), 10)), endsAt: fmt(addHours(addDays(-3), 11)), reference: "BOK-010", location: cs ? "Studio B" : "Studio B", staffName: "", notes: "" },
];
return {
summary: {
tenantName: cs ? "Demo Studio" : "Demo Studio",
tenantSlug: "demo-studio",
publicBookingUrl: "http://localhost:3000/book/demo-studio",
kpis: [
{ label: i18n.t("dashboard.kpi.bookings"), value: 42, change: "+12%", trend: "up" },
{ label: i18n.t("dashboard.kpi.cancelled"), value: 3, change: "-5%", trend: "down" },
{ label: i18n.t("dashboard.kpi.completed"), value: 38, change: "+8%", trend: "up" },
{ label: i18n.t("dashboard.kpi.newClients"), value: 12, change: "+24%", trend: "up" },
],
staff: [{ id: "s1", name: cs ? "Petra M." : "Petra M.", role: cs ? "Specialista" : "Specialist", email: "petra@demo.cz" }],
upcomingBookings: allBookings.filter(b => b.status !== "cancelled" && b.status !== "completed"),
allBookings,
recentActivity: [
{ action: cs ? "Nová rezervace" : "New booking", detail: `${customers[0].name} - ${services[0]}`, time: cs ? "Dnes, 10:00" : "Today, 10:00", type: "booking" },
{ action: cs ? "Zrušení rezervace" : "Booking cancelled", detail: `${customers[3].name} - ${services[3]}`, time: cs ? "Včera" : "Yesterday", type: "cancel" },
{ action: cs ? "Dokončená rezervace" : "Booking completed", detail: `${customers[4].name} - ${services[1]}`, time: cs ? "Před 2 dny" : "2 days ago", type: "reminder" },
],
},
bootstrap: {
tenantName: cs ? "Demo Studio" : "Demo Studio",
slug: "demo-studio",
brand: { primaryColor: "#c25e3a" },
locale: cs ? "cs" : "en",
timezone: "Europe/Prague",
},
billing: {
planCode: "pro",
planName: "Pro",
subscriptionStatus: "trialing",
trialDaysRemaining: 12,
entitlements: { maxLocations: 3, maxBookings: -1, maxStaff: 10 },
billingProvider: "stripe",
},
};
}
export interface DashboardData {
i18n: ReturnType<typeof useI18n>;
auth: ReturnType<typeof useAuth>;
token: () => string | null | undefined;
isDemoMode: () => boolean;
resolvedSummary: () => any;
resolvedBootstrap: () => any;
resolvedBilling: () => any;
normalizedKpis: () => any[];
normalizedUpcomingBookings: () => any[];
normalizedRecentActivity: () => any[];
normalizedAllBookings: () => any[];
isDashboardReady: () => boolean;
locationData: () => number;
smsUsage: () => any;
demoNotice: () => string | null;
setDemoNotice: (v: string | null) => void;
billingNotice: () => string | null;
setBillingNotice: (v: string | null) => void;
billingError: () => string | null;
setBillingError: (v: string | null) => void;
billingAction: () => "checkout" | "refresh" | "portal" | null;
setBillingAction: (v: "checkout" | "refresh" | "portal" | null) => void;
billingInterval: () => "monthly" | "yearly";
setBillingInterval: (v: "monthly" | "yearly") => void;
openCheckout: () => Promise<void>;
openPortal: () => Promise<void>;
refreshBilling: () => Promise<void>;
showIntegrationModal: () => boolean;
setShowIntegrationModal: (v: boolean) => void;
showBookingDetail: () => boolean;
setShowBookingDetail: (v: boolean) => void;
selectedBooking: () => any;
setSelectedBooking: (v: any) => void;
openBookingDetail: (booking: any) => void;
}
const DashboardContext = createContext<DashboardData>();
export function useDashboardData() {
const ctx = useContext(DashboardContext);
if (!ctx) throw new Error("useDashboardData must be used within DashboardContext provider");
return ctx;
}
export function DashboardDataProvider(props: { children: any }) {
const i18n = useI18n();
const auth = useAuth();
const [billingInterval, setBillingInterval] = createSignal<"monthly" | "yearly">("monthly");
const [billingNotice, setBillingNotice] = createSignal<string | null>(null);
const [billingError, setBillingError] = createSignal<string | null>(null);
const [billingAction, setBillingAction] = createSignal<"checkout" | "refresh" | "portal" | null>(null);
const [showIntegrationModal, setShowIntegrationModal] = createSignal(false);
const [demoNotice, setDemoNotice] = createSignal<string | null>(null);
const [showBookingDetail, setShowBookingDetail] = createSignal(false);
const [selectedBooking, setSelectedBooking] = createSignal<any>(null);
const [token] = createResource(() => auth.session()?.session?.id, () => auth.getToken());
const isDemoMode = () => token()?.startsWith("demo.") ?? false;
const demoData = createMemo(() => createDemoData(i18n));
const [summary] = createResource(token, async (bearer) => {
if (!bearer) return null;
if (bearer.startsWith("demo.")) return demoData().summary;
const response = await apiClient.GET("/v1/dashboard/summary", { headers: { Authorization: `Bearer ${bearer}` } });
return response.data ?? null;
});
const [bootstrap] = createResource(token, async (bearer) => {
if (!bearer) return null;
if (bearer.startsWith("demo.")) return demoData().bootstrap;
const response = await apiClient.GET("/v1/tenants/bootstrap", { headers: { Authorization: `Bearer ${bearer}` } });
return response.data ?? null;
});
const [billing, { refetch: refetchBilling }] = createResource(token, async (bearer) => {
if (!bearer) return null;
if (bearer.startsWith("demo.")) return demoData().billing;
const response = await apiClient.GET("/v1/billing/subscription", { headers: { Authorization: `Bearer ${bearer}` } });
return response.data ?? null;
});
const [locationData] = createResource(token, async (bearer) => {
if (!bearer) return 0;
if (bearer.startsWith("demo.")) return 1;
try {
const response = await (apiClient as any).GET("/v1/catalog/locations", { headers: { Authorization: `Bearer ${bearer}` } });
return (response.data as any[])?.length ?? 0;
} catch { return 0; }
});
const [smsUsage] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) return null;
try {
const response = await (apiClient as any).GET("/v1/sms/usage", { headers: { Authorization: `Bearer ${bearer}` } });
return response.data as any ?? null;
} catch { return null; }
});
const resolvedSummary = () => (summary.latest as any) ?? (isDemoMode() ? demoData().summary : undefined);
const resolvedBootstrap = () => (bootstrap.latest as any) ?? (isDemoMode() ? demoData().bootstrap : undefined);
const resolvedBilling = () => (billing.latest as any) ?? (isDemoMode() ? demoData().billing : undefined);
const normalizedKpis = createMemo(() => (resolvedSummary()?.kpis ?? []).map((kpi: any) => ({
...kpi, trend: kpi.trend ?? "neutral", change: kpi.change ?? "—",
})));
const normalizedUpcomingBookings = createMemo(() => (resolvedSummary()?.upcomingBookings ?? []).map((booking: any) => ({
...booking,
avatar: booking.avatar ?? getInitials(booking.customerName),
service: booking.service ?? booking.label ?? booking.reference ?? (i18n.locale() === "cs" ? "Rezervace" : "Booking"),
duration: booking.duration ?? getBookingDuration(booking.startsAt, booking.endsAt),
})));
const normalizedRecentActivity = createMemo(() => {
if (resolvedSummary()?.recentActivity?.length) return resolvedSummary().recentActivity;
return normalizedUpcomingBookings().slice(0, 5).map((booking: any) => ({
action: i18n.locale() === "cs" ? "Nadcházející rezervace" : "Upcoming booking",
detail: `${booking.customerName} \u2022 ${booking.service}`,
time: new Date(booking.startsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }),
type: "booking",
}));
});
const normalizedAllBookings = createMemo(() => resolvedSummary()?.allBookings ?? normalizedUpcomingBookings());
const isDashboardReady = () => !!token() && !bootstrap.loading && !summary.loading && !billing.loading;
const openCheckout = async () => {
const bearer = token();
if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo režimu nelze upravovat předplatné." : "Cannot modify billing in demo mode."); return; }
setBillingAction("checkout");
setBillingError(null);
try {
const response = await (apiClient as any).POST("/v1/billing/checkout", {
headers: { Authorization: `Bearer ${bearer}` },
body: { interval: billingInterval() },
});
if (response.data?.url) window.location.href = response.data.url;
else throw new Error("No checkout URL");
} catch (err) { setBillingError(err instanceof Error ? err.message : String(err)); }
finally { setBillingAction(null); }
};
const openPortal = async () => {
const bearer = token();
if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo režimu nelze upravovat předplatné." : "Cannot modify billing in demo mode."); return; }
setBillingAction("portal");
setBillingError(null);
try {
const response = await (apiClient as any).POST("/v1/billing/portal", { headers: { Authorization: `Bearer ${bearer}` } });
if (response.data?.url) window.location.href = response.data.url;
else throw new Error("No portal URL");
} catch (err) { setBillingError(err instanceof Error ? err.message : String(err)); }
finally { setBillingAction(null); }
};
const refreshBilling = async () => {
const bearer = token();
if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo režimu nelze obnovit předplatné." : "Cannot refresh billing in demo mode."); return; }
setBillingAction("refresh");
setBillingError(null);
try {
const response = await (apiClient as any).POST("/v1/billing/refresh", { headers: { Authorization: `Bearer ${bearer}` } });
if (response.data) { setBillingNotice(i18n.locale() === "cs" ? "Předplatné obnoveno." : "Billing refreshed."); void refetchBilling(); }
} catch (err) { setBillingError(err instanceof Error ? err.message : String(err)); }
finally { setBillingAction(null); }
};
const openBookingDetail = (booking: any) => {
setSelectedBooking(booking);
setShowBookingDetail(true);
};
const value: DashboardData = {
i18n,
auth,
token: () => token(),
isDemoMode,
resolvedSummary,
resolvedBootstrap,
resolvedBilling,
normalizedKpis,
normalizedUpcomingBookings,
normalizedRecentActivity,
normalizedAllBookings,
isDashboardReady,
locationData: () => locationData() ?? 0,
smsUsage: () => smsUsage.latest,
demoNotice,
setDemoNotice,
billingNotice,
setBillingNotice,
billingError,
setBillingError,
billingAction,
setBillingAction,
billingInterval,
setBillingInterval,
openCheckout,
openPortal,
refreshBilling,
showIntegrationModal,
setShowIntegrationModal,
showBookingDetail,
setShowBookingDetail,
selectedBooking,
setSelectedBooking,
openBookingDetail,
};
return value;
}
export { DashboardContext };
@@ -0,0 +1,344 @@
import { Show, createSignal, type JSX, type ParentComponent } from "solid-js";
import { A, useLocation } from "@solidjs/router";
import { useTheme } from "../../providers/theme-provider";
import { BookraCharacter } from "../../components/bookra-character";
import { IntegrationModal } from "../../components/integration-modal";
import { NotificationDropdown } from "../../components/dashboard/notification-dropdown";
import { getInitials, getBookingDuration } from "../../components/dashboard/types";
import {
LayoutDashboardIcon, CalendarDaysIcon, CreditCardIcon, Settings2Icon,
LogOutIcon, MenuIcon, XIcon, SparklesIcon, GlobeIcon, SunIcon, MoonIcon,
UserCircleIcon, MapPinIcon
} from "../../components/dashboard/icons";
import { DashboardContext, useDashboardData, DashboardDataProvider } from "./dashboard-data";
export { useDashboardData };
const navItems = [
{ id: "overview", label: "dashboard.overview", icon: LayoutDashboardIcon, path: "/dashboard" },
{ id: "bookings", label: "dashboard.bookings", icon: CalendarDaysIcon, path: "/dashboard/bookings" },
{ id: "customers", label: "dashboard.customers", icon: UserCircleIcon, path: "/dashboard/customers" },
{ id: "zones", label: "dashboard.zones", icon: MapPinIcon, path: "/dashboard/zones" },
{ id: "billing", label: "dashboard.billing", icon: CreditCardIcon, path: "/dashboard/billing" },
{ id: "settings", label: "dashboard.settings", icon: Settings2Icon, path: "/dashboard/settings" },
];
function DashboardShell(props: { children: JSX.Element }) {
const data = useDashboardData();
const theme = useTheme();
const location = useLocation();
const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(false);
const activeSection = () => {
const p = location.pathname;
if (p === "/dashboard" || p === "/dashboard/") return "overview";
const match = navItems.find(item => p === item.path || p.startsWith(item.path + "/"));
return match?.id ?? "overview";
};
const isDark = () => theme.resolvedTheme() === "dark";
const DashboardLogo = (props: { class?: string }) => (
<div class={`relative h-8 ${props.class ?? ""}`}>
<img src="/bookra-illustrations/logo_text_horizontal.svg" alt="Bookra" class="h-8 w-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": isDark(), "opacity-75": !isDark() }} />
<img src="/bookra-illustrations/logo_text_horizontal_white.svg" alt="Bookra" class="h-8 w-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": !isDark(), "opacity-75": isDark() }} />
</div>
);
const LanguageToggle = () => (
<button
onClick={() => data.i18n.toggleLocale()}
class="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-ink-muted hover:text-ink hover:bg-canvas-subtle rounded-lg transition-all"
title={data.i18n.t("dashboard.language")}
>
<GlobeIcon />
{data.i18n.locale() === "cs" ? "CS" : "EN"}
</button>
);
const ThemeToggle = () => (
<button
onClick={() => theme.toggle()}
class="p-2 text-ink-subtle hover:text-ink hover:bg-canvas-subtle rounded-xl transition-all"
aria-label="Toggle theme"
>
{theme.resolvedTheme() === "dark" ? <SunIcon /> : <MoonIcon />}
</button>
);
const DemoBanner = () => (
<Show when={data.isDemoMode()}>
<div class="mb-6 p-4 rounded-xl bg-accent-subtle border border-accent/20 flex items-center justify-between animate-fade-in">
<div class="flex items-center gap-3 min-w-0">
<SparklesIcon />
<div class="min-w-0">
<p class="text-sm font-semibold text-accent">{data.i18n.t("dashboard.demoBanner")}</p>
<p class="text-xs text-accent/70">{data.i18n.t("dashboard.demoBannerDesc")}</p>
</div>
</div>
<button
onClick={() => window.dispatchEvent(new CustomEvent("openAuthDialog", { detail: { mode: "register" } }))}
class="shrink-0 ml-4 px-4 py-2 bg-accent text-canvas text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors"
>
{data.i18n.t("dashboard.demoBannerCTA")}
</button>
</div>
</Show>
);
const Sidebar = () => (
<aside class="hidden lg:flex flex-col w-64 h-screen bg-canvas border-r border-border sticky top-0 shrink-0">
<div class="p-6">
<A href="/" class="flex items-center gap-2.5 text-lg font-display font-semibold tracking-tight text-ink hover:text-accent transition-colors">
<DashboardLogo />
</A>
</div>
<nav class="flex-1 px-4 space-y-1 overflow-y-auto">
{navItems.map((item) => (
<A
href={item.path}
class={`
w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200
${activeSection() === item.id
? "bg-accent text-canvas shadow-sm"
: "text-ink-muted hover:bg-canvas-subtle hover:text-ink"
}
`}
>
<item.icon /> {data.i18n.t(item.label)}
</A>
))}
</nav>
<div class="p-4 border-t border-border space-y-1">
<div class="flex items-center gap-2 px-4 py-2">
<LanguageToggle />
<ThemeToggle />
</div>
<Show when={data.auth.session()?.user}>
<div class="flex items-center gap-3 px-4 py-3">
<div class="w-8 h-8 rounded-full bg-accent-subtle flex items-center justify-center text-sm font-bold text-accent shrink-0">
{getInitials(data.auth.session()?.user?.name)}
</div>
<div class="min-w-0">
<p class="text-sm font-semibold text-ink truncate">{data.auth.session()?.user?.name}</p>
<p class="text-xs text-ink-muted truncate">{data.auth.session()?.user?.email}</p>
</div>
</div>
</Show>
<button
onClick={() => void data.auth.signOut()}
class="w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium text-ink-muted hover:text-ink hover:bg-canvas-subtle transition-all group"
>
<span class="group-hover:translate-x-0.5 transition-transform"><LogOutIcon /></span>
{data.i18n.t("auth.signOut")}
</button>
</div>
</aside>
);
const MobileMenu = () => (
<Show when={isMobileMenuOpen()}>
<div class="lg:hidden fixed inset-0 z-50">
<div class="absolute inset-0 bg-ink/40 backdrop-blur-sm transition-opacity" onClick={() => setIsMobileMenuOpen(false)} />
<div class="absolute left-0 top-0 h-full w-72 bg-canvas shadow-2xl">
<div class="p-4 border-b border-border flex items-center justify-between">
<DashboardLogo />
<button onClick={() => setIsMobileMenuOpen(false)} class="p-2 hover:bg-canvas-subtle rounded-xl transition-colors" aria-label={data.i18n.t("dashboard.close")}>
<XIcon />
</button>
</div>
<nav class="p-4 space-y-1">
{navItems.map((item) => (
<A
href={item.path}
onClick={() => setIsMobileMenuOpen(false)}
class={`
w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all
${activeSection() === item.id ? "bg-accent text-canvas" : "text-ink-muted hover:bg-canvas-subtle"}
`}
>
<item.icon /> {data.i18n.t(item.label)}
</A>
))}
</nav>
<div class="p-4 border-t border-border">
<div class="flex items-center gap-2 mb-2">
<LanguageToggle />
<ThemeToggle />
</div>
<button onClick={() => void data.auth.signOut()} class="w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium text-ink-muted hover:text-ink hover:bg-canvas-subtle transition-all">
<LogOutIcon /> {data.i18n.t("auth.signOut")}
</button>
</div>
</div>
</div>
</Show>
);
const BookingDetailModal = () => (
<Show when={data.showBookingDetail() && data.selectedBooking()}>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-ink/50 backdrop-blur-sm" onClick={() => data.setShowBookingDetail(false)} />
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto animate-scale-in">
<div class="p-6 border-b border-border flex items-center justify-between">
<h3 class="text-xl font-bold text-ink">{data.i18n.t("dashboard.bookingDetails")}</h3>
<button onClick={() => data.setShowBookingDetail(false)} class="p-2 hover:bg-canvas-subtle rounded-lg transition-colors" aria-label={data.i18n.t("dashboard.close")}>
<XIcon />
</button>
</div>
<div class="p-6 space-y-5">
<div class="flex items-center gap-4 p-4 bg-canvas-subtle rounded-xl">
<div class="w-14 h-14 rounded-full bg-accent-subtle flex items-center justify-center text-lg font-bold text-accent">
{getInitials(data.selectedBooking()?.customerName)}
</div>
<div class="min-w-0">
<p class="font-semibold text-ink text-lg">{data.selectedBooking()?.customerName}</p>
<p class="text-ink-muted">{data.selectedBooking()?.customerEmail}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.service")}</p>
<p class="font-medium text-ink">{data.selectedBooking()?.service}</p>
</div>
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.location")}</p>
<p class="font-medium text-ink">{data.selectedBooking()?.location || "\u2014"}</p>
</div>
<div class="p-4 bg-canvas-subtle rounded-xl col-span-2">
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.assignedTo")}</p>
<Show when={data.selectedBooking()?.staffName} fallback={<p class="text-ink-subtle">{data.i18n.t("dashboard.bookingModal.noAssign")}</p>}>
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-full bg-accent-subtle flex items-center justify-center text-[10px] font-bold text-accent">
{data.selectedBooking()?.staffName?.split(" ").map((n: string) => n[0]).join("")}
</div>
<p class="font-medium text-ink">{data.selectedBooking()?.staffName}</p>
</div>
</Show>
</div>
</div>
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.dateTime")}</p>
<p class="font-medium text-ink">
{new Date(data.selectedBooking()?.startsAt).toLocaleString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}
</p>
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.bookingModal.duration")}: {getBookingDuration(data.selectedBooking()?.startsAt, data.selectedBooking()?.endsAt)}</p>
</div>
<div class="flex items-center justify-between p-4 bg-canvas-subtle rounded-xl">
<div>
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.reference")}</p>
<p class="font-medium text-ink font-mono text-sm">{data.selectedBooking()?.reference}</p>
</div>
<span class={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
data.selectedBooking()?.status === "confirmed" ? "bg-accent/10 text-accent"
: data.selectedBooking()?.status === "pending" ? "bg-canvas-muted text-ink-muted"
: "bg-canvas-subtle text-ink-subtle"
}`}>
{data.i18n.t(`dashboard.${data.selectedBooking()?.status}`)}
</span>
</div>
<Show when={data.selectedBooking()?.notes}>
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.notes")}</p>
<p class="text-ink text-sm">{data.selectedBooking()?.notes}</p>
</div>
</Show>
</div>
</div>
</div>
</Show>
);
return (
<div class="min-h-screen flex bg-canvas font-dashboard">
<Sidebar />
<div class="flex-1 flex flex-col min-h-screen">
{/* Mobile Header */}
<div class="lg:hidden bg-canvas/80 backdrop-blur-xl border-b border-border p-4 flex items-center justify-between sticky top-0 z-40">
<DashboardLogo />
<div class="flex items-center gap-2">
<NotificationDropdown bookings={data.normalizedAllBookings()} billing={data.resolvedBilling()} />
<button onClick={() => setIsMobileMenuOpen(true)} aria-label={data.i18n.locale() === "cs" ? "Otevrit menu" : "Open menu"} class="p-2 hover:bg-canvas-subtle rounded-xl transition-all">
<MenuIcon />
</button>
</div>
</div>
<MobileMenu />
<main class="flex-1 p-4 lg:p-8 overflow-y-auto">
<DemoBanner />
{/* Demo Notice Toast */}
<Show when={data.demoNotice()}>
<div class="mb-6 p-4 rounded-xl bg-accent-subtle border border-accent/20 flex items-center justify-between animate-fade-in">
<div class="flex items-center gap-3">
<SparklesIcon />
<p class="text-sm font-medium text-accent">{data.demoNotice()}</p>
</div>
<button onClick={() => data.setDemoNotice(null)} class="p-1.5 hover:bg-accent/10 rounded-lg text-accent"><XIcon /></button>
</div>
</Show>
{/* Not logged in */}
<Show when={!data.token() && data.auth.session() !== undefined}>
<div class="max-w-lg mx-auto py-12 lg:py-20 px-4 text-center">
<div class="relative inline-block mb-6">
<div class="absolute inset-0 bg-accent/20 rounded-full blur-3xl opacity-60" />
<BookraCharacter pose="hello" size="xl" animate={true} />
</div>
<h2 class="text-3xl lg:text-4xl font-bold text-ink tracking-tight font-display">{data.i18n.locale() === "cs" ? "Vas rezervacni system ceka" : "Your booking system awaits"}</h2>
<p class="text-ink-muted mt-3 text-lg max-w-md mx-auto">{data.i18n.t("dashboard.welcome.body")}</p>
<div class="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
<button onClick={() => window.dispatchEvent(new CustomEvent("openAuthDialog", { detail: { mode: "sign-in" } }))} class="btn-primary w-full sm:w-auto">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" x2="3" y1="12" y2="12"/></svg>
{data.i18n.t("auth.signIn")}
</button>
<button onClick={async () => { await data.auth.signInAsDemo(); window.location.reload(); }} class="btn-secondary w-full sm:w-auto">
<SparklesIcon /> {data.i18n.t("dashboard.tryDemo")}
</button>
</div>
<p class="mt-6 text-sm text-ink-muted">{data.i18n.locale() === "cs" ? "Jeste nemate ucet? " : "No account yet? "}
<button onClick={() => window.dispatchEvent(new CustomEvent("openAuthDialog", { detail: { mode: "register" } }))} class="text-accent hover:text-accent-hover font-medium underline underline-offset-2">{data.i18n.t("auth.createAccount")}</button>
</p>
</div>
</Show>
{/* Loading */}
<Show when={data.token() && !data.isDashboardReady()}>
<div class="flex flex-col items-center justify-center py-20">
<BookraCharacter pose="walk" size="lg" animate={true} />
<p class="mt-6 text-sm text-ink-muted animate-pulse font-medium">{data.i18n.locale() === "cs" ? "Nacitani dashboardu..." : "Loading your dashboard..."}</p>
</div>
</Show>
{/* Dashboard Content */}
<Show when={data.isDashboardReady()}>
<div class="max-w-7xl mx-auto">
{props.children}
</div>
</Show>
</main>
</div>
<IntegrationModal
isOpen={data.showIntegrationModal()}
onClose={() => data.setShowIntegrationModal(false)}
tenantSlug={data.resolvedSummary()?.tenantSlug || "demo-studio"}
publicBookingUrl={data.resolvedSummary()?.publicBookingUrl || "https://bookra.eu/book/demo-studio"}
tenantName={data.resolvedSummary()?.tenantName || "Demo Studio"}
primaryColor={data.resolvedBootstrap()?.brand?.primaryColor}
/>
<BookingDetailModal />
</div>
);
}
export const DashboardLayout: ParentComponent = (props) => {
const value = DashboardDataProvider({ children: undefined } as any);
return (
<DashboardContext.Provider value={value}>
<DashboardShell>{props.children}</DashboardShell>
</DashboardContext.Provider>
);
};
@@ -0,0 +1,223 @@
import { A, useNavigate } from "@solidjs/router";
import { Show } from "solid-js";
import { CalendarView } from "../../components/dashboard/calendar-view";
import { RevenueChart } from "../../components/dashboard/revenue-chart";
import { KpiCard } from "../../components/dashboard/kpi-card";
import { ActivityTimeline } from "../../components/dashboard/activity-timeline";
import { NotificationDropdown } from "../../components/dashboard/notification-dropdown";
import { getInitials } from "../../components/dashboard/types";
import { ChevronRightIcon, MailIcon, SparklesIcon } from "../../components/dashboard/icons";
import { PinnedList } from "../../components/pinned-list";
import { DashboardLayout, useDashboardData } from "./layout";
function OverviewPage() {
const data = useDashboardData();
const navigate = useNavigate();
return (
<div class="space-y-6">
{/* Header */}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink tracking-tight font-display">
{data.i18n.t("dashboard.welcome")} <span class="text-accent">{data.auth.session()?.user?.name}</span>
</h1>
<p class="text-ink-muted mt-1">{data.i18n.t("dashboard.overviewFor")} {data.resolvedBootstrap()?.tenantName}</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<button onClick={() => data.setShowIntegrationModal(true)} class="btn-secondary text-sm">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" 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>
{data.i18n.t("dashboard.shareManage")}
</button>
<span class="text-sm text-ink-subtle hidden sm:inline">{new Date().toLocaleDateString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "long", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
<NotificationDropdown bookings={data.normalizedAllBookings()} billing={data.resolvedBilling()} />
</div>
</div>
{/* KPI Cards */}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{data.normalizedKpis().map((kpi: any, index: number) => (
<KpiCard kpi={kpi} index={index} />
))}
</div>
{/* Setup Guide */}
<div class="grid lg:grid-cols-3 gap-6">
<div class="lg:col-span-1 surface-card p-5">
<div class="flex items-center gap-3 mb-4">
<div class="w-9 h-9 rounded-lg bg-accent-subtle flex items-center justify-center text-accent">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5"/></svg>
</div>
<div>
<h3 class="font-semibold text-ink">{data.i18n.locale() === "cs" ? "Začínáme" : "Getting Started"}</h3>
<p class="text-xs text-ink-muted">{data.i18n.locale() === "cs" ? "Dokončete nastavení" : "Complete your setup"}</p>
</div>
</div>
<PinnedList
items={[
{
id: "setup-locations",
name: data.i18n.locale() === "cs" ? "Přidat lokace" : "Add locations",
subtitle: data.i18n.locale() === "cs" ? "Salon, klinika, studio" : "Salon, clinic, studio",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>,
href: "/dashboard/zones",
},
{
id: "setup-hours",
name: data.i18n.locale() === "cs" ? "Nastavit provozní dobu" : "Set business hours",
subtitle: data.i18n.locale() === "cs" ? "PoNe, svátky" : "MonSun, holidays",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>,
href: "/dashboard/zones",
},
{
id: "setup-services",
name: data.i18n.locale() === "cs" ? "Definovat služby" : "Define services",
subtitle: data.i18n.locale() === "cs" ? "Ceník, délka, kapacita" : "Pricing, duration, capacity",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>,
href: "/dashboard/settings",
},
{
id: "setup-widget",
name: data.i18n.locale() === "cs" ? "Vložit rezervační widget" : "Embed booking widget",
subtitle: data.i18n.locale() === "cs" ? "Web, Instagram, email" : "Website, Instagram, email",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>,
href: "/dashboard/settings",
},
{
id: "setup-team",
name: data.i18n.locale() === "cs" ? "Přidat členy týmu" : "Add team members",
subtitle: data.i18n.locale() === "cs" ? "Role, oprávnění" : "Roles, permissions",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
href: "/dashboard/settings",
},
{
id: "setup-branding",
name: data.i18n.locale() === "cs" ? "Upravit branding" : "Customize branding",
subtitle: data.i18n.locale() === "cs" ? "Barva, logo, jazyk" : "Color, logo, language",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.01 17.461 2 12 2z"/></svg>,
href: "/dashboard/settings",
},
]}
pinnedLabel={data.i18n.locale() === "cs" ? "Připnuto" : "Pinned"}
allLabel={data.i18n.locale() === "cs" ? "Zbývá" : "Remaining"}
initialPinned={["setup-locations"]}
/>
</div>
<div class="lg:col-span-2">
<RevenueChart />
</div>
</div>
{/* SMS Usage */}
{(() => {
const usage = data.smsUsage();
if (!usage || usage.messageCount === 0) return null;
return (
<div class="surface-card p-5 border-l-4 border-accent">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-accent">
<MailIcon />
</div>
<div>
<p class="font-semibold text-ink">SMS {data.i18n.t("dashboard.billing.planUsage")}</p>
<p class="text-sm text-ink-muted">{usage.messageCount} messages &mdash; {(usage.totalCostCents / 100).toFixed(2)} Kc</p>
</div>
</div>
<A href="/dashboard/settings" class="text-sm font-medium text-accent hover:text-accent-hover transition-colors">
{data.i18n.t("dashboard.settings")}
</A>
</div>
</div>
);
})()}
{/* Plan Limit Warning */}
{(() => {
const entitlements = data.resolvedBilling()?.entitlements;
const limit = entitlements?.maxLocations ?? -1;
const count = data.locationData() ?? 0;
const isNear = limit > 0 && count >= limit * 0.8;
const isAt = limit > 0 && count >= limit;
if (!isNear && !isAt) return null;
return (
<div class={`p-4 rounded-xl border ${isAt ? "bg-canvas-subtle border-ink/10" : "bg-accent/5 border-accent/20"}`}>
<div class="flex items-center gap-3">
<div class={`w-10 h-10 rounded-full flex items-center justify-center ${isAt ? "bg-canvas-muted" : "bg-accent/10"}`}>
<SparklesIcon />
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-ink">{isAt ? data.i18n.t("dashboard.locationLimitReached") : data.i18n.t("dashboard.nearingLocationLimit")}</p>
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.locationsUsed")} {count}/{limit === -1 ? "\u221e" : limit}</p>
</div>
<button onClick={() => void data.openCheckout()} class="px-4 py-2 text-sm font-medium rounded-lg bg-accent text-canvas hover:bg-accent-hover transition-colors shrink-0">
{data.i18n.t("dashboard.upgrade")}
</button>
</div>
</div>
);
})()}
{/* Revenue Chart + Calendar + Activity */}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-6">
<RevenueChart bookings={data.normalizedAllBookings()} />
<CalendarView bookings={data.normalizedUpcomingBookings()} locale={data.i18n.locale()} onBookingClick={data.openBookingDetail} />
</div>
<div class="space-y-6">
<ActivityTimeline activities={data.normalizedRecentActivity()} />
</div>
</div>
{/* Upcoming Bookings */}
<div class="surface-card p-6 shadow-sm">
<div class="flex items-center justify-between mb-5">
<div>
<h3 class="text-lg font-semibold text-ink">{data.i18n.t("dashboard.upcomingBookings")}</h3>
<p class="text-sm text-ink-muted mt-0.5">{data.normalizedUpcomingBookings().length} total</p>
</div>
<A href="/dashboard/bookings" class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-accent hover:text-accent-hover hover:bg-accent-subtle rounded-xl transition-all">
{data.i18n.t("dashboard.viewAll")} <ChevronRightIcon />
</A>
</div>
<div class="space-y-3">
{data.normalizedUpcomingBookings().slice(0, 5).map((booking: any) => (
<button
onClick={() => data.openBookingDetail(booking)}
class="w-full text-left flex items-center gap-4 p-4 rounded-xl border border-border/60 hover:border-accent/30 hover:bg-accent-subtle/20 transition-all group"
>
<div class="w-11 h-11 rounded-full bg-accent-subtle flex items-center justify-center text-sm font-bold text-accent shrink-0">
{getInitials(booking.customerName)}
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-ink group-hover:text-accent transition-colors">{booking.customerName}</p>
<p class="text-sm text-ink-muted">{booking.service} &bull; {booking.duration}</p>
</div>
<div class="text-right shrink-0">
<p class="text-sm font-medium text-ink">{new Date(booking.startsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}</p>
<span class={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
booking.status === "confirmed" ? "bg-accent/10 text-accent"
: booking.status === "pending" ? "bg-canvas-muted text-ink-muted"
: "bg-canvas-subtle text-ink-subtle"
}`}>
{data.i18n.t(`dashboard.${booking.status}`)}
</span>
</div>
</button>
))}
</div>
</div>
</div>
);
}
export default function OverviewRoute() {
return (
<DashboardLayout>
<OverviewPage />
</DashboardLayout>
);
}
@@ -0,0 +1,140 @@
import { Show, createSignal } from "solid-js";
import { apiClient } from "../../lib/api-client";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import { PaletteIcon, MailIcon, CheckCircleIcon, BellIcon, XCircleIcon, CalendarDaysIcon } from "../../components/dashboard/icons";
import { SMSSettings } from "../../components/sms-settings";
import { WidgetBuilder } from "../../components/widget-builder";
import { DashboardLayout, useDashboardData } from "./layout";
function SettingsPage() {
const data = useDashboardData();
const [brandColor, setBrandColor] = createSignal(data.resolvedBootstrap()?.brand?.primaryColor ?? "#c25e3a");
const [brandSaving, setBrandSaving] = createSignal(false);
const [activeEmailType, setActiveEmailType] = createSignal("confirmation");
const [emailSubject, setEmailSubject] = createSignal("");
const [emailBody, setEmailBody] = createSignal("");
const [emailSaving, setEmailSaving] = createSignal(false);
const presetColors = [
{ name: data.i18n.locale() === "cs" ? "Hnědá" : "Terracotta", color: "#c25e3a" },
{ name: data.i18n.locale() === "cs" ? "Zelená" : "Sage", color: "#5a7c5a" },
{ name: data.i18n.locale() === "cs" ? "Modrá" : "Ocean", color: "#3a6a8a" },
{ name: data.i18n.locale() === "cs" ? "Fialová" : "Plum", color: "#6a4a7a" },
];
const handleSaveBrand = async () => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setBrandSaving(true);
try {
await (apiClient as any).PUT("/v1/tenants/brand", { headers: { Authorization: `Bearer ${bearer}` }, body: { primaryColor: brandColor() } });
} catch { /* ignore */ }
finally { setBrandSaving(false); }
};
return (
<div class="space-y-6">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.settings")}</h1>
<p class="text-ink-muted mt-1">{data.i18n.locale() === "cs" ? "Spravujte svůj podnik, branding a předvolby." : "Manage your business, branding and preferences."}</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="surface-card p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-accent"><PaletteIcon /></div>
<h3 class="text-lg font-semibold text-ink">{data.i18n.t("dashboard.settings.branding")}</h3>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-ink-muted mb-3">{data.i18n.t("dashboard.settings.branding")}</label>
<div class="grid grid-cols-4 gap-2 mb-3">
{presetColors.map((preset) => (
<button onClick={() => setBrandColor(preset.color)} class={`p-2 rounded-lg border-2 transition-all ${brandColor() === preset.color ? "border-ink" : "border-transparent hover:border-ink/20"}`}>
<div class="w-full h-8 rounded-md shadow-sm" style={{ background: preset.color }} />
<p class="text-xs text-ink-muted mt-1">{preset.name}</p>
</button>
))}
</div>
<div class="flex items-center gap-3">
<input type="color" value={brandColor()} onInput={(e) => setBrandColor(e.currentTarget.value)} class="w-12 h-10 rounded-lg border border-border cursor-pointer" />
<input type="text" value={brandColor()} onInput={(e) => setBrandColor(e.currentTarget.value)} class="flex-1 px-4 py-2.5 bg-canvas-subtle border border-border rounded-xl text-ink text-sm font-mono uppercase" />
</div>
</div>
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-sm text-ink-muted mb-3">{data.i18n.t("dashboard.preview")}</p>
<div class="p-4 rounded-xl border border-border" style={{ "border-left": `4px solid ${brandColor()}` }}>
<div class="flex items-center gap-3 mb-3">
<div class="w-10 h-10 rounded-full flex items-center justify-center text-canvas font-bold" style={{ background: brandColor() }}>B</div>
<div>
<p class="font-semibold text-ink">Bookra</p>
<p class="text-xs text-ink-muted">{data.i18n.locale() === "cs" ? "Rezervujte si termin" : "Book an appointment"}</p>
</div>
</div>
<button class="w-full py-2.5 rounded-lg text-canvas font-medium text-sm" style={{ background: brandColor() }}>{data.i18n.locale() === "cs" ? "Rezervovat" : "Book Now"}</button>
</div>
</div>
<button onClick={handleSaveBrand} disabled={brandSaving()} class="mt-4 w-full py-2.5 bg-accent text-canvas rounded-xl hover:bg-accent-hover transition-colors font-medium disabled:opacity-60 flex items-center justify-center gap-2">
<Show when={brandSaving()}><span class="inline-block w-4 h-4 border-2 border-canvas/30 border-t-canvas rounded-full animate-spin" /></Show>
{brandSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
</button>
</div>
<div class="surface-card p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-accent"><MailIcon /></div>
<div>
<h3 class="text-lg font-semibold text-ink">{data.i18n.t("dashboard.settings.emailNotifications")}</h3>
<p class="text-sm text-ink-muted">{data.i18n.locale() === "cs" ? "Upravte e-maily posílané zákazníkům." : "Customize emails sent to customers."}</p>
</div>
</div>
<div class="flex gap-2 mb-4 overflow-x-auto pb-2">
{[
{ key: "confirmation", label: data.i18n.t("dashboard.confirmed"), icon: CheckCircleIcon },
{ key: "reminder", label: data.i18n.t("dashboard.notifications"), icon: BellIcon },
{ key: "cancellation", label: data.i18n.t("dashboard.cancelled"), icon: XCircleIcon },
{ key: "reschedule", label: data.i18n.t("dashboard.edit"), icon: CalendarDaysIcon },
].map((type) => (
<button onClick={() => setActiveEmailType(type.key)} class={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-all ${activeEmailType() === type.key ? "bg-accent text-canvas" : "bg-canvas-subtle text-ink-muted hover:text-ink"}`}>
<type.icon /> {type.label}
</button>
))}
</div>
<div class="space-y-4">
<Input type="text" label={data.i18n.t("dashboard.settings.emailSubject")} value={emailSubject()} onInput={(e) => setEmailSubject(e.currentTarget.value)} placeholder={activeEmailType() === "confirmation" ? (data.i18n.locale() === "cs" ? "Potvrzení rezervace" : "Booking Confirmation") : ""} />
<Textarea label={data.i18n.t("dashboard.settings.emailBody")} value={emailBody()} onInput={(e) => setEmailBody(e.currentTarget.value)} rows={6} resize="none" placeholder={data.i18n.locale() === "cs" ? "Obsah e-mailu..." : "Email body..."} />
</div>
<button onClick={async () => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setEmailSaving(true);
try {
await (apiClient as any).PUT("/v1/tenants/email-template", { headers: { Authorization: `Bearer ${bearer}` }, body: { type: activeEmailType(), subject: emailSubject(), body: emailBody() } });
data.setDemoNotice(data.i18n.locale() === "cs" ? "Šablona uložena." : "Template saved.");
} catch { data.setDemoNotice(data.i18n.locale() === "cs" ? "Chyba při ukládání." : "Save failed."); }
finally { setEmailSaving(false); }
}} disabled={emailSaving()} class="mt-4 w-full py-2.5 bg-accent text-canvas rounded-xl hover:bg-accent-hover transition-colors font-medium disabled:opacity-60 flex items-center justify-center gap-2">
<Show when={emailSaving()}><span class="inline-block w-4 h-4 border-2 border-canvas/30 border-t-canvas rounded-full animate-spin" /></Show>
{emailSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
</button>
</div>
</div>
<SMSSettings token={data.token} />
<WidgetBuilder config={{
tenantSlug: data.resolvedSummary()?.tenantSlug || "demo-studio",
publicBookingUrl: data.resolvedSummary()?.publicBookingUrl || "https://bookra.eu/book/demo-studio",
tenantName: data.resolvedSummary()?.tenantName || "Demo Studio",
primaryColor: data.resolvedBootstrap()?.brand?.primaryColor,
}} />
</div>
);
}
export default function SettingsRoute() {
return (
<DashboardLayout>
<SettingsPage />
</DashboardLayout>
);
}
@@ -0,0 +1,156 @@
import { Show, createSignal } from "solid-js";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import { LocationMap } from "../../components/location-map";
import { MapPinIcon, PlusIcon, XIcon, BanIcon, ClockIcon, SparklesIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout";
function ZonesPage() {
const data = useDashboardData();
const [activeTab, setActiveTab] = createSignal<"zones" | "blocked" | "hours">("zones");
const [showAddZone, setShowAddZone] = createSignal(false);
const [newZoneName, setNewZoneName] = createSignal("");
const [newZoneType, setNewZoneType] = createSignal("room");
const [newZoneCapacity, setNewZoneCapacity] = createSignal(10);
const [showUpgradePrompt, setShowUpgradePrompt] = createSignal(false);
const resolvedLocations = () => [
{ id: "z1", name: "Main Hall", type: "hall", capacity: 20, bookingsToday: 5 },
{ id: "z2", name: "Private Room A", type: "private", capacity: 4, bookingsToday: 2 },
{ id: "z3", name: "Treatment Room", type: "room", capacity: 2, bookingsToday: 8 },
];
const locationLimit = () => data.resolvedBilling()?.entitlements?.maxLocations ?? 3;
const canAddLocation = () => resolvedLocations().length < locationLimit();
const handleAddZone = () => {
if (!canAddLocation()) { setShowUpgradePrompt(true); setShowAddZone(false); return; }
setNewZoneName(""); setNewZoneType("room"); setNewZoneCapacity(10); setShowAddZone(false);
};
const typeLabel = (type: string) => {
const map: Record<string, string> = { room: data.i18n.t("dashboard.zone.rooms"), private: data.i18n.t("dashboard.zone.private"), hall: data.i18n.t("dashboard.zone.hall") };
return map[type] || type;
};
return (
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.zones")}</h1>
<p class="text-ink-muted mt-1">{resolvedLocations().length} {data.i18n.locale() === "cs" ? "zon" : "zones"}</p>
</div>
</div>
<div class="flex gap-2 p-1 bg-canvas-subtle rounded-xl w-fit">
{[
{ id: "zones", label: data.i18n.t("dashboard.zones"), icon: MapPinIcon },
{ id: "blocked", label: data.i18n.t("dashboard.zone.blockedDays"), icon: BanIcon },
{ id: "hours", label: data.i18n.t("dashboard.zone.workingHours"), icon: ClockIcon },
].map((tab) => (
<button onClick={() => setActiveTab(tab.id as any)} class={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${activeTab() === tab.id ? "bg-canvas text-accent shadow-sm" : "text-ink-muted hover:text-ink"}`}>
<tab.icon /> {tab.label}
</button>
))}
</div>
{activeTab() === "zones" && (
<div class="space-y-6">
<LocationMap
coordinates={{ latitude: 50.0755, longitude: 14.4378, zoom: 14, address: data.i18n.locale() === "cs" ? "Praha, Česko" : "Prague, Czechia", source: "coordinates" }}
markerColor={data.resolvedBootstrap()?.brand?.primaryColor}
height={320}
class="shadow-sm"
/>
<div class="surface-card overflow-hidden">
<div class="p-6 border-b border-border flex items-center justify-between">
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.zones")}</h3>
<Show when={canAddLocation()} fallback={
<button onClick={() => setShowUpgradePrompt(true)} class="flex items-center gap-2 px-4 py-2 bg-canvas-subtle text-ink-muted border border-border rounded-lg text-sm opacity-60 cursor-not-allowed">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
{data.i18n.t("dashboard.zone.limitReached")}
</button>
}>
<button onClick={() => setShowAddZone(true)} class="btn-primary text-sm"><PlusIcon /> {data.i18n.t("dashboard.zone.add")}</button>
</Show>
</div>
<Show when={showAddZone()}>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<h4 class="font-medium text-ink mb-4">{data.i18n.t("dashboard.zone.add")}</h4>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Input type="text" label={data.i18n.t("dashboard.zone.name")} value={newZoneName()} onInput={(e) => setNewZoneName(e.currentTarget.value)} placeholder={data.i18n.locale() === "cs" ? "např. Hlavní sál" : "e.g. Main Hall"} />
<Select label={data.i18n.t("dashboard.zone.type")} value={newZoneType()} onChange={(v) => setNewZoneType(v)} options={[{ value: "room", label: data.i18n.t("dashboard.zone.rooms") }, { value: "private", label: data.i18n.t("dashboard.zone.private") }, { value: "hall", label: data.i18n.t("dashboard.zone.hall") }]} />
<Input type="number" label={data.i18n.t("dashboard.zone.capacity")} value={newZoneCapacity()} onInput={(e) => setNewZoneCapacity(parseInt(e.currentTarget.value) || 10)} />
</div>
<div class="flex gap-3 mt-4">
<button onClick={handleAddZone} class="btn-primary text-sm">{data.i18n.t("dashboard.settings.save")}</button>
<button onClick={() => setShowAddZone(false)} class="btn-secondary text-sm">{data.i18n.t("common.cancel")}</button>
</div>
</div>
</Show>
<div class="divide-y divide-border/60">
{resolvedLocations().map((zone: any) => (
<div class="flex items-center justify-between p-6 hover:bg-canvas-subtle/30 transition-colors">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent"><MapPinIcon /></div>
<div>
<h4 class="font-medium text-ink">{zone.name}</h4>
<p class="text-sm text-ink-muted">{typeLabel(zone.type)} &bull; {data.i18n.t("dashboard.zone.capacity")}: {zone.capacity}</p>
</div>
</div>
<div class="text-right">
<p class="text-sm font-medium text-ink">{zone.bookingsToday}</p>
<p class="text-sm text-ink-muted">{zone.bookingsToday} {data.i18n.locale() === "cs" ? "rezervací dnes" : "bookings today"}</p>
</div>
</div>
))}
</div>
</div>
</div>
)}
{activeTab() === "blocked" && (
<div class="surface-card p-12 text-center">
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4"><BanIcon /></div>
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p>
<p class="text-sm text-ink-muted">{data.i18n.locale() === "cs" ? "Spravujte zóny, blokované dny a pracovní dobu." : "Manage locations, blocked days and working hours."}</p>
</div>
)}
{activeTab() === "hours" && (
<div class="surface-card p-12 text-center">
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4"><ClockIcon /></div>
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p>
<p class="text-ink-muted text-sm">{data.i18n.locale() === "cs" ? "Nastavení pracovní doby bude dostupné brzy." : "Working hours settings coming soon."}</p>
</div>
)}
<Show when={showUpgradePrompt()}>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-ink/50 backdrop-blur-sm" onClick={() => setShowUpgradePrompt(false)} />
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-md p-6 animate-scale-in">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center shrink-0"><SparklesIcon /></div>
<div class="flex-1">
<h4 class="font-medium text-ink mb-1">{data.i18n.t("dashboard.locationLimitReached")}</h4>
<p class="text-sm text-ink-muted">{data.i18n.locale() === "cs" ? "Váš plán umožňuje pouze omezený počet lokací. Pro více lokací upgradujte." : "Your plan only allows a limited number of locations. Upgrade for more."}</p>
<div class="flex gap-3">
<button onClick={() => { setShowUpgradePrompt(false); void data.openCheckout(); }} class="px-4 py-2 bg-accent text-canvas text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors">{data.i18n.t("dashboard.upgrade")}</button>
<button onClick={() => setShowUpgradePrompt(false)} class="px-4 py-2 bg-canvas-subtle text-ink-muted text-sm font-medium rounded-lg hover:bg-canvas transition-colors">{data.i18n.t("dashboard.close")}</button>
</div>
</div>
</div>
</div>
</div>
</Show>
</div>
);
}
export default function ZonesRoute() {
return (
<DashboardLayout>
<ZonesPage />
</DashboardLayout>
);
}
+672 -125
View File
@@ -1,7 +1,24 @@
import { A } from "@solidjs/router";
import { createSignal, onMount, createMemo } from "solid-js";
import { A, useNavigate } from "@solidjs/router";
import { createSignal, onMount, createMemo, Show, For } from "solid-js";
import { useI18n } from "../providers/i18n-provider";
import { useTheme } from "../providers/theme-provider";
import { BookraCharacter } from "../components/bookra-character";
import { HoverFeatureCards } from "../components/hover-feature-cards";
import { DashboardMockup } from "../components/dashboard-mockup";
import { VideoPlayer } from "../components/video-player";
import { AnimatedList } from "../components/animated-list";
import { FloatingDock } from "../components/floating-dock";
const HomeLogo = () => {
const theme = useTheme();
const isDark = () => theme.resolvedTheme() === "dark";
return (
<div class="relative h-9">
<img src="/bookra-illustrations/logo_text_horizontal.svg" alt="Bookra" class="h-9 w-auto opacity-75 absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": isDark(), "opacity-75": !isDark() }} />
<img src="/bookra-illustrations/logo_text_horizontal_white.svg" alt="Bookra" class="h-9 w-auto opacity-75 absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": !isDark(), "opacity-75": isDark() }} />
</div>
);
};
// Lucide-like icon components (lightweight SVG)
const CalendarIcon = () => (
@@ -107,11 +124,119 @@ const StepCard = (props: StepCardProps) => (
// Main home route component
export function HomeRoute() {
const i18n = useI18n();
const [billingInterval, setBillingInterval] = createSignal<"monthly" | "yearly">("monthly");
const isYearly = () => billingInterval() === "yearly";
const isCs = () => i18n.locale() === "cs";
const ComparisonValue = (props: { value: string; highlight?: boolean }) => {
const v = props.value.toLowerCase();
const yesLabel = i18n.t("pricing.compare.yes").toLowerCase();
const noLabel = i18n.t("pricing.compare.no").toLowerCase();
if (v === "yes" || v === yesLabel) {
return (
<span class={`inline-flex items-center justify-center w-6 h-6 rounded-full ${props.highlight ? 'bg-accent/15 text-accent' : 'bg-success/15 text-success'}`}>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5"/>
</svg>
</span>
);
}
if (v === "no" || v === noLabel) {
return (
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-ink/5 text-ink-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"/>
<path d="m6 6 12 12"/>
</svg>
</span>
);
}
return (
<span class={`text-sm font-medium ${props.highlight ? 'text-accent' : 'text-ink'}`}>
{props.value}
</span>
);
};
// Pricing data with monthly and yearly options
const plans = createMemo(() => [
{
id: "starter",
name: isCs() ? "Starter" : "Starter",
desc: isCs() ? "Pro jednotlivce a malé podniky" : "For individuals and small businesses",
monthly: isCs() ? "199 Kč" : "$9",
yearly: isCs() ? "1 990 Kč" : "$90",
period: isCs() ? "/měsíc" : "/mo",
yearlyPeriod: isCs() ? "/rok" : "/yr",
savings: isCs() ? "Ušetřete 398 Kč" : "Save $18",
savingsPercent: "17%",
trial: isCs() ? "15 dní zdarma po registraci" : "15 days free after sign-up",
features: [
isCs() ? "Do 50 rezervací/měsíc" : "Up to 50 bookings/month",
isCs() ? "1 lokace, 1 zaměstnanec" : "1 location, 1 staff member",
isCs() ? "E-mailová podpora" : "Email support",
],
cta: isCs() ? "Začít zdarma" : "Start for free",
popular: false,
},
{
id: "pro",
name: isCs() ? "Pro" : "Pro",
desc: isCs() ? "Pro rostoucí podniky" : "For growing businesses",
monthly: isCs() ? "399 Kč" : "$19",
yearly: isCs() ? "3 990 Kč" : "$190",
period: isCs() ? "/měsíc" : "/mo",
yearlyPeriod: isCs() ? "/rok" : "/yr",
savings: isCs() ? "Ušetřete 798 Kč" : "Save $38",
savingsPercent: "17%",
trial: isCs() ? "15 dní zdarma po registraci" : "15 days free after sign-up",
features: [
isCs() ? "Neomezené rezervace" : "Unlimited bookings",
isCs() ? "3 lokace, 10 zaměstnanců" : "3 locations, 10 staff",
isCs() ? "E-mailová připomenutí" : "Email reminders",
isCs() ? "Prioritní podpora" : "Priority support",
isCs() ? "Analytika a reporty" : "Analytics & reports",
],
cta: isCs() ? "Začít 15denní zkoušku" : "Start 15-day trial",
popular: true,
},
{
id: "business",
name: isCs() ? "Business" : "Business",
desc: isCs() ? "Pro větší týmy a franšízy" : "For larger teams and franchises",
monthly: isCs() ? "799 Kč" : "$39",
yearly: isCs() ? "7 990 Kč" : "$390",
period: isCs() ? "/měsíc" : "/mo",
yearlyPeriod: isCs() ? "/rok" : "/yr",
savings: isCs() ? "Ušetřete 1 598 Kč" : "Save $78",
savingsPercent: "17%",
trial: isCs() ? "Individuální řešení na míru" : "Custom enterprise solutions",
features: [
isCs() ? "Neomezené vše" : "Unlimited everything",
isCs() ? "Více lokací" : "Multiple locations",
isCs() ? "API přístup" : "API access",
isCs() ? "Dedikovaný manažer" : "Dedicated manager",
],
cta: isCs() ? "Kontaktovat prodej" : "Contact sales",
popular: false,
},
]);
const [isVisible, setIsVisible] = createSignal(false);
const [currentDate, setCurrentDate] = createSignal(new Date());
const navigate = useNavigate();
onMount(() => {
setIsVisible(true);
// Demo mode: redirect landing page to dashboard immediately
const hostname = window.location.hostname;
const isDemo = hostname.includes("demo") || hostname === "localhost" || hostname === "127.0.0.1";
if (isDemo) {
navigate("/dashboard");
}
});
// Calendar helpers
@@ -354,20 +479,27 @@ export function HomeRoute() {
<section class="py-12 border-y border-border/50 bg-canvas-subtle/50">
<div class="section-container">
<div class="mb-6 flex justify-center">
<img
src="/bookra-illustrations/logo_text_horizontal.svg"
alt="Bookra"
class="h-9 w-auto opacity-75"
/>
<HomeLogo />
</div>
<p class="text-center text-sm text-ink-subtle mb-8 tracking-wide uppercase">
{i18n.t("home.trust")}
</p>
<div class="flex flex-wrap items-center justify-center gap-8 lg:gap-16 opacity-60">
{["Salon Ella", "Physio Care", "Massage Studio", "Yoga Flow", "Repair Pro"].map((name) => (
<span class="text-lg lg:text-xl font-medium text-ink-muted tracking-tight">
{name}
</span>
<div class="flex flex-wrap items-center justify-center gap-4 lg:gap-6">
{[
{ name: "Salon Ella", icon: "SE" },
{ name: "Physio Care", icon: "PC" },
{ name: "Massage Studio", icon: "MS" },
{ name: "Yoga Flow", icon: "YF" },
{ name: "Repair Pro", icon: "RP" },
].map((biz) => (
<div class="flex items-center gap-2.5 px-4 py-2.5 rounded-xl bg-canvas border border-border/60 shadow-sm hover:shadow-md hover:border-border transition-all duration-300 group">
<div class="w-8 h-8 rounded-lg bg-accent-subtle/60 flex items-center justify-center text-[10px] font-bold text-accent tracking-wide">
{biz.icon}
</div>
<span class="text-sm font-medium text-ink-muted group-hover:text-ink transition-colors tracking-tight">
{biz.name}
</span>
</div>
))}
</div>
</div>
@@ -404,6 +536,164 @@ export function HomeRoute() {
</div>
</section>
{/* Product Showcase — Hover Feature Cards */}
<section class="py-20 lg:py-32 bg-canvas-subtle/30">
<div class="section-container">
<div class="max-w-2xl mx-auto text-center mb-16 lg:mb-20">
<span class="text-sm font-medium text-accent mb-3 block tracking-wide uppercase animate-fade-in">
{isCs() ? "Prozkoumejte" : "Explore"}
</span>
<h2 class="text-display-md font-semibold text-ink mb-4 animate-slide-up" style={{ "animation-delay": "0.1s" }}>
{isCs() ? "Vše, co potřebujete, na jednom místě" : "Everything you need in one place"}
</h2>
<p class="text-lg text-ink-muted animate-slide-up" style={{ "animation-delay": "0.2s" }}>
{isCs()
? "Náhled do aplikace — rezervace, zákazníci, analytika i nastavení."
: "A peek inside the app — bookings, customers, analytics, and settings."}
</p>
</div>
<HoverFeatureCards
items={[
{
name: isCs() ? "Přehledný dashboard" : "Clean Dashboard",
description: isCs()
? "Sledujte KPI, trendy rezervací a dnešní agenda v reálném čase."
: "Track KPIs, booking trends, and today's agenda in real time.",
href: "/dashboard",
children: <DashboardMockup />,
fadeBottom: true,
},
{
name: isCs() ? "Kalendář rezervací" : "Booking Calendar",
description: isCs()
? "Intuitivní týdenní a měsíční pohled s drag-and-drop plánováním."
: "Intuitive weekly and monthly views with drag-and-drop scheduling.",
href: "/dashboard/bookings",
children: (
<div class="w-full h-full flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-[10px] font-medium text-ink-muted">Květen 2026</span>
<div class="flex gap-1">
<div class="w-5 h-5 rounded border border-border flex items-center justify-center text-ink-subtle">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 18-6-6 6-6"/></svg>
</div>
<div class="w-5 h-5 rounded border border-border flex items-center justify-center text-ink-subtle">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg>
</div>
</div>
</div>
<div class="grid grid-cols-7 gap-px bg-border/40 rounded-lg overflow-hidden">
{["Po","Út","St","Čt","Pá","So","Ne"].map((d) => (
<div class="bg-canvas-subtle/50 py-1 text-center text-[8px] font-medium text-ink-subtle">{d}</div>
))}
{Array.from({ length: 31 }, (_, i) => {
const day = i + 1;
const hasBooking = [3, 7, 12, 15, 18, 22, 24, 28].includes(day);
const isToday = day === 18;
return (
<div
class={`p-1 min-h-[28px] bg-canvas text-center transition-colors ${
isToday ? "bg-accent/15 text-accent font-semibold" : "text-ink-muted"
}`}
>
<span class="text-[9px]">{day}</span>
{hasBooking && (
<div class="flex justify-center mt-0.5">
<div class="w-1 h-1 rounded-full bg-accent" />
</div>
)}
</div>
);
})}
</div>
<div class="mt-auto flex items-center gap-2 text-[9px] text-ink-subtle">
<div class="flex items-center gap-1"><div class="w-1.5 h-1.5 rounded-full bg-accent" /><span>Rezervace</span></div>
<div class="flex items-center gap-1"><div class="w-1.5 h-1.5 rounded-full bg-accent/30" /><span>Dnes</span></div>
</div>
</div>
),
fadeBottom: true,
},
{
name: isCs() ? "Správa zákazníků" : "Customer CRM",
description: isCs()
? "Historie návštěv, poznámky a preference na jednom místě."
: "Visit history, notes, and preferences all in one place.",
href: "/dashboard/customers",
children: (
<div class="w-full h-full flex flex-col gap-2">
<div class="flex items-center gap-2 mb-1">
<div class="flex-1 h-6 rounded-lg border border-border bg-canvas-subtle/40 flex items-center px-2">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-ink-subtle mr-1.5"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<span class="text-[9px] text-ink-subtle">{isCs() ? "Hledat..." : "Search..."}</span>
</div>
</div>
{[
{ name: "Martina Nováková", email: "martina@example.com", count: 5 },
{ name: "David Svoboda", email: "david@example.com", count: 3 },
{ name: "Jana Kovářová", email: "jana@example.com", count: 8 },
].map((c) => (
<div class="flex items-center gap-2 p-1.5 rounded-lg border border-border/60 bg-canvas-subtle/30">
<div class="w-6 h-6 rounded-full bg-accent-subtle flex items-center justify-center text-[8px] font-bold text-accent shrink-0">
{c.name.split(" ").map((n) => n[0]).join("")}
</div>
<div class="min-w-0 flex-1">
<p class="text-[10px] font-medium text-ink truncate">{c.name}</p>
<p class="text-[8px] text-ink-subtle truncate">{c.email}</p>
</div>
<span class="text-[9px] text-accent font-medium">{c.count}</span>
</div>
))}
</div>
),
fadeBottom: true,
},
{
name: isCs() ? "Analytika a reporty" : "Analytics & Reports",
description: isCs()
? "Podrobné statistiky, exporty a přehledy pro váš podnik."
: "Detailed statistics, exports, and business insights.",
href: "/dashboard?tab=analytics",
children: (
<div class="w-full h-full flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="text-[10px] font-medium text-ink-muted">{isCs() ? "Příjmy" : "Revenue"}</span>
<span class="text-[9px] text-accent font-semibold">+24 %</span>
</div>
{/* Mini bar chart */}
<div class="flex items-end gap-1 h-16">
{[40, 55, 35, 70, 50, 80, 65].map((h) => (
<div class="flex-1 rounded-t-sm bg-accent/20 hover:bg-accent/40 transition-colors" style={{ height: `${h}%` }} />
))}
</div>
<div class="flex justify-between text-[8px] text-ink-subtle">
<span>Po</span><span>Út</span><span>St</span><span>Čt</span><span></span><span>So</span><span>Ne</span>
</div>
{/* KPI row */}
<div class="mt-auto grid grid-cols-3 gap-2">
<div class="rounded-lg bg-canvas-subtle/50 p-1.5 text-center">
<p class="text-[9px] font-semibold text-ink">128</p>
<p class="text-[7px] text-ink-subtle">{isCs() ? "Rezervace" : "Bookings"}</p>
</div>
<div class="rounded-lg bg-canvas-subtle/50 p-1.5 text-center">
<p class="text-[9px] font-semibold text-ink">94 %</p>
<p class="text-[7px] text-ink-subtle">{isCs() ? "Obsazenost" : "Occupancy"}</p>
</div>
<div class="rounded-lg bg-canvas-subtle/50 p-1.5 text-center">
<p class="text-[9px] font-semibold text-accent">15.2k</p>
<p class="text-[7px] text-ink-subtle">{isCs() ? "Kč" : "$"}</p>
</div>
</div>
</div>
),
fadeBottom: true,
},
]}
/>
</div>
</section>
{/* How it works */}
<section class="py-20 lg:py-32 bg-canvas-subtle/30">
<div class="section-container">
@@ -512,125 +802,168 @@ export function HomeRoute() {
{i18n.t("home.pricing.subtitle")}
</p>
</div>
<div class="grid md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto items-center">
{/* Starter Plan */}
<div
class="group surface-elevated p-6 lg:p-8 rounded-card transition-all duration-500 hover:shadow-xl hover:-translate-y-1 animate-slide-up"
style={{ "animation-delay": "0.3s" }}
>
<div class="mb-6">
<h3 class="font-display text-lg font-semibold mb-1 text-ink">
{i18n.t("home.pricing.starter.name")}
</h3>
<p class="text-ink-muted">{i18n.t("home.pricing.starter.desc")}</p>
</div>
<div class="mb-6">
<span class="font-display text-4xl font-semibold text-ink">
{i18n.locale() === 'cs' ? '119 Kč' : '$5'}
</span>
<span class="text-ink-muted">{i18n.t("home.pricing.perMonth")}</span>
<p class="mt-1 text-xs text-accent font-medium">{i18n.t("home.pricing.starter.trial")}</p>
</div>
<ul class="space-y-3 mb-8">
{[i18n.t("home.pricing.starter.f1"), i18n.t("home.pricing.starter.f2"), i18n.t("home.pricing.starter.f3")].map((feature) => (
<li class="flex items-start gap-3 text-ink-muted">
<span class="mt-0.5 text-accent shrink-0">
<CheckIcon />
</span>
<span class="text-sm">{feature}</span>
</li>
))}
</ul>
<A
href="/dashboard"
class="block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 btn-secondary w-full"
{/* Billing Toggle */}
<div class="flex items-center justify-center mb-12 animate-slide-up" style={{ "animation-delay": "0.25s" }}>
<div class="relative inline-flex items-center gap-4">
<span class={`text-sm font-semibold transition-colors ${!isYearly() ? 'text-ink' : 'text-ink-muted'}`}>
{isCs() ? "Měsíčně" : "Monthly"}
</span>
<button
type="button"
onClick={() => setBillingInterval(isYearly() ? "monthly" : "yearly")}
class={`relative w-14 h-7 rounded-full transition-colors duration-300 cursor-pointer ${isYearly() ? 'bg-accent' : 'bg-ink/30'}`}
role="switch"
aria-checked={isYearly()}
aria-label={isYearly() ? (isCs() ? "Ročně" : "Yearly") : (isCs() ? "Měsíčně" : "Monthly")}
>
{i18n.t("home.pricing.starter.cta")}
</A>
</div>
{/* Pro Plan - Highlighted */}
<div
class="group relative p-6 lg:p-8 rounded-card transition-all duration-500 hover:shadow-2xl hover:-translate-y-2 animate-slide-up lg:scale-105"
style={{ "animation-delay": "0.4s" }}
>
{/* Gradient background for highlighted card */}
<div class="absolute inset-0 bg-gradient-to-b from-ink to-ink/95 rounded-card" />
{/* Popular badge */}
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
<span class="px-3 py-1 bg-accent text-white text-xs font-display font-medium rounded-full shadow-lg">
{i18n.t("home.pricing.popular")}
</span>
</div>
<div class="relative z-10">
<div class="mb-6">
<h3 class="font-display text-lg font-semibold mb-1 text-canvas">
{i18n.t("home.pricing.pro.name")}
</h3>
<p class="text-canvas/70">{i18n.t("home.pricing.pro.desc")}</p>
</div>
<div class="mb-6">
<span class="font-display text-4xl font-semibold text-canvas">
{i18n.locale() === 'cs' ? '499 Kč' : '$20'}
<span
class={`absolute top-1 left-1 w-5 h-5 bg-white rounded-full shadow-md transition-transform duration-300 ease-spring ${isYearly() ? 'translate-x-7' : 'translate-x-0'}`}
/>
</button>
<span class={`text-sm font-semibold transition-colors ${isYearly() ? 'text-ink' : 'text-ink-muted'}`}>
{isCs() ? "Ročně" : "Yearly"}
</span>
<div class="absolute left-full ml-3 top-1/2 -translate-y-1/2">
<Show when={isYearly()}>
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-accent text-white text-xs font-bold rounded-full shadow-sm whitespace-nowrap">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>
</svg>
{isCs() ? "-17%" : "-17%"}
</span>
<span class="text-canvas/60">{i18n.t("home.pricing.perMonth")}</span>
<p class="mt-1 text-xs text-accent font-medium">{i18n.t("home.pricing.pro.trial")}</p>
</div>
<ul class="space-y-3 mb-8">
{[i18n.t("home.pricing.pro.f1"), i18n.t("home.pricing.pro.f2"), i18n.t("home.pricing.pro.f3"), i18n.t("home.pricing.pro.f4"), i18n.t("home.pricing.pro.f5")].map((feature) => (
<li class="flex items-start gap-3 text-canvas/80">
<span class="mt-0.5 text-accent shrink-0">
<CheckIcon />
</span>
<span class="text-sm">{feature}</span>
</li>
))}
</ul>
<A
href="/dashboard"
class="block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 bg-canvas text-ink hover:bg-canvas-subtle w-full shadow-lg group-hover:shadow-xl"
>
{i18n.t("home.pricing.pro.cta")}
</A>
</Show>
</div>
</div>
</div>
{/* Business Plan */}
<div
class="group surface-elevated p-6 lg:p-8 rounded-card transition-all duration-500 hover:shadow-xl hover:-translate-y-1 animate-slide-up"
style={{ "animation-delay": "0.5s" }}
>
<div class="mb-6">
<h3 class="font-display text-lg font-semibold mb-1 text-ink">
{i18n.t("home.pricing.biz.name")}
</h3>
<p class="text-ink-muted">{i18n.t("home.pricing.biz.desc")}</p>
</div>
<div class="mb-6">
<span class="font-display text-4xl font-semibold text-ink">
{i18n.locale() === 'cs' ? '1 199 Kč' : '$50'}
</span>
<span class="text-ink-muted">{i18n.t("home.pricing.perMonth")}</span>
<p class="mt-1 text-xs text-accent font-medium">{i18n.t("home.pricing.biz.trial")}</p>
</div>
<ul class="space-y-3 mb-8">
{[i18n.t("home.pricing.biz.f1"), i18n.t("home.pricing.biz.f2"), i18n.t("home.pricing.biz.f3"), i18n.t("home.pricing.biz.f4")].map((feature) => (
<li class="flex items-start gap-3 text-ink-muted">
<span class="mt-0.5 text-accent shrink-0">
<CheckIcon />
</span>
<span class="text-sm">{feature}</span>
</li>
))}
</ul>
<A
href="/dashboard"
class="block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 btn-secondary w-full"
<div class="grid md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto items-center">
{plans().map((plan, index) => (
<div
class={`group relative p-6 lg:p-8 rounded-card transition-all duration-500 animate-slide-up ${plan.popular ? 'hover:shadow-2xl hover:-translate-y-2 lg:scale-105' : 'hover:shadow-xl hover:-translate-y-1'}`}
style={{ "animation-delay": `${0.3 + index * 0.1}s` }}
>
{i18n.t("home.pricing.biz.cta")}
</A>
{/* Gradient background for popular card */}
<Show when={plan.popular}>
<div class="absolute inset-0 bg-gradient-to-b from-ink to-ink/95 rounded-card" />
</Show>
<Show when={!plan.popular}>
<div class="absolute inset-0 surface-elevated rounded-card" />
</Show>
{/* Popular badge */}
<Show when={plan.popular}>
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
<span class="px-3 py-1 bg-accent text-white text-xs font-display font-medium rounded-full shadow-lg">
{i18n.t("home.pricing.popular")}
</span>
</div>
</Show>
<div class="relative z-10">
<div class="mb-6">
<h3 class={`font-display text-lg font-semibold mb-1 ${plan.popular ? 'text-canvas' : 'text-ink'}`}>
{plan.name}
</h3>
<p class={plan.popular ? 'text-canvas/70' : 'text-ink-muted'}>{plan.desc}</p>
</div>
<div class="mb-6">
<div class="flex items-baseline gap-1">
<span class={`font-display text-4xl font-semibold ${plan.popular ? 'text-canvas' : 'text-ink'}`}>
{isYearly() ? plan.yearly : plan.monthly}
</span>
<span class={plan.popular ? 'text-canvas/60' : 'text-ink-muted'}>
{isYearly() ? plan.yearlyPeriod : plan.period}
</span>
</div>
<Show when={isYearly()}>
<div class="mt-2 flex items-center gap-1.5">
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-accent-subtle text-accent text-xs font-semibold rounded-full border border-accent/10">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
{plan.savingsPercent}
</span>
<span class="text-xs text-ink-subtle">{isCs() ? 'sleva při roční platbě' : 'discount on yearly billing'}</span>
</div>
</Show>
<Show when={!isYearly()}>
<p class="mt-1 text-xs text-accent font-medium">{plan.trial}</p>
</Show>
</div>
<ul class="space-y-3 mb-8">
{plan.features.map((feature) => (
<li class={`flex items-start gap-3 ${plan.popular ? 'text-canvas/80' : 'text-ink-muted'}`}>
<span class="mt-0.5 text-accent shrink-0">
<CheckIcon />
</span>
<span class="text-sm">{feature}</span>
</li>
))}
</ul>
<A
href="/dashboard"
class={`block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 w-full ${plan.popular ? 'bg-canvas text-ink hover:bg-canvas-subtle shadow-lg group-hover:shadow-xl' : 'btn-secondary'}`}
>
{plan.cta}
</A>
</div>
</div>
))}
</div>
</div>
</section>
{/* Comparison Table */}
<section class="py-16 px-4">
<div class="section-container">
<div class="max-w-4xl mx-auto">
<div class="text-center mb-10">
<span class="text-sm font-medium text-accent mb-2 block tracking-wide uppercase">
{i18n.t("pricing.compare.eyebrow")}
</span>
<h2 class="text-2xl sm:text-3xl font-display font-bold text-ink">
{i18n.t("pricing.compare.title")}
</h2>
</div>
<div class="surface-elevated rounded-card overflow-hidden border border-border/50 shadow-sm">
{/* Header */}
<div class="grid grid-cols-[1fr_100px_100px_100px] sm:grid-cols-[1.5fr_1fr_1fr_1fr] gap-2 p-4 sm:p-5 bg-canvas-subtle/60 border-b border-border/50">
<div class="text-sm font-semibold text-ink-muted self-center">{i18n.t("pricing.compare.feature")}</div>
<div class="text-center font-display font-semibold text-ink text-sm">Starter</div>
<div class="text-center">
<span class="inline-block px-3 py-1 bg-accent/10 text-accent font-display font-semibold text-sm rounded-full">
Pro
</span>
</div>
<div class="text-center font-display font-semibold text-ink text-sm">Business</div>
</div>
{/* Rows */}
<For each={[
{ key: "pricing.compare.locations", starter: "1", pro: "3", business: "∞" },
{ key: "pricing.compare.staff", starter: "1", pro: "10", business: "∞" },
{ key: "pricing.compare.bookings", starter: "50", pro: "∞", business: "∞" },
{ key: "pricing.compare.emailSupport", starter: i18n.t("pricing.compare.yes"), pro: i18n.t("pricing.compare.priority"), business: i18n.t("pricing.compare.dedicated") },
{ key: "pricing.compare.reminders", starter: "no", pro: "yes", business: "yes" },
{ key: "pricing.compare.analytics", starter: "no", pro: "yes", business: i18n.t("pricing.compare.advanced") },
{ key: "pricing.compare.api", starter: "no", pro: "no", business: "yes" },
{ key: "pricing.compare.branding", starter: "no", pro: "yes", business: "yes" },
{ key: "pricing.compare.whiteLabel", starter: "no", pro: "no", business: "yes" },
{ key: "pricing.compare.manager", starter: "no", pro: "no", business: "yes" },
]}>
{(feature, i) => (
<div class={`grid grid-cols-[1fr_100px_100px_100px] sm:grid-cols-[1.5fr_1fr_1fr_1fr] gap-2 p-4 sm:p-5 items-center border-b border-border/30 last:border-0 transition-colors ${i() % 2 === 0 ? 'bg-canvas/30' : ''} hover:bg-canvas-subtle/40`}>
<span class="text-sm text-ink font-medium">{i18n.t(feature.key)}</span>
<div class="flex justify-center">
<ComparisonValue value={feature.starter} />
</div>
<div class="flex justify-center">
<ComparisonValue value={feature.pro} highlight />
</div>
<div class="flex justify-center">
<ComparisonValue value={feature.business} />
</div>
</div>
)}
</For>
</div>
</div>
</div>
@@ -677,6 +1010,220 @@ export function HomeRoute() {
</div>
</div>
</section>
{/* Video Showcase */}
<section class="py-20 lg:py-32 bg-canvas-subtle/30">
<div class="section-container">
<div class="max-w-2xl mx-auto text-center mb-12 lg:mb-16">
<span class="text-sm font-medium text-accent mb-3 block tracking-wide uppercase animate-fade-in">
{isCs() ? "Podívejte se" : "Watch it"}
</span>
<h2 class="text-display-md font-semibold text-ink mb-4 animate-slide-up" style={{ "animation-delay": "0.1s" }}>
{isCs() ? "Bookra v akci" : "Bookra in action"}
</h2>
<p class="text-lg text-ink-muted animate-slide-up" style={{ "animation-delay": "0.2s" }}>
{isCs()
? "Jak vypadá správa rezervací v praxi — rychlé, přehledné a bez starostí."
: "What booking management looks like in practice — fast, clear, and hassle-free."}
</p>
</div>
<div class="max-w-4xl mx-auto animate-slide-up" style={{ "animation-delay": "0.3s" }}>
<VideoPlayer
src="https://www.pexels.com/fr-fr/download/video/18069166/"
poster="https://images.pexels.com/photos/25626507/pexels-photo-25626507.jpeg"
/>
</div>
</div>
</section>
{/* Live Notifications Mockup */}
<section class="py-20 lg:py-32 overflow-hidden">
<div class="section-container">
<div class="grid lg:grid-cols-2 gap-12 lg:gap-20 items-center">
<div>
<span class="text-sm font-medium text-accent mb-3 block tracking-wide uppercase animate-fade-in">
{isCs() ? "Oznámení v reálném čase" : "Real-time notifications"}
</span>
<h2 class="text-display-md font-semibold text-ink mb-6 animate-slide-up" style={{ "animation-delay": "0.1s" }}>
{isCs() ? "Nic vám neunikne" : "Never miss a thing"}
</h2>
<p class="text-lg text-ink-muted mb-8 animate-slide-up" style={{ "animation-delay": "0.2s" }}>
{isCs()
? "Nové rezervace, připomenutí a upozornění — všechno uvidíte okamžitě."
: "New bookings, reminders, and alerts — everything at a glance."}
</p>
<div class="flex items-center gap-4 animate-slide-up" style={{ "animation-delay": "0.3s" }}>
<A href="/dashboard" class="btn-primary inline-flex items-center gap-2">
{i18n.t("home.cta.primary")}
<ArrowRightIcon />
</A>
</div>
</div>
<div class="animate-slide-up" style={{ "animation-delay": "0.3s" }}>
<div class="surface-elevated rounded-2xl p-5 border border-border/60 shadow-xl max-w-sm mx-auto lg:mx-0 lg:ml-auto">
<div class="flex items-center gap-2 mb-4">
<div class="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center">
<BellIcon />
</div>
<span class="font-display font-semibold text-sm text-ink">{isCs() ? "Oznámení" : "Notifications"}</span>
<span class="ml-auto w-2 h-2 rounded-full bg-accent" />
</div>
<AnimatedList
items={[
{ id: "n1", type: "booking", title: isCs() ? "Nová rezervace — Martina N." : "New booking — Martina N.", message: isCs() ? "Masáž — Po 19. května" : "Massage — Mon May 19", time: "2m" },
{ id: "n2", type: "reminder", title: isCs() ? "Připomenutí — David S." : "Reminder — David S.", message: isCs() ? "Fyzio — Za 2 hodiny" : "Physio — In 2 hours", time: "15m" },
{ id: "n3", type: "upgrade", title: isCs() ? "Blížíte se limitu" : "Nearing plan limit", message: isCs() ? "45/50 rezervací" : "45/50 bookings", time: "1h" },
{ id: "n4", type: "booking", title: isCs() ? "Nová rezervace — Jana K." : "New booking — Jana K.", message: isCs() ? "Manikúra — St 21. května" : "Manicure — Wed May 21", time: "3h" },
]}
animation="slide"
gap={8}
renderItem={(n) => (
<div class="flex items-start gap-3 p-3 rounded-xl bg-canvas-subtle/50 border border-border/40">
<div class={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
n.type === "booking" ? "bg-accent-subtle text-accent" :
n.type === "reminder" ? "bg-canvas-muted text-ink-muted" :
"bg-warning-subtle text-warning"
}`}>
{n.type === "booking" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
) : n.type === "reminder" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
)}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-ink truncate">{n.title}</p>
<p class="text-xs text-ink-muted">{n.message}</p>
</div>
<span class="text-[10px] text-ink-subtle shrink-0">{n.time}</span>
</div>
)}
/>
</div>
</div>
</div>
</div>
</section>
{/* Mobile Dock Showcase */}
<section class="py-20 lg:py-32 overflow-hidden">
<div class="section-container">
<div class="grid lg:grid-cols-2 gap-12 lg:gap-20 items-center">
<div class="order-2 lg:order-1 flex justify-center">
<div class="relative">
{/* Phone frame */}
<div class="w-[285px] h-[620px] rounded-[40px] border-[6px] border-canvas-muted bg-canvas shadow-2xl overflow-hidden relative">
{/* Notch */}
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-24 h-6 bg-canvas-muted rounded-b-2xl z-10" />
{/* Screen content */}
<div class="h-full flex flex-col bg-canvas">
{/* Header mock */}
<div class="pt-10 pb-3 px-4 border-b border-border/40">
<div class="flex items-center justify-between">
<div class="w-8 h-8 rounded-full bg-accent/10" />
<span class="font-display font-semibold text-sm">Bookra</span>
<div class="w-8 h-8 rounded-full bg-canvas-subtle" />
</div>
</div>
{/* Scrollable content */}
<div class="flex-1 overflow-y-auto p-4 space-y-3">
<div class="h-20 rounded-xl bg-canvas-subtle border border-border/40" />
<div class="h-16 rounded-xl bg-accent-subtle/30 border border-accent/10" />
<div class="h-20 rounded-xl bg-canvas-subtle border border-border/40" />
<div class="h-16 rounded-xl bg-canvas-subtle border border-border/40" />
</div>
{/* FloatingDock at bottom */}
<div class="p-3">
<FloatingDock
menuItems={[
{
id: "profile",
label: isCs() ? "Profil" : "Profile",
icon: <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="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>,
},
{
id: "upgrade",
label: isCs() ? "Upgrade" : "Upgrade",
icon: <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="M12 2a10 10 0 0 1 7.38 16.75"/><path d="m16 12-4-4-4 4"/><path d="M12 16V8"/></svg>,
},
{
id: "projects",
label: isCs() ? "Projekty" : "Projects",
icon: <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="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/><path d="M8 10v4"/><path d="M12 10v2"/><path d="M16 10v6"/></svg>,
},
{
id: "docs",
label: isCs() ? "Dokumentace" : "Documentation",
icon: <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="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>,
},
{
id: "logout",
label: isCs() ? "Odhlásit" : "Logout",
icon: <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="m16 17 5-5-5-5"/><path d="M21 12H9"/><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/></svg>,
variant: "danger",
},
]}
bottomActions={[
{
id: "zap",
label: "Zap",
icon: <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="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>,
},
{
id: "stats",
label: "Stats",
icon: <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="M16 7h6v6"/><path d="m22 7-8.5 8.5-5-5L2 17"/></svg>,
},
{
id: "puzzle",
label: "Apps",
icon: <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="M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z"/></svg>,
},
{
id: "bell",
label: "Alerts",
icon: <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="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>,
},
{
id: "user",
label: isCs() ? "Profil" : "Profile",
icon: <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="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>,
},
]}
activeId="user"
/>
</div>
</div>
</div>
{/* Glow effect */}
<div class="absolute -inset-4 bg-accent/5 rounded-[48px] blur-2xl -z-10" />
</div>
</div>
<div class="order-1 lg:order-2">
<span class="text-sm font-medium text-accent mb-3 block tracking-wide uppercase animate-fade-in">
{isCs() ? "Mobilní zážitek" : "Mobile Experience"}
</span>
<h2 class="text-display-md font-semibold text-ink mb-6 animate-slide-up" style={{ "animation-delay": "0.1s" }}>
{isCs() ? "Všechno v kapse" : "Everything in your pocket"}
</h2>
<p class="text-lg text-ink-muted mb-8 animate-slide-up" style={{ "animation-delay": "0.2s" }}>
{isCs()
? "Spravujte rezervace, zákazníky a tým odkudkoli. Intuitivní rozhraní navržené pro mobilní použití."
: "Manage bookings, customers, and your team from anywhere. An intuitive interface designed for mobile."}
</p>
<div class="flex items-center gap-4 animate-slide-up" style={{ "animation-delay": "0.3s" }}>
<A href="/dashboard" class="btn-primary inline-flex items-center gap-2">
{i18n.t("home.cta.primary")}
<ArrowRightIcon />
</A>
</div>
</div>
</div>
</div>
</section>
</div>
);
}
+118 -37
View File
@@ -3,35 +3,98 @@ import { For } from "solid-js";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui";
import { BookraCharacter } from "../components/bookra-character";
import { useI18n } from "../providers/i18n-provider";
import { useTheme } from "../providers/theme-provider";
const LegalLogo = () => {
const theme = useTheme();
const isDark = () => theme.resolvedTheme() === "dark";
return (
<div class="relative h-28 mx-auto mb-6">
<img src="/bookra-illustrations/logo_text_vertical.svg" alt="Bookra" class="h-28 w-auto mx-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": isDark(), "opacity-90": !isDark() }} />
<img src="/bookra-illustrations/logo_text_vertical_white.svg" alt="Bookra" class="h-28 w-auto mx-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": !isDark(), "opacity-90": isDark() }} />
</div>
);
};
export function LegalRoute() {
const params = useParams();
const i18n = useI18n();
const kind = () => (params.kind === "terms" ? "terms" : "privacy");
const heroPose = () => (kind() === "terms" ? "flag" : "educate");
const helperPose = () => (kind() === "terms" ? "announcement" : "happy_note");
const sections = () =>
kind() === "terms"
? [
{
title: i18n.t("legal.terms.service.title"),
body: i18n.t("legal.terms.service.body"),
},
{
title: i18n.t("legal.terms.billing.title"),
body: i18n.t("legal.terms.billing.body"),
},
]
: [
{
title: i18n.t("legal.privacy.data.title"),
body: i18n.t("legal.privacy.data.body"),
},
{
title: i18n.t("legal.privacy.rights.title"),
body: i18n.t("legal.privacy.rights.body"),
},
];
const isCs = () => i18n.locale() === "cs";
const companyInfo = () =>
isCs()
? "Provozovatel: Bookra, IČO 24330621. Sídlo: Česká republika."
: "Operator: Bookra, Business ID 24330621. Registered in the Czech Republic.";
const termsSections = () => [
{
title: isCs() ? "1. Úvod a předmět smlouvy" : "1. Introduction and subject",
body: isCs()
? "Tyto podmínky upravují používání služby Bookra — online rezervačního systému pro lokální služby. Poskytovatelem je Bookra , IČO 24330621. Službu mohou využívat podnikatelé a právnické osoby k správě rezervací, zákazníků a dostupnosti. Uživatel se zavazuje používat službu v souladu s právními předpisy, bez zneužívání rezervačních formulářů, obcházení zabezpečení nebo ukládání zakázaného obsahu."
: "These terms govern the use of Bookra — an online booking system for local services. The provider is Bookra , Business ID 24330621. Entrepreneurs and legal entities may use the service to manage bookings, customers, and availability. The user agrees to use the service lawfully, without abusing booking forms, bypassing security, or storing prohibited content.",
},
{
title: isCs() ? "2. Registrace a účet" : "2. Registration and account",
body: isCs()
? "Pro plné využití služby je nutná registrace. Uživatel zodpovídá za správnost údajů uvedených při registraci a za bezpečnost přihlašovacích údajů. Provozovatel účtu odpovídá za správnost nabídky, dostupnost termínů a komunikaci se zákazníky. Bookra nezodpovídá za obsah rezervací a komunikaci mezi provozovatelem a zákazníkem."
: "Full use of the service requires registration. The user is responsible for the accuracy of registration details and the security of login credentials. The workspace operator is responsible for the accuracy of their offer, availability of times, and customer communication. Bookra is not liable for booking content or communication between operators and customers.",
},
{
title: isCs() ? "3. Předplatné a platby" : "3. Subscription and payments",
body: isCs()
? "Placené plány se účtují předem prostřednictvím platební brány Paddle nebo Stripe. Aktivní plán určuje dostupné limity, rozšíření a podpůrné funkce. Při roční platbě je poskytována sleva oproti měsíčnímu zúčtování. Uživatel může předplatné kdykoliv zrušit; přístup zůstává do konce zaplaceného období. Neposkytujeme refundace za již zaplacená období."
: "Paid plans are billed in advance through Paddle or Stripe. The active plan determines available limits, add-ons, and support features. Annual billing includes a discount compared to monthly billing. Users may cancel anytime; access continues until the end of the paid period. No refunds are provided for already-paid periods.",
},
{
title: isCs() ? "4. Odpovědnost a omezení" : "4. Liability and limitations",
body: isCs()
? "Bookra se snaží zajistit nepřetržitý provoz, ale nezaručuje 100% dostupnost. Neneseme odpovědnost za přímé ani nepřímé škody způsobené výpadkem služby, ztrátou dat způsobenou uživatelem nebo technickými problémy třetích stran. Doporučujeme pravidelnou zálohu důležitých dat."
: "Bookra strives to ensure uninterrupted service but does not guarantee 100% uptime. We are not liable for direct or indirect damages caused by service outages, data loss caused by the user, or technical issues from third parties. We recommend regular backups of important data.",
},
{
title: isCs() ? "5. Ukončení a výpověď" : "5. Termination",
body: isCs()
? "Uživatel může účet zrušit kdykoliv v nastavení. Při dlouhodobé neaktivitě (12 měsíců bez přihlášení) si vyhrazujeme právo účet deaktivovat po předchozím upozornění. Při porušení podmínek může být účet ukončen okamžitě."
: "Users may cancel their account anytime in settings. After prolonged inactivity (12 months without login), we reserve the right to deactivate the account after prior notice. Accounts violating these terms may be terminated immediately.",
},
];
const privacySections = () => [
{
title: isCs() ? "1. Jaké údaje zpracováváme a proč" : "1. What data we process and why",
body: isCs()
? "Zpracováváme minimální množství dat nezbytných pro fungování služby: kontaktní údaje zákazníků (jméno, e-mail) pro potvrzení rezervace, čas rezervace a poznámky zadané při rezervaci, údaje o účtu provozovatele (e-mail, jméno) pro správu účtu, a technické záznamy (IP adresa, čas požadavku) pro zabezpečení. Údaje tenantů jsou oddělené a přístup k nim je omezen podle role uživatele."
: "We process the minimum data necessary for the service: customer contact details (name, email) for booking confirmation, booking times and notes, workspace account details (email, name) for account management, and technical records (IP address, request time) for security. Tenant data is isolated and access is limited by user role.",
},
{
title: isCs() ? "2. Cookies a sledování" : "2. Cookies and tracking",
body: isCs()
? "Bookra nepoužívá žádné sledovací cookies pro marketingové ani analytické účely. Jediné cookies, které ukládáme, jsou technicky nezbytné pro přihlášení a správu relace. Pro anonymní statistiky využíváme Rybbit — nástroj, který pracuje bez cookies a neukládá osobní údaje návštěvníků."
: "Bookra does not use any tracking cookies for marketing or analytics purposes. The only cookies we store are technically necessary for login and session management. For anonymous statistics, we use Rybbit — a tool that operates without cookies and does not store visitors' personal data.",
},
{
title: isCs() ? "3. Údaje registrovaných uživatelů" : "3. Registered user data",
body: isCs()
? "Při registraci shromažďujeme e-mailovou adresu a jméno uživatele. Tato data slouží výhradně k autentizaci, správě účtu a komunikaci ohledně služby (připomenutí, oznámení o změnách). Vaše data neprodáváme, nepronajímáme a nesdílíme s třetími stranami pro marketingové účely. Přístup mají pouze oprávnění zaměstnanci Bookry a to pouze v nezbytném rozsahu pro technickou podporu."
: "During registration, we collect the user's email address and name. This data is used solely for authentication, account management, and service-related communication (reminders, change notifications). We do not sell, rent, or share your data with third parties for marketing purposes. Only authorized Bookra employees have access, and only to the extent necessary for technical support.",
},
{
title: isCs() ? "4. Práva a žádosti" : "4. Rights and requests",
body: isCs()
? "V souladu s GDPR máte právo na přístup ke svým údajům, jejich opravu, výmaz nebo omezení zpracování. Žádosti o přístup, opravu nebo výmaz údajů řeší provozovatel konkrétního účtu. Bookra poskytuje technické prostředky pro bezpečné zpracování. Svá práva můžete uplatnit e-mailem na hello@bookra.eu."
: "In accordance with GDPR, you have the right to access, correct, delete, or restrict processing of your data. Access, correction, and deletion requests are handled by the operator of the relevant workspace. Bookra provides the technical system for secure processing. You may exercise your rights by emailing hello@bookra.eu.",
},
{
title: isCs() ? "5. Doba uchování a zabezpečení" : "5. Retention and security",
body: isCs()
? "Rezervační údaje uchováváme po dobu existence účtu provozovatele, pokud není smazány dříve. Technické záznamy uchováváme po dobu 90 dnů. Všechna data jsou přenášena šifrovaně (TLS), uchovávána v zabezpečených datových centrech v EU a pravidelně zálohována."
: "Booking data is retained for the lifetime of the operator's account unless deleted earlier. Technical records are kept for 90 days. All data is transmitted encrypted (TLS), stored in secure EU data centers, and regularly backed up.",
},
];
const sections = () => (kind() === "terms" ? termsSections() : privacySections());
return (
<section class="section-container py-16">
@@ -42,44 +105,62 @@ export function LegalRoute() {
{i18n.t(`legal.${kind()}.title`)}
</h1>
<p class="text-lg text-ink-muted">{i18n.t(`legal.${kind()}.body`)}</p>
<p class="text-sm text-ink-subtle">{companyInfo()}</p>
</div>
<For each={sections()}>
{(section) => (
<Card class="surface-elevated">
{(section, i) => (
<Card class="surface-elevated hover:shadow-md transition-shadow">
<CardHeader>
<CardTitle>{section.title}</CardTitle>
<CardTitle class="text-lg">{section.title}</CardTitle>
</CardHeader>
<CardContent>
<p class="text-ink-muted">{section.body}</p>
<p class="text-ink-muted leading-relaxed">{section.body}</p>
</CardContent>
</Card>
)}
</For>
<div class="pt-4 border-t border-border">
<p class="text-sm text-ink-subtle">
{isCs()
? "Poslední aktualizace: květen 2026. V případě dotazů nás kontaktujte na hello@bookra.eu."
: "Last updated: May 2026. For questions, contact us at hello@bookra.eu."}
</p>
</div>
</div>
<aside class="lg:sticky lg:top-24">
<aside class="lg:sticky lg:top-24 h-fit space-y-6">
<Card class="surface-elevated overflow-hidden">
<CardContent class="p-8 text-center">
<div class="mb-6 flex justify-center">
<BookraCharacter pose={heroPose()} size="xl" animate={true} />
</div>
<img
src="/bookra-illustrations/logo_text_vertical.svg"
alt="Bookra"
class="mx-auto mb-6 h-28 w-auto opacity-90"
/>
<LegalLogo />
<p class="text-sm leading-relaxed text-ink-muted">
{kind() === "terms"
? i18n.locale() === "cs"
? isCs()
? "Pravidla držíme stručná, čitelná a navázaná na reálný provoz služby."
: "We keep terms short, readable, and tied to real product behavior."
: i18n.locale() === "cs"
: isCs()
? "Soukromí řešíme prakticky: minimum dat navíc, jasný účel a předvídatelné zpracování."
: "We handle privacy pragmatically: minimal extra data, clear purpose, and predictable processing."}
</p>
<div class="mt-6 flex justify-center">
<BookraCharacter pose={helperPose()} size="sm" animate={true} />
</CardContent>
</Card>
<Card class="surface-elevated">
<CardContent class="p-6">
<h3 class="font-display font-semibold text-ink text-sm mb-3">
{isCs() ? "Kontakt" : "Contact"}
</h3>
<div class="space-y-2 text-sm text-ink-muted">
<p>Bookra </p>
<p>IČO: 24330621</p>
<p>Česká republika</p>
<a href="mailto:hello@bookra.eu" class="text-accent hover:underline block mt-2">
hello@bookra.eu
</a>
</div>
</CardContent>
</Card>
+424
View File
@@ -0,0 +1,424 @@
import { createSignal, createMemo, Show, For } from "solid-js";
import { useNavigate } from "@solidjs/router";
import { useI18n } from "../providers/i18n-provider";
const PricingRoute = () => {
const navigate = useNavigate();
const { locale, toggleLocale } = useI18n();
const isCs = () => locale() === "cs";
const [billingInterval, setBillingInterval] = createSignal<"monthly" | "yearly">("monthly");
const isYearly = () => billingInterval() === "yearly";
const [openFaq, setOpenFaq] = createSignal<number | null>(0);
const plans = createMemo(() => [
{
id: "starter",
name: "Starter",
desc: isCs() ? "Pro jednotlivce a malé podniky" : "For individuals and small businesses",
monthly: isCs() ? "199 Kč" : "$9",
yearly: isCs() ? "1 990 Kč" : "$90",
period: isCs() ? "/měsíc" : "/mo",
yearlyPeriod: isCs() ? "/rok" : "/yr",
savings: isCs() ? "Ušetřete 398 Kč" : "Save $18",
savingsPercent: "17%",
trial: isCs() ? "15 dní zdarma po registraci" : "15 days free after sign-up",
features: [
isCs() ? "Do 50 rezervací/měsíc" : "Up to 50 bookings/month",
isCs() ? "1 lokace, 1 zaměstnanec" : "1 location, 1 staff member",
isCs() ? "E-mailová podpora" : "Email support",
isCs() ? "Základní rezervační widget" : "Basic booking widget",
isCs() ? "Potvrzení e-mailem" : "Email confirmations",
],
cta: isCs() ? "Začít zdarma" : "Start for free",
popular: false,
},
{
id: "pro",
name: "Pro",
desc: isCs() ? "Pro rostoucí podniky" : "For growing businesses",
monthly: isCs() ? "399 Kč" : "$19",
yearly: isCs() ? "3 990 Kč" : "$190",
period: isCs() ? "/měsíc" : "/mo",
yearlyPeriod: isCs() ? "/rok" : "/yr",
savings: isCs() ? "Ušetřete 798 Kč" : "Save $38",
savingsPercent: "17%",
trial: isCs() ? "15 dní zdarma po registraci" : "15 days free after sign-up",
features: [
isCs() ? "Neomezené rezervace" : "Unlimited bookings",
isCs() ? "3 lokace, 10 zaměstnanců" : "3 locations, 10 staff",
isCs() ? "E-mailová připomenutí" : "Email reminders",
isCs() ? "Prioritní podpora" : "Priority support",
isCs() ? "Analytika a reporty" : "Analytics & reports",
isCs() ? "Vlastní branding widgetu" : "Custom widget branding",
isCs() ? "Rozšířené nastavení dostupnosti" : "Advanced availability",
],
cta: isCs() ? "Začít 15denní zkoušku" : "Start 15-day trial",
popular: true,
},
{
id: "business",
name: "Business",
desc: isCs() ? "Pro větší týmy a franšízy" : "For larger teams and franchises",
monthly: isCs() ? "799 Kč" : "$39",
yearly: isCs() ? "7 990 Kč" : "$390",
period: isCs() ? "/měsíc" : "/mo",
yearlyPeriod: isCs() ? "/rok" : "/yr",
savings: isCs() ? "Ušetřete 1 598 Kč" : "Save $78",
savingsPercent: "17%",
trial: "",
features: [
isCs() ? "Neomezené vše" : "Unlimited everything",
isCs() ? "Neomezené lokace a zaměstnanci" : "Unlimited locations & staff",
isCs() ? "API přístup" : "API access",
isCs() ? "Dedikovaný manažer" : "Dedicated manager",
isCs() ? "Bílý labeling" : "White labeling",
isCs() ? "Pokročilá analytika" : "Advanced analytics",
isCs() ? "Integrace s externími systémy" : "External system integrations",
],
cta: isCs() ? "Kontaktovat nás" : "Contact us",
popular: false,
},
]);
const handleSelectPlan = (planId: string) => {
// Redirect to signup with plan selection
navigate("/?signup=true&plan=" + planId + "&billing=" + billingInterval());
};
const { t } = useI18n();
const comparisonFeatures = [
{ key: "pricing.compare.locations", starter: "1", pro: "3", business: "∞" },
{ key: "pricing.compare.staff", starter: "1", pro: "10", business: "∞" },
{ key: "pricing.compare.bookings", starter: "50", pro: "∞", business: "∞" },
{ key: "pricing.compare.emailSupport", starter: t("pricing.compare.yes"), pro: t("pricing.compare.priority"), business: t("pricing.compare.dedicated") },
{ key: "pricing.compare.reminders", starter: "no", pro: "yes", business: "yes" },
{ key: "pricing.compare.analytics", starter: "no", pro: "yes", business: t("pricing.compare.advanced") },
{ key: "pricing.compare.api", starter: "no", pro: "no", business: "yes" },
{ key: "pricing.compare.branding", starter: "no", pro: "yes", business: "yes" },
{ key: "pricing.compare.whiteLabel", starter: "no", pro: "no", business: "yes" },
{ key: "pricing.compare.manager", starter: "no", pro: "no", business: "yes" },
];
const ComparisonValue = (props: { value: string; highlight?: boolean }) => {
const v = props.value.toLowerCase();
if (v === "yes" || v === t("pricing.compare.yes").toLowerCase()) {
return (
<span class={`inline-flex items-center justify-center w-6 h-6 rounded-full ${props.highlight ? 'bg-accent/15 text-accent' : 'bg-success/15 text-success'}`}>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5"/>
</svg>
</span>
);
}
if (v === "no" || v === t("pricing.compare.no").toLowerCase()) {
return (
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-ink/5 text-ink-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"/>
<path d="m6 6 12 12"/>
</svg>
</span>
);
}
return (
<span class={`text-sm font-medium ${props.highlight ? 'text-accent' : 'text-ink'}`}>
{props.value}
</span>
);
};
const faqs = [
{
enQ: "Can I cancel anytime?",
csQ: "Mohu kdykoliv zrušit?",
enA: "Yes, you can cancel anytime. If you have an annual subscription, you'll have access until the end of the period. No cancellation fees.",
csA: "Ano, můžete zrušit kdykoliv. Pokud máte roční předplatné, budete mít přístup do konce období. Žádné poplatky za zrušení.",
},
{
enQ: "What if I exceed my limits?",
csQ: "Co když překročím limity?",
enA: "You'll be notified and prompted to upgrade to a higher plan. Your data will be preserved and you can continue using Bookra seamlessly.",
csA: "Budete upozorněni a vyzváni k upgradu na vyšší plán. Vaše data zůstanou zachována a můžete dál používat Bookra bez přerušení.",
},
{
enQ: "What payment methods do you accept?",
csQ: "Jaké platební metody přijímáte?",
enA: "We accept all major credit cards through Stripe. Payments are secure, encrypted, and PCI compliant.",
csA: "Přijímáme všechny hlavní kreditní karty přes Stripe. Platby jsou zabezpečené, šifrované a splňují PCI standard.",
},
{
enQ: "Can I switch plans?",
csQ: "Můžu změnit plán?",
enA: "Yes, you can upgrade or downgrade at any time. When upgrading, we'll prorate the difference. When downgrading, the new rate applies at the next billing cycle.",
csA: "Ano, můžete kdykoliv upgradovat nebo downgradovat. Při upgradu doplatíte poměrnou částku. Při downgradu se nová cena aplikuje od dalšího fakturačního období.",
},
{
enQ: "Is there a free trial?",
csQ: "Je k dispozici bezplatná zkouška?",
enA: "Yes, every plan includes a 15-day free trial. No credit card required to start.",
csA: "Ano, každý plán obsahuje 15denní bezplatnou zkoušku. Není potřeba zadávat platební kartu.",
},
{
enQ: "Do you offer support?",
csQ: "Poskytujete podporu?",
enA: "Absolutely. Starter includes email support, Pro gets priority support, and Business includes a dedicated account manager.",
csA: "Samozřejmě. Starter obsahuje e-mailovou podporu, Pro má prioritní podporu a Business zahrnuje dedikovaného account managera.",
},
];
return (
<div class="min-h-screen bg-gradient-to-br from-canvas-subtle via-canvas to-canvas-subtle">
{/* Hero */}
<section class="pt-16 pb-12 sm:pt-24 sm:pb-16 text-center px-4">
<h1 class="text-4xl sm:text-5xl font-display font-bold text-ink mb-4 animate-slide-up">
{isCs() ? "Jednoduché a férové ceny" : "Simple, fair pricing"}
</h1>
<p class="text-lg text-ink-muted max-w-2xl mx-auto mb-8 animate-slide-up" style={{ "animation-delay": "0.1s" }}>
{isCs()
? "Vyberte si plán, který vyhovuje vašemu podnikání. Žádné skryté poplatky, žádné překvapení."
: "Choose a plan that fits your business. No hidden fees, no surprises."}
</p>
{/* Billing Toggle */}
<div class="flex items-center justify-center mb-8 animate-slide-up" style={{ "animation-delay": "0.2s" }}>
<div class="relative inline-flex items-center gap-4">
<span class={`text-sm font-semibold transition-colors ${!isYearly() ? 'text-ink' : 'text-ink-muted'}`}>
{isCs() ? "Měsíčně" : "Monthly"}
</span>
<button
type="button"
onClick={() => setBillingInterval(isYearly() ? "monthly" : "yearly")}
class={`relative w-14 h-7 rounded-full transition-colors duration-300 cursor-pointer ${isYearly() ? 'bg-accent' : 'bg-ink/30'}`}
role="switch"
aria-checked={isYearly()}
>
<span class={`absolute top-1 left-1 w-5 h-5 bg-white rounded-full shadow-md transition-transform duration-300 ${isYearly() ? 'translate-x-7' : 'translate-x-0'}`} />
</button>
<span class={`text-sm font-semibold transition-colors ${isYearly() ? 'text-ink' : 'text-ink-muted'}`}>
{isCs() ? "Ročně" : "Yearly"}
</span>
<div class="absolute left-full ml-3 top-1/2 -translate-y-1/2">
<Show when={isYearly()}>
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-accent text-white text-xs font-bold rounded-full shadow-sm whitespace-nowrap">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
-17%
</span>
</Show>
</div>
</div>
</div>
</section>
{/* Pricing Cards */}
<section class="pb-16 px-4">
<div class="grid md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto items-center">
<For each={plans()}>
{(plan, index) => (
<div
class={`group relative p-6 lg:p-8 rounded-card transition-all duration-500 animate-slide-up ${plan.popular ? 'hover:shadow-2xl hover:-translate-y-2 lg:scale-105' : 'hover:shadow-xl hover:-translate-y-1'}`}
style={{ "animation-delay": `${0.3 + index() * 0.1}s` }}
>
<Show when={plan.popular}>
<div class="absolute inset-0 bg-gradient-to-b from-ink to-ink/95 rounded-card" />
</Show>
<Show when={!plan.popular}>
<div class="absolute inset-0 surface-elevated rounded-card" />
</Show>
<Show when={plan.popular}>
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
<span class="px-3 py-1 bg-accent text-white text-xs font-display font-medium rounded-full shadow-lg">
{t("home.pricing.popular")}
</span>
</div>
</Show>
<div class="relative z-10">
<div class="mb-6">
<h3 class={`font-display text-lg font-semibold mb-1 ${plan.popular ? 'text-canvas' : 'text-ink'}`}>
{plan.name}
</h3>
<p class={plan.popular ? 'text-canvas/70' : 'text-ink-muted'}>{plan.desc}</p>
</div>
<div class="mb-6">
<div class="flex items-baseline gap-1">
<span class={`font-display text-4xl font-semibold ${plan.popular ? 'text-canvas' : 'text-ink'}`}>
{isYearly() ? plan.yearly : plan.monthly}
</span>
<span class={plan.popular ? 'text-canvas/60' : 'text-ink-muted'}>
{isYearly() ? plan.yearlyPeriod : plan.period}
</span>
</div>
<Show when={isYearly()}>
<div class="mt-2 flex items-center gap-1.5">
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-accent-subtle text-accent text-xs font-semibold rounded-full border border-accent/10">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
{plan.savingsPercent}
</span>
<span class="text-xs text-ink-subtle">{isCs() ? 'sleva při roční platbě' : 'discount on yearly billing'}</span>
</div>
</Show>
<Show when={!isYearly() && plan.trial}>
<p class="mt-1 text-xs text-accent font-medium">{plan.trial}</p>
</Show>
</div>
<ul class="space-y-3 mb-8">
<For each={plan.features}>
{(feature) => (
<li class={`flex items-start gap-3 ${plan.popular ? 'text-canvas/80' : 'text-ink-muted'}`}>
<span class="mt-0.5 text-accent shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5"/>
</svg>
</span>
<span class="text-sm">{feature}</span>
</li>
)}
</For>
</ul>
<button
onClick={() => handleSelectPlan(plan.id)}
class={`w-full py-3 px-4 rounded-xl font-semibold text-sm transition-all duration-300 ${
plan.popular
? 'bg-accent text-white hover:bg-accent/90 hover:shadow-lg hover:-translate-y-0.5'
: 'bg-ink text-canvas hover:bg-ink/90 hover:shadow-lg hover:-translate-y-0.5'
}`}
>
{plan.cta}
</button>
</div>
</div>
)}
</For>
</div>
</section>
{/* Comparison Table */}
<section class="py-16 px-4">
<div class="max-w-4xl mx-auto">
<div class="text-center mb-10">
<span class="text-sm font-medium text-accent mb-2 block tracking-wide uppercase">
{isCs() ? "Detailní srovnání" : "Detailed comparison"}
</span>
<h2 class="text-2xl sm:text-3xl font-display font-bold text-ink">
{isCs() ? "Porovnání plánů" : "Compare plans"}
</h2>
</div>
<div class="surface-elevated rounded-card overflow-hidden border border-border/50 shadow-sm">
{/* Header */}
<div class="grid grid-cols-[1fr_100px_100px_100px] sm:grid-cols-[1.5fr_1fr_1fr_1fr] gap-2 p-4 sm:p-5 bg-canvas-subtle/60 border-b border-border/50">
<div class="text-sm font-semibold text-ink-muted self-center">{isCs() ? "Funkce" : "Feature"}</div>
<div class="text-center font-display font-semibold text-ink text-sm">Starter</div>
<div class="text-center">
<span class="inline-block px-3 py-1 bg-accent/10 text-accent font-display font-semibold text-sm rounded-full">
Pro
</span>
</div>
<div class="text-center font-display font-semibold text-ink text-sm">Business</div>
</div>
{/* Rows */}
<For each={comparisonFeatures}>
{(feature, i) => (
<div class={`grid grid-cols-[1fr_100px_100px_100px] sm:grid-cols-[1.5fr_1fr_1fr_1fr] gap-2 p-4 sm:p-5 items-center border-b border-border/30 last:border-0 transition-colors ${i() % 2 === 0 ? 'bg-canvas/30' : ''} hover:bg-canvas-subtle/40`}>
<span class="text-sm text-ink font-medium">{t(feature.key)}</span>
<div class="flex justify-center">
<ComparisonValue value={feature.starter} />
</div>
<div class="flex justify-center">
<ComparisonValue value={feature.pro} highlight />
</div>
<div class="flex justify-center">
<ComparisonValue value={feature.business} />
</div>
</div>
)}
</For>
</div>
</div>
</section>
{/* FAQ */}
<section class="py-16 px-4 bg-canvas-subtle/30">
<div class="max-w-2xl mx-auto">
<div class="text-center mb-10">
<span class="text-sm font-medium text-accent mb-2 block tracking-wide uppercase">
{isCs() ? "Máte otázky?" : "Got questions?"}
</span>
<h2 class="text-2xl sm:text-3xl font-display font-bold text-ink">
{isCs() ? "Časté dotazy" : "Frequently asked questions"}
</h2>
</div>
<div class="space-y-3">
<For each={faqs}>
{(faq, i) => (
<div class="surface-elevated rounded-card border border-border/40 overflow-hidden transition-all duration-300 hover:border-border/70 hover:shadow-md">
<button
type="button"
onClick={() => setOpenFaq(openFaq() === i() ? null : i())}
class="w-full flex items-center justify-between p-5 text-left group"
>
<span class="font-semibold text-ink text-sm sm:text-base pr-4 group-hover:text-accent transition-colors">
{isCs() ? faq.csQ : faq.enQ}
</span>
<span class={`shrink-0 w-8 h-8 rounded-full bg-canvas-subtle flex items-center justify-center text-ink-muted group-hover:bg-accent/10 group-hover:text-accent transition-all duration-300 ${openFaq() === i() ? 'rotate-180' : ''}`}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</span>
</button>
<div class={`grid transition-all duration-300 ${openFaq() === i() ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'}`}>
<div class="overflow-hidden">
<div class="px-5 pb-5 text-sm text-ink-muted leading-relaxed border-t border-border/30 pt-4">
{isCs() ? faq.csA : faq.enA}
</div>
</div>
</div>
</div>
)}
</For>
</div>
</div>
</section>
{/* CTA */}
<section class="py-16 px-4">
<div class="max-w-2xl mx-auto text-center">
<h2 class="text-2xl font-display font-bold text-ink mb-4">
{isCs() ? "Stále si nejste jistí?" : "Still not sure?"}
</h2>
<p class="text-ink-muted mb-6">
{isCs()
? "Začněte s bezplatným 15denním trial a rozhodněte se později."
: "Start with a free 15-day trial and decide later."}
</p>
<a
href="/dashboard?signup=true"
class="inline-block px-8 py-3 bg-accent text-white font-semibold rounded-xl hover:bg-accent/90 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300"
>
{isCs() ? "Začít zdarma" : "Start for free"}
</a>
</div>
</section>
{/* Footer */}
<footer class="border-t border-border py-8 px-4">
<div class="max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded bg-gradient-to-br from-accent to-accent/70 flex items-center justify-center">
<span class="text-white font-bold text-xs">B</span>
</div>
<span class="text-ink-muted text-sm">© 2024 Bookra</span>
</div>
<nav class="flex items-center gap-6">
<a href="/privacy" class="text-ink-muted hover:text-ink text-sm transition-colors">{isCs() ? "Ochrana soukromí" : "Privacy"}</a>
<a href="/terms" class="text-ink-muted hover:text-ink text-sm transition-colors">{isCs() ? "Podmínky" : "Terms"}</a>
<a href="/contact" class="text-ink-muted hover:text-ink text-sm transition-colors">{isCs() ? "Kontakt" : "Contact"}</a>
</nav>
</div>
</footer>
</div>
);
};
export default PricingRoute;

Some files were not shown because too many files have changed in this diff Show More