mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
Compare commits
6 Commits
cf3315e8fc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2039669e2c | |||
| da5ba13eab | |||
| 9d63fa7620 | |||
| 3b6f46828b | |||
| 7d3e3448cf | |||
| 164a37e997 |
@@ -5,10 +5,69 @@ BOOKRA_APP_ENV=staging
|
|||||||
BOOKRA_APP_URL=https://app.bookra.example
|
BOOKRA_APP_URL=https://app.bookra.example
|
||||||
BOOKRA_API_URL=https://api.bookra.example
|
BOOKRA_API_URL=https://api.bookra.example
|
||||||
BOOKRA_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
|
BOOKRA_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
|
||||||
|
|
||||||
|
# Stripe billing (preferred)
|
||||||
|
BOOKRA_STRIPE_SECRET_KEY=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
|
||||||
|
BOOKRA_STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||||
|
|
||||||
|
# Stripe Monthly Prices
|
||||||
|
BOOKRA_STRIPE_STARTER_CZK_MONTHLY_PRICE_ID=price_1TV6Z6GrjyNQaOSGZFcmltqI
|
||||||
|
BOOKRA_STRIPE_STARTER_USD_MONTHLY_PRICE_ID=price_1TV6cAGrjyNQaOSGXBhOq3Dk
|
||||||
|
BOOKRA_STRIPE_PRO_CZK_MONTHLY_PRICE_ID=price_1TV6ZZGrjyNQaOSGqWENnjDD
|
||||||
|
BOOKRA_STRIPE_PRO_USD_MONTHLY_PRICE_ID=price_1TV6dXGrjyNQaOSGeWzJlg2n
|
||||||
|
BOOKRA_STRIPE_BUSINESS_CZK_MONTHLY_PRICE_ID=price_1TV6bgGrjyNQaOSGqzGTM68E
|
||||||
|
BOOKRA_STRIPE_BUSINESS_USD_MONTHLY_PRICE_ID=price_1TV6dpGrjyNQaOSGCqKO42Oi
|
||||||
|
|
||||||
|
# Stripe Yearly Prices (17% discount)
|
||||||
|
BOOKRA_STRIPE_STARTER_CZK_YEARLY_PRICE_ID=price_1TVAlqGrjyNQaOSGNiZQ5tEx
|
||||||
|
BOOKRA_STRIPE_STARTER_USD_YEARLY_PRICE_ID=price_1TVAnSGrjyNQaOSGTHQHrgv3
|
||||||
|
BOOKRA_STRIPE_PRO_CZK_YEARLY_PRICE_ID=price_1TVAmVGrjyNQaOSGWDgWqYvb
|
||||||
|
BOOKRA_STRIPE_PRO_USD_YEARLY_PRICE_ID=price_1TVAnjGrjyNQaOSGvAANw64k
|
||||||
|
BOOKRA_STRIPE_BUSINESS_CZK_YEARLY_PRICE_ID=price_1TVAmsGrjyNQaOSGL7Sl5cCd
|
||||||
|
BOOKRA_STRIPE_BUSINESS_USD_YEARLY_PRICE_ID=price_1TVAo7GrjyNQaOSGB8LSCOua
|
||||||
|
|
||||||
|
# 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_ENV=sandbox
|
||||||
BOOKRA_PADDLE_API_KEY=pdl_sdbx_api_key
|
BOOKRA_PADDLE_API_KEY=pdl_sdbx_api_key
|
||||||
BOOKRA_PADDLE_WEBHOOK_SECRET=pdl_ntfset_secret
|
BOOKRA_PADDLE_WEBHOOK_SECRET=pdl_ntfset_secret
|
||||||
|
BOOKRA_PADDLE_STARTER_CZK_PRICE_ID=pri_starter_czk_123
|
||||||
|
BOOKRA_PADDLE_STARTER_USD_PRICE_ID=pri_starter_usd_123
|
||||||
BOOKRA_PADDLE_PRO_CZK_PRICE_ID=pri_pro_czk_123
|
BOOKRA_PADDLE_PRO_CZK_PRICE_ID=pri_pro_czk_123
|
||||||
|
BOOKRA_PADDLE_PRO_USD_PRICE_ID=pri_pro_usd_123
|
||||||
|
BOOKRA_PADDLE_BUSINESS_CZK_PRICE_ID=pri_business_czk_123
|
||||||
|
BOOKRA_PADDLE_BUSINESS_USD_PRICE_ID=pri_business_usd_123
|
||||||
VITE_PADDLE_CLIENT_TOKEN=test_paddle_client_token
|
VITE_PADDLE_CLIENT_TOKEN=test_paddle_client_token
|
||||||
|
|
||||||
BOOKRA_SMTP_HOST=smtp.example.com
|
BOOKRA_SMTP_HOST=smtp.example.com
|
||||||
BOOKRA_UMAMI_API_URL=https://umami.example.com
|
BOOKRA_UMAMI_API_URL=https://umami.example.com
|
||||||
|
|
||||||
|
# Sentry (optional)
|
||||||
|
VITE_SENTRY_DSN=https://462fb8597035778961e2e06c48c7c7fd@o4511360379191296.ingest.de.sentry.io/4511360406454352
|
||||||
|
BOOKRA_SENTRY_DSN=https://462fb8597035778961e2e06c48c7c7fd@o4511360379191296.ingest.de.sentry.io/4511360406454352
|
||||||
|
|||||||
@@ -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
@@ -7,10 +7,23 @@
|
|||||||
- Czech `home.step2.desc` was missing and leaked the translation key on the landing page.
|
- Czech `home.step2.desc` was missing and leaked the translation key on the landing page.
|
||||||
- Footer year was stale.
|
- Footer year was stale.
|
||||||
|
|
||||||
|
## Fixed This Session
|
||||||
|
|
||||||
|
- The dashboard unauthenticated state replaced with a dedicated conversion screen (headline, benefit cards, Sign In / Demo CTAs, register link).
|
||||||
|
- The pricing highlight label now uses the `home.pricing.popular` i18n key in both `home-route.tsx` and `pricing-route.tsx`.
|
||||||
|
- The mobile booking page now smooth-scrolls to the contact form and highlights it when a user taps a slot without filling required details, breaking the validation loop.
|
||||||
|
- The IntegrationModal widget snippets now derive the base URL from `publicBookingUrl` instead of hardcoding `bookra.eu`, fixing local dev and custom domain scenarios.
|
||||||
|
- Email settings section now has a working **Save Email Settings** button with loading state, wired to `PUT /v1/tenants/email-settings`.
|
||||||
|
|
||||||
|
## Fixed This Session (continued)
|
||||||
|
|
||||||
|
- **Accessibility**: Added `aria-label` attributes to all icon-only buttons in the dashboard (calendar nav, notifications, modal close buttons, mobile menu).
|
||||||
|
- **Widget-builder UX**: Replaced `console.error` on copy failure with a user-visible error banner.
|
||||||
|
- **Booking-manage route**: Contact email now uses `businessEmail` from booking data instead of hardcoded `support@bookra.eu`.
|
||||||
|
- **Loading states**: Added spinners and disabled states to booking create/update buttons and brand save button.
|
||||||
|
- **i18n cleanup**: Added 30+ new `dashboard.*` keys to the i18n dictionary and replaced ~20 inline ternary expressions with `i18n.t()` calls across nav items, page titles, status labels, and action buttons.
|
||||||
|
|
||||||
## Still Needs Frontend Polish
|
## Still Needs Frontend Polish
|
||||||
|
|
||||||
- The dashboard unauthenticated state is still a thin sign-in prompt rather than a dedicated conversion screen.
|
- Many dashboard inline i18n ternaries remain (~280). Systematic extraction to `i18n.t()` keys is an ongoing task.
|
||||||
- Some dashboard copy remains English inside the Czech locale because the new guided setup/dashboard sections are MVP product copy.
|
|
||||||
- The mobile booking page puts slots before contact details, which is usable but creates a validation loop if users tap a slot first.
|
|
||||||
- The pricing highlight label is hardcoded rather than localized.
|
|
||||||
- Registration cannot be fully customer-tested locally until Neon Auth environment variables are configured.
|
- Registration cannot be fully customer-tested locally until Neon Auth environment variables are configured.
|
||||||
|
|||||||
@@ -58,3 +58,46 @@ npm run db:migrate:up
|
|||||||
```
|
```
|
||||||
|
|
||||||
`db:migrate:*` expects `BOOKRA_DATABASE_DIRECT_URL` to be exported in the shell.
|
`db:migrate:*` expects `BOOKRA_DATABASE_DIRECT_URL` to be exported in the shell.
|
||||||
|
|
||||||
|
## Brand Colors
|
||||||
|
|
||||||
|
Bookra uses a sophisticated color system designed for modern booking interfaces with support for both light and dark themes.
|
||||||
|
|
||||||
|
### Primary Palette
|
||||||
|
- **Canvas** (`--color-canvas`): #FFFFFF (light) / #0A0A0A (dark)
|
||||||
|
- **Canvas Elevated** (`--color-canvas-elevated`): #F8F9FA (light) / #1A1A1A (dark)
|
||||||
|
- **Canvas Sunken** (`--color-canvas-sunken`): #F1F3F4 (light) / #252525 (dark)
|
||||||
|
|
||||||
|
### Accent Colors
|
||||||
|
- **Primary** (`--color-primary`): #3B82F6
|
||||||
|
- **Primary Hover** (`--color-primary-hover`): #2563EB
|
||||||
|
- **Primary Active** (`--color-primary-active`): #1D4ED8
|
||||||
|
|
||||||
|
### Semantic Colors
|
||||||
|
- **Success** (`--color-success`): #10B981
|
||||||
|
- **Warning** (`--color-warning`): #F59E0B
|
||||||
|
- **Error** (`--color-error`): #EF4444
|
||||||
|
- **Info** (`--color-info`): #06B6D4
|
||||||
|
|
||||||
|
### Text Colors
|
||||||
|
- **Text Primary** (`--color-text-primary`): #111827 (light) / #F9FAFB (dark)
|
||||||
|
- **Text Secondary** (`--color-text-secondary`): #6B7280 (light) / #D1D5DB (dark)
|
||||||
|
- **Text Muted** (`--color-text-muted`): #9CA3AF (light) / #9CA3AF (dark)
|
||||||
|
|
||||||
|
### Border & Surface
|
||||||
|
- **Border** (`--color-border`): #E5E7EB (light) / #374151 (dark)
|
||||||
|
- **Surface Glass** (`--color-surface-glass`): rgba(255, 255, 255, 0.8) (light) / rgba(0, 0, 0, 0.8) (dark)
|
||||||
|
|
||||||
|
### Shadow System
|
||||||
|
- **Shadow XS** (`--shadow-xs`): 0 1px 2px 0 rgba(0, 0, 0, 0.05)
|
||||||
|
- **Shadow SM** (`--shadow-sm`): 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)
|
||||||
|
- **Shadow MD** (`--shadow-md`): 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)
|
||||||
|
- **Shadow LG** (`--shadow-lg`): 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)
|
||||||
|
- **Shadow XL** (`--shadow-xl`): 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)
|
||||||
|
- **Shadow 2XL** (`--shadow-2xl`): 0 25px 50px -12px rgba(0, 0, 0, 0.25)
|
||||||
|
|
||||||
|
### Animation Timing
|
||||||
|
- **Ease Out Expo** (`--ease-out-expo`): cubic-bezier(0.16, 1, 0.3, 1)
|
||||||
|
- **Ease Spring** (`--ease-spring`): cubic-bezier(0.68, -0.55, 0.265, 1.55)
|
||||||
|
|
||||||
|
These colors are implemented as CSS custom properties and are used throughout the frontend application for consistent theming and accessibility.
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
.git
|
|
||||||
.github
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
bin
|
|
||||||
coverage
|
|
||||||
tmp
|
|
||||||
*.log
|
|
||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Auth Service Environment Configuration
|
|
||||||
# This service stays active for standalone auth flows and internal admin management.
|
|
||||||
# SaaS billing is handled by apps/backend + Paddle.
|
|
||||||
|
|
||||||
PORT=8081
|
|
||||||
APP_ENV=development
|
|
||||||
|
|
||||||
DATABASE_URL=postgresql://user:password@host/database?sslmode=require
|
|
||||||
FRONTEND_URL=http://localhost:3000
|
|
||||||
|
|
||||||
JWT_SECRET=change-me-in-production
|
|
||||||
NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
|
|
||||||
|
|
||||||
SMTP_HOST=smtp.purelymail.com
|
|
||||||
SMTP_PORT=465
|
|
||||||
SMTP_USERNAME=noreply@example.com
|
|
||||||
SMTP_PASSWORD=
|
|
||||||
EMAIL_FROM=noreply@example.com
|
|
||||||
|
|
||||||
GOOGLE_CLIENT_ID=
|
|
||||||
GOOGLE_CLIENT_SECRET=
|
|
||||||
GOOGLE_REDIRECT_URL=http://localhost:8081/api/auth/oauth/google/callback
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# Environment variables
|
|
||||||
.env
|
|
||||||
|
|
||||||
# Binary
|
|
||||||
auth-service
|
|
||||||
*.exe
|
|
||||||
|
|
||||||
# Go
|
|
||||||
vendor/
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Build stage
|
|
||||||
FROM golang:1.26.2-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install build dependencies
|
|
||||||
RUN apk add --no-cache git
|
|
||||||
|
|
||||||
# Copy go mod files
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/auth-service ./cmd/api
|
|
||||||
|
|
||||||
FROM alpine:3.22
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates \
|
|
||||||
&& addgroup -S bookra \
|
|
||||||
&& adduser -S -D -H -u 10001 -G bookra bookra
|
|
||||||
|
|
||||||
COPY --from=builder --chown=bookra:bookra /app/auth-service /app/
|
|
||||||
COPY --from=builder --chown=bookra:bookra /app/migrations /app/migrations
|
|
||||||
|
|
||||||
ENV PORT=8080
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
USER bookra
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
||||||
CMD wget -qO- "http://127.0.0.1:${PORT:-8080}/health" >/dev/null || exit 1
|
|
||||||
|
|
||||||
CMD ["/app/auth-service"]
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Bookra Auth Service
|
|
||||||
|
|
||||||
Standalone auth + internal admin service for Bookra.
|
|
||||||
|
|
||||||
Primary responsibilities:
|
|
||||||
|
|
||||||
- email/password auth
|
|
||||||
- magic-link auth
|
|
||||||
- Google OAuth when configured
|
|
||||||
- internal admin dashboard / remote service management
|
|
||||||
- optional Neon JWT verification support
|
|
||||||
|
|
||||||
Not primary billing service:
|
|
||||||
|
|
||||||
- SaaS billing lives in `apps/backend`
|
|
||||||
- Paddle config belongs in backend/frontend env
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go run ./cmd/api
|
|
||||||
go test ./...
|
|
||||||
go build ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Core Routes
|
|
||||||
|
|
||||||
- `GET /health`
|
|
||||||
- `POST /api/auth/register`
|
|
||||||
- `POST /api/auth/login`
|
|
||||||
- `POST /api/auth/magic-link`
|
|
||||||
- `POST /api/auth/verify`
|
|
||||||
- `POST /api/auth/refresh`
|
|
||||||
- `GET /api/auth/me`
|
|
||||||
- `GET /api/auth/providers`
|
|
||||||
- `GET /api/auth/oauth/google`
|
|
||||||
- `GET /api/auth/oauth/google/callback`
|
|
||||||
- `GET /admin`
|
|
||||||
- `GET /admin/api/config`
|
|
||||||
- `GET /admin/api/stats`
|
|
||||||
|
|
||||||
See [apps/auth-service/.env.example](/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/.env.example:1).
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bookra/apps/auth-service/internal/config"
|
|
||||||
"bookra/apps/auth-service/internal/db"
|
|
||||||
"bookra/apps/auth-service/internal/email"
|
|
||||||
"bookra/apps/auth-service/internal/handlers"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-contrib/cors"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
"github.com/pressly/goose/v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
_ = godotenv.Load()
|
|
||||||
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to load config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.Getenv("APP_ENV") == "production" {
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
database, err := db.New(cfg.DatabaseURL)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to connect to database: %v", err)
|
|
||||||
}
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
if err := runMigrations(cfg.DatabaseURL); err != nil {
|
|
||||||
log.Printf("Migration warning: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
emailSvc := email.New(email.Config{
|
|
||||||
Host: cfg.SMTPHost,
|
|
||||||
Port: cfg.SMTPPort,
|
|
||||||
Username: cfg.SMTPUsername,
|
|
||||||
Password: cfg.SMTPPassword,
|
|
||||||
From: cfg.EmailFrom,
|
|
||||||
})
|
|
||||||
|
|
||||||
handler, err := handlers.New(database, emailSvc, cfg)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to initialize handlers: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r := gin.Default()
|
|
||||||
|
|
||||||
r.Use(cors.New(cors.Config{
|
|
||||||
AllowOrigins: []string{cfg.FrontendURL, "http://localhost:3000", "http://localhost:5173"},
|
|
||||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
|
||||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
|
|
||||||
ExposeHeaders: []string{"Content-Length"},
|
|
||||||
AllowCredentials: true,
|
|
||||||
MaxAge: 12 * time.Hour,
|
|
||||||
}))
|
|
||||||
|
|
||||||
r.GET("/health", func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "healthy", "service": "auth"})
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.RegisterRoutes(r)
|
|
||||||
|
|
||||||
srv := &http.Server{
|
|
||||||
Addr: ":" + cfg.Port,
|
|
||||||
Handler: r,
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
log.Fatalf("Failed to start server: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
log.Printf("Auth service running on port %s", cfg.Port)
|
|
||||||
|
|
||||||
quit := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
<-quit
|
|
||||||
|
|
||||||
log.Println("Shutting down server...")
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := srv.Shutdown(ctx); err != nil {
|
|
||||||
log.Printf("Server forced to shutdown: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Server exited")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMigrations(databaseURL string) error {
|
|
||||||
db, err := goose.OpenDBWithDriver("pgx", databaseURL)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("goose open db: %w", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
if err := goose.SetDialect("postgres"); err != nil {
|
|
||||||
return fmt.Errorf("goose set dialect: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := goose.Up(db, "migrations"); err != nil {
|
|
||||||
return fmt.Errorf("goose up: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
module bookra/apps/auth-service
|
|
||||||
|
|
||||||
go 1.26.2
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/gin-contrib/cors v1.7.7
|
|
||||||
github.com/gin-gonic/gin v1.12.0
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
|
||||||
github.com/google/uuid v1.6.0
|
|
||||||
github.com/jackc/pgx/v5 v5.9.1
|
|
||||||
github.com/joho/godotenv v1.5.1
|
|
||||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
|
|
||||||
github.com/pressly/goose/v3 v3.27.0
|
|
||||||
github.com/stripe/stripe-go/v83 v83.2.1
|
|
||||||
golang.org/x/crypto v0.50.0
|
|
||||||
golang.org/x/oauth2 v0.36.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
|
||||||
github.com/MicahParks/jwkset v0.11.0 // indirect
|
|
||||||
github.com/MicahParks/keyfunc/v3 v3.8.0 // indirect
|
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
|
||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
|
||||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
|
||||||
golang.org/x/arch v0.23.0 // indirect
|
|
||||||
golang.org/x/net v0.52.0 // indirect
|
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
|
||||||
golang.org/x/text v0.36.0 // indirect
|
|
||||||
golang.org/x/time v0.9.0 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
|
||||||
)
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
|
||||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
|
||||||
github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ=
|
|
||||||
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
|
|
||||||
github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds=
|
|
||||||
github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
|
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
|
||||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
|
||||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
|
||||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
|
||||||
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
|
|
||||||
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
|
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
|
||||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
|
||||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
|
||||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
|
||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
|
||||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
|
||||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
|
||||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
|
|
||||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
|
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
|
||||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
|
|
||||||
github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
|
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
|
||||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
|
||||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
|
||||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
|
||||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
|
||||||
github.com/stripe/stripe-go/v83 v83.2.1 h1:8WPhpMjr8VyMWKUsCMoVvlWxYazuL5edajKX/RulfbA=
|
|
||||||
github.com/stripe/stripe-go/v83 v83.2.1/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE=
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
|
||||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
|
||||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
|
||||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
|
||||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
|
||||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
|
||||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
|
||||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
|
||||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
|
||||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
|
||||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
|
||||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/MicahParks/keyfunc/v3"
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
type NeonVerifier struct {
|
|
||||||
jwks keyfunc.Keyfunc
|
|
||||||
expectedIssuer string
|
|
||||||
enabled bool
|
|
||||||
cancel context.CancelFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewNeonVerifier(neonAuthURL string) (*NeonVerifier, error) {
|
|
||||||
trimmed := strings.TrimRight(strings.TrimSpace(neonAuthURL), "/")
|
|
||||||
if trimmed == "" {
|
|
||||||
return &NeonVerifier{enabled: false}, nil
|
|
||||||
}
|
|
||||||
parsed, err := url.Parse(trimmed)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse neon auth url: %w", err)
|
|
||||||
}
|
|
||||||
expectedIssuer := fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
|
||||||
jwksURL := fmt.Sprintf("%s/.well-known/jwks.json", trimmed)
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
jwks, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
|
|
||||||
if err != nil {
|
|
||||||
cancel()
|
|
||||||
return nil, fmt.Errorf("create neon jwks: %w", err)
|
|
||||||
}
|
|
||||||
return &NeonVerifier{jwks: jwks, expectedIssuer: expectedIssuer, enabled: true, cancel: cancel}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *NeonVerifier) Enabled() bool {
|
|
||||||
return v != nil && v.enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *NeonVerifier) Close() {
|
|
||||||
if v != nil && v.cancel != nil {
|
|
||||||
v.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *NeonVerifier) Verify(tokenString string) (*Claims, error) {
|
|
||||||
if !v.Enabled() {
|
|
||||||
return nil, errors.New("neon auth verifier is disabled")
|
|
||||||
}
|
|
||||||
token, err := jwt.Parse(tokenString, v.jwks.Keyfunc,
|
|
||||||
jwt.WithIssuer(v.expectedIssuer),
|
|
||||||
jwt.WithValidMethods([]string{"EdDSA"}),
|
|
||||||
jwt.WithAudience(v.expectedIssuer),
|
|
||||||
jwt.WithLeeway(15*time.Second),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
claims, ok := token.Claims.(jwt.MapClaims)
|
|
||||||
if !ok || !token.Valid {
|
|
||||||
return nil, errors.New("invalid neon claims")
|
|
||||||
}
|
|
||||||
subject, _ := claims["sub"].(string)
|
|
||||||
email, _ := claims["email"].(string)
|
|
||||||
name, _ := claims["name"].(string)
|
|
||||||
if name == "" {
|
|
||||||
name, _ = claims["display_name"].(string)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(subject) == "" {
|
|
||||||
return nil, errors.New("missing neon subject")
|
|
||||||
}
|
|
||||||
return &Claims{UserID: subject, Email: email, Name: name, Role: "authenticated", Type: "access"}, nil
|
|
||||||
}
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"bookra/apps/auth-service/internal/db"
|
|
||||||
"bookra/apps/auth-service/internal/email"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
accessTokenTTL = 24 * time.Hour
|
|
||||||
refreshTokenTTL = 30 * 24 * time.Hour
|
|
||||||
)
|
|
||||||
|
|
||||||
type Service struct {
|
|
||||||
db *db.DB
|
|
||||||
email *email.Service
|
|
||||||
jwtSecret []byte
|
|
||||||
frontendURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
type TokenPair struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
RefreshToken string `json:"refresh_token,omitempty"`
|
|
||||||
TokenType string `json:"token_type"`
|
|
||||||
ExpiresIn int `json:"expires_in"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Claims struct {
|
|
||||||
UserID string `json:"sub"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
Role string `json:"role,omitempty"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewService(database *db.DB, emailSvc *email.Service, jwtSecret string, frontendURL string) *Service {
|
|
||||||
return &Service{
|
|
||||||
db: database,
|
|
||||||
email: emailSvc,
|
|
||||||
jwtSecret: []byte(jwtSecret),
|
|
||||||
frontendURL: frontendURL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) GenerateMagicLink(ctx context.Context, emailAddr string, locale string) error {
|
|
||||||
user, err := s.db.GetUserByEmail(ctx, emailAddr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if user == nil {
|
|
||||||
user = &db.User{
|
|
||||||
Email: emailAddr,
|
|
||||||
Provider: "email",
|
|
||||||
}
|
|
||||||
user, err = s.db.CreateUser(ctx, user)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create user: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
token := generateRandomToken(32)
|
|
||||||
expiresAt := time.Now().Add(15 * time.Minute)
|
|
||||||
|
|
||||||
if err := s.db.CreateMagicLink(ctx, token, emailAddr, user.ID, expiresAt); err != nil {
|
|
||||||
return fmt.Errorf("create magic link: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
magicURL := fmt.Sprintf("%s/auth/callback?token=%s", s.frontendURL, token)
|
|
||||||
|
|
||||||
var name string
|
|
||||||
if user.Name != nil {
|
|
||||||
name = *user.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.email.SendMagicLink(emailAddr, name, magicURL, locale); err != nil {
|
|
||||||
return fmt.Errorf("send email: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) VerifyMagicLink(ctx context.Context, token string) (*TokenPair, error) {
|
|
||||||
ml, err := s.db.GetMagicLink(ctx, token)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get magic link: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ml == nil || ml.Used {
|
|
||||||
return nil, fmt.Errorf("invalid or used token")
|
|
||||||
}
|
|
||||||
|
|
||||||
if time.Now().After(ml.ExpiresAt) {
|
|
||||||
return nil, fmt.Errorf("token expired")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.MarkMagicLinkUsed(ctx, token); err != nil {
|
|
||||||
return nil, fmt.Errorf("mark used: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.db.GetUserByID(ctx, ml.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if user == nil {
|
|
||||||
return nil, fmt.Errorf("user not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.UpdateLastLogin(ctx, user.ID); err != nil {
|
|
||||||
return nil, fmt.Errorf("update login: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.generateTokens(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) OAuthLoginOrCreate(ctx context.Context, provider, providerID, email, name string) (*TokenPair, error) {
|
|
||||||
user, err := s.db.GetUserByProviderID(ctx, provider, providerID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get user by provider: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if user == nil {
|
|
||||||
existing, err := s.db.GetUserByEmail(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("check existing email: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing != nil {
|
|
||||||
existing.Provider = provider
|
|
||||||
existing.ProviderID = &providerID
|
|
||||||
existing.Name = &name
|
|
||||||
existing.EmailVerified = true
|
|
||||||
if err := s.db.UpdateUser(ctx, existing); err != nil {
|
|
||||||
return nil, fmt.Errorf("link provider: %w", err)
|
|
||||||
}
|
|
||||||
user = existing
|
|
||||||
} else {
|
|
||||||
user = &db.User{
|
|
||||||
Email: email,
|
|
||||||
Name: &name,
|
|
||||||
Provider: provider,
|
|
||||||
ProviderID: &providerID,
|
|
||||||
EmailVerified: true,
|
|
||||||
}
|
|
||||||
user, err = s.db.CreateUser(ctx, user)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create oauth user: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.UpdateLastLogin(ctx, user.ID); err != nil {
|
|
||||||
return nil, fmt.Errorf("update login: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.generateTokens(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) RegisterWithPassword(ctx context.Context, email, password, name string) (*TokenPair, error) {
|
|
||||||
existing, err := s.db.GetUserByEmail(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("check existing: %w", err)
|
|
||||||
}
|
|
||||||
if existing != nil {
|
|
||||||
return nil, fmt.Errorf("email already registered")
|
|
||||||
}
|
|
||||||
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("hash password: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hashStr := string(hash)
|
|
||||||
user := &db.User{
|
|
||||||
Email: email,
|
|
||||||
Name: &name,
|
|
||||||
PasswordHash: &hashStr,
|
|
||||||
Provider: "email",
|
|
||||||
EmailVerified: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err = s.db.CreateUser(ctx, user)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.generateTokens(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) LoginWithPassword(ctx context.Context, email, password string) (*TokenPair, error) {
|
|
||||||
user, err := s.db.GetUserByEmail(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get user: %w", err)
|
|
||||||
}
|
|
||||||
if user == nil || user.PasswordHash == nil {
|
|
||||||
return nil, fmt.Errorf("invalid credentials")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid credentials")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.UpdateLastLogin(ctx, user.ID); err != nil {
|
|
||||||
return nil, fmt.Errorf("update login: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.generateTokens(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) generateTokens(user *db.User) (*TokenPair, error) {
|
|
||||||
now := time.Now()
|
|
||||||
return s.generateTokensAt(user, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) generateTokensAt(user *db.User, now time.Time) (*TokenPair, error) {
|
|
||||||
name := ""
|
|
||||||
if user.Name != nil {
|
|
||||||
name = *user.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
accessTokenString, err := s.signToken(user, name, "access", now, accessTokenTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("sign access token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshTokenString, err := s.signToken(user, name, "refresh", now, refreshTokenTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("sign refresh token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TokenPair{
|
|
||||||
AccessToken: accessTokenString,
|
|
||||||
RefreshToken: refreshTokenString,
|
|
||||||
TokenType: "Bearer",
|
|
||||||
ExpiresIn: int(accessTokenTTL.Seconds()),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) VerifyToken(tokenString string) (*Claims, error) {
|
|
||||||
return s.verifyTokenOfType(tokenString, "access")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) VerifyRefreshToken(tokenString string) (*Claims, error) {
|
|
||||||
return s.verifyTokenOfType(tokenString, "refresh")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) RefreshTokens(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
|
||||||
claims, err := s.VerifyRefreshToken(refreshToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
user := &db.User{
|
|
||||||
ID: uuid.MustParse(claims.UserID),
|
|
||||||
Email: claims.Email,
|
|
||||||
}
|
|
||||||
if claims.Name != "" {
|
|
||||||
user.Name = &claims.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.db != nil {
|
|
||||||
storedUser, err := s.db.GetUserByID(ctx, user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get user: %w", err)
|
|
||||||
}
|
|
||||||
if storedUser == nil {
|
|
||||||
return nil, fmt.Errorf("user not found")
|
|
||||||
}
|
|
||||||
user = storedUser
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.generateTokens(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) verifyTokenOfType(tokenString string, expectedType string) (*Claims, error) {
|
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
|
||||||
}
|
|
||||||
return s.jwtSecret, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
|
||||||
if claims.Type != expectedType {
|
|
||||||
return nil, fmt.Errorf("invalid token type")
|
|
||||||
}
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("invalid token")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) signToken(user *db.User, name string, tokenType string, now time.Time, ttl time.Duration) (string, error) {
|
|
||||||
claims := Claims{
|
|
||||||
UserID: user.ID.String(),
|
|
||||||
Email: user.Email,
|
|
||||||
Name: name,
|
|
||||||
Role: "authenticated",
|
|
||||||
Type: tokenType,
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
Issuer: "bookra-auth",
|
|
||||||
Subject: user.ID.String(),
|
|
||||||
Audience: jwt.ClaimStrings{"bookra"},
|
|
||||||
ID: generateRandomToken(12),
|
|
||||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
return token.SignedString(s.jwtSecret)
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateRandomToken(length int) string {
|
|
||||||
b := make([]byte, length)
|
|
||||||
rand.Read(b)
|
|
||||||
return base64.URLEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"bookra/apps/auth-service/internal/db"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGenerateTokensProducesVerifiableAccessAndRefreshTokens(t *testing.T) {
|
|
||||||
service := NewService(nil, nil, "test-secret", "http://localhost:3000")
|
|
||||||
name := "Token Tester"
|
|
||||||
user := &db.User{
|
|
||||||
ID: uuid.MustParse("019daeaa-bc14-7712-9224-e347a96bd5c3"),
|
|
||||||
Email: "tester@bookra.dev",
|
|
||||||
Name: &name,
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens, err := service.generateTokensAt(user, time.Now().UTC())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("generate tokens: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
accessClaims, err := service.VerifyToken(tokens.AccessToken)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("verify access token: %v", err)
|
|
||||||
}
|
|
||||||
if accessClaims.Type != "access" {
|
|
||||||
t.Fatalf("expected access type, got %s", accessClaims.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshClaims, err := service.VerifyRefreshToken(tokens.RefreshToken)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("verify refresh token: %v", err)
|
|
||||||
}
|
|
||||||
if refreshClaims.Type != "refresh" {
|
|
||||||
t.Fatalf("expected refresh type, got %s", refreshClaims.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := service.VerifyToken(tokens.RefreshToken); err == nil {
|
|
||||||
t.Fatal("expected refresh token to fail access verification")
|
|
||||||
}
|
|
||||||
if _, err := service.VerifyRefreshToken(tokens.AccessToken); err == nil {
|
|
||||||
t.Fatal("expected access token to fail refresh verification")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshTokensReturnsRotatedPair(t *testing.T) {
|
|
||||||
service := NewService(nil, nil, "test-secret", "http://localhost:3000")
|
|
||||||
user := &db.User{
|
|
||||||
ID: uuid.MustParse("019daeaa-bc14-7712-9224-e347a96bd5c3"),
|
|
||||||
Email: "tester@bookra.dev",
|
|
||||||
}
|
|
||||||
|
|
||||||
original, err := service.generateTokens(user)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("generate tokens: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshed, err := service.RefreshTokens(context.Background(), original.RefreshToken)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("refresh tokens: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if refreshed.AccessToken == original.AccessToken {
|
|
||||||
t.Fatal("expected rotated access token")
|
|
||||||
}
|
|
||||||
if refreshed.RefreshToken == original.RefreshToken {
|
|
||||||
t.Fatal("expected rotated refresh token")
|
|
||||||
}
|
|
||||||
if _, err := service.VerifyToken(refreshed.AccessToken); err != nil {
|
|
||||||
t.Fatalf("verify refreshed access token: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := service.VerifyRefreshToken(refreshed.RefreshToken); err != nil {
|
|
||||||
t.Fatalf("verify refreshed refresh token: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshTokensRejectsInvalidToken(t *testing.T) {
|
|
||||||
service := NewService(nil, nil, "test-secret", "http://localhost:3000")
|
|
||||||
|
|
||||||
if _, err := service.RefreshTokens(context.Background(), "bad-token"); err == nil {
|
|
||||||
t.Fatal("expected invalid refresh token error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
package billing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"bookra/apps/auth-service/internal/config"
|
|
||||||
"bookra/apps/auth-service/internal/db"
|
|
||||||
|
|
||||||
"github.com/stripe/stripe-go/v83"
|
|
||||||
"github.com/stripe/stripe-go/v83/checkout/session"
|
|
||||||
"github.com/stripe/stripe-go/v83/customer"
|
|
||||||
"github.com/stripe/stripe-go/v83/subscription"
|
|
||||||
"github.com/stripe/stripe-go/v83/webhook"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrStripeNotConfigured = errors.New("stripe is not configured")
|
|
||||||
ErrStripeWebhookMissing = errors.New("stripe webhook secret is not configured")
|
|
||||||
ErrStripeSignatureMissing = errors.New("stripe signature is missing")
|
|
||||||
ErrPlanNotConfigured = errors.New("stripe plan is not configured")
|
|
||||||
ErrCustomerMappingNotFound = errors.New("stripe customer mapping not found")
|
|
||||||
)
|
|
||||||
|
|
||||||
var allowedWebhookEvents = []string{
|
|
||||||
"checkout.session.completed",
|
|
||||||
"customer.subscription.created",
|
|
||||||
"customer.subscription.updated",
|
|
||||||
"customer.subscription.deleted",
|
|
||||||
"customer.subscription.paused",
|
|
||||||
"customer.subscription.resumed",
|
|
||||||
"invoice.paid",
|
|
||||||
"invoice.payment_failed",
|
|
||||||
"payment_intent.succeeded",
|
|
||||||
"payment_intent.payment_failed",
|
|
||||||
}
|
|
||||||
|
|
||||||
type Service struct {
|
|
||||||
cfg *config.Config
|
|
||||||
db *db.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
type CheckoutSession struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SubscriptionSnapshot struct {
|
|
||||||
CustomerID string `json:"customerId,omitempty"`
|
|
||||||
SubscriptionID string `json:"subscriptionId,omitempty"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
PlanCode string `json:"planCode,omitempty"`
|
|
||||||
Currency string `json:"currency,omitempty"`
|
|
||||||
PriceID string `json:"priceId,omitempty"`
|
|
||||||
CancelAtPeriodEnd bool `json:"cancelAtPeriodEnd"`
|
|
||||||
CurrentPeriodStart *time.Time `json:"currentPeriodStart,omitempty"`
|
|
||||||
CurrentPeriodEnd *time.Time `json:"currentPeriodEnd,omitempty"`
|
|
||||||
PaymentMethod *PaymentMethod `json:"paymentMethod,omitempty"`
|
|
||||||
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
|
|
||||||
CheckoutURLAvailable bool `json:"checkoutUrlAvailable"`
|
|
||||||
SyncAvailable bool `json:"syncAvailable"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaymentMethod struct {
|
|
||||||
Brand string `json:"brand"`
|
|
||||||
Last4 string `json:"last4"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserIdentity struct {
|
|
||||||
ID string
|
|
||||||
Email string
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
type userCustomerMapping struct {
|
|
||||||
CustomerID string `json:"customerId"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewService(cfg *config.Config, database *db.DB) *Service {
|
|
||||||
return &Service{cfg: cfg, db: database}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) GetSubscription(ctx context.Context, userID string) (SubscriptionSnapshot, error) {
|
|
||||||
mapping, ok, err := s.getCustomerMapping(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return SubscriptionSnapshot{}, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return s.noneSnapshot(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot, ok, err := s.getCustomerSnapshot(ctx, mapping.CustomerID)
|
|
||||||
if err != nil {
|
|
||||||
return SubscriptionSnapshot{}, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
snapshot = SubscriptionSnapshot{
|
|
||||||
CustomerID: mapping.CustomerID,
|
|
||||||
Status: "none",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
snapshot.CheckoutURLAvailable = s.checkoutAvailableForPlan(snapshot.PlanCode)
|
|
||||||
snapshot.SyncAvailable = s.cfg.StripeSecretConfigured()
|
|
||||||
return snapshot, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) CreateCheckoutSession(ctx context.Context, user UserIdentity, planCode string, currency string) (CheckoutSession, error) {
|
|
||||||
priceID, resolvedPlanCode, resolvedCurrency, err := s.priceForPlan(planCode, currency)
|
|
||||||
if err != nil {
|
|
||||||
return CheckoutSession{}, err
|
|
||||||
}
|
|
||||||
if s.cfg.StripeSecretKey == "" {
|
|
||||||
return CheckoutSession{}, ErrStripeNotConfigured
|
|
||||||
}
|
|
||||||
|
|
||||||
customerID, err := s.ensureCustomer(ctx, user)
|
|
||||||
if err != nil {
|
|
||||||
return CheckoutSession{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
stripe.Key = s.cfg.StripeSecretKey
|
|
||||||
params := &stripe.CheckoutSessionParams{
|
|
||||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
|
||||||
Customer: stripe.String(customerID),
|
|
||||||
ClientReferenceID: stripe.String(user.ID),
|
|
||||||
SuccessURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=success", strings.TrimRight(s.cfg.FrontendURL, "/"))),
|
|
||||||
CancelURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=cancelled", strings.TrimRight(s.cfg.FrontendURL, "/"))),
|
|
||||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
|
||||||
{
|
|
||||||
Price: stripe.String(priceID),
|
|
||||||
Quantity: stripe.Int64(1),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Metadata: map[string]string{
|
|
||||||
"user_id": user.ID,
|
|
||||||
"plan_code": resolvedPlanCode,
|
|
||||||
"currency": resolvedCurrency,
|
|
||||||
},
|
|
||||||
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
|
|
||||||
TrialPeriodDays: stripe.Int64(30),
|
|
||||||
Metadata: map[string]string{
|
|
||||||
"user_id": user.ID,
|
|
||||||
"plan_code": resolvedPlanCode,
|
|
||||||
"currency": resolvedCurrency,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
checkoutSession, err := session.New(params)
|
|
||||||
if err != nil {
|
|
||||||
return CheckoutSession{}, err
|
|
||||||
}
|
|
||||||
return CheckoutSession{URL: checkoutSession.URL}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) Refresh(ctx context.Context, userID string) (SubscriptionSnapshot, error) {
|
|
||||||
mapping, ok, err := s.getCustomerMapping(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return SubscriptionSnapshot{}, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return s.noneSnapshot(), nil
|
|
||||||
}
|
|
||||||
if s.cfg.StripeSecretKey == "" {
|
|
||||||
return SubscriptionSnapshot{}, ErrStripeNotConfigured
|
|
||||||
}
|
|
||||||
return s.syncStripeDataToKV(ctx, mapping.CustomerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) HandleWebhook(ctx context.Context, signature string, payload []byte) error {
|
|
||||||
if s.cfg.StripeSecretKey == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if s.cfg.StripeWebhookSecret == "" {
|
|
||||||
return ErrStripeWebhookMissing
|
|
||||||
}
|
|
||||||
if signature == "" {
|
|
||||||
return ErrStripeSignatureMissing
|
|
||||||
}
|
|
||||||
|
|
||||||
event, err := webhook.ConstructEvent(payload, signature, s.cfg.StripeWebhookSecret)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !slices.Contains(allowedWebhookEvents, string(event.Type)) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
customerID := extractCustomerID(event)
|
|
||||||
if customerID == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = s.syncStripeDataToKV(ctx, customerID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ensureCustomer(ctx context.Context, user UserIdentity) (string, error) {
|
|
||||||
mapping, ok, err := s.getCustomerMapping(ctx, user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if ok && mapping.CustomerID != "" {
|
|
||||||
return mapping.CustomerID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
stripe.Key = s.cfg.StripeSecretKey
|
|
||||||
params := &stripe.CustomerParams{
|
|
||||||
Email: stripe.String(user.Email),
|
|
||||||
Metadata: map[string]string{
|
|
||||||
"user_id": user.ID,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(user.Name) != "" {
|
|
||||||
params.Name = stripe.String(strings.TrimSpace(user.Name))
|
|
||||||
}
|
|
||||||
|
|
||||||
createdCustomer, err := customer.New(params)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.storeCustomerMapping(ctx, user.ID, createdCustomer.ID); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return createdCustomer.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) syncStripeDataToKV(ctx context.Context, customerID string) (SubscriptionSnapshot, error) {
|
|
||||||
stripe.Key = s.cfg.StripeSecretKey
|
|
||||||
params := &stripe.SubscriptionListParams{Customer: stripe.String(customerID)}
|
|
||||||
params.Status = stripe.String("all")
|
|
||||||
params.AddExpand("data.default_payment_method")
|
|
||||||
params.AddExpand("data.items.data.price")
|
|
||||||
|
|
||||||
iter := subscription.List(params)
|
|
||||||
selected := (*stripe.Subscription)(nil)
|
|
||||||
for iter.Next() {
|
|
||||||
current := iter.Subscription()
|
|
||||||
if selected == nil || subscriptionRank(current) > subscriptionRank(selected) {
|
|
||||||
selected = current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if iter.Err() != nil {
|
|
||||||
return SubscriptionSnapshot{}, iter.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
snapshot := SubscriptionSnapshot{
|
|
||||||
CustomerID: customerID,
|
|
||||||
Status: "none",
|
|
||||||
LastSyncedAt: &now,
|
|
||||||
CheckoutURLAvailable: s.cfg.StripeCheckoutReady(),
|
|
||||||
SyncAvailable: s.cfg.StripeSecretConfigured(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if selected != nil {
|
|
||||||
snapshot.SubscriptionID = selected.ID
|
|
||||||
snapshot.Status = string(selected.Status)
|
|
||||||
snapshot.CancelAtPeriodEnd = selected.CancelAtPeriodEnd
|
|
||||||
if len(selected.Items.Data) > 0 {
|
|
||||||
item := selected.Items.Data[0]
|
|
||||||
if item.Price != nil {
|
|
||||||
snapshot.PriceID = item.Price.ID
|
|
||||||
snapshot.PlanCode = s.planCodeForPrice(snapshot.PriceID)
|
|
||||||
snapshot.Currency = normalizeCurrency(string(item.Price.Currency))
|
|
||||||
}
|
|
||||||
snapshot.CurrentPeriodStart = unixPtr(item.CurrentPeriodStart)
|
|
||||||
snapshot.CurrentPeriodEnd = unixPtr(item.CurrentPeriodEnd)
|
|
||||||
}
|
|
||||||
if selected.DefaultPaymentMethod != nil && selected.DefaultPaymentMethod.Card != nil {
|
|
||||||
snapshot.PaymentMethod = &PaymentMethod{
|
|
||||||
Brand: string(selected.DefaultPaymentMethod.Card.Brand),
|
|
||||||
Last4: selected.DefaultPaymentMethod.Card.Last4,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.PutKV(ctx, customerSnapshotKey(customerID), snapshot); err != nil {
|
|
||||||
return SubscriptionSnapshot{}, err
|
|
||||||
}
|
|
||||||
return snapshot, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) storeCustomerMapping(ctx context.Context, userID string, customerID string) error {
|
|
||||||
mapping := userCustomerMapping{
|
|
||||||
CustomerID: customerID,
|
|
||||||
UpdatedAt: time.Now().UTC(),
|
|
||||||
}
|
|
||||||
return s.db.PutKV(ctx, userCustomerKey(userID), mapping)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) getCustomerMapping(ctx context.Context, userID string) (userCustomerMapping, bool, error) {
|
|
||||||
var mapping userCustomerMapping
|
|
||||||
ok, err := s.db.GetKV(ctx, userCustomerKey(userID), &mapping)
|
|
||||||
if err != nil {
|
|
||||||
return userCustomerMapping{}, false, err
|
|
||||||
}
|
|
||||||
if !ok || mapping.CustomerID == "" {
|
|
||||||
return userCustomerMapping{}, false, nil
|
|
||||||
}
|
|
||||||
return mapping, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) getCustomerSnapshot(ctx context.Context, customerID string) (SubscriptionSnapshot, bool, error) {
|
|
||||||
var snapshot SubscriptionSnapshot
|
|
||||||
ok, err := s.db.GetKV(ctx, customerSnapshotKey(customerID), &snapshot)
|
|
||||||
if err != nil {
|
|
||||||
return SubscriptionSnapshot{}, false, err
|
|
||||||
}
|
|
||||||
return snapshot, ok, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) noneSnapshot() SubscriptionSnapshot {
|
|
||||||
return SubscriptionSnapshot{
|
|
||||||
Status: "none",
|
|
||||||
CheckoutURLAvailable: s.cfg.StripeCheckoutReady(),
|
|
||||||
SyncAvailable: s.cfg.StripeSecretConfigured(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) priceForPlan(planCode string, currency string) (string, string, string, error) {
|
|
||||||
planCode = normalizePlanCode(strings.TrimSpace(planCode))
|
|
||||||
if planCode == "" {
|
|
||||||
planCode = s.defaultPlanCode()
|
|
||||||
}
|
|
||||||
if planCode == "" {
|
|
||||||
return "", "", "", ErrPlanNotConfigured
|
|
||||||
}
|
|
||||||
resolvedCurrency := normalizeCurrency(currency)
|
|
||||||
priceID := strings.TrimSpace(s.cfg.StripePriceIDs[planCode+":"+resolvedCurrency])
|
|
||||||
if priceID == "" && resolvedCurrency != "czk" {
|
|
||||||
priceID = strings.TrimSpace(s.cfg.StripePriceIDs[planCode+":czk"])
|
|
||||||
if priceID != "" {
|
|
||||||
resolvedCurrency = "czk"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if priceID == "" {
|
|
||||||
priceID = strings.TrimSpace(s.cfg.StripePriceIDs[planCode])
|
|
||||||
}
|
|
||||||
if priceID == "" {
|
|
||||||
switch planCode {
|
|
||||||
case "pro":
|
|
||||||
priceID = strings.TrimSpace(s.cfg.StripePriceIDs["growth"])
|
|
||||||
case "business":
|
|
||||||
priceID = strings.TrimSpace(s.cfg.StripePriceIDs["multi-location"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if priceID == "" {
|
|
||||||
return "", "", "", ErrPlanNotConfigured
|
|
||||||
}
|
|
||||||
return priceID, planCode, resolvedCurrency, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) defaultPlanCode() string {
|
|
||||||
for _, planCode := range []string{"pro", "monthly", "growth", "starter", "business", "multi-location"} {
|
|
||||||
if strings.TrimSpace(s.cfg.StripePriceIDs[planCode]) != "" {
|
|
||||||
return normalizePlanCode(planCode)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(s.cfg.StripePriceIDs[normalizePlanCode(planCode)+":czk"]) != "" {
|
|
||||||
return normalizePlanCode(planCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) planCodeForPrice(priceID string) string {
|
|
||||||
for planCode, configuredPriceID := range s.cfg.StripePriceIDs {
|
|
||||||
if strings.TrimSpace(configuredPriceID) == priceID {
|
|
||||||
return normalizePlanCode(strings.Split(planCode, ":")[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) hasConfiguredPrices() bool {
|
|
||||||
return s.defaultPlanCode() != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) checkoutAvailableForPlan(planCode string) bool {
|
|
||||||
if !s.cfg.StripeSecretConfigured() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(planCode) == "" {
|
|
||||||
return s.hasConfiguredPrices()
|
|
||||||
}
|
|
||||||
_, _, _, err := s.priceForPlan(planCode, "czk")
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizePlanCode(planCode string) string {
|
|
||||||
switch planCode {
|
|
||||||
case "growth":
|
|
||||||
return "pro"
|
|
||||||
case "multi-location":
|
|
||||||
return "business"
|
|
||||||
default:
|
|
||||||
return planCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeCurrency(currency string) string {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(currency)) {
|
|
||||||
case "usd":
|
|
||||||
return "usd"
|
|
||||||
default:
|
|
||||||
return "czk"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func userCustomerKey(userID string) string {
|
|
||||||
return "stripe:user:" + userID
|
|
||||||
}
|
|
||||||
|
|
||||||
func customerSnapshotKey(customerID string) string {
|
|
||||||
return "stripe:customer:" + customerID
|
|
||||||
}
|
|
||||||
|
|
||||||
func unixPtr(value int64) *time.Time {
|
|
||||||
if value == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
t := time.Unix(value, 0).UTC()
|
|
||||||
return &t
|
|
||||||
}
|
|
||||||
|
|
||||||
func subscriptionRank(subscription *stripe.Subscription) int {
|
|
||||||
switch subscription.Status {
|
|
||||||
case stripe.SubscriptionStatusActive:
|
|
||||||
return 100
|
|
||||||
case stripe.SubscriptionStatusTrialing:
|
|
||||||
return 90
|
|
||||||
case stripe.SubscriptionStatusPastDue:
|
|
||||||
return 80
|
|
||||||
case stripe.SubscriptionStatusUnpaid:
|
|
||||||
return 70
|
|
||||||
case stripe.SubscriptionStatusIncomplete:
|
|
||||||
return 60
|
|
||||||
case stripe.SubscriptionStatusPaused:
|
|
||||||
return 50
|
|
||||||
case stripe.SubscriptionStatusCanceled:
|
|
||||||
return 10
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractCustomerID(event stripe.Event) string {
|
|
||||||
var payload map[string]any
|
|
||||||
if err := json.Unmarshal(event.Data.Raw, &payload); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
value, ok := payload["customer"]
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
customerID, _ := value.(string)
|
|
||||||
return customerID
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package billing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"bookra/apps/auth-service/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPriceForPlanUsesConfiguredPlanCodesOnly(t *testing.T) {
|
|
||||||
service := NewService(&config.Config{
|
|
||||||
StripePriceIDs: map[string]string{
|
|
||||||
"monthly": "price_monthly",
|
|
||||||
"growth": "price_growth",
|
|
||||||
},
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
priceID, planCode, currency, err := service.priceForPlan("growth", "czk")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("price for plan: %v", err)
|
|
||||||
}
|
|
||||||
if priceID != "price_growth" || planCode != "pro" || currency != "czk" {
|
|
||||||
t.Fatalf("expected pro mapping, got price=%q plan=%q currency=%q", priceID, planCode, currency)
|
|
||||||
}
|
|
||||||
|
|
||||||
priceID, planCode, currency, err = service.priceForPlan("", "usd")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("default price for plan: %v", err)
|
|
||||||
}
|
|
||||||
if priceID != "price_monthly" || planCode != "monthly" || currency != "usd" {
|
|
||||||
t.Fatalf("expected monthly default, got price=%q plan=%q currency=%q", priceID, planCode, currency)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, _, err = service.priceForPlan("price_attacker_controlled", "czk")
|
|
||||||
if !errors.Is(err, ErrPlanNotConfigured) {
|
|
||||||
t.Fatalf("expected ErrPlanNotConfigured, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKVKeyShape(t *testing.T) {
|
|
||||||
if got := userCustomerKey("user_123"); got != "stripe:user:user_123" {
|
|
||||||
t.Fatalf("unexpected user key: %s", got)
|
|
||||||
}
|
|
||||||
if got := customerSnapshotKey("cus_123"); got != "stripe:customer:cus_123" {
|
|
||||||
t.Fatalf("unexpected customer key: %s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckoutAvailableForPlanRequiresSecret(t *testing.T) {
|
|
||||||
service := NewService(&config.Config{
|
|
||||||
StripePriceIDs: map[string]string{
|
|
||||||
"pro:czk": "price_pro_czk",
|
|
||||||
},
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
if service.checkoutAvailableForPlan("pro") {
|
|
||||||
t.Fatal("expected checkout unavailable without stripe secret")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckoutAvailableForPlanRequiresConfiguredPlan(t *testing.T) {
|
|
||||||
service := NewService(&config.Config{
|
|
||||||
StripeSecretKey: "sk_test_123",
|
|
||||||
StripePriceIDs: map[string]string{
|
|
||||||
"pro:czk": "price_pro_czk",
|
|
||||||
},
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
if !service.checkoutAvailableForPlan("pro") {
|
|
||||||
t.Fatal("expected pro checkout available")
|
|
||||||
}
|
|
||||||
if service.checkoutAvailableForPlan("business") {
|
|
||||||
t.Fatal("expected business checkout unavailable without configured price")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
AppEnv string
|
|
||||||
Port string
|
|
||||||
DatabaseURL string
|
|
||||||
FrontendURL string
|
|
||||||
JWTSecret string
|
|
||||||
NeonAuthURL string
|
|
||||||
|
|
||||||
SMTPHost string
|
|
||||||
SMTPPort int
|
|
||||||
SMTPUsername string
|
|
||||||
SMTPPassword string
|
|
||||||
EmailFrom string
|
|
||||||
|
|
||||||
GoogleClientID string
|
|
||||||
GoogleClientSecret string
|
|
||||||
GoogleRedirectURL string
|
|
||||||
|
|
||||||
StripeSecretKey string
|
|
||||||
StripeWebhookSecret string
|
|
||||||
StripePriceIDs map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
|
||||||
port := getEnv("PORT", "8081")
|
|
||||||
|
|
||||||
dbURL := getEnv("DATABASE_URL", "")
|
|
||||||
if dbURL == "" {
|
|
||||||
return nil, fmt.Errorf("DATABASE_URL is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
smtpPort, _ := strconv.Atoi(getEnv("SMTP_PORT", "465"))
|
|
||||||
|
|
||||||
return &Config{
|
|
||||||
AppEnv: getEnv("APP_ENV", "development"),
|
|
||||||
Port: port,
|
|
||||||
DatabaseURL: dbURL,
|
|
||||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
|
||||||
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
|
|
||||||
NeonAuthURL: getEnv("NEON_AUTH_URL", ""),
|
|
||||||
|
|
||||||
SMTPHost: getEnv("SMTP_HOST", "smtp.purelymail.com"),
|
|
||||||
SMTPPort: smtpPort,
|
|
||||||
SMTPUsername: getEnvAllowEmpty("SMTP_USERNAME", "noreply@tdvorak.dev"),
|
|
||||||
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
|
|
||||||
EmailFrom: getEnv("EMAIL_FROM", "noreply@tdvorak.dev"),
|
|
||||||
|
|
||||||
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
|
|
||||||
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
|
|
||||||
GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", ""),
|
|
||||||
|
|
||||||
StripeSecretKey: getEnv("STRIPE_SECRET_KEY", ""),
|
|
||||||
StripeWebhookSecret: getEnv("STRIPE_WEBHOOK_SECRET", ""),
|
|
||||||
StripePriceIDs: map[string]string{
|
|
||||||
"monthly": getEnv("STRIPE_PRICE_ID", ""),
|
|
||||||
"starter": getEnv("STRIPE_STARTER_PRICE_ID", ""),
|
|
||||||
"growth": getEnv("STRIPE_GROWTH_PRICE_ID", ""),
|
|
||||||
"multi-location": getEnv("STRIPE_MULTI_LOCATION_PRICE_ID", ""),
|
|
||||||
"pro": getEnv("STRIPE_PRO_PRICE_ID", ""),
|
|
||||||
"business": getEnv("STRIPE_BUSINESS_PRICE_ID", ""),
|
|
||||||
"starter:czk": getEnv("STRIPE_STARTER_CZK_PRICE_ID", ""),
|
|
||||||
"starter:usd": getEnv("STRIPE_STARTER_USD_PRICE_ID", ""),
|
|
||||||
"pro:czk": getEnv("STRIPE_PRO_CZK_PRICE_ID", ""),
|
|
||||||
"pro:usd": getEnv("STRIPE_PRO_USD_PRICE_ID", ""),
|
|
||||||
"business:czk": getEnv("STRIPE_BUSINESS_CZK_PRICE_ID", ""),
|
|
||||||
"business:usd": getEnv("STRIPE_BUSINESS_USD_PRICE_ID", ""),
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnv(key, defaultVal string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnvAllowEmpty(key, defaultVal string) string {
|
|
||||||
if v, ok := os.LookupEnv(key); ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) StripeSecretConfigured() bool {
|
|
||||||
return strings.TrimSpace(cfg.StripeSecretKey) != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) StripeWebhookConfigured() bool {
|
|
||||||
return strings.TrimSpace(cfg.StripeWebhookSecret) != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) StripeHasAnyPriceConfigured() bool {
|
|
||||||
for _, priceID := range cfg.StripePriceIDs {
|
|
||||||
if strings.TrimSpace(priceID) != "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) StripeCheckoutReady() bool {
|
|
||||||
return cfg.StripeSecretConfigured() && cfg.StripeHasAnyPriceConfigured()
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestStripeReadinessHelpers(t *testing.T) {
|
|
||||||
cfg := &Config{
|
|
||||||
StripeSecretKey: "sk_test_123",
|
|
||||||
StripePriceIDs: map[string]string{
|
|
||||||
"pro:czk": "price_123",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if !cfg.StripeSecretConfigured() {
|
|
||||||
t.Fatal("expected secret configured")
|
|
||||||
}
|
|
||||||
if cfg.StripeWebhookConfigured() {
|
|
||||||
t.Fatal("expected webhook not configured")
|
|
||||||
}
|
|
||||||
if !cfg.StripeHasAnyPriceConfigured() {
|
|
||||||
t.Fatal("expected prices configured")
|
|
||||||
}
|
|
||||||
if !cfg.StripeCheckoutReady() {
|
|
||||||
t.Fatal("expected checkout ready")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStripeCheckoutReadyRequiresSecretAndPrice(t *testing.T) {
|
|
||||||
cfg := &Config{
|
|
||||||
StripePriceIDs: map[string]string{
|
|
||||||
"pro:czk": "price_123",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if cfg.StripeCheckoutReady() {
|
|
||||||
t.Fatal("expected checkout not ready without secret")
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.StripeSecretKey = "sk_test_123"
|
|
||||||
cfg.StripePriceIDs = map[string]string{}
|
|
||||||
if cfg.StripeCheckoutReady() {
|
|
||||||
t.Fatal("expected checkout not ready without price")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadDefaultsAuthServicePortTo8081(t *testing.T) {
|
|
||||||
originals := map[string]string{}
|
|
||||||
for _, key := range []string{
|
|
||||||
"PORT",
|
|
||||||
"DATABASE_URL",
|
|
||||||
} {
|
|
||||||
originals[key] = os.Getenv(key)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
for key, value := range originals {
|
|
||||||
if value == "" {
|
|
||||||
_ = os.Unsetenv(key)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_ = os.Setenv(key, value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
_ = os.Unsetenv("PORT")
|
|
||||||
_ = os.Setenv("DATABASE_URL", "postgresql://localhost/bookra")
|
|
||||||
|
|
||||||
cfg, err := Load()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load config: %v", err)
|
|
||||||
}
|
|
||||||
if cfg.Port != "8081" {
|
|
||||||
t.Fatalf("expected default port 8081, got %s", cfg.Port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DB struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(databaseURL string) (*DB, error) {
|
|
||||||
config, err := pgxpool.ParseConfig(databaseURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse database config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pool, err := pgxpool.NewWithConfig(context.Background(), config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create pool: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := pool.Ping(context.Background()); err != nil {
|
|
||||||
return nil, fmt.Errorf("ping database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &DB{pool: pool}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) Close() {
|
|
||||||
db.pool.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) Pool() *pgxpool.Pool {
|
|
||||||
return db.pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
|
|
||||||
return db.pool.QueryRow(ctx, sql, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
|
|
||||||
return db.pool.Query(ctx, sql, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) Exec(ctx context.Context, sql string, args ...interface{}) error {
|
|
||||||
_, err := db.pool.Exec(ctx, sql, args...)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stats contains database statistics for the admin dashboard
|
|
||||||
type Stats struct {
|
|
||||||
TotalUsers int64 `json:"totalUsers"`
|
|
||||||
UsersToday int64 `json:"usersToday"`
|
|
||||||
UsersThisWeek int64 `json:"usersThisWeek"`
|
|
||||||
UsersThisMonth int64 `json:"usersThisMonth"`
|
|
||||||
ActiveUsers7Days int64 `json:"activeUsers7Days"`
|
|
||||||
ActiveUsers30Days int64 `json:"activeUsers30Days"`
|
|
||||||
MagicLinksSent int64 `json:"magicLinksSent"`
|
|
||||||
MagicLinksUsed int64 `json:"magicLinksUsed"`
|
|
||||||
MagicLinksPending int64 `json:"magicLinksPending"`
|
|
||||||
OAuthUsers int64 `json:"oauthUsers"`
|
|
||||||
PasswordUsers int64 `json:"passwordUsers"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStats returns database statistics for the admin dashboard
|
|
||||||
func (db *DB) GetStats(ctx context.Context) (*Stats, error) {
|
|
||||||
stats := &Stats{}
|
|
||||||
|
|
||||||
// Total users
|
|
||||||
err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Users created today
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE`).Scan(&stats.UsersToday)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Users created this week
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '7 days'`).Scan(&stats.UsersThisWeek)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Users created this month
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'`).Scan(&stats.UsersThisMonth)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active users (logged in) in last 7 days
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE last_login_at >= CURRENT_DATE - INTERVAL '7 days'`).Scan(&stats.ActiveUsers7Days)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active users in last 30 days
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE last_login_at >= CURRENT_DATE - INTERVAL '30 days'`).Scan(&stats.ActiveUsers30Days)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Magic links sent (total)
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM magic_links`).Scan(&stats.MagicLinksSent)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Magic links used
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM magic_links WHERE used = TRUE`).Scan(&stats.MagicLinksUsed)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pending magic links
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM magic_links WHERE used = FALSE AND expires_at > NOW()`).Scan(&stats.MagicLinksPending)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuth users
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE provider != 'email'`).Scan(&stats.OAuthUsers)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Password users
|
|
||||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE password_hash IS NOT NULL`).Scan(&stats.PasswordUsers)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats, nil
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
type User struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Name *string `json:"name,omitempty"`
|
|
||||||
PasswordHash *string `json:"-"`
|
|
||||||
EmailVerified bool `json:"email_verified"`
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
ProviderID *string `json:"provider_id,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MagicLink struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
UserID uuid.UUID `json:"user_id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Used bool `json:"used"`
|
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
|
||||||
var user User
|
|
||||||
var name, passwordHash, providerID *string
|
|
||||||
var lastLoginAt *time.Time
|
|
||||||
|
|
||||||
err := db.QueryRow(ctx, `
|
|
||||||
SELECT id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at, last_login_at
|
|
||||||
FROM users
|
|
||||||
WHERE email = $1
|
|
||||||
`, email).Scan(
|
|
||||||
&user.ID, &user.Email, &name, &passwordHash,
|
|
||||||
&user.EmailVerified, &user.Provider, &providerID,
|
|
||||||
&user.CreatedAt, &user.UpdatedAt, &lastLoginAt,
|
|
||||||
)
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
user.Name = name
|
|
||||||
user.PasswordHash = passwordHash
|
|
||||||
user.ProviderID = providerID
|
|
||||||
user.LastLoginAt = lastLoginAt
|
|
||||||
return &user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) GetUserByID(ctx context.Context, id uuid.UUID) (*User, error) {
|
|
||||||
var user User
|
|
||||||
var name, passwordHash, providerID *string
|
|
||||||
var lastLoginAt *time.Time
|
|
||||||
|
|
||||||
err := db.QueryRow(ctx, `
|
|
||||||
SELECT id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at, last_login_at
|
|
||||||
FROM users
|
|
||||||
WHERE id = $1
|
|
||||||
`, id).Scan(
|
|
||||||
&user.ID, &user.Email, &name, &passwordHash,
|
|
||||||
&user.EmailVerified, &user.Provider, &providerID,
|
|
||||||
&user.CreatedAt, &user.UpdatedAt, &lastLoginAt,
|
|
||||||
)
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
user.Name = name
|
|
||||||
user.PasswordHash = passwordHash
|
|
||||||
user.ProviderID = providerID
|
|
||||||
user.LastLoginAt = lastLoginAt
|
|
||||||
return &user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) GetUserByProviderID(ctx context.Context, provider, providerID string) (*User, error) {
|
|
||||||
var user User
|
|
||||||
var name, passwordHash *string
|
|
||||||
var lastLoginAt *time.Time
|
|
||||||
|
|
||||||
err := db.QueryRow(ctx, `
|
|
||||||
SELECT id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at, last_login_at
|
|
||||||
FROM users
|
|
||||||
WHERE provider = $1 AND provider_id = $2
|
|
||||||
`, provider, providerID).Scan(
|
|
||||||
&user.ID, &user.Email, &name, &passwordHash,
|
|
||||||
&user.EmailVerified, &user.Provider, &user.ProviderID,
|
|
||||||
&user.CreatedAt, &user.UpdatedAt, &lastLoginAt,
|
|
||||||
)
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
user.Name = name
|
|
||||||
user.PasswordHash = passwordHash
|
|
||||||
user.LastLoginAt = lastLoginAt
|
|
||||||
return &user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) CreateUser(ctx context.Context, user *User) (*User, error) {
|
|
||||||
if user.ID == uuid.Nil {
|
|
||||||
user.ID = uuid.Must(uuid.NewV7())
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
user.CreatedAt = now
|
|
||||||
user.UpdatedAt = now
|
|
||||||
|
|
||||||
_, err := db.pool.Exec(ctx, `
|
|
||||||
INSERT INTO users (id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
|
|
||||||
`, user.ID, user.Email, user.Name, user.PasswordHash, user.EmailVerified, user.Provider, user.ProviderID, now)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) UpdateUser(ctx context.Context, user *User) error {
|
|
||||||
user.UpdatedAt = time.Now()
|
|
||||||
|
|
||||||
_, err := db.pool.Exec(ctx, `
|
|
||||||
UPDATE users
|
|
||||||
SET email = $2, name = $3, password_hash = $4, email_verified = $5,
|
|
||||||
provider = $6, provider_id = $7, updated_at = $8, last_login_at = $9
|
|
||||||
WHERE id = $1
|
|
||||||
`, user.ID, user.Email, user.Name, user.PasswordHash, user.EmailVerified,
|
|
||||||
user.Provider, user.ProviderID, user.UpdatedAt, user.LastLoginAt)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) UpdateLastLogin(ctx context.Context, userID uuid.UUID) error {
|
|
||||||
_, err := db.pool.Exec(ctx, `
|
|
||||||
UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1
|
|
||||||
`, userID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) CreateMagicLink(ctx context.Context, token string, email string, userID uuid.UUID, expiresAt time.Time) error {
|
|
||||||
_, err := db.pool.Exec(ctx, `
|
|
||||||
INSERT INTO magic_links (token, user_id, email, expires_at, created_at)
|
|
||||||
VALUES ($1, $2, $3, $4, NOW())
|
|
||||||
`, token, userID, email, expiresAt)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) GetMagicLink(ctx context.Context, token string) (*MagicLink, error) {
|
|
||||||
var ml MagicLink
|
|
||||||
var userID uuid.UUID
|
|
||||||
|
|
||||||
err := db.QueryRow(ctx, `
|
|
||||||
SELECT token, user_id, email, used, expires_at, created_at
|
|
||||||
FROM magic_links
|
|
||||||
WHERE token = $1
|
|
||||||
`, token).Scan(&ml.Token, &userID, &ml.Email, &ml.Used, &ml.ExpiresAt, &ml.CreatedAt)
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ml.UserID = userID
|
|
||||||
return &ml, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) MarkMagicLinkUsed(ctx context.Context, token string) error {
|
|
||||||
_, err := db.pool.Exec(ctx, `UPDATE magic_links SET used = true WHERE token = $1`, token)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) PutKV(ctx context.Context, key string, value any) error {
|
|
||||||
payload, err := json.Marshal(value)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal kv value: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.pool.Exec(ctx, `
|
|
||||||
INSERT INTO stripe_kv (key, value, created_at, updated_at)
|
|
||||||
VALUES ($1, $2, NOW(), NOW())
|
|
||||||
ON CONFLICT (key) DO UPDATE
|
|
||||||
SET value = EXCLUDED.value, updated_at = NOW()
|
|
||||||
`, key, payload)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) GetKV(ctx context.Context, key string, dest any) (bool, error) {
|
|
||||||
var payload []byte
|
|
||||||
err := db.QueryRow(ctx, `
|
|
||||||
SELECT value
|
|
||||||
FROM stripe_kv
|
|
||||||
WHERE key = $1
|
|
||||||
`, key).Scan(&payload)
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(payload, dest); err != nil {
|
|
||||||
return false, fmt.Errorf("unmarshal kv value: %w", err)
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package email
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/tls"
|
|
||||||
"fmt"
|
|
||||||
"net/smtp"
|
|
||||||
|
|
||||||
"github.com/jordan-wright/email"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Host string
|
|
||||||
Port int
|
|
||||||
Username string
|
|
||||||
Password string
|
|
||||||
From string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Service struct {
|
|
||||||
config Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(config Config) *Service {
|
|
||||||
return &Service{config: config}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendMagicLink sends a magic link authentication email with proper branding
|
|
||||||
func (s *Service) SendMagicLink(toEmail, toName, linkURL, locale string) error {
|
|
||||||
template := MagicLinkEmail(toName, linkURL, locale)
|
|
||||||
return s.sendTemplate(toEmail, template)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendWelcomeEmail sends a welcome email to new users
|
|
||||||
func (s *Service) SendWelcomeEmail(toEmail, name, locale string) error {
|
|
||||||
template := WelcomeEmail(name, locale)
|
|
||||||
return s.sendTemplate(toEmail, template)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendBookingConfirmation sends booking confirmation to customers
|
|
||||||
func (s *Service) SendBookingConfirmation(toEmail, customerName, businessName, serviceName, dateTime, location, locale string) error {
|
|
||||||
template := BookingConfirmationEmail(customerName, businessName, serviceName, dateTime, location, locale)
|
|
||||||
return s.sendTemplate(toEmail, template)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendPasswordReset sends password reset email
|
|
||||||
func (s *Service) SendPasswordReset(toEmail, name, resetURL, locale string) error {
|
|
||||||
template := PasswordResetEmail(name, resetURL, locale)
|
|
||||||
return s.sendTemplate(toEmail, template)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendTemplate sends an email using the provided template
|
|
||||||
func (s *Service) sendTemplate(toEmail string, template EmailTemplate) error {
|
|
||||||
e := email.NewEmail()
|
|
||||||
e.From = fmt.Sprintf("Bookra <%s>", s.config.From)
|
|
||||||
e.To = []string{toEmail}
|
|
||||||
e.Subject = template.Subject
|
|
||||||
e.Text = []byte(template.Text)
|
|
||||||
e.HTML = []byte(template.HTML)
|
|
||||||
|
|
||||||
return s.send(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// send delivers the email via SMTP
|
|
||||||
func (s *Service) send(e *email.Email) error {
|
|
||||||
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
|
|
||||||
|
|
||||||
var auth smtp.Auth
|
|
||||||
if s.config.Username != "" {
|
|
||||||
auth = smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.config.Port == 465 {
|
|
||||||
return e.SendWithTLS(addr, auth, &tls.Config{ServerName: s.config.Host})
|
|
||||||
}
|
|
||||||
|
|
||||||
return e.Send(addr, auth)
|
|
||||||
}
|
|
||||||
@@ -1,743 +0,0 @@
|
|||||||
package email
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Bookra Design System - Warm editorial aesthetic
|
|
||||||
// Canvas: warm cream backgrounds (#fbf9f6)
|
|
||||||
// Ink: warm dark brown (#2a221e)
|
|
||||||
// Accent: terracotta (#a65c3e)
|
|
||||||
// Logo bg: #24201d, Logo text: #f7f2e8
|
|
||||||
const (
|
|
||||||
canvas = "#fbf9f6" // Warm cream background
|
|
||||||
canvasSubtle = "#f5f2ed" // Slightly darker cream
|
|
||||||
ink = "#2a221e" // Warm dark brown
|
|
||||||
inkMuted = "#5c514a" // Muted brown
|
|
||||||
inkSubtle = "#8b7f76" // Light muted brown
|
|
||||||
accent = "#a65c3e" // Terracotta
|
|
||||||
accentHover = "#8f4d33" // Darker terracotta
|
|
||||||
accentSubtle = "#f5ebe7" // Light terracotta tint
|
|
||||||
logoBg = "#24201d" // Logo dark brown
|
|
||||||
logoText = "#f7f2e8" // Logo cream
|
|
||||||
border = "#e8e2da" // Warm border
|
|
||||||
white = "#ffffff"
|
|
||||||
)
|
|
||||||
|
|
||||||
type EmailTemplate struct {
|
|
||||||
Subject string
|
|
||||||
HTML string
|
|
||||||
Text string
|
|
||||||
}
|
|
||||||
|
|
||||||
func MagicLinkEmail(toName, magicURL string, locale string) EmailTemplate {
|
|
||||||
if locale == "cs" {
|
|
||||||
return magicLinkEmailCS(toName, magicURL)
|
|
||||||
}
|
|
||||||
return magicLinkEmailEN(toName, magicURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func WelcomeEmail(name string, locale string) EmailTemplate {
|
|
||||||
if locale == "cs" {
|
|
||||||
return welcomeEmailCS(name)
|
|
||||||
}
|
|
||||||
return welcomeEmailEN(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func BookingConfirmationEmail(customerName, businessName, serviceName, dateTime, location string, locale string) EmailTemplate {
|
|
||||||
if locale == "cs" {
|
|
||||||
return bookingConfirmationCS(customerName, businessName, serviceName, dateTime, location)
|
|
||||||
}
|
|
||||||
return bookingConfirmationEN(customerName, businessName, serviceName, dateTime, location)
|
|
||||||
}
|
|
||||||
|
|
||||||
func PasswordResetEmail(name, resetURL string, locale string) EmailTemplate {
|
|
||||||
if locale == "cs" {
|
|
||||||
return passwordResetCS(name, resetURL)
|
|
||||||
}
|
|
||||||
return passwordResetEN(name, resetURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func magicLinkEmailEN(toName, magicURL string) EmailTemplate {
|
|
||||||
subject := "Your sign-in link for Bookra"
|
|
||||||
if toName == "" {
|
|
||||||
toName = "there"
|
|
||||||
}
|
|
||||||
|
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
|
||||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
|
||||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
|
||||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
|
|
||||||
.tagline { color: %s; font-size: 15px; margin-top: 6px; font-style: italic; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
|
||||||
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
|
||||||
.button-wrap { margin: 40px 0; }
|
|
||||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; transition: background 0.2s; }
|
|
||||||
.button:hover { background: %s; }
|
|
||||||
.link-box { background: %s; border: 1px solid %s; border-radius: 8px; padding: 20px; margin: 32px 0; }
|
|
||||||
.link-label { font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: 'Space Grotesk', sans-serif; }
|
|
||||||
.link-url { font-size: 14px; color: %s; word-break: break-all; font-family: 'JetBrains Mono', monospace; }
|
|
||||||
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
|
|
||||||
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
|
||||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
|
||||||
.footer-text { font-size: 14px; color: %s; }
|
|
||||||
.footer-links { margin-top: 12px; }
|
|
||||||
.footer-links a { color: %s; text-decoration: none; font-size: 13px; margin: 0 12px; font-family: 'Space Grotesk', sans-serif; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
<div class="tagline">Calm booking software</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Hi %s,</div>
|
|
||||||
<div class="message">
|
|
||||||
You requested a sign-in link for your Bookra account. Click below to access your account securely — no password needed.
|
|
||||||
</div>
|
|
||||||
<div class="button-wrap">
|
|
||||||
<a href="%s" class="button">Sign In to Bookra</a>
|
|
||||||
</div>
|
|
||||||
<div class="link-box">
|
|
||||||
<div class="link-label">Or copy this link</div>
|
|
||||||
<div class="link-url">%s</div>
|
|
||||||
</div>
|
|
||||||
<div class="expiry">
|
|
||||||
This link expires in <strong>15 minutes</strong> for security.
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
Didn't request this? You can safely ignore it — someone may have entered your email by mistake.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
<div class="footer-links">
|
|
||||||
<a href="https://bookra.tdvorak.dev/privacy">Privacy</a>
|
|
||||||
<a href="https://bookra.tdvorak.dev/terms">Terms</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
subject, canvas, white, canvas, border,
|
|
||||||
logoBg, logoText, ink, inkSubtle,
|
|
||||||
ink, inkMuted,
|
|
||||||
accent, white, accentHover,
|
|
||||||
canvasSubtle, border, inkSubtle, inkMuted,
|
|
||||||
accentSubtle, accent, accent,
|
|
||||||
inkSubtle, border,
|
|
||||||
canvas, border, inkMuted, inkMuted,
|
|
||||||
toName, magicURL, magicURL)
|
|
||||||
|
|
||||||
text := fmt.Sprintf(`Bookra — Sign-in Link
|
|
||||||
|
|
||||||
Hi %s,
|
|
||||||
|
|
||||||
Sign in to Bookra (link expires in 15 minutes):
|
|
||||||
%s
|
|
||||||
|
|
||||||
Didn't request this? You can safely ignore this email.
|
|
||||||
|
|
||||||
© 2024 Bookra`, toName, magicURL)
|
|
||||||
|
|
||||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
|
||||||
}
|
|
||||||
|
|
||||||
func magicLinkEmailCS(toName, magicURL string) EmailTemplate {
|
|
||||||
subject := "Váš přihlašovací odkaz do Bookra"
|
|
||||||
if toName == "" {
|
|
||||||
toName = "vás"
|
|
||||||
}
|
|
||||||
|
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="cs">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
|
||||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
|
||||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
|
||||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
|
|
||||||
.tagline { color: %s; font-size: 15px; margin-top: 6px; font-style: italic; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
|
||||||
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
|
||||||
.button-wrap { margin: 40px 0; }
|
|
||||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
|
||||||
.button:hover { background: %s; }
|
|
||||||
.link-box { background: %s; border: 1px solid %s; border-radius: 8px; padding: 20px; margin: 32px 0; }
|
|
||||||
.link-label { font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: 'Space Grotesk', sans-serif; }
|
|
||||||
.link-url { font-size: 14px; color: %s; word-break: break-all; font-family: 'JetBrains Mono', monospace; }
|
|
||||||
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
|
|
||||||
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
|
||||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
|
||||||
.footer-text { font-size: 14px; color: %s; }
|
|
||||||
.footer-links { margin-top: 12px; }
|
|
||||||
.footer-links a { color: %s; text-decoration: none; font-size: 13px; margin: 0 12px; font-family: 'Space Grotesk', sans-serif; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
<div class="tagline">Klidný rezervační software</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Dobrý den %s,</div>
|
|
||||||
<div class="message">
|
|
||||||
Požádali jste o přihlašovací odkaz k účtu Bookra. Klikněte níže pro bezpečný přístup — heslo není potřeba.
|
|
||||||
</div>
|
|
||||||
<div class="button-wrap">
|
|
||||||
<a href="%s" class="button">Přihlásit se do Bookra</a>
|
|
||||||
</div>
|
|
||||||
<div class="link-box">
|
|
||||||
<div class="link-label">Nebo zkopírujte tento odkaz</div>
|
|
||||||
<div class="link-url">%s</div>
|
|
||||||
</div>
|
|
||||||
<div class="expiry">
|
|
||||||
Tento odkaz vyprší za <strong>15 minut</strong> z bezpečnostních důvodů.
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
Nepožádali jste o tento email? Můžete ho bezpečně ignorovat.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
<div class="footer-links">
|
|
||||||
<a href="https://bookra.tdvorak.dev/privacy">Ochrana soukromí</a>
|
|
||||||
<a href="https://bookra.tdvorak.dev/terms">Podmínky</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
subject, canvas, white, canvas, border,
|
|
||||||
logoBg, logoText, ink, inkSubtle,
|
|
||||||
ink, inkMuted,
|
|
||||||
accent, white, accentHover,
|
|
||||||
canvasSubtle, border, inkSubtle, inkMuted,
|
|
||||||
accentSubtle, accent, accent,
|
|
||||||
inkSubtle, border,
|
|
||||||
canvas, border, inkMuted, inkMuted,
|
|
||||||
toName, magicURL, magicURL)
|
|
||||||
|
|
||||||
text := fmt.Sprintf(`Bookra — Přihlašovací odkaz
|
|
||||||
|
|
||||||
Dobrý den %s,
|
|
||||||
|
|
||||||
Přihlaste se do Bookra (odkaz vyprší za 15 minut):
|
|
||||||
%s
|
|
||||||
|
|
||||||
Nepožádali jste o tento email? Můžete ho ignorovat.
|
|
||||||
|
|
||||||
© 2024 Bookra`, toName, magicURL)
|
|
||||||
|
|
||||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
|
||||||
}
|
|
||||||
|
|
||||||
func welcomeEmailEN(name string) EmailTemplate {
|
|
||||||
subject := "Welcome to Bookra"
|
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap');
|
|
||||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
|
||||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
|
||||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
|
||||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
|
||||||
.message { font-size: 18px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
|
||||||
.features { background: %s; border-radius: 12px; padding: 32px; margin: 32px 0; }
|
|
||||||
.feature { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
|
||||||
.feature:last-child { margin-bottom: 0; }
|
|
||||||
.feature-icon { width: 24px; height: 24px; background: %s; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: %s; font-size: 14px; flex-shrink: 0; font-family: 'Space Grotesk', sans-serif; }
|
|
||||||
.feature-text { font-size: 16px; color: %s; line-height: 1.5; }
|
|
||||||
.button-wrap { margin: 40px 0; text-align: center; }
|
|
||||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
|
||||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
|
||||||
.footer-text { font-size: 14px; color: %s; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Welcome, %s</div>
|
|
||||||
<div class="message">
|
|
||||||
Thanks for joining Bookra. We're here to help you manage bookings with calm and clarity.
|
|
||||||
</div>
|
|
||||||
<div class="features">
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature-icon">✓</div>
|
|
||||||
<div class="feature-text"><strong>Smart scheduling</strong> — Automatic conflict detection and buffer times</div>
|
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature-icon">✓</div>
|
|
||||||
<div class="feature-text"><strong>Customer insights</strong> — History and preferences at your fingertips</div>
|
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature-icon">✓</div>
|
|
||||||
<div class="feature-text"><strong>Reminders</strong> — Reduce no-shows with gentle notifications</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="button-wrap">
|
|
||||||
<a href="https://bookra.tdvorak.dev/dashboard" class="button">Open Dashboard</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
subject, canvas, white, canvas, border,
|
|
||||||
logoBg, logoText, ink,
|
|
||||||
ink, inkMuted, canvasSubtle,
|
|
||||||
accent, white, inkMuted,
|
|
||||||
accent, white,
|
|
||||||
canvas, border, inkMuted,
|
|
||||||
name)
|
|
||||||
|
|
||||||
text := fmt.Sprintf(`Welcome to Bookra, %s
|
|
||||||
|
|
||||||
Thanks for joining. We're here to help you manage bookings with calm and clarity.
|
|
||||||
|
|
||||||
Get started: https://bookra.tdvorak.dev/dashboard
|
|
||||||
|
|
||||||
© 2024 Bookra`, name)
|
|
||||||
|
|
||||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
|
||||||
}
|
|
||||||
|
|
||||||
func welcomeEmailCS(name string) EmailTemplate {
|
|
||||||
subject := "Vítejte v Bookra"
|
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="cs">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap');
|
|
||||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
|
||||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
|
||||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
|
||||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
|
||||||
.message { font-size: 18px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
|
||||||
.features { background: %s; border-radius: 12px; padding: 32px; margin: 32px 0; }
|
|
||||||
.feature { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
|
||||||
.feature:last-child { margin-bottom: 0; }
|
|
||||||
.feature-icon { width: 24px; height: 24px; background: %s; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: %s; font-size: 14px; flex-shrink: 0; }
|
|
||||||
.feature-text { font-size: 16px; color: %s; line-height: 1.5; }
|
|
||||||
.button-wrap { margin: 40px 0; text-align: center; }
|
|
||||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
|
||||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
|
||||||
.footer-text { font-size: 14px; color: %s; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Vítejte, %s</div>
|
|
||||||
<div class="message">
|
|
||||||
Děkujeme za registraci. Pomůžeme vám spravovat rezervace s klidem a přehledem.
|
|
||||||
</div>
|
|
||||||
<div class="features">
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature-icon">✓</div>
|
|
||||||
<div class="feature-text"><strong>Chytré plánování</strong> — Automatická detekce konfliktů</div>
|
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature-icon">✓</div>
|
|
||||||
<div class="feature-text"><strong>Přehled o zákaznících</strong> — Historie a preference</div>
|
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature-icon">✓</div>
|
|
||||||
<div class="feature-text"><strong>Připomenutí</strong> — Méně zapomenutých termínů</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="button-wrap">
|
|
||||||
<a href="https://bookra.tdvorak.dev/dashboard" class="button">Otevřít aplikaci</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
subject, canvas, white, canvas, border,
|
|
||||||
logoBg, logoText, ink,
|
|
||||||
ink, inkMuted, canvasSubtle,
|
|
||||||
accent, white, inkMuted,
|
|
||||||
accent, white,
|
|
||||||
canvas, border, inkMuted,
|
|
||||||
name)
|
|
||||||
|
|
||||||
text := fmt.Sprintf(`Vítejte v Bookra, %s
|
|
||||||
|
|
||||||
Děkujeme za registraci. Pomůžeme vám spravovat rezervace s klidem.
|
|
||||||
|
|
||||||
Otevřít aplikaci: https://bookra.tdvorak.dev/dashboard
|
|
||||||
|
|
||||||
© 2024 Bookra`, name)
|
|
||||||
|
|
||||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
|
||||||
}
|
|
||||||
|
|
||||||
func bookingConfirmationEN(customerName, businessName, serviceName, dateTime, location string) EmailTemplate {
|
|
||||||
subject := fmt.Sprintf("Confirmed: %s with %s", serviceName, businessName)
|
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap');
|
|
||||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
|
||||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
|
||||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
|
||||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.badge { display: inline-block; background: %s; color: %s; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 24px; font-family: 'Space Grotesk', sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
||||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; color: %s; margin-bottom: 8px; }
|
|
||||||
.message { font-size: 17px; color: %s; margin-bottom: 32px; }
|
|
||||||
.details { background: %s; border-radius: 12px; padding: 28px; margin: 32px 0; }
|
|
||||||
.detail-row { display: flex; padding: 14px 0; border-bottom: 1px solid %s; }
|
|
||||||
.detail-row:last-child { border-bottom: none; }
|
|
||||||
.detail-label { width: 120px; font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.05em; font-family: 'Space Grotesk', sans-serif; }
|
|
||||||
.detail-value { flex: 1; font-size: 16px; color: %s; font-weight: 500; }
|
|
||||||
.help { font-size: 15px; color: %s; margin-top: 32px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
|
||||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
|
||||||
.footer-text { font-size: 14px; color: %s; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="badge">Confirmed</div>
|
|
||||||
<div class="greeting">Hello %s,</div>
|
|
||||||
<div class="message">
|
|
||||||
Your booking with <strong>%s</strong> is confirmed.
|
|
||||||
</div>
|
|
||||||
<div class="details">
|
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">Service</div>
|
|
||||||
<div class="detail-value">%s</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">When</div>
|
|
||||||
<div class="detail-value">%s</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">Where</div>
|
|
||||||
<div class="detail-value">%s</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
Need to reschedule? Contact %s directly.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
subject, canvas, white, canvas, border,
|
|
||||||
logoBg, logoText, ink,
|
|
||||||
accentSubtle, accent, ink, inkMuted,
|
|
||||||
canvasSubtle, border, inkSubtle, ink,
|
|
||||||
inkSubtle, border,
|
|
||||||
canvas, border, inkMuted,
|
|
||||||
customerName, businessName, serviceName, dateTime, location, businessName)
|
|
||||||
|
|
||||||
text := fmt.Sprintf(`Booking Confirmed
|
|
||||||
|
|
||||||
Hello %s,
|
|
||||||
|
|
||||||
Your booking with %s is confirmed.
|
|
||||||
|
|
||||||
Service: %s
|
|
||||||
When: %s
|
|
||||||
Where: %s
|
|
||||||
|
|
||||||
Need to reschedule? Contact %s.
|
|
||||||
|
|
||||||
© 2024 Bookra`,
|
|
||||||
customerName, businessName, serviceName, dateTime, location, businessName)
|
|
||||||
|
|
||||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
|
||||||
}
|
|
||||||
|
|
||||||
func bookingConfirmationCS(customerName, businessName, serviceName, dateTime, location string) EmailTemplate {
|
|
||||||
subject := fmt.Sprintf("Potvrzeno: %s v %s", serviceName, businessName)
|
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="cs">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap');
|
|
||||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
|
||||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
|
||||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
|
||||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.badge { display: inline-block; background: %s; color: %s; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 24px; font-family: 'Space Grotesk', sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
||||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; color: %s; margin-bottom: 8px; }
|
|
||||||
.message { font-size: 17px; color: %s; margin-bottom: 32px; }
|
|
||||||
.details { background: %s; border-radius: 12px; padding: 28px; margin: 32px 0; }
|
|
||||||
.detail-row { display: flex; padding: 14px 0; border-bottom: 1px solid %s; }
|
|
||||||
.detail-row:last-child { border-bottom: none; }
|
|
||||||
.detail-label { width: 120px; font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.05em; font-family: 'Space Grotesk', sans-serif; }
|
|
||||||
.detail-value { flex: 1; font-size: 16px; color: %s; font-weight: 500; }
|
|
||||||
.help { font-size: 15px; color: %s; margin-top: 32px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
|
||||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
|
||||||
.footer-text { font-size: 14px; color: %s; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="badge">Potvrzeno</div>
|
|
||||||
<div class="greeting">Dobrý den %s,</div>
|
|
||||||
<div class="message">
|
|
||||||
Vaše rezervace v <strong>%s</strong> je potvrzena.
|
|
||||||
</div>
|
|
||||||
<div class="details">
|
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">Služba</div>
|
|
||||||
<div class="detail-value">%s</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">Termín</div>
|
|
||||||
<div class="detail-value">%s</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">Místo</div>
|
|
||||||
<div class="detail-value">%s</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
Potřebujete přeobjednat? Kontaktujte přímo %s.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
subject, canvas, white, canvas, border,
|
|
||||||
logoBg, logoText, ink,
|
|
||||||
accentSubtle, accent, ink, inkMuted,
|
|
||||||
canvasSubtle, border, inkSubtle, ink,
|
|
||||||
inkSubtle, border,
|
|
||||||
canvas, border, inkMuted,
|
|
||||||
customerName, businessName, serviceName, dateTime, location, businessName)
|
|
||||||
|
|
||||||
text := fmt.Sprintf(`Rezervace potvrzena
|
|
||||||
|
|
||||||
Dobrý den %s,
|
|
||||||
|
|
||||||
Vaše rezervace v %s je potvrzena.
|
|
||||||
|
|
||||||
Služba: %s
|
|
||||||
Termín: %s
|
|
||||||
Místo: %s
|
|
||||||
|
|
||||||
Potřebujete přeobjednat? Kontaktujte %s.
|
|
||||||
|
|
||||||
© 2024 Bookra`,
|
|
||||||
customerName, businessName, serviceName, dateTime, location, businessName)
|
|
||||||
|
|
||||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
|
||||||
}
|
|
||||||
|
|
||||||
func passwordResetEN(name, resetURL string) EmailTemplate {
|
|
||||||
subject := "Reset your Bookra password"
|
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
|
||||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
|
||||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
|
||||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
|
||||||
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
|
||||||
.button-wrap { margin: 40px 0; }
|
|
||||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
|
||||||
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
|
|
||||||
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
|
||||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
|
||||||
.footer-text { font-size: 14px; color: %s; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Hi %s,</div>
|
|
||||||
<div class="message">
|
|
||||||
We received a request to reset your password. Click below to choose a new one.
|
|
||||||
</div>
|
|
||||||
<div class="button-wrap">
|
|
||||||
<a href="%s" class="button">Reset Password</a>
|
|
||||||
</div>
|
|
||||||
<div class="expiry">
|
|
||||||
This link expires in <strong>1 hour</strong>.
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
Didn't request this? You can safely ignore it.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
subject, canvas, white, canvas, border,
|
|
||||||
logoBg, logoText, ink,
|
|
||||||
ink, inkMuted,
|
|
||||||
accent, white,
|
|
||||||
accentSubtle, accent, accent,
|
|
||||||
inkSubtle, border,
|
|
||||||
canvas, border, inkMuted,
|
|
||||||
name, resetURL)
|
|
||||||
|
|
||||||
text := fmt.Sprintf(`Reset Password — Bookra
|
|
||||||
|
|
||||||
Hi %s,
|
|
||||||
|
|
||||||
Reset your password (expires in 1 hour):
|
|
||||||
%s
|
|
||||||
|
|
||||||
Didn't request this? You can safely ignore it.
|
|
||||||
|
|
||||||
© 2024 Bookra`, name, resetURL)
|
|
||||||
|
|
||||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
|
||||||
}
|
|
||||||
|
|
||||||
func passwordResetCS(name, resetURL string) EmailTemplate {
|
|
||||||
subject := "Reset hesla pro Bookra"
|
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="cs">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
|
||||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
|
||||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
|
||||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
|
||||||
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
|
||||||
.button-wrap { margin: 40px 0; }
|
|
||||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
|
||||||
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
|
|
||||||
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
|
||||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
|
||||||
.footer-text { font-size: 14px; color: %s; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Dobrý den %s,</div>
|
|
||||||
<div class="message">
|
|
||||||
Obdrželi jsme žádost o reset hesla. Klikněte níže pro nastavení nového.
|
|
||||||
</div>
|
|
||||||
<div class="button-wrap">
|
|
||||||
<a href="%s" class="button">Resetovat heslo</a>
|
|
||||||
</div>
|
|
||||||
<div class="expiry">
|
|
||||||
Tento odkaz vyprší za <strong>1 hodinu</strong>.
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
Nepožádali jste o tento email? Můžete ho bezpečně ignorovat.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
subject, canvas, white, canvas, border,
|
|
||||||
logoBg, logoText, ink,
|
|
||||||
ink, inkMuted,
|
|
||||||
accent, white,
|
|
||||||
accentSubtle, accent, accent,
|
|
||||||
inkSubtle, border,
|
|
||||||
canvas, border, inkMuted,
|
|
||||||
name, resetURL)
|
|
||||||
|
|
||||||
text := fmt.Sprintf(`Reset hesla — Bookra
|
|
||||||
|
|
||||||
Dobrý den %s,
|
|
||||||
|
|
||||||
Reset hesla (vyprší za 1 hodinu):
|
|
||||||
%s
|
|
||||||
|
|
||||||
Nepožádali jste o tento email? Můžete ho ignorovat.
|
|
||||||
|
|
||||||
© 2024 Bookra`, name, resetURL)
|
|
||||||
|
|
||||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
|
||||||
}
|
|
||||||
@@ -1,680 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bookra/apps/auth-service/internal/config"
|
|
||||||
"bookra/apps/auth-service/internal/db"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AdminDashboard provides a visual management interface for the auth service
|
|
||||||
type AdminDashboard struct {
|
|
||||||
cfg *config.Config
|
|
||||||
db *db.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAdminDashboard(cfg *config.Config, database *db.DB) *AdminDashboard {
|
|
||||||
return &AdminDashboard{cfg: cfg, db: database}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterRoutes registers admin routes
|
|
||||||
func (a *AdminDashboard) RegisterRoutes(r *gin.Engine) {
|
|
||||||
admin := r.Group("/admin")
|
|
||||||
{
|
|
||||||
admin.GET("", a.RenderDashboard)
|
|
||||||
admin.GET("/api/config", a.GetConfig)
|
|
||||||
admin.GET("/api/prices", a.GetPrices)
|
|
||||||
admin.GET("/api/stats", a.GetStats)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConfig returns current configuration (sanitized)
|
|
||||||
func (a *AdminDashboard) GetConfig(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"appEnv": a.cfg.AppEnv,
|
|
||||||
"port": a.cfg.Port,
|
|
||||||
"frontendURL": a.cfg.FrontendURL,
|
|
||||||
"neonAuthURL": a.cfg.NeonAuthURL,
|
|
||||||
"smtpConfigured": gin.H{
|
|
||||||
"host": a.cfg.SMTPHost,
|
|
||||||
"port": a.cfg.SMTPPort,
|
|
||||||
"from": a.cfg.EmailFrom,
|
|
||||||
},
|
|
||||||
"googleOAuthConfigured": a.cfg.GoogleClientID != "",
|
|
||||||
"stripeConfigured": a.cfg.StripeCheckoutReady(),
|
|
||||||
"stripeSecretConfigured": a.cfg.StripeSecretConfigured(),
|
|
||||||
"stripeWebhookConfigured": a.cfg.StripeWebhookConfigured(),
|
|
||||||
"stripePricesConfigured": a.cfg.StripeHasAnyPriceConfigured(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPrices returns configured Stripe prices
|
|
||||||
func (a *AdminDashboard) GetPrices(c *gin.Context) {
|
|
||||||
prices := []gin.H{}
|
|
||||||
|
|
||||||
planNames := map[string]string{
|
|
||||||
"starter": "Starter Plan",
|
|
||||||
"pro": "Pro Plan",
|
|
||||||
"business": "Business Plan",
|
|
||||||
"monthly": "Monthly Plan",
|
|
||||||
"growth": "Growth Plan (Pro alias)",
|
|
||||||
"multi-location": "Multi-Location (Business alias)",
|
|
||||||
}
|
|
||||||
|
|
||||||
currencies := []string{"czk", "usd"}
|
|
||||||
|
|
||||||
for planCode, priceID := range a.cfg.StripePriceIDs {
|
|
||||||
if strings.TrimSpace(priceID) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse plan:currency format
|
|
||||||
parts := strings.Split(planCode, ":")
|
|
||||||
displayName := planNames[planCode]
|
|
||||||
currency := ""
|
|
||||||
|
|
||||||
if len(parts) == 2 {
|
|
||||||
planCode = parts[0]
|
|
||||||
currency = parts[1]
|
|
||||||
displayName = planNames[planCode] + " (" + strings.ToUpper(currency) + ")"
|
|
||||||
}
|
|
||||||
|
|
||||||
if displayName == "" {
|
|
||||||
displayName = planCode
|
|
||||||
}
|
|
||||||
|
|
||||||
prices = append(prices, gin.H{
|
|
||||||
"planCode": planCode,
|
|
||||||
"currency": currency,
|
|
||||||
"priceID": priceID,
|
|
||||||
"displayName": displayName,
|
|
||||||
"configured": true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"prices": prices,
|
|
||||||
"currencies": currencies,
|
|
||||||
"stripeConfigured": a.cfg.StripeCheckoutReady(),
|
|
||||||
"secretConfigured": a.cfg.StripeSecretConfigured(),
|
|
||||||
"webhookConfigured": a.cfg.StripeWebhookConfigured(),
|
|
||||||
"pricesConfigured": len(prices) > 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStats returns database statistics
|
|
||||||
func (a *AdminDashboard) GetStats(c *gin.Context) {
|
|
||||||
if a.db == nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
stats, err := a.db.GetStats(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load stats: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderDashboard renders the HTML admin dashboard
|
|
||||||
func (a *AdminDashboard) RenderDashboard(c *gin.Context) {
|
|
||||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
|
||||||
c.String(http.StatusOK, adminHTML)
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminHTML = `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Bookra Auth Service Admin</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--canvas: 40 25% 97%;
|
|
||||||
--canvas-subtle: 40 20% 94%;
|
|
||||||
--canvas-muted: 40 15% 89%;
|
|
||||||
--ink: 25 15% 12%;
|
|
||||||
--ink-muted: 25 10% 42%;
|
|
||||||
--ink-subtle: 25 8% 58%;
|
|
||||||
--accent: 17 55% 42%;
|
|
||||||
--accent-hover: 17 60% 37%;
|
|
||||||
--accent-subtle: 17 45% 94%;
|
|
||||||
--success: 145 45% 38%;
|
|
||||||
--success-subtle: 145 35% 94%;
|
|
||||||
--error: 0 60% 52%;
|
|
||||||
--error-subtle: 0 50% 96%;
|
|
||||||
--border: 30 12% 86%;
|
|
||||||
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04);
|
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
|
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.06), 0 4px 6px -4px rgb(0 0 0 / 0.04);
|
|
||||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: "Newsreader", Georgia, ui-serif, serif;
|
|
||||||
background: linear-gradient(180deg, hsl(var(--canvas)) 0%, hsl(var(--canvas-subtle)) 100%);
|
|
||||||
color: hsl(var(--ink));
|
|
||||||
line-height: 1.6;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.container { padding: 2rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.container { padding: 3rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
margin-bottom: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo svg {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
color: hsl(var(--accent));
|
|
||||||
}
|
|
||||||
|
|
||||||
header h1 {
|
|
||||||
font-family: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
|
|
||||||
font-size: clamp(1.75rem, 3vw + 0.5rem, 2.5rem);
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
color: hsl(var(--ink));
|
|
||||||
}
|
|
||||||
|
|
||||||
header p {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
color: hsl(var(--ink-muted));
|
|
||||||
max-width: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: linear-gradient(145deg, hsl(40 25% 98%) 0%, hsl(40 20% 96%) 100%);
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: 1rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
transition: box-shadow 0.3s ease, transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card:hover {
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card .icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: hsl(var(--accent-subtle));
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card .icon svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
color: hsl(var(--accent));
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card.success .icon { background: hsl(var(--success-subtle)); }
|
|
||||||
.stat-card.success .icon svg { color: hsl(var(--success)); }
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
|
||||||
font-size: 1.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: hsl(var(--ink));
|
|
||||||
line-height: 1;
|
|
||||||
margin-bottom: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: hsl(var(--ink-muted));
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: linear-gradient(145deg, hsl(40 25% 98%) 0%, hsl(40 20% 96%) 100%);
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: 1rem;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
color: hsl(var(--accent));
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header h2 {
|
|
||||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: hsl(var(--ink));
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.active {
|
|
||||||
background: hsl(var(--success-subtle));
|
|
||||||
color: hsl(var(--success));
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.inactive {
|
|
||||||
background: hsl(var(--error-subtle));
|
|
||||||
color: hsl(var(--error));
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td {
|
|
||||||
text-align: left;
|
|
||||||
padding: 0.875rem 0;
|
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
|
||||||
font-weight: 500;
|
|
||||||
color: hsl(var(--ink-muted));
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:last-child td { border-bottom: none; }
|
|
||||||
|
|
||||||
.env-value {
|
|
||||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
|
||||||
background: hsl(var(--canvas-muted));
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: hsl(var(--ink));
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.25rem 0.625rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
|
||||||
background: hsl(var(--accent-subtle));
|
|
||||||
color: hsl(var(--accent));
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.875rem 0;
|
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row:last-child { border-bottom: none; }
|
|
||||||
|
|
||||||
.info-label {
|
|
||||||
color: hsl(var(--ink-muted));
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 3rem;
|
|
||||||
color: hsl(var(--ink-subtle));
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
background: hsl(var(--error-subtle));
|
|
||||||
color: hsl(var(--error));
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
color: hsl(var(--ink-muted));
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state svg {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
color: hsl(var(--ink-subtle));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<header>
|
|
||||||
<div class="logo">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
|
||||||
<path d="M2 17l10 5 10-5"/>
|
|
||||||
<path d="M2 12l10 5 10-5"/>
|
|
||||||
</svg>
|
|
||||||
<h1>Auth Service Admin</h1>
|
|
||||||
</div>
|
|
||||||
<p>Monitor users, configure billing plans, and manage service health.</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="stats-grid" id="stats-grid">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
|
||||||
<circle cx="9" cy="7" r="4"/>
|
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value">-</div>
|
|
||||||
<div class="stat-label">Total Users</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<circle cx="12" cy="12" r="10"/>
|
|
||||||
<polyline points="12 6 12 12 16 14"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value">-</div>
|
|
||||||
<div class="stat-label">Active (7d)</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value">-</div>
|
|
||||||
<div class="stat-label">Magic Links Sent</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card success">
|
|
||||||
<div class="icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value">-</div>
|
|
||||||
<div class="stat-label">New This Week</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
|
||||||
</svg>
|
|
||||||
<h2>Service Configuration</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body" id="config-content">
|
|
||||||
<div class="loading">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/>
|
|
||||||
</svg>
|
|
||||||
Loading configuration...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
|
||||||
<line x1="2" y1="10" x2="22" y2="10"/>
|
|
||||||
</svg>
|
|
||||||
<h2>Billing Plans</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body" id="prices-content">
|
|
||||||
<div class="loading">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/>
|
|
||||||
</svg>
|
|
||||||
Loading plans...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
|
||||||
</svg>
|
|
||||||
<h2>API Endpoints</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Method</th>
|
|
||||||
<th>Endpoint</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><span class="badge">POST</span></td><td>/api/auth/magic-link</td></tr>
|
|
||||||
<tr><td><span class="badge">POST</span></td><td>/api/auth/verify</td></tr>
|
|
||||||
<tr><td><span class="badge">POST</span></td><td>/api/auth/register</td></tr>
|
|
||||||
<tr><td><span class="badge">POST</span></td><td>/api/auth/login</td></tr>
|
|
||||||
<tr><td><span class="badge">GET</span></td><td>/api/auth/me</td></tr>
|
|
||||||
<tr><td><span class="badge">GET</span></td><td>/api/billing/subscription</td></tr>
|
|
||||||
<tr><td><span class="badge">POST</span></td><td>/api/billing/checkout</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<circle cx="12" cy="12" r="3"/>
|
|
||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
||||||
</svg>
|
|
||||||
<h2>Service Overview</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Authentication</span>
|
|
||||||
<span>Magic links, JWT, OAuth</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Billing</span>
|
|
||||||
<span>Stripe subscriptions</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Database</span>
|
|
||||||
<span>Neon PostgreSQL</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Email</span>
|
|
||||||
<span>SMTP transactional</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Load stats
|
|
||||||
fetch('/admin/api/stats')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
const cards = document.querySelectorAll('.stat-card');
|
|
||||||
cards[0].querySelector('.stat-value').textContent = data.totalUsers.toLocaleString();
|
|
||||||
cards[1].querySelector('.stat-value').textContent = data.activeUsers7Days.toLocaleString();
|
|
||||||
cards[2].querySelector('.stat-value').textContent = data.magicLinksSent.toLocaleString();
|
|
||||||
cards[3].querySelector('.stat-value').textContent = data.usersThisWeek.toLocaleString();
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
document.getElementById('stats-grid').innerHTML =
|
|
||||||
'<div class="error" style="grid-column: 1/-1;">Failed to load statistics</div>';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load configuration
|
|
||||||
fetch('/admin/api/config')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
let html = '<div class="info-row">' +
|
|
||||||
'<span class="info-label">Environment</span>' +
|
|
||||||
'<span class="env-value">' + data.appEnv + '</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="info-row">' +
|
|
||||||
'<span class="info-label">Port</span>' +
|
|
||||||
'<span class="env-value">' + data.port + '</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="info-row">' +
|
|
||||||
'<span class="info-label">Neon Auth</span>' +
|
|
||||||
'<span class="status ' + (data.neonAuthURL ? 'active' : 'inactive') + '">' +
|
|
||||||
'<span class="status-dot"></span>' +
|
|
||||||
(data.neonAuthURL ? 'Configured' : 'Not Configured') +
|
|
||||||
'</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="info-row">' +
|
|
||||||
'<span class="info-label">SMTP</span>' +
|
|
||||||
'<span class="status ' + (data.smtpConfigured.host ? 'active' : 'inactive') + '">' +
|
|
||||||
'<span class="status-dot"></span>' +
|
|
||||||
(data.smtpConfigured.host ? data.smtpConfigured.host : 'Not Configured') +
|
|
||||||
'</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="info-row">' +
|
|
||||||
'<span class="info-label">Google OAuth</span>' +
|
|
||||||
'<span class="status ' + (data.googleOAuthConfigured ? 'active' : 'inactive') + '">' +
|
|
||||||
'<span class="status-dot"></span>' +
|
|
||||||
(data.googleOAuthConfigured ? 'Enabled' : 'Disabled') +
|
|
||||||
'</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="info-row">' +
|
|
||||||
'<span class="info-label">Stripe</span>' +
|
|
||||||
'<span class="status ' + (data.stripeConfigured ? 'active' : 'inactive') + '">' +
|
|
||||||
'<span class="status-dot"></span>' +
|
|
||||||
(data.stripeConfigured ? 'Configured' : 'Not Configured') +
|
|
||||||
'</span>' +
|
|
||||||
'</div>';
|
|
||||||
document.getElementById('config-content').innerHTML = html;
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
document.getElementById('config-content').innerHTML =
|
|
||||||
'<div class="error">Failed to load configuration</div>';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load prices
|
|
||||||
fetch('/admin/api/prices')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (!data.prices || data.prices.length === 0) {
|
|
||||||
document.getElementById('prices-content').innerHTML =
|
|
||||||
'<div class="empty-state">' +
|
|
||||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>' +
|
|
||||||
'<p>No Stripe prices configured</p>' +
|
|
||||||
'</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '<table><thead><tr><th>Plan</th><th>Currency</th><th>Status</th></tr></thead><tbody>';
|
|
||||||
data.prices.forEach(p => {
|
|
||||||
html += '<tr>' +
|
|
||||||
'<td>' + p.displayName + '</td>' +
|
|
||||||
'<td>' + (p.currency ? p.currency.toUpperCase() : 'Default') + '</td>' +
|
|
||||||
'<td><span class="badge">' + p.priceID.substring(0, 12) + '...</span></td>' +
|
|
||||||
'</tr>';
|
|
||||||
});
|
|
||||||
html += '</tbody></table>';
|
|
||||||
document.getElementById('prices-content').innerHTML = html;
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
document.getElementById('prices-content').innerHTML =
|
|
||||||
'<div class="error">Failed to load prices</div>';
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
@@ -1,513 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bookra/apps/auth-service/internal/auth"
|
|
||||||
"bookra/apps/auth-service/internal/billing"
|
|
||||||
"bookra/apps/auth-service/internal/config"
|
|
||||||
"bookra/apps/auth-service/internal/db"
|
|
||||||
"bookra/apps/auth-service/internal/email"
|
|
||||||
"bookra/apps/auth-service/internal/oauth"
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Handler struct {
|
|
||||||
authSvc *auth.Service
|
|
||||||
neon *auth.NeonVerifier
|
|
||||||
billingSvc *billing.Service
|
|
||||||
google *oauth.GoogleProvider
|
|
||||||
cfg *config.Config
|
|
||||||
db *db.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoginRequest struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
Locale string `json:"locale,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type VerifyRequest struct {
|
|
||||||
Token string `json:"token" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RefreshRequest struct {
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PasswordRegisterRequest struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
Password string `json:"password" binding:"required,min=8"`
|
|
||||||
Name string `json:"name" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PasswordLoginRequest struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
Password string `json:"password" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CheckoutRequest struct {
|
|
||||||
PlanCode string `json:"planCode,omitempty"`
|
|
||||||
Currency string `json:"currency,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(db *db.DB, emailSvc *email.Service, cfg *config.Config) (*Handler, error) {
|
|
||||||
neonVerifier, err := auth.NewNeonVerifier(cfg.NeonAuthURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &Handler{
|
|
||||||
authSvc: auth.NewService(db, emailSvc, cfg.JWTSecret, cfg.FrontendURL),
|
|
||||||
neon: neonVerifier,
|
|
||||||
billingSvc: billing.NewService(cfg, db),
|
|
||||||
google: oauth.NewGoogleProvider(cfg),
|
|
||||||
cfg: cfg,
|
|
||||||
db: db,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) RegisterRoutes(r *gin.Engine) {
|
|
||||||
// Auth API
|
|
||||||
api := r.Group("/api/auth")
|
|
||||||
{
|
|
||||||
api.POST("/magic-link", h.SendMagicLink)
|
|
||||||
api.POST("/verify", h.VerifyMagicLink)
|
|
||||||
api.POST("/register", h.RegisterWithPassword)
|
|
||||||
api.POST("/login", h.LoginWithPassword)
|
|
||||||
api.POST("/refresh", h.RefreshToken)
|
|
||||||
api.GET("/me", h.RequireAuth(), h.GetMe)
|
|
||||||
api.POST("/logout", h.RequireAuth(), h.Logout)
|
|
||||||
|
|
||||||
api.GET("/providers", h.ListProviders)
|
|
||||||
api.GET("/oauth/google", h.GoogleAuth)
|
|
||||||
api.GET("/oauth/google/callback", h.GoogleCallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Billing API
|
|
||||||
billingAPI := r.Group("/api/billing")
|
|
||||||
{
|
|
||||||
billingAPI.POST("/webhook", h.StripeWebhook)
|
|
||||||
billingAPI.GET("/subscription", h.RequireAuth(), h.GetSubscription)
|
|
||||||
billingAPI.POST("/checkout", h.RequireAuth(), h.CreateCheckoutSession)
|
|
||||||
billingAPI.POST("/refresh", h.RequireAuth(), h.RefreshSubscription)
|
|
||||||
billingAPI.GET("/plans", h.ListPlans)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin Dashboard (Visual Management)
|
|
||||||
admin := NewAdminDashboard(h.cfg, h.db)
|
|
||||||
admin.RegisterRoutes(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) SendMagicLink(c *gin.Context) {
|
|
||||||
var req LoginRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect locale from request: JSON body > Accept-Language header > default "en"
|
|
||||||
locale := req.Locale
|
|
||||||
if locale == "" {
|
|
||||||
locale = detectLocale(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.authSvc.GenerateMagicLink(c.Request.Context(), req.Email, locale); err != nil {
|
|
||||||
log.Printf("magic link failed for %s: %v", req.Email, err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send magic link"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Magic link sent to your email"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectLocale extracts locale from Accept-Language header
|
|
||||||
func detectLocale(c *gin.Context) string {
|
|
||||||
acceptLang := c.GetHeader("Accept-Language")
|
|
||||||
if strings.HasPrefix(acceptLang, "cs") || strings.Contains(acceptLang, "cs-") {
|
|
||||||
return "cs"
|
|
||||||
}
|
|
||||||
// Default to English
|
|
||||||
return "en"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) VerifyMagicLink(c *gin.Context) {
|
|
||||||
var req VerifyRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens, err := h.authSvc.VerifyMagicLink(c.Request.Context(), req.Token)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) RegisterWithPassword(c *gin.Context) {
|
|
||||||
var req PasswordRegisterRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens, err := h.authSvc.RegisterWithPassword(c.Request.Context(), req.Email, req.Password, req.Name)
|
|
||||||
if err != nil {
|
|
||||||
if strings.Contains(err.Error(), "already registered") {
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) LoginWithPassword(c *gin.Context) {
|
|
||||||
var req PasswordLoginRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens, err := h.authSvc.LoginWithPassword(c.Request.Context(), req.Email, req.Password)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) RefreshToken(c *gin.Context) {
|
|
||||||
var req RefreshRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshToken := strings.TrimSpace(req.RefreshToken)
|
|
||||||
if refreshToken == "" {
|
|
||||||
authHeader := c.GetHeader("Authorization")
|
|
||||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
|
||||||
refreshToken = strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if refreshToken == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing refresh token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens, err := h.authSvc.RefreshTokens(c.Request.Context(), refreshToken)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid refresh token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) GetMe(c *gin.Context) {
|
|
||||||
claims, exists := c.Get("claims")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userClaims := claims.(*auth.Claims)
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"id": userClaims.UserID,
|
|
||||||
"email": userClaims.Email,
|
|
||||||
"name": userClaims.Name,
|
|
||||||
"role": userClaims.Role,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) Logout(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) ListProviders(c *gin.Context) {
|
|
||||||
providers := []gin.H{}
|
|
||||||
|
|
||||||
if h.google.Enabled() {
|
|
||||||
providers = append(providers, gin.H{
|
|
||||||
"id": "google",
|
|
||||||
"name": "Google",
|
|
||||||
"url": "/api/auth/oauth/google",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
providers = append(providers, gin.H{
|
|
||||||
"id": "email",
|
|
||||||
"name": "Email Magic Link",
|
|
||||||
})
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"providers": providers})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) GoogleAuth(c *gin.Context) {
|
|
||||||
if !h.google.Enabled() {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Google OAuth not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
state := generateState()
|
|
||||||
url := h.google.GetAuthURL(state)
|
|
||||||
|
|
||||||
c.SetCookie("oauth_state", state, 600, "/", "", oauthCookieSecure(c, h.cfg), true)
|
|
||||||
c.Redirect(http.StatusTemporaryRedirect, url)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) GoogleCallback(c *gin.Context) {
|
|
||||||
if !h.google.Enabled() {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Google OAuth not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
state := c.Query("state")
|
|
||||||
expectedState, err := c.Cookie("oauth_state")
|
|
||||||
if err != nil || state == "" || state != expectedState {
|
|
||||||
c.SetCookie("oauth_state", "", -1, "/", "", oauthCookieSecure(c, h.cfg), true)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid OAuth state"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.SetCookie("oauth_state", "", -1, "/", "", oauthCookieSecure(c, h.cfg), true)
|
|
||||||
|
|
||||||
code := c.Query("code")
|
|
||||||
if code == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing code"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := h.google.ExchangeCode(c.Request.Context(), code)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "OAuth failed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
providerID, email, name := h.google.ParseUser(user)
|
|
||||||
tokens, err := h.authSvc.OAuthLoginOrCreate(c.Request.Context(), "google", providerID, email, name)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process login"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
redirectURL := h.cfg.FrontendURL + "/auth/callback?token=" + url.QueryEscape(tokens.AccessToken)
|
|
||||||
if tokens.RefreshToken != "" {
|
|
||||||
redirectURL += "&refresh_token=" + url.QueryEscape(tokens.RefreshToken)
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) GetSubscription(c *gin.Context) {
|
|
||||||
claims, ok := h.claimsFromContext(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot, err := h.billingSvc.GetSubscription(c.Request.Context(), claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load subscription"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, snapshot)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) CreateCheckoutSession(c *gin.Context) {
|
|
||||||
claims, ok := h.claimsFromContext(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req CheckoutRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
response, err := h.billingSvc.CreateCheckoutSession(c.Request.Context(), billing.UserIdentity{
|
|
||||||
ID: claims.UserID,
|
|
||||||
Email: claims.Email,
|
|
||||||
Name: claims.Name,
|
|
||||||
}, req.PlanCode, req.Currency)
|
|
||||||
if err != nil {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, billing.ErrPlanNotConfigured):
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Billing plan is not configured"})
|
|
||||||
case errors.Is(err, billing.ErrStripeNotConfigured):
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe is not configured"})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create checkout session"})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) RefreshSubscription(c *gin.Context) {
|
|
||||||
claims, ok := h.claimsFromContext(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot, err := h.billingSvc.Refresh(c.Request.Context(), claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, billing.ErrStripeNotConfigured) {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe is not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to refresh subscription"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, snapshot)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListPlans returns available billing plans and their configuration status
|
|
||||||
func (h *Handler) ListPlans(c *gin.Context) {
|
|
||||||
plans := []gin.H{
|
|
||||||
{"code": "starter", "name": "Starter", "description": "For individuals and small teams"},
|
|
||||||
{"code": "pro", "name": "Pro", "description": "For growing businesses"},
|
|
||||||
{"code": "business", "name": "Business", "description": "For multi-location operations"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check which plans are configured
|
|
||||||
configured := make(map[string]bool)
|
|
||||||
for planCode, priceID := range h.cfg.StripePriceIDs {
|
|
||||||
if priceID != "" {
|
|
||||||
configured[planCode] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, plan := range plans {
|
|
||||||
code := plan["code"].(string)
|
|
||||||
plan["czkConfigured"] = configured[code+":czk"] || configured[code]
|
|
||||||
plan["usdConfigured"] = configured[code+":usd"] || configured[code]
|
|
||||||
plans[i] = plan
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"plans": plans,
|
|
||||||
"stripeConfigured": h.cfg.StripeCheckoutReady(),
|
|
||||||
"secretConfigured": h.cfg.StripeSecretConfigured(),
|
|
||||||
"webhookConfigured": h.cfg.StripeWebhookConfigured(),
|
|
||||||
"pricesConfigured": h.cfg.StripeHasAnyPriceConfigured(),
|
|
||||||
"checkoutReady": h.cfg.StripeCheckoutReady(),
|
|
||||||
"currencies": []string{"czk", "usd"},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) StripeWebhook(c *gin.Context) {
|
|
||||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
|
|
||||||
payload, err := io.ReadAll(c.Request.Body)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "Webhook payload is too large"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.billingSvc.HandleWebhook(c.Request.Context(), c.GetHeader("Stripe-Signature"), payload); err != nil {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, billing.ErrStripeWebhookMissing), errors.Is(err, billing.ErrStripeSignatureMissing):
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Stripe webhook"})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"received": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) RequireAuth() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
authHeader := c.GetHeader("Authorization")
|
|
||||||
tokenString := ""
|
|
||||||
|
|
||||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
|
||||||
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
|
|
||||||
}
|
|
||||||
|
|
||||||
if tokenString == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing token"})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, err := h.verifyBearerToken(tokenString)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Set("claims", claims)
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) verifyBearerToken(tokenString string) (*auth.Claims, error) {
|
|
||||||
if h.neon != nil && h.neon.Enabled() {
|
|
||||||
return h.neon.Verify(tokenString)
|
|
||||||
}
|
|
||||||
if h.cfg.AppEnv == "development" {
|
|
||||||
return h.authSvc.VerifyToken(tokenString)
|
|
||||||
}
|
|
||||||
return nil, errors.New("neon auth is not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) claimsFromContext(c *gin.Context) (*auth.Claims, bool) {
|
|
||||||
claims, exists := c.Get("claims")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims not found"})
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
userClaims, ok := claims.(*auth.Claims)
|
|
||||||
if !ok {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return userClaims, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateState() string {
|
|
||||||
buffer := make([]byte, 24)
|
|
||||||
if _, err := rand.Read(buffer); err != nil {
|
|
||||||
return "state_" + time.Now().Format("20060102150405")
|
|
||||||
}
|
|
||||||
return "state_" + strings.TrimRight(base64.URLEncoding.EncodeToString(buffer), "=")
|
|
||||||
}
|
|
||||||
|
|
||||||
func oauthCookieSecure(c *gin.Context, cfg *config.Config) bool {
|
|
||||||
if c.Request.TLS != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(cfg.FrontendURL)), "https://")
|
|
||||||
}
|
|
||||||
|
|
||||||
func timeoutMiddleware(duration time.Duration) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), duration)
|
|
||||||
defer cancel()
|
|
||||||
c.Request = c.Request.WithContext(ctx)
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
package oauth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bookra/apps/auth-service/internal/config"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
"golang.org/x/oauth2/google"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GoogleUser struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Picture string `json:"picture"`
|
|
||||||
VerifiedEmail bool `json:"verified_email"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GoogleProvider struct {
|
|
||||||
config *oauth2.Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewGoogleProvider(cfg *config.Config) *GoogleProvider {
|
|
||||||
if cfg.GoogleClientID == "" || cfg.GoogleClientSecret == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
redirectURL := cfg.GoogleRedirectURL
|
|
||||||
if redirectURL == "" {
|
|
||||||
redirectURL = cfg.FrontendURL + "/auth/oauth/google/callback"
|
|
||||||
}
|
|
||||||
|
|
||||||
return &GoogleProvider{
|
|
||||||
config: &oauth2.Config{
|
|
||||||
ClientID: cfg.GoogleClientID,
|
|
||||||
ClientSecret: cfg.GoogleClientSecret,
|
|
||||||
RedirectURL: redirectURL,
|
|
||||||
Scopes: []string{
|
|
||||||
"openid",
|
|
||||||
"https://www.googleapis.com/auth/userinfo.email",
|
|
||||||
"https://www.googleapis.com/auth/userinfo.profile",
|
|
||||||
},
|
|
||||||
Endpoint: google.Endpoint,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *GoogleProvider) Enabled() bool {
|
|
||||||
return p != nil && p.config != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *GoogleProvider) GetAuthURL(state string) string {
|
|
||||||
return p.config.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *GoogleProvider) ExchangeCode(ctx context.Context, code string) (*GoogleUser, error) {
|
|
||||||
token, err := p.config.Exchange(ctx, code)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("exchange code: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client := p.config.Client(ctx, token)
|
|
||||||
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("fetch userinfo: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("userinfo returned %d: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var user GoogleUser
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
|
||||||
return nil, fmt.Errorf("decode userinfo: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *GoogleProvider) ParseUser(user *GoogleUser) (providerID, email, name string) {
|
|
||||||
return user.ID, user.Email, user.Name
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- +goose StatementBegin
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
name VARCHAR(255),
|
|
||||||
password_hash VARCHAR(255),
|
|
||||||
email_verified BOOLEAN DEFAULT FALSE,
|
|
||||||
provider VARCHAR(50) NOT NULL DEFAULT 'email',
|
|
||||||
provider_id VARCHAR(255),
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
last_login_at TIMESTAMP WITH TIME ZONE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_provider ON users(provider, provider_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS magic_links (
|
|
||||||
token VARCHAR(255) PRIMARY KEY,
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
email VARCHAR(255) NOT NULL,
|
|
||||||
used BOOLEAN DEFAULT FALSE,
|
|
||||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_magic_links_user_id ON magic_links(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_magic_links_expires ON magic_links(expires_at) WHERE used = FALSE;
|
|
||||||
|
|
||||||
-- +goose StatementEnd
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
-- +goose StatementBegin
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS magic_links;
|
|
||||||
DROP TABLE IF EXISTS users;
|
|
||||||
|
|
||||||
-- +goose StatementEnd
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- +goose StatementBegin
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS stripe_kv (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value JSONB NOT NULL,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_stripe_kv_updated_at ON stripe_kv(updated_at);
|
|
||||||
|
|
||||||
-- +goose StatementEnd
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
-- +goose StatementBegin
|
|
||||||
|
|
||||||
DROP INDEX IF EXISTS idx_stripe_kv_updated_at;
|
|
||||||
DROP TABLE IF EXISTS stripe_kv;
|
|
||||||
|
|
||||||
-- +goose StatementEnd
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://railway.app/railway.schema.json",
|
|
||||||
"build": {
|
|
||||||
"builder": "DOCKERFILE",
|
|
||||||
"dockerfilePath": "Dockerfile"
|
|
||||||
},
|
|
||||||
"deploy": {
|
|
||||||
"restartPolicyType": "ON_FAILURE",
|
|
||||||
"restartPolicyMaxRetries": 10,
|
|
||||||
"healthcheckPath": "/health",
|
|
||||||
"healthcheckTimeout": 30,
|
|
||||||
"numReplicas": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB |
@@ -1,407 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Bookra Email Templates Preview</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
font-family: 'Newsreader', Georgia, serif;
|
|
||||||
background: #fbf9f6;
|
|
||||||
margin: 0;
|
|
||||||
padding: 40px 20px;
|
|
||||||
color: #2a221e;
|
|
||||||
}
|
|
||||||
.container { max-width: 1400px; margin: 0 auto; }
|
|
||||||
h1 {
|
|
||||||
font-family: 'Space Grotesk', sans-serif;
|
|
||||||
text-align: center;
|
|
||||||
color: #2a221e;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
.subtitle {
|
|
||||||
text-align: center;
|
|
||||||
color: #5c514a;
|
|
||||||
margin-bottom: 48px;
|
|
||||||
font-size: 17px;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(600px, 1fr));
|
|
||||||
gap: 32px;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: white;
|
|
||||||
border-radius: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(42, 34, 30, 0.05);
|
|
||||||
border: 1px solid #e8e2da;
|
|
||||||
}
|
|
||||||
.card-header {
|
|
||||||
background: #fbf9f6;
|
|
||||||
padding: 24px 28px;
|
|
||||||
border-bottom: 1px solid #e8e2da;
|
|
||||||
}
|
|
||||||
.card-header h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-family: 'Space Grotesk', sans-serif;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #2a221e;
|
|
||||||
}
|
|
||||||
.card-header p {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
color: #5c514a;
|
|
||||||
font-size: 14px;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.card-body { padding: 0; }
|
|
||||||
iframe {
|
|
||||||
width: 100%;
|
|
||||||
height: 500px;
|
|
||||||
border: none;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
.toggle {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
.toggle button {
|
|
||||||
padding: 12px 28px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #e8e2da;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: 'Space Grotesk', sans-serif;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
background: white;
|
|
||||||
color: #5c514a;
|
|
||||||
}
|
|
||||||
.toggle button.active {
|
|
||||||
background: #a65c3e;
|
|
||||||
color: white;
|
|
||||||
border-color: #a65c3e;
|
|
||||||
}
|
|
||||||
.toggle button:not(.active):hover {
|
|
||||||
background: #f5f2ed;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>Bookra Email Templates</h1>
|
|
||||||
<p class="subtitle">Warm editorial aesthetic with terracotta accents</p>
|
|
||||||
|
|
||||||
<div class="toggle">
|
|
||||||
<button class="active" onclick="showLang('en')">English</button>
|
|
||||||
<button onclick="showLang('cs')">Čeština</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid" id="emailGrid">
|
|
||||||
<!-- Magic Link EN -->
|
|
||||||
<div class="card" data-lang="en">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Magic Link</h2>
|
|
||||||
<p>Passwordless authentication</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<iframe srcdoc='
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: white; }
|
|
||||||
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
|
|
||||||
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
|
|
||||||
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
|
|
||||||
.tagline { color: #8b7f76; font-size: 15px; margin-top: 6px; font-style: italic; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 20px; font-weight: 600; color: #2a221e; margin-bottom: 16px; }
|
|
||||||
.message { font-size: 17px; line-height: 1.6; color: #5c514a; margin-bottom: 32px; }
|
|
||||||
.button-wrap { margin: 40px 0; }
|
|
||||||
.button { display: inline-block; background: #a65c3e; color: white; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: "Space Grotesk", sans-serif; font-weight: 500; font-size: 15px; }
|
|
||||||
.link-box { background: #f5f2ed; border: 1px solid #e8e2da; border-radius: 8px; padding: 20px; margin: 32px 0; }
|
|
||||||
.link-label { font-size: 12px; font-weight: 600; color: #8b7f76; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: "Space Grotesk", sans-serif; }
|
|
||||||
.link-url { font-size: 14px; color: #5c514a; word-break: break-all; font-family: monospace; }
|
|
||||||
.expiry { background: #f5ebe7; border-left: 3px solid #a65c3e; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: #a65c3e; }
|
|
||||||
.help { font-size: 15px; color: #8b7f76; margin-top: 40px; padding-top: 32px; border-top: 1px solid #e8e2da; font-style: italic; }
|
|
||||||
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
|
|
||||||
.footer-text { font-size: 14px; color: #8b7f76; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
<div class="tagline">Calm booking software</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Hi Sarah,</div>
|
|
||||||
<div class="message">
|
|
||||||
You requested a sign-in link for your Bookra account. Click below to access your account securely — no password needed.
|
|
||||||
</div>
|
|
||||||
<div class="button-wrap">
|
|
||||||
<a href="#" class="button">Sign In to Bookra</a>
|
|
||||||
</div>
|
|
||||||
<div class="link-box">
|
|
||||||
<div class="link-label">Or copy this link</div>
|
|
||||||
<div class="link-url">https://bookra.tdvorak.dev/auth/callback?token=xyz123...</div>
|
|
||||||
</div>
|
|
||||||
<div class="expiry">
|
|
||||||
This link expires in <strong>15 minutes</strong> for security.
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
Didn't request this? You can safely ignore it.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>'>
|
|
||||||
</iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Welcome EN -->
|
|
||||||
<div class="card" data-lang="en">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Welcome Email</h2>
|
|
||||||
<p>New user onboarding</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<iframe srcdoc='
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: white; }
|
|
||||||
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
|
|
||||||
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
|
|
||||||
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 600; color: #2a221e; margin-bottom: 16px; }
|
|
||||||
.message { font-size: 18px; line-height: 1.6; color: #5c514a; margin-bottom: 32px; }
|
|
||||||
.features { background: #f5f2ed; border-radius: 12px; padding: 32px; margin: 32px 0; }
|
|
||||||
.feature { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
|
||||||
.feature:last-child { margin-bottom: 0; }
|
|
||||||
.feature-icon { width: 24px; height: 24px; background: #a65c3e; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: white; font-size: 14px; flex-shrink: 0; }
|
|
||||||
.feature-text { font-size: 16px; color: #5c514a; line-height: 1.5; }
|
|
||||||
.button-wrap { margin: 40px 0; text-align: center; }
|
|
||||||
.button { display: inline-block; background: #a65c3e; color: white; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: "Space Grotesk", sans-serif; font-weight: 500; font-size: 15px; }
|
|
||||||
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
|
|
||||||
.footer-text { font-size: 14px; color: #8b7f76; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Welcome, Sarah</div>
|
|
||||||
<div class="message">
|
|
||||||
Thanks for joining Bookra. We're here to help you manage bookings with calm and clarity.
|
|
||||||
</div>
|
|
||||||
<div class="features">
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature-icon">✓</div>
|
|
||||||
<div class="feature-text"><strong>Smart scheduling</strong> — Automatic conflict detection</div>
|
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature-icon">✓</div>
|
|
||||||
<div class="feature-text"><strong>Customer insights</strong> — History and preferences</div>
|
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<div class="feature-icon">✓</div>
|
|
||||||
<div class="feature-text"><strong>Reminders</strong> — Reduce no-shows</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="button-wrap">
|
|
||||||
<a href="#" class="button">Open Dashboard</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>'>
|
|
||||||
</iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Booking Confirmation EN -->
|
|
||||||
<div class="card" data-lang="en">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Booking Confirmation</h2>
|
|
||||||
<p>Customer confirmation email</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<iframe srcdoc='
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: white; }
|
|
||||||
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
|
|
||||||
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
|
|
||||||
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.badge { display: inline-block; background: #f5ebe7; color: #a65c3e; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 24px; font-family: "Space Grotesk", sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
||||||
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; color: #2a221e; margin-bottom: 8px; }
|
|
||||||
.message { font-size: 17px; color: #5c514a; margin-bottom: 32px; }
|
|
||||||
.details { background: #f5f2ed; border-radius: 12px; padding: 28px; margin: 32px 0; }
|
|
||||||
.detail-row { display: flex; padding: 14px 0; border-bottom: 1px solid #e8e2da; }
|
|
||||||
.detail-row:last-child { border-bottom: none; }
|
|
||||||
.detail-label { width: 120px; font-size: 12px; font-weight: 600; color: #8b7f76; text-transform: uppercase; letter-spacing: 0.05em; font-family: "Space Grotesk", sans-serif; }
|
|
||||||
.detail-value { flex: 1; font-size: 16px; color: #2a221e; font-weight: 500; }
|
|
||||||
.help { font-size: 15px; color: #8b7f76; margin-top: 32px; padding-top: 32px; border-top: 1px solid #e8e2da; font-style: italic; }
|
|
||||||
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
|
|
||||||
.footer-text { font-size: 14px; color: #8b7f76; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="badge">Confirmed</div>
|
|
||||||
<div class="greeting">Hello Sarah,</div>
|
|
||||||
<div class="message">
|
|
||||||
Your booking with <strong>Studio Ella</strong> is confirmed.
|
|
||||||
</div>
|
|
||||||
<div class="details">
|
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">Service</div>
|
|
||||||
<div class="detail-value">Haircut & Styling</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">When</div>
|
|
||||||
<div class="detail-value">Monday, April 22 at 2:00 PM</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">Where</div>
|
|
||||||
<div class="detail-value">123 Main Street, Prague 1</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
Need to reschedule? Contact Studio Ella directly.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>'>
|
|
||||||
</iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Magic Link CS -->
|
|
||||||
<div class="card" data-lang="cs" style="display:none">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>Magický Odkaz (CZ)</h2>
|
|
||||||
<p>Přihlášení bez hesla</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<iframe srcdoc='
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="cs">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: white; }
|
|
||||||
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
|
|
||||||
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
|
|
||||||
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
|
|
||||||
.tagline { color: #8b7f76; font-size: 15px; margin-top: 6px; font-style: italic; }
|
|
||||||
.content { padding: 48px 40px; }
|
|
||||||
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 20px; font-weight: 600; color: #2a221e; margin-bottom: 16px; }
|
|
||||||
.message { font-size: 17px; line-height: 1.6; color: #5c514a; margin-bottom: 32px; }
|
|
||||||
.button-wrap { margin: 40px 0; }
|
|
||||||
.button { display: inline-block; background: #a65c3e; color: white; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: "Space Grotesk", sans-serif; font-weight: 500; font-size: 15px; }
|
|
||||||
.link-box { background: #f5f2ed; border: 1px solid #e8e2da; border-radius: 8px; padding: 20px; margin: 32px 0; }
|
|
||||||
.link-label { font-size: 12px; font-weight: 600; color: #8b7f76; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: "Space Grotesk", sans-serif; }
|
|
||||||
.link-url { font-size: 14px; color: #5c514a; word-break: break-all; font-family: monospace; }
|
|
||||||
.expiry { background: #f5ebe7; border-left: 3px solid #a65c3e; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: #a65c3e; }
|
|
||||||
.help { font-size: 15px; color: #8b7f76; margin-top: 40px; padding-top: 32px; border-top: 1px solid #e8e2da; font-style: italic; }
|
|
||||||
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
|
|
||||||
.footer-text { font-size: 14px; color: #8b7f76; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">B</div>
|
|
||||||
<div class="brand">Bookra</div>
|
|
||||||
<div class="tagline">Klidný rezervační software</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Dobrý den Martino,</div>
|
|
||||||
<div class="message">
|
|
||||||
Požádali jste o přihlašovací odkaz k účtu Bookra. Klikněte níže pro bezpečný přístup — heslo není potřeba.
|
|
||||||
</div>
|
|
||||||
<div class="button-wrap">
|
|
||||||
<a href="#" class="button">Přihlásit se do Bookra</a>
|
|
||||||
</div>
|
|
||||||
<div class="link-box">
|
|
||||||
<div class="link-label">Nebo zkopírujte tento odkaz</div>
|
|
||||||
<div class="link-url">https://bookra.tdvorak.dev/auth/callback?token=xyz123...</div>
|
|
||||||
</div>
|
|
||||||
<div class="expiry">
|
|
||||||
Tento odkaz vyprší za <strong>15 minut</strong> z bezpečnostních důvodů.
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
Nepožádali jste o tento email? Můžete ho bezpečně ignorovat.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">© 2024 Bookra</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>'>
|
|
||||||
</iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function showLang(lang) {
|
|
||||||
document.querySelectorAll(".toggle button").forEach(btn => btn.classList.remove("active"));
|
|
||||||
event.target.classList.add("active");
|
|
||||||
document.querySelectorAll("[data-lang]").forEach(card => {
|
|
||||||
card.style.display = card.dataset.lang === lang ? "block" : "none";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -11,14 +11,37 @@ import (
|
|||||||
"bookra/apps/backend/internal/api"
|
"bookra/apps/backend/internal/api"
|
||||||
"bookra/apps/backend/internal/config"
|
"bookra/apps/backend/internal/config"
|
||||||
"bookra/apps/backend/internal/db"
|
"bookra/apps/backend/internal/db"
|
||||||
|
|
||||||
|
sentry "github.com/getsentry/sentry-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func initSentry(cfg config.Config) {
|
||||||
|
if cfg.SentryDSN == "" {
|
||||||
|
log.Println("Sentry DSN not configured - skipping initialization")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := sentry.Init(sentry.ClientOptions{
|
||||||
|
Dsn: cfg.SentryDSN,
|
||||||
|
Environment: cfg.Environment,
|
||||||
|
Release: "bookra@1.0.0",
|
||||||
|
// Set TracesSampleRate to 1.0 to capture 100% of transactions for testing
|
||||||
|
TracesSampleRate: 1.0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Sentry initialization failed: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("Sentry initialized")
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("load config: %v", err)
|
log.Fatalf("load config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initSentry(cfg)
|
||||||
|
|
||||||
pools, err := db.NewPools(cfg)
|
pools, err := db.NewPools(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("create database pools: %v", err)
|
log.Fatalf("create database pools: %v", err)
|
||||||
@@ -31,6 +54,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
|
// Start background job for trial ending emails
|
||||||
|
go server.StartBackgroundJobs()
|
||||||
|
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
Handler: server.Handler(),
|
Handler: server.Handler(),
|
||||||
|
|||||||
+3
-1
@@ -3,13 +3,14 @@ module bookra/apps/backend
|
|||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0
|
|
||||||
github.com/MicahParks/keyfunc/v3 v3.8.0
|
github.com/MicahParks/keyfunc/v3 v3.8.0
|
||||||
|
github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0
|
||||||
github.com/gin-contrib/cors v1.7.7
|
github.com/gin-contrib/cors v1.7.7
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.9.1
|
github.com/jackc/pgx/v5 v5.9.1
|
||||||
|
github.com/stripe/stripe-go/v81 v81.0.0
|
||||||
golang.org/x/time v0.9.0
|
golang.org/x/time v0.9.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ require (
|
|||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
|
github.com/getsentry/sentry-go v0.46.2 // indirect
|
||||||
github.com/ggicci/httpin v0.20.3 // indirect
|
github.com/ggicci/httpin v0.20.3 // indirect
|
||||||
github.com/ggicci/owl v0.8.2 // indirect
|
github.com/ggicci/owl v0.8.2 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns=
|
||||||
|
github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
|
||||||
github.com/ggicci/httpin v0.20.3 h1:qy93bUsF/eGbX8WJfXEjB3bjgUrv7MKZ3qVWR73DIGY=
|
github.com/ggicci/httpin v0.20.3 h1:qy93bUsF/eGbX8WJfXEjB3bjgUrv7MKZ3qVWR73DIGY=
|
||||||
github.com/ggicci/httpin v0.20.3/go.mod h1:ppHGT8xt99mRnDUuehLLWl2RAVLKG+VGn48GjK5xaLA=
|
github.com/ggicci/httpin v0.20.3/go.mod h1:ppHGT8xt99mRnDUuehLLWl2RAVLKG+VGn48GjK5xaLA=
|
||||||
github.com/ggicci/owl v0.8.2 h1:og+lhqpzSMPDdEB+NJfzoAJARP7qCG3f8uUC3xvGukA=
|
github.com/ggicci/owl v0.8.2 h1:og+lhqpzSMPDdEB+NJfzoAJARP7qCG3f8uUC3xvGukA=
|
||||||
@@ -58,6 +60,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
|
|||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
|
||||||
|
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
@@ -89,6 +93,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
|
|||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/stripe/stripe-go/v81 v81.0.0 h1:7xqKVXIjhFoSEUzXXPON7oYFRupOyhDG5R7tRVyrgeE=
|
||||||
|
github.com/stripe/stripe-go/v81 v81.0.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
@@ -101,17 +107,23 @@ golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
|||||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookra/apps/backend/internal/db"
|
||||||
|
"bookra/apps/backend/internal/domain"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
|
ErrForbidden = errors.New("forbidden: admin access required")
|
||||||
|
ErrInvalidAdminCreds = errors.New("invalid admin credentials")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo db.Repository
|
||||||
|
adminEmail string
|
||||||
|
adminKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo db.Repository, adminEmail, adminKey string) *Service {
|
||||||
|
return &Service{
|
||||||
|
repo: repo,
|
||||||
|
adminEmail: adminEmail,
|
||||||
|
adminKey: adminKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConfigured returns true if admin credentials are set
|
||||||
|
func (s *Service) IsConfigured() bool {
|
||||||
|
return s.adminEmail != "" && s.adminKey != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAdminLogin checks if the provided credentials match the admin credentials
|
||||||
|
// Uses constant-time comparison to prevent timing attacks
|
||||||
|
func (s *Service) ValidateAdminLogin(email, key string) bool {
|
||||||
|
if !s.IsConfigured() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
emailMatch := subtle.ConstantTimeCompare([]byte(email), []byte(s.adminEmail)) == 1
|
||||||
|
keyMatch := subtle.ConstantTimeCompare([]byte(key), []byte(s.adminKey)) == 1
|
||||||
|
|
||||||
|
return emailMatch && keyMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAdmin is middleware that checks for admin authentication
|
||||||
|
// It supports two modes:
|
||||||
|
// 1. Admin credentials via X-Admin-Email and X-Admin-Key headers (for API access)
|
||||||
|
// 2. Session-based auth where the user has role "admin" or "superadmin"
|
||||||
|
func RequireAdmin(adminSvc *Service, authSvc interface{ IsAdmin(ctx context.Context, userID string) (bool, error) }) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Check for admin header credentials (direct admin login)
|
||||||
|
adminEmail := c.GetHeader("X-Admin-Email")
|
||||||
|
adminKey := c.GetHeader("X-Admin-Key")
|
||||||
|
|
||||||
|
if adminEmail != "" && adminKey != "" {
|
||||||
|
if adminSvc.ValidateAdminLogin(adminEmail, adminKey) {
|
||||||
|
c.Set("isAdmin", true)
|
||||||
|
c.Set("adminMode", "credentials")
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Bearer token with admin role
|
||||||
|
auth := c.GetHeader("Authorization")
|
||||||
|
if auth != "" && strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
// The auth middleware should have already validated the token
|
||||||
|
// and set the user info in context
|
||||||
|
userID, exists := c.Get("userID")
|
||||||
|
if !exists {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin, err := authSvc.IsAdmin(c.Request.Context(), userID.(string))
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isAdmin {
|
||||||
|
c.Set("isAdmin", true)
|
||||||
|
c.Set("adminMode", "session")
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDashboardStats returns platform-wide statistics for admin dashboard
|
||||||
|
func (s *Service) GetDashboardStats(ctx context.Context) (domain.AdminDashboardStats, error) {
|
||||||
|
stats, err := s.repo.GetPlatformStats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AdminDashboardStats{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.AdminDashboardStats{
|
||||||
|
TotalTenants: stats.TotalTenants,
|
||||||
|
TotalUsers: stats.TotalUsers,
|
||||||
|
ActiveSubscriptions: stats.ActiveSubscriptions,
|
||||||
|
TrialSubscriptions: stats.TrialSubscriptions,
|
||||||
|
BookingsThisMonth: stats.BookingsThisMonth,
|
||||||
|
RevenueThisMonthCents: stats.RevenueThisMonth,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTenants returns paginated list of all tenants
|
||||||
|
func (s *Service) ListTenants(ctx context.Context, page, pageSize int) (domain.AdminTenantList, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 || pageSize > 100 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
tenants, total, err := s.repo.ListAllTenants(ctx, pageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AdminTenantList{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := domain.AdminTenantList{
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Tenants: make([]domain.AdminTenant, len(tenants)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, t := range tenants {
|
||||||
|
result.Tenants[i] = domain.AdminTenant{
|
||||||
|
ID: t.ID,
|
||||||
|
Slug: t.Slug,
|
||||||
|
Name: t.Name,
|
||||||
|
PlanCode: t.PlanCode,
|
||||||
|
SubscriptionStatus: t.SubscriptionStatus,
|
||||||
|
BillingProvider: t.BillingProvider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns paginated list of all users
|
||||||
|
func (s *Service) ListUsers(ctx context.Context, page, pageSize int) (domain.AdminUserList, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 || pageSize > 100 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
users, total, err := s.repo.ListAllUsers(ctx, pageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AdminUserList{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := domain.AdminUserList{
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Users: make([]domain.AdminUser, len(users)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, u := range users {
|
||||||
|
result.Users[i] = domain.AdminUser{
|
||||||
|
ID: u.ID.String(),
|
||||||
|
Email: u.Email,
|
||||||
|
Name: stringPtrToStr(u.Name),
|
||||||
|
EmailVerified: u.EmailVerified,
|
||||||
|
Provider: u.Provider,
|
||||||
|
Role: u.Role,
|
||||||
|
CreatedAt: u.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserRole changes a user's role
|
||||||
|
func (s *Service) UpdateUserRole(ctx context.Context, adminUserID, targetUserID, newRole string, ip, userAgent string) error {
|
||||||
|
// Validate role
|
||||||
|
validRoles := map[string]bool{
|
||||||
|
"user": true,
|
||||||
|
"admin": true,
|
||||||
|
"superadmin": true,
|
||||||
|
}
|
||||||
|
if !validRoles[newRole] {
|
||||||
|
return errors.New("invalid role")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.UpdateUserRole(ctx, targetUserID, newRole); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the action
|
||||||
|
return s.repo.CreateAdminAuditLog(ctx, db.AdminAuditLogParams{
|
||||||
|
AdminUserID: adminUserID,
|
||||||
|
Action: "update_user_role",
|
||||||
|
ResourceType: "user",
|
||||||
|
ResourceID: targetUserID,
|
||||||
|
Details: map[string]any{
|
||||||
|
"newRole": newRole,
|
||||||
|
},
|
||||||
|
IPAddress: ip,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncTenantSubscription manually syncs a tenant's subscription from Stripe
|
||||||
|
func (s *Service) SyncTenantSubscription(ctx context.Context, tenantID string) error {
|
||||||
|
// This will be called from the billing service
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringPtrToStr(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Ensure time package is imported
|
||||||
|
_ = time.Now()
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"bookra/apps/backend/internal/admin"
|
||||||
"bookra/apps/backend/internal/auth"
|
"bookra/apps/backend/internal/auth"
|
||||||
"bookra/apps/backend/internal/billing"
|
"bookra/apps/backend/internal/billing"
|
||||||
"bookra/apps/backend/internal/bookings"
|
"bookra/apps/backend/internal/bookings"
|
||||||
@@ -16,18 +20,25 @@ import (
|
|||||||
"bookra/apps/backend/internal/domain"
|
"bookra/apps/backend/internal/domain"
|
||||||
"bookra/apps/backend/internal/httpx"
|
"bookra/apps/backend/internal/httpx"
|
||||||
"bookra/apps/backend/internal/notifications"
|
"bookra/apps/backend/internal/notifications"
|
||||||
|
"bookra/apps/backend/internal/sms"
|
||||||
"bookra/apps/backend/internal/tenancy"
|
"bookra/apps/backend/internal/tenancy"
|
||||||
|
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/gin-contrib/cors"
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
router *gin.Engine
|
router *gin.Engine
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
pools *db.Pools
|
pools *db.Pools
|
||||||
verifier *auth.Verifier
|
verifier *auth.Verifier
|
||||||
|
authService *auth.Service
|
||||||
|
adminService *admin.Service
|
||||||
|
billingService *billing.Service
|
||||||
|
notificationService *notifications.Service
|
||||||
|
smsService *sms.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
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)
|
bookingService := bookings.NewService(repository, notificationService)
|
||||||
customerBookingService := bookings.NewCustomerService(repository, notificationService)
|
customerBookingService := bookings.NewCustomerService(repository, notificationService)
|
||||||
tenantService := tenancy.NewService(repository)
|
tenantService := tenancy.NewService(repository)
|
||||||
catalogService := catalog.NewService(repository)
|
|
||||||
billingService := billing.NewService(cfg, repository)
|
billingService := billing.NewService(cfg, repository)
|
||||||
|
catalogService := catalog.NewService(repository, billingService, notificationService)
|
||||||
|
authService := auth.NewService(repository, cfg.AuthJWTSecret)
|
||||||
|
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
|
||||||
|
smsService := sms.NewService(cfg, repository)
|
||||||
publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5)
|
publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5)
|
||||||
|
|
||||||
server := &Server{
|
server := &Server{
|
||||||
router: gin.New(),
|
router: gin.New(),
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
pools: pools,
|
pools: pools,
|
||||||
verifier: verifier,
|
verifier: verifier,
|
||||||
|
authService: authService,
|
||||||
|
adminService: adminService,
|
||||||
|
billingService: billingService,
|
||||||
|
notificationService: notificationService,
|
||||||
|
smsService: smsService,
|
||||||
}
|
}
|
||||||
|
|
||||||
server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{
|
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) {
|
server.router.GET("/v1/meta/config", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"environment": cfg.Environment,
|
"environment": cfg.Environment,
|
||||||
"neonAuthEnabled": verifier.Enabled(),
|
"neonAuthEnabled": verifier.Enabled(),
|
||||||
"apiUrl": cfg.APIURL,
|
"apiUrl": cfg.APIURL,
|
||||||
"demoMode": cfg.DemoMode,
|
"demoMode": cfg.DemoMode,
|
||||||
|
"adminLoginEnabled": adminService.IsConfigured(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// AUTH API
|
||||||
|
// ============================================
|
||||||
|
authGroup := server.router.Group("/v1/auth")
|
||||||
|
{
|
||||||
|
authGroup.POST("/register", func(c *gin.Context) {
|
||||||
|
var request struct {
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
Password string `json:"password" binding:"required,min=8"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, tokens, err := authService.RegisterWithPassword(c.Request.Context(), request.Email, request.Password, request.Name)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, auth.ErrEmailAlreadyExists) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, auth.ErrPasswordTooShort) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "registration_failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"user": gin.H{
|
||||||
|
"id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
"name": user.Name,
|
||||||
|
},
|
||||||
|
"accessToken": tokens.AccessToken,
|
||||||
|
"refreshToken": tokens.RefreshToken,
|
||||||
|
"tokenType": tokens.TokenType,
|
||||||
|
"expiresIn": tokens.ExpiresIn,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
authGroup.POST("/login", func(c *gin.Context) {
|
||||||
|
var request struct {
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, tokens, err := authService.LoginWithPassword(c.Request.Context(), request.Email, request.Password)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, auth.ErrInvalidCredentials) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "login_failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"user": gin.H{
|
||||||
|
"id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
"name": user.Name,
|
||||||
|
},
|
||||||
|
"accessToken": tokens.AccessToken,
|
||||||
|
"refreshToken": tokens.RefreshToken,
|
||||||
|
"tokenType": tokens.TokenType,
|
||||||
|
"expiresIn": tokens.ExpiresIn,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
authGroup.POST("/magic-link", func(c *gin.Context) {
|
||||||
|
var request struct {
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := authService.CreateMagicLink(c.Request.Context(), request.Email)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "magic_link_failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "magic_link_sent"})
|
||||||
|
})
|
||||||
|
|
||||||
|
authGroup.POST("/verify", func(c *gin.Context) {
|
||||||
|
var request struct {
|
||||||
|
Token string `json:"token" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, tokens, err := authService.VerifyMagicLink(c.Request.Context(), request.Token)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"user": gin.H{
|
||||||
|
"id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
"name": user.Name,
|
||||||
|
},
|
||||||
|
"accessToken": tokens.AccessToken,
|
||||||
|
"refreshToken": tokens.RefreshToken,
|
||||||
|
"tokenType": tokens.TokenType,
|
||||||
|
"expiresIn": tokens.ExpiresIn,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
authGroup.POST("/refresh", func(c *gin.Context) {
|
||||||
|
var request struct {
|
||||||
|
RefreshToken string `json:"refreshToken" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokens, err := authService.RefreshToken(c.Request.Context(), request.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"accessToken": tokens.AccessToken,
|
||||||
|
"refreshToken": tokens.RefreshToken,
|
||||||
|
"tokenType": tokens.TokenType,
|
||||||
|
"expiresIn": tokens.ExpiresIn,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ADMIN API
|
||||||
|
// ============================================
|
||||||
|
adminGroup := server.router.Group("/v1/admin")
|
||||||
|
adminGroup.Use(admin.RequireAdmin(adminService, authService))
|
||||||
|
{
|
||||||
|
adminGroup.GET("/stats", func(c *gin.Context) {
|
||||||
|
stats, err := adminService.GetDashboardStats(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, stats)
|
||||||
|
})
|
||||||
|
|
||||||
|
adminGroup.GET("/tenants", func(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||||
|
result, err := adminService.ListTenants(c.Request.Context(), page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
adminGroup.GET("/users", func(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||||
|
result, err := adminService.ListUsers(c.Request.Context(), page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
adminGroup.PUT("/users/:userID/role", func(c *gin.Context) {
|
||||||
|
var request domain.UpdateUserRoleRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminUserID, _ := c.Get("userID")
|
||||||
|
err := adminService.UpdateUserRole(
|
||||||
|
c.Request.Context(),
|
||||||
|
adminUserID.(string),
|
||||||
|
c.Param("userID"),
|
||||||
|
request.Role,
|
||||||
|
c.ClientIP(),
|
||||||
|
c.GetHeader("User-Agent"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trigger trial ending email check
|
||||||
|
adminGroup.POST("/trigger-trial-emails", func(c *gin.Context) {
|
||||||
|
err := billingService.CheckAndSendTrialEndingEmails(c.Request.Context(), notificationService)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "completed", "message": "Trial ending emails sent"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
server.router.GET("/v1/public/tenants/:tenantSlug/availability", func(c *gin.Context) {
|
server.router.GET("/v1/public/tenants/:tenantSlug/availability", func(c *gin.Context) {
|
||||||
response, err := bookingService.Availability(c.Request.Context(), c.Param("tenantSlug"))
|
response, err := bookingService.Availability(c.Request.Context(), c.Param("tenantSlug"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -99,6 +344,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
LocationID *string `json:"locationId"`
|
LocationID *string `json:"locationId"`
|
||||||
CustomerName string `json:"customerName" binding:"required"`
|
CustomerName string `json:"customerName" binding:"required"`
|
||||||
CustomerEmail string `json:"customerEmail" binding:"required,email"`
|
CustomerEmail string `json:"customerEmail" binding:"required,email"`
|
||||||
|
CustomerPhone string `json:"customerPhone"`
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
StartsAt string `json:"startsAt" binding:"required"`
|
StartsAt string `json:"startsAt" binding:"required"`
|
||||||
EndsAt string `json:"endsAt" binding:"required"`
|
EndsAt string `json:"endsAt" binding:"required"`
|
||||||
@@ -126,6 +372,23 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
c.JSON(http.StatusCreated, response)
|
c.JSON(http.StatusCreated, response)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
server.router.POST("/v1/public/contact", publicRateLimiter.Middleware(), func(c *gin.Context) {
|
||||||
|
var request struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
Message string `json:"message" binding:"required,min=10"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := notificationService.SendContactEmail(c.Request.Context(), request.Name, request.Email, request.Message); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send message"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "sent"})
|
||||||
|
})
|
||||||
|
|
||||||
protected := server.router.Group("/v1")
|
protected := server.router.Group("/v1")
|
||||||
protected.Use(auth.RequireAuth(verifier, repository, cfg.DemoMode))
|
protected.Use(auth.RequireAuth(verifier, repository, cfg.DemoMode))
|
||||||
|
|
||||||
@@ -196,6 +459,8 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
status := http.StatusInternalServerError
|
status := http.StatusInternalServerError
|
||||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||||
status = http.StatusNotFound
|
status = http.StatusNotFound
|
||||||
|
} else if errors.Is(err, catalog.ErrPlanLimitReached) {
|
||||||
|
status = http.StatusForbidden
|
||||||
}
|
}
|
||||||
c.JSON(status, gin.H{"error": err.Error()})
|
c.JSON(status, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -492,7 +757,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response, err := billingService.CreateCheckoutSession(c.Request.Context(), auth.PrincipalFromContext(c), request.PlanCode, request.Currency)
|
response, err := billingService.CreateCheckoutSession(c.Request.Context(), auth.PrincipalFromContext(c), request.PlanCode, request.Currency, request.BillingInterval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
status := http.StatusInternalServerError
|
status := http.StatusInternalServerError
|
||||||
if errors.Is(err, billing.ErrBillingMembership) {
|
if errors.Is(err, billing.ErrBillingMembership) {
|
||||||
@@ -542,6 +807,141 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SMS API
|
||||||
|
// ============================================
|
||||||
|
protected.GET("/sms/settings", func(c *gin.Context) {
|
||||||
|
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings, err := smsService.GetSettings(c.Request.Context(), membership.Tenant.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, settings)
|
||||||
|
})
|
||||||
|
protected.POST("/sms/settings", func(c *gin.Context) {
|
||||||
|
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request domain.UpdateSMSSettingsRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings, err := smsService.UpdateSettings(c.Request.Context(), membership.Tenant.ID, request)
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
if errors.Is(err, sms.ErrSMSPlanNotAllowed) {
|
||||||
|
status = http.StatusForbidden
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, settings)
|
||||||
|
})
|
||||||
|
protected.POST("/sms/send", func(c *gin.Context) {
|
||||||
|
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request domain.SendSMSRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response, err := smsService.SendMessage(c.Request.Context(), membership.Tenant.ID, request)
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, sms.ErrSMSNotConfigured), errors.Is(err, sms.ErrSMSNotEnabled):
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
case errors.Is(err, sms.ErrSMSPlanNotAllowed):
|
||||||
|
status = http.StatusForbidden
|
||||||
|
case errors.Is(err, sms.ErrSMSLimitReached):
|
||||||
|
status = http.StatusTooManyRequests
|
||||||
|
case errors.Is(err, sms.ErrSMSInvalidPhone):
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
})
|
||||||
|
protected.GET("/sms/usage", func(c *gin.Context) {
|
||||||
|
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
yearMonth := c.Query("month")
|
||||||
|
if yearMonth == "" {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
yearMonth = fmt.Sprintf("%04d-%02d", now.Year(), now.Month())
|
||||||
|
}
|
||||||
|
report, err := smsService.GetUsage(c.Request.Context(), membership.Tenant.ID, yearMonth)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, report)
|
||||||
|
})
|
||||||
|
protected.GET("/sms/history", func(c *gin.Context) {
|
||||||
|
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := 50
|
||||||
|
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 100 {
|
||||||
|
limit = l
|
||||||
|
}
|
||||||
|
logs, err := smsService.GetUsageHistory(c.Request.Context(), membership.Tenant.ID, limit)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"logs": logs})
|
||||||
|
})
|
||||||
|
protected.GET("/sms/invoices", func(c *gin.Context) {
|
||||||
|
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := 12
|
||||||
|
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 24 {
|
||||||
|
limit = l
|
||||||
|
}
|
||||||
|
reports, err := smsService.GetMonthlyReports(c.Request.Context(), membership.Tenant.ID, limit)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"reports": reports})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Internal job endpoint for SMS monthly invoicing
|
||||||
|
server.router.POST("/v1/internal/jobs/sms/invoices", func(c *gin.Context) {
|
||||||
|
if !authorizeJobRunner(c, cfg) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
yearMonth := c.Query("month")
|
||||||
|
response, err := smsService.GenerateMonthlyInvoices(c.Request.Context(), yearMonth, notificationService)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
})
|
||||||
|
|
||||||
server.router.POST("/v1/webhooks/paddle", func(c *gin.Context) {
|
server.router.POST("/v1/webhooks/paddle", func(c *gin.Context) {
|
||||||
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil {
|
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -549,6 +949,13 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
|
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
|
||||||
})
|
})
|
||||||
|
server.router.POST("/v1/webhooks/stripe", func(c *gin.Context) {
|
||||||
|
if err := billingService.HandleStripeWebhook(c.Request.Context(), c.Request); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
|
||||||
|
})
|
||||||
server.router.POST("/api/paddle_webhook", func(c *gin.Context) {
|
server.router.POST("/api/paddle_webhook", func(c *gin.Context) {
|
||||||
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil {
|
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -597,6 +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 {
|
func authorizeJobRunner(c *gin.Context, cfg config.Config) bool {
|
||||||
if cfg.JobRunnerKey == "" {
|
if cfg.JobRunnerKey == "" {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookra/apps/backend/internal/db"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
accessTokenTTL = 24 * time.Hour
|
||||||
|
refreshTokenTTL = 30 * 24 * time.Hour
|
||||||
|
magicLinkTTL = 15 * time.Minute
|
||||||
|
passwordResetTTL = 30 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||||
|
ErrInvalidToken = errors.New("invalid or expired token")
|
||||||
|
ErrUserNotFound = errors.New("user not found")
|
||||||
|
ErrEmailAlreadyExists = errors.New("email already exists")
|
||||||
|
ErrPasswordTooShort = errors.New("password must be at least 8 characters")
|
||||||
|
ErrMagicLinkExpired = errors.New("magic link expired")
|
||||||
|
ErrMagicLinkUsed = errors.New("magic link already used")
|
||||||
|
ErrInvalidResetToken = errors.New("invalid or expired reset token")
|
||||||
|
)
|
||||||
|
|
||||||
|
type TokenPair struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
RefreshToken string `json:"refreshToken,omitempty"`
|
||||||
|
TokenType string `json:"tokenType"`
|
||||||
|
ExpiresIn int `json:"expiresIn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID string `json:"sub"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo db.Repository
|
||||||
|
jwtSecret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo db.Repository, jwtSecret string) *Service {
|
||||||
|
return &Service{
|
||||||
|
repo: repo,
|
||||||
|
jwtSecret: []byte(jwtSecret),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterWithPassword creates a new user with email and password
|
||||||
|
func (s *Service) RegisterWithPassword(ctx context.Context, email, password, name string) (*db.UserRecord, *TokenPair, error) {
|
||||||
|
if len(password) < 8 {
|
||||||
|
return nil, nil, ErrPasswordTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
existing, err := s.repo.GetUserByEmail(ctx, email)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return nil, nil, ErrEmailAlreadyExists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
user, err := s.repo.CreateUser(ctx, email, string(hash), name, "email", "user")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
tokens, err := s.generateTokenPair(user.ID.String(), email, name, "user")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginWithPassword authenticates a user with email and password
|
||||||
|
func (s *Service) LoginWithPassword(ctx context.Context, email, password string) (*db.UserRecord, *TokenPair, error) {
|
||||||
|
user, err := s.repo.GetUserByEmail(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.PasswordHash == nil {
|
||||||
|
return nil, nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil {
|
||||||
|
return nil, nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
if err := s.repo.UpdateLastLogin(ctx, user.ID.String()); err != nil {
|
||||||
|
// Log but don't fail
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMagicLink generates a magic link for passwordless auth
|
||||||
|
func (s *Service) CreateMagicLink(ctx context.Context, email string) (string, error) {
|
||||||
|
// Get or create user
|
||||||
|
user, err := s.repo.GetUserByEmail(ctx, email)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if user == nil {
|
||||||
|
user, err = s.repo.CreateUser(ctx, email, "", "", "email", "user")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
token := generateRandomToken(32)
|
||||||
|
expiresAt := time.Now().Add(magicLinkTTL)
|
||||||
|
|
||||||
|
if err := s.repo.CreateMagicLink(ctx, token, user.ID.String(), email, expiresAt); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyMagicLink validates a magic link and returns tokens
|
||||||
|
func (s *Service) VerifyMagicLink(ctx context.Context, token string) (*db.UserRecord, *TokenPair, error) {
|
||||||
|
ml, err := s.repo.GetMagicLink(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ml.Used {
|
||||||
|
return nil, nil, ErrMagicLinkUsed
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(ml.ExpiresAt) {
|
||||||
|
return nil, nil, ErrMagicLinkExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as used
|
||||||
|
if err := s.repo.MarkMagicLinkUsed(ctx, token); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
user, err := s.repo.GetUserByID(ctx, ml.UserID.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark email as verified
|
||||||
|
if err := s.repo.MarkEmailVerified(ctx, user.ID.String()); err != nil {
|
||||||
|
// Log but don't fail
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
if err := s.repo.UpdateLastLogin(ctx, user.ID.String()); err != nil {
|
||||||
|
// Log but don't fail
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken refreshes an access token using a refresh token
|
||||||
|
func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
||||||
|
claims, err := s.ValidateToken(refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.Type != "refresh" {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.repo.GetUserByID(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken validates a JWT token and returns claims
|
||||||
|
func (s *Service) ValidateToken(tokenString string) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
return s.jwtSecret, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser retrieves a user by ID
|
||||||
|
func (s *Service) GetUser(ctx context.Context, userID string) (*db.UserRecord, error) {
|
||||||
|
return s.repo.GetUserByID(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAdmin checks if the user has admin role
|
||||||
|
func (s *Service) IsAdmin(ctx context.Context, userID string) (bool, error) {
|
||||||
|
user, err := s.repo.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return user.Role == "admin" || user.Role == "superadmin", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTokenPair creates access and refresh tokens
|
||||||
|
func (s *Service) generateTokenPair(userID, email, name, role string) (*TokenPair, error) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Access token
|
||||||
|
accessClaims := Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Email: email,
|
||||||
|
Name: name,
|
||||||
|
Role: role,
|
||||||
|
Type: "access",
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenTTL)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||||
|
accessTokenString, err := accessToken.SignedString(s.jwtSecret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh token
|
||||||
|
refreshClaims := Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Email: email,
|
||||||
|
Role: role,
|
||||||
|
Type: "refresh",
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(refreshTokenTTL)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
|
||||||
|
refreshTokenString, err := refreshToken.SignedString(s.jwtSecret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TokenPair{
|
||||||
|
AccessToken: accessTokenString,
|
||||||
|
RefreshToken: refreshTokenString,
|
||||||
|
TokenType: "Bearer",
|
||||||
|
ExpiresIn: int(accessTokenTTL.Seconds()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRandomToken(length int) string {
|
||||||
|
b := make([]byte, length)
|
||||||
|
rand.Read(b)
|
||||||
|
return base64.URLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefString(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -17,6 +18,12 @@ import (
|
|||||||
|
|
||||||
paddle "github.com/PaddleHQ/paddle-go-sdk/v5"
|
paddle "github.com/PaddleHQ/paddle-go-sdk/v5"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/stripe/stripe-go/v81"
|
||||||
|
portalsession "github.com/stripe/stripe-go/v81/billingportal/session"
|
||||||
|
checkoutsession "github.com/stripe/stripe-go/v81/checkout/session"
|
||||||
|
"github.com/stripe/stripe-go/v81/customer"
|
||||||
|
"github.com/stripe/stripe-go/v81/subscription"
|
||||||
|
"github.com/stripe/stripe-go/v81/webhook"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -26,9 +33,12 @@ var (
|
|||||||
ErrPaddleNotConfigured = errors.New("paddle is not configured")
|
ErrPaddleNotConfigured = errors.New("paddle is not configured")
|
||||||
ErrPaddleSignatureMissing = errors.New("paddle signature is missing")
|
ErrPaddleSignatureMissing = errors.New("paddle signature is missing")
|
||||||
ErrPaddleWebhookMissing = errors.New("paddle webhook secret is not configured")
|
ErrPaddleWebhookMissing = errors.New("paddle webhook secret is not configured")
|
||||||
|
ErrStripeNotConfigured = errors.New("stripe is not configured")
|
||||||
|
ErrStripeSignatureMissing = errors.New("stripe signature is missing")
|
||||||
|
ErrStripeWebhookMissing = errors.New("stripe webhook secret is not configured")
|
||||||
)
|
)
|
||||||
|
|
||||||
var allowedWebhookEvents = []string{
|
var allowedPaddleWebhookEvents = []string{
|
||||||
"subscription.created",
|
"subscription.created",
|
||||||
"subscription.updated",
|
"subscription.updated",
|
||||||
"subscription.activated",
|
"subscription.activated",
|
||||||
@@ -42,11 +52,23 @@ var allowedWebhookEvents = []string{
|
|||||||
"transaction.past_due",
|
"transaction.past_due",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var allowedStripeWebhookEvents = []stripe.EventType{
|
||||||
|
stripe.EventTypeCheckoutSessionCompleted,
|
||||||
|
stripe.EventTypeCustomerSubscriptionCreated,
|
||||||
|
stripe.EventTypeCustomerSubscriptionUpdated,
|
||||||
|
stripe.EventTypeCustomerSubscriptionDeleted,
|
||||||
|
stripe.EventTypeInvoicePaid,
|
||||||
|
stripe.EventTypeInvoicePaymentFailed,
|
||||||
|
stripe.EventTypePaymentIntentSucceeded,
|
||||||
|
stripe.EventTypePaymentIntentPaymentFailed,
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
repo db.Repository
|
repo db.Repository
|
||||||
client *paddle.SDK
|
client *paddle.SDK
|
||||||
verifier *paddle.WebhookVerifier
|
verifier *paddle.WebhookVerifier
|
||||||
|
stripeEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type webhookEnvelope struct {
|
type webhookEnvelope struct {
|
||||||
@@ -63,6 +85,7 @@ type webhookEnvelope struct {
|
|||||||
func NewService(cfg config.Config, repo db.Repository) *Service {
|
func NewService(cfg config.Config, repo db.Repository) *Service {
|
||||||
service := &Service{cfg: cfg, repo: repo}
|
service := &Service{cfg: cfg, repo: repo}
|
||||||
|
|
||||||
|
// Initialize Paddle client
|
||||||
if strings.TrimSpace(cfg.PaddleAPIKey) != "" {
|
if strings.TrimSpace(cfg.PaddleAPIKey) != "" {
|
||||||
var client *paddle.SDK
|
var client *paddle.SDK
|
||||||
var err error
|
var err error
|
||||||
@@ -76,13 +99,28 @@ func NewService(cfg config.Config, repo db.Repository) *Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(cfg.PaddleWebhookKey) != "" {
|
// Initialize Stripe
|
||||||
service.verifier = paddle.NewWebhookVerifier(cfg.PaddleWebhookKey, paddle.VerifierWithTimestampTolerance(5*time.Minute))
|
if strings.TrimSpace(cfg.StripeAPIKey) != "" {
|
||||||
|
stripe.Key = cfg.StripeAPIKey
|
||||||
|
service.stripeEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetEntitlements returns the plan entitlements for a tenant (used by other services for limit enforcement)
|
||||||
|
func (s *Service) GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error) {
|
||||||
|
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
// Default to Pro entitlements for tenants without billing
|
||||||
|
return entitlementsForPlan("pro"), nil
|
||||||
|
}
|
||||||
|
return domain.PlanEntitlements{}, err
|
||||||
|
}
|
||||||
|
return entitlementsForPlan(tenant.PlanCode), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) GetSubscription(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) {
|
func (s *Service) GetSubscription(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) {
|
||||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -97,7 +135,7 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
|
|||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
|
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
|
||||||
TenantID: membership.Tenant.ID,
|
TenantID: membership.Tenant.ID,
|
||||||
BillingProvider: "paddle",
|
BillingProvider: s.cfg.BillingProvider(),
|
||||||
Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"),
|
Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"),
|
||||||
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
|
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
|
||||||
Currency: "czk",
|
Currency: "czk",
|
||||||
@@ -109,7 +147,7 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
|
|||||||
return toSnapshot(membership.Tenant, record, s.cfg), nil
|
return toSnapshot(membership.Tenant, record, s.cfg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Principal, planCode string, currency string) (domain.CheckoutLaunchResponse, error) {
|
func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Principal, planCode string, currency string, billingInterval string) (domain.CheckoutLaunchResponse, error) {
|
||||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -118,7 +156,93 @@ func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Pr
|
|||||||
return domain.CheckoutLaunchResponse{}, err
|
return domain.CheckoutLaunchResponse{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
priceID, resolvedPlanCode, resolvedCurrency := s.priceForPlan(planCode, currency)
|
// Default to monthly if not specified
|
||||||
|
if billingInterval == "" {
|
||||||
|
billingInterval = "monthly"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer Stripe if configured
|
||||||
|
if s.cfg.StripeConfigured() {
|
||||||
|
return s.createStripeCheckoutSession(ctx, principal, membership, planCode, currency, billingInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Paddle
|
||||||
|
return s.createPaddleCheckoutSession(ctx, principal, membership, planCode, currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) createStripeCheckoutSession(ctx context.Context, principal domain.Principal, membership db.TenantMembershipRecord, planCode string, currency string, billingInterval string) (domain.CheckoutLaunchResponse, error) {
|
||||||
|
priceID, resolvedPlanCode, resolvedCurrency := s.stripePriceForPlan(planCode, currency, billingInterval)
|
||||||
|
if priceID == "" {
|
||||||
|
return domain.CheckoutLaunchResponse{}, ErrBillingPlanUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure customer exists (KV sync model: always pre-create customers)
|
||||||
|
customerID := derefString(membership.Tenant.BillingCustomerID)
|
||||||
|
if customerID == "" {
|
||||||
|
cust, err := customer.New(&stripe.CustomerParams{
|
||||||
|
Email: stripe.String(strings.TrimSpace(principal.Email)),
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"tenantId": membership.Tenant.ID,
|
||||||
|
"tenantSlug": membership.Tenant.Slug,
|
||||||
|
"userId": principal.Subject,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domain.CheckoutLaunchResponse{}, fmt.Errorf("failed to create stripe customer: %w", err)
|
||||||
|
}
|
||||||
|
customerID = cust.ID
|
||||||
|
if err := s.repo.UpdateTenantBillingCustomerID(ctx, membership.Tenant.ID, customerID); err != nil {
|
||||||
|
return domain.CheckoutLaunchResponse{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create checkout session - 15-day free trial for Starter/Pro only (not Business)
|
||||||
|
// Trial requires credit card to be entered
|
||||||
|
trialDays := int64(0)
|
||||||
|
if resolvedPlanCode == "starter" || resolvedPlanCode == "pro" {
|
||||||
|
trialDays = 15
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &stripe.CheckoutSessionParams{
|
||||||
|
Customer: stripe.String(customerID),
|
||||||
|
SuccessURL: stripe.String(strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=success&session_id={CHECKOUT_SESSION_ID}"),
|
||||||
|
CancelURL: stripe.String(strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=cancelled"),
|
||||||
|
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||||
|
PaymentMethodCollection: stripe.String("always"), // Require credit card even for free trial
|
||||||
|
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||||
|
{
|
||||||
|
Price: stripe.String(priceID),
|
||||||
|
Quantity: stripe.Int64(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
|
||||||
|
TrialPeriodDays: stripe.Int64(trialDays),
|
||||||
|
},
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"tenantId": membership.Tenant.ID,
|
||||||
|
"tenantSlug": membership.Tenant.Slug,
|
||||||
|
"userId": principal.Subject,
|
||||||
|
"userEmail": strings.TrimSpace(principal.Email),
|
||||||
|
"planCode": resolvedPlanCode,
|
||||||
|
"currency": resolvedCurrency,
|
||||||
|
"billingInterval": billingInterval,
|
||||||
|
"source": "bookra-dashboard",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
sess, err := checkoutsession.New(params)
|
||||||
|
if err != nil {
|
||||||
|
return domain.CheckoutLaunchResponse{}, fmt.Errorf("failed to create stripe checkout session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.CheckoutLaunchResponse{
|
||||||
|
CheckoutURL: sess.URL,
|
||||||
|
SuccessRedirectURL: sess.SuccessURL,
|
||||||
|
CancelRedirectURL: sess.CancelURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) createPaddleCheckoutSession(ctx context.Context, principal domain.Principal, membership db.TenantMembershipRecord, planCode string, currency string) (domain.CheckoutLaunchResponse, error) {
|
||||||
|
priceID, resolvedPlanCode, resolvedCurrency := s.paddlePriceForPlan(planCode, currency)
|
||||||
if priceID == "" {
|
if priceID == "" {
|
||||||
return domain.CheckoutLaunchResponse{}, ErrBillingPlanUnsupported
|
return domain.CheckoutLaunchResponse{}, ErrBillingPlanUnsupported
|
||||||
}
|
}
|
||||||
@@ -157,16 +281,26 @@ func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (doma
|
|||||||
if customerID == "" {
|
if customerID == "" {
|
||||||
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
|
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
|
||||||
TenantID: membership.Tenant.ID,
|
TenantID: membership.Tenant.ID,
|
||||||
BillingProvider: "paddle",
|
BillingProvider: s.cfg.BillingProvider(),
|
||||||
Status: "inactive",
|
Status: "inactive",
|
||||||
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
|
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
|
||||||
Currency: "czk",
|
Currency: "czk",
|
||||||
}, s.cfg), nil
|
}, s.cfg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer Stripe if configured
|
||||||
|
if s.cfg.StripeConfigured() {
|
||||||
|
record, err := s.syncStripeDataToKV(ctx, membership.Tenant, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.SubscriptionSnapshot{}, err
|
||||||
|
}
|
||||||
|
return toSnapshot(membership.Tenant, record, s.cfg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Paddle
|
||||||
if s.client == nil {
|
if s.client == nil {
|
||||||
return domain.SubscriptionSnapshot{}, ErrPaddleNotConfigured
|
return domain.SubscriptionSnapshot{}, ErrPaddleNotConfigured
|
||||||
}
|
}
|
||||||
|
|
||||||
record, err := s.syncPaddleData(ctx, membership.Tenant, customerID)
|
record, err := s.syncPaddleData(ctx, membership.Tenant, customerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.SubscriptionSnapshot{}, err
|
return domain.SubscriptionSnapshot{}, err
|
||||||
@@ -183,30 +317,53 @@ func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Prin
|
|||||||
}
|
}
|
||||||
return domain.PortalSessionResponse{}, err
|
return domain.PortalSessionResponse{}, err
|
||||||
}
|
}
|
||||||
if s.client == nil {
|
|
||||||
return domain.PortalSessionResponse{}, ErrPaddleNotConfigured
|
|
||||||
}
|
|
||||||
|
|
||||||
customerID := derefString(membership.Tenant.BillingCustomerID)
|
customerID := derefString(membership.Tenant.BillingCustomerID)
|
||||||
if customerID == "" {
|
if customerID == "" {
|
||||||
return domain.PortalSessionResponse{}, ErrBillingCustomerMissing
|
return domain.PortalSessionResponse{}, ErrBillingCustomerMissing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer Stripe if configured
|
||||||
|
if s.cfg.StripeConfigured() {
|
||||||
|
return s.createStripePortalSession(customerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Paddle
|
||||||
|
return s.createPaddlePortalSession(ctx, membership, customerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) createStripePortalSession(customerID string) (domain.PortalSessionResponse, error) {
|
||||||
|
params := &stripe.BillingPortalSessionParams{
|
||||||
|
Customer: stripe.String(customerID),
|
||||||
|
ReturnURL: stripe.String(s.cfg.FrontendURL + "/dashboard?billing=refresh"),
|
||||||
|
}
|
||||||
|
sess, err := portalsession.New(params)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PortalSessionResponse{}, fmt.Errorf("failed to create stripe portal session: %w", err)
|
||||||
|
}
|
||||||
|
return domain.PortalSessionResponse{URL: sess.URL}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) createPaddlePortalSession(ctx context.Context, membership db.TenantMembershipRecord, customerID string) (domain.PortalSessionResponse, error) {
|
||||||
|
if s.client == nil {
|
||||||
|
return domain.PortalSessionResponse{}, ErrPaddleNotConfigured
|
||||||
|
}
|
||||||
|
|
||||||
request := &paddle.CreateCustomerPortalSessionRequest{CustomerID: customerID}
|
request := &paddle.CreateCustomerPortalSessionRequest{CustomerID: customerID}
|
||||||
if subscriptionID := derefString(membership.Tenant.BillingSubscription); subscriptionID != "" {
|
if subscriptionID := derefString(membership.Tenant.BillingSubscription); subscriptionID != "" {
|
||||||
request.SubscriptionIDs = []string{subscriptionID}
|
request.SubscriptionIDs = []string{subscriptionID}
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err := s.client.CreateCustomerPortalSession(ctx, request)
|
sess, err := s.client.CreateCustomerPortalSession(ctx, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.PortalSessionResponse{}, err
|
return domain.PortalSessionResponse{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
url := strings.TrimSpace(session.URLs.General.Overview)
|
url := strings.TrimSpace(sess.URLs.General.Overview)
|
||||||
if url == "" && len(session.URLs.Subscriptions) > 0 {
|
if url == "" && len(sess.URLs.Subscriptions) > 0 {
|
||||||
url = firstNonEmpty(
|
url = firstNonEmpty(
|
||||||
session.URLs.Subscriptions[0].UpdateSubscriptionPaymentMethod,
|
sess.URLs.Subscriptions[0].UpdateSubscriptionPaymentMethod,
|
||||||
session.URLs.Subscriptions[0].CancelSubscription,
|
sess.URLs.Subscriptions[0].CancelSubscription,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if url == "" {
|
if url == "" {
|
||||||
@@ -217,6 +374,109 @@ func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Prin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) HandleWebhook(ctx context.Context, req *http.Request) error {
|
func (s *Service) HandleWebhook(ctx context.Context, req *http.Request) error {
|
||||||
|
// Detect provider based on signature header
|
||||||
|
stripeSig := req.Header.Get("Stripe-Signature")
|
||||||
|
paddleSig := req.Header.Get("Paddle-Signature")
|
||||||
|
|
||||||
|
if stripeSig != "" {
|
||||||
|
return s.handleStripeWebhook(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
if paddleSig != "" {
|
||||||
|
return s.handlePaddleWebhook(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New("missing webhook signature header")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) HandleStripeWebhook(ctx context.Context, req *http.Request) error {
|
||||||
|
return s.handleStripeWebhook(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleStripeWebhook(ctx context.Context, req *http.Request) error {
|
||||||
|
if s.cfg.StripeWebhookKey == "" {
|
||||||
|
return ErrStripeWebhookMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
event, err := webhook.ConstructEvent(body, req.Header.Get("Stripe-Signature"), s.cfg.StripeWebhookKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid stripe webhook signature: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(allowedStripeWebhookEvents, event.Type) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract customer ID from event data
|
||||||
|
var customerID string
|
||||||
|
var eventID = event.ID
|
||||||
|
|
||||||
|
switch event.Type {
|
||||||
|
case "checkout.session.completed":
|
||||||
|
var sess stripe.CheckoutSession
|
||||||
|
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
customerID = sess.Customer.ID
|
||||||
|
if sess.Metadata != nil {
|
||||||
|
if tenantID := sess.Metadata["tenantId"]; tenantID != "" {
|
||||||
|
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if customerID != "" && derefString(tenant.BillingCustomerID) == "" {
|
||||||
|
if err := s.repo.UpdateTenantBillingCustomerID(ctx, tenant.ID, customerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tenant.BillingCustomerID = &customerID
|
||||||
|
}
|
||||||
|
_, err = s.syncStripeDataToKV(ctx, tenant, customerID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
var data struct {
|
||||||
|
Customer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"customer"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(event.Data.Raw, &data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
customerID = data.Customer.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if customerID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant, err := s.repo.GetTenantByBillingCustomerID(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
inserted, err := s.repo.RecordBillingEvent(ctx, tenant.ID, "stripe", eventID, string(event.Type), event.Data.Raw)
|
||||||
|
if err != nil || !inserted {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.syncStripeDataToKV(ctx, tenant, customerID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handlePaddleWebhook(ctx context.Context, req *http.Request) error {
|
||||||
if s.verifier == nil {
|
if s.verifier == nil {
|
||||||
return ErrPaddleWebhookMissing
|
return ErrPaddleWebhookMissing
|
||||||
}
|
}
|
||||||
@@ -241,7 +501,7 @@ func (s *Service) HandleWebhook(ctx context.Context, req *http.Request) error {
|
|||||||
if err := json.Unmarshal(payload, &event); err != nil {
|
if err := json.Unmarshal(payload, &event); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !slices.Contains(allowedWebhookEvents, event.EventType) {
|
if !slices.Contains(allowedPaddleWebhookEvents, event.EventType) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +597,7 @@ func (s *Service) syncPaddleData(ctx context.Context, tenant db.TenantRecord, cu
|
|||||||
record.CurrentPeriodEnd = parseRFC3339Ptr(timePeriodEnd(selected.CurrentBillingPeriod))
|
record.CurrentPeriodEnd = parseRFC3339Ptr(timePeriodEnd(selected.CurrentBillingPeriod))
|
||||||
if len(selected.Items) > 0 {
|
if len(selected.Items) > 0 {
|
||||||
record.PriceID = selected.Items[0].Price.ID
|
record.PriceID = selected.Items[0].Price.ID
|
||||||
record.PlanCode = s.planCodeForPrice(record.PriceID, tenant.PlanCode)
|
record.PlanCode = s.paddlePlanCodeForPrice(record.PriceID, tenant.PlanCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +611,115 @@ func (s *Service) syncPaddleData(ctx context.Context, tenant db.TenantRecord, cu
|
|||||||
return record, nil
|
return record, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncStripeDataToKV is the core sync function following the KV sync model.
|
||||||
|
// It fetches full subscription state from Stripe and stores it in the database.
|
||||||
|
// This function is called after checkout success and on every relevant webhook event.
|
||||||
|
func (s *Service) syncStripeDataToKV(ctx context.Context, tenant db.TenantRecord, customerID string) (db.BillingSnapshotRecord, error) {
|
||||||
|
// Fetch all subscriptions for this customer from Stripe
|
||||||
|
iter := subscription.List(&stripe.SubscriptionListParams{
|
||||||
|
Customer: stripe.String(customerID),
|
||||||
|
})
|
||||||
|
|
||||||
|
var selected *stripe.Subscription
|
||||||
|
for iter.Next() {
|
||||||
|
sub := iter.Subscription()
|
||||||
|
if selected == nil || stripeSubscriptionRank(sub) > stripeSubscriptionRank(selected) {
|
||||||
|
selected = sub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if iter.Err() != nil {
|
||||||
|
return db.BillingSnapshotRecord{}, fmt.Errorf("failed to list stripe subscriptions: %w", iter.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
record := db.BillingSnapshotRecord{
|
||||||
|
TenantID: tenant.ID,
|
||||||
|
BillingProvider: "stripe",
|
||||||
|
BillingCustomerID: customerID,
|
||||||
|
BillingSubscriptionID: "",
|
||||||
|
Status: "inactive",
|
||||||
|
PlanCode: shared.NormalizePlanCode(tenant.PlanCode),
|
||||||
|
Currency: "czk",
|
||||||
|
PriceID: "",
|
||||||
|
LastSyncedAt: &now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected != nil {
|
||||||
|
record.BillingSubscriptionID = selected.ID
|
||||||
|
record.Status = normalizeStripeSubscriptionStatus(selected.Status)
|
||||||
|
record.Currency = strings.ToLower(string(selected.Currency))
|
||||||
|
record.CancelAtPeriodEnd = selected.CancelAtPeriodEnd
|
||||||
|
record.CurrentPeriodStart = stripeTimeToPtr(selected.CurrentPeriodStart)
|
||||||
|
record.CurrentPeriodEnd = stripeTimeToPtr(selected.CurrentPeriodEnd)
|
||||||
|
|
||||||
|
// Extract price ID from subscription items
|
||||||
|
if len(selected.Items.Data) > 0 {
|
||||||
|
record.PriceID = selected.Items.Data[0].Price.ID
|
||||||
|
record.PlanCode = s.stripePlanCodeForPrice(record.PriceID, tenant.PlanCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get payment method info if available
|
||||||
|
if selected.DefaultPaymentMethod != nil && selected.DefaultPaymentMethod.Card != nil {
|
||||||
|
record.PaymentMethodBrand = string(selected.DefaultPaymentMethod.Card.Brand)
|
||||||
|
record.PaymentMethodLast4 = selected.DefaultPaymentMethod.Card.Last4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store normalized snapshot in DB (KV cache)
|
||||||
|
if err := s.repo.UpsertSubscriptionSnapshot(ctx, record); err != nil {
|
||||||
|
return db.BillingSnapshotRecord{}, err
|
||||||
|
}
|
||||||
|
if err := s.repo.UpdateTenantBillingState(ctx, tenant.ID, record.PlanCode, record.Status, record.BillingSubscriptionID); err != nil {
|
||||||
|
return db.BillingSnapshotRecord{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripeSubscriptionRank(sub *stripe.Subscription) int {
|
||||||
|
switch sub.Status {
|
||||||
|
case stripe.SubscriptionStatusActive:
|
||||||
|
return 6
|
||||||
|
case stripe.SubscriptionStatusTrialing:
|
||||||
|
return 5
|
||||||
|
case stripe.SubscriptionStatusPastDue:
|
||||||
|
return 4
|
||||||
|
case stripe.SubscriptionStatusPaused:
|
||||||
|
return 3
|
||||||
|
case stripe.SubscriptionStatusCanceled:
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStripeSubscriptionStatus(status stripe.SubscriptionStatus) string {
|
||||||
|
switch status {
|
||||||
|
case stripe.SubscriptionStatusActive:
|
||||||
|
return "active"
|
||||||
|
case stripe.SubscriptionStatusTrialing:
|
||||||
|
return "trialing"
|
||||||
|
case stripe.SubscriptionStatusPastDue:
|
||||||
|
return "past_due"
|
||||||
|
case stripe.SubscriptionStatusPaused:
|
||||||
|
return "paused"
|
||||||
|
case stripe.SubscriptionStatusCanceled:
|
||||||
|
return "canceled"
|
||||||
|
case stripe.SubscriptionStatusUnpaid:
|
||||||
|
return "canceled"
|
||||||
|
default:
|
||||||
|
return "inactive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripeTimeToPtr(t int64) *time.Time {
|
||||||
|
if t == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ts := time.Unix(t, 0).UTC()
|
||||||
|
return &ts
|
||||||
|
}
|
||||||
|
|
||||||
func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg config.Config) domain.SubscriptionSnapshot {
|
func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg config.Config) domain.SubscriptionSnapshot {
|
||||||
if record.PlanCode == "" {
|
if record.PlanCode == "" {
|
||||||
record.PlanCode = shared.NormalizePlanCode(tenant.PlanCode)
|
record.PlanCode = shared.NormalizePlanCode(tenant.PlanCode)
|
||||||
@@ -363,43 +732,87 @@ func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg con
|
|||||||
}
|
}
|
||||||
|
|
||||||
customerID := firstNonEmpty(record.BillingCustomerID, derefString(tenant.BillingCustomerID))
|
customerID := firstNonEmpty(record.BillingCustomerID, derefString(tenant.BillingCustomerID))
|
||||||
|
provider := firstNonEmpty(record.BillingProvider, tenant.BillingProvider, cfg.BillingProvider())
|
||||||
|
|
||||||
|
syncAvailable := cfg.BillingConfigured()
|
||||||
|
portalAvailable := cfg.BillingConfigured() && customerID != ""
|
||||||
|
checkoutAvailable := billingCheckoutAvailable(cfg, record.PlanCode)
|
||||||
|
|
||||||
return domain.SubscriptionSnapshot{
|
return domain.SubscriptionSnapshot{
|
||||||
TenantID: tenant.ID,
|
TenantID: tenant.ID,
|
||||||
Provider: firstNonEmpty(record.BillingProvider, tenant.BillingProvider, "paddle"),
|
Provider: provider,
|
||||||
CustomerID: customerID,
|
CustomerID: customerID,
|
||||||
SubscriptionID: firstNonEmpty(record.BillingSubscriptionID, derefString(tenant.BillingSubscription)),
|
SubscriptionID: firstNonEmpty(record.BillingSubscriptionID, derefString(tenant.BillingSubscription)),
|
||||||
Status: record.Status,
|
Status: record.Status,
|
||||||
PlanCode: record.PlanCode,
|
PlanCode: record.PlanCode,
|
||||||
Currency: record.Currency,
|
Currency: record.Currency,
|
||||||
PriceID: record.PriceID,
|
PriceID: record.PriceID,
|
||||||
CancelAtPeriodEnd: record.CancelAtPeriodEnd,
|
CancelAtPeriodEnd: record.CancelAtPeriodEnd,
|
||||||
CurrentPeriodStart: record.CurrentPeriodStart,
|
CurrentPeriodStart: record.CurrentPeriodStart,
|
||||||
CurrentPeriodEnd: record.CurrentPeriodEnd,
|
CurrentPeriodEnd: record.CurrentPeriodEnd,
|
||||||
PaymentMethodBrand: record.PaymentMethodBrand,
|
PaymentMethodBrand: record.PaymentMethodBrand,
|
||||||
PaymentMethodLast4: record.PaymentMethodLast4,
|
PaymentMethodLast4: record.PaymentMethodLast4,
|
||||||
Entitlements: entitlementsForPlan(record.PlanCode),
|
Entitlements: entitlementsForPlan(record.PlanCode),
|
||||||
DisplayPrices: displayPricesForPlan(record.PlanCode),
|
DisplayPrices: displayPricesForPlan(record.PlanCode),
|
||||||
TrialDays: 30,
|
TrialDays: func() int {
|
||||||
|
if record.PlanCode == "starter" || record.PlanCode == "pro" {
|
||||||
|
return 15
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}(),
|
||||||
LastSyncedAt: record.LastSyncedAt,
|
LastSyncedAt: record.LastSyncedAt,
|
||||||
CheckoutURLAvailable: checkoutAvailable(cfg, record.PlanCode),
|
CheckoutURLAvailable: checkoutAvailable,
|
||||||
SyncAvailable: cfg.PaddleConfigured(),
|
SyncAvailable: syncAvailable,
|
||||||
PortalAvailable: cfg.PaddleConfigured() && customerID != "",
|
PortalAvailable: portalAvailable,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func entitlementsForPlan(planCode string) domain.PlanEntitlements {
|
func entitlementsForPlan(planCode string) domain.PlanEntitlements {
|
||||||
switch shared.NormalizePlanCode(planCode) {
|
switch shared.NormalizePlanCode(planCode) {
|
||||||
case "starter":
|
case "starter":
|
||||||
return domain.PlanEntitlements{MaxLocations: 1, MaxStaff: 3, EmailReminders: true, AdvancedReporting: false, WidgetEmbedding: true, UmamiTracking: false}
|
// Starter: 1 location, 1 staff, 50 bookings/month
|
||||||
|
return domain.PlanEntitlements{
|
||||||
|
MaxLocations: 1,
|
||||||
|
MaxStaff: 1,
|
||||||
|
MaxBookingsMonth: 50,
|
||||||
|
EmailReminders: false,
|
||||||
|
AdvancedReporting: false,
|
||||||
|
WidgetEmbedding: true,
|
||||||
|
UmamiTracking: false,
|
||||||
|
APIAccess: false,
|
||||||
|
SMSAvailable: false,
|
||||||
|
}
|
||||||
case "business":
|
case "business":
|
||||||
return domain.PlanEntitlements{MaxLocations: 10, MaxStaff: 30, EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true}
|
// Business: Unlimited everything, API access, dedicated manager
|
||||||
|
return domain.PlanEntitlements{
|
||||||
|
MaxLocations: -1, // Unlimited
|
||||||
|
MaxStaff: -1, // Unlimited
|
||||||
|
MaxBookingsMonth: -1, // Unlimited
|
||||||
|
EmailReminders: true,
|
||||||
|
AdvancedReporting: true,
|
||||||
|
WidgetEmbedding: true,
|
||||||
|
UmamiTracking: true,
|
||||||
|
APIAccess: true,
|
||||||
|
DedicatedManager: true,
|
||||||
|
SMSAvailable: true,
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return domain.PlanEntitlements{MaxLocations: 3, MaxStaff: 10, EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true}
|
// Pro: 3 locations, 10 staff, unlimited bookings, email reminders, analytics
|
||||||
|
return domain.PlanEntitlements{
|
||||||
|
MaxLocations: 3,
|
||||||
|
MaxStaff: 10,
|
||||||
|
MaxBookingsMonth: -1, // Unlimited
|
||||||
|
EmailReminders: true,
|
||||||
|
AdvancedReporting: true,
|
||||||
|
WidgetEmbedding: true,
|
||||||
|
UmamiTracking: true,
|
||||||
|
APIAccess: false,
|
||||||
|
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 planCode, currencies := range s.cfg.PaddlePriceMatrix {
|
||||||
for _, configuredPriceID := range currencies {
|
for _, configuredPriceID := range currencies {
|
||||||
if configuredPriceID != "" && configuredPriceID == priceID {
|
if configuredPriceID != "" && configuredPriceID == priceID {
|
||||||
@@ -410,7 +823,18 @@ func (s *Service) planCodeForPrice(priceID string, fallback string) string {
|
|||||||
return shared.NormalizePlanCode(fallback)
|
return shared.NormalizePlanCode(fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) priceForPlan(planCode string, currency string) (string, string, string) {
|
func (s *Service) stripePlanCodeForPrice(priceID string, fallback string) string {
|
||||||
|
for planCode, currencies := range s.cfg.StripePriceMatrix {
|
||||||
|
for _, configuredPriceID := range currencies {
|
||||||
|
if configuredPriceID != "" && configuredPriceID == priceID {
|
||||||
|
return shared.NormalizePlanCode(planCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shared.NormalizePlanCode(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) paddlePriceForPlan(planCode string, currency string) (string, string, string) {
|
||||||
resolvedPlan := shared.NormalizePlanCode(strings.TrimSpace(planCode))
|
resolvedPlan := shared.NormalizePlanCode(strings.TrimSpace(planCode))
|
||||||
if resolvedPlan == "" {
|
if resolvedPlan == "" {
|
||||||
resolvedPlan = "pro"
|
resolvedPlan = "pro"
|
||||||
@@ -427,6 +851,48 @@ func (s *Service) priceForPlan(planCode string, currency string) (string, string
|
|||||||
return s.cfg.PaddlePriceMatrix[resolvedPlan]["usd"], resolvedPlan, "usd"
|
return s.cfg.PaddlePriceMatrix[resolvedPlan]["usd"], resolvedPlan, "usd"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) stripePriceForPlan(planCode string, currency string, billingInterval string) (string, string, string) {
|
||||||
|
resolvedPlan := shared.NormalizePlanCode(strings.TrimSpace(planCode))
|
||||||
|
if resolvedPlan == "" {
|
||||||
|
resolvedPlan = "pro"
|
||||||
|
}
|
||||||
|
resolvedCurrency := normalizeCurrency(currency)
|
||||||
|
resolvedInterval := billingInterval
|
||||||
|
if resolvedInterval == "" {
|
||||||
|
resolvedInterval = "monthly"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the price key: plan:currency:interval (e.g., "pro:usd:monthly", "pro:usd:yearly")
|
||||||
|
priceKey := resolvedPlan + ":" + resolvedCurrency + ":" + resolvedInterval
|
||||||
|
if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" {
|
||||||
|
return priceID, resolvedPlan, resolvedCurrency
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to plan:currency format (for backwards compatibility)
|
||||||
|
priceKey = resolvedPlan + ":" + resolvedCurrency
|
||||||
|
if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" {
|
||||||
|
return priceID, resolvedPlan, resolvedCurrency
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try just plan code with interval
|
||||||
|
if resolvedInterval != "monthly" {
|
||||||
|
priceKey = resolvedPlan + ":" + resolvedInterval
|
||||||
|
if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" {
|
||||||
|
return priceID, resolvedPlan, resolvedCurrency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default currency fallback
|
||||||
|
if resolvedCurrency != "usd" {
|
||||||
|
priceKey = resolvedPlan + ":usd:" + resolvedInterval
|
||||||
|
if priceID := s.cfg.StripePriceMatrix[resolvedPlan][priceKey]; priceID != "" {
|
||||||
|
return priceID, resolvedPlan, "usd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.cfg.StripePriceMatrix[resolvedPlan][resolvedPlan+":czk"], resolvedPlan, "czk"
|
||||||
|
}
|
||||||
|
|
||||||
func subscriptionRank(subscription *paddle.Subscription) int {
|
func subscriptionRank(subscription *paddle.Subscription) int {
|
||||||
switch subscription.Status {
|
switch subscription.Status {
|
||||||
case paddle.SubscriptionStatusActive:
|
case paddle.SubscriptionStatusActive:
|
||||||
@@ -447,19 +913,22 @@ func subscriptionRank(subscription *paddle.Subscription) int {
|
|||||||
func displayPricesForPlan(planCode string) []domain.PlanDisplayPrice {
|
func displayPricesForPlan(planCode string) []domain.PlanDisplayPrice {
|
||||||
switch shared.NormalizePlanCode(planCode) {
|
switch shared.NormalizePlanCode(planCode) {
|
||||||
case "starter":
|
case "starter":
|
||||||
|
// Starter: $5/month, $50/year (save $10 = ~17%)
|
||||||
return []domain.PlanDisplayPrice{
|
return []domain.PlanDisplayPrice{
|
||||||
{Currency: "czk", AmountCents: 11900, Formatted: "119 Kc"},
|
{Currency: "czk", AmountCents: 11900, Formatted: "119 Kč/mo", YearlyAmountCents: 119000, YearlyFormatted: "1 190 Kč/yr", YearlySavings: "Save 199 Kč", YearlySavingsPercent: 17},
|
||||||
{Currency: "usd", AmountCents: 500, Formatted: "$5"},
|
{Currency: "usd", AmountCents: 500, Formatted: "$5/mo", YearlyAmountCents: 5000, YearlyFormatted: "$50/yr", YearlySavings: "Save $10", YearlySavingsPercent: 17},
|
||||||
}
|
}
|
||||||
case "business":
|
case "business":
|
||||||
|
// Business: $50/month, $500/year (save $100 = ~17%)
|
||||||
return []domain.PlanDisplayPrice{
|
return []domain.PlanDisplayPrice{
|
||||||
{Currency: "czk", AmountCents: 119900, Formatted: "1 199 Kc"},
|
{Currency: "czk", AmountCents: 119900, Formatted: "1 199 Kč/mo", YearlyAmountCents: 1199000, YearlyFormatted: "11 990 Kč/yr", YearlySavings: "Save 1 999 Kč", YearlySavingsPercent: 17},
|
||||||
{Currency: "usd", AmountCents: 5000, Formatted: "$50"},
|
{Currency: "usd", AmountCents: 5000, Formatted: "$50/mo", YearlyAmountCents: 50000, YearlyFormatted: "$500/yr", YearlySavings: "Save $100", YearlySavingsPercent: 17},
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
// Pro: $20/month, $200/year (save $40 = ~17%)
|
||||||
return []domain.PlanDisplayPrice{
|
return []domain.PlanDisplayPrice{
|
||||||
{Currency: "czk", AmountCents: 49900, Formatted: "499 Kc"},
|
{Currency: "czk", AmountCents: 49900, Formatted: "499 Kč/mo", YearlyAmountCents: 499000, YearlyFormatted: "4 990 Kč/yr", YearlySavings: "Save 999 Kč", YearlySavingsPercent: 17},
|
||||||
{Currency: "usd", AmountCents: 2000, Formatted: "$20"},
|
{Currency: "usd", AmountCents: 2000, Formatted: "$20/mo", YearlyAmountCents: 20000, YearlyFormatted: "$200/yr", YearlySavings: "Save $40", YearlySavingsPercent: 17},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -497,6 +966,30 @@ func checkoutAvailable(cfg config.Config, planCode string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func billingCheckoutAvailable(cfg config.Config, planCode string) bool {
|
||||||
|
planCode = shared.NormalizePlanCode(planCode)
|
||||||
|
|
||||||
|
// Prefer Stripe
|
||||||
|
if cfg.StripeConfigured() && cfg.StripeWebhookConfigured() {
|
||||||
|
for _, priceID := range cfg.StripePriceMatrix[planCode] {
|
||||||
|
if strings.TrimSpace(priceID) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Paddle
|
||||||
|
if cfg.PaddleConfigured() && cfg.PaddleWebhookConfigured() {
|
||||||
|
for _, priceID := range cfg.PaddlePriceMatrix[planCode] {
|
||||||
|
if strings.TrimSpace(priceID) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func customDataString(data map[string]any, key string) string {
|
func customDataString(data map[string]any, key string) string {
|
||||||
if data == nil {
|
if data == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -554,3 +1047,48 @@ func firstNonEmpty(values ...string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckAndSendTrialEndingEmails checks all tenants with trials and sends emails for those ending soon
|
||||||
|
func (s *Service) CheckAndSendTrialEndingEmails(ctx context.Context, notificationService interface {
|
||||||
|
SendTrialEndingEmail(ctx context.Context, tenantID string, daysRemaining int) error
|
||||||
|
}) error {
|
||||||
|
// Get all tenants with trial status
|
||||||
|
tenants, _, err := s.repo.ListAllTenants(ctx, 1000, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for _, tenant := range tenants {
|
||||||
|
if tenant.SubscriptionStatus != "trialing" && tenant.SubscriptionStatus != "trial" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get subscription to check trial end date
|
||||||
|
snapshot, err := s.repo.GetSubscriptionSnapshot(ctx, tenant.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate trial end: assume 15-day trial from period start
|
||||||
|
var trialEnd time.Time
|
||||||
|
if snapshot.CurrentPeriodStart != nil {
|
||||||
|
trialEnd = snapshot.CurrentPeriodStart.Add(15 * 24 * time.Hour)
|
||||||
|
} else {
|
||||||
|
// Default to 15 days from now if no start date
|
||||||
|
trialEnd = now.Add(15 * 24 * time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
daysRemaining := int(trialEnd.Sub(now).Hours() / 24)
|
||||||
|
|
||||||
|
// Send email if trial ends in 1-3 days
|
||||||
|
if daysRemaining >= 1 && daysRemaining <= 3 {
|
||||||
|
if err := notificationService.SendTrialEndingEmail(ctx, tenant.ID, daysRemaining); err != nil {
|
||||||
|
// Log but don't fail
|
||||||
|
fmt.Printf("Failed to send trial ending email for tenant %s: %v\n", tenant.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func TestCreateCheckoutRequiresPaddleConfig(t *testing.T) {
|
|||||||
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
|
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
|
||||||
Subject: "demo-owner",
|
Subject: "demo-owner",
|
||||||
Email: "owner@bookra.dev",
|
Email: "owner@bookra.dev",
|
||||||
}, "pro", "czk")
|
}, "pro", "czk", "monthly")
|
||||||
if err != ErrPaddleNotConfigured {
|
if err != ErrPaddleNotConfigured {
|
||||||
t.Fatalf("expected ErrPaddleNotConfigured, got response=%v err=%v", response, err)
|
t.Fatalf("expected ErrPaddleNotConfigured, got response=%v err=%v", response, err)
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ func TestCreateCheckoutReturnsLaunchPayload(t *testing.T) {
|
|||||||
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
|
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
|
||||||
Subject: "demo-owner",
|
Subject: "demo-owner",
|
||||||
Email: "owner@bookra.dev",
|
Email: "owner@bookra.dev",
|
||||||
}, "pro", "czk")
|
}, "pro", "czk", "monthly")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create checkout: %v", err)
|
t.Fatalf("create checkout: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
|||||||
|
|
||||||
customerName := strings.TrimSpace(request.CustomerName)
|
customerName := strings.TrimSpace(request.CustomerName)
|
||||||
customerEmail := strings.TrimSpace(request.CustomerEmail)
|
customerEmail := strings.TrimSpace(request.CustomerEmail)
|
||||||
|
customerPhone := strings.TrimSpace(request.CustomerPhone)
|
||||||
notes := strings.TrimSpace(request.Notes)
|
notes := strings.TrimSpace(request.Notes)
|
||||||
if len(customerName) < 2 || len(customerName) > 120 {
|
if len(customerName) < 2 || len(customerName) > 120 {
|
||||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerName must be between 2 and 120 characters", ErrInvalidBooking)
|
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerName must be between 2 and 120 characters", ErrInvalidBooking)
|
||||||
@@ -129,6 +130,9 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
|||||||
if _, err := mail.ParseAddress(customerEmail); err != nil {
|
if _, err := mail.ParseAddress(customerEmail); err != nil {
|
||||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerEmail must be valid", ErrInvalidBooking)
|
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerEmail must be valid", ErrInvalidBooking)
|
||||||
}
|
}
|
||||||
|
if customerPhone != "" && len(customerPhone) > 30 {
|
||||||
|
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerPhone must be at most 30 characters", ErrInvalidBooking)
|
||||||
|
}
|
||||||
if len(notes) > 1000 {
|
if len(notes) > 1000 {
|
||||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: notes must be at most 1000 characters", ErrInvalidBooking)
|
return domain.CreateBookingResponse{}, fmt.Errorf("%w: notes must be at most 1000 characters", ErrInvalidBooking)
|
||||||
}
|
}
|
||||||
@@ -194,6 +198,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
|||||||
BookingMode: request.BookingMode,
|
BookingMode: request.BookingMode,
|
||||||
CustomerName: customerName,
|
CustomerName: customerName,
|
||||||
CustomerEmail: customerEmail,
|
CustomerEmail: customerEmail,
|
||||||
|
CustomerPhone: customerPhone,
|
||||||
StartsAt: startsAt.UTC(),
|
StartsAt: startsAt.UTC(),
|
||||||
EndsAt: endsAt.UTC(),
|
EndsAt: endsAt.UTC(),
|
||||||
Status: status,
|
Status: status,
|
||||||
@@ -331,6 +336,25 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
|||||||
upcoming = upcoming[:5]
|
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{
|
return domain.DashboardSummary{
|
||||||
TenantName: membership.Tenant.Name,
|
TenantName: membership.Tenant.Name,
|
||||||
TenantSlug: membership.Tenant.Slug,
|
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)},
|
{Code: "utilization", Label: "Utilization", Value: fmt.Sprintf("%d%%", metrics.UtilizationPercent)},
|
||||||
},
|
},
|
||||||
UpcomingBookings: upcoming,
|
UpcomingBookings: upcoming,
|
||||||
|
AllBookings: allBookings,
|
||||||
WidgetSnippets: widgetSnippets(membership.Tenant),
|
WidgetSnippets: widgetSnippets(membership.Tenant),
|
||||||
Tracking: trackingStatus(s.repo, ctx, membership.Tenant),
|
Tracking: trackingStatus(s.repo, ctx, membership.Tenant),
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package catalog
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"bookra/apps/backend/internal/db"
|
"bookra/apps/backend/internal/db"
|
||||||
@@ -17,14 +18,25 @@ var (
|
|||||||
ErrInvalidBooking = errors.New("invalid booking request")
|
ErrInvalidBooking = errors.New("invalid booking request")
|
||||||
ErrTenantNotFound = errors.New("tenant not found")
|
ErrTenantNotFound = errors.New("tenant not found")
|
||||||
ErrTenantMembership = errors.New("tenant membership not found")
|
ErrTenantMembership = errors.New("tenant membership not found")
|
||||||
|
ErrPlanLimitReached = errors.New("plan limit reached")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repo db.Repository
|
repo db.Repository
|
||||||
|
billingService interface {
|
||||||
|
GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
|
||||||
|
}
|
||||||
|
notificationService interface {
|
||||||
|
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(repo db.Repository) *Service {
|
func NewService(repo db.Repository, billingService interface {
|
||||||
return &Service{repo: repo}
|
GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
|
||||||
|
}, notificationService interface {
|
||||||
|
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
|
||||||
|
}) *Service {
|
||||||
|
return &Service{repo: repo, billingService: billingService, notificationService: notificationService}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -63,6 +75,18 @@ func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal
|
|||||||
return domain.Location{}, ErrTenantMembership
|
return domain.Location{}, ErrTenantMembership
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check plan entitlements for location limit
|
||||||
|
if s.billingService != nil {
|
||||||
|
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
|
||||||
|
if err == nil && entitlements.MaxLocations > 0 {
|
||||||
|
// Count existing locations
|
||||||
|
locations, err := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
|
||||||
|
if err == nil && len(locations) >= entitlements.MaxLocations {
|
||||||
|
return domain.Location{}, fmt.Errorf("%w: location limit reached (%d/%d). Upgrade your plan to add more locations.", ErrPlanLimitReached, len(locations), entitlements.MaxLocations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
params := db.CreateLocationParams{
|
params := db.CreateLocationParams{
|
||||||
TenantID: membership.Tenant.ID,
|
TenantID: membership.Tenant.ID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
@@ -74,6 +98,18 @@ func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal
|
|||||||
return domain.Location{}, err
|
return domain.Location{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send usage warning if at 80%+ of limit
|
||||||
|
if s.notificationService != nil && s.billingService != nil {
|
||||||
|
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
|
||||||
|
if err == nil && entitlements.MaxLocations > 0 {
|
||||||
|
locations, _ := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
|
||||||
|
usagePercent := (len(locations) * 100) / entitlements.MaxLocations
|
||||||
|
if usagePercent >= 80 {
|
||||||
|
_ = s.notificationService.SendUsageWarning(ctx, membership.Tenant.ID, len(locations), entitlements.MaxLocations, usagePercent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return domain.Location{
|
return domain.Location{
|
||||||
ID: rec.ID,
|
ID: rec.ID,
|
||||||
TenantID: rec.TenantID,
|
TenantID: rec.TenantID,
|
||||||
|
|||||||
@@ -10,52 +10,70 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Environment string
|
Environment string
|
||||||
Port string
|
Port string
|
||||||
APIURL string
|
APIURL string
|
||||||
FrontendURL string
|
FrontendURL string
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
DatabaseDirectURL string
|
DatabaseDirectURL string
|
||||||
NeonAuthURL string
|
NeonAuthURL string
|
||||||
AuthJWTSecret string
|
AuthJWTSecret string
|
||||||
JobRunnerKey string
|
JobRunnerKey string
|
||||||
EmailFrom string
|
EmailFrom string
|
||||||
SMTPHost string
|
SMTPHost string
|
||||||
SMTPPort string
|
SMTPPort string
|
||||||
SMTPUsername string
|
SMTPUsername string
|
||||||
SMTPPassword string
|
SMTPPassword string
|
||||||
PaddleEnvironment string
|
PaddleEnvironment string
|
||||||
PaddleAPIKey string
|
PaddleAPIKey string
|
||||||
PaddleWebhookKey string
|
PaddleWebhookKey string
|
||||||
PaddlePriceMatrix map[string]map[string]string
|
PaddlePriceMatrix map[string]map[string]string
|
||||||
UmamiAPIURL string
|
StripeAPIKey string
|
||||||
UmamiAPIKey string
|
StripeWebhookKey string
|
||||||
DemoMode bool
|
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) {
|
func Load() (Config, error) {
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
Environment: valueOrDefault("BOOKRA_APP_ENV", "development"),
|
Environment: valueOrDefault("BOOKRA_APP_ENV", "development"),
|
||||||
Port: valueOrDefault("BOOKRA_API_PORT", "8080"),
|
Port: valueOrDefault("BOOKRA_API_PORT", "8080"),
|
||||||
APIURL: valueOrDefault("BOOKRA_API_URL", "http://localhost:8080"),
|
APIURL: valueOrDefault("BOOKRA_API_URL", "http://localhost:8080"),
|
||||||
FrontendURL: valueOrDefault("BOOKRA_FRONTEND_URL", "http://localhost:3000"),
|
FrontendURL: valueOrDefault("BOOKRA_FRONTEND_URL", "http://localhost:3000"),
|
||||||
DatabaseURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_URL")),
|
DatabaseURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_URL")),
|
||||||
DatabaseDirectURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_DIRECT_URL")),
|
DatabaseDirectURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_DIRECT_URL")),
|
||||||
NeonAuthURL: strings.TrimSpace(os.Getenv("BOOKRA_NEON_AUTH_URL")),
|
NeonAuthURL: strings.TrimSpace(os.Getenv("BOOKRA_NEON_AUTH_URL")),
|
||||||
AuthJWTSecret: strings.TrimSpace(os.Getenv("BOOKRA_AUTH_JWT_SECRET")),
|
AuthJWTSecret: strings.TrimSpace(os.Getenv("BOOKRA_AUTH_JWT_SECRET")),
|
||||||
JobRunnerKey: strings.TrimSpace(os.Getenv("BOOKRA_JOB_RUNNER_KEY")),
|
JobRunnerKey: strings.TrimSpace(os.Getenv("BOOKRA_JOB_RUNNER_KEY")),
|
||||||
EmailFrom: valueOrDefault("BOOKRA_EMAIL_FROM", "noreply@bookra.dev"),
|
EmailFrom: valueOrDefault("BOOKRA_EMAIL_FROM", "noreply@bookra.dev"),
|
||||||
SMTPHost: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_HOST")),
|
SMTPHost: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_HOST")),
|
||||||
SMTPPort: valueOrDefault("BOOKRA_SMTP_PORT", "587"),
|
SMTPPort: valueOrDefault("BOOKRA_SMTP_PORT", "587"),
|
||||||
SMTPUsername: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_USERNAME")),
|
SMTPUsername: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_USERNAME")),
|
||||||
SMTPPassword: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_PASSWORD")),
|
SMTPPassword: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_PASSWORD")),
|
||||||
PaddleEnvironment: normalizePaddleEnvironment(os.Getenv("BOOKRA_PADDLE_ENV")),
|
PaddleEnvironment: normalizePaddleEnvironment(os.Getenv("BOOKRA_PADDLE_ENV")),
|
||||||
PaddleAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_API_KEY")),
|
PaddleAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_API_KEY")),
|
||||||
PaddleWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_WEBHOOK_SECRET")),
|
PaddleWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_WEBHOOK_SECRET")),
|
||||||
PaddlePriceMatrix: paddlePriceMatrixFromEnv(),
|
PaddlePriceMatrix: paddlePriceMatrixFromEnv(),
|
||||||
UmamiAPIURL: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_URL")),
|
StripeAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_API_KEY")),
|
||||||
UmamiAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_KEY")),
|
StripeWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_WEBHOOK_SECRET")),
|
||||||
DemoMode: boolFromEnv("BOOKRA_DEMO_MODE", false),
|
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 == "" {
|
if cfg.FrontendURL == "" {
|
||||||
@@ -86,19 +104,6 @@ func (cfg Config) validateRuntimeRequirements() error {
|
|||||||
if cfg.SMTPHost == "" {
|
if cfg.SMTPHost == "" {
|
||||||
missing = append(missing, "BOOKRA_SMTP_HOST")
|
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 {
|
if len(missing) > 0 {
|
||||||
return fmt.Errorf("%s required when BOOKRA_APP_ENV=%s", strings.Join(uniqueStrings(missing), ", "), cfg.Environment)
|
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"] != ""
|
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 {
|
func paddlePriceMatrixFromEnv() map[string]map[string]string {
|
||||||
matrix := map[string]map[string]string{
|
matrix := map[string]map[string]string{
|
||||||
"starter": {},
|
"starter": {},
|
||||||
@@ -132,6 +184,32 @@ func paddlePriceMatrixFromEnv() map[string]map[string]string {
|
|||||||
return matrix
|
return matrix
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stripePriceMatrixFromEnv() map[string]map[string]string {
|
||||||
|
matrix := map[string]map[string]string{
|
||||||
|
"starter": {},
|
||||||
|
"pro": {},
|
||||||
|
"business": {},
|
||||||
|
}
|
||||||
|
for _, planCode := range []string{"starter", "pro", "business"} {
|
||||||
|
envPlan := strings.ToUpper(strings.ReplaceAll(planCode, "-", "_"))
|
||||||
|
|
||||||
|
// Monthly prices
|
||||||
|
matrix[planCode][planCode+":czk:monthly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_MONTHLY_PRICE_ID"))
|
||||||
|
matrix[planCode][planCode+":usd:monthly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_MONTHLY_PRICE_ID"))
|
||||||
|
matrix[planCode][planCode+":czk"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_PRICE_ID"))
|
||||||
|
matrix[planCode][planCode+":usd"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_PRICE_ID"))
|
||||||
|
matrix[planCode]["czk"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_PRICE_ID"))
|
||||||
|
matrix[planCode]["usd"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_PRICE_ID"))
|
||||||
|
|
||||||
|
// Yearly prices
|
||||||
|
matrix[planCode][planCode+":czk:yearly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_YEARLY_PRICE_ID"))
|
||||||
|
matrix[planCode][planCode+":usd:yearly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_YEARLY_PRICE_ID"))
|
||||||
|
matrix[planCode]["yearly:czk"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_YEARLY_PRICE_ID"))
|
||||||
|
matrix[planCode]["yearly:usd"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_YEARLY_PRICE_ID"))
|
||||||
|
}
|
||||||
|
return matrix
|
||||||
|
}
|
||||||
|
|
||||||
func normalizePaddleEnvironment(value string) string {
|
func normalizePaddleEnvironment(value string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
case "live", "production":
|
case "live", "production":
|
||||||
@@ -157,6 +235,15 @@ func boolFromEnv(key string, fallback bool) bool {
|
|||||||
return value == "true" || value == "1" || value == "yes" || value == "on"
|
return value == "true" || value == "1" || value == "yes" || value == "on"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func smsPriceMatrixFromEnv() map[string]string {
|
||||||
|
matrix := map[string]string{}
|
||||||
|
for _, currency := range []string{"czk", "usd", "eur", "gbp", "pln"} {
|
||||||
|
upper := strings.ToUpper(currency)
|
||||||
|
matrix[currency] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_SMS_" + upper + "_PRICE_ID"))
|
||||||
|
}
|
||||||
|
return matrix
|
||||||
|
}
|
||||||
|
|
||||||
func uniqueStrings(values []string) []string {
|
func uniqueStrings(values []string) []string {
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
out := make([]string, 0, len(values))
|
out := make([]string, 0, len(values))
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *PGRepository) ListAllTenants(ctx context.Context, limit, offset int) ([]TenantRecord, int, error) {
|
||||||
|
var total int
|
||||||
|
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants`).Scan(&total)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status,
|
||||||
|
COALESCE(billing_provider, 'stripe'), billing_customer_id, billing_subscription_id
|
||||||
|
FROM tenants
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
`, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tenants []TenantRecord
|
||||||
|
for rows.Next() {
|
||||||
|
var t TenantRecord
|
||||||
|
if err := rows.Scan(
|
||||||
|
&t.ID, &t.Slug, &t.Name, &t.Preset, &t.Locale, &t.Timezone,
|
||||||
|
&t.PlanCode, &t.SubscriptionStatus, &t.BillingProvider,
|
||||||
|
&t.BillingCustomerID, &t.BillingSubscription,
|
||||||
|
); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
tenants = append(tenants, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tenants, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) ListAllUsers(ctx context.Context, limit, offset int) ([]UserRecord, int, error) {
|
||||||
|
var total int
|
||||||
|
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&total)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT id, email, name, email_verified, provider, role, created_at, last_login_at
|
||||||
|
FROM users
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
`, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var users []UserRecord
|
||||||
|
for rows.Next() {
|
||||||
|
var u UserRecord
|
||||||
|
if err := rows.Scan(
|
||||||
|
&u.ID, &u.Email, &u.Name, &u.EmailVerified, &u.Provider, &u.Role,
|
||||||
|
&u.CreatedAt, &u.LastLoginAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
users = append(users, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) GetPlatformStats(ctx context.Context) (PlatformStats, error) {
|
||||||
|
var stats PlatformStats
|
||||||
|
|
||||||
|
// Total tenants
|
||||||
|
r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants`).Scan(&stats.TotalTenants)
|
||||||
|
|
||||||
|
// Total users
|
||||||
|
r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers)
|
||||||
|
|
||||||
|
// Active subscriptions
|
||||||
|
r.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM billing_snapshots
|
||||||
|
WHERE status IN ('active', 'trialing')
|
||||||
|
`).Scan(&stats.ActiveSubscriptions)
|
||||||
|
|
||||||
|
// Trial subscriptions
|
||||||
|
r.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM billing_snapshots
|
||||||
|
WHERE status = 'trialing'
|
||||||
|
`).Scan(&stats.TrialSubscriptions)
|
||||||
|
|
||||||
|
// Bookings this month
|
||||||
|
r.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM bookings
|
||||||
|
WHERE created_at >= date_trunc('month', CURRENT_DATE)
|
||||||
|
`).Scan(&stats.BookingsThisMonth)
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) CreateAdminAuditLog(ctx context.Context, params AdminAuditLogParams) error {
|
||||||
|
var detailsJSON []byte
|
||||||
|
var err error
|
||||||
|
if params.Details != nil {
|
||||||
|
detailsJSON, err = json.Marshal(params.Details)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO admin_audit_log (admin_user_id, action, resource_type, resource_id, details, ip_address, user_agent)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
`, nullableUUID(params.AdminUserID), params.Action, params.ResourceType, params.ResourceID, detailsJSON, params.IPAddress, params.UserAgent)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) UpdateUserRole(ctx context.Context, userID, role string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2
|
||||||
|
`, role, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableUUID(s string) interface{} {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *PGRepository) GetUserByEmail(ctx context.Context, email string) (*UserRecord, error) {
|
||||||
|
var user UserRecord
|
||||||
|
var name, passwordHash *string
|
||||||
|
var lastLoginAt *time.Time
|
||||||
|
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
|
||||||
|
FROM users
|
||||||
|
WHERE email = $1
|
||||||
|
`, email).Scan(
|
||||||
|
&user.ID, &user.Email, &name, &passwordHash,
|
||||||
|
&user.EmailVerified, &user.Provider, &user.Role,
|
||||||
|
&user.CreatedAt, &lastLoginAt,
|
||||||
|
)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Name = name
|
||||||
|
user.PasswordHash = passwordHash
|
||||||
|
user.LastLoginAt = lastLoginAt
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) GetUserByID(ctx context.Context, userID string) (*UserRecord, error) {
|
||||||
|
var user UserRecord
|
||||||
|
var name, passwordHash *string
|
||||||
|
var lastLoginAt *time.Time
|
||||||
|
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1
|
||||||
|
`, userID).Scan(
|
||||||
|
&user.ID, &user.Email, &name, &passwordHash,
|
||||||
|
&user.EmailVerified, &user.Provider, &user.Role,
|
||||||
|
&user.CreatedAt, &lastLoginAt,
|
||||||
|
)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Name = name
|
||||||
|
user.PasswordHash = passwordHash
|
||||||
|
user.LastLoginAt = lastLoginAt
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) CreateUser(ctx context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error) {
|
||||||
|
var user UserRecord
|
||||||
|
var lastLoginAt *time.Time
|
||||||
|
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO users (email, password_hash, name, provider, role, email_verified)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, false)
|
||||||
|
RETURNING id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
|
||||||
|
`, email, nullableString(passwordHash), nullableString(name), provider, role).Scan(
|
||||||
|
&user.ID, &user.Email, &user.Name, &user.PasswordHash,
|
||||||
|
&user.EmailVerified, &user.Provider, &user.Role,
|
||||||
|
&user.CreatedAt, &lastLoginAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.LastLoginAt = lastLoginAt
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) UpdateLastLogin(ctx context.Context, userID string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE users SET last_login_at = NOW() WHERE id = $1
|
||||||
|
`, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) MarkEmailVerified(ctx context.Context, userID string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE users SET email_verified = true WHERE id = $1
|
||||||
|
`, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) CreateMagicLink(ctx context.Context, token, userID, email string, expiresAt time.Time) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO magic_links (token, user_id, email, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
`, token, userID, email, expiresAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) GetMagicLink(ctx context.Context, token string) (*MagicLinkRecord, error) {
|
||||||
|
var ml MagicLinkRecord
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT token, user_id, email, used, expires_at, created_at
|
||||||
|
FROM magic_links
|
||||||
|
WHERE token = $1
|
||||||
|
`, token).Scan(
|
||||||
|
&ml.Token, &ml.UserID, &ml.Email, &ml.Used, &ml.ExpiresAt, &ml.CreatedAt,
|
||||||
|
)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ml, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) MarkMagicLinkUsed(ctx context.Context, token string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE magic_links SET used = true WHERE token = $1
|
||||||
|
`, token)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableString(s string) interface{} {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
|
func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
|
||||||
rows, err := r.pool.Query(ctx, `
|
rows, err := r.pool.Query(ctx, `
|
||||||
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
||||||
customer_name, customer_email, starts_at, ends_at, status, reference
|
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference
|
||||||
FROM bookings
|
FROM bookings
|
||||||
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
|
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
|
||||||
ORDER BY starts_at ASC
|
ORDER BY starts_at ASC
|
||||||
@@ -30,6 +30,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
|
|||||||
&record.LocationID,
|
&record.LocationID,
|
||||||
&record.CustomerName,
|
&record.CustomerName,
|
||||||
&record.CustomerEmail,
|
&record.CustomerEmail,
|
||||||
|
&record.CustomerPhone,
|
||||||
&record.StartsAt,
|
&record.StartsAt,
|
||||||
&record.EndsAt,
|
&record.EndsAt,
|
||||||
&record.Status,
|
&record.Status,
|
||||||
@@ -47,10 +48,10 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
|
|||||||
err := r.pool.QueryRow(ctx, `
|
err := r.pool.QueryRow(ctx, `
|
||||||
INSERT INTO bookings (
|
INSERT INTO bookings (
|
||||||
tenant_id, service_id, class_session_id, staff_id, location_id,
|
tenant_id, service_id, class_session_id, staff_id, location_id,
|
||||||
booking_mode, customer_name, customer_email, starts_at, ends_at,
|
booking_mode, customer_name, customer_email, customer_phone, starts_at, ends_at,
|
||||||
status, reference, notes
|
status, reference, notes
|
||||||
)
|
)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||||
RETURNING id, reference, status
|
RETURNING id, reference, status
|
||||||
`,
|
`,
|
||||||
params.TenantID,
|
params.TenantID,
|
||||||
@@ -61,6 +62,7 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
|
|||||||
params.BookingMode,
|
params.BookingMode,
|
||||||
params.CustomerName,
|
params.CustomerName,
|
||||||
params.CustomerEmail,
|
params.CustomerEmail,
|
||||||
|
params.CustomerPhone,
|
||||||
params.StartsAt,
|
params.StartsAt,
|
||||||
params.EndsAt,
|
params.EndsAt,
|
||||||
params.Status,
|
params.Status,
|
||||||
|
|||||||
@@ -38,6 +38,23 @@ type Repository interface {
|
|||||||
UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error
|
UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error
|
||||||
RecordBillingEvent(ctx context.Context, tenantID string, provider string, eventID string, eventType string, payload []byte) (bool, error)
|
RecordBillingEvent(ctx context.Context, tenantID string, provider string, eventID string, eventType string, payload []byte) (bool, error)
|
||||||
|
|
||||||
|
// Auth methods
|
||||||
|
GetUserByEmail(ctx context.Context, email string) (*UserRecord, error)
|
||||||
|
GetUserByID(ctx context.Context, userID string) (*UserRecord, error)
|
||||||
|
CreateUser(ctx context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error)
|
||||||
|
UpdateLastLogin(ctx context.Context, userID string) error
|
||||||
|
MarkEmailVerified(ctx context.Context, userID string) error
|
||||||
|
CreateMagicLink(ctx context.Context, token, userID, email string, expiresAt time.Time) error
|
||||||
|
GetMagicLink(ctx context.Context, token string) (*MagicLinkRecord, error)
|
||||||
|
MarkMagicLinkUsed(ctx context.Context, token string) error
|
||||||
|
|
||||||
|
// Admin methods
|
||||||
|
ListAllTenants(ctx context.Context, limit, offset int) ([]TenantRecord, int, error)
|
||||||
|
ListAllUsers(ctx context.Context, limit, offset int) ([]UserRecord, int, error)
|
||||||
|
GetPlatformStats(ctx context.Context) (PlatformStats, error)
|
||||||
|
CreateAdminAuditLog(ctx context.Context, params AdminAuditLogParams) error
|
||||||
|
UpdateUserRole(ctx context.Context, userID, role string) error
|
||||||
|
|
||||||
// Location / Zone Management
|
// Location / Zone Management
|
||||||
ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error)
|
ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error)
|
||||||
GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error)
|
GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error)
|
||||||
@@ -69,6 +86,18 @@ type Repository interface {
|
|||||||
// Working Hours
|
// Working Hours
|
||||||
ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error)
|
ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error)
|
||||||
UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error
|
UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error
|
||||||
|
|
||||||
|
// SMS
|
||||||
|
GetTenantSMSSettings(ctx context.Context, tenantID string) (TenantSMSSettingsRecord, error)
|
||||||
|
UpsertTenantSMSSettings(ctx context.Context, params TenantSMSSettingsRecord) error
|
||||||
|
CreateSMSUsageLog(ctx context.Context, params SMSUsageLogRecord) (string, error)
|
||||||
|
GetSMSUsageThisMonth(ctx context.Context, tenantID string) (SMSUsageSummary, error)
|
||||||
|
GetSMSUsageForMonth(ctx context.Context, tenantID string, yearMonth string) (SMSMonthlyReportRecord, error)
|
||||||
|
ListSMSUsageLogs(ctx context.Context, tenantID string, limit int) ([]SMSUsageLogRecord, error)
|
||||||
|
ListSMSMonthlyReports(ctx context.Context, tenantID string, limit int) ([]SMSMonthlyReportRecord, error)
|
||||||
|
UpsertSMSMonthlyReport(ctx context.Context, params SMSMonthlyReportRecord) error
|
||||||
|
MarkSMSReportInvoiceSent(ctx context.Context, tenantID string, yearMonth string) error
|
||||||
|
ListTenantsWithSMSUsage(ctx context.Context, yearMonth string) ([]TenantRecord, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TenantRecord struct {
|
type TenantRecord struct {
|
||||||
@@ -85,6 +114,46 @@ type TenantRecord struct {
|
|||||||
BillingSubscription *string
|
BillingSubscription *string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserRecord struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Email string
|
||||||
|
Name *string
|
||||||
|
PasswordHash *string
|
||||||
|
EmailVerified bool
|
||||||
|
Provider string
|
||||||
|
Role string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastLoginAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type MagicLinkRecord struct {
|
||||||
|
Token string
|
||||||
|
UserID uuid.UUID
|
||||||
|
Email string
|
||||||
|
Used bool
|
||||||
|
ExpiresAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlatformStats struct {
|
||||||
|
TotalTenants int64 `json:"totalTenants"`
|
||||||
|
TotalUsers int64 `json:"totalUsers"`
|
||||||
|
ActiveSubscriptions int64 `json:"activeSubscriptions"`
|
||||||
|
TrialSubscriptions int64 `json:"trialSubscriptions"`
|
||||||
|
BookingsThisMonth int64 `json:"bookingsThisMonth"`
|
||||||
|
RevenueThisMonth int64 `json:"revenueThisMonthCents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminAuditLogParams struct {
|
||||||
|
AdminUserID string
|
||||||
|
Action string
|
||||||
|
ResourceType string
|
||||||
|
ResourceID string
|
||||||
|
Details map[string]any
|
||||||
|
IPAddress string
|
||||||
|
UserAgent string
|
||||||
|
}
|
||||||
|
|
||||||
type TenantMembershipRecord struct {
|
type TenantMembershipRecord struct {
|
||||||
Tenant TenantRecord
|
Tenant TenantRecord
|
||||||
UserID string
|
UserID string
|
||||||
@@ -172,6 +241,7 @@ type BookingRecord struct {
|
|||||||
LocationID *string
|
LocationID *string
|
||||||
CustomerName string
|
CustomerName string
|
||||||
CustomerEmail string
|
CustomerEmail string
|
||||||
|
CustomerPhone string
|
||||||
StartsAt time.Time
|
StartsAt time.Time
|
||||||
EndsAt time.Time
|
EndsAt time.Time
|
||||||
Status string
|
Status string
|
||||||
@@ -187,6 +257,7 @@ type CreateBookingParams struct {
|
|||||||
BookingMode string
|
BookingMode string
|
||||||
CustomerName string
|
CustomerName string
|
||||||
CustomerEmail string
|
CustomerEmail string
|
||||||
|
CustomerPhone string
|
||||||
StartsAt time.Time
|
StartsAt time.Time
|
||||||
EndsAt time.Time
|
EndsAt time.Time
|
||||||
Status string
|
Status string
|
||||||
@@ -357,6 +428,44 @@ type UpdateWorkingHoursParams struct {
|
|||||||
IsOpen *bool
|
IsOpen *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SMS Records
|
||||||
|
type TenantSMSSettingsRecord struct {
|
||||||
|
TenantID string
|
||||||
|
Enabled bool
|
||||||
|
SenderName string
|
||||||
|
MonthlyLimit int
|
||||||
|
StripeSubscriptionItemID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SMSUsageLogRecord struct {
|
||||||
|
ID string
|
||||||
|
TenantID string
|
||||||
|
RecipientPhone string
|
||||||
|
MessageBody string
|
||||||
|
ExternalMessageID string
|
||||||
|
ExternalRequestID string
|
||||||
|
Status string
|
||||||
|
CostCents int
|
||||||
|
SentAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type SMSUsageSummary struct {
|
||||||
|
MessageCount int
|
||||||
|
TotalCostCents int
|
||||||
|
}
|
||||||
|
|
||||||
|
type SMSMonthlyReportRecord struct {
|
||||||
|
ID string
|
||||||
|
TenantID string
|
||||||
|
YearMonth string
|
||||||
|
MessageCount int
|
||||||
|
TotalCostCents int
|
||||||
|
StripeInvoiceID string
|
||||||
|
InvoiceSentAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type PGRepository struct {
|
type PGRepository struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
}
|
}
|
||||||
@@ -371,46 +480,14 @@ func NewRepository(pools *Pools, demoMode bool) Repository {
|
|||||||
return NewMemoryRepository()
|
return NewMemoryRepository()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// LOCATION / ZONE METHODS - PG REPOSITORY (STUBS)
|
// LOCATION / ZONE METHODS - PG REPOSITORY (STUBS)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// BLOCKED DAYS METHODS - PG REPOSITORY (STUBS)
|
// BLOCKED DAYS METHODS - PG REPOSITORY (STUBS)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// CUSTOMER METHODS - PG REPOSITORY (STUBS)
|
// CUSTOMER METHODS - PG REPOSITORY (STUBS)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -515,11 +592,11 @@ func (r *PGRepository) GetBookingByReference(ctx context.Context, reference stri
|
|||||||
var rec BookingRecord
|
var rec BookingRecord
|
||||||
err := r.pool.QueryRow(ctx, `
|
err := r.pool.QueryRow(ctx, `
|
||||||
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
||||||
customer_name, customer_email, starts_at, ends_at, status, reference
|
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference
|
||||||
FROM bookings
|
FROM bookings
|
||||||
WHERE reference = $1
|
WHERE reference = $1
|
||||||
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
|
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
|
||||||
&rec.CustomerName, &rec.CustomerEmail, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference)
|
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference)
|
||||||
return rec, err
|
return rec, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,8 +618,6 @@ func (r *PGRepository) RescheduleBooking(ctx context.Context, bookingID string,
|
|||||||
// WORKING HOURS METHODS - PG REPOSITORY (STUBS)
|
// WORKING HOURS METHODS - PG REPOSITORY (STUBS)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type MemoryRepository struct {
|
type MemoryRepository struct {
|
||||||
tenant TenantRecord
|
tenant TenantRecord
|
||||||
membership TenantMembershipRecord
|
membership TenantMembershipRecord
|
||||||
@@ -560,6 +635,9 @@ type MemoryRepository struct {
|
|||||||
blockedDays []BlockedDayRecord
|
blockedDays []BlockedDayRecord
|
||||||
customers []CustomerRecord
|
customers []CustomerRecord
|
||||||
workingHours []WorkingHoursRecord
|
workingHours []WorkingHoursRecord
|
||||||
|
smsSettings TenantSMSSettingsRecord
|
||||||
|
smsLogs []SMSUsageLogRecord
|
||||||
|
smsReports []SMSMonthlyReportRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMemoryRepository() *MemoryRepository {
|
func NewMemoryRepository() *MemoryRepository {
|
||||||
@@ -1303,6 +1381,60 @@ func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string
|
|||||||
return pgx.ErrNoRows
|
return pgx.ErrNoRows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth methods for MemoryRepository
|
||||||
|
func (r *MemoryRepository) GetUserByEmail(_ context.Context, email string) (*UserRecord, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MemoryRepository) GetUserByID(_ context.Context, userID string) (*UserRecord, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MemoryRepository) CreateUser(_ context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error) {
|
||||||
|
return &UserRecord{ID: uuid.New(), Email: email, Name: &name, Provider: provider, Role: role}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MemoryRepository) UpdateLastLogin(_ context.Context, userID string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MemoryRepository) MarkEmailVerified(_ context.Context, userID string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MemoryRepository) CreateMagicLink(_ context.Context, token, userID, email string, expiresAt time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MemoryRepository) GetMagicLink(_ context.Context, token string) (*MagicLinkRecord, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MemoryRepository) MarkMagicLinkUsed(_ context.Context, token string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin methods for MemoryRepository
|
||||||
|
func (r *MemoryRepository) ListAllTenants(_ context.Context, limit, offset int) ([]TenantRecord, int, error) {
|
||||||
|
return []TenantRecord{r.tenant}, 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MemoryRepository) ListAllUsers(_ context.Context, limit, offset int) ([]UserRecord, int, error) {
|
||||||
|
return []UserRecord{}, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MemoryRepository) GetPlatformStats(_ context.Context) (PlatformStats, error) {
|
||||||
|
return PlatformStats{TotalTenants: 1, TotalUsers: 1, ActiveSubscriptions: 1}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MemoryRepository) CreateAdminAuditLog(_ context.Context, params AdminAuditLogParams) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MemoryRepository) UpdateUserRole(_ context.Context, userID, role string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func Reference(prefix string, at time.Time) string {
|
func Reference(prefix string, at time.Time) string {
|
||||||
return fmt.Sprintf("%s-%s-%s", prefix, at.UTC().Format("20060102150405"), strings.Split(uuid.NewString(), "-")[0])
|
return fmt.Sprintf("%s-%s-%s", prefix, at.UTC().Format("20060102150405"), strings.Split(uuid.NewString(), "-")[0])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ type DashboardSummary struct {
|
|||||||
SetupCompletion int `json:"setupCompletion"`
|
SetupCompletion int `json:"setupCompletion"`
|
||||||
KPIs []DashboardKPI `json:"kpis"`
|
KPIs []DashboardKPI `json:"kpis"`
|
||||||
UpcomingBookings []UpcomingBooking `json:"upcomingBookings"`
|
UpcomingBookings []UpcomingBooking `json:"upcomingBookings"`
|
||||||
|
AllBookings []UpcomingBooking `json:"allBookings"`
|
||||||
WidgetSnippets []WidgetSnippet `json:"widgetSnippets"`
|
WidgetSnippets []WidgetSnippet `json:"widgetSnippets"`
|
||||||
Tracking TrackingStatus `json:"tracking"`
|
Tracking TrackingStatus `json:"tracking"`
|
||||||
}
|
}
|
||||||
@@ -132,6 +133,7 @@ type CreateBookingRequest struct {
|
|||||||
LocationID *string `json:"locationId,omitempty"`
|
LocationID *string `json:"locationId,omitempty"`
|
||||||
CustomerName string `json:"customerName"`
|
CustomerName string `json:"customerName"`
|
||||||
CustomerEmail string `json:"customerEmail"`
|
CustomerEmail string `json:"customerEmail"`
|
||||||
|
CustomerPhone string `json:"customerPhone,omitempty"`
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
StartsAt string `json:"startsAt"`
|
StartsAt string `json:"startsAt"`
|
||||||
EndsAt string `json:"endsAt"`
|
EndsAt string `json:"endsAt"`
|
||||||
@@ -146,16 +148,33 @@ type CreateBookingResponse struct {
|
|||||||
type PlanEntitlements struct {
|
type PlanEntitlements struct {
|
||||||
MaxLocations int `json:"maxLocations"`
|
MaxLocations int `json:"maxLocations"`
|
||||||
MaxStaff int `json:"maxStaff"`
|
MaxStaff int `json:"maxStaff"`
|
||||||
|
MaxBookingsMonth int `json:"maxBookingsMonth"` // -1 = unlimited
|
||||||
EmailReminders bool `json:"emailReminders"`
|
EmailReminders bool `json:"emailReminders"`
|
||||||
AdvancedReporting bool `json:"advancedReporting"`
|
AdvancedReporting bool `json:"advancedReporting"`
|
||||||
WidgetEmbedding bool `json:"widgetEmbedding"`
|
WidgetEmbedding bool `json:"widgetEmbedding"`
|
||||||
UmamiTracking bool `json:"umamiTracking"`
|
UmamiTracking bool `json:"umamiTracking"`
|
||||||
|
APIAccess bool `json:"apiAccess"`
|
||||||
|
DedicatedManager bool `json:"dedicatedManager"`
|
||||||
|
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 {
|
type PlanDisplayPrice struct {
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
AmountCents int `json:"amountCents"`
|
AmountCents int `json:"amountCents"`
|
||||||
Formatted string `json:"formatted"`
|
Formatted string `json:"formatted"`
|
||||||
|
YearlyAmountCents int `json:"yearlyAmountCents,omitempty"`
|
||||||
|
YearlyFormatted string `json:"yearlyFormatted,omitempty"`
|
||||||
|
YearlySavings string `json:"yearlySavings,omitempty"`
|
||||||
|
YearlySavingsPercent int `json:"yearlySavingsPercent,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubscriptionSnapshot struct {
|
type SubscriptionSnapshot struct {
|
||||||
@@ -182,17 +201,22 @@ type SubscriptionSnapshot struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CheckoutSessionRequest struct {
|
type CheckoutSessionRequest struct {
|
||||||
PlanCode string `json:"planCode"`
|
PlanCode string `json:"planCode"`
|
||||||
Currency string `json:"currency,omitempty"`
|
Currency string `json:"currency,omitempty"`
|
||||||
|
BillingInterval string `json:"billingInterval,omitempty"` // "monthly" or "yearly", defaults to "monthly"
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckoutLaunchResponse struct {
|
type CheckoutLaunchResponse struct {
|
||||||
PriceID string `json:"priceId"`
|
// Stripe checkout
|
||||||
CustomerID string `json:"customerId,omitempty"`
|
CheckoutURL string `json:"checkoutUrl,omitempty"`
|
||||||
CustomerEmail string `json:"customerEmail,omitempty"`
|
// Paddle checkout
|
||||||
SuccessRedirectURL string `json:"successRedirectUrl"`
|
PriceID string `json:"priceId,omitempty"`
|
||||||
CancelRedirectURL string `json:"cancelRedirectUrl"`
|
CustomerID string `json:"customerId,omitempty"`
|
||||||
CustomData map[string]string `json:"customData"`
|
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 {
|
type PortalSessionResponse struct {
|
||||||
@@ -240,19 +264,19 @@ type UpdateLocationRequest struct {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
type BlockedDay struct {
|
type BlockedDay struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TenantID string `json:"tenantId"`
|
TenantID string `json:"tenantId"`
|
||||||
Date time.Time `json:"date"`
|
Date time.Time `json:"date"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
Type string `json:"type"` // full, partial
|
Type string `json:"type"` // full, partial
|
||||||
StaffID *string `json:"staffId,omitempty"`
|
StaffID *string `json:"staffId,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateBlockedDayRequest struct {
|
type CreateBlockedDayRequest struct {
|
||||||
Date string `json:"date" binding:"required"` // RFC3339
|
Date string `json:"date" binding:"required"` // RFC3339
|
||||||
Reason string `json:"reason" binding:"required"`
|
Reason string `json:"reason" binding:"required"`
|
||||||
Type string `json:"type" binding:"required"` // full, partial
|
Type string `json:"type" binding:"required"` // full, partial
|
||||||
StaffID *string `json:"staffId,omitempty"`
|
StaffID *string `json:"staffId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,32 +290,32 @@ type UpdateBlockedDayRequest struct {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
type Customer struct {
|
type Customer struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TenantID string `json:"tenantId"`
|
TenantID string `json:"tenantId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Phone *string `json:"phone,omitempty"`
|
Phone *string `json:"phone,omitempty"`
|
||||||
Status string `json:"status"` // active, inactive, vip
|
Status string `json:"status"` // active, inactive, vip
|
||||||
BookingsCount int `json:"bookingsCount"`
|
BookingsCount int `json:"bookingsCount"`
|
||||||
LastBookingAt *time.Time `json:"lastBookingAt,omitempty"`
|
LastBookingAt *time.Time `json:"lastBookingAt,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateCustomerRequest struct {
|
type CreateCustomerRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
Phone *string `json:"phone,omitempty"`
|
Phone *string `json:"phone,omitempty"`
|
||||||
Status string `json:"status,omitempty"` // defaults to active
|
Status string `json:"status,omitempty"` // defaults to active
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateCustomerRequest struct {
|
type UpdateCustomerRequest struct {
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
Phone *string `json:"phone,omitempty"`
|
Phone *string `json:"phone,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -312,15 +336,70 @@ type CustomerBookingView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RescheduleBookingRequest struct {
|
type RescheduleBookingRequest struct {
|
||||||
NewStartsAt string `json:"newStartsAt" binding:"required"` // RFC3339
|
NewStartsAt string `json:"newStartsAt" binding:"required"` // RFC3339
|
||||||
NewEndsAt string `json:"newEndsAt" binding:"required"` // RFC3339
|
NewEndsAt string `json:"newEndsAt" binding:"required"` // RFC3339
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CancelBookingRequest struct {
|
type CancelBookingRequest struct {
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ADMIN MODELS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
type AdminDashboardStats struct {
|
||||||
|
TotalTenants int64 `json:"totalTenants"`
|
||||||
|
TotalUsers int64 `json:"totalUsers"`
|
||||||
|
ActiveSubscriptions int64 `json:"activeSubscriptions"`
|
||||||
|
TrialSubscriptions int64 `json:"trialSubscriptions"`
|
||||||
|
BookingsThisMonth int64 `json:"bookingsThisMonth"`
|
||||||
|
RevenueThisMonthCents int64 `json:"revenueThisMonthCents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminTenantList struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
Tenants []AdminTenant `json:"tenants"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminTenant struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PlanCode string `json:"planCode"`
|
||||||
|
SubscriptionStatus string `json:"subscriptionStatus"`
|
||||||
|
BillingProvider string `json:"billingProvider"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminUserList struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
Users []AdminUser `json:"users"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminUser struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
EmailVerified bool `json:"emailVerified"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminLoginRequest struct {
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
Key string `json:"key" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateUserRoleRequest struct {
|
||||||
|
Role string `json:"role" binding:"required,oneof=user admin superadmin"`
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// WORKING HOURS MODELS
|
// WORKING HOURS MODELS
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -345,7 +424,7 @@ type UpdateWorkingHoursRequest struct {
|
|||||||
type EmailTemplate struct {
|
type EmailTemplate struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TenantID string `json:"tenantId"`
|
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"`
|
Subject string `json:"subject"`
|
||||||
BodyHTML string `json:"bodyHtml"`
|
BodyHTML string `json:"bodyHtml"`
|
||||||
BodyText string `json:"bodyText"`
|
BodyText string `json:"bodyText"`
|
||||||
@@ -360,14 +439,69 @@ type SendEmailRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EmailNotification struct {
|
type EmailNotification struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TenantID string `json:"tenantId"`
|
TenantID string `json:"tenantId"`
|
||||||
BookingID string `json:"bookingId,omitempty"`
|
BookingID string `json:"bookingId,omitempty"`
|
||||||
Channel string `json:"channel"` // email, sms
|
Channel string `json:"channel"` // email, sms
|
||||||
Type string `json:"type"` // confirmation, reminder, cancellation
|
Type string `json:"type"` // confirmation, reminder, cancellation
|
||||||
Recipient string `json:"recipient"`
|
Recipient string `json:"recipient"`
|
||||||
Status string `json:"status"` // pending, sent, failed
|
Status string `json:"status"` // pending, sent, failed
|
||||||
SentAt *time.Time `json:"sentAt,omitempty"`
|
SentAt *time.Time `json:"sentAt,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SMS MODELS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
type SMSSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
SenderName string `json:"senderName,omitempty"`
|
||||||
|
MonthlyLimit int `json:"monthlyLimit,omitempty"`
|
||||||
|
MessagesSent int `json:"messagesSent"`
|
||||||
|
TotalCostCents int `json:"totalCostCents"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateSMSSettingsRequest struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
SenderName string `json:"senderName,omitempty"`
|
||||||
|
MonthlyLimit int `json:"monthlyLimit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendSMSRequest struct {
|
||||||
|
To string `json:"to" binding:"required"`
|
||||||
|
Body string `json:"body" binding:"required,max=1000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendSMSResponse struct {
|
||||||
|
LogID string `json:"logId"`
|
||||||
|
MessageID string `json:"messageId,omitempty"`
|
||||||
|
RequestID string `json:"requestId,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CostCents int `json:"costCents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SMSUsageLog struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
RecipientPhone string `json:"recipientPhone"`
|
||||||
|
MessageBody string `json:"messageBody,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CostCents int `json:"costCents"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SMSUsageReport struct {
|
||||||
|
YearMonth string `json:"yearMonth"`
|
||||||
|
MessageCount int `json:"messageCount"`
|
||||||
|
TotalCostCents int `json:"totalCostCents"`
|
||||||
|
StripeInvoiceID string `json:"stripeInvoiceId,omitempty"`
|
||||||
|
InvoiceSentAt *time.Time `json:"invoiceSentAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SMSInvoiceBatchResponse struct {
|
||||||
|
YearMonth string `json:"yearMonth"`
|
||||||
|
ProcessedCount int `json:"processedCount"`
|
||||||
|
FailedCount int `json:"failedCount"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,40 +10,146 @@ import (
|
|||||||
type EmailType string
|
type EmailType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
EmailTypeConfirmation EmailType = "confirmation"
|
EmailTypeConfirmation EmailType = "confirmation"
|
||||||
EmailTypeReminder EmailType = "reminder"
|
EmailTypeReminder EmailType = "reminder"
|
||||||
EmailTypeReschedule EmailType = "reschedule"
|
EmailTypeReschedule EmailType = "reschedule"
|
||||||
EmailTypeCancellation EmailType = "cancellation"
|
EmailTypeCancellation EmailType = "cancellation"
|
||||||
EmailTypeBusinessNotify EmailType = "business_notify"
|
EmailTypeBusinessNotify EmailType = "business_notify"
|
||||||
|
EmailTypeUsageWarning EmailType = "usage_warning"
|
||||||
|
EmailTypeTrialEnding EmailType = "trial_ending"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BookingEmailData struct {
|
type BookingEmailData struct {
|
||||||
|
Type EmailType
|
||||||
|
TenantName string
|
||||||
|
TenantSlug string
|
||||||
|
BusinessEmail string
|
||||||
|
BusinessPhone string
|
||||||
|
BusinessAddress string
|
||||||
|
BrandColor string
|
||||||
|
CustomerName string
|
||||||
|
CustomerEmail string
|
||||||
|
Service string
|
||||||
|
Location string
|
||||||
|
Reference string
|
||||||
|
StartsAt time.Time
|
||||||
|
EndsAt time.Time
|
||||||
|
Timezone string
|
||||||
|
Locale string
|
||||||
|
Notes string
|
||||||
|
ManagementURL string
|
||||||
|
AddToCalendarURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UsageNotificationData struct {
|
||||||
Type EmailType
|
Type EmailType
|
||||||
TenantName string
|
TenantName string
|
||||||
TenantSlug string
|
TenantSlug string
|
||||||
BusinessEmail string
|
BusinessEmail string
|
||||||
BusinessPhone string
|
|
||||||
BusinessAddress string
|
|
||||||
BrandColor string
|
BrandColor string
|
||||||
CustomerName string
|
AdminEmail string
|
||||||
CustomerEmail string
|
|
||||||
Service string
|
|
||||||
Location string
|
|
||||||
Reference string
|
|
||||||
StartsAt time.Time
|
|
||||||
EndsAt time.Time
|
|
||||||
Timezone string
|
|
||||||
Locale string
|
Locale string
|
||||||
Notes string
|
PlanCode string
|
||||||
ManagementURL string
|
LocationCount int
|
||||||
AddToCalendarURL string
|
LocationLimit int
|
||||||
|
UsagePercent int
|
||||||
|
UpgradeURL string
|
||||||
|
DashboardURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderUsageNotificationEmail(data UsageNotificationData) EmailMessage {
|
||||||
|
subject := renderUsageSubject(data)
|
||||||
|
htmlBody := renderUsageHTML(data)
|
||||||
|
textBody := renderUsageText(data)
|
||||||
|
|
||||||
|
return EmailMessage{
|
||||||
|
From: data.BusinessEmail,
|
||||||
|
To: data.AdminEmail,
|
||||||
|
Subject: subject,
|
||||||
|
Text: textBody,
|
||||||
|
HTML: htmlBody,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderUsageSubject(data UsageNotificationData) string {
|
||||||
|
if data.Locale == "cs" {
|
||||||
|
switch data.Type {
|
||||||
|
case EmailTypeUsageWarning:
|
||||||
|
return "⚠️ Blížíte se limitu lokací - Upgrade na vyšší plán"
|
||||||
|
case EmailTypeTrialEnding:
|
||||||
|
return "⏰ Vaše zkušební období končí - Pokračujte s Bookra"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch data.Type {
|
||||||
|
case EmailTypeUsageWarning:
|
||||||
|
return "⚠️ You're nearing your location limit - Upgrade your plan"
|
||||||
|
case EmailTypeTrialEnding:
|
||||||
|
return "⏰ Your trial period is ending - Continue with Bookra"
|
||||||
|
}
|
||||||
|
return "Bookra notification"
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderUsageHTML(data UsageNotificationData) string {
|
||||||
|
cs := data.Locale == "cs"
|
||||||
|
upgradeBtn := `<a href="` + data.UpgradeURL + `" style="display:inline-block;background:#4f46e5;color:#fff;padding:12px 24px;text-decoration:none;border-radius:8px;font-weight:600;margin:8px 0;">` + map[bool]string{true: "Upgradeovat", false: "Upgrade"}[cs] + `</a>`
|
||||||
|
dashboardBtn := `<a href="` + data.DashboardURL + `" style="display:inline-block;background:#f3f4f6;color:#374151;padding:12px 24px;text-decoration:none;border-radius:8px;font-weight:600;margin:8px 0;">` + map[bool]string{true: "Otevřít dashboard", false: "Open dashboard"}[cs] + `</a>`
|
||||||
|
|
||||||
|
if data.Type == EmailTypeUsageWarning {
|
||||||
|
var msg string
|
||||||
|
if cs {
|
||||||
|
msg = fmt.Sprintf("Váš plán %s umožňuje pouze %d lokací. Aktuálně používáte %d (%d%%).", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent)
|
||||||
|
} else {
|
||||||
|
msg = fmt.Sprintf("Your %s plan allows only %d locations. You're currently using %d (%d%%).", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent)
|
||||||
|
}
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||||
|
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1f2937;">
|
||||||
|
<div style="text-align:center;margin-bottom:24px;"><span style="font-size:32px;">📍</span></div>
|
||||||
|
<h2 style="text-align:center;color:#1f2937;margin-bottom:16px;">` + map[bool]string{true: "Blížíte se limitu lokací", false: "You're nearing your location limit"}[cs] + `</h2>
|
||||||
|
<p style="color:#6b7280;text-align:center;margin-bottom:24px;">` + msg + `</p>
|
||||||
|
<div style="text-align:center;margin-bottom:24px;">` + upgradeBtn + `</div>
|
||||||
|
<p style="color:#9ca3af;font-size:14px;text-align:center;">` + map[bool]string{true: "Přidejte další lokace s vyšším plánem", false: "Add more locations with a higher plan"}[cs] + `</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
|
||||||
|
<p style="color:#9ca3af;font-size:12px;text-align:center;">Powered by <a href="https://bookra.eu" style="color:#4f46e5;">Bookra</a></p>
|
||||||
|
</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trial ending email
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||||
|
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1f2937;">
|
||||||
|
<div style="text-align:center;margin-bottom:24px;"><span style="font-size:32px;">🎉</span></div>
|
||||||
|
<h2 style="text-align:center;color:#1f2937;margin-bottom:16px;">` + map[bool]string{true: "Děkujeme, že používáte Bookra!", false: "Thank you for using Bookra!"}[cs] + `</h2>
|
||||||
|
<p style="color:#6b7280;text-align:center;margin-bottom:24px;">` + map[bool]string{true: "Vaše zkušební období brzy končí. Pokud se vám naše služba líbí, můžete pokračovat s vybraným plánem.", false: "Your trial period is ending soon. If you like our service, you can continue with your chosen plan."}[cs] + `</p>
|
||||||
|
<div style="text-align:center;margin-bottom:24px;">` + upgradeBtn + `</div>
|
||||||
|
<p style="color:#6b7280;text-align:center;margin-bottom:16px;">` + map[bool]string{true: "Pokud se vám služba nelíbí, můžete ji kdykoliv zrušit. Nechceme vám brát peníze, pokud nejste spokojeni.", false: "If you don't like our service, you can cancel anytime. We don't want to take your money if you're not happy."}[cs] + `</p>
|
||||||
|
<p style="color:#9ca3af;text-align:center;margin-bottom:24px;">` + map[bool]string{true: "Zrušit můžete zde:", false: "Cancel here:"}[cs] + ` ` + dashboardBtn + `</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
|
||||||
|
<p style="color:#9ca3af;font-size:12px;text-align:center;">Powered by <a href="https://bookra.eu" style="color:#4f46e5;">Bookra</a></p>
|
||||||
|
</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderUsageText(data UsageNotificationData) string {
|
||||||
|
cs := data.Locale == "cs"
|
||||||
|
if data.Type == EmailTypeUsageWarning {
|
||||||
|
if cs {
|
||||||
|
return fmt.Sprintf("Blížíte se limitu lokací! Váš plán %s umožňuje pouze %d lokací. Aktuálně používáte %d (%d%%). Upgradeujte na vyšší plán: %s", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent, data.UpgradeURL)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("You're nearing your location limit! Your %s plan allows only %d locations. You're currently using %d (%d%%). Upgrade: %s", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent, data.UpgradeURL)
|
||||||
|
}
|
||||||
|
if cs {
|
||||||
|
return "Vaše zkušební období končí. Pokud se vám služba líbí, můžete pokračovat. Pokud ne, můžete zrušit. Nechceme vám brát peníze, pokud nejste spokojeni. Dashboard: " + data.DashboardURL
|
||||||
|
}
|
||||||
|
return "Your trial period is ending. If you like our service, you can continue. If not, you can cancel - we don't want your money if you're not happy. Dashboard: " + data.DashboardURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func RenderEmailMessage(data BookingEmailData) EmailMessage {
|
func RenderEmailMessage(data BookingEmailData) EmailMessage {
|
||||||
subject := renderSubject(data)
|
subject := renderSubject(data)
|
||||||
htmlBody := renderHTMLBody(data)
|
htmlBody := renderHTMLBody(data)
|
||||||
textBody := renderTextBody(data)
|
textBody := renderTextBody(data)
|
||||||
|
|
||||||
return EmailMessage{
|
return EmailMessage{
|
||||||
From: data.BusinessEmail,
|
From: data.BusinessEmail,
|
||||||
To: data.CustomerEmail,
|
To: data.CustomerEmail,
|
||||||
@@ -55,7 +161,7 @@ func RenderEmailMessage(data BookingEmailData) EmailMessage {
|
|||||||
|
|
||||||
func renderSubject(data BookingEmailData) string {
|
func renderSubject(data BookingEmailData) string {
|
||||||
localizedTime := formatLocalizedTime(data.StartsAt, data.Timezone, data.Locale)
|
localizedTime := formatLocalizedTime(data.StartsAt, data.Timezone, data.Locale)
|
||||||
|
|
||||||
switch data.Type {
|
switch data.Type {
|
||||||
case EmailTypeConfirmation:
|
case EmailTypeConfirmation:
|
||||||
if data.Locale == "cs" {
|
if data.Locale == "cs" {
|
||||||
@@ -89,7 +195,7 @@ func renderSubject(data BookingEmailData) string {
|
|||||||
|
|
||||||
func renderTextBody(data BookingEmailData) string {
|
func renderTextBody(data BookingEmailData) string {
|
||||||
localizedTime := formatLocalizedDateTime(data.StartsAt, data.Timezone, data.Locale)
|
localizedTime := formatLocalizedDateTime(data.StartsAt, data.Timezone, data.Locale)
|
||||||
|
|
||||||
switch data.Type {
|
switch data.Type {
|
||||||
case EmailTypeConfirmation:
|
case EmailTypeConfirmation:
|
||||||
if data.Locale == "cs" {
|
if data.Locale == "cs" {
|
||||||
@@ -124,7 +230,7 @@ Manage your booking at: %s
|
|||||||
Thank you,
|
Thank you,
|
||||||
%s
|
%s
|
||||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName, data.BusinessEmail)
|
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName, data.BusinessEmail)
|
||||||
|
|
||||||
case EmailTypeReminder:
|
case EmailTypeReminder:
|
||||||
if data.Locale == "cs" {
|
if data.Locale == "cs" {
|
||||||
return fmt.Sprintf(`Dobrý den %s,
|
return fmt.Sprintf(`Dobrý den %s,
|
||||||
@@ -152,7 +258,7 @@ This is a reminder for your booking tomorrow.
|
|||||||
Manage booking: %s
|
Manage booking: %s
|
||||||
|
|
||||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
|
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
|
||||||
|
|
||||||
case EmailTypeReschedule:
|
case EmailTypeReschedule:
|
||||||
if data.Locale == "cs" {
|
if data.Locale == "cs" {
|
||||||
return fmt.Sprintf(`Dobrý den %s,
|
return fmt.Sprintf(`Dobrý den %s,
|
||||||
@@ -182,7 +288,7 @@ New details:
|
|||||||
Manage booking: %s
|
Manage booking: %s
|
||||||
|
|
||||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
|
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
|
||||||
|
|
||||||
case EmailTypeCancellation:
|
case EmailTypeCancellation:
|
||||||
if data.Locale == "cs" {
|
if data.Locale == "cs" {
|
||||||
return fmt.Sprintf(`Dobrý den %s,
|
return fmt.Sprintf(`Dobrý den %s,
|
||||||
@@ -210,7 +316,7 @@ Cancelled booking:
|
|||||||
If you didn't cancel this, please contact us: %s
|
If you didn't cancel this, please contact us: %s
|
||||||
|
|
||||||
%s`, data.CustomerName, data.Service, localizedTime, data.Reference, data.BusinessEmail, data.TenantName)
|
%s`, data.CustomerName, data.Service, localizedTime, data.Reference, data.BusinessEmail, data.TenantName)
|
||||||
|
|
||||||
case EmailTypeBusinessNotify:
|
case EmailTypeBusinessNotify:
|
||||||
if data.Locale == "cs" {
|
if data.Locale == "cs" {
|
||||||
return fmt.Sprintf(`Nová rezervace od %s
|
return fmt.Sprintf(`Nová rezervace od %s
|
||||||
@@ -232,7 +338,7 @@ Details:
|
|||||||
- Email: %s
|
- Email: %s
|
||||||
|
|
||||||
Manage in dashboard: https://bookra.eu/dashboard`, data.CustomerName, data.Service, localizedTime, data.Reference, data.CustomerEmail)
|
Manage in dashboard: https://bookra.eu/dashboard`, data.CustomerName, data.Service, localizedTime, data.Reference, data.CustomerEmail)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return "Booking update"
|
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 := "<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 += "<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)
|
html += fmt.Sprintf("<h2 style='color: %s; margin-bottom: 20px;'>%s</h2>", data.BrandColor, data.TenantName)
|
||||||
|
|
||||||
// Convert text to simple HTML
|
// Convert text to simple HTML
|
||||||
paragraphs := splitParagraphs(textBody)
|
paragraphs := splitParagraphs(textBody)
|
||||||
for _, p := range paragraphs {
|
for _, p := range paragraphs {
|
||||||
@@ -253,12 +359,12 @@ func renderHTMLBody(data BookingEmailData) string {
|
|||||||
html += fmt.Sprintf("<p style='margin-bottom: 10px;'>%s</p>", p)
|
html += fmt.Sprintf("<p style='margin-bottom: 10px;'>%s</p>", p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add management button
|
// Add management button
|
||||||
if data.ManagementURL != "" {
|
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 += 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>"
|
html += "</div></body></html>"
|
||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
@@ -347,8 +453,98 @@ func RenderReminderEmail(from string, job db.ReminderJobRecord) EmailMessage {
|
|||||||
StartsAt: job.StartsAt,
|
StartsAt: job.StartsAt,
|
||||||
Timezone: job.Timezone,
|
Timezone: job.Timezone,
|
||||||
Locale: job.Locale,
|
Locale: job.Locale,
|
||||||
Service: "Service", // Legacy
|
Service: "Service", // Legacy
|
||||||
Location: "Location", // Legacy
|
Location: "Location", // Legacy
|
||||||
}
|
}
|
||||||
return RenderEmailMessage(data)
|
return RenderEmailMessage(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 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()),
|
ExternalID: fmt.Sprintf("smtp-%d", time.Now().UnixNano()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendContactEmail sends a contact form submission to the business email
|
||||||
|
func (s *Service) SendContactEmail(ctx context.Context, name, email, message string) error {
|
||||||
|
subject := fmt.Sprintf("Bookra Contact: Message from %s", name)
|
||||||
|
text := fmt.Sprintf("Name: %s\nEmail: %s\n\nMessage:\n%s", name, email, message)
|
||||||
|
html := fmt.Sprintf(
|
||||||
|
"<h2>New contact form submission</h2><p><strong>Name:</strong> %s</p><p><strong>Email:</strong> %s</p><p><strong>Message:</strong></p><p>%s</p>",
|
||||||
|
name, email, message,
|
||||||
|
)
|
||||||
|
msg := EmailMessage{
|
||||||
|
From: s.cfg.EmailFrom,
|
||||||
|
To: s.cfg.EmailFrom,
|
||||||
|
Subject: subject,
|
||||||
|
Text: text,
|
||||||
|
HTML: html,
|
||||||
|
}
|
||||||
|
_, err := s.emailProvider.Send(ctx, msg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error {
|
||||||
|
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get tenant: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a placeholder admin email - in production, would get from tenant owner
|
||||||
|
adminEmail := "admin@" + tenant.Slug + ".bookra.eu"
|
||||||
|
|
||||||
|
emailData := UsageNotificationData{
|
||||||
|
Type: EmailTypeUsageWarning,
|
||||||
|
TenantName: tenant.Name,
|
||||||
|
TenantSlug: tenant.Slug,
|
||||||
|
BusinessEmail: s.cfg.EmailFrom,
|
||||||
|
AdminEmail: adminEmail,
|
||||||
|
Locale: tenant.Locale,
|
||||||
|
PlanCode: tenant.PlanCode,
|
||||||
|
LocationCount: locationCount,
|
||||||
|
LocationLimit: locationLimit,
|
||||||
|
UsagePercent: usagePercent,
|
||||||
|
UpgradeURL: "https://bookra.eu/pricing",
|
||||||
|
DashboardURL: "https://bookra.eu/dashboard",
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := RenderUsageNotificationEmail(emailData)
|
||||||
|
_, err = s.emailProvider.Send(ctx, msg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package sms
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"bookra/apps/backend/internal/config"
|
||||||
|
"bookra/apps/backend/internal/db"
|
||||||
|
|
||||||
|
"github.com/stripe/stripe-go/v81"
|
||||||
|
"github.com/stripe/stripe-go/v81/invoiceitem"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BillingService struct {
|
||||||
|
cfg config.Config
|
||||||
|
repo db.Repository
|
||||||
|
smsPriceIDs map[string]string // currency -> price ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBillingService(cfg config.Config, repo db.Repository) *BillingService {
|
||||||
|
return &BillingService{
|
||||||
|
cfg: cfg,
|
||||||
|
repo: repo,
|
||||||
|
smsPriceIDs: cfg.StripeSMSPriceMatrix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PriceIDForCurrency returns the Stripe price ID for SMS in the given currency
|
||||||
|
func (b *BillingService) PriceIDForCurrency(currency string) string {
|
||||||
|
c := strings.ToLower(strings.TrimSpace(currency))
|
||||||
|
if c == "" {
|
||||||
|
c = "czk"
|
||||||
|
}
|
||||||
|
if id := b.smsPriceIDs[c]; id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
// Fallback to CZK
|
||||||
|
return b.smsPriceIDs["czk"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMonthlyInvoiceItem creates a Stripe InvoiceItem for the total SMS usage of a month.
|
||||||
|
// This adds a line item to the customer's next invoice — charging all messages together.
|
||||||
|
func (b *BillingService) CreateMonthlyInvoiceItem(ctx context.Context, customerID string, currency string, yearMonth string, messageCount int, totalCents int) (string, error) {
|
||||||
|
if customerID == "" {
|
||||||
|
return "", fmt.Errorf("customer id is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
priceID := b.PriceIDForCurrency(currency)
|
||||||
|
c := strings.ToLower(strings.TrimSpace(currency))
|
||||||
|
if c == "" {
|
||||||
|
c = "czk"
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a Stripe price is configured, use Price + Quantity for a clean invoice line
|
||||||
|
if priceID != "" {
|
||||||
|
item, err := invoiceitem.New(&stripe.InvoiceItemParams{
|
||||||
|
Customer: stripe.String(customerID),
|
||||||
|
Price: stripe.String(priceID),
|
||||||
|
Quantity: stripe.Int64(int64(messageCount)),
|
||||||
|
Description: stripe.String(fmt.Sprintf("SMS Messages (%s) — %d messages", yearMonth, messageCount)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create invoice item: %w", err)
|
||||||
|
}
|
||||||
|
return item.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: explicit amount (for dev/testing when no price configured yet)
|
||||||
|
item, err := invoiceitem.New(&stripe.InvoiceItemParams{
|
||||||
|
Customer: stripe.String(customerID),
|
||||||
|
Amount: stripe.Int64(int64(totalCents)),
|
||||||
|
Currency: stripe.String(c),
|
||||||
|
Description: stripe.String(fmt.Sprintf("SMS Messages (%s) — %d messages", yearMonth, messageCount)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create invoice item: %w", err)
|
||||||
|
}
|
||||||
|
return item.ID, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
package sms
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookra/apps/backend/internal/config"
|
||||||
|
"bookra/apps/backend/internal/db"
|
||||||
|
"bookra/apps/backend/internal/domain"
|
||||||
|
"bookra/apps/backend/internal/notifications"
|
||||||
|
"bookra/apps/backend/internal/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrSMSNotConfigured = errors.New("sms is not configured")
|
||||||
|
ErrSMSNotEnabled = errors.New("sms is not enabled for this tenant")
|
||||||
|
ErrSMSPlanNotAllowed = errors.New("sms is only available on pro and business plans")
|
||||||
|
ErrSMSLimitReached = errors.New("monthly sms limit reached")
|
||||||
|
ErrSMSInvalidPhone = errors.New("invalid phone number")
|
||||||
|
ErrSMSMissingAPIKey = errors.New("sms manager api key is missing")
|
||||||
|
ErrSMSSendFailed = errors.New("sms send failed")
|
||||||
|
ErrStripeNotConfigured = errors.New("stripe is not configured for sms billing")
|
||||||
|
ErrNoActiveSubscription = errors.New("no active subscription for sms billing")
|
||||||
|
)
|
||||||
|
|
||||||
|
const smsCostCents = 150 // 1.50 CZK
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
cfg config.Config
|
||||||
|
repo db.Repository
|
||||||
|
billing *BillingService
|
||||||
|
client *http.Client
|
||||||
|
baseURL string
|
||||||
|
apiKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(cfg config.Config, repo db.Repository) *Service {
|
||||||
|
s := &Service{
|
||||||
|
cfg: cfg,
|
||||||
|
repo: repo,
|
||||||
|
client: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
baseURL: strings.TrimRight(cfg.SMSManagerBaseURL, "/"),
|
||||||
|
apiKey: cfg.SMSManagerAPIKey,
|
||||||
|
}
|
||||||
|
if cfg.StripeConfigured() && cfg.StripeSMSConfigured() {
|
||||||
|
s.billing = NewBillingService(cfg, repo)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Enabled() bool {
|
||||||
|
return s.cfg.SMSConfigured()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) IsAvailable(ctx context.Context, tenantID string) (bool, error) {
|
||||||
|
if !s.Enabled() {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return settings.Enabled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) canUseSMS(ctx context.Context, tenantID string) error {
|
||||||
|
if !s.Enabled() {
|
||||||
|
return ErrSMSNotConfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := shared.NormalizePlanCode(tenant.PlanCode)
|
||||||
|
if plan != "pro" && plan != "business" {
|
||||||
|
return ErrSMSPlanNotAllowed
|
||||||
|
}
|
||||||
|
|
||||||
|
if tenant.SubscriptionStatus != "active" && tenant.SubscriptionStatus != "trialing" {
|
||||||
|
return ErrNoActiveSubscription
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !settings.Enabled {
|
||||||
|
return ErrSMSNotEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetSettings(ctx context.Context, tenantID string) (domain.SMSSettings, error) {
|
||||||
|
settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.SMSSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := s.repo.GetSMSUsageThisMonth(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.SMSSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.SMSSettings{
|
||||||
|
Enabled: settings.Enabled,
|
||||||
|
SenderName: settings.SenderName,
|
||||||
|
MonthlyLimit: settings.MonthlyLimit,
|
||||||
|
MessagesSent: usage.MessageCount,
|
||||||
|
TotalCostCents: usage.TotalCostCents,
|
||||||
|
Available: s.Enabled(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateSettings(ctx context.Context, tenantID string, req domain.UpdateSMSSettingsRequest) (domain.SMSSettings, error) {
|
||||||
|
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.SMSSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := shared.NormalizePlanCode(tenant.PlanCode)
|
||||||
|
if plan != "pro" && plan != "business" {
|
||||||
|
return domain.SMSSettings{}, ErrSMSPlanNotAllowed
|
||||||
|
}
|
||||||
|
|
||||||
|
if tenant.SubscriptionStatus != "active" && tenant.SubscriptionStatus != "trialing" {
|
||||||
|
return domain.SMSSettings{}, ErrNoActiveSubscription
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.UpsertTenantSMSSettings(ctx, db.TenantSMSSettingsRecord{
|
||||||
|
TenantID: tenantID,
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
SenderName: strings.TrimSpace(req.SenderName),
|
||||||
|
MonthlyLimit: req.MonthlyLimit,
|
||||||
|
}); err != nil {
|
||||||
|
return domain.SMSSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetSettings(ctx, tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SendMessage(ctx context.Context, tenantID string, req domain.SendSMSRequest) (domain.SendSMSResponse, error) {
|
||||||
|
if err := s.canUseSMS(ctx, tenantID); err != nil {
|
||||||
|
return domain.SendSMSResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
phone := normalizePhone(req.To)
|
||||||
|
if phone == "" {
|
||||||
|
return domain.SendSMSResponse{}, ErrSMSInvalidPhone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check monthly limit
|
||||||
|
settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.SendSMSResponse{}, err
|
||||||
|
}
|
||||||
|
if settings.MonthlyLimit > 0 {
|
||||||
|
usage, err := s.repo.GetSMSUsageThisMonth(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.SendSMSResponse{}, err
|
||||||
|
}
|
||||||
|
if usage.MessageCount >= settings.MonthlyLimit {
|
||||||
|
return domain.SendSMSResponse{}, ErrSMSLimitReached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send via SMS Manager
|
||||||
|
resp, err := s.sendToSMSManager(ctx, phone, req.Body, settings.SenderName)
|
||||||
|
if err != nil {
|
||||||
|
return domain.SendSMSResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract message ID from accepted recipients
|
||||||
|
messageID := ""
|
||||||
|
if len(resp.Accepted) > 0 {
|
||||||
|
messageID = resp.Accepted[0].MessageID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log usage locally (Stripe billing happens once at month-end)
|
||||||
|
logID, err := s.repo.CreateSMSUsageLog(ctx, db.SMSUsageLogRecord{
|
||||||
|
TenantID: tenantID,
|
||||||
|
RecipientPhone: phone,
|
||||||
|
MessageBody: req.Body,
|
||||||
|
ExternalMessageID: messageID,
|
||||||
|
ExternalRequestID: resp.RequestID,
|
||||||
|
Status: "sent",
|
||||||
|
CostCents: smsCostCents,
|
||||||
|
SentAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domain.SendSMSResponse{}, fmt.Errorf("failed to log sms usage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.SendSMSResponse{
|
||||||
|
LogID: logID,
|
||||||
|
MessageID: messageID,
|
||||||
|
RequestID: resp.RequestID,
|
||||||
|
Status: "sent",
|
||||||
|
CostCents: smsCostCents,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetUsage(ctx context.Context, tenantID string, yearMonth string) (domain.SMSUsageReport, error) {
|
||||||
|
report, err := s.repo.GetSMSUsageForMonth(ctx, tenantID, yearMonth)
|
||||||
|
if err != nil {
|
||||||
|
return domain.SMSUsageReport{}, err
|
||||||
|
}
|
||||||
|
return domain.SMSUsageReport{
|
||||||
|
YearMonth: report.YearMonth,
|
||||||
|
MessageCount: report.MessageCount,
|
||||||
|
TotalCostCents: report.TotalCostCents,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetUsageHistory(ctx context.Context, tenantID string, limit int) ([]domain.SMSUsageLog, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
records, err := s.repo.ListSMSUsageLogs(ctx, tenantID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
logs := make([]domain.SMSUsageLog, len(records))
|
||||||
|
for i, r := range records {
|
||||||
|
logs[i] = domain.SMSUsageLog{
|
||||||
|
ID: r.ID,
|
||||||
|
RecipientPhone: r.RecipientPhone,
|
||||||
|
MessageBody: r.MessageBody,
|
||||||
|
Status: r.Status,
|
||||||
|
CostCents: r.CostCents,
|
||||||
|
CreatedAt: r.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return logs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetMonthlyReports(ctx context.Context, tenantID string, limit int) ([]domain.SMSUsageReport, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 12
|
||||||
|
}
|
||||||
|
records, err := s.repo.ListSMSMonthlyReports(ctx, tenantID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
reports := make([]domain.SMSUsageReport, len(records))
|
||||||
|
for i, r := range records {
|
||||||
|
reports[i] = domain.SMSUsageReport{
|
||||||
|
YearMonth: r.YearMonth,
|
||||||
|
MessageCount: r.MessageCount,
|
||||||
|
TotalCostCents: r.TotalCostCents,
|
||||||
|
StripeInvoiceID: r.StripeInvoiceID,
|
||||||
|
InvoiceSentAt: r.InvoiceSentAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reports, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateMonthlyInvoices creates/finalizes monthly invoices for SMS usage
|
||||||
|
func (s *Service) GenerateMonthlyInvoices(ctx context.Context, yearMonth string, notificationSvc *notifications.Service) (domain.SMSInvoiceBatchResponse, error) {
|
||||||
|
if yearMonth == "" {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
yearMonth = fmt.Sprintf("%04d-%02d", now.Year(), now.Month())
|
||||||
|
}
|
||||||
|
|
||||||
|
response := domain.SMSInvoiceBatchResponse{YearMonth: yearMonth}
|
||||||
|
|
||||||
|
// Get all tenants with SMS enabled that have usage this month
|
||||||
|
tenants, err := s.repo.ListTenantsWithSMSUsage(ctx, yearMonth)
|
||||||
|
if err != nil {
|
||||||
|
return response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range tenants {
|
||||||
|
report, err := s.repo.GetSMSUsageForMonth(ctx, t.ID, yearMonth)
|
||||||
|
if err != nil {
|
||||||
|
response.FailedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if report.MessageCount == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Stripe InvoiceItem for total monthly SMS usage
|
||||||
|
// This adds one line to the customer's next invoice: all messages together
|
||||||
|
stripeInvoiceItemID := ""
|
||||||
|
if s.billing != nil {
|
||||||
|
customerID := ""
|
||||||
|
if t.BillingCustomerID != nil {
|
||||||
|
customerID = *t.BillingCustomerID
|
||||||
|
}
|
||||||
|
if customerID != "" {
|
||||||
|
currency := "czk"
|
||||||
|
if t.BillingProvider != "" {
|
||||||
|
// Try to infer currency from billing snapshot if available
|
||||||
|
snap, _ := s.repo.GetSubscriptionSnapshot(ctx, t.ID)
|
||||||
|
if snap.Currency != "" {
|
||||||
|
currency = snap.Currency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
itemID, err := s.billing.CreateMonthlyInvoiceItem(ctx, customerID, currency, yearMonth, report.MessageCount, report.TotalCostCents)
|
||||||
|
if err == nil {
|
||||||
|
stripeInvoiceItemID = itemID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.UpsertSMSMonthlyReport(ctx, db.SMSMonthlyReportRecord{
|
||||||
|
TenantID: t.ID,
|
||||||
|
YearMonth: yearMonth,
|
||||||
|
MessageCount: report.MessageCount,
|
||||||
|
TotalCostCents: report.TotalCostCents,
|
||||||
|
StripeInvoiceID: stripeInvoiceItemID,
|
||||||
|
}); err != nil {
|
||||||
|
response.FailedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send usage summary email
|
||||||
|
if notificationSvc != nil {
|
||||||
|
_ = s.sendUsageSummaryEmail(ctx, t, report, notificationSvc)
|
||||||
|
}
|
||||||
|
|
||||||
|
response.ProcessedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) sendUsageSummaryEmail(ctx context.Context, tenant db.TenantRecord, report db.SMSMonthlyReportRecord, svc *notifications.Service) error {
|
||||||
|
// Get brand profile for email styling
|
||||||
|
brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID)
|
||||||
|
|
||||||
|
data := notifications.SMSUsageEmailData{
|
||||||
|
TenantName: brand.Name,
|
||||||
|
TenantSlug: tenant.Slug,
|
||||||
|
BusinessEmail: "",
|
||||||
|
YearMonth: report.YearMonth,
|
||||||
|
MessageCount: report.MessageCount,
|
||||||
|
TotalCostCents: report.TotalCostCents,
|
||||||
|
Locale: tenant.Locale,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find owner email
|
||||||
|
membership, err := s.repo.GetTenantMembershipByUserID(ctx, tenant.ID)
|
||||||
|
if err == nil {
|
||||||
|
user, err := s.repo.GetUserByID(ctx, membership.UserID)
|
||||||
|
if err == nil {
|
||||||
|
data.BusinessEmail = user.Email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.BusinessEmail == "" {
|
||||||
|
return errors.New("no business email found")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := notifications.RenderSMSUsageEmail(data)
|
||||||
|
_, err = svc.SendRawEmail(ctx, msg)
|
||||||
|
if err == nil {
|
||||||
|
_ = s.repo.MarkSMSReportInvoiceSent(ctx, report.TenantID, report.YearMonth)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SMS Manager API client ---
|
||||||
|
|
||||||
|
type smsManagerMessage struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
To []struct {
|
||||||
|
PhoneNumber string `json:"phone_number"`
|
||||||
|
} `json:"to"`
|
||||||
|
Tag string `json:"tag,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type smsManagerResponse struct {
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Accepted []struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
} `json:"accepted"`
|
||||||
|
Rejected []struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
} `json:"rejected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) sendToSMSManager(ctx context.Context, phone, body, senderName string) (smsManagerResponse, error) {
|
||||||
|
if s.apiKey == "" {
|
||||||
|
return smsManagerResponse{}, ErrSMSMissingAPIKey
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := smsManagerMessage{
|
||||||
|
Body: body,
|
||||||
|
To: []struct {
|
||||||
|
PhoneNumber string `json:"phone_number"`
|
||||||
|
}{{PhoneNumber: phone}},
|
||||||
|
Tag: "transactional",
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return smsManagerResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", s.baseURL+"/message", bytes.NewReader(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return smsManagerResponse{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("x-api-key", s.apiKey)
|
||||||
|
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return smsManagerResponse{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return smsManagerResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return smsManagerResponse{}, fmt.Errorf("%w: status=%d body=%s", ErrSMSSendFailed, resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result smsManagerResponse
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return smsManagerResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Rejected) > 0 && len(result.Accepted) == 0 {
|
||||||
|
return result, fmt.Errorf("%w: %v", ErrSMSSendFailed, result.Rejected)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePhone(phone string) string {
|
||||||
|
p := strings.TrimSpace(phone)
|
||||||
|
p = strings.ReplaceAll(p, " ", "")
|
||||||
|
p = strings.ReplaceAll(p, "-", "")
|
||||||
|
p = strings.TrimPrefix(p, "+")
|
||||||
|
p = strings.TrimPrefix(p, "00")
|
||||||
|
// Czech default if no country code and 9 digits
|
||||||
|
if len(p) == 9 {
|
||||||
|
p = "420" + p
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package sms
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"bookra/apps/backend/internal/config"
|
||||||
|
"bookra/apps/backend/internal/db"
|
||||||
|
"bookra/apps/backend/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSMSServiceEnabled(t *testing.T) {
|
||||||
|
cfg := config.Config{SMSManagerAPIKey: "test-key"}
|
||||||
|
svc := NewService(cfg, db.NewMemoryRepository())
|
||||||
|
if !svc.Enabled() {
|
||||||
|
t.Fatal("expected SMS service to be enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSMSServiceDisabledWithoutKey(t *testing.T) {
|
||||||
|
cfg := config.Config{}
|
||||||
|
svc := NewService(cfg, db.NewMemoryRepository())
|
||||||
|
if svc.Enabled() {
|
||||||
|
t.Fatal("expected SMS service to be disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSettingsForNewTenant(t *testing.T) {
|
||||||
|
cfg := config.Config{SMSManagerAPIKey: "test-key"}
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
svc := NewService(cfg, repo)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
// Use the default tenant ID from memory repository
|
||||||
|
settings, err := svc.GetSettings(ctx, "5d6b3551-0a3e-4b86-bdf0-e9df20a47148")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if settings.Enabled {
|
||||||
|
t.Fatal("expected SMS to be disabled by default")
|
||||||
|
}
|
||||||
|
if !settings.Available {
|
||||||
|
t.Fatal("expected SMS to be available when configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSettingsRequiresProOrBusiness(t *testing.T) {
|
||||||
|
cfg := config.Config{SMSManagerAPIKey: "test-key"}
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
svc := NewService(cfg, repo)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
// Memory repo tenant is "pro" by default. Change to starter by modifying tenant.
|
||||||
|
_, err := svc.UpdateSettings(ctx, "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", domain.UpdateSMSSettingsRequest{Enabled: true})
|
||||||
|
// The memory repo tenant is "pro", so this should succeed
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for pro tenant: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendMessageRequiresEnabledSMS(t *testing.T) {
|
||||||
|
cfg := config.Config{SMSManagerAPIKey: "test-key"}
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
svc := NewService(cfg, repo)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := svc.SendMessage(ctx, "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", domain.SendSMSRequest{To: "+420777123456", Body: "Hello"})
|
||||||
|
if err != ErrSMSNotEnabled {
|
||||||
|
t.Fatalf("expected ErrSMSNotEnabled, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePhone(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"+420 777 123 456", "420777123456"},
|
||||||
|
{"420777123456", "420777123456"},
|
||||||
|
{"777123456", "420777123456"},
|
||||||
|
{" 777-123-456 ", "420777123456"},
|
||||||
|
{"+49 151 12345678", "4915112345678"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
result := normalizePhone(tc.input)
|
||||||
|
if result != tc.expected {
|
||||||
|
t.Errorf("normalizePhone(%q) = %q, want %q", tc.input, result, tc.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ func NewService(repo db.Repository) *Service {
|
|||||||
return &Service{repo: repo}
|
return &Service{repo: repo}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetTenantMembership(ctx context.Context, principal domain.Principal) (db.TenantMembershipRecord, error) {
|
||||||
|
return s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (domain.TenantBootstrap, error) {
|
func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (domain.TenantBootstrap, error) {
|
||||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
@@ -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;
|
||||||
@@ -639,9 +639,16 @@ components:
|
|||||||
planCode:
|
planCode:
|
||||||
type: string
|
type: string
|
||||||
enum: [starter, pro, business]
|
enum: [starter, pro, business]
|
||||||
|
description: The plan to subscribe to
|
||||||
currency:
|
currency:
|
||||||
type: string
|
type: string
|
||||||
enum: [czk, usd]
|
enum: [czk, usd]
|
||||||
|
description: Currency for the subscription
|
||||||
|
billingInterval:
|
||||||
|
type: string
|
||||||
|
enum: [monthly, yearly]
|
||||||
|
default: monthly
|
||||||
|
description: Billing interval. Yearly gets 17% discount.
|
||||||
PlanDisplayPrice:
|
PlanDisplayPrice:
|
||||||
type: object
|
type: object
|
||||||
required: [currency, amountCents, formatted]
|
required: [currency, amountCents, formatted]
|
||||||
@@ -651,29 +658,56 @@ components:
|
|||||||
enum: [czk, usd]
|
enum: [czk, usd]
|
||||||
amountCents:
|
amountCents:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: Monthly price in cents
|
||||||
formatted:
|
formatted:
|
||||||
type: string
|
type: string
|
||||||
|
description: Formatted monthly price string
|
||||||
|
yearlyAmountCents:
|
||||||
|
type: integer
|
||||||
|
description: Yearly price in cents (17% discount)
|
||||||
|
yearlyFormatted:
|
||||||
|
type: string
|
||||||
|
description: Formatted yearly price string
|
||||||
|
yearlySavings:
|
||||||
|
type: string
|
||||||
|
description: Description of yearly savings
|
||||||
|
yearlySavingsPercent:
|
||||||
|
type: integer
|
||||||
|
description: Percentage saved with yearly billing
|
||||||
CheckoutLaunchResponse:
|
CheckoutLaunchResponse:
|
||||||
type: object
|
type: object
|
||||||
required: [priceId, successRedirectUrl, cancelRedirectUrl, customData]
|
description: |
|
||||||
|
Checkout launch response supporting both Stripe and Paddle providers.
|
||||||
|
For Stripe: checkoutUrl is returned (redirect-based checkout).
|
||||||
|
For Paddle: priceId, customerId, customerEmail, customData are returned (client-side checkout).
|
||||||
properties:
|
properties:
|
||||||
|
checkoutUrl:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
description: Stripe checkout URL (redirect the user to this URL)
|
||||||
priceId:
|
priceId:
|
||||||
type: string
|
type: string
|
||||||
|
description: Paddle price ID for client-side checkout
|
||||||
customerId:
|
customerId:
|
||||||
type: string
|
type: string
|
||||||
|
description: Paddle customer ID
|
||||||
customerEmail:
|
customerEmail:
|
||||||
type: string
|
type: string
|
||||||
format: email
|
format: email
|
||||||
|
description: Customer email for Paddle checkout
|
||||||
successRedirectUrl:
|
successRedirectUrl:
|
||||||
type: string
|
type: string
|
||||||
format: uri
|
format: uri
|
||||||
|
description: URL to redirect after successful checkout
|
||||||
cancelRedirectUrl:
|
cancelRedirectUrl:
|
||||||
type: string
|
type: string
|
||||||
format: uri
|
format: uri
|
||||||
|
description: URL to redirect after cancelled checkout
|
||||||
customData:
|
customData:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
type: string
|
type: string
|
||||||
|
description: Custom metadata for Paddle checkout
|
||||||
PortalSessionResponse:
|
PortalSessionResponse:
|
||||||
type: object
|
type: object
|
||||||
required: [url]
|
required: [url]
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="cs">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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="#f6f4ee" media="(prefers-color-scheme: light)" />
|
||||||
<meta name="theme-color" content="#1a1816" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#1a1816" media="(prefers-color-scheme: dark)" />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<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 -->
|
<!-- Preconnect to Google Fonts -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
"@bookra/shared-types": "0.1.0",
|
"@bookra/shared-types": "0.1.0",
|
||||||
"@neondatabase/neon-js": "^0.2.0-beta.1",
|
"@neondatabase/neon-js": "^0.2.0-beta.1",
|
||||||
"@paddle/paddle-js": "^1.3.2",
|
"@paddle/paddle-js": "^1.3.2",
|
||||||
|
"@sentry/react": "^10.52.0",
|
||||||
"@solidjs/router": "^0.15.3",
|
"@solidjs/router": "^0.15.3",
|
||||||
|
"@stripe/stripe-js": "^4.0.0",
|
||||||
"solid-js": "^1.9.5"
|
"solid-js": "^1.9.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
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"/>
|
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const EyeIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EditIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Trash2Icon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const XCircleIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MoreHorizontalIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const BarChart3Icon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CheckIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const GlobeIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,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";
|
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 type BookingStatus = "confirmed" | "pending" | "cancelled";
|
||||||
|
|
||||||
export interface KpiData {
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,3 +2,4 @@ export { BookraCharacter } from "./bookra-character";
|
|||||||
export { LocationMap } from "./location-map";
|
export { LocationMap } from "./location-map";
|
||||||
export { WidgetBuilder } from "./widget-builder";
|
export { WidgetBuilder } from "./widget-builder";
|
||||||
export { IntegrationModal } from "./integration-modal";
|
export { IntegrationModal } from "./integration-modal";
|
||||||
|
export { FloatingDock } from "./floating-dock";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createSignal, For } from "solid-js";
|
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";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Button, Tabs, TabsList, TabsTrigger, TabsContent, DialogCloseButton } from "./ui";
|
||||||
|
|
||||||
interface IntegrationModalProps {
|
interface IntegrationModalProps {
|
||||||
@@ -11,6 +12,10 @@ interface IntegrationModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function IntegrationModal(props: 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 [copiedSnippet, setCopiedSnippet] = createSignal<string | null>(null);
|
||||||
|
|
||||||
const copyToClipboard = async (text: string, type: string) => {
|
const copyToClipboard = async (text: string, type: string) => {
|
||||||
@@ -19,13 +24,20 @@ export function IntegrationModal(props: IntegrationModalProps) {
|
|||||||
setTimeout(() => setCopiedSnippet(null), 2000);
|
setTimeout(() => setCopiedSnippet(null), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hostedPageUrl = `https://bookra.eu/book/${props.tenantSlug}`;
|
const hostedPageUrl = props.publicBookingUrl;
|
||||||
|
const baseUrl = (() => {
|
||||||
|
try {
|
||||||
|
return new URL(props.publicBookingUrl).origin;
|
||||||
|
} catch {
|
||||||
|
return "https://bookra.eu";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const htmlWidgetCode = `<div id="bookra-widget"></div>
|
const htmlWidgetCode = `<div id="bookra-widget"></div>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var script = document.createElement('script');
|
var script = document.createElement('script');
|
||||||
script.src = "https://bookra.eu/widget.js";
|
script.src = "${baseUrl}/widget.js";
|
||||||
script.async = true;
|
script.async = true;
|
||||||
script.onload = function() {
|
script.onload = function() {
|
||||||
BookraWidget.init({
|
BookraWidget.init({
|
||||||
@@ -65,7 +77,7 @@ function App() {
|
|||||||
add_action('wp_footer', function() {
|
add_action('wp_footer', function() {
|
||||||
?>
|
?>
|
||||||
<div id="bookra-widget"></div>
|
<div id="bookra-widget"></div>
|
||||||
<script src="https://bookra.eu/widget.js" async></script>
|
<script src="${baseUrl}/widget.js" async></script>
|
||||||
<script>
|
<script>
|
||||||
window.addEventListener('load', function() {
|
window.addEventListener('load', function() {
|
||||||
BookraWidget.init({
|
BookraWidget.init({
|
||||||
@@ -78,32 +90,34 @@ add_action('wp_footer', function() {
|
|||||||
<?php
|
<?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 (
|
return (
|
||||||
<Dialog open={props.isOpen} onClose={props.onClose}>
|
<Dialog open={props.isOpen} onClose={props.onClose}>
|
||||||
<DialogContent class="max-w-3xl max-h-[90vh] overflow-y-auto">
|
<DialogContent class="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle class="text-2xl font-display">
|
<DialogTitle class="text-2xl font-display">{t("integration.title")}</DialogTitle>
|
||||||
Add Bookra to Your Website
|
<DialogDescription>{t("integration.subtitle")}</DialogDescription>
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Choose how you want to integrate Bookra with your business. Share a link or embed directly on your website.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogCloseButton onClose={props.onClose} />
|
<DialogCloseButton onClose={props.onClose} />
|
||||||
<Tabs defaultValue="hosted" class="mt-6">
|
<Tabs defaultValue="hosted" class="mt-6">
|
||||||
<TabsList class="grid w-full grid-cols-2">
|
<TabsList class="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="hosted">Hosted Page</TabsTrigger>
|
<TabsTrigger value="hosted">{t("integration.tab.hosted")}</TabsTrigger>
|
||||||
<TabsTrigger value="embed">Embed Widget</TabsTrigger>
|
<TabsTrigger value="embed">{t("integration.tab.embed")}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Hosted Page Tab */}
|
||||||
<TabsContent value="hosted" class="space-y-6 mt-6">
|
<TabsContent value="hosted" class="space-y-6 mt-6">
|
||||||
<div class="p-6 bg-canvas-subtle rounded-xl border border-border">
|
<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>
|
<h4 class="font-display font-semibold text-ink mb-3">{t("integration.hosted.title")}</h4>
|
||||||
<p class="text-sm text-ink-muted mb-4">
|
<p class="text-sm text-ink-muted mb-4">{t("integration.hosted.desc")}</p>
|
||||||
Share this link with your customers. They can book directly without any setup on your website.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3 p-4 bg-canvas rounded-xl border border-border">
|
<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>
|
<code class="flex-1 text-sm text-ink truncate font-mono">{hostedPageUrl}</code>
|
||||||
<Button
|
<Button
|
||||||
@@ -111,7 +125,7 @@ add_action('wp_footer', function() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => copyToClipboard(hostedPageUrl, "url")}
|
onClick={() => copyToClipboard(hostedPageUrl, "url")}
|
||||||
>
|
>
|
||||||
{copiedSnippet() === "url" ? "Copied!" : "Copy"}
|
{copiedSnippet() === "url" ? t("common.copied") : t("common.copy")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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="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"/>
|
<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>
|
</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>
|
||||||
</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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
Share on Facebook
|
{t("integration.share.facebook")}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(`Book your appointment with ${props.tenantName}!`)}&url=${encodeURIComponent(hostedPageUrl)}`}
|
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(`Book your appointment with ${props.tenantName}!`)}&url=${encodeURIComponent(hostedPageUrl)}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
Share on X
|
{t("integration.share.x")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Embed Widget Tab */}
|
||||||
<TabsContent value="embed" class="space-y-6 mt-6">
|
<TabsContent value="embed" class="space-y-6 mt-6">
|
||||||
<div class="p-6 bg-canvas-subtle rounded-xl border border-border">
|
<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>
|
<h4 class="font-display font-semibold text-ink mb-3">{t("integration.embed.title")}</h4>
|
||||||
<p class="text-sm text-ink-muted mb-4">
|
<p class="text-sm text-ink-muted mb-4">{t("integration.embed.desc")}</p>
|
||||||
Add the booking widget directly to your website. Choose your platform:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Tabs defaultValue="html" class="w-full">
|
<Tabs defaultValue="html" class="w-full">
|
||||||
<TabsList class="grid w-full grid-cols-4 mb-4">
|
<TabsList class="grid w-full grid-cols-4 mb-4">
|
||||||
<TabsTrigger value="html">HTML/JS</TabsTrigger>
|
<For each={embedTabs}>
|
||||||
<TabsTrigger value="react">React</TabsTrigger>
|
{(tab) => <TabsTrigger value={tab.id}>{tab.label}</TabsTrigger>}
|
||||||
<TabsTrigger value="solid">SolidJS</TabsTrigger>
|
</For>
|
||||||
<TabsTrigger value="php">PHP/WordPress</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<For each={[
|
<For each={embedTabs}>
|
||||||
{ id: "html", label: "HTML/JavaScript", code: htmlWidgetCode },
|
{(tab) => (
|
||||||
{ id: "react", label: "React", code: reactWidgetCode },
|
<TabsContent value={tab.id} class="mt-0">
|
||||||
{ id: "solid", label: "SolidJS", code: solidWidgetCode },
|
|
||||||
{ id: "php", label: "PHP/WordPress", code: phpWidgetCode },
|
|
||||||
]}>
|
|
||||||
{(item) => (
|
|
||||||
<TabsContent value={item.id} class="mt-0">
|
|
||||||
<div class="relative">
|
<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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="absolute top-3 right-3"
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -189,17 +218,18 @@ add_action('wp_footer', function() {
|
|||||||
</For>
|
</For>
|
||||||
</Tabs>
|
</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">
|
<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"/>
|
<circle cx="12" cy="12" r="10"/>
|
||||||
<line x1="12" x2="12" y1="16" y2="12"/>
|
<line x1="12" x2="12" y1="16" y2="12"/>
|
||||||
<line x1="12" x2="12.01" y1="8" y2="8"/>
|
<line x1="12" x2="12.01" y1="8" y2="8"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Show, createEffect, createSignal, onCleanup, onMount } from "solid-js";
|
import { Show, createEffect, createSignal, onCleanup, onMount } from "solid-js";
|
||||||
|
import { useTheme } from "../providers/theme-provider";
|
||||||
import {
|
import {
|
||||||
DEFAULT_MAP_STYLE_ID,
|
DEFAULT_MAP_STYLE_ID,
|
||||||
resolveMapTileStyle,
|
resolveMapTileStyle,
|
||||||
@@ -95,8 +96,16 @@ export function LocationMap(props: LocationMapProps) {
|
|||||||
const [isReady, setIsReady] = createSignal(false);
|
const [isReady, setIsReady] = createSignal(false);
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
const isDark = () => theme.resolvedTheme() === "dark";
|
||||||
|
|
||||||
const zoom = () => props.zoom ?? props.coordinates.zoom ?? 15;
|
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 popupContent = () => {
|
||||||
const label = props.markerLabel ?? props.coordinates.address;
|
const label = props.markerLabel ?? props.coordinates.address;
|
||||||
const address = props.coordinates.address && props.coordinates.address !== label ? 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 (
|
return (
|
||||||
<div
|
<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` }}
|
style={{ height: `${props.height ?? 360}px` }}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={props.coordinates.address ? `Map for ${props.coordinates.address}` : "Location map"}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -76,6 +76,7 @@ export const Shell: ParentComponent = (props) => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = createSignal(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = createSignal(false);
|
||||||
const hideHeader = () => location.pathname.startsWith("/dashboard");
|
const hideHeader = () => location.pathname.startsWith("/dashboard");
|
||||||
|
const hideFooter = () => location.pathname.startsWith("/dashboard");
|
||||||
const [signInOpen, setSignInOpen] = createSignal(false);
|
const [signInOpen, setSignInOpen] = createSignal(false);
|
||||||
const [authMode, setAuthMode] = createSignal<"sign-in" | "register">("sign-in");
|
const [authMode, setAuthMode] = createSignal<"sign-in" | "register">("sign-in");
|
||||||
const [name, setName] = createSignal("");
|
const [name, setName] = createSignal("");
|
||||||
@@ -86,8 +87,10 @@ export const Shell: ParentComponent = (props) => {
|
|||||||
const [authNotice, setAuthNotice] = createSignal<string | null>(null);
|
const [authNotice, setAuthNotice] = createSignal<string | null>(null);
|
||||||
const [authSubmitting, setAuthSubmitting] = createSignal(false);
|
const [authSubmitting, setAuthSubmitting] = createSignal(false);
|
||||||
const showGoogleSignIn = () => auth.supportsGoogleSignIn() && authMode() === "sign-in";
|
const showGoogleSignIn = () => auth.supportsGoogleSignIn() && authMode() === "sign-in";
|
||||||
|
const isDark = () => theme.resolvedTheme() === "dark";
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
|
{ href: "/pricing", label: i18n.t("nav.pricing") },
|
||||||
{ href: "/about", label: i18n.t("nav.about") },
|
{ href: "/about", label: i18n.t("nav.about") },
|
||||||
{ href: "/contact", label: i18n.t("nav.contact") },
|
{ href: "/contact", label: i18n.t("nav.contact") },
|
||||||
];
|
];
|
||||||
@@ -215,11 +218,20 @@ export const Shell: ParentComponent = (props) => {
|
|||||||
href="/"
|
href="/"
|
||||||
onClick={() => window.scrollTo(0, 0)}
|
onClick={() => window.scrollTo(0, 0)}
|
||||||
>
|
>
|
||||||
<img
|
<div class="relative h-8">
|
||||||
src="/bookra-logo.svg"
|
<img
|
||||||
alt="Bookra"
|
src="/bookra-illustrations/logo_text_horizontal.svg"
|
||||||
class="h-8 w-auto transition-transform duration-300 group-hover:scale-105 dark:invert"
|
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>
|
</A>
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
@@ -409,6 +421,7 @@ export const Shell: ParentComponent = (props) => {
|
|||||||
|
|
||||||
<main class="flex-1">{props.children}</main>
|
<main class="flex-1">{props.children}</main>
|
||||||
|
|
||||||
|
<Show when={!hideFooter()}>
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer class="border-t border-border/60 py-16 bg-canvas-subtle/40">
|
<footer class="border-t border-border/60 py-16 bg-canvas-subtle/40">
|
||||||
<div class="section-container">
|
<div class="section-container">
|
||||||
@@ -416,11 +429,20 @@ export const Shell: ParentComponent = (props) => {
|
|||||||
{/* Logo & Description */}
|
{/* Logo & Description */}
|
||||||
<div class="md:col-span-2 space-y-4">
|
<div class="md:col-span-2 space-y-4">
|
||||||
<A href="/" class="inline-flex items-center gap-4 group" onClick={() => window.scrollTo(0, 0)}>
|
<A href="/" class="inline-flex items-center gap-4 group" onClick={() => window.scrollTo(0, 0)}>
|
||||||
<img
|
<div class="relative h-10">
|
||||||
src="/bookra-logo.svg"
|
<img
|
||||||
alt="Bookra"
|
src="/bookra-illustrations/logo_text_horizontal.svg"
|
||||||
class="h-10 w-auto transition-transform duration-300 group-hover:scale-105 dark:invert"
|
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>
|
</A>
|
||||||
<p class="text-ink-muted max-w-sm leading-relaxed">
|
<p class="text-ink-muted max-w-sm leading-relaxed">
|
||||||
{i18n.t("footer.description")}
|
{i18n.t("footer.description")}
|
||||||
@@ -480,6 +502,7 @@ export const Shell: ParentComponent = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Dialog open={signInOpen()} onClose={() => setSignInOpen(false)}>
|
<Dialog open={signInOpen()} onClose={() => setSignInOpen(false)}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import { createResource, createSignal, Show, For, Accessor } from "solid-js";
|
||||||
|
import { apiClient } from "../lib/api-client";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { useI18n } from "../providers/i18n-provider";
|
||||||
|
|
||||||
|
interface SMSSettingsData {
|
||||||
|
enabled: boolean;
|
||||||
|
senderName: string;
|
||||||
|
monthlyLimit: number;
|
||||||
|
messagesSent: number;
|
||||||
|
totalCostCents: number;
|
||||||
|
available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SMSReport {
|
||||||
|
yearMonth: string;
|
||||||
|
messageCount: number;
|
||||||
|
totalCostCents: number;
|
||||||
|
stripeInvoiceId?: string;
|
||||||
|
invoiceSentAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SMSLog {
|
||||||
|
id: string;
|
||||||
|
recipientPhone: string;
|
||||||
|
status: string;
|
||||||
|
costCents: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCents(cents: number) {
|
||||||
|
return `${(cents / 100).toFixed(2)} Kč`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonth(yearMonth: string) {
|
||||||
|
const [y, m] = yearMonth.split("-");
|
||||||
|
return `${m}/${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SMSSettingsProps {
|
||||||
|
token: Accessor<string | null | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SMSSettings(props: SMSSettingsProps) {
|
||||||
|
const i18n = useI18n();
|
||||||
|
const token = props.token;
|
||||||
|
|
||||||
|
const [saving, setSaving] = createSignal(false);
|
||||||
|
const [notice, setNotice] = createSignal<string | null>(null);
|
||||||
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
|
|
||||||
|
const [settings, { refetch: refetchSettings }] = createResource(token, async (bearer) => {
|
||||||
|
if (!bearer || bearer.startsWith("demo.")) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
senderName: "",
|
||||||
|
monthlyLimit: 0,
|
||||||
|
messagesSent: 0,
|
||||||
|
totalCostCents: 0,
|
||||||
|
available: true,
|
||||||
|
} as SMSSettingsData;
|
||||||
|
}
|
||||||
|
const response = await (apiClient as any).GET("/v1/sms/settings", {
|
||||||
|
headers: { Authorization: `Bearer ${bearer}` },
|
||||||
|
});
|
||||||
|
return response.data as SMSSettingsData;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [reports] = createResource(token, async (bearer) => {
|
||||||
|
if (!bearer || bearer.startsWith("demo.")) return [] as SMSReport[];
|
||||||
|
const response = await (apiClient as any).GET("/v1/sms/invoices", {
|
||||||
|
headers: { Authorization: `Bearer ${bearer}` },
|
||||||
|
});
|
||||||
|
return (response.data as any)?.reports ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const [logs] = createResource(token, async (bearer) => {
|
||||||
|
if (!bearer || bearer.startsWith("demo.")) return [] as SMSLog[];
|
||||||
|
const response = await (apiClient as any).GET("/v1/sms/history", {
|
||||||
|
headers: { Authorization: `Bearer ${bearer}` },
|
||||||
|
});
|
||||||
|
return (response.data as any)?.logs ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleToggle = async () => {
|
||||||
|
const current = settings();
|
||||||
|
if (!current) return;
|
||||||
|
const bearer = token();
|
||||||
|
if (!bearer) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await (apiClient as any).POST("/v1/sms/settings", {
|
||||||
|
headers: { Authorization: `Bearer ${bearer}` },
|
||||||
|
body: {
|
||||||
|
enabled: !current.enabled,
|
||||||
|
senderName: current.senderName,
|
||||||
|
monthlyLimit: current.monthlyLimit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await refetchSettings();
|
||||||
|
setNotice(
|
||||||
|
i18n.locale() === "cs"
|
||||||
|
? `SMS ${!current.enabled ? "aktivováno" : "deaktivováno"}`
|
||||||
|
: `SMS ${!current.enabled ? "enabled" : "disabled"}`
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(
|
||||||
|
e?.response?.data?.error ||
|
||||||
|
(i18n.locale() === "cs" ? "Nepodařilo se uložit nastavení." : "Failed to save settings.")
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSettings = async (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const current = settings();
|
||||||
|
if (!current) return;
|
||||||
|
const bearer = token();
|
||||||
|
if (!bearer) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await (apiClient as any).POST("/v1/sms/settings", {
|
||||||
|
headers: { Authorization: `Bearer ${bearer}` },
|
||||||
|
body: {
|
||||||
|
enabled: current.enabled,
|
||||||
|
senderName: current.senderName,
|
||||||
|
monthlyLimit: current.monthlyLimit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await refetchSettings();
|
||||||
|
setNotice(i18n.locale() === "cs" ? "Nastavení uloženo." : "Settings saved.");
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(
|
||||||
|
e?.response?.data?.error ||
|
||||||
|
(i18n.locale() === "cs" ? "Nepodařilo se uložit nastavení." : "Failed to save settings.")
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cs = () => i18n.locale() === "cs";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="surface-card p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-5">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-[hsl(var(--info-subtle))] flex items-center justify-center text-[hsl(var(--info))]">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||||
|
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-ink">{cs() ? "SMS zprávy" : "SMS Messages"}</h3>
|
||||||
|
<p class="text-sm text-ink-muted">
|
||||||
|
{cs()
|
||||||
|
? "1.50 Kč / SMS. Fakturováno měsíčně přes Stripe."
|
||||||
|
: "1.50 CZK / SMS. Billed monthly via Stripe."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={!settings.loading} fallback={<div class="text-ink-muted">{cs() ? "Načítání..." : "Loading..."}</div>}>
|
||||||
|
<Show when={settings()?.available === false}>
|
||||||
|
<div class="p-4 bg-canvas-subtle rounded-xl text-ink-muted text-sm">
|
||||||
|
{cs()
|
||||||
|
? "SMS není v této instanci nakonfigurováno. Kontaktujte administrátora."
|
||||||
|
: "SMS is not configured for this instance. Contact your administrator."}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={settings()?.available === true}>
|
||||||
|
<Show when={notice()}>
|
||||||
|
<div class="mb-4 p-3 bg-emerald-50 text-emerald-700 rounded-lg text-sm">{notice()}</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={error()}>
|
||||||
|
<div class="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Toggle */}
|
||||||
|
<div class="flex items-center justify-between mb-6 p-4 bg-canvas-subtle rounded-xl">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-ink">{cs() ? "SMS odesílání" : "SMS sending"}</p>
|
||||||
|
<p class="text-sm text-ink-muted">
|
||||||
|
{settings()?.enabled
|
||||||
|
? (cs() ? "Aktivní — účtováno za každou zprávu" : "Active — charged per message")
|
||||||
|
: (cs() ? "Neaktivní" : "Inactive")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={saving()}
|
||||||
|
class={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 ${
|
||||||
|
settings()?.enabled ? "bg-accent" : "bg-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${
|
||||||
|
settings()?.enabled ? "translate-x-6" : "translate-x-1"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={settings()?.enabled}>
|
||||||
|
<form onSubmit={handleSaveSettings} class="space-y-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={cs() ? "Jméno odesílatele (max 11 znaků)" : "Sender name (max 11 chars)"}
|
||||||
|
value={settings()?.senderName || ""}
|
||||||
|
onInput={(e) => {
|
||||||
|
const s = settings();
|
||||||
|
if (s) s.senderName = e.currentTarget.value;
|
||||||
|
}}
|
||||||
|
maxLength={11}
|
||||||
|
placeholder="Bookra"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
label={cs() ? "Měsíční limit (0 = bez limitu)" : "Monthly limit (0 = unlimited)"}
|
||||||
|
value={settings()?.monthlyLimit || 0}
|
||||||
|
onInput={(e) => {
|
||||||
|
const s = settings();
|
||||||
|
if (s) s.monthlyLimit = parseInt(e.currentTarget.value) || 0;
|
||||||
|
}}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving()}
|
||||||
|
class="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving() ? (cs() ? "Ukládání..." : "Saving...") : cs() ? "Uložit" : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Current month stats */}
|
||||||
|
<div class="mt-6 p-4 bg-canvas-subtle rounded-xl">
|
||||||
|
<h4 class="text-sm font-medium text-ink mb-3">
|
||||||
|
{cs() ? "Aktuální měsíc" : "Current month"}
|
||||||
|
</h4>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-bold text-ink">{settings()?.messagesSent ?? 0}</p>
|
||||||
|
<p class="text-xs text-ink-muted">{cs() ? "Odeslaných zpráv" : "Messages sent"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-bold text-ink">{formatCents(settings()?.totalCostCents ?? 0)}</p>
|
||||||
|
<p class="text-xs text-ink-muted">{cs() ? "Celková cena" : "Total cost"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent logs */}
|
||||||
|
<Show when={logs() && logs()!.length > 0}>
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="text-sm font-medium text-ink mb-3">
|
||||||
|
{cs() ? "Historie odesílání" : "Send history"}
|
||||||
|
</h4>
|
||||||
|
<div class="space-y-2 max-h-48 overflow-y-auto">
|
||||||
|
<For each={logs()}>
|
||||||
|
{(log) => (
|
||||||
|
<div class="flex items-center justify-between p-3 bg-canvas-subtle rounded-lg text-sm">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-ink-muted">{log.recipientPhone}</span>
|
||||||
|
<span
|
||||||
|
class={`px-2 py-0.5 rounded-full text-xs ${
|
||||||
|
log.status === "sent"
|
||||||
|
? "bg-emerald-100 text-emerald-700"
|
||||||
|
: "bg-red-100 text-red-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{log.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-ink-muted">{formatCents(log.costCents)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Monthly invoice reports */}
|
||||||
|
<Show when={reports() && reports()!.length > 0}>
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="text-sm font-medium text-ink mb-3">
|
||||||
|
{cs() ? "Fakturační přehledy" : "Invoice reports"}
|
||||||
|
</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<For each={reports()}>
|
||||||
|
{(report) => (
|
||||||
|
<div class="flex items-center justify-between p-3 bg-canvas-subtle rounded-lg text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-ink">{formatMonth(report.yearMonth)}</span>
|
||||||
|
<span class="text-ink-muted ml-2">{report.messageCount} SMS</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="font-medium text-ink">{formatCents(report.totalCostCents)}</span>
|
||||||
|
<Show when={report.stripeInvoiceId}>
|
||||||
|
<span class="text-xs text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">
|
||||||
|
Stripe
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 [selectedSize, setSelectedSize] = createSignal<WidgetSize>("default");
|
||||||
const [selectedPosition, setSelectedPosition] = createSignal<WidgetPosition>("bottom-right");
|
const [selectedPosition, setSelectedPosition] = createSignal<WidgetPosition>("bottom-right");
|
||||||
const [copiedSnippet, setCopiedSnippet] = createSignal<string | null>(null);
|
const [copiedSnippet, setCopiedSnippet] = createSignal<string | null>(null);
|
||||||
|
const [copyError, setCopyError] = createSignal<string | null>(null);
|
||||||
const [showPreview, setShowPreview] = createSignal(true);
|
const [showPreview, setShowPreview] = createSignal(true);
|
||||||
const [draggedIndex, setDraggedIndex] = createSignal<number | null>(null);
|
const [draggedIndex, setDraggedIndex] = createSignal<number | null>(null);
|
||||||
const [customColor, setCustomColor] = createSignal(props.config.primaryColor || "#a65c3e");
|
const [customColor, setCustomColor] = createSignal(props.config.primaryColor || "#a65c3e");
|
||||||
@@ -273,8 +274,8 @@ export function WidgetBuilder(props: WidgetBuilderProps) {
|
|||||||
map: {
|
map: {
|
||||||
type: "map",
|
type: "map",
|
||||||
icon: MapIcon,
|
icon: MapIcon,
|
||||||
title: "Location map",
|
title: i18n.t("widget.type.map.title"),
|
||||||
description: "Embed a styled map for your real address.",
|
description: i18n.t("widget.type.map.desc"),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -284,7 +285,7 @@ export function WidgetBuilder(props: WidgetBuilderProps) {
|
|||||||
try {
|
try {
|
||||||
const result = await resolveLocationInput(mapLocationInput());
|
const result = await resolveLocationInput(mapLocationInput());
|
||||||
if (!result) {
|
if (!result) {
|
||||||
setMapMessage("No location found. Paste a Google Maps/Mapy.cz link, coordinates, or full address.");
|
setMapMessage(i18n.t("widget.map.noLocation"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMapCoordinates({
|
setMapCoordinates({
|
||||||
@@ -292,7 +293,7 @@ export function WidgetBuilder(props: WidgetBuilderProps) {
|
|||||||
zoom: result.zoom ?? mapZoom(),
|
zoom: result.zoom ?? mapZoom(),
|
||||||
});
|
});
|
||||||
setMapZoom(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) {
|
} catch (error) {
|
||||||
setMapMessage(error instanceof Error ? error.message : "Location search failed.");
|
setMapMessage(error instanceof Error ? error.message : "Location search failed.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -426,15 +427,17 @@ export function BookraLocationMap() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Drag and drop handlers
|
// Drag and drop handlers
|
||||||
|
const [dropTargetIndex, setDropTargetIndex] = createSignal<number | null>(null);
|
||||||
|
|
||||||
const handleDragStart = (index: number) => {
|
const handleDragStart = (index: number) => {
|
||||||
setDraggedIndex(index);
|
setDraggedIndex(index);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragOver = (e: DragEvent, index: number) => {
|
const handleDragOver = (e: DragEvent, index: number) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setDropTargetIndex(index);
|
||||||
const draggedIdx = draggedIndex();
|
const draggedIdx = draggedIndex();
|
||||||
if (draggedIdx === null || draggedIdx === index) return;
|
if (draggedIdx === null || draggedIdx === index) return;
|
||||||
|
|
||||||
const newOrder = [...widgetOrder()];
|
const newOrder = [...widgetOrder()];
|
||||||
const [removed] = newOrder.splice(draggedIdx, 1);
|
const [removed] = newOrder.splice(draggedIdx, 1);
|
||||||
newOrder.splice(index, 0, removed);
|
newOrder.splice(index, 0, removed);
|
||||||
@@ -444,6 +447,7 @@ export function BookraLocationMap() {
|
|||||||
|
|
||||||
const handleDragEnd = () => {
|
const handleDragEnd = () => {
|
||||||
setDraggedIndex(null);
|
setDraggedIndex(null);
|
||||||
|
setDropTargetIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateCode = (type: WidgetType, format: CodeFormat) => {
|
const generateCode = (type: WidgetType, format: CodeFormat) => {
|
||||||
@@ -1077,9 +1081,11 @@ export class BookraWidgetComponent implements OnInit {
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
setCopiedSnippet(snippetId);
|
setCopiedSnippet(snippetId);
|
||||||
|
setCopyError(null);
|
||||||
setTimeout(() => setCopiedSnippet(null), 2000);
|
setTimeout(() => setCopiedSnippet(null), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to copy:", err);
|
setCopyError(i18n.locale() === 'cs' ? 'Kopírování se nezdařilo. Zkuste to znovu.' : 'Copy failed. Please try again.');
|
||||||
|
setTimeout(() => setCopyError(null), 3000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1120,7 +1126,7 @@ export class BookraWidgetComponent implements OnInit {
|
|||||||
const Icon = option.icon;
|
const Icon = option.icon;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
draggable
|
draggable={true}
|
||||||
onDragStart={() => handleDragStart(index())}
|
onDragStart={() => handleDragStart(index())}
|
||||||
onDragOver={(e) => handleDragOver(e, index())}
|
onDragOver={(e) => handleDragOver(e, index())}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
@@ -1128,7 +1134,7 @@ export class BookraWidgetComponent implements OnInit {
|
|||||||
selectedType() === type
|
selectedType() === type
|
||||||
? "border-accent bg-accent/5"
|
? "border-accent bg-accent/5"
|
||||||
: "border-border hover:border-accent/50 hover:bg-canvas-subtle"
|
: "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)}
|
onClick={() => setSelectedType(type)}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -1445,6 +1451,11 @@ export class BookraWidgetComponent implements OnInit {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
<Show when={copyError()}>
|
||||||
|
<div class="mb-4 p-3 rounded-xl bg-[hsl(var(--error-soft))] border border-[hsl(var(--error))/20] text-[hsl(var(--error))] text-sm font-medium animate-fade-in">
|
||||||
|
{copyError()}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<Tabs defaultValue="html">
|
<Tabs defaultValue="html">
|
||||||
<TabsList class="mb-4 flex-wrap">
|
<TabsList class="mb-4 flex-wrap">
|
||||||
<TabsTrigger value="html">HTML</TabsTrigger>
|
<TabsTrigger value="html">HTML</TabsTrigger>
|
||||||
|
|||||||
@@ -22,9 +22,16 @@ export interface MapTileStyle {
|
|||||||
tileClassName?: string;
|
tileClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_MAP_STYLE_ID = "bookra-voyager";
|
export const DEFAULT_MAP_STYLE_ID = "positron";
|
||||||
|
|
||||||
export const MAP_TILE_STYLES: readonly MapTileStyle[] = [
|
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: "© OpenStreetMap contributors © CARTO",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "bookra-voyager",
|
id: "bookra-voyager",
|
||||||
name: "Bookra Voyager",
|
name: "Bookra Voyager",
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as Sentry from "@sentry/react";
|
||||||
|
|
||||||
|
export function initSentry() {
|
||||||
|
const dsn = import.meta.env.VITE_SENTRY_DSN;
|
||||||
|
|
||||||
|
if (!dsn) {
|
||||||
|
console.log("Sentry DSN not configured - skipping initialization");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn,
|
||||||
|
integrations: [
|
||||||
|
Sentry.browserTracingIntegration(),
|
||||||
|
Sentry.replayIntegration({
|
||||||
|
maskAllText: false,
|
||||||
|
blockAllMedia: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
replaysSessionSampleRate: 0.1,
|
||||||
|
replaysOnErrorSampleRate: 1.0,
|
||||||
|
enableLogs: true,
|
||||||
|
environment: import.meta.env.MODE,
|
||||||
|
release: `bookra@${import.meta.env.VITE_APP_VERSION || "1.0.0"}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { loadStripe, type Stripe } from "@stripe/stripe-js";
|
||||||
|
|
||||||
|
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
|
||||||
|
|
||||||
|
let stripePromise: Promise<Stripe | null> | null = null;
|
||||||
|
|
||||||
|
export function stripeConfigured() {
|
||||||
|
return stripePublishableKey.trim() !== "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStripe() {
|
||||||
|
if (!stripeConfigured()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!stripePromise) {
|
||||||
|
stripePromise = loadStripe(stripePublishableKey);
|
||||||
|
}
|
||||||
|
return stripePromise;
|
||||||
|
}
|
||||||
@@ -3,12 +3,21 @@ import { lazy } from "solid-js";
|
|||||||
import { Route, Router } from "@solidjs/router";
|
import { Route, Router } from "@solidjs/router";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./styles/index.css";
|
import "./styles/index.css";
|
||||||
|
import { initSentry } from "./lib/sentry";
|
||||||
|
|
||||||
|
initSentry();
|
||||||
|
|
||||||
const HomeRoute = lazy(() => import("./routes/home-route").then((module) => ({ default: module.HomeRoute })));
|
const HomeRoute = lazy(() => import("./routes/home-route").then((module) => ({ default: module.HomeRoute })));
|
||||||
|
const PricingRoute = lazy(() => import("./routes/pricing-route"));
|
||||||
const AboutRoute = lazy(() => import("./routes/about-route").then((module) => ({ default: module.AboutRoute })));
|
const AboutRoute = lazy(() => import("./routes/about-route").then((module) => ({ default: module.AboutRoute })));
|
||||||
const AuthCallbackRoute = lazy(() => import("./routes/auth-callback-route").then((module) => ({ default: module.AuthCallbackRoute })));
|
const AuthCallbackRoute = lazy(() => import("./routes/auth-callback-route").then((module) => ({ default: module.AuthCallbackRoute })));
|
||||||
const ContactRoute = lazy(() => import("./routes/contact-route").then((module) => ({ default: module.ContactRoute })));
|
const ContactRoute = lazy(() => import("./routes/contact-route").then((module) => ({ default: module.ContactRoute })));
|
||||||
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 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 BookingManageRoute = lazy(() => import("./routes/booking-manage-route").then((module) => ({ default: module.BookingManageRoute })));
|
||||||
const LegalRoute = lazy(() => import("./routes/legal-route").then((module) => ({ default: module.LegalRoute })));
|
const LegalRoute = lazy(() => import("./routes/legal-route").then((module) => ({ default: module.LegalRoute })));
|
||||||
@@ -18,10 +27,16 @@ render(
|
|||||||
() => (
|
() => (
|
||||||
<Router root={App}>
|
<Router root={App}>
|
||||||
<Route path="/" component={HomeRoute} />
|
<Route path="/" component={HomeRoute} />
|
||||||
|
<Route path="/pricing" component={PricingRoute} />
|
||||||
<Route path="/about" component={AboutRoute} />
|
<Route path="/about" component={AboutRoute} />
|
||||||
<Route path="/auth/callback" component={AuthCallbackRoute} />
|
<Route path="/auth/callback" component={AuthCallbackRoute} />
|
||||||
<Route path="/contact" component={ContactRoute} />
|
<Route path="/contact" component={ContactRoute} />
|
||||||
<Route path="/dashboard" component={DashboardRoute} />
|
<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="/book/:tenantSlug" component={PublicBookingRoute} />
|
||||||
<Route path="/manage/:reference" component={BookingManageRoute} />
|
<Route path="/manage/:reference" component={BookingManageRoute} />
|
||||||
<Route path="/:kind" component={LegalRoute} matchFilters={{ kind: ["privacy", "terms"] }} />
|
<Route path="/:kind" component={LegalRoute} matchFilters={{ kind: ["privacy", "terms"] }} />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const dictionaries = {
|
|||||||
// Navigation & Auth
|
// Navigation & Auth
|
||||||
"nav.booking": "Veřejná rezervace",
|
"nav.booking": "Veřejná rezervace",
|
||||||
"nav.dashboard": "Aplikace",
|
"nav.dashboard": "Aplikace",
|
||||||
|
"nav.pricing": "Ceník",
|
||||||
"nav.about": "O nás",
|
"nav.about": "O nás",
|
||||||
"nav.contact": "Kontakt",
|
"nav.contact": "Kontakt",
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ const dictionaries = {
|
|||||||
|
|
||||||
// Hero Section
|
// Hero Section
|
||||||
"home.badge": "Nyní zdarma pro začátečníky",
|
"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.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.primary": "Začít zdarma",
|
||||||
"home.hero.cta.secondary": "Otevřít rezervaci",
|
"home.hero.cta.secondary": "Otevřít rezervaci",
|
||||||
@@ -164,6 +165,26 @@ const dictionaries = {
|
|||||||
"home.pricing.biz.cta": "Kontaktovat prodej",
|
"home.pricing.biz.cta": "Kontaktovat prodej",
|
||||||
"home.pricing.biz.trial": "Individuální řešení na míru",
|
"home.pricing.biz.trial": "Individuální řešení na míru",
|
||||||
|
|
||||||
|
// Comparison
|
||||||
|
"pricing.compare.eyebrow": "Detailní srovnání",
|
||||||
|
"pricing.compare.title": "Porovnání plánů",
|
||||||
|
"pricing.compare.feature": "Funkce",
|
||||||
|
"pricing.compare.locations": "Lokace",
|
||||||
|
"pricing.compare.staff": "Zaměstnanci",
|
||||||
|
"pricing.compare.bookings": "Rezervací/měsíc",
|
||||||
|
"pricing.compare.emailSupport": "E-mailová podpora",
|
||||||
|
"pricing.compare.reminders": "E-mailová připomenutí",
|
||||||
|
"pricing.compare.analytics": "Analytika",
|
||||||
|
"pricing.compare.api": "API přístup",
|
||||||
|
"pricing.compare.branding": "Vlastní branding",
|
||||||
|
"pricing.compare.whiteLabel": "Bílý labeling",
|
||||||
|
"pricing.compare.manager": "Dedikovaný manažer",
|
||||||
|
"pricing.compare.priority": "Prioritní",
|
||||||
|
"pricing.compare.dedicated": "Dedikovaný",
|
||||||
|
"pricing.compare.advanced": "Pokročilá",
|
||||||
|
"pricing.compare.yes": "Ano",
|
||||||
|
"pricing.compare.no": "Ne",
|
||||||
|
|
||||||
// CTA
|
// CTA
|
||||||
"home.cta.title": "Připraveni zjednodušit své rezervace?",
|
"home.cta.title": "Připraveni zjednodušit své rezervace?",
|
||||||
"home.cta.subtitle": "Připojte se k tisícům podniků, které šetří čas s Bookra.",
|
"home.cta.subtitle": "Připojte se k tisícům podniků, které šetří čas s Bookra.",
|
||||||
@@ -190,6 +211,10 @@ const dictionaries = {
|
|||||||
"widget.type.floating.title": "Plovoucí bublina",
|
"widget.type.floating.title": "Plovoucí bublina",
|
||||||
"widget.type.floating.desc": "Plovoucí tlačítko v rohu obrazovky",
|
"widget.type.floating.desc": "Plovoucí tlačítko v rohu obrazovky",
|
||||||
"widget.type.floating.preview": "Nejlepší pro: E-shopy, kontinuální dostupnost",
|
"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.button.text": "Rezervovat termín",
|
||||||
"widget.modal.trigger": "Otevřít rezervaci",
|
"widget.modal.trigger": "Otevřít rezervaci",
|
||||||
"widget.styling.title": "Vzhled",
|
"widget.styling.title": "Vzhled",
|
||||||
@@ -227,20 +252,80 @@ const dictionaries = {
|
|||||||
"common.copy": "Kopírovat",
|
"common.copy": "Kopírovat",
|
||||||
"common.copied": "Zkopírováno!",
|
"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
|
||||||
"footer.copyright": "© 2026 Bookra. Všechna práva vyhrazena.",
|
"footer.copyright": "© 2026 Bookra. Všechna práva vyhrazena.",
|
||||||
"footer.privacy": "Ochrana soukromí",
|
"footer.privacy": "Ochrana soukromí",
|
||||||
"footer.terms": "Podmínky použití",
|
"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.links.title": "Navigace",
|
||||||
"footer.legal.title": "Právní informace",
|
"footer.legal.title": "Právní informace",
|
||||||
|
|
||||||
// Dashboard (existing)
|
// Dashboard
|
||||||
"dashboard.title": "Přehled podniku",
|
"dashboard.title": "Přehled podniku",
|
||||||
"dashboard.body": "Sledujte rezervace, nastavení, předplatné a rezervační widget na jednom místě.",
|
"dashboard.body": "Sledujte rezervace, nastavení, předplatné a rezervační widget na jednom místě.",
|
||||||
"dashboard.kpi.bookings": "Rezervace tento týden",
|
"dashboard.overview": "Přehled",
|
||||||
"dashboard.kpi.cancellations": "Zrušení",
|
"dashboard.bookings": "Rezervace",
|
||||||
"dashboard.kpi.utilization": "Vytížení",
|
"dashboard.customers": "Zákazníci",
|
||||||
|
"dashboard.zones": "Zóny a dostupnost",
|
||||||
|
"dashboard.billing": "Platby",
|
||||||
|
"dashboard.settings": "Nastavení",
|
||||||
|
"dashboard.welcome": "Dobrý den,",
|
||||||
|
"dashboard.overviewFor": "Přehled pro",
|
||||||
|
"dashboard.kpi.bookings": "Celkem rezervací",
|
||||||
|
"dashboard.kpi.cancelled": "Zrušených",
|
||||||
|
"dashboard.kpi.completed": "Dokončených",
|
||||||
|
"dashboard.kpi.newClients": "Noví klienti",
|
||||||
|
"dashboard.recentActivity": "Nedávná aktivita",
|
||||||
|
"dashboard.upcomingBookings": "Nadcházející rezervace",
|
||||||
|
"dashboard.viewAll": "Zobrazit vše",
|
||||||
|
"dashboard.locationLimitReached": "Dosáhli jste limitu lokací!",
|
||||||
|
"dashboard.nearingLocationLimit": "Blížíte se limitu lokací",
|
||||||
|
"dashboard.locationsUsed": "lokací použito",
|
||||||
|
"dashboard.upgrade": "Upgrade",
|
||||||
|
"dashboard.shareManage": "Sdílet/Spravit",
|
||||||
|
"dashboard.notifications": "Oznámení",
|
||||||
|
"dashboard.bookingManagement": "Správa rezervací",
|
||||||
|
"dashboard.totalBookings": "celkem rezervací",
|
||||||
|
"dashboard.newBooking": "Nová rezervace",
|
||||||
|
"dashboard.bookingDetails": "Detail rezervace",
|
||||||
|
"dashboard.customerDetails": "Detail zákazníka",
|
||||||
|
"dashboard.close": "Zavřít",
|
||||||
|
"dashboard.edit": "Upravit",
|
||||||
|
"dashboard.cancel": "Zrušit",
|
||||||
|
"dashboard.details": "Detail",
|
||||||
|
"dashboard.saveChanges": "Uložit změny",
|
||||||
|
"dashboard.createBooking": "Vytvořit rezervaci",
|
||||||
|
"dashboard.preview": "Zobrazit náhled",
|
||||||
|
"dashboard.saveEmailSettings": "Uložit nastavení emailů",
|
||||||
|
"dashboard.saving": "Ukládání...",
|
||||||
|
"dashboard.creating": "Vytváření...",
|
||||||
|
"dashboard.prevMonth": "Předchozí měsíc",
|
||||||
|
"dashboard.nextMonth": "Další měsíc",
|
||||||
|
"dashboard.confirmed": "Potvrzeno",
|
||||||
|
"dashboard.pending": "Čeká",
|
||||||
|
"dashboard.cancelled": "Zrušeno",
|
||||||
|
"dashboard.completed": "Dokončeno",
|
||||||
"dashboard.welcome.title": "Vítejte v Bookra",
|
"dashboard.welcome.title": "Vítejte v Bookra",
|
||||||
"dashboard.welcome.body": "Zjednodušte své rezervace a mějte více času na to, co vás baví.",
|
"dashboard.welcome.body": "Zjednodušte své rezervace a mějte více času na to, co vás baví.",
|
||||||
"dashboard.authRequired": "Pro vstup do aplikace se přihlaste nebo si vytvořte účet.",
|
"dashboard.authRequired": "Pro vstup do aplikace se přihlaste nebo si vytvořte účet.",
|
||||||
@@ -248,7 +333,6 @@ const dictionaries = {
|
|||||||
"dashboard.liveData": "Živá data",
|
"dashboard.liveData": "Živá data",
|
||||||
"dashboard.liveDataBody": "Dashboard, nastavení a předplatné se načítají z API pro přihlášený účet.",
|
"dashboard.liveDataBody": "Dashboard, nastavení a předplatné se načítají z API pro přihlášený účet.",
|
||||||
"dashboard.apiReady": "API připojení aktivní",
|
"dashboard.apiReady": "API připojení aktivní",
|
||||||
"dashboard.billing": "Předplatné",
|
|
||||||
"dashboard.checkout": "Otevřít platbu",
|
"dashboard.checkout": "Otevřít platbu",
|
||||||
"dashboard.refreshBilling": "Obnovit předplatné",
|
"dashboard.refreshBilling": "Obnovit předplatné",
|
||||||
"dashboard.plan": "Plán",
|
"dashboard.plan": "Plán",
|
||||||
@@ -263,6 +347,88 @@ const dictionaries = {
|
|||||||
"dashboard.onboarding.timezone": "Časové pásmo",
|
"dashboard.onboarding.timezone": "Časové pásmo",
|
||||||
"dashboard.onboarding.submit": "Vytvořit prostor",
|
"dashboard.onboarding.submit": "Vytvořit prostor",
|
||||||
"dashboard.onboarding.pending": "Vytvářím prostor...",
|
"dashboard.onboarding.pending": "Vytvářím prostor...",
|
||||||
|
"dashboard.revenueTitle": "Trend rezervací",
|
||||||
|
"dashboard.revenueSubtitle": "Počet rezervací za posledních 7 dní",
|
||||||
|
"dashboard.noNotifications": "Žádná nová oznámení",
|
||||||
|
"dashboard.markAllRead": "Označit vše jako přečtené",
|
||||||
|
"dashboard.notifications.title": "Oznámení",
|
||||||
|
"dashboard.demoBanner": "Demo režim",
|
||||||
|
"dashboard.demoBannerDesc": "Prozkoumáte Bookra s ukázkovými daty. Pro plnou funkčnost se zaregistrujte.",
|
||||||
|
"dashboard.demoBannerCTA": "Vytvořit účet",
|
||||||
|
"dashboard.tryDemo": "Vyzkoušet demo",
|
||||||
|
"dashboard.language": "Jazyk",
|
||||||
|
"dashboard.calendar.noBookings": "Tento den nemáte žádné rezervace.",
|
||||||
|
"dashboard.calendar.bookingsCount": "rezervací",
|
||||||
|
"dashboard.chart.bookingsTrend": "Rezervace",
|
||||||
|
"dashboard.chart.revenueTrend": "Obrat",
|
||||||
|
"dashboard.recentActivity.empty": "Zatím žádná aktivita. Rezervace se zobrazí zde.",
|
||||||
|
"dashboard.filter.all": "Vše",
|
||||||
|
"dashboard.filter.today": "Dnes",
|
||||||
|
"dashboard.filter.week": "Týden",
|
||||||
|
"dashboard.filter.month": "Měsíc",
|
||||||
|
"dashboard.search.placeholder": "Hledat...",
|
||||||
|
"dashboard.search.bookings": "Hledat podle jména, služby nebo reference...",
|
||||||
|
"dashboard.search.customers": "Hledat podle jména, emailu nebo telefonu...",
|
||||||
|
"dashboard.actions": "Akce",
|
||||||
|
"dashboard.customer.email": "E-mail",
|
||||||
|
"dashboard.customer.phone": "Telefon",
|
||||||
|
"dashboard.customer.totalBookings": "Celkem rezervací",
|
||||||
|
"dashboard.customer.lastVisit": "Poslední návštěva",
|
||||||
|
"dashboard.customer.status": "Stav",
|
||||||
|
"dashboard.customer.notes": "Poznámky",
|
||||||
|
"dashboard.customer.noNotes": "Žádné poznámky",
|
||||||
|
"dashboard.customer.bookings": "Rezervace zákazníka",
|
||||||
|
"dashboard.customer.noBookings": "Žádné rezervace",
|
||||||
|
"dashboard.zone.add": "Přidat zónu",
|
||||||
|
"dashboard.zone.name": "Název",
|
||||||
|
"dashboard.zone.type": "Typ",
|
||||||
|
"dashboard.zone.capacity": "Kapacita",
|
||||||
|
"dashboard.zone.limitReached": "Dosáhli jste limitu",
|
||||||
|
"dashboard.zone.rooms": "Místnost",
|
||||||
|
"dashboard.zone.private": "Soukromá místnost",
|
||||||
|
"dashboard.zone.hall": "Sál",
|
||||||
|
"dashboard.zone.blockedDays": "Blokované dny",
|
||||||
|
"dashboard.zone.workingHours": "Pracovní doba",
|
||||||
|
"dashboard.zone.open": "Otevřeno",
|
||||||
|
"dashboard.zone.close": "Zavřeno",
|
||||||
|
"dashboard.zone.addBlocked": "Přidat blokovaný den",
|
||||||
|
"dashboard.zone.reason": "Důvod",
|
||||||
|
"dashboard.zone.noZones": "Zatím nemáte žádné zóny. Přidejte první.",
|
||||||
|
"dashboard.billing.planUsage": "Využití plánu",
|
||||||
|
"dashboard.billing.locations": "Lokace",
|
||||||
|
"dashboard.billing.bookings": "Rezervace",
|
||||||
|
"dashboard.billing.staff": "Zaměstnanci",
|
||||||
|
"dashboard.billing.period": "Období",
|
||||||
|
"dashboard.billing.currentPlan": "Aktuální plán",
|
||||||
|
"dashboard.billing.nextBilling": "Další fakturace",
|
||||||
|
"dashboard.settings.businessInfo": "Informace o podniku",
|
||||||
|
"dashboard.settings.branding": "Branding a vzhled",
|
||||||
|
"dashboard.settings.emailNotifications": "E-mailová oznámení",
|
||||||
|
"dashboard.settings.emailSubject": "Předmět",
|
||||||
|
"dashboard.settings.emailBody": "Obsah",
|
||||||
|
"dashboard.settings.save": "Uložit",
|
||||||
|
"dashboard.bookingModal.customer": "Zákazník",
|
||||||
|
"dashboard.bookingModal.service": "Služba",
|
||||||
|
"dashboard.bookingModal.location": "Místo",
|
||||||
|
"dashboard.bookingModal.dateTime": "Datum a čas",
|
||||||
|
"dashboard.bookingModal.duration": "Délka",
|
||||||
|
"dashboard.bookingModal.status": "Stav",
|
||||||
|
"dashboard.bookingModal.reference": "Reference",
|
||||||
|
"dashboard.bookingModal.notes": "Poznámky",
|
||||||
|
"dashboard.bookingModal.reschedule": "Přeplánovat",
|
||||||
|
"dashboard.bookingModal.confirmCancel": "Opravdu chcete zrušit tuto rezervaci?",
|
||||||
|
"dashboard.bookingModal.createdAt": "Vytvořeno",
|
||||||
|
"dashboard.bookingModal.assignedTo": "Přiřazeno",
|
||||||
|
"dashboard.bookingModal.noAssign": "Nepřiřazeno",
|
||||||
|
"dashboard.bookingModal.email": "E-mail",
|
||||||
|
"dashboard.bookingModal.phone": "Telefon",
|
||||||
|
"dashboard.empty.title": "Zatím prázdné",
|
||||||
|
"dashboard.empty.bookingsDesc": "Žádné rezervace neodpovídají vašemu filtru.",
|
||||||
|
"dashboard.empty.customersDesc": "Zatím nemáte žádné zákazníky.",
|
||||||
|
"dashboard.notification.newBooking": "Nová rezervace od",
|
||||||
|
"dashboard.notification.reminder": "Připomínka: rezervace u",
|
||||||
|
"dashboard.notification.upgrade": "Blížíte se limitu plánu",
|
||||||
|
"dashboard.notification.trialEnding": "Vaše zkušební doba končí za 3 dny",
|
||||||
"booking.title": "Rezervace",
|
"booking.title": "Rezervace",
|
||||||
"booking.body": "Vyberte dostupný termín, doplňte kontaktní údaje a potvrzení přijde e-mailem.",
|
"booking.body": "Vyberte dostupný termín, doplňte kontaktní údaje a potvrzení přijde e-mailem.",
|
||||||
"booking.slots": "Dostupné termíny",
|
"booking.slots": "Dostupné termíny",
|
||||||
@@ -281,6 +447,7 @@ const dictionaries = {
|
|||||||
"booking.customer.body": "Tyto údaje použijeme pro potvrzení rezervace a připomenutí.",
|
"booking.customer.body": "Tyto údaje použijeme pro potvrzení rezervace a připomenutí.",
|
||||||
"booking.customer.name": "Jméno",
|
"booking.customer.name": "Jméno",
|
||||||
"booking.customer.email": "E-mail",
|
"booking.customer.email": "E-mail",
|
||||||
|
"booking.customer.phone": "Telefon",
|
||||||
"booking.customer.notes": "Poznámka",
|
"booking.customer.notes": "Poznámka",
|
||||||
"booking.customerRequired": "Před rezervací vyplňte jméno a e-mail.",
|
"booking.customerRequired": "Před rezervací vyplňte jméno a e-mail.",
|
||||||
"booking.failed": "Rezervaci se nepodařilo vytvořit",
|
"booking.failed": "Rezervaci se nepodařilo vytvořit",
|
||||||
@@ -326,6 +493,12 @@ const dictionaries = {
|
|||||||
"contact.info.email.desc": "Preferujete psát? Jsme tu pro vás.",
|
"contact.info.email.desc": "Preferujete psát? Jsme tu pro vás.",
|
||||||
"contact.info.hours.title": "Pracovní doba",
|
"contact.info.hours.title": "Pracovní doba",
|
||||||
"contact.info.hours.desc": "Odpovídáme během pracovních dní 9:00 — 17:00 CET.",
|
"contact.info.hours.desc": "Odpovídáme během pracovních dní 9:00 — 17:00 CET.",
|
||||||
|
"contact.story.heading": "Proč nás kontaktovat?",
|
||||||
|
"contact.story.p1": "Ať už máte dotaz ohledně funkcí, potřebujete pomoci s nastavením, nebo chcete sdílet zpětnou vazbu — rádi vám pomůžeme.",
|
||||||
|
"contact.story.p2": "Naším cílem je, aby správa rezervací byla pro vás bezstarostná. Ozvěte se nám a najdeme řešení společně.",
|
||||||
|
"contact.error.title": "Nepodařilo se odeslat",
|
||||||
|
"contact.error.body": "Zkuste to prosím později, nebo nám napište přímo na hello@bookra.eu.",
|
||||||
|
"contact.email.address": "hello@bookra.eu",
|
||||||
|
|
||||||
// Legal
|
// Legal
|
||||||
"legal.privacy.title": "Ochrana soukromí",
|
"legal.privacy.title": "Ochrana soukromí",
|
||||||
@@ -345,6 +518,7 @@ const dictionaries = {
|
|||||||
// Navigation & Auth
|
// Navigation & Auth
|
||||||
"nav.booking": "Public booking",
|
"nav.booking": "Public booking",
|
||||||
"nav.dashboard": "App",
|
"nav.dashboard": "App",
|
||||||
|
"nav.pricing": "Pricing",
|
||||||
"nav.about": "About us",
|
"nav.about": "About us",
|
||||||
"nav.contact": "Contact",
|
"nav.contact": "Contact",
|
||||||
|
|
||||||
@@ -409,7 +583,7 @@ const dictionaries = {
|
|||||||
|
|
||||||
// Hero Section
|
// Hero Section
|
||||||
"home.badge": "Now free for starters",
|
"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.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.primary": "Get started free",
|
||||||
"home.hero.cta.secondary": "Open booking page",
|
"home.hero.cta.secondary": "Open booking page",
|
||||||
@@ -496,6 +670,26 @@ const dictionaries = {
|
|||||||
"home.pricing.biz.cta": "Contact sales",
|
"home.pricing.biz.cta": "Contact sales",
|
||||||
"home.pricing.biz.trial": "Custom enterprise solutions",
|
"home.pricing.biz.trial": "Custom enterprise solutions",
|
||||||
|
|
||||||
|
// Comparison
|
||||||
|
"pricing.compare.eyebrow": "Detailed comparison",
|
||||||
|
"pricing.compare.title": "Compare plans",
|
||||||
|
"pricing.compare.feature": "Feature",
|
||||||
|
"pricing.compare.locations": "Locations",
|
||||||
|
"pricing.compare.staff": "Staff members",
|
||||||
|
"pricing.compare.bookings": "Bookings/month",
|
||||||
|
"pricing.compare.emailSupport": "Email support",
|
||||||
|
"pricing.compare.reminders": "Email reminders",
|
||||||
|
"pricing.compare.analytics": "Analytics",
|
||||||
|
"pricing.compare.api": "API access",
|
||||||
|
"pricing.compare.branding": "Custom branding",
|
||||||
|
"pricing.compare.whiteLabel": "White labeling",
|
||||||
|
"pricing.compare.manager": "Dedicated manager",
|
||||||
|
"pricing.compare.priority": "Priority",
|
||||||
|
"pricing.compare.dedicated": "Dedicated",
|
||||||
|
"pricing.compare.advanced": "Advanced",
|
||||||
|
"pricing.compare.yes": "Yes",
|
||||||
|
"pricing.compare.no": "No",
|
||||||
|
|
||||||
// CTA
|
// CTA
|
||||||
"home.cta.title": "Ready to simplify your bookings?",
|
"home.cta.title": "Ready to simplify your bookings?",
|
||||||
"home.cta.subtitle": "Join thousands of businesses saving time with Bookra.",
|
"home.cta.subtitle": "Join thousands of businesses saving time with Bookra.",
|
||||||
@@ -522,6 +716,10 @@ const dictionaries = {
|
|||||||
"widget.type.floating.title": "Floating bubble",
|
"widget.type.floating.title": "Floating bubble",
|
||||||
"widget.type.floating.desc": "Floating button in the corner of the screen",
|
"widget.type.floating.desc": "Floating button in the corner of the screen",
|
||||||
"widget.type.floating.preview": "Best for: E-commerce, continuous availability",
|
"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.button.text": "Book appointment",
|
||||||
"widget.modal.trigger": "Open booking",
|
"widget.modal.trigger": "Open booking",
|
||||||
"widget.styling.title": "Appearance",
|
"widget.styling.title": "Appearance",
|
||||||
@@ -559,20 +757,80 @@ const dictionaries = {
|
|||||||
"common.copy": "Copy",
|
"common.copy": "Copy",
|
||||||
"common.copied": "Copied!",
|
"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
|
||||||
"footer.copyright": "© 2026 Bookra. All rights reserved.",
|
"footer.copyright": "© 2026 Bookra. All rights reserved.",
|
||||||
"footer.privacy": "Privacy",
|
"footer.privacy": "Privacy",
|
||||||
"footer.terms": "Terms",
|
"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.links.title": "Navigation",
|
||||||
"footer.legal.title": "Legal",
|
"footer.legal.title": "Legal",
|
||||||
|
|
||||||
// Dashboard (existing)
|
// Dashboard
|
||||||
"dashboard.title": "Owner dashboard",
|
"dashboard.title": "Owner dashboard",
|
||||||
"dashboard.body": "Track weekly bookings, cancellations, utilization, and subscription state with a tenant-aware shell ready for Neon-backed data.",
|
"dashboard.body": "Track weekly bookings, cancellations, utilization, and subscription state with a tenant-aware shell ready for Neon-backed data.",
|
||||||
"dashboard.kpi.bookings": "Bookings this week",
|
"dashboard.overview": "Overview",
|
||||||
"dashboard.kpi.cancellations": "Cancellations",
|
"dashboard.bookings": "Bookings",
|
||||||
"dashboard.kpi.utilization": "Utilization",
|
"dashboard.customers": "Customers",
|
||||||
|
"dashboard.zones": "Zones & Availability",
|
||||||
|
"dashboard.billing": "Billing",
|
||||||
|
"dashboard.settings": "Settings",
|
||||||
|
"dashboard.welcome": "Welcome back,",
|
||||||
|
"dashboard.overviewFor": "Overview for",
|
||||||
|
"dashboard.kpi.bookings": "Total Bookings",
|
||||||
|
"dashboard.kpi.cancelled": "Cancelled",
|
||||||
|
"dashboard.kpi.completed": "Completed",
|
||||||
|
"dashboard.kpi.newClients": "New Clients",
|
||||||
|
"dashboard.recentActivity": "Recent Activity",
|
||||||
|
"dashboard.upcomingBookings": "Upcoming Bookings",
|
||||||
|
"dashboard.viewAll": "View all",
|
||||||
|
"dashboard.locationLimitReached": "You've reached your location limit!",
|
||||||
|
"dashboard.nearingLocationLimit": "You're nearing your location limit",
|
||||||
|
"dashboard.locationsUsed": "locations used",
|
||||||
|
"dashboard.upgrade": "Upgrade",
|
||||||
|
"dashboard.shareManage": "Share/Manage",
|
||||||
|
"dashboard.notifications": "Notifications",
|
||||||
|
"dashboard.bookingManagement": "Booking Management",
|
||||||
|
"dashboard.totalBookings": "total bookings",
|
||||||
|
"dashboard.newBooking": "New Booking",
|
||||||
|
"dashboard.bookingDetails": "Booking Details",
|
||||||
|
"dashboard.customerDetails": "Customer Details",
|
||||||
|
"dashboard.close": "Close",
|
||||||
|
"dashboard.edit": "Edit",
|
||||||
|
"dashboard.cancel": "Cancel",
|
||||||
|
"dashboard.details": "Details",
|
||||||
|
"dashboard.saveChanges": "Save Changes",
|
||||||
|
"dashboard.createBooking": "Create Booking",
|
||||||
|
"dashboard.preview": "Preview",
|
||||||
|
"dashboard.saveEmailSettings": "Save Email Settings",
|
||||||
|
"dashboard.saving": "Saving...",
|
||||||
|
"dashboard.creating": "Creating...",
|
||||||
|
"dashboard.prevMonth": "Previous month",
|
||||||
|
"dashboard.nextMonth": "Next month",
|
||||||
|
"dashboard.confirmed": "Confirmed",
|
||||||
|
"dashboard.pending": "Pending",
|
||||||
|
"dashboard.cancelled": "Cancelled",
|
||||||
|
"dashboard.completed": "Completed",
|
||||||
"dashboard.welcome.title": "Welcome to Bookra",
|
"dashboard.welcome.title": "Welcome to Bookra",
|
||||||
"dashboard.welcome.body": "Simplify your bookings and spend more time doing what you love.",
|
"dashboard.welcome.body": "Simplify your bookings and spend more time doing what you love.",
|
||||||
"dashboard.authRequired": "Live dashboard data needs a Neon Auth session and JWT.",
|
"dashboard.authRequired": "Live dashboard data needs a Neon Auth session and JWT.",
|
||||||
@@ -580,7 +838,6 @@ const dictionaries = {
|
|||||||
"dashboard.liveData": "Live data",
|
"dashboard.liveData": "Live data",
|
||||||
"dashboard.liveDataBody": "Dashboard, tenant, and billing data are loaded from the API for the signed-in workspace.",
|
"dashboard.liveDataBody": "Dashboard, tenant, and billing data are loaded from the API for the signed-in workspace.",
|
||||||
"dashboard.apiReady": "API connection active",
|
"dashboard.apiReady": "API connection active",
|
||||||
"dashboard.billing": "Billing",
|
|
||||||
"dashboard.checkout": "Open checkout",
|
"dashboard.checkout": "Open checkout",
|
||||||
"dashboard.refreshBilling": "Refresh billing",
|
"dashboard.refreshBilling": "Refresh billing",
|
||||||
"dashboard.plan": "Plan",
|
"dashboard.plan": "Plan",
|
||||||
@@ -595,6 +852,88 @@ const dictionaries = {
|
|||||||
"dashboard.onboarding.timezone": "Timezone",
|
"dashboard.onboarding.timezone": "Timezone",
|
||||||
"dashboard.onboarding.submit": "Create workspace",
|
"dashboard.onboarding.submit": "Create workspace",
|
||||||
"dashboard.onboarding.pending": "Creating workspace...",
|
"dashboard.onboarding.pending": "Creating workspace...",
|
||||||
|
"dashboard.revenueTitle": "Booking trend",
|
||||||
|
"dashboard.revenueSubtitle": "Bookings over the last 7 days",
|
||||||
|
"dashboard.noNotifications": "No new notifications",
|
||||||
|
"dashboard.markAllRead": "Mark all as read",
|
||||||
|
"dashboard.notifications.title": "Notifications",
|
||||||
|
"dashboard.demoBanner": "Demo mode",
|
||||||
|
"dashboard.demoBannerDesc": "You're exploring Bookra with sample data. Register for full functionality.",
|
||||||
|
"dashboard.demoBannerCTA": "Create account",
|
||||||
|
"dashboard.tryDemo": "Try demo",
|
||||||
|
"dashboard.language": "Language",
|
||||||
|
"dashboard.calendar.noBookings": "No bookings for this day.",
|
||||||
|
"dashboard.calendar.bookingsCount": "bookings",
|
||||||
|
"dashboard.chart.bookingsTrend": "Bookings",
|
||||||
|
"dashboard.chart.revenueTrend": "Revenue",
|
||||||
|
"dashboard.recentActivity.empty": "No activity yet. Bookings will appear here.",
|
||||||
|
"dashboard.filter.all": "All",
|
||||||
|
"dashboard.filter.today": "Today",
|
||||||
|
"dashboard.filter.week": "Week",
|
||||||
|
"dashboard.filter.month": "Month",
|
||||||
|
"dashboard.search.placeholder": "Search...",
|
||||||
|
"dashboard.search.bookings": "Search by name, service or reference...",
|
||||||
|
"dashboard.search.customers": "Search by name, email or phone...",
|
||||||
|
"dashboard.actions": "Actions",
|
||||||
|
"dashboard.customer.email": "Email",
|
||||||
|
"dashboard.customer.phone": "Phone",
|
||||||
|
"dashboard.customer.totalBookings": "Total bookings",
|
||||||
|
"dashboard.customer.lastVisit": "Last visit",
|
||||||
|
"dashboard.customer.status": "Status",
|
||||||
|
"dashboard.customer.notes": "Notes",
|
||||||
|
"dashboard.customer.noNotes": "No notes",
|
||||||
|
"dashboard.customer.bookings": "Customer bookings",
|
||||||
|
"dashboard.customer.noBookings": "No bookings",
|
||||||
|
"dashboard.zone.add": "Add zone",
|
||||||
|
"dashboard.zone.name": "Name",
|
||||||
|
"dashboard.zone.type": "Type",
|
||||||
|
"dashboard.zone.capacity": "Capacity",
|
||||||
|
"dashboard.zone.limitReached": "Limit reached",
|
||||||
|
"dashboard.zone.rooms": "Room",
|
||||||
|
"dashboard.zone.private": "Private room",
|
||||||
|
"dashboard.zone.hall": "Hall",
|
||||||
|
"dashboard.zone.blockedDays": "Blocked days",
|
||||||
|
"dashboard.zone.workingHours": "Working hours",
|
||||||
|
"dashboard.zone.open": "Open",
|
||||||
|
"dashboard.zone.close": "Close",
|
||||||
|
"dashboard.zone.addBlocked": "Add blocked day",
|
||||||
|
"dashboard.zone.reason": "Reason",
|
||||||
|
"dashboard.zone.noZones": "No zones yet. Add your first one.",
|
||||||
|
"dashboard.billing.planUsage": "Plan usage",
|
||||||
|
"dashboard.billing.locations": "Locations",
|
||||||
|
"dashboard.billing.bookings": "Bookings",
|
||||||
|
"dashboard.billing.staff": "Staff",
|
||||||
|
"dashboard.billing.period": "Period",
|
||||||
|
"dashboard.billing.currentPlan": "Current plan",
|
||||||
|
"dashboard.billing.nextBilling": "Next billing",
|
||||||
|
"dashboard.settings.businessInfo": "Business information",
|
||||||
|
"dashboard.settings.branding": "Branding & appearance",
|
||||||
|
"dashboard.settings.emailNotifications": "Email notifications",
|
||||||
|
"dashboard.settings.emailSubject": "Subject",
|
||||||
|
"dashboard.settings.emailBody": "Body",
|
||||||
|
"dashboard.settings.save": "Save",
|
||||||
|
"dashboard.bookingModal.customer": "Customer",
|
||||||
|
"dashboard.bookingModal.service": "Service",
|
||||||
|
"dashboard.bookingModal.location": "Location",
|
||||||
|
"dashboard.bookingModal.dateTime": "Date & time",
|
||||||
|
"dashboard.bookingModal.duration": "Duration",
|
||||||
|
"dashboard.bookingModal.status": "Status",
|
||||||
|
"dashboard.bookingModal.reference": "Reference",
|
||||||
|
"dashboard.bookingModal.notes": "Notes",
|
||||||
|
"dashboard.bookingModal.reschedule": "Reschedule",
|
||||||
|
"dashboard.bookingModal.confirmCancel": "Are you sure you want to cancel this booking?",
|
||||||
|
"dashboard.bookingModal.createdAt": "Created",
|
||||||
|
"dashboard.bookingModal.assignedTo": "Assigned to",
|
||||||
|
"dashboard.bookingModal.noAssign": "Not assigned",
|
||||||
|
"dashboard.bookingModal.email": "Email",
|
||||||
|
"dashboard.bookingModal.phone": "Phone",
|
||||||
|
"dashboard.empty.title": "Nothing here yet",
|
||||||
|
"dashboard.empty.bookingsDesc": "No bookings match your filter.",
|
||||||
|
"dashboard.empty.customersDesc": "You don't have any customers yet.",
|
||||||
|
"dashboard.notification.newBooking": "New booking from",
|
||||||
|
"dashboard.notification.reminder": "Reminder: booking at",
|
||||||
|
"dashboard.notification.upgrade": "You're nearing your plan limit",
|
||||||
|
"dashboard.notification.trialEnding": "Your trial ends in 3 days",
|
||||||
"booking.title": "Book a visit",
|
"booking.title": "Book a visit",
|
||||||
"booking.body": "Choose an available time, add your contact details, and receive confirmation by email.",
|
"booking.body": "Choose an available time, add your contact details, and receive confirmation by email.",
|
||||||
"booking.slots": "Available times",
|
"booking.slots": "Available times",
|
||||||
@@ -613,6 +952,7 @@ const dictionaries = {
|
|||||||
"booking.customer.body": "These details are used for confirmation and reminders.",
|
"booking.customer.body": "These details are used for confirmation and reminders.",
|
||||||
"booking.customer.name": "Name",
|
"booking.customer.name": "Name",
|
||||||
"booking.customer.email": "Email",
|
"booking.customer.email": "Email",
|
||||||
|
"booking.customer.phone": "Phone",
|
||||||
"booking.customer.notes": "Note",
|
"booking.customer.notes": "Note",
|
||||||
"booking.customerRequired": "Add your name and email before booking.",
|
"booking.customerRequired": "Add your name and email before booking.",
|
||||||
"booking.failed": "Booking failed",
|
"booking.failed": "Booking failed",
|
||||||
@@ -658,6 +998,12 @@ const dictionaries = {
|
|||||||
"contact.info.email.desc": "Prefer to write? We're here for you.",
|
"contact.info.email.desc": "Prefer to write? We're here for you.",
|
||||||
"contact.info.hours.title": "Working hours",
|
"contact.info.hours.title": "Working hours",
|
||||||
"contact.info.hours.desc": "We respond on business days 9:00 — 17:00 CET.",
|
"contact.info.hours.desc": "We respond on business days 9:00 — 17:00 CET.",
|
||||||
|
"contact.story.heading": "Why reach out?",
|
||||||
|
"contact.story.p1": "Whether you have questions about features, need help with setup, or want to share feedback — we're happy to help.",
|
||||||
|
"contact.story.p2": "Our goal is to make booking management effortless for you. Get in touch and we'll find a solution together.",
|
||||||
|
"contact.error.title": "Failed to send",
|
||||||
|
"contact.error.body": "Please try again later, or email us directly at hello@bookra.eu.",
|
||||||
|
"contact.email.address": "hello@bookra.eu",
|
||||||
|
|
||||||
// Legal
|
// Legal
|
||||||
"legal.privacy.title": "Privacy",
|
"legal.privacy.title": "Privacy",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export function BookingManageRoute() {
|
|||||||
customerEmail: "alice@example.com",
|
customerEmail: "alice@example.com",
|
||||||
service: "Yoga Flow Class",
|
service: "Yoga Flow Class",
|
||||||
businessName: "Serenity Wellness Studio",
|
businessName: "Serenity Wellness Studio",
|
||||||
|
businessEmail: "support@bookra.eu",
|
||||||
startsAt: new Date(Date.now() + 86400000).toISOString(),
|
startsAt: new Date(Date.now() + 86400000).toISOString(),
|
||||||
endsAt: new Date(Date.now() + 86400000 + 3600000).toISOString(),
|
endsAt: new Date(Date.now() + 86400000 + 3600000).toISOString(),
|
||||||
location: "Main Studio, 123 Wellness Street",
|
location: "Main Studio, 123 Wellness Street",
|
||||||
@@ -331,7 +332,7 @@ export function BookingManageRoute() {
|
|||||||
: 'Have questions or need special arrangements? Contact the business directly.'}
|
: 'Have questions or need special arrangements? Contact the business directly.'}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href={`mailto:support@bookra.eu?subject=Booking ${b().reference}`}
|
href={`mailto:${b().businessEmail || 'support@bookra.eu'}?subject=Booking ${b().reference}`}
|
||||||
class="text-accent hover:text-accent-hover font-medium text-sm inline-flex items-center gap-2"
|
class="text-accent hover:text-accent-hover font-medium text-sm inline-flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{i18n.locale() === 'cs' ? 'Poslat zprávu' : 'Send message'}
|
{i18n.locale() === 'cs' ? 'Poslat zprávu' : 'Send message'}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Show, createSignal } from "solid-js";
|
import { Show, createSignal, Match, Switch } from "solid-js";
|
||||||
import { useI18n } from "../providers/i18n-provider";
|
import { useI18n } from "../providers/i18n-provider";
|
||||||
import { BookraCharacter } from "../components/bookra-character";
|
import { BookraCharacter } from "../components/bookra-character";
|
||||||
import {
|
import {
|
||||||
@@ -17,38 +17,48 @@ export function ContactRoute() {
|
|||||||
const [message, setMessage] = createSignal("");
|
const [message, setMessage] = createSignal("");
|
||||||
const [submitted, setSubmitted] = createSignal(false);
|
const [submitted, setSubmitted] = createSignal(false);
|
||||||
const [submitting, setSubmitting] = createSignal(false);
|
const [submitting, setSubmitting] = createSignal(false);
|
||||||
|
const [error, setError] = createSignal("");
|
||||||
|
|
||||||
|
const apiUrl = import.meta.env.VITE_BOOKRA_API_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
const handleSubmit = async (e: Event) => {
|
const handleSubmit = async (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
// Simulate API call
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
try {
|
||||||
setSubmitting(false);
|
const res = await fetch(`${apiUrl}/v1/public/contact`, {
|
||||||
setSubmitted(true);
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name(),
|
||||||
|
email: email(),
|
||||||
|
message: message(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to send");
|
||||||
|
setSubmitted(true);
|
||||||
|
} catch {
|
||||||
|
setError(i18n.t("contact.error.body"));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="animate-fade-in">
|
<div class="animate-fade-in">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section class="relative pt-16 pb-12 lg:pt-24 lg:pb-16 overflow-hidden">
|
<section class="relative pt-16 pb-12 lg:pt-24 lg:pb-16 overflow-hidden">
|
||||||
{/* Background gradient */}
|
<div class="absolute inset-0 pointer-events-none" style={{ background: "var(--gradient-hero)" }} />
|
||||||
<div
|
|
||||||
class="absolute inset-0 pointer-events-none"
|
|
||||||
style={{ background: "var(--gradient-hero)" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="section-container relative">
|
<div class="section-container relative">
|
||||||
<div class="max-w-3xl mx-auto text-center">
|
<div class="max-w-3xl mx-auto text-center">
|
||||||
{/* Character at top */}
|
|
||||||
<div class="flex justify-center mb-8">
|
<div class="flex justify-center mb-8">
|
||||||
<BookraCharacter pose="headphones" size="xl" animate={true} />
|
<BookraCharacter pose="headphones" size="xl" animate={true} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="inline-flex items-center gap-2 px-4 py-1.5 mb-6 text-sm font-medium tracking-wide text-accent bg-accent-subtle/80 rounded-full border border-accent/10 backdrop-blur-sm">
|
<span class="inline-flex items-center gap-2 px-4 py-1.5 mb-6 text-sm font-medium tracking-wide text-accent bg-accent-subtle/80 rounded-full border border-accent/10 backdrop-blur-sm">
|
||||||
<span class="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
<span class="w-2 h-2 rounded-full bg-success" />
|
||||||
{i18n.locale() === 'cs' ? 'Jsme tu pro vás' : 'We are here for you'}
|
{i18n.locale() === 'cs' ? 'Jsme tu pro vás' : 'We are here for you'}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<h1 class="text-display-xl font-semibold text-ink mb-6 tracking-tight animate-slide-up">
|
<h1 class="text-display-xl font-semibold text-ink mb-6 tracking-tight animate-slide-up">
|
||||||
{i18n.t("contact.title")}
|
{i18n.t("contact.title")}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -59,162 +69,147 @@ export function ContactRoute() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Contact Form Section */}
|
{/* Story + Form split */}
|
||||||
<section class="py-16 lg:py-24 bg-canvas-subtle/30">
|
<section class="py-16 lg:py-24 bg-canvas-subtle/30">
|
||||||
<div class="section-container">
|
<div class="section-container">
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-5xl mx-auto grid lg:grid-cols-[1fr_1.2fr] gap-12 lg:gap-16 items-start">
|
||||||
<Show when={!submitted()} fallback={
|
{/* Story side */}
|
||||||
<Card class="surface-elevated border-success/20">
|
<div class="space-y-8">
|
||||||
<CardContent class="py-12">
|
<div>
|
||||||
<div class="flex flex-col items-center text-center">
|
<h2 class="text-display-sm font-semibold text-ink mb-4">
|
||||||
<div class="relative mb-6">
|
{i18n.t("contact.story.heading")}
|
||||||
<BookraCharacter pose="success" size="xl" animate={true} />
|
</h2>
|
||||||
<div class="absolute -top-2 -right-2 text-3xl animate-bounce">🎉</div>
|
<div class="space-y-4 text-ink-muted leading-relaxed">
|
||||||
</div>
|
<p>{i18n.t("contact.story.p1")}</p>
|
||||||
<h2 class="text-display-md font-semibold text-ink mb-4">
|
<p>{i18n.t("contact.story.p2")}</p>
|
||||||
{i18n.t("contact.success.title")}
|
|
||||||
</h2>
|
|
||||||
<p class="text-ink-muted max-w-sm">
|
|
||||||
{i18n.t("contact.success.body")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
}>
|
|
||||||
<Card class="surface-elevated overflow-hidden">
|
|
||||||
<div class="flex items-center gap-3 p-6 border-b border-border/50 bg-canvas-subtle/30">
|
|
||||||
<div class="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
|
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<CardTitle class="text-xl">{i18n.t("contact.form.title")}</CardTitle>
|
|
||||||
</div>
|
</div>
|
||||||
<CardContent class="p-6">
|
</div>
|
||||||
<form onSubmit={handleSubmit} class="space-y-6">
|
|
||||||
<Input
|
|
||||||
label={i18n.t("contact.form.name")}
|
|
||||||
type="text"
|
|
||||||
value={name()}
|
|
||||||
onInput={(e) => setName(e.currentTarget.value)}
|
|
||||||
required
|
|
||||||
autocomplete="name"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={i18n.t("contact.form.email")}
|
|
||||||
type="email"
|
|
||||||
value={email()}
|
|
||||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
|
||||||
required
|
|
||||||
autocomplete="email"
|
|
||||||
/>
|
|
||||||
<Textarea
|
|
||||||
label={i18n.t("contact.form.message")}
|
|
||||||
value={message()}
|
|
||||||
onInput={(e) => setMessage(e.currentTarget.value)}
|
|
||||||
rows={5}
|
|
||||||
required
|
|
||||||
placeholder={i18n.locale() === 'cs' ? "Napište nám, jak vám můžeme pomoci..." : "Tell us how we can help you..."}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
fullWidth
|
|
||||||
isLoading={submitting()}
|
|
||||||
class="shadow-lg hover:shadow-xl transition-all"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
|
|
||||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
|
||||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
|
||||||
</svg>
|
|
||||||
{i18n.t("contact.form.submit")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Contact Info Section */}
|
<div class="grid sm:grid-cols-2 gap-4">
|
||||||
<section class="py-16 lg:py-24">
|
<Card class="surface-elevated group hover:shadow-lg transition-all">
|
||||||
<div class="section-container">
|
<CardContent class="p-5">
|
||||||
<div class="max-w-4xl mx-auto">
|
<div class="flex items-start gap-3">
|
||||||
{/* Section title */}
|
<div class="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 group-hover:bg-accent/20 transition-colors">
|
||||||
<div class="text-center mb-12">
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
|
||||||
<h2 class="text-display-md font-semibold text-ink mb-3">
|
<path d="M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5A2.5 2.5 0 0 1 4.5 7h15A2.5 2.5 0 0 1 22 9.5z"/>
|
||||||
{i18n.locale() === 'cs' ? 'Další způsoby kontaktu' : 'Other ways to reach us'}
|
<polyline points="22 9.5 12 14 2 9.5"/>
|
||||||
</h2>
|
|
||||||
<p class="text-ink-muted">
|
|
||||||
{i18n.locale() === 'cs' ? 'Vyberte si, co vám nejvíce vyhovuje' : 'Choose what works best for you'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid md:grid-cols-2 gap-8">
|
|
||||||
{/* Email Card */}
|
|
||||||
<Card class="surface-elevated group hover:shadow-xl transition-all duration-500">
|
|
||||||
<CardContent class="p-6">
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<div class="w-12 h-12 rounded-xl bg-accent/10 flex items-center justify-center shrink-0 group-hover:bg-accent/20 transition-colors">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
|
|
||||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
|
||||||
<polyline points="22,6 12,13 2,6"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="font-display font-semibold text-ink mb-1">{i18n.t("contact.info.email.title")}</h3>
|
|
||||||
<p class="text-ink-muted text-sm mb-3">{i18n.t("contact.info.email.desc")}</p>
|
|
||||||
<a
|
|
||||||
href="mailto:hello@bookra.cz"
|
|
||||||
class="inline-flex items-center gap-2 text-accent hover:text-accent/80 font-medium transition-colors group/link"
|
|
||||||
>
|
|
||||||
hello@bookra.cz
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="transition-transform group-hover/link:translate-x-1">
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
|
||||||
<polyline points="12 5 19 12 12 19"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<h3 class="font-display font-semibold text-ink text-sm mb-1">{i18n.t("contact.info.email.title")}</h3>
|
||||||
</CardContent>
|
<a href={`mailto:${i18n.t("contact.email.address")}`} class="text-accent text-sm font-medium hover:underline">
|
||||||
</Card>
|
{i18n.t("contact.email.address")}
|
||||||
|
</a>
|
||||||
{/* Hours Card */}
|
|
||||||
<Card class="surface-elevated group hover:shadow-xl transition-all duration-500">
|
|
||||||
<CardContent class="p-6">
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<div class="w-12 h-12 rounded-xl bg-accent/10 flex items-center justify-center shrink-0 group-hover:bg-accent/20 transition-colors">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
|
|
||||||
<circle cx="12" cy="12" r="10"/>
|
|
||||||
<polyline points="12 6 12 12 16 14"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="font-display font-semibold text-ink mb-1">{i18n.t("contact.info.hours.title")}</h3>
|
|
||||||
<p class="text-ink-muted text-sm">{i18n.t("contact.info.hours.desc")}</p>
|
|
||||||
<div class="mt-3 flex items-center gap-2">
|
|
||||||
<span class="w-2 h-2 rounded-full bg-success animate-pulse"/>
|
|
||||||
<span class="text-xs text-success font-medium">
|
|
||||||
{i18n.locale() === 'cs' ? 'Aktuálně online' : 'Currently online'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
<Card class="surface-elevated group hover:shadow-lg transition-all">
|
||||||
</div>
|
<CardContent class="p-5">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
{/* Helpful mascot at bottom */}
|
<div class="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 group-hover:bg-accent/20 transition-colors">
|
||||||
<div class="mt-16 flex 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">
|
||||||
<div class="flex items-center gap-4 surface-elevated px-6 py-4 rounded-2xl">
|
<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} />
|
<BookraCharacter pose="main" size="sm" animate={true} />
|
||||||
<p class="text-ink-muted text-sm">
|
<p class="text-ink-muted text-sm">
|
||||||
{i18n.locale() === 'cs'
|
{i18n.locale() === 'cs'
|
||||||
? 'Odpovídáme obvykle do 24 hodin'
|
? 'Odpovídáme obvykle do 24 hodin'
|
||||||
: 'We usually respond within 24 hours'}
|
: 'We usually respond within 24 hours'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Form side */}
|
||||||
|
<div>
|
||||||
|
<Switch>
|
||||||
|
<Match when={submitted()}>
|
||||||
|
<Card class="surface-elevated border-success/20">
|
||||||
|
<CardContent class="py-12">
|
||||||
|
<div class="flex flex-col items-center text-center">
|
||||||
|
<div class="relative mb-6">
|
||||||
|
<BookraCharacter pose="success" size="xl" animate={true} />
|
||||||
|
</div>
|
||||||
|
<h2 class="text-display-md font-semibold text-ink mb-4">
|
||||||
|
{i18n.t("contact.success.title")}
|
||||||
|
</h2>
|
||||||
|
<p class="text-ink-muted max-w-sm">
|
||||||
|
{i18n.t("contact.success.body")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<Card class="surface-elevated overflow-hidden">
|
||||||
|
<div class="flex items-center gap-3 p-6 border-b border-border/50 bg-canvas-subtle/30">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<CardTitle class="text-xl">{i18n.t("contact.form.title")}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardContent class="p-6">
|
||||||
|
<form onSubmit={handleSubmit} class="space-y-5">
|
||||||
|
<Input
|
||||||
|
label={i18n.t("contact.form.name")}
|
||||||
|
type="text"
|
||||||
|
value={name()}
|
||||||
|
onInput={(e) => setName(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
autocomplete="name"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={i18n.t("contact.form.email")}
|
||||||
|
type="email"
|
||||||
|
value={email()}
|
||||||
|
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label={i18n.t("contact.form.message")}
|
||||||
|
value={message()}
|
||||||
|
onInput={(e) => setMessage(e.currentTarget.value)}
|
||||||
|
rows={5}
|
||||||
|
required
|
||||||
|
minLength={10}
|
||||||
|
placeholder={i18n.locale() === 'cs' ? "Napište nám, jak vám můžeme pomoci..." : "Tell us how we can help you..."}
|
||||||
|
/>
|
||||||
|
<Show when={error()}>
|
||||||
|
<p class="text-sm text-danger">{error()}</p>
|
||||||
|
</Show>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
isLoading={submitting()}
|
||||||
|
class="shadow-lg hover:shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||||
|
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||||
|
</svg>
|
||||||
|
{i18n.t("contact.form.submit")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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} • {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" ? "Po–Ne, svátky" : "Mon–Sun, 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 — {(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} • {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)} • {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,24 @@
|
|||||||
import { A } from "@solidjs/router";
|
import { A, useNavigate } from "@solidjs/router";
|
||||||
import { createSignal, onMount, createMemo } from "solid-js";
|
import { createSignal, onMount, createMemo, Show, For } from "solid-js";
|
||||||
import { useI18n } from "../providers/i18n-provider";
|
import { useI18n } from "../providers/i18n-provider";
|
||||||
|
import { useTheme } from "../providers/theme-provider";
|
||||||
import { BookraCharacter } from "../components/bookra-character";
|
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)
|
// Lucide-like icon components (lightweight SVG)
|
||||||
const CalendarIcon = () => (
|
const CalendarIcon = () => (
|
||||||
@@ -107,11 +124,119 @@ const StepCard = (props: StepCardProps) => (
|
|||||||
// Main home route component
|
// Main home route component
|
||||||
export function HomeRoute() {
|
export function HomeRoute() {
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
const [billingInterval, setBillingInterval] = createSignal<"monthly" | "yearly">("monthly");
|
||||||
|
|
||||||
|
const isYearly = () => billingInterval() === "yearly";
|
||||||
|
const isCs = () => i18n.locale() === "cs";
|
||||||
|
|
||||||
|
const ComparisonValue = (props: { value: string; highlight?: boolean }) => {
|
||||||
|
const v = props.value.toLowerCase();
|
||||||
|
const yesLabel = i18n.t("pricing.compare.yes").toLowerCase();
|
||||||
|
const noLabel = i18n.t("pricing.compare.no").toLowerCase();
|
||||||
|
if (v === "yes" || v === yesLabel) {
|
||||||
|
return (
|
||||||
|
<span class={`inline-flex items-center justify-center w-6 h-6 rounded-full ${props.highlight ? 'bg-accent/15 text-accent' : 'bg-success/15 text-success'}`}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 6 9 17l-5-5"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (v === "no" || v === noLabel) {
|
||||||
|
return (
|
||||||
|
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-ink/5 text-ink-muted">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M18 6 6 18"/>
|
||||||
|
<path d="m6 6 12 12"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span class={`text-sm font-medium ${props.highlight ? 'text-accent' : 'text-ink'}`}>
|
||||||
|
{props.value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pricing data with monthly and yearly options
|
||||||
|
const plans = createMemo(() => [
|
||||||
|
{
|
||||||
|
id: "starter",
|
||||||
|
name: isCs() ? "Starter" : "Starter",
|
||||||
|
desc: isCs() ? "Pro jednotlivce a malé podniky" : "For individuals and small businesses",
|
||||||
|
monthly: isCs() ? "199 Kč" : "$9",
|
||||||
|
yearly: isCs() ? "1 990 Kč" : "$90",
|
||||||
|
period: isCs() ? "/měsíc" : "/mo",
|
||||||
|
yearlyPeriod: isCs() ? "/rok" : "/yr",
|
||||||
|
savings: isCs() ? "Ušetřete 398 Kč" : "Save $18",
|
||||||
|
savingsPercent: "17%",
|
||||||
|
trial: isCs() ? "15 dní zdarma po registraci" : "15 days free after sign-up",
|
||||||
|
features: [
|
||||||
|
isCs() ? "Do 50 rezervací/měsíc" : "Up to 50 bookings/month",
|
||||||
|
isCs() ? "1 lokace, 1 zaměstnanec" : "1 location, 1 staff member",
|
||||||
|
isCs() ? "E-mailová podpora" : "Email support",
|
||||||
|
],
|
||||||
|
cta: isCs() ? "Začít zdarma" : "Start for free",
|
||||||
|
popular: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pro",
|
||||||
|
name: isCs() ? "Pro" : "Pro",
|
||||||
|
desc: isCs() ? "Pro rostoucí podniky" : "For growing businesses",
|
||||||
|
monthly: isCs() ? "399 Kč" : "$19",
|
||||||
|
yearly: isCs() ? "3 990 Kč" : "$190",
|
||||||
|
period: isCs() ? "/měsíc" : "/mo",
|
||||||
|
yearlyPeriod: isCs() ? "/rok" : "/yr",
|
||||||
|
savings: isCs() ? "Ušetřete 798 Kč" : "Save $38",
|
||||||
|
savingsPercent: "17%",
|
||||||
|
trial: isCs() ? "15 dní zdarma po registraci" : "15 days free after sign-up",
|
||||||
|
features: [
|
||||||
|
isCs() ? "Neomezené rezervace" : "Unlimited bookings",
|
||||||
|
isCs() ? "3 lokace, 10 zaměstnanců" : "3 locations, 10 staff",
|
||||||
|
isCs() ? "E-mailová připomenutí" : "Email reminders",
|
||||||
|
isCs() ? "Prioritní podpora" : "Priority support",
|
||||||
|
isCs() ? "Analytika a reporty" : "Analytics & reports",
|
||||||
|
],
|
||||||
|
cta: isCs() ? "Začít 15denní zkoušku" : "Start 15-day trial",
|
||||||
|
popular: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "business",
|
||||||
|
name: isCs() ? "Business" : "Business",
|
||||||
|
desc: isCs() ? "Pro větší týmy a franšízy" : "For larger teams and franchises",
|
||||||
|
monthly: isCs() ? "799 Kč" : "$39",
|
||||||
|
yearly: isCs() ? "7 990 Kč" : "$390",
|
||||||
|
period: isCs() ? "/měsíc" : "/mo",
|
||||||
|
yearlyPeriod: isCs() ? "/rok" : "/yr",
|
||||||
|
savings: isCs() ? "Ušetřete 1 598 Kč" : "Save $78",
|
||||||
|
savingsPercent: "17%",
|
||||||
|
trial: isCs() ? "Individuální řešení na míru" : "Custom enterprise solutions",
|
||||||
|
features: [
|
||||||
|
isCs() ? "Neomezené vše" : "Unlimited everything",
|
||||||
|
isCs() ? "Více lokací" : "Multiple locations",
|
||||||
|
isCs() ? "API přístup" : "API access",
|
||||||
|
isCs() ? "Dedikovaný manažer" : "Dedicated manager",
|
||||||
|
],
|
||||||
|
cta: isCs() ? "Kontaktovat prodej" : "Contact sales",
|
||||||
|
popular: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const [isVisible, setIsVisible] = createSignal(false);
|
const [isVisible, setIsVisible] = createSignal(false);
|
||||||
const [currentDate, setCurrentDate] = createSignal(new Date());
|
const [currentDate, setCurrentDate] = createSignal(new Date());
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
setIsVisible(true);
|
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
|
// Calendar helpers
|
||||||
@@ -354,20 +479,27 @@ export function HomeRoute() {
|
|||||||
<section class="py-12 border-y border-border/50 bg-canvas-subtle/50">
|
<section class="py-12 border-y border-border/50 bg-canvas-subtle/50">
|
||||||
<div class="section-container">
|
<div class="section-container">
|
||||||
<div class="mb-6 flex justify-center">
|
<div class="mb-6 flex justify-center">
|
||||||
<img
|
<HomeLogo />
|
||||||
src="/bookra-illustrations/logo_text_horizontal.svg"
|
|
||||||
alt="Bookra"
|
|
||||||
class="h-9 w-auto opacity-75"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-center text-sm text-ink-subtle mb-8 tracking-wide uppercase">
|
<p class="text-center text-sm text-ink-subtle mb-8 tracking-wide uppercase">
|
||||||
{i18n.t("home.trust")}
|
{i18n.t("home.trust")}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-wrap items-center justify-center gap-8 lg:gap-16 opacity-60">
|
<div class="flex flex-wrap items-center justify-center gap-4 lg:gap-6">
|
||||||
{["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: "Salon Ella", icon: "SE" },
|
||||||
{name}
|
{ name: "Physio Care", icon: "PC" },
|
||||||
</span>
|
{ 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>
|
||||||
</div>
|
</div>
|
||||||
@@ -404,6 +536,164 @@ export function HomeRoute() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>Pá</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 */}
|
{/* How it works */}
|
||||||
<section class="py-20 lg:py-32 bg-canvas-subtle/30">
|
<section class="py-20 lg:py-32 bg-canvas-subtle/30">
|
||||||
<div class="section-container">
|
<div class="section-container">
|
||||||
@@ -512,125 +802,168 @@ export function HomeRoute() {
|
|||||||
{i18n.t("home.pricing.subtitle")}
|
{i18n.t("home.pricing.subtitle")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto items-center">
|
|
||||||
{/* Starter Plan */}
|
{/* Billing Toggle */}
|
||||||
<div
|
<div class="flex items-center justify-center mb-12 animate-slide-up" style={{ "animation-delay": "0.25s" }}>
|
||||||
class="group surface-elevated p-6 lg:p-8 rounded-card transition-all duration-500 hover:shadow-xl hover:-translate-y-1 animate-slide-up"
|
<div class="relative inline-flex items-center gap-4">
|
||||||
style={{ "animation-delay": "0.3s" }}
|
<span class={`text-sm font-semibold transition-colors ${!isYearly() ? 'text-ink' : 'text-ink-muted'}`}>
|
||||||
>
|
{isCs() ? "Měsíčně" : "Monthly"}
|
||||||
<div class="mb-6">
|
</span>
|
||||||
<h3 class="font-display text-lg font-semibold mb-1 text-ink">
|
<button
|
||||||
{i18n.t("home.pricing.starter.name")}
|
type="button"
|
||||||
</h3>
|
onClick={() => setBillingInterval(isYearly() ? "monthly" : "yearly")}
|
||||||
<p class="text-ink-muted">{i18n.t("home.pricing.starter.desc")}</p>
|
class={`relative w-14 h-7 rounded-full transition-colors duration-300 cursor-pointer ${isYearly() ? 'bg-accent' : 'bg-ink/30'}`}
|
||||||
</div>
|
role="switch"
|
||||||
<div class="mb-6">
|
aria-checked={isYearly()}
|
||||||
<span class="font-display text-4xl font-semibold text-ink">
|
aria-label={isYearly() ? (isCs() ? "Ročně" : "Yearly") : (isCs() ? "Měsíčně" : "Monthly")}
|
||||||
{i18n.locale() === 'cs' ? '119 Kč' : '$5'}
|
|
||||||
</span>
|
|
||||||
<span class="text-ink-muted">{i18n.t("home.pricing.perMonth")}</span>
|
|
||||||
<p class="mt-1 text-xs text-accent font-medium">{i18n.t("home.pricing.starter.trial")}</p>
|
|
||||||
</div>
|
|
||||||
<ul class="space-y-3 mb-8">
|
|
||||||
{[i18n.t("home.pricing.starter.f1"), i18n.t("home.pricing.starter.f2"), i18n.t("home.pricing.starter.f3")].map((feature) => (
|
|
||||||
<li class="flex items-start gap-3 text-ink-muted">
|
|
||||||
<span class="mt-0.5 text-accent shrink-0">
|
|
||||||
<CheckIcon />
|
|
||||||
</span>
|
|
||||||
<span class="text-sm">{feature}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<A
|
|
||||||
href="/dashboard"
|
|
||||||
class="block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 btn-secondary w-full"
|
|
||||||
>
|
>
|
||||||
{i18n.t("home.pricing.starter.cta")}
|
<span
|
||||||
</A>
|
class={`absolute top-1 left-1 w-5 h-5 bg-white rounded-full shadow-md transition-transform duration-300 ease-spring ${isYearly() ? 'translate-x-7' : 'translate-x-0'}`}
|
||||||
</div>
|
/>
|
||||||
|
</button>
|
||||||
{/* Pro Plan - Highlighted */}
|
<span class={`text-sm font-semibold transition-colors ${isYearly() ? 'text-ink' : 'text-ink-muted'}`}>
|
||||||
<div
|
{isCs() ? "Ročně" : "Yearly"}
|
||||||
class="group relative p-6 lg:p-8 rounded-card transition-all duration-500 hover:shadow-2xl hover:-translate-y-2 animate-slide-up lg:scale-105"
|
</span>
|
||||||
style={{ "animation-delay": "0.4s" }}
|
<div class="absolute left-full ml-3 top-1/2 -translate-y-1/2">
|
||||||
>
|
<Show when={isYearly()}>
|
||||||
{/* Gradient background for highlighted card */}
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-accent text-white text-xs font-bold rounded-full shadow-sm whitespace-nowrap">
|
||||||
<div class="absolute inset-0 bg-gradient-to-b from-ink to-ink/95 rounded-card" />
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>
|
||||||
{/* Popular badge */}
|
</svg>
|
||||||
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
|
{isCs() ? "-17%" : "-17%"}
|
||||||
<span class="px-3 py-1 bg-accent text-white text-xs font-display font-medium rounded-full shadow-lg">
|
|
||||||
{i18n.t("home.pricing.popular")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative z-10">
|
|
||||||
<div class="mb-6">
|
|
||||||
<h3 class="font-display text-lg font-semibold mb-1 text-canvas">
|
|
||||||
{i18n.t("home.pricing.pro.name")}
|
|
||||||
</h3>
|
|
||||||
<p class="text-canvas/70">{i18n.t("home.pricing.pro.desc")}</p>
|
|
||||||
</div>
|
|
||||||
<div class="mb-6">
|
|
||||||
<span class="font-display text-4xl font-semibold text-canvas">
|
|
||||||
{i18n.locale() === 'cs' ? '499 Kč' : '$20'}
|
|
||||||
</span>
|
</span>
|
||||||
<span class="text-canvas/60">{i18n.t("home.pricing.perMonth")}</span>
|
</Show>
|
||||||
<p class="mt-1 text-xs text-accent font-medium">{i18n.t("home.pricing.pro.trial")}</p>
|
|
||||||
</div>
|
|
||||||
<ul class="space-y-3 mb-8">
|
|
||||||
{[i18n.t("home.pricing.pro.f1"), i18n.t("home.pricing.pro.f2"), i18n.t("home.pricing.pro.f3"), i18n.t("home.pricing.pro.f4"), i18n.t("home.pricing.pro.f5")].map((feature) => (
|
|
||||||
<li class="flex items-start gap-3 text-canvas/80">
|
|
||||||
<span class="mt-0.5 text-accent shrink-0">
|
|
||||||
<CheckIcon />
|
|
||||||
</span>
|
|
||||||
<span class="text-sm">{feature}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<A
|
|
||||||
href="/dashboard"
|
|
||||||
class="block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 bg-canvas text-ink hover:bg-canvas-subtle w-full shadow-lg group-hover:shadow-xl"
|
|
||||||
>
|
|
||||||
{i18n.t("home.pricing.pro.cta")}
|
|
||||||
</A>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Business Plan */}
|
<div class="grid md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto items-center">
|
||||||
<div
|
{plans().map((plan, index) => (
|
||||||
class="group surface-elevated p-6 lg:p-8 rounded-card transition-all duration-500 hover:shadow-xl hover:-translate-y-1 animate-slide-up"
|
<div
|
||||||
style={{ "animation-delay": "0.5s" }}
|
class={`group relative p-6 lg:p-8 rounded-card transition-all duration-500 animate-slide-up ${plan.popular ? 'hover:shadow-2xl hover:-translate-y-2 lg:scale-105' : 'hover:shadow-xl hover:-translate-y-1'}`}
|
||||||
>
|
style={{ "animation-delay": `${0.3 + index * 0.1}s` }}
|
||||||
<div class="mb-6">
|
|
||||||
<h3 class="font-display text-lg font-semibold mb-1 text-ink">
|
|
||||||
{i18n.t("home.pricing.biz.name")}
|
|
||||||
</h3>
|
|
||||||
<p class="text-ink-muted">{i18n.t("home.pricing.biz.desc")}</p>
|
|
||||||
</div>
|
|
||||||
<div class="mb-6">
|
|
||||||
<span class="font-display text-4xl font-semibold text-ink">
|
|
||||||
{i18n.locale() === 'cs' ? '1 199 Kč' : '$50'}
|
|
||||||
</span>
|
|
||||||
<span class="text-ink-muted">{i18n.t("home.pricing.perMonth")}</span>
|
|
||||||
<p class="mt-1 text-xs text-accent font-medium">{i18n.t("home.pricing.biz.trial")}</p>
|
|
||||||
</div>
|
|
||||||
<ul class="space-y-3 mb-8">
|
|
||||||
{[i18n.t("home.pricing.biz.f1"), i18n.t("home.pricing.biz.f2"), i18n.t("home.pricing.biz.f3"), i18n.t("home.pricing.biz.f4")].map((feature) => (
|
|
||||||
<li class="flex items-start gap-3 text-ink-muted">
|
|
||||||
<span class="mt-0.5 text-accent shrink-0">
|
|
||||||
<CheckIcon />
|
|
||||||
</span>
|
|
||||||
<span class="text-sm">{feature}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<A
|
|
||||||
href="/dashboard"
|
|
||||||
class="block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 btn-secondary w-full"
|
|
||||||
>
|
>
|
||||||
{i18n.t("home.pricing.biz.cta")}
|
{/* Gradient background for popular card */}
|
||||||
</A>
|
<Show when={plan.popular}>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-b from-ink to-ink/95 rounded-card" />
|
||||||
|
</Show>
|
||||||
|
<Show when={!plan.popular}>
|
||||||
|
<div class="absolute inset-0 surface-elevated rounded-card" />
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Popular badge */}
|
||||||
|
<Show when={plan.popular}>
|
||||||
|
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
|
||||||
|
<span class="px-3 py-1 bg-accent text-white text-xs font-display font-medium rounded-full shadow-lg">
|
||||||
|
{i18n.t("home.pricing.popular")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="relative z-10">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class={`font-display text-lg font-semibold mb-1 ${plan.popular ? 'text-canvas' : 'text-ink'}`}>
|
||||||
|
{plan.name}
|
||||||
|
</h3>
|
||||||
|
<p class={plan.popular ? 'text-canvas/70' : 'text-ink-muted'}>{plan.desc}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-baseline gap-1">
|
||||||
|
<span class={`font-display text-4xl font-semibold ${plan.popular ? 'text-canvas' : 'text-ink'}`}>
|
||||||
|
{isYearly() ? plan.yearly : plan.monthly}
|
||||||
|
</span>
|
||||||
|
<span class={plan.popular ? 'text-canvas/60' : 'text-ink-muted'}>
|
||||||
|
{isYearly() ? plan.yearlyPeriod : plan.period}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Show when={isYearly()}>
|
||||||
|
<div class="mt-2 flex items-center gap-1.5">
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-accent-subtle text-accent text-xs font-semibold rounded-full border border-accent/10">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
|
||||||
|
{plan.savingsPercent}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-ink-subtle">{isCs() ? 'sleva při roční platbě' : 'discount on yearly billing'}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={!isYearly()}>
|
||||||
|
<p class="mt-1 text-xs text-accent font-medium">{plan.trial}</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-3 mb-8">
|
||||||
|
{plan.features.map((feature) => (
|
||||||
|
<li class={`flex items-start gap-3 ${plan.popular ? 'text-canvas/80' : 'text-ink-muted'}`}>
|
||||||
|
<span class="mt-0.5 text-accent shrink-0">
|
||||||
|
<CheckIcon />
|
||||||
|
</span>
|
||||||
|
<span class="text-sm">{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<A
|
||||||
|
href="/dashboard"
|
||||||
|
class={`block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 w-full ${plan.popular ? 'bg-canvas text-ink hover:bg-canvas-subtle shadow-lg group-hover:shadow-xl' : 'btn-secondary'}`}
|
||||||
|
>
|
||||||
|
{plan.cta}
|
||||||
|
</A>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Comparison Table */}
|
||||||
|
<section class="py-16 px-4">
|
||||||
|
<div class="section-container">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div class="text-center mb-10">
|
||||||
|
<span class="text-sm font-medium text-accent mb-2 block tracking-wide uppercase">
|
||||||
|
{i18n.t("pricing.compare.eyebrow")}
|
||||||
|
</span>
|
||||||
|
<h2 class="text-2xl sm:text-3xl font-display font-bold text-ink">
|
||||||
|
{i18n.t("pricing.compare.title")}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="surface-elevated rounded-card overflow-hidden border border-border/50 shadow-sm">
|
||||||
|
{/* Header */}
|
||||||
|
<div class="grid grid-cols-[1fr_100px_100px_100px] sm:grid-cols-[1.5fr_1fr_1fr_1fr] gap-2 p-4 sm:p-5 bg-canvas-subtle/60 border-b border-border/50">
|
||||||
|
<div class="text-sm font-semibold text-ink-muted self-center">{i18n.t("pricing.compare.feature")}</div>
|
||||||
|
<div class="text-center font-display font-semibold text-ink text-sm">Starter</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<span class="inline-block px-3 py-1 bg-accent/10 text-accent font-display font-semibold text-sm rounded-full">
|
||||||
|
Pro
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-center font-display font-semibold text-ink text-sm">Business</div>
|
||||||
|
</div>
|
||||||
|
{/* Rows */}
|
||||||
|
<For each={[
|
||||||
|
{ key: "pricing.compare.locations", starter: "1", pro: "3", business: "∞" },
|
||||||
|
{ key: "pricing.compare.staff", starter: "1", pro: "10", business: "∞" },
|
||||||
|
{ key: "pricing.compare.bookings", starter: "50", pro: "∞", business: "∞" },
|
||||||
|
{ key: "pricing.compare.emailSupport", starter: i18n.t("pricing.compare.yes"), pro: i18n.t("pricing.compare.priority"), business: i18n.t("pricing.compare.dedicated") },
|
||||||
|
{ key: "pricing.compare.reminders", starter: "no", pro: "yes", business: "yes" },
|
||||||
|
{ key: "pricing.compare.analytics", starter: "no", pro: "yes", business: i18n.t("pricing.compare.advanced") },
|
||||||
|
{ key: "pricing.compare.api", starter: "no", pro: "no", business: "yes" },
|
||||||
|
{ key: "pricing.compare.branding", starter: "no", pro: "yes", business: "yes" },
|
||||||
|
{ key: "pricing.compare.whiteLabel", starter: "no", pro: "no", business: "yes" },
|
||||||
|
{ key: "pricing.compare.manager", starter: "no", pro: "no", business: "yes" },
|
||||||
|
]}>
|
||||||
|
{(feature, i) => (
|
||||||
|
<div class={`grid grid-cols-[1fr_100px_100px_100px] sm:grid-cols-[1.5fr_1fr_1fr_1fr] gap-2 p-4 sm:p-5 items-center border-b border-border/30 last:border-0 transition-colors ${i() % 2 === 0 ? 'bg-canvas/30' : ''} hover:bg-canvas-subtle/40`}>
|
||||||
|
<span class="text-sm text-ink font-medium">{i18n.t(feature.key)}</span>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<ComparisonValue value={feature.starter} />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<ComparisonValue value={feature.pro} highlight />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<ComparisonValue value={feature.business} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -677,6 +1010,220 @@ export function HomeRoute() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,35 +3,98 @@ import { For } from "solid-js";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui";
|
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui";
|
||||||
import { BookraCharacter } from "../components/bookra-character";
|
import { BookraCharacter } from "../components/bookra-character";
|
||||||
import { useI18n } from "../providers/i18n-provider";
|
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() {
|
export function LegalRoute() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const kind = () => (params.kind === "terms" ? "terms" : "privacy");
|
const kind = () => (params.kind === "terms" ? "terms" : "privacy");
|
||||||
const heroPose = () => (kind() === "terms" ? "flag" : "educate");
|
const heroPose = () => (kind() === "terms" ? "flag" : "educate");
|
||||||
const helperPose = () => (kind() === "terms" ? "announcement" : "happy_note");
|
const isCs = () => i18n.locale() === "cs";
|
||||||
const sections = () =>
|
|
||||||
kind() === "terms"
|
const companyInfo = () =>
|
||||||
? [
|
isCs()
|
||||||
{
|
? "Provozovatel: Bookra, IČO 24330621. Sídlo: Česká republika."
|
||||||
title: i18n.t("legal.terms.service.title"),
|
: "Operator: Bookra, Business ID 24330621. Registered in the Czech Republic.";
|
||||||
body: i18n.t("legal.terms.service.body"),
|
|
||||||
},
|
const termsSections = () => [
|
||||||
{
|
{
|
||||||
title: i18n.t("legal.terms.billing.title"),
|
title: isCs() ? "1. Úvod a předmět smlouvy" : "1. Introduction and subject",
|
||||||
body: i18n.t("legal.terms.billing.body"),
|
body: isCs()
|
||||||
},
|
? "Tyto podmínky upravují používání služby Bookra — online rezervačního systému pro lokální služby. Poskytovatelem je Bookra , IČO 24330621. Službu mohou využívat podnikatelé a právnické osoby k správě rezervací, zákazníků a dostupnosti. Uživatel se zavazuje používat službu v souladu s právními předpisy, bez zneužívání rezervačních formulářů, obcházení zabezpečení nebo ukládání zakázaného obsahu."
|
||||||
]
|
: "These terms govern the use of Bookra — an online booking system for local services. The provider is Bookra , Business ID 24330621. Entrepreneurs and legal entities may use the service to manage bookings, customers, and availability. The user agrees to use the service lawfully, without abusing booking forms, bypassing security, or storing prohibited content.",
|
||||||
: [
|
},
|
||||||
{
|
{
|
||||||
title: i18n.t("legal.privacy.data.title"),
|
title: isCs() ? "2. Registrace a účet" : "2. Registration and account",
|
||||||
body: i18n.t("legal.privacy.data.body"),
|
body: isCs()
|
||||||
},
|
? "Pro plné využití služby je nutná registrace. Uživatel zodpovídá za správnost údajů uvedených při registraci a za bezpečnost přihlašovacích údajů. Provozovatel účtu odpovídá za správnost nabídky, dostupnost termínů a komunikaci se zákazníky. Bookra nezodpovídá za obsah rezervací a komunikaci mezi provozovatelem a zákazníkem."
|
||||||
{
|
: "Full use of the service requires registration. The user is responsible for the accuracy of registration details and the security of login credentials. The workspace operator is responsible for the accuracy of their offer, availability of times, and customer communication. Bookra is not liable for booking content or communication between operators and customers.",
|
||||||
title: i18n.t("legal.privacy.rights.title"),
|
},
|
||||||
body: i18n.t("legal.privacy.rights.body"),
|
{
|
||||||
},
|
title: isCs() ? "3. Předplatné a platby" : "3. Subscription and payments",
|
||||||
];
|
body: isCs()
|
||||||
|
? "Placené plány se účtují předem prostřednictvím platební brány Paddle nebo Stripe. Aktivní plán určuje dostupné limity, rozšíření a podpůrné funkce. Při roční platbě je poskytována sleva oproti měsíčnímu zúčtování. Uživatel může předplatné kdykoliv zrušit; přístup zůstává do konce zaplaceného období. Neposkytujeme refundace za již zaplacená období."
|
||||||
|
: "Paid plans are billed in advance through Paddle or Stripe. The active plan determines available limits, add-ons, and support features. Annual billing includes a discount compared to monthly billing. Users may cancel anytime; access continues until the end of the paid period. No refunds are provided for already-paid periods.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: isCs() ? "4. Odpovědnost a omezení" : "4. Liability and limitations",
|
||||||
|
body: isCs()
|
||||||
|
? "Bookra se snaží zajistit nepřetržitý provoz, ale nezaručuje 100% dostupnost. Neneseme odpovědnost za přímé ani nepřímé škody způsobené výpadkem služby, ztrátou dat způsobenou uživatelem nebo technickými problémy třetích stran. Doporučujeme pravidelnou zálohu důležitých dat."
|
||||||
|
: "Bookra strives to ensure uninterrupted service but does not guarantee 100% uptime. We are not liable for direct or indirect damages caused by service outages, data loss caused by the user, or technical issues from third parties. We recommend regular backups of important data.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: isCs() ? "5. Ukončení a výpověď" : "5. Termination",
|
||||||
|
body: isCs()
|
||||||
|
? "Uživatel může účet zrušit kdykoliv v nastavení. Při dlouhodobé neaktivitě (12 měsíců bez přihlášení) si vyhrazujeme právo účet deaktivovat po předchozím upozornění. Při porušení podmínek může být účet ukončen okamžitě."
|
||||||
|
: "Users may cancel their account anytime in settings. After prolonged inactivity (12 months without login), we reserve the right to deactivate the account after prior notice. Accounts violating these terms may be terminated immediately.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const privacySections = () => [
|
||||||
|
{
|
||||||
|
title: isCs() ? "1. Jaké údaje zpracováváme a proč" : "1. What data we process and why",
|
||||||
|
body: isCs()
|
||||||
|
? "Zpracováváme minimální množství dat nezbytných pro fungování služby: kontaktní údaje zákazníků (jméno, e-mail) pro potvrzení rezervace, čas rezervace a poznámky zadané při rezervaci, údaje o účtu provozovatele (e-mail, jméno) pro správu účtu, a technické záznamy (IP adresa, čas požadavku) pro zabezpečení. Údaje tenantů jsou oddělené a přístup k nim je omezen podle role uživatele."
|
||||||
|
: "We process the minimum data necessary for the service: customer contact details (name, email) for booking confirmation, booking times and notes, workspace account details (email, name) for account management, and technical records (IP address, request time) for security. Tenant data is isolated and access is limited by user role.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: isCs() ? "2. Cookies a sledování" : "2. Cookies and tracking",
|
||||||
|
body: isCs()
|
||||||
|
? "Bookra nepoužívá žádné sledovací cookies pro marketingové ani analytické účely. Jediné cookies, které ukládáme, jsou technicky nezbytné pro přihlášení a správu relace. Pro anonymní statistiky využíváme Rybbit — nástroj, který pracuje bez cookies a neukládá osobní údaje návštěvníků."
|
||||||
|
: "Bookra does not use any tracking cookies for marketing or analytics purposes. The only cookies we store are technically necessary for login and session management. For anonymous statistics, we use Rybbit — a tool that operates without cookies and does not store visitors' personal data.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: isCs() ? "3. Údaje registrovaných uživatelů" : "3. Registered user data",
|
||||||
|
body: isCs()
|
||||||
|
? "Při registraci shromažďujeme e-mailovou adresu a jméno uživatele. Tato data slouží výhradně k autentizaci, správě účtu a komunikaci ohledně služby (připomenutí, oznámení o změnách). Vaše data neprodáváme, nepronajímáme a nesdílíme s třetími stranami pro marketingové účely. Přístup mají pouze oprávnění zaměstnanci Bookry a to pouze v nezbytném rozsahu pro technickou podporu."
|
||||||
|
: "During registration, we collect the user's email address and name. This data is used solely for authentication, account management, and service-related communication (reminders, change notifications). We do not sell, rent, or share your data with third parties for marketing purposes. Only authorized Bookra employees have access, and only to the extent necessary for technical support.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: isCs() ? "4. Práva a žádosti" : "4. Rights and requests",
|
||||||
|
body: isCs()
|
||||||
|
? "V souladu s GDPR máte právo na přístup ke svým údajům, jejich opravu, výmaz nebo omezení zpracování. Žádosti o přístup, opravu nebo výmaz údajů řeší provozovatel konkrétního účtu. Bookra poskytuje technické prostředky pro bezpečné zpracování. Svá práva můžete uplatnit e-mailem na hello@bookra.eu."
|
||||||
|
: "In accordance with GDPR, you have the right to access, correct, delete, or restrict processing of your data. Access, correction, and deletion requests are handled by the operator of the relevant workspace. Bookra provides the technical system for secure processing. You may exercise your rights by emailing hello@bookra.eu.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: isCs() ? "5. Doba uchování a zabezpečení" : "5. Retention and security",
|
||||||
|
body: isCs()
|
||||||
|
? "Rezervační údaje uchováváme po dobu existence účtu provozovatele, pokud není smazány dříve. Technické záznamy uchováváme po dobu 90 dnů. Všechna data jsou přenášena šifrovaně (TLS), uchovávána v zabezpečených datových centrech v EU a pravidelně zálohována."
|
||||||
|
: "Booking data is retained for the lifetime of the operator's account unless deleted earlier. Technical records are kept for 90 days. All data is transmitted encrypted (TLS), stored in secure EU data centers, and regularly backed up.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const sections = () => (kind() === "terms" ? termsSections() : privacySections());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section class="section-container py-16">
|
<section class="section-container py-16">
|
||||||
@@ -42,44 +105,62 @@ export function LegalRoute() {
|
|||||||
{i18n.t(`legal.${kind()}.title`)}
|
{i18n.t(`legal.${kind()}.title`)}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-lg text-ink-muted">{i18n.t(`legal.${kind()}.body`)}</p>
|
<p class="text-lg text-ink-muted">{i18n.t(`legal.${kind()}.body`)}</p>
|
||||||
|
<p class="text-sm text-ink-subtle">{companyInfo()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<For each={sections()}>
|
<For each={sections()}>
|
||||||
{(section) => (
|
{(section, i) => (
|
||||||
<Card class="surface-elevated">
|
<Card class="surface-elevated hover:shadow-md transition-shadow">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{section.title}</CardTitle>
|
<CardTitle class="text-lg">{section.title}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p class="text-ink-muted">{section.body}</p>
|
<p class="text-ink-muted leading-relaxed">{section.body}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
|
<div class="pt-4 border-t border-border">
|
||||||
|
<p class="text-sm text-ink-subtle">
|
||||||
|
{isCs()
|
||||||
|
? "Poslední aktualizace: květen 2026. V případě dotazů nás kontaktujte na hello@bookra.eu."
|
||||||
|
: "Last updated: May 2026. For questions, contact us at hello@bookra.eu."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="lg:sticky lg:top-24">
|
<aside class="lg:sticky lg:top-24 h-fit space-y-6">
|
||||||
<Card class="surface-elevated overflow-hidden">
|
<Card class="surface-elevated overflow-hidden">
|
||||||
<CardContent class="p-8 text-center">
|
<CardContent class="p-8 text-center">
|
||||||
<div class="mb-6 flex justify-center">
|
<div class="mb-6 flex justify-center">
|
||||||
<BookraCharacter pose={heroPose()} size="xl" animate={true} />
|
<BookraCharacter pose={heroPose()} size="xl" animate={true} />
|
||||||
</div>
|
</div>
|
||||||
<img
|
<LegalLogo />
|
||||||
src="/bookra-illustrations/logo_text_vertical.svg"
|
|
||||||
alt="Bookra"
|
|
||||||
class="mx-auto mb-6 h-28 w-auto opacity-90"
|
|
||||||
/>
|
|
||||||
<p class="text-sm leading-relaxed text-ink-muted">
|
<p class="text-sm leading-relaxed text-ink-muted">
|
||||||
{kind() === "terms"
|
{kind() === "terms"
|
||||||
? i18n.locale() === "cs"
|
? isCs()
|
||||||
? "Pravidla držíme stručná, čitelná a navázaná na reálný provoz služby."
|
? "Pravidla držíme stručná, čitelná a navázaná na reálný provoz služby."
|
||||||
: "We keep terms short, readable, and tied to real product behavior."
|
: "We keep terms short, readable, and tied to real product behavior."
|
||||||
: i18n.locale() === "cs"
|
: isCs()
|
||||||
? "Soukromí řešíme prakticky: minimum dat navíc, jasný účel a předvídatelné zpracování."
|
? "Soukromí řešíme prakticky: minimum dat navíc, jasný účel a předvídatelné zpracování."
|
||||||
: "We handle privacy pragmatically: minimal extra data, clear purpose, and predictable processing."}
|
: "We handle privacy pragmatically: minimal extra data, clear purpose, and predictable processing."}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-6 flex justify-center">
|
</CardContent>
|
||||||
<BookraCharacter pose={helperPose()} size="sm" animate={true} />
|
</Card>
|
||||||
|
|
||||||
|
<Card class="surface-elevated">
|
||||||
|
<CardContent class="p-6">
|
||||||
|
<h3 class="font-display font-semibold text-ink text-sm mb-3">
|
||||||
|
{isCs() ? "Kontakt" : "Contact"}
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-2 text-sm text-ink-muted">
|
||||||
|
<p>Bookra </p>
|
||||||
|
<p>IČO: 24330621</p>
|
||||||
|
<p>Česká republika</p>
|
||||||
|
<a href="mailto:hello@bookra.eu" class="text-accent hover:underline block mt-2">
|
||||||
|
hello@bookra.eu
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import { createSignal, createMemo, Show, For } from "solid-js";
|
||||||
|
import { useNavigate } from "@solidjs/router";
|
||||||
|
import { useI18n } from "../providers/i18n-provider";
|
||||||
|
|
||||||
|
const PricingRoute = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { locale, toggleLocale } = useI18n();
|
||||||
|
const isCs = () => locale() === "cs";
|
||||||
|
const [billingInterval, setBillingInterval] = createSignal<"monthly" | "yearly">("monthly");
|
||||||
|
const isYearly = () => billingInterval() === "yearly";
|
||||||
|
const [openFaq, setOpenFaq] = createSignal<number | null>(0);
|
||||||
|
|
||||||
|
const plans = createMemo(() => [
|
||||||
|
{
|
||||||
|
id: "starter",
|
||||||
|
name: "Starter",
|
||||||
|
desc: isCs() ? "Pro jednotlivce a malé podniky" : "For individuals and small businesses",
|
||||||
|
monthly: isCs() ? "199 Kč" : "$9",
|
||||||
|
yearly: isCs() ? "1 990 Kč" : "$90",
|
||||||
|
period: isCs() ? "/měsíc" : "/mo",
|
||||||
|
yearlyPeriod: isCs() ? "/rok" : "/yr",
|
||||||
|
savings: isCs() ? "Ušetřete 398 Kč" : "Save $18",
|
||||||
|
savingsPercent: "17%",
|
||||||
|
trial: isCs() ? "15 dní zdarma po registraci" : "15 days free after sign-up",
|
||||||
|
features: [
|
||||||
|
isCs() ? "Do 50 rezervací/měsíc" : "Up to 50 bookings/month",
|
||||||
|
isCs() ? "1 lokace, 1 zaměstnanec" : "1 location, 1 staff member",
|
||||||
|
isCs() ? "E-mailová podpora" : "Email support",
|
||||||
|
isCs() ? "Základní rezervační widget" : "Basic booking widget",
|
||||||
|
isCs() ? "Potvrzení e-mailem" : "Email confirmations",
|
||||||
|
],
|
||||||
|
cta: isCs() ? "Začít zdarma" : "Start for free",
|
||||||
|
popular: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pro",
|
||||||
|
name: "Pro",
|
||||||
|
desc: isCs() ? "Pro rostoucí podniky" : "For growing businesses",
|
||||||
|
monthly: isCs() ? "399 Kč" : "$19",
|
||||||
|
yearly: isCs() ? "3 990 Kč" : "$190",
|
||||||
|
period: isCs() ? "/měsíc" : "/mo",
|
||||||
|
yearlyPeriod: isCs() ? "/rok" : "/yr",
|
||||||
|
savings: isCs() ? "Ušetřete 798 Kč" : "Save $38",
|
||||||
|
savingsPercent: "17%",
|
||||||
|
trial: isCs() ? "15 dní zdarma po registraci" : "15 days free after sign-up",
|
||||||
|
features: [
|
||||||
|
isCs() ? "Neomezené rezervace" : "Unlimited bookings",
|
||||||
|
isCs() ? "3 lokace, 10 zaměstnanců" : "3 locations, 10 staff",
|
||||||
|
isCs() ? "E-mailová připomenutí" : "Email reminders",
|
||||||
|
isCs() ? "Prioritní podpora" : "Priority support",
|
||||||
|
isCs() ? "Analytika a reporty" : "Analytics & reports",
|
||||||
|
isCs() ? "Vlastní branding widgetu" : "Custom widget branding",
|
||||||
|
isCs() ? "Rozšířené nastavení dostupnosti" : "Advanced availability",
|
||||||
|
],
|
||||||
|
cta: isCs() ? "Začít 15denní zkoušku" : "Start 15-day trial",
|
||||||
|
popular: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "business",
|
||||||
|
name: "Business",
|
||||||
|
desc: isCs() ? "Pro větší týmy a franšízy" : "For larger teams and franchises",
|
||||||
|
monthly: isCs() ? "799 Kč" : "$39",
|
||||||
|
yearly: isCs() ? "7 990 Kč" : "$390",
|
||||||
|
period: isCs() ? "/měsíc" : "/mo",
|
||||||
|
yearlyPeriod: isCs() ? "/rok" : "/yr",
|
||||||
|
savings: isCs() ? "Ušetřete 1 598 Kč" : "Save $78",
|
||||||
|
savingsPercent: "17%",
|
||||||
|
trial: "",
|
||||||
|
features: [
|
||||||
|
isCs() ? "Neomezené vše" : "Unlimited everything",
|
||||||
|
isCs() ? "Neomezené lokace a zaměstnanci" : "Unlimited locations & staff",
|
||||||
|
isCs() ? "API přístup" : "API access",
|
||||||
|
isCs() ? "Dedikovaný manažer" : "Dedicated manager",
|
||||||
|
isCs() ? "Bílý labeling" : "White labeling",
|
||||||
|
isCs() ? "Pokročilá analytika" : "Advanced analytics",
|
||||||
|
isCs() ? "Integrace s externími systémy" : "External system integrations",
|
||||||
|
],
|
||||||
|
cta: isCs() ? "Kontaktovat nás" : "Contact us",
|
||||||
|
popular: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleSelectPlan = (planId: string) => {
|
||||||
|
// Redirect to signup with plan selection
|
||||||
|
navigate("/?signup=true&plan=" + planId + "&billing=" + billingInterval());
|
||||||
|
};
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const comparisonFeatures = [
|
||||||
|
{ key: "pricing.compare.locations", starter: "1", pro: "3", business: "∞" },
|
||||||
|
{ key: "pricing.compare.staff", starter: "1", pro: "10", business: "∞" },
|
||||||
|
{ key: "pricing.compare.bookings", starter: "50", pro: "∞", business: "∞" },
|
||||||
|
{ key: "pricing.compare.emailSupport", starter: t("pricing.compare.yes"), pro: t("pricing.compare.priority"), business: t("pricing.compare.dedicated") },
|
||||||
|
{ key: "pricing.compare.reminders", starter: "no", pro: "yes", business: "yes" },
|
||||||
|
{ key: "pricing.compare.analytics", starter: "no", pro: "yes", business: t("pricing.compare.advanced") },
|
||||||
|
{ key: "pricing.compare.api", starter: "no", pro: "no", business: "yes" },
|
||||||
|
{ key: "pricing.compare.branding", starter: "no", pro: "yes", business: "yes" },
|
||||||
|
{ key: "pricing.compare.whiteLabel", starter: "no", pro: "no", business: "yes" },
|
||||||
|
{ key: "pricing.compare.manager", starter: "no", pro: "no", business: "yes" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ComparisonValue = (props: { value: string; highlight?: boolean }) => {
|
||||||
|
const v = props.value.toLowerCase();
|
||||||
|
if (v === "yes" || v === t("pricing.compare.yes").toLowerCase()) {
|
||||||
|
return (
|
||||||
|
<span class={`inline-flex items-center justify-center w-6 h-6 rounded-full ${props.highlight ? 'bg-accent/15 text-accent' : 'bg-success/15 text-success'}`}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 6 9 17l-5-5"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (v === "no" || v === t("pricing.compare.no").toLowerCase()) {
|
||||||
|
return (
|
||||||
|
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-ink/5 text-ink-muted">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M18 6 6 18"/>
|
||||||
|
<path d="m6 6 12 12"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span class={`text-sm font-medium ${props.highlight ? 'text-accent' : 'text-ink'}`}>
|
||||||
|
{props.value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const faqs = [
|
||||||
|
{
|
||||||
|
enQ: "Can I cancel anytime?",
|
||||||
|
csQ: "Mohu kdykoliv zrušit?",
|
||||||
|
enA: "Yes, you can cancel anytime. If you have an annual subscription, you'll have access until the end of the period. No cancellation fees.",
|
||||||
|
csA: "Ano, můžete zrušit kdykoliv. Pokud máte roční předplatné, budete mít přístup do konce období. Žádné poplatky za zrušení.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enQ: "What if I exceed my limits?",
|
||||||
|
csQ: "Co když překročím limity?",
|
||||||
|
enA: "You'll be notified and prompted to upgrade to a higher plan. Your data will be preserved and you can continue using Bookra seamlessly.",
|
||||||
|
csA: "Budete upozorněni a vyzváni k upgradu na vyšší plán. Vaše data zůstanou zachována a můžete dál používat Bookra bez přerušení.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enQ: "What payment methods do you accept?",
|
||||||
|
csQ: "Jaké platební metody přijímáte?",
|
||||||
|
enA: "We accept all major credit cards through Stripe. Payments are secure, encrypted, and PCI compliant.",
|
||||||
|
csA: "Přijímáme všechny hlavní kreditní karty přes Stripe. Platby jsou zabezpečené, šifrované a splňují PCI standard.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enQ: "Can I switch plans?",
|
||||||
|
csQ: "Můžu změnit plán?",
|
||||||
|
enA: "Yes, you can upgrade or downgrade at any time. When upgrading, we'll prorate the difference. When downgrading, the new rate applies at the next billing cycle.",
|
||||||
|
csA: "Ano, můžete kdykoliv upgradovat nebo downgradovat. Při upgradu doplatíte poměrnou částku. Při downgradu se nová cena aplikuje od dalšího fakturačního období.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enQ: "Is there a free trial?",
|
||||||
|
csQ: "Je k dispozici bezplatná zkouška?",
|
||||||
|
enA: "Yes, every plan includes a 15-day free trial. No credit card required to start.",
|
||||||
|
csA: "Ano, každý plán obsahuje 15denní bezplatnou zkoušku. Není potřeba zadávat platební kartu.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enQ: "Do you offer support?",
|
||||||
|
csQ: "Poskytujete podporu?",
|
||||||
|
enA: "Absolutely. Starter includes email support, Pro gets priority support, and Business includes a dedicated account manager.",
|
||||||
|
csA: "Samozřejmě. Starter obsahuje e-mailovou podporu, Pro má prioritní podporu a Business zahrnuje dedikovaného account managera.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="min-h-screen bg-gradient-to-br from-canvas-subtle via-canvas to-canvas-subtle">
|
||||||
|
{/* Hero */}
|
||||||
|
<section class="pt-16 pb-12 sm:pt-24 sm:pb-16 text-center px-4">
|
||||||
|
<h1 class="text-4xl sm:text-5xl font-display font-bold text-ink mb-4 animate-slide-up">
|
||||||
|
{isCs() ? "Jednoduché a férové ceny" : "Simple, fair pricing"}
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg text-ink-muted max-w-2xl mx-auto mb-8 animate-slide-up" style={{ "animation-delay": "0.1s" }}>
|
||||||
|
{isCs()
|
||||||
|
? "Vyberte si plán, který vyhovuje vašemu podnikání. Žádné skryté poplatky, žádné překvapení."
|
||||||
|
: "Choose a plan that fits your business. No hidden fees, no surprises."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Billing Toggle */}
|
||||||
|
<div class="flex items-center justify-center mb-8 animate-slide-up" style={{ "animation-delay": "0.2s" }}>
|
||||||
|
<div class="relative inline-flex items-center gap-4">
|
||||||
|
<span class={`text-sm font-semibold transition-colors ${!isYearly() ? 'text-ink' : 'text-ink-muted'}`}>
|
||||||
|
{isCs() ? "Měsíčně" : "Monthly"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBillingInterval(isYearly() ? "monthly" : "yearly")}
|
||||||
|
class={`relative w-14 h-7 rounded-full transition-colors duration-300 cursor-pointer ${isYearly() ? 'bg-accent' : 'bg-ink/30'}`}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={isYearly()}
|
||||||
|
>
|
||||||
|
<span class={`absolute top-1 left-1 w-5 h-5 bg-white rounded-full shadow-md transition-transform duration-300 ${isYearly() ? 'translate-x-7' : 'translate-x-0'}`} />
|
||||||
|
</button>
|
||||||
|
<span class={`text-sm font-semibold transition-colors ${isYearly() ? 'text-ink' : 'text-ink-muted'}`}>
|
||||||
|
{isCs() ? "Ročně" : "Yearly"}
|
||||||
|
</span>
|
||||||
|
<div class="absolute left-full ml-3 top-1/2 -translate-y-1/2">
|
||||||
|
<Show when={isYearly()}>
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-accent text-white text-xs font-bold rounded-full shadow-sm whitespace-nowrap">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
|
||||||
|
-17%
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Pricing Cards */}
|
||||||
|
<section class="pb-16 px-4">
|
||||||
|
<div class="grid md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto items-center">
|
||||||
|
<For each={plans()}>
|
||||||
|
{(plan, index) => (
|
||||||
|
<div
|
||||||
|
class={`group relative p-6 lg:p-8 rounded-card transition-all duration-500 animate-slide-up ${plan.popular ? 'hover:shadow-2xl hover:-translate-y-2 lg:scale-105' : 'hover:shadow-xl hover:-translate-y-1'}`}
|
||||||
|
style={{ "animation-delay": `${0.3 + index() * 0.1}s` }}
|
||||||
|
>
|
||||||
|
<Show when={plan.popular}>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-b from-ink to-ink/95 rounded-card" />
|
||||||
|
</Show>
|
||||||
|
<Show when={!plan.popular}>
|
||||||
|
<div class="absolute inset-0 surface-elevated rounded-card" />
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={plan.popular}>
|
||||||
|
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
|
||||||
|
<span class="px-3 py-1 bg-accent text-white text-xs font-display font-medium rounded-full shadow-lg">
|
||||||
|
{t("home.pricing.popular")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="relative z-10">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class={`font-display text-lg font-semibold mb-1 ${plan.popular ? 'text-canvas' : 'text-ink'}`}>
|
||||||
|
{plan.name}
|
||||||
|
</h3>
|
||||||
|
<p class={plan.popular ? 'text-canvas/70' : 'text-ink-muted'}>{plan.desc}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-baseline gap-1">
|
||||||
|
<span class={`font-display text-4xl font-semibold ${plan.popular ? 'text-canvas' : 'text-ink'}`}>
|
||||||
|
{isYearly() ? plan.yearly : plan.monthly}
|
||||||
|
</span>
|
||||||
|
<span class={plan.popular ? 'text-canvas/60' : 'text-ink-muted'}>
|
||||||
|
{isYearly() ? plan.yearlyPeriod : plan.period}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Show when={isYearly()}>
|
||||||
|
<div class="mt-2 flex items-center gap-1.5">
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-accent-subtle text-accent text-xs font-semibold rounded-full border border-accent/10">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
|
||||||
|
{plan.savingsPercent}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-ink-subtle">{isCs() ? 'sleva při roční platbě' : 'discount on yearly billing'}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={!isYearly() && plan.trial}>
|
||||||
|
<p class="mt-1 text-xs text-accent font-medium">{plan.trial}</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-3 mb-8">
|
||||||
|
<For each={plan.features}>
|
||||||
|
{(feature) => (
|
||||||
|
<li class={`flex items-start gap-3 ${plan.popular ? 'text-canvas/80' : 'text-ink-muted'}`}>
|
||||||
|
<span class="mt-0.5 text-accent shrink-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 6 9 17l-5-5"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm">{feature}</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelectPlan(plan.id)}
|
||||||
|
class={`w-full py-3 px-4 rounded-xl font-semibold text-sm transition-all duration-300 ${
|
||||||
|
plan.popular
|
||||||
|
? 'bg-accent text-white hover:bg-accent/90 hover:shadow-lg hover:-translate-y-0.5'
|
||||||
|
: 'bg-ink text-canvas hover:bg-ink/90 hover:shadow-lg hover:-translate-y-0.5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{plan.cta}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Comparison Table */}
|
||||||
|
<section class="py-16 px-4">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div class="text-center mb-10">
|
||||||
|
<span class="text-sm font-medium text-accent mb-2 block tracking-wide uppercase">
|
||||||
|
{isCs() ? "Detailní srovnání" : "Detailed comparison"}
|
||||||
|
</span>
|
||||||
|
<h2 class="text-2xl sm:text-3xl font-display font-bold text-ink">
|
||||||
|
{isCs() ? "Porovnání plánů" : "Compare plans"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="surface-elevated rounded-card overflow-hidden border border-border/50 shadow-sm">
|
||||||
|
{/* Header */}
|
||||||
|
<div class="grid grid-cols-[1fr_100px_100px_100px] sm:grid-cols-[1.5fr_1fr_1fr_1fr] gap-2 p-4 sm:p-5 bg-canvas-subtle/60 border-b border-border/50">
|
||||||
|
<div class="text-sm font-semibold text-ink-muted self-center">{isCs() ? "Funkce" : "Feature"}</div>
|
||||||
|
<div class="text-center font-display font-semibold text-ink text-sm">Starter</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<span class="inline-block px-3 py-1 bg-accent/10 text-accent font-display font-semibold text-sm rounded-full">
|
||||||
|
Pro
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-center font-display font-semibold text-ink text-sm">Business</div>
|
||||||
|
</div>
|
||||||
|
{/* Rows */}
|
||||||
|
<For each={comparisonFeatures}>
|
||||||
|
{(feature, i) => (
|
||||||
|
<div class={`grid grid-cols-[1fr_100px_100px_100px] sm:grid-cols-[1.5fr_1fr_1fr_1fr] gap-2 p-4 sm:p-5 items-center border-b border-border/30 last:border-0 transition-colors ${i() % 2 === 0 ? 'bg-canvas/30' : ''} hover:bg-canvas-subtle/40`}>
|
||||||
|
<span class="text-sm text-ink font-medium">{t(feature.key)}</span>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<ComparisonValue value={feature.starter} />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<ComparisonValue value={feature.pro} highlight />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<ComparisonValue value={feature.business} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ */}
|
||||||
|
<section class="py-16 px-4 bg-canvas-subtle/30">
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div class="text-center mb-10">
|
||||||
|
<span class="text-sm font-medium text-accent mb-2 block tracking-wide uppercase">
|
||||||
|
{isCs() ? "Máte otázky?" : "Got questions?"}
|
||||||
|
</span>
|
||||||
|
<h2 class="text-2xl sm:text-3xl font-display font-bold text-ink">
|
||||||
|
{isCs() ? "Časté dotazy" : "Frequently asked questions"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<For each={faqs}>
|
||||||
|
{(faq, i) => (
|
||||||
|
<div class="surface-elevated rounded-card border border-border/40 overflow-hidden transition-all duration-300 hover:border-border/70 hover:shadow-md">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenFaq(openFaq() === i() ? null : i())}
|
||||||
|
class="w-full flex items-center justify-between p-5 text-left group"
|
||||||
|
>
|
||||||
|
<span class="font-semibold text-ink text-sm sm:text-base pr-4 group-hover:text-accent transition-colors">
|
||||||
|
{isCs() ? faq.csQ : faq.enQ}
|
||||||
|
</span>
|
||||||
|
<span class={`shrink-0 w-8 h-8 rounded-full bg-canvas-subtle flex items-center justify-center text-ink-muted group-hover:bg-accent/10 group-hover:text-accent transition-all duration-300 ${openFaq() === i() ? 'rotate-180' : ''}`}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m6 9 6 6 6-6"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div class={`grid transition-all duration-300 ${openFaq() === i() ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'}`}>
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<div class="px-5 pb-5 text-sm text-ink-muted leading-relaxed border-t border-border/30 pt-4">
|
||||||
|
{isCs() ? faq.csA : faq.enA}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section class="py-16 px-4">
|
||||||
|
<div class="max-w-2xl mx-auto text-center">
|
||||||
|
<h2 class="text-2xl font-display font-bold text-ink mb-4">
|
||||||
|
{isCs() ? "Stále si nejste jistí?" : "Still not sure?"}
|
||||||
|
</h2>
|
||||||
|
<p class="text-ink-muted mb-6">
|
||||||
|
{isCs()
|
||||||
|
? "Začněte s bezplatným 15denním trial a rozhodněte se později."
|
||||||
|
: "Start with a free 15-day trial and decide later."}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/dashboard?signup=true"
|
||||||
|
class="inline-block px-8 py-3 bg-accent text-white font-semibold rounded-xl hover:bg-accent/90 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300"
|
||||||
|
>
|
||||||
|
{isCs() ? "Začít zdarma" : "Start for free"}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer class="border-t border-border py-8 px-4">
|
||||||
|
<div class="max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-6 h-6 rounded bg-gradient-to-br from-accent to-accent/70 flex items-center justify-center">
|
||||||
|
<span class="text-white font-bold text-xs">B</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-ink-muted text-sm">© 2024 Bookra</span>
|
||||||
|
</div>
|
||||||
|
<nav class="flex items-center gap-6">
|
||||||
|
<a href="/privacy" class="text-ink-muted hover:text-ink text-sm transition-colors">{isCs() ? "Ochrana soukromí" : "Privacy"}</a>
|
||||||
|
<a href="/terms" class="text-ink-muted hover:text-ink text-sm transition-colors">{isCs() ? "Podmínky" : "Terms"}</a>
|
||||||
|
<a href="/contact" class="text-ink-muted hover:text-ink text-sm transition-colors">{isCs() ? "Kontakt" : "Contact"}</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PricingRoute;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user