clavitor.ai: oauth, onboarding flow, admin, templates, css
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
659caa20b8
commit
aada1c6a4e
|
|
@ -17,6 +17,7 @@ import (
|
|||
)
|
||||
|
||||
var db *sql.DB
|
||||
var isSandboxMode bool
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
|
|
@ -26,6 +27,14 @@ func main() {
|
|||
}
|
||||
defer db.Close()
|
||||
|
||||
// Detect sandbox mode by IP
|
||||
isSandboxMode = !isProductionServer()
|
||||
if isSandboxMode {
|
||||
fmt.Println("🧪 SANDBOX MODE DETECTED (non-production IP)")
|
||||
} else {
|
||||
fmt.Println("🏭 PRODUCTION MODE (running on clavitor.ai)")
|
||||
}
|
||||
|
||||
initDB()
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
|
@ -114,9 +123,9 @@ func handleDynamicHome(w http.ResponseWriter, r *http.Request) {
|
|||
html := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Clavitor Dynamic Admin</title>
|
||||
<title>Clavitor Dynamic Admin{{if .IsSandbox}} [SANDBOX]{{end}}</title>
|
||||
<style>
|
||||
body { font-family: system-ui; max-width: 1400px; margin: 0 auto; padding: 20px; }
|
||||
body { font-family: system-ui; max-width: 1400px; margin: 0 auto; padding: 20px; {{if .IsSandbox}}background: #fff5f5;{{end}} }
|
||||
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
||||
nav a { color: white; margin-right: 20px; text-decoration: none; font-weight: 500; }
|
||||
h1 { margin-bottom: 10px; }
|
||||
|
|
@ -129,15 +138,28 @@ h1 { margin-bottom: 10px; }
|
|||
.card .actions { margin-top: 15px; }
|
||||
.card a { color: #0066cc; text-decoration: none; font-size: 14px; }
|
||||
.card a:hover { text-decoration: underline; }
|
||||
.sandbox-banner { background: #dc3545; color: white; text-align: center; padding: 10px; margin: -20px -20px 20px -20px; font-weight: bold; font-size: 16px; }
|
||||
.sandbox-banner small { font-weight: normal; opacity: 0.9; }
|
||||
|
||||
/* Pricing section */
|
||||
.pricing { margin: 40px 0; background: #f0f7ff; border-radius: 8px; padding: 25px; }
|
||||
.pricing h2 { margin-top: 0; color: #0066cc; }
|
||||
.price-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; margin-top: 20px; }
|
||||
.price-card { background: white; border: 1px solid #dee2e6; border-radius: 8px; padding: 15px; }
|
||||
.price-card h4 { margin: 0 0 8px 0; color: #333; }
|
||||
.price-card .price { font-size: 24px; font-weight: bold; color: #0066cc; }
|
||||
.price-card .period { color: #666; font-size: 12px; }
|
||||
.price-card .features { color: #666; font-size: 13px; margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{if .IsSandbox}}<div class="sandbox-banner">🧪 SANDBOX MODE <small>Test environment - not production</small></div>{{end}}
|
||||
<nav>
|
||||
<a href="/">Dashboard</a>
|
||||
{{range .Tables}}<a href="/table/{{.}}">{{.}}</a>{{end}}
|
||||
</nav>
|
||||
<h1>Clavitor Corporate Admin</h1>
|
||||
<p class="subtitle">Auto-generated from SQLite schema • 95% Paddle + 5% Extensions</p>
|
||||
<h1>Clavitor Corporate Admin{{if .IsSandbox}} <span style="color:#dc3545">[SANDBOX]</span>{{end}}</h1>
|
||||
<p class="subtitle">Auto-generated from SQLite schema • 95% Paddle + 5% Extensions{{if .IsSandbox}} • <strong style="color:#dc3545">TEST ENVIRONMENT</strong>{{end}}</p>
|
||||
|
||||
<div class="tables">
|
||||
{{range .Cards}}
|
||||
|
|
@ -152,6 +174,20 @@ h1 { margin-bottom: 10px; }
|
|||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- USD Pricing Section -->
|
||||
<div class="pricing">
|
||||
<h2>💰 USD Pricing (from database)</h2>
|
||||
<div class="price-grid">
|
||||
{{range .Prices}}
|
||||
<div class="price-card">
|
||||
<h4>{{.ProductName}}</h4>
|
||||
<div class="price">{{.Amount}}<span style="font-size:14px;font-weight:normal"> {{.Currency}}</span></div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
|
|
@ -167,10 +203,56 @@ h1 { margin-bottom: 10px; }
|
|||
cards = append(cards, Card{Name: t, Count: count})
|
||||
}
|
||||
|
||||
// Fetch USD prices from database (Paddle JSON format)
|
||||
type PriceInfo struct {
|
||||
ProductName string
|
||||
Amount string
|
||||
Currency string
|
||||
}
|
||||
var prices []PriceInfo
|
||||
rows, err := db.Query(`
|
||||
SELECT p.name, pr.unit_price
|
||||
FROM products p
|
||||
JOIN prices pr ON p.id = pr.product_id
|
||||
WHERE pr.unit_price LIKE '%USD%'
|
||||
ORDER BY p.name
|
||||
`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var name, unitPrice string
|
||||
rows.Scan(&name, &unitPrice)
|
||||
// Parse Paddle JSON: {"amount": "1200", "currency_code": "USD"}
|
||||
amount := "0"
|
||||
currency := "USD"
|
||||
if idx := strings.Index(unitPrice, `"amount":`); idx >= 0 {
|
||||
after := unitPrice[idx+9:]
|
||||
if q1 := strings.Index(after, `"`); q1 >= 0 {
|
||||
valStart := q1 + 1
|
||||
if q2 := strings.Index(after[valStart:], `"`); q2 >= 0 {
|
||||
amount = after[valStart : valStart+q2]
|
||||
}
|
||||
}
|
||||
}
|
||||
if idx := strings.Index(unitPrice, `"currency_code":`); idx >= 0 {
|
||||
after := unitPrice[idx+17:]
|
||||
if q1 := strings.Index(after, `"`); q1 >= 0 {
|
||||
valStart := q1 + 1
|
||||
if q2 := strings.Index(after[valStart:], `"`); q2 >= 0 {
|
||||
currency = after[valStart : valStart+q2]
|
||||
}
|
||||
}
|
||||
}
|
||||
prices = append(prices, PriceInfo{ProductName: name, Amount: amount, Currency: currency})
|
||||
}
|
||||
}
|
||||
|
||||
tmpl := template.Must(template.New("home").Parse(html))
|
||||
tmpl.Execute(w, map[string]interface{}{
|
||||
"Tables": tables,
|
||||
"Cards": cards,
|
||||
"Prices": prices,
|
||||
"IsSandbox": isSandboxMode,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -222,9 +304,9 @@ func handleDynamicList(w http.ResponseWriter, r *http.Request) {
|
|||
htmlTemplate := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Table}} - List</title>
|
||||
<title>{{.Table}} - List{{if .IsSandbox}} [SANDBOX]{{end}}</title>
|
||||
<style>
|
||||
body { font-family: system-ui; max-width: 1600px; margin: 0 auto; padding: 20px; }
|
||||
body { font-family: system-ui; max-width: 1600px; margin: 0 auto; padding: 20px; {{if .IsSandbox}}background: #fff5f5;{{end}} }
|
||||
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
||||
nav a { color: white; margin-right: 20px; text-decoration: none; font-weight: 500; }
|
||||
h1 { text-transform: capitalize; }
|
||||
|
|
@ -235,9 +317,11 @@ tr:hover { background: #f8f9fa; }
|
|||
.id { font-family: monospace; font-size: 11px; max-width: 150px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.btn { display: inline-block; padding: 4px 12px; background: #0066cc; color: white; text-decoration: none; border-radius: 4px; font-size: 12px; }
|
||||
.null { color: #999; font-style: italic; }
|
||||
.sandbox-banner { background: #dc3545; color: white; text-align: center; padding: 10px; margin: -20px -20px 20px -20px; font-weight: bold; font-size: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{if .IsSandbox}}<div class="sandbox-banner">🧪 SANDBOX MODE - Test environment</div>{{end}}
|
||||
<nav>
|
||||
<a href="/">Dashboard</a>
|
||||
{{range .Tables}}<a href="/table/{{.}}">{{.}}</a>{{end}}
|
||||
|
|
@ -250,15 +334,15 @@ tr:hover { background: #f8f9fa; }
|
|||
{{range .Columns}}<th>{{.Name}}<br><small style="color:#999;font-weight:normal">{{.Type}}{{if .PK}} 🔑{{end}}</small></th>{{end}}
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
{{range .Records}}
|
||||
{{range $record := .Records}}
|
||||
<tr>
|
||||
{{range $.Columns}}
|
||||
<td class="{{if eq .Type "TEXT"}}id{{end}}">
|
||||
{{$val := index $.Record .Name}}
|
||||
{{if eq $val ""}}<span class="null">null</span>{{else}}{{html $val}}{{end}}
|
||||
{{$val := index $record .Name}}
|
||||
{{if eq $val ""}}<span class="null">null</span>{{else}}{{$val}}{{end}}
|
||||
</td>
|
||||
{{end}}
|
||||
<td><a href="/table/{{$.Table}}/{{index . $.PK}}" class="btn">View</a></td>
|
||||
<td><a href="/table/{{$.Table}}/{{index $record $.PK}}" class="btn">View</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
|
|
@ -283,6 +367,7 @@ tr:hover { background: #f8f9fa; }
|
|||
"Columns": cols,
|
||||
"Records": records,
|
||||
"PK": pk,
|
||||
"IsSandbox": isSandboxMode,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -298,9 +383,9 @@ func handleDynamicForm(w http.ResponseWriter, r *http.Request) {
|
|||
htmlTemplate := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Table}} - New</title>
|
||||
<title>{{.Table}} - New{{if .IsSandbox}} [SANDBOX]{{end}}</title>
|
||||
<style>
|
||||
body { font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px; }
|
||||
body { font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px; {{if .IsSandbox}}background: #fff5f5;{{end}} }
|
||||
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
||||
nav a { color: white; margin-right: 20px; text-decoration: none; font-weight: 500; }
|
||||
h1 { text-transform: capitalize; }
|
||||
|
|
@ -311,9 +396,11 @@ input, select, textarea { width: 100%; padding: 10px; font-size: 14px; border: 1
|
|||
input:focus, select:focus, textarea:focus { border-color: #0066cc; outline: none; }
|
||||
button { margin-top: 25px; padding: 12px 30px; font-size: 16px; background: #0066cc; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||
button:hover { background: #0052a3; }
|
||||
.sandbox-banner { background: #dc3545; color: white; text-align: center; padding: 10px; margin: -20px -20px 20px -20px; font-weight: bold; font-size: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{if .IsSandbox}}<div class="sandbox-banner">🧪 SANDBOX MODE - Test environment</div>{{end}}
|
||||
<nav>
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/table/{{.Table}}">{{.Table}}</a>
|
||||
|
|
@ -348,10 +435,11 @@ button:hover { background: #0052a3; }
|
|||
"Table": table,
|
||||
"Tables": tables,
|
||||
"Columns": cols,
|
||||
"IsSandbox": isSandboxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle create
|
||||
// Handle create - with Dual-Write Layer for Paddle entities
|
||||
func handleDynamicCreate(w http.ResponseWriter, r *http.Request) {
|
||||
table := chi.URLParam(r, "table")
|
||||
cols := getTableInfo(table)
|
||||
|
|
@ -365,24 +453,54 @@ func handleDynamicCreate(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Build INSERT
|
||||
// Build data map from form values
|
||||
data := make(map[string]interface{})
|
||||
for _, col := range cols {
|
||||
if col.PK && col.Type == "INTEGER" {
|
||||
continue // Auto-increment
|
||||
}
|
||||
val := r.FormValue(col.Name)
|
||||
if val != "" {
|
||||
data[col.Name] = val
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a Paddle-managed entity
|
||||
if _, ok := EntityRegistry[table]; ok {
|
||||
// Use Dual-Write Layer (auto-detects sandbox mode by IP)
|
||||
sync := NewSyncLayer("", db)
|
||||
result := sync.Create(table, data)
|
||||
|
||||
if !result.Success {
|
||||
http.Error(w, fmt.Sprintf("Sync failed: DB: %v, Paddle: %v", result.DBError, result.PaddleError), 500)
|
||||
return
|
||||
}
|
||||
|
||||
// Log the sync result
|
||||
log.Printf("Created %s: our_id=%s, paddle_id=%s", table, result.OurID, result.PaddleID)
|
||||
http.Redirect(w, r, "/table/"+table, 302)
|
||||
return
|
||||
}
|
||||
|
||||
// Non-Paddle entity: direct SQL insert
|
||||
var colNames []string
|
||||
var placeholders []string
|
||||
var values []interface{}
|
||||
|
||||
for _, col := range cols {
|
||||
if col.PK && col.Type == "INTEGER" {
|
||||
// Auto-increment, skip
|
||||
continue
|
||||
}
|
||||
val := r.FormValue(col.Name)
|
||||
if val == "" && col.Default != nil {
|
||||
val = fmt.Sprintf("%v", col.Default)
|
||||
}
|
||||
if val != "" {
|
||||
colNames = append(colNames, col.Name)
|
||||
placeholders = append(placeholders, "?")
|
||||
values = append(values, val)
|
||||
}
|
||||
}
|
||||
|
||||
// Add timestamps
|
||||
now := time.Now().Unix()
|
||||
|
|
@ -465,9 +583,9 @@ func handleDynamicView(w http.ResponseWriter, r *http.Request) {
|
|||
htmlTemplate := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Table}} - {{.ID}}</title>
|
||||
<title>{{.Table}} - {{.ID}}{{if .IsSandbox}} [SANDBOX]{{end}}</title>
|
||||
<style>
|
||||
body { font-family: system-ui; max-width: 800px; margin: 50px auto; padding: 20px; }
|
||||
body { font-family: system-ui; max-width: 800px; margin: 50px auto; padding: 20px; {{if .IsSandbox}}background: #fff5f5;{{end}} }
|
||||
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
||||
nav a { color: white; margin-right: 20px; text-decoration: none; font-weight: 500; }
|
||||
h1 { text-transform: capitalize; }
|
||||
|
|
@ -478,9 +596,11 @@ td:first-child { font-weight: 500; width: 200px; color: #666; }
|
|||
td:last-child { font-family: monospace; font-size: 13px; }
|
||||
.null { color: #999; font-style: italic; }
|
||||
.btn { display: inline-block; padding: 8px 16px; background: #0066cc; color: white; text-decoration: none; border-radius: 4px; font-size: 14px; margin-right: 10px; }
|
||||
.sandbox-banner { background: #dc3545; color: white; text-align: center; padding: 10px; margin: -20px -20px 20px -20px; font-weight: bold; font-size: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{if .IsSandbox}}<div class="sandbox-banner">🧪 SANDBOX MODE - Test environment</div>{{end}}
|
||||
<nav>
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/table/{{.Table}}">{{.Table}}</a>
|
||||
|
|
@ -499,7 +619,7 @@ td:last-child { font-family: monospace; font-size: 13px; }
|
|||
<td>{{.Name}} {{if .PK}}🔑{{end}}</td>
|
||||
<td>
|
||||
{{$val := index $.Record .Name}}
|
||||
{{if eq $val ""}}<span class="null">null</span>{{else}}{{html $val}}{{end}}
|
||||
{{if eq $val ""}}<span class="null">null</span>{{else}}{{$val}}{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
|
@ -515,6 +635,7 @@ td:last-child { font-family: monospace; font-size: 13px; }
|
|||
"Tables": tables,
|
||||
"Columns": cols,
|
||||
"Record": record,
|
||||
"IsSandbox": isSandboxMode,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -568,9 +689,9 @@ func handleDynamicEdit(w http.ResponseWriter, r *http.Request) {
|
|||
htmlTemplate := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Table}} - Edit {{.ID}}</title>
|
||||
<title>{{.Table}} - Edit {{.ID}}{{if .IsSandbox}} [SANDBOX]{{end}}</title>
|
||||
<style>
|
||||
body { font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px; }
|
||||
body { font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px; {{if .IsSandbox}}background: #fff5f5;{{end}} }
|
||||
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
||||
nav a { color: white; margin-right: 20px; text-decoration: none; font-weight: 500; }
|
||||
h1 { text-transform: capitalize; }
|
||||
|
|
@ -578,9 +699,11 @@ form { background: #f8f9fa; padding: 25px; border-radius: 8px; }
|
|||
label { display: block; margin: 20px 0 5px 0; font-weight: 500; font-size: 14px; }
|
||||
input, select, textarea { width: 100%; padding: 10px; font-size: 14px; border: 1px solid #ccc; border-radius: 4px; }
|
||||
button { margin-top: 25px; padding: 12px 30px; font-size: 16px; background: #0066cc; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||
.sandbox-banner { background: #dc3545; color: white; text-align: center; padding: 10px; margin: -20px -20px 20px -20px; font-weight: bold; font-size: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{if .IsSandbox}}<div class="sandbox-banner">🧪 SANDBOX MODE - Test environment</div>{{end}}
|
||||
<nav>
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/table/{{.Table}}">{{.Table}}</a>
|
||||
|
|
@ -594,11 +717,11 @@ button { margin-top: 25px; padding: 12px 30px; font-size: 16px; background: #006
|
|||
<label>
|
||||
{{.Name}} {{if .PK}}🔑 <small>(readonly)</small>{{end}}
|
||||
{{if .PK}}
|
||||
<input type="text" value="{{html $val}}" disabled>
|
||||
<input type="text" value="{{$val}}" disabled>
|
||||
{{else if eq .Type "INTEGER"}}
|
||||
<input type="number" name="{{.Name}}" value="{{html $val}}">
|
||||
<input type="number" name="{{.Name}}" value="{{$val}}">
|
||||
{{else}}
|
||||
<textarea name="{{.Name}}" rows="3">{{html $val}}</textarea>
|
||||
<textarea name="{{.Name}}" rows="3">{{$val}}</textarea>
|
||||
{{end}}
|
||||
</label>
|
||||
{{end}}
|
||||
|
|
@ -615,6 +738,7 @@ button { margin-top: 25px; padding: 12px 30px; font-size: 16px; background: #006
|
|||
"Tables": tables,
|
||||
"Columns": cols,
|
||||
"Record": record,
|
||||
"IsSandbox": isSandboxMode,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -967,6 +1091,22 @@ CREATE TABLE IF NOT EXISTS wl3_storage (
|
|||
sync_count INTEGER DEFAULT 0,
|
||||
last_sync_at INTEGER
|
||||
);
|
||||
|
||||
-- CURRENCY TABLE: Conversion metadata + pretty pricing rules
|
||||
-- See schema.sql for full documentation on "Pretty Commercial Prices"
|
||||
CREATE TABLE IF NOT EXISTS currencies (
|
||||
code TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
decimals INTEGER NOT NULL DEFAULT 2,
|
||||
exchange_rate REAL,
|
||||
rate_fetched_at INTEGER,
|
||||
symbol TEXT,
|
||||
symbol_position TEXT DEFAULT 'prefix',
|
||||
pretty_pattern TEXT DEFAULT 'x.99',
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
`
|
||||
|
||||
_, err := db.Exec(schemaSQL)
|
||||
|
|
@ -1013,4 +1153,11 @@ CREATE TABLE IF NOT EXISTS wl3_storage (
|
|||
p.ID, p.Name, p.Description, now)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-populate currencies if empty
|
||||
var currCount int
|
||||
db.QueryRow("SELECT COUNT(*) FROM currencies").Scan(&currCount)
|
||||
if currCount == 0 {
|
||||
log.Println("Currencies table empty. Run: go run scripts/fetch_rates.go")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,896 @@
|
|||
// Admin service tests - automated verification of all endpoints
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// TestSuite holds test state
|
||||
type TestSuite struct {
|
||||
Router *chi.Mux
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
// setup creates a test environment with fresh database
|
||||
func setup() *TestSuite {
|
||||
// Create in-memory database for tests
|
||||
testDB, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Initialize schema
|
||||
initDBWithConn(testDB)
|
||||
|
||||
// Set global db for handlers
|
||||
db = testDB
|
||||
|
||||
// Create router
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Mount all routes
|
||||
r.Get("/", handleDynamicHome)
|
||||
r.Get("/table/{table}", handleDynamicList)
|
||||
r.Get("/table/{table}/new", handleDynamicForm)
|
||||
r.Post("/table/{table}", handleDynamicCreate)
|
||||
r.Get("/table/{table}/{id}", handleDynamicView)
|
||||
r.Get("/table/{table}/{id}/edit", handleDynamicEdit)
|
||||
r.Post("/table/{table}/{id}", handleDynamicUpdate)
|
||||
|
||||
// API routes
|
||||
r.Post("/api/checkout", HandleCheckout)
|
||||
r.Post("/webhooks/paddle", HandlePaddleWebhook)
|
||||
|
||||
return &TestSuite{Router: r, DB: testDB}
|
||||
}
|
||||
|
||||
func (s *TestSuite) teardown() {
|
||||
s.DB.Close()
|
||||
}
|
||||
|
||||
// Helper to make requests
|
||||
func (s *TestSuite) request(method, path string, body io.Reader) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, body)
|
||||
if method == "POST" {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
s.Router.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// Test 1: Home page loads and shows all tables
|
||||
func TestHomePage(t *testing.T) {
|
||||
s := setup()
|
||||
defer s.teardown()
|
||||
|
||||
rr := s.request("GET", "/", nil)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
body := rr.Body.String()
|
||||
|
||||
// Check that all expected tables are listed
|
||||
expectedTables := []string{"products", "customers", "subscriptions", "vaults", "events"}
|
||||
for _, table := range expectedTables {
|
||||
if !strings.Contains(body, table) {
|
||||
t.Errorf("Home page missing table: %s", table)
|
||||
}
|
||||
}
|
||||
|
||||
// Check products are pre-populated
|
||||
if !strings.Contains(body, "pro_personal") {
|
||||
t.Log("Note: products may not be pre-populated in test DB")
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2: All tables have list pages
|
||||
func TestTableListPages(t *testing.T) {
|
||||
s := setup()
|
||||
defer s.teardown()
|
||||
|
||||
tables := []string{
|
||||
"products", "prices", "discounts",
|
||||
"customers", "addresses", "businesses",
|
||||
"subscriptions", "subscription_items", "transactions",
|
||||
"adjustments", "vaults", "credentials",
|
||||
"roles", "humans", "role_assignments",
|
||||
"pop_sync_state", "events", "wl3_storage",
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
rr := s.request("GET", "/table/"+table, nil)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Table %s list page failed: status %d", table, rr.Code)
|
||||
continue
|
||||
}
|
||||
|
||||
body := rr.Body.String()
|
||||
|
||||
// Check table header exists
|
||||
if !strings.Contains(body, "<table>") {
|
||||
t.Errorf("Table %s missing HTML table", table)
|
||||
}
|
||||
|
||||
// Check navigation
|
||||
if !strings.Contains(body, table) {
|
||||
t.Errorf("Table %s page missing title reference", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 3: Form pages exist for all tables
|
||||
func TestFormPages(t *testing.T) {
|
||||
s := setup()
|
||||
defer s.teardown()
|
||||
|
||||
tables := []string{"customers", "products", "discounts", "subscriptions"}
|
||||
|
||||
for _, table := range tables {
|
||||
rr := s.request("GET", "/table/"+table+"/new", nil)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Form page for %s failed: status %d", table, rr.Code)
|
||||
continue
|
||||
}
|
||||
|
||||
body := rr.Body.String()
|
||||
|
||||
// Check form exists
|
||||
if !strings.Contains(body, "<form") {
|
||||
t.Errorf("Form page for %s missing form element", table)
|
||||
}
|
||||
|
||||
// Check submit button
|
||||
if !strings.Contains(body, "type=\"submit\"") {
|
||||
t.Errorf("Form page for %s missing submit button", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 4: Create customer via form POST (Paddle sync may fail in tests)
|
||||
func TestCreateCustomer(t *testing.T) {
|
||||
s := setup()
|
||||
defer s.teardown()
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("email", "test@example.com")
|
||||
form.Set("name", "Test Customer")
|
||||
form.Set("locale", "en")
|
||||
|
||||
s.request("POST", "/table/customers", strings.NewReader(form.Encode()))
|
||||
|
||||
// In test mode, Paddle sync may fail, but local DB should still work
|
||||
// Check directly in DB since the TLW may return an error but still create locally
|
||||
var count int
|
||||
s.DB.QueryRow("SELECT COUNT(*) FROM customers WHERE email = ?", "test@example.com").Scan(&count)
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("Customer not created in local DB. Count: %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// Test 5: Create product via form POST
|
||||
func TestCreateProduct(t *testing.T) {
|
||||
s := setup()
|
||||
defer s.teardown()
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("id", "pro_test_new")
|
||||
form.Set("name", "Test Product New")
|
||||
form.Set("description", "For testing")
|
||||
form.Set("status", "active")
|
||||
|
||||
rr := s.request("POST", "/table/products", strings.NewReader(form.Encode()))
|
||||
|
||||
// Check directly in DB (products don't use Paddle sync)
|
||||
var count int
|
||||
s.DB.QueryRow("SELECT COUNT(*) FROM products WHERE id = ?", "pro_test_new").Scan(&count)
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("Product not created. Count: %d, status: %d, body: %s", count, rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Test 6: View individual record
|
||||
func TestViewRecord(t *testing.T) {
|
||||
s := setup()
|
||||
defer s.teardown()
|
||||
|
||||
// First create a product
|
||||
form := url.Values{}
|
||||
form.Set("id", "pro_viewtest")
|
||||
form.Set("name", "View Test Product")
|
||||
form.Set("status", "active")
|
||||
s.request("POST", "/table/products", strings.NewReader(form.Encode()))
|
||||
|
||||
// Now view it
|
||||
rr := s.request("GET", "/table/products/pro_viewtest", nil)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("View record failed: status %d", rr.Code)
|
||||
}
|
||||
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "View Test Product") {
|
||||
t.Errorf("View page doesn't show product name")
|
||||
}
|
||||
}
|
||||
|
||||
// Test 7: Edit record
|
||||
func TestEditRecord(t *testing.T) {
|
||||
s := setup()
|
||||
defer s.teardown()
|
||||
|
||||
// Create a customer
|
||||
form := url.Values{}
|
||||
form.Set("email", "edit@test.com")
|
||||
form.Set("name", "Before Edit")
|
||||
s.request("POST", "/table/customers", strings.NewReader(form.Encode()))
|
||||
|
||||
// Get customer ID (we need to find it since it's auto-generated)
|
||||
var customerID string
|
||||
err := s.DB.QueryRow("SELECT id FROM customers WHERE email = ?", "edit@test.com").Scan(&customerID)
|
||||
if err != nil {
|
||||
t.Fatalf("Could not find created customer: %v", err)
|
||||
}
|
||||
|
||||
// Edit form page
|
||||
rr := s.request("GET", "/table/customers/"+customerID+"/edit", nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Edit form failed: status %d", rr.Code)
|
||||
}
|
||||
|
||||
// Submit edit
|
||||
editForm := url.Values{}
|
||||
editForm.Set("name", "After Edit")
|
||||
editForm.Set("email", "edit@test.com") // Keep same email
|
||||
rr = s.request("POST", "/table/customers/"+customerID, strings.NewReader(editForm.Encode()))
|
||||
|
||||
if rr.Code != http.StatusFound && rr.Code != http.StatusOK {
|
||||
t.Errorf("Update failed: status %d, body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Test 8: Schema introspection
|
||||
func TestSchemaIntrospection(t *testing.T) {
|
||||
s := setup()
|
||||
defer s.teardown()
|
||||
|
||||
// Get list of tables
|
||||
tables := getTablesWithConn(s.DB)
|
||||
|
||||
expectedTables := 18
|
||||
if len(tables) < expectedTables {
|
||||
t.Errorf("Expected at least %d tables, got %d: %v", expectedTables, len(tables), tables)
|
||||
}
|
||||
|
||||
// Check each table has columns
|
||||
for _, table := range []string{"customers", "products", "subscriptions"} {
|
||||
cols := getTableInfoWithConn(s.DB, table)
|
||||
if len(cols) == 0 {
|
||||
t.Errorf("Table %s has no columns", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 9: Database schema completeness
|
||||
func TestSchemaCompleteness(t *testing.T) {
|
||||
s := setup()
|
||||
defer s.teardown()
|
||||
|
||||
// Check all expected tables exist
|
||||
requiredTables := map[string][]string{
|
||||
"customers": {"id", "email", "name", "status", "created_at"},
|
||||
"products": {"id", "name", "description", "status"},
|
||||
"prices": {"id", "product_id", "unit_price"},
|
||||
"subscriptions": {"id", "customer_id", "status", "items"},
|
||||
"vaults": {"l0", "customer_id", "status"},
|
||||
"credentials": {"id", "l0", "p0"},
|
||||
"events": {"id", "occurred_at", "event_type"},
|
||||
"roles": {"id", "name", "permissions"},
|
||||
}
|
||||
|
||||
for table, requiredCols := range requiredTables {
|
||||
cols := getTableInfoWithConn(s.DB, table)
|
||||
colMap := make(map[string]bool)
|
||||
for _, c := range cols {
|
||||
colMap[c.Name] = true
|
||||
}
|
||||
|
||||
for _, col := range requiredCols {
|
||||
if !colMap[col] {
|
||||
t.Errorf("Table %s missing required column: %s", table, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 10: Sync layer integration (customers, subscriptions, discounts)
|
||||
func TestSyncLayerEntities(t *testing.T) {
|
||||
// These entities should be in EntityRegistry for Paddle sync
|
||||
paddleEntities := []string{"customers", "addresses", "businesses", "subscriptions", "transactions", "discounts"}
|
||||
|
||||
for _, entity := range paddleEntities {
|
||||
if _, ok := EntityRegistry[entity]; !ok {
|
||||
t.Errorf("Entity %s not found in EntityRegistry for Paddle sync", entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 11: Non-Paddle entities (no sync needed)
|
||||
func TestNonSyncEntities(t *testing.T) {
|
||||
// These entities should NOT be in EntityRegistry
|
||||
localOnlyEntities := []string{"vaults", "credentials", "events", "roles", "humans", "pop_sync_state", "wl3_storage"}
|
||||
|
||||
for _, entity := range localOnlyEntities {
|
||||
if _, ok := EntityRegistry[entity]; ok {
|
||||
t.Logf("Note: %s is in EntityRegistry but probably shouldn't sync to Paddle", entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 12: Pre-populated data
|
||||
func TestPrepopulatedData(t *testing.T) {
|
||||
s := setup()
|
||||
defer s.teardown()
|
||||
|
||||
// Check roles are pre-populated
|
||||
var roleCount int
|
||||
err := s.DB.QueryRow("SELECT COUNT(*) FROM roles").Scan(&roleCount)
|
||||
if err != nil {
|
||||
t.Fatalf("Could not query roles: %v", err)
|
||||
}
|
||||
if roleCount != 6 {
|
||||
t.Errorf("Expected 6 pre-populated roles, got %d", roleCount)
|
||||
}
|
||||
|
||||
// Check specific roles exist
|
||||
var superAdminCount int
|
||||
s.DB.QueryRow("SELECT COUNT(*) FROM roles WHERE name = 'SuperAdmin'").Scan(&superAdminCount)
|
||||
if superAdminCount != 1 {
|
||||
t.Errorf("SuperAdmin role not found")
|
||||
}
|
||||
}
|
||||
|
||||
// Test 13: API endpoints
|
||||
func TestAPIEndpoints(t *testing.T) {
|
||||
s := setup()
|
||||
defer s.teardown()
|
||||
|
||||
// Test checkout endpoint exists
|
||||
rr := s.request("POST", "/api/checkout", bytes.NewReader([]byte(`{}`)))
|
||||
// Should return error (no valid data) but not 404
|
||||
if rr.Code == http.StatusNotFound {
|
||||
t.Errorf("Checkout API endpoint not found")
|
||||
}
|
||||
|
||||
// Test webhook endpoint — without a signature it must REFUSE.
|
||||
// Signature verification is enforced; the previous "accept all" behavior
|
||||
// was a SECURITY.md violation.
|
||||
rr = s.request("POST", "/webhooks/paddle", bytes.NewReader([]byte(`{}`)))
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Webhook without signature should return 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Test 14: Error handling - invalid table
|
||||
func TestInvalidTable(t *testing.T) {
|
||||
s := setup()
|
||||
defer s.teardown()
|
||||
|
||||
rr := s.request("GET", "/table/nonexistent", nil)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("Invalid table should return 404, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Test 15: Navigation is consistent
|
||||
func TestNavigationConsistency(t *testing.T) {
|
||||
s := setup()
|
||||
defer s.teardown()
|
||||
|
||||
// Get home page
|
||||
rr := s.request("GET", "/", nil)
|
||||
homeBody := rr.Body.String()
|
||||
|
||||
// Get a table page
|
||||
rr = s.request("GET", "/table/customers", nil)
|
||||
tableBody := rr.Body.String()
|
||||
|
||||
// Both should have nav with Dashboard link
|
||||
if !strings.Contains(homeBody, "Dashboard") || !strings.Contains(tableBody, "Dashboard") {
|
||||
t.Errorf("Navigation inconsistent - missing Dashboard link")
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark: List page performance
|
||||
func BenchmarkListPage(b *testing.B) {
|
||||
s := setup()
|
||||
defer s.teardown()
|
||||
|
||||
// Pre-populate with 100 customers
|
||||
for i := 0; i < 100; i++ {
|
||||
_, err := s.DB.Exec("INSERT INTO customers (id, email, name, status, created_at) VALUES (?, ?, ?, 'active', ?)",
|
||||
fmt.Sprintf("ctm_%d", i), fmt.Sprintf("test%d@example.com", i), fmt.Sprintf("User %d", i), time.Now().Unix())
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.request("GET", "/table/customers", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions that work with specific DB connection
|
||||
func initDBWithConn(db *sql.DB) {
|
||||
schemaSQL := `-- Core tables
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
tax_category TEXT,
|
||||
image_url TEXT,
|
||||
custom_data TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS prices (
|
||||
id TEXT PRIMARY KEY,
|
||||
product_id TEXT REFERENCES products(id),
|
||||
description TEXT,
|
||||
name TEXT,
|
||||
billing_cycle TEXT,
|
||||
trial_period TEXT,
|
||||
tax_mode TEXT DEFAULT 'account_setting',
|
||||
unit_price TEXT,
|
||||
unit_price_overrides TEXT,
|
||||
quantity TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
custom_data TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS discounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT DEFAULT 'active',
|
||||
description TEXT,
|
||||
enabled_for_checkout INTEGER DEFAULT 0,
|
||||
code TEXT,
|
||||
type TEXT,
|
||||
mode TEXT DEFAULT 'standard',
|
||||
amount TEXT,
|
||||
currency_code TEXT,
|
||||
recur INTEGER DEFAULT 1,
|
||||
maximum_recurring_intervals INTEGER,
|
||||
usage_limit INTEGER,
|
||||
restrict_to TEXT,
|
||||
expires_at INTEGER,
|
||||
times_used INTEGER DEFAULT 0,
|
||||
discount_group_id TEXT,
|
||||
custom_data TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
paddle_id TEXT,
|
||||
sync_status TEXT DEFAULT 'pending',
|
||||
sync_error TEXT,
|
||||
last_paddle_sync_at INTEGER,
|
||||
pending_since INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
name TEXT,
|
||||
locale TEXT DEFAULT 'en',
|
||||
status TEXT DEFAULT 'active',
|
||||
marketing_consent INTEGER DEFAULT 0,
|
||||
import_meta TEXT,
|
||||
parent_id TEXT REFERENCES customers(id),
|
||||
level INTEGER DEFAULT 1,
|
||||
internal_notes TEXT,
|
||||
custom_data TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
paddle_id TEXT,
|
||||
billing_email TEXT,
|
||||
sync_status TEXT DEFAULT 'pending',
|
||||
sync_error TEXT,
|
||||
last_paddle_sync_at INTEGER,
|
||||
pending_since INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS addresses (
|
||||
id TEXT PRIMARY KEY,
|
||||
customer_id TEXT NOT NULL REFERENCES customers(id),
|
||||
description TEXT,
|
||||
city TEXT,
|
||||
region TEXT,
|
||||
country_code TEXT NOT NULL,
|
||||
zip_code TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS businesses (
|
||||
id TEXT PRIMARY KEY,
|
||||
customer_id TEXT NOT NULL REFERENCES customers(id),
|
||||
name TEXT NOT NULL,
|
||||
company_number TEXT,
|
||||
tax_identifier TEXT,
|
||||
contacts TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
custom_data TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
paddle_id TEXT,
|
||||
customer_id TEXT NOT NULL REFERENCES customers(id),
|
||||
address_id TEXT NOT NULL REFERENCES addresses(id),
|
||||
business_id TEXT REFERENCES businesses(id),
|
||||
currency_code TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
collection_mode TEXT DEFAULT 'automatic',
|
||||
billing_details TEXT,
|
||||
discount TEXT,
|
||||
started_at INTEGER,
|
||||
first_billed_at INTEGER,
|
||||
next_billed_at INTEGER,
|
||||
paused_at INTEGER,
|
||||
canceled_at INTEGER,
|
||||
vault_l0 TEXT,
|
||||
billing_cycle TEXT,
|
||||
current_billing_period TEXT,
|
||||
scheduled_change TEXT,
|
||||
items TEXT,
|
||||
management_urls TEXT,
|
||||
custom_data TEXT,
|
||||
import_meta TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
consent_requirements TEXT,
|
||||
sync_status TEXT DEFAULT 'pending',
|
||||
sync_error TEXT,
|
||||
last_paddle_sync_at INTEGER,
|
||||
pending_since INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subscription_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
subscription_id TEXT NOT NULL REFERENCES subscriptions(id),
|
||||
price_id TEXT NOT NULL,
|
||||
product_id TEXT NOT NULL,
|
||||
quantity INTEGER DEFAULT 1,
|
||||
status TEXT DEFAULT 'active',
|
||||
recurring INTEGER DEFAULT 1,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
previously_billed_at INTEGER,
|
||||
next_billed_at INTEGER,
|
||||
trial_dates TEXT,
|
||||
price_snapshot TEXT,
|
||||
product_snapshot TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
paddle_id TEXT,
|
||||
subscription_id TEXT REFERENCES subscriptions(id),
|
||||
customer_id TEXT NOT NULL REFERENCES customers(id),
|
||||
address_id TEXT REFERENCES addresses(id),
|
||||
business_id TEXT REFERENCES businesses(id),
|
||||
invoice_id TEXT,
|
||||
invoice_number TEXT,
|
||||
discount_id TEXT REFERENCES discounts(id),
|
||||
status TEXT NOT NULL,
|
||||
origin TEXT,
|
||||
currency_code TEXT NOT NULL,
|
||||
details TEXT,
|
||||
payments TEXT,
|
||||
adjusted_totals TEXT,
|
||||
payout_totals TEXT,
|
||||
adjusted_payout_totals TEXT,
|
||||
billing_details TEXT,
|
||||
additional_information TEXT,
|
||||
purchase_order_number TEXT,
|
||||
checkout_url TEXT,
|
||||
billed_at INTEGER,
|
||||
completed_at INTEGER,
|
||||
revised_at INTEGER,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
processed_at INTEGER,
|
||||
processing_error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adjustments (
|
||||
id TEXT PRIMARY KEY,
|
||||
transaction_id TEXT NOT NULL REFERENCES transactions(id),
|
||||
subscription_id TEXT REFERENCES subscriptions(id),
|
||||
customer_id TEXT NOT NULL REFERENCES customers(id),
|
||||
action TEXT NOT NULL,
|
||||
type TEXT,
|
||||
amount TEXT NOT NULL,
|
||||
currency_code TEXT NOT NULL,
|
||||
totals TEXT,
|
||||
payout_totals TEXT,
|
||||
reason TEXT,
|
||||
credit_applied_to TEXT,
|
||||
created_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vaults (
|
||||
l0 TEXT PRIMARY KEY,
|
||||
customer_id TEXT NOT NULL REFERENCES customers(id),
|
||||
subscription_id TEXT REFERENCES subscriptions(id),
|
||||
status TEXT DEFAULT 'active',
|
||||
created_at INTEGER,
|
||||
last_seen_at INTEGER,
|
||||
created_by_human INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
id INTEGER PRIMARY KEY,
|
||||
customer_id TEXT NOT NULL REFERENCES customers(id),
|
||||
p0 TEXT NOT NULL,
|
||||
l0 TEXT NOT NULL REFERENCES vaults(l0),
|
||||
credential_id BLOB,
|
||||
wl3_path TEXT,
|
||||
enrolled_by INTEGER,
|
||||
created_at INTEGER,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
permissions TEXT,
|
||||
description TEXT,
|
||||
created_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS humans (
|
||||
id INTEGER PRIMARY KEY,
|
||||
customer_id TEXT REFERENCES customers(id),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'active',
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS role_assignments (
|
||||
id INTEGER PRIMARY KEY,
|
||||
human_id INTEGER NOT NULL REFERENCES humans(id),
|
||||
customer_id TEXT NOT NULL REFERENCES customers(id),
|
||||
role_id INTEGER NOT NULL REFERENCES roles(id),
|
||||
granted_by INTEGER REFERENCES humans(id),
|
||||
created_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pop_sync_state (
|
||||
pop_id TEXT PRIMARY KEY,
|
||||
last_sync_at INTEGER,
|
||||
last_sync_cursor INTEGER,
|
||||
status TEXT DEFAULT 'active',
|
||||
error_message TEXT,
|
||||
updated_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
occurred_at INTEGER,
|
||||
event_type TEXT,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
customer_id TEXT REFERENCES customers(id),
|
||||
human_id INTEGER REFERENCES humans(id),
|
||||
vault_l0 TEXT,
|
||||
amount_cents INTEGER,
|
||||
currency_code TEXT,
|
||||
details TEXT,
|
||||
source TEXT DEFAULT 'api',
|
||||
ip_address TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wl3_storage (
|
||||
path TEXT PRIMARY KEY,
|
||||
content BLOB,
|
||||
p0 TEXT,
|
||||
customer_id TEXT,
|
||||
created_at INTEGER,
|
||||
sync_count INTEGER DEFAULT 0,
|
||||
last_sync_at INTEGER
|
||||
);
|
||||
`
|
||||
|
||||
_, err := db.Exec(schemaSQL)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Pre-populate roles
|
||||
now := time.Now().Unix()
|
||||
db.Exec(`INSERT INTO roles (id, name, permissions, description, created_at) VALUES
|
||||
(1, 'SuperAdmin', '{"all": true}', 'Full system access', ?),
|
||||
(2, 'MSPAdmin', '{"customer_admin": true, "billing_view": true}', 'MSP-level admin', ?),
|
||||
(3, 'TenantAdmin', '{"vault_create": true, "agent_create": true, "device_enroll": true}', 'Customer admin', ?),
|
||||
(4, 'VaultUser', '{"vault_read": true, "vault_write": true}', 'Regular user', ?),
|
||||
(5, 'BillingAdmin', '{"billing_view": true, "invoices": true}', 'Can view/pay invoices', ?),
|
||||
(6, 'ReadOnly', '{"vault_read": true}', 'View only', ?)`,
|
||||
now, now, now, now, now, now)
|
||||
|
||||
// Pre-populate products
|
||||
products := []struct {
|
||||
ID, Name, Description string
|
||||
}{
|
||||
{"pro_personal", "Clavitor Personal", "1 vault, 5 agents, 2 devices"},
|
||||
{"pro_family", "Clavitor Family", "1 vault, 15 agents, 6 devices"},
|
||||
{"pro_pro", "Clavitor Pro", "1 vault, 50 agents, unlimited devices"},
|
||||
{"pro_team10", "Clavitor Team 10", "Up to 10 employees"},
|
||||
{"pro_team25", "Clavitor Team 25", "Up to 25 employees"},
|
||||
{"pro_team100", "Clavitor Team 100", "Up to 100 employees"},
|
||||
{"pro_team250", "Clavitor Team 250", "Up to 250 employees"},
|
||||
{"pro_team500", "Clavitor Team 500", "Up to 500 employees"},
|
||||
}
|
||||
for _, p := range products {
|
||||
db.Exec("INSERT INTO products (id, name, description, status, created_at) VALUES (?, ?, ?, 'active', ?)",
|
||||
p.ID, p.Name, p.Description, now)
|
||||
}
|
||||
}
|
||||
|
||||
func getTablesWithConn(db *sql.DB) []string {
|
||||
rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tables []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
rows.Scan(&name)
|
||||
tables = append(tables, name)
|
||||
}
|
||||
return tables
|
||||
}
|
||||
|
||||
func getTableInfoWithConn(db *sql.DB, table string) []ColumnInfo {
|
||||
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var cols []ColumnInfo
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var col ColumnInfo
|
||||
rows.Scan(&cid, &col.Name, &col.Type, &col.NotNull, &col.Default, &col.PK)
|
||||
cols = append(cols, col)
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Paddle webhook signature verification
|
||||
// ============================================================================
|
||||
|
||||
func makePaddleSig(t *testing.T, secret, body string, ts int64) string {
|
||||
t.Helper()
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
tsStr := strconv.FormatInt(ts, 10)
|
||||
mac.Write([]byte(tsStr))
|
||||
mac.Write([]byte{':'})
|
||||
mac.Write([]byte(body))
|
||||
return "ts=" + tsStr + ";h1=" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func TestVerifyWebhookSignature_validSignaturePasses(t *testing.T) {
|
||||
const secret = "test-secret-do-not-use-in-prod"
|
||||
t.Setenv("PADDLE_WEBHOOK_SECRET", secret)
|
||||
|
||||
body := `{"event_type":"transaction.completed","data":{"id":"txn_x"}}`
|
||||
sig := makePaddleSig(t, secret, body, time.Now().Unix())
|
||||
|
||||
req := httptest.NewRequest("POST", "/webhooks/paddle", strings.NewReader(body))
|
||||
req.Header.Set("Paddle-Signature", sig)
|
||||
|
||||
bodyBytes, _ := io.ReadAll(req.Body)
|
||||
if !verifyWebhookSignature(req, bodyBytes) {
|
||||
t.Error("valid signature was rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyWebhookSignature_missingSecretRefuses(t *testing.T) {
|
||||
t.Setenv("PADDLE_WEBHOOK_SECRET", "")
|
||||
req := httptest.NewRequest("POST", "/webhooks/paddle", strings.NewReader(`{}`))
|
||||
req.Header.Set("Paddle-Signature", "ts=0;h1=00")
|
||||
if verifyWebhookSignature(req, []byte(`{}`)) {
|
||||
t.Error("must refuse when PADDLE_WEBHOOK_SECRET is unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyWebhookSignature_missingHeaderRefuses(t *testing.T) {
|
||||
t.Setenv("PADDLE_WEBHOOK_SECRET", "secret")
|
||||
req := httptest.NewRequest("POST", "/webhooks/paddle", strings.NewReader(`{}`))
|
||||
if verifyWebhookSignature(req, []byte(`{}`)) {
|
||||
t.Error("must refuse when Paddle-Signature header is absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyWebhookSignature_tamperedBodyRefuses(t *testing.T) {
|
||||
const secret = "secret"
|
||||
t.Setenv("PADDLE_WEBHOOK_SECRET", secret)
|
||||
|
||||
body := `{"a":1}`
|
||||
sig := makePaddleSig(t, secret, body, time.Now().Unix())
|
||||
|
||||
req := httptest.NewRequest("POST", "/webhooks/paddle", strings.NewReader(body))
|
||||
req.Header.Set("Paddle-Signature", sig)
|
||||
|
||||
tampered := []byte(`{"a":2}`) // body the verifier sees has been changed
|
||||
if verifyWebhookSignature(req, tampered) {
|
||||
t.Error("tampered body must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyWebhookSignature_oldTimestampRefuses(t *testing.T) {
|
||||
const secret = "secret"
|
||||
t.Setenv("PADDLE_WEBHOOK_SECRET", secret)
|
||||
|
||||
body := `{}`
|
||||
oldTs := time.Now().Unix() - 600 // 10 min ago, outside ±5 min window
|
||||
sig := makePaddleSig(t, secret, body, oldTs)
|
||||
|
||||
req := httptest.NewRequest("POST", "/webhooks/paddle", strings.NewReader(body))
|
||||
req.Header.Set("Paddle-Signature", sig)
|
||||
|
||||
if verifyWebhookSignature(req, []byte(body)) {
|
||||
t.Error("timestamp older than 5 minutes must be refused (anti-replay)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyWebhookSignature_wrongSecretRefuses(t *testing.T) {
|
||||
t.Setenv("PADDLE_WEBHOOK_SECRET", "the-real-secret")
|
||||
|
||||
body := `{"a":1}`
|
||||
// Sign with a DIFFERENT secret
|
||||
sig := makePaddleSig(t, "wrong-secret", body, time.Now().Unix())
|
||||
|
||||
req := httptest.NewRequest("POST", "/webhooks/paddle", strings.NewReader(body))
|
||||
req.Header.Set("Paddle-Signature", sig)
|
||||
|
||||
if verifyWebhookSignature(req, []byte(body)) {
|
||||
t.Error("signature from wrong secret must be refused")
|
||||
}
|
||||
}
|
||||
|
|
@ -4,10 +4,17 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -146,34 +153,53 @@ func HandleCheckoutSuccess(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// WebhookHandler - Paddle sends us events
|
||||
func HandlePaddleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify webhook signature (important!)
|
||||
if !verifyWebhookSignature(r) {
|
||||
// Read the raw body BEFORE verification — the signature is over the raw
|
||||
// bytes, so we can't let json.Decoder rewrite or reformat anything.
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Read error", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !verifyWebhookSignature(r, body) {
|
||||
http.Error(w, "Invalid signature", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var event map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&event)
|
||||
if err := json.Unmarshal(body, &event); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
eventType := event["event_type"].(string)
|
||||
data := event["data"].(map[string]interface{})
|
||||
// Handle empty/malformed events gracefully
|
||||
if event == nil {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
eventType, _ := event["event_type"].(string)
|
||||
data, _ := event["data"].(map[string]interface{})
|
||||
|
||||
switch eventType {
|
||||
case "transaction.completed":
|
||||
// Payment succeeded
|
||||
transactionID := data["id"].(string)
|
||||
if transactionID, ok := data["id"].(string); ok {
|
||||
activateVaultForTransaction(transactionID)
|
||||
}
|
||||
|
||||
case "transaction.past_due":
|
||||
// Payment failed
|
||||
transactionID := data["id"].(string)
|
||||
if transactionID, ok := data["id"].(string); ok {
|
||||
suspendVaultForTransaction(transactionID)
|
||||
}
|
||||
|
||||
case "subscription.canceled":
|
||||
// Subscription cancelled
|
||||
subscriptionID := data["id"].(string)
|
||||
if subscriptionID, ok := data["id"].(string); ok {
|
||||
cancelVaultSubscription(subscriptionID)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
|
@ -225,10 +251,77 @@ func cancelVaultSubscription(subscriptionID string) {
|
|||
// Webhook handler - mark cancelled
|
||||
}
|
||||
|
||||
func verifyWebhookSignature(r *http.Request) bool {
|
||||
// TODO: Implement webhook signature verification
|
||||
// Paddle signs webhooks with secret key
|
||||
return true // For now, accept all
|
||||
// verifyWebhookSignature verifies the Paddle webhook signature.
|
||||
//
|
||||
// Paddle signs webhooks with HMAC-SHA256 over "<timestamp>:<raw_body>". The
|
||||
// signature header looks like:
|
||||
//
|
||||
// Paddle-Signature: ts=1700000000;h1=abcdef0123456789...
|
||||
//
|
||||
// We require:
|
||||
// - PADDLE_WEBHOOK_SECRET env var is set (refuse if missing — no permissive default)
|
||||
// - the Paddle-Signature header is present and parseable
|
||||
// - the timestamp is within ±5 minutes of now (anti-replay)
|
||||
// - hmac-sha256(secret, ts + ":" + body) matches h1 in constant time
|
||||
//
|
||||
// Returns false on any failure. SECURITY.md: failures are visible and noisy,
|
||||
// no silent fallback.
|
||||
func verifyWebhookSignature(r *http.Request, body []byte) bool {
|
||||
secret := os.Getenv("PADDLE_WEBHOOK_SECRET")
|
||||
if secret == "" {
|
||||
log.Printf("PADDLE_WEBHOOK_SECRET not set; refusing webhook")
|
||||
return false
|
||||
}
|
||||
|
||||
sigHeader := r.Header.Get("Paddle-Signature")
|
||||
if sigHeader == "" {
|
||||
log.Printf("webhook: missing Paddle-Signature header")
|
||||
return false
|
||||
}
|
||||
|
||||
var ts, h1 string
|
||||
for _, part := range strings.Split(sigHeader, ";") {
|
||||
kv := strings.SplitN(part, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
switch kv[0] {
|
||||
case "ts":
|
||||
ts = kv[1]
|
||||
case "h1":
|
||||
h1 = kv[1]
|
||||
}
|
||||
}
|
||||
if ts == "" || h1 == "" {
|
||||
log.Printf("webhook: malformed Paddle-Signature header: %q", sigHeader)
|
||||
return false
|
||||
}
|
||||
|
||||
// Anti-replay: timestamp within 5 minutes
|
||||
tsInt, err := strconv.ParseInt(ts, 10, 64)
|
||||
if err != nil {
|
||||
log.Printf("webhook: invalid ts: %v", err)
|
||||
return false
|
||||
}
|
||||
const maxSkew = 300 // 5 minutes
|
||||
now := time.Now().Unix()
|
||||
if now-tsInt > maxSkew || tsInt-now > maxSkew {
|
||||
log.Printf("webhook: timestamp out of range (ts=%d now=%d)", tsInt, now)
|
||||
return false
|
||||
}
|
||||
|
||||
// Compute HMAC-SHA256(secret, ts + ":" + body)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(ts))
|
||||
mac.Write([]byte{':'})
|
||||
mac.Write(body)
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
if !hmac.Equal([]byte(expected), []byte(h1)) {
|
||||
log.Printf("webhook: signature mismatch")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,256 @@
|
|||
// Price formatting and "pretty commercial price" conversion
|
||||
// Design principle: Never show exact converted prices (e.g., $29.99 → €27.42)
|
||||
// Instead: Round UP to psychologically pleasing local prices
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CurrencyInfo holds currency metadata for formatting
|
||||
type CurrencyInfo struct {
|
||||
Code string
|
||||
Name string
|
||||
Decimals int
|
||||
Symbol string
|
||||
SymbolPosition string
|
||||
PrettyPattern string
|
||||
ExchangeRate float64
|
||||
}
|
||||
|
||||
// FormatPrice displays an amount in minor units as human-readable string
|
||||
// e.g., 149900 cents USD → "USD 1,499.00" or "$1,499.00" depending on useSymbol
|
||||
func FormatPrice(amountMinor int64, currencyCode string, useSymbol bool) string {
|
||||
// This is a stub - in production, fetch from DB
|
||||
info := getCurrencyInfo(currencyCode)
|
||||
|
||||
// Convert to major units (e.g., dollars from cents)
|
||||
divisor := math.Pow(10, float64(info.Decimals))
|
||||
major := float64(amountMinor) / divisor
|
||||
|
||||
// Format with thousands separator and correct decimals
|
||||
formatted := formatNumber(major, info.Decimals)
|
||||
|
||||
if useSymbol && info.Symbol != "" {
|
||||
if info.SymbolPosition == "suffix" {
|
||||
return formatted + " " + info.Symbol
|
||||
}
|
||||
return info.Symbol + formatted
|
||||
}
|
||||
|
||||
return currencyCode + " " + formatted
|
||||
}
|
||||
|
||||
// PrettyPrice converts USD amount to local currency and rounds to "pretty" commercial price
|
||||
// Uses roundToNine algorithm (ends in 9, step-based rounding)
|
||||
// Returns amount in MINOR units (e.g., 11900 for $119.00)
|
||||
func PrettyPrice(usdAmount float64, targetCurrency string, dbRate float64) int64 {
|
||||
info := getCurrencyInfo(targetCurrency)
|
||||
|
||||
var majorAmount float64
|
||||
if targetCurrency == "USD" {
|
||||
majorAmount = usdAmount
|
||||
} else {
|
||||
// Convert to target currency major units
|
||||
majorAmount = usdAmount * dbRate
|
||||
}
|
||||
|
||||
// Round to nice "ends in 9" price
|
||||
roundedMajor := roundToNine(majorAmount)
|
||||
|
||||
// Convert to minor units
|
||||
multiplier := math.Pow(10, float64(info.Decimals))
|
||||
return int64(roundedMajor * multiplier)
|
||||
}
|
||||
|
||||
// roundToTen rounds UP to the next clean step boundary
|
||||
|
||||
// roundToTen rounds UP to the next clean step boundary
|
||||
// Step sizes: 10 (for 100-200), 25 (for 200-250), 50 (for 250-500), 100 (for 500-1000), etc.
|
||||
func roundToTen(price float64) float64 {
|
||||
if price <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Find magnitude M where 10^M <= price < 10^(M+1)
|
||||
M := math.Floor(math.Log10(price))
|
||||
magnitude := math.Pow(10, M)
|
||||
|
||||
// Normalize to 1-10 range
|
||||
N := price / magnitude
|
||||
|
||||
// Lookup step ratio based on coefficient
|
||||
var stepRatio float64
|
||||
switch {
|
||||
case N < 2.0:
|
||||
stepRatio = 0.1 // step = 10, 100, 1000...
|
||||
case N < 2.5:
|
||||
stepRatio = 0.25 // step = 25, 250...
|
||||
case N < 5.0:
|
||||
stepRatio = 0.5 // step = 50, 500...
|
||||
default:
|
||||
stepRatio = 1.0 // step = 100, 1000...
|
||||
}
|
||||
|
||||
step := stepRatio * magnitude
|
||||
return math.Ceil(price/step) * step
|
||||
}
|
||||
|
||||
// roundToNine: Commercial pricing (always ends just below the step)
|
||||
func roundToNine(price float64) float64 {
|
||||
return roundToTen(price) - 1
|
||||
}
|
||||
|
||||
// formatNumber formats with thousands separators and correct decimals
|
||||
func formatNumber(num float64, decimals int) string {
|
||||
// Round to specified decimals first
|
||||
multiplier := math.Pow(10, float64(decimals))
|
||||
rounded := math.Round(num*multiplier) / multiplier
|
||||
|
||||
// Format with decimals
|
||||
format := fmt.Sprintf("%%.%df", decimals)
|
||||
withDecimals := fmt.Sprintf(format, math.Abs(rounded))
|
||||
|
||||
// Split integer and decimal parts
|
||||
parts := strings.Split(withDecimals, ".")
|
||||
intPart := parts[0]
|
||||
decPart := ""
|
||||
if len(parts) > 1 {
|
||||
decPart = parts[1]
|
||||
}
|
||||
|
||||
// Add thousands separators
|
||||
var result strings.Builder
|
||||
for i, c := range intPart {
|
||||
if i > 0 && (len(intPart)-i)%3 == 0 {
|
||||
result.WriteRune(',')
|
||||
}
|
||||
result.WriteRune(c)
|
||||
}
|
||||
|
||||
if decimals > 0 && decPart != "" {
|
||||
result.WriteString(".")
|
||||
result.WriteString(decPart)
|
||||
}
|
||||
|
||||
if rounded < 0 {
|
||||
return "-" + result.String()
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// getCurrencyInfo returns currency metadata
|
||||
// In production, this queries the database
|
||||
func getCurrencyInfo(code string) CurrencyInfo {
|
||||
// Default fallback
|
||||
info := CurrencyInfo{
|
||||
Code: code,
|
||||
Name: code,
|
||||
Decimals: 2,
|
||||
Symbol: code,
|
||||
SymbolPosition: "prefix",
|
||||
PrettyPattern: "x9",
|
||||
ExchangeRate: 1.0,
|
||||
}
|
||||
|
||||
// Common currencies
|
||||
switch code {
|
||||
case "USD":
|
||||
info = CurrencyInfo{
|
||||
Code: "USD", Name: "US Dollar", Decimals: 2,
|
||||
Symbol: "$", SymbolPosition: "prefix",
|
||||
PrettyPattern: "x9", ExchangeRate: 1.0,
|
||||
}
|
||||
case "EUR":
|
||||
info = CurrencyInfo{
|
||||
Code: "EUR", Name: "Euro", Decimals: 2,
|
||||
Symbol: "€", SymbolPosition: "prefix",
|
||||
PrettyPattern: "x9", ExchangeRate: 0.92,
|
||||
}
|
||||
case "GBP":
|
||||
info = CurrencyInfo{
|
||||
Code: "GBP", Name: "British Pound", Decimals: 2,
|
||||
Symbol: "£", SymbolPosition: "prefix",
|
||||
PrettyPattern: "x9", ExchangeRate: 0.79,
|
||||
}
|
||||
case "JPY":
|
||||
info = CurrencyInfo{
|
||||
Code: "JPY", Name: "Japanese Yen", Decimals: 0,
|
||||
Symbol: "¥", SymbolPosition: "prefix",
|
||||
PrettyPattern: "x0", ExchangeRate: 151.5,
|
||||
}
|
||||
case "CAD":
|
||||
info = CurrencyInfo{
|
||||
Code: "CAD", Name: "Canadian Dollar", Decimals: 2,
|
||||
Symbol: "C$", SymbolPosition: "prefix",
|
||||
PrettyPattern: "x9", ExchangeRate: 1.36,
|
||||
}
|
||||
case "AUD":
|
||||
info = CurrencyInfo{
|
||||
Code: "AUD", Name: "Australian Dollar", Decimals: 2,
|
||||
Symbol: "A$", SymbolPosition: "prefix",
|
||||
PrettyPattern: "x9", ExchangeRate: 1.52,
|
||||
}
|
||||
case "CHF":
|
||||
info = CurrencyInfo{
|
||||
Code: "CHF", Name: "Swiss Franc", Decimals: 2,
|
||||
Symbol: "Fr", SymbolPosition: "prefix",
|
||||
PrettyPattern: "x0", ExchangeRate: 0.90,
|
||||
}
|
||||
case "SEK", "NOK", "DKK":
|
||||
symbol := "kr"
|
||||
info = CurrencyInfo{
|
||||
Code: code, Name: "Krona", Decimals: 2,
|
||||
Symbol: symbol, SymbolPosition: "suffix",
|
||||
PrettyPattern: "x0", ExchangeRate: 10.5,
|
||||
}
|
||||
case "BHD":
|
||||
info = CurrencyInfo{
|
||||
Code: "BHD", Name: "Bahraini Dinar", Decimals: 3,
|
||||
Symbol: "BD", SymbolPosition: "prefix",
|
||||
PrettyPattern: "x000", ExchangeRate: 0.376,
|
||||
}
|
||||
case "KWD":
|
||||
info = CurrencyInfo{
|
||||
Code: "KWD", Name: "Kuwaiti Dinar", Decimals: 3,
|
||||
Symbol: "KD", SymbolPosition: "prefix",
|
||||
PrettyPattern: "x000", ExchangeRate: 0.307,
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// ExamplePrettyPrices shows examples of pretty pricing conversion
|
||||
func ExamplePrettyPrices() {
|
||||
fmt.Println("=== Pretty Commercial Prices ===")
|
||||
fmt.Println("(Never show exact converted prices - always round UP)")
|
||||
fmt.Println()
|
||||
|
||||
examples := []struct {
|
||||
USD float64
|
||||
Rate float64
|
||||
Target string
|
||||
}{
|
||||
{23.21, 0.92, "EUR"}, // Should be €24.00, not €21.35
|
||||
{47.32, 0.92, "EUR"}, // Should be €49.99, not €43.53
|
||||
{123.45, 0.92, "EUR"}, // Should be €129.00, not €113.57
|
||||
{499.00, 0.92, "EUR"}, // Should be €499.00 or €459.00
|
||||
{999.00, 0.79, "GBP"}, // Should be £799.00, not £789.21
|
||||
{29.99, 151.5, "JPY"}, // Should be ¥5,000, not ¥4,544
|
||||
{1499.00, 1.0, "USD"}, // Should be $1,499.00
|
||||
{1499.00, 0.92, "EUR"}, // Should be €1,499.00, not €1,379
|
||||
}
|
||||
|
||||
for _, ex := range examples {
|
||||
pretty := PrettyPrice(ex.USD, ex.Target, ex.Rate)
|
||||
exactConverted := ex.USD * ex.Rate
|
||||
|
||||
display := FormatPrice(pretty, ex.Target, true)
|
||||
|
||||
fmt.Printf("$%.2f USD @ %.4f → EXACT: %.2f → PRETTY: %s\n",
|
||||
ex.USD, ex.Rate, exactConverted, display)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
// Fetch exchange rates from Frankfurter API (free, ECB rates)
|
||||
// Usage: go run scripts/fetch_rates.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const frankfurterAPI = "https://api.frankfurter.app/latest?from=USD"
|
||||
|
||||
type RatesResponse struct {
|
||||
Amount float64 `json:"amount"`
|
||||
Base string `json:"base"`
|
||||
Date string `json:"date"`
|
||||
Rates map[string]float64 `json:"rates"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
db, err := sql.Open("sqlite3", "corporate.db")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
fmt.Println("Fetching exchange rates from Frankfurter API (ECB rates)...")
|
||||
|
||||
// Fetch rates from API
|
||||
resp, err := http.Get(frankfurterAPI)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to fetch rates: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var ratesData RatesResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ratesData); err != nil {
|
||||
log.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Base: %s, Date: %s\n", ratesData.Base, ratesData.Date)
|
||||
fmt.Printf("Got rates for %d currencies\n", len(ratesData.Rates))
|
||||
|
||||
// Update database
|
||||
now := time.Now().Unix()
|
||||
updated := 0
|
||||
notFound := []string{}
|
||||
|
||||
for currency, rate := range ratesData.Rates {
|
||||
result, err := db.Exec(
|
||||
"UPDATE currencies SET exchange_rate = ?, rate_fetched_at = ? WHERE code = ?",
|
||||
rate, now, currency,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Error updating %s: %v", currency, err)
|
||||
continue
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows > 0 {
|
||||
updated++
|
||||
} else {
|
||||
notFound = append(notFound, currency)
|
||||
}
|
||||
}
|
||||
|
||||
// Always set USD to 1.0
|
||||
db.Exec("UPDATE currencies SET exchange_rate = 1.0, rate_fetched_at = ? WHERE code = 'USD'", now)
|
||||
updated++
|
||||
|
||||
fmt.Printf("\nUpdated %d currencies in database\n", updated)
|
||||
if len(notFound) > 0 {
|
||||
fmt.Printf("Rates not in our DB (consider adding): %v\n", notFound)
|
||||
}
|
||||
|
||||
// Show sample rates
|
||||
fmt.Println("\n--- Sample Rates (1 USD = X units) ---")
|
||||
rows, _ := db.Query(`
|
||||
SELECT code, name, exchange_rate, decimals
|
||||
FROM currencies
|
||||
WHERE exchange_rate IS NOT NULL
|
||||
ORDER BY code
|
||||
LIMIT 15
|
||||
`)
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var code, name string
|
||||
var rate float64
|
||||
var decimals int
|
||||
rows.Scan(&code, &name, &rate, &decimals)
|
||||
fmt.Printf(" %s (%s): %.4f (%d decimals)\n", code, name, rate, decimals)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,11 +9,38 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// isProductionServer checks if we're running on the production clavitor.ai server
|
||||
func isProductionServer() bool {
|
||||
prodIPs, err := net.LookupIP("clavitor.ai")
|
||||
if err != nil || len(prodIPs) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
if ipnet.IP.To4() != nil {
|
||||
for _, prodIP := range prodIPs {
|
||||
if ipnet.IP.Equal(prodIP) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EntityMap defines how each entity maps between our DB and Paddle
|
||||
type EntityMap struct {
|
||||
PaddleEndpoint string // e.g., "/customers"
|
||||
|
|
@ -91,25 +118,30 @@ type SyncLayer struct {
|
|||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Default to sandbox for local development
|
||||
const DefaultSandbox = true
|
||||
|
||||
// Default API key - in production load from env
|
||||
// For sandbox: get from https://sandbox-vendors.paddle.com/authentication
|
||||
const DefaultAPIKey = "pdl_sdbx_apikey_01knegw36v6cvybp2y5652xpmq_weT2XzhV6Qk0rGEYDY0V5X_Aig" // Set your sandbox API key here
|
||||
const DefaultAPIKey = "pdl_sdbx_apikey_01knegw36v6cvybp2y5652xpmq_weT2XzhV6Qk0rGEYDY0V5X_Aig"
|
||||
|
||||
func NewSyncLayer(apiKey string, sandbox bool, db *sql.DB) *SyncLayer {
|
||||
// NewSyncLayer creates a sync layer with auto-detected sandbox mode
|
||||
// Uses isProductionServer() from main.go to determine environment
|
||||
func NewSyncLayer(apiKey string, db *sql.DB) *SyncLayer {
|
||||
if apiKey == "" {
|
||||
apiKey = DefaultAPIKey
|
||||
}
|
||||
if !sandbox && DefaultSandbox {
|
||||
sandbox = true // Force sandbox for local dev unless explicitly production
|
||||
|
||||
// Auto-detect: if not on production server, use sandbox
|
||||
sandbox := !isProductionServer()
|
||||
if sandbox {
|
||||
fmt.Println("Using PADDLE SANDBOX environment")
|
||||
} else {
|
||||
fmt.Println("Using PADDLE LIVE environment")
|
||||
}
|
||||
|
||||
baseURL := "https://api.paddle.com"
|
||||
if sandbox {
|
||||
baseURL = "https://sandbox-api.paddle.com"
|
||||
fmt.Println("Using PADDLE SANDBOX environment")
|
||||
}
|
||||
|
||||
return &SyncLayer{
|
||||
apiKey: apiKey,
|
||||
baseURL: baseURL,
|
||||
|
|
@ -309,7 +341,10 @@ func (s *SyncLayer) toPaddlePayload(data map[string]interface{}, fieldMap map[st
|
|||
if ourField == "id" || ourField == "paddle_id" || ourField == "sync_status" || ourField == "sync_error" {
|
||||
continue
|
||||
}
|
||||
if ourField == "created_at" || ourField == "updated_at" {
|
||||
if ourField == "created_at" || ourField == "updated_at" || ourField == "pending_since" {
|
||||
continue
|
||||
}
|
||||
if ourField == "last_paddle_sync_at" || ourField == "paddle_event_id" || ourField == "internal_notes" {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,26 @@
|
|||
Single source of truth. Design system tokens + website components.
|
||||
============================================================ */
|
||||
|
||||
/* Self-hosted fonts. Latin only — covers English plus Western European
|
||||
accents (ü, é, ñ, etc.). No third-party calls. */
|
||||
/* EVALUATING: IBM Plex Sans as the body font (replacing Figtree). */
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400 700;
|
||||
font-display: swap;
|
||||
src: url('/fonts/ibm-plex-sans-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 100 800;
|
||||
font-display: swap;
|
||||
src: url('/fonts/jetbrains-mono-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* === TOKENS (from design-system/styleguide.css) === */
|
||||
:root {
|
||||
/* Brand */
|
||||
|
|
@ -33,7 +53,7 @@
|
|||
--gold: #D4AF37;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: "Figtree", system-ui, sans-serif;
|
||||
--font-sans: "IBM Plex Sans", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
|
||||
/* Layout */
|
||||
|
|
@ -52,10 +72,13 @@ a { color: inherit; text-decoration: none; }
|
|||
img, svg { display: block; max-width: 100%; }
|
||||
|
||||
/* === LAYOUT === */
|
||||
/* Widths: 1280px (site), 520px (forms), 720px (prose/legal) */
|
||||
.container { max-width: var(--width); margin: 0 auto; padding: 0 var(--pad); }
|
||||
.container-sm { max-width: 520px; margin: 0 auto; padding: 0 var(--pad); } /* auth, onboarding */
|
||||
.container-lg { max-width: 900px; margin: 0 auto; padding: 0 var(--pad); } /* pricing, complex forms */
|
||||
.section { padding-top: 48px; padding-bottom: 48px; }
|
||||
.narrow { max-width: 800px; margin: 0 auto; }
|
||||
.prose-width { max-width: 720px; }
|
||||
.prose-width { max-width: 720px; } /* prose, legal pages */
|
||||
hr.divider { border: none; border-top: 1px solid var(--border); margin: 0 auto; max-width: var(--width); padding: 0 var(--pad); }
|
||||
|
||||
/* === TYPOGRAPHY === */
|
||||
|
|
@ -104,6 +127,7 @@ code { font-size: 0.875em; }
|
|||
.nav-dropdown-trigger { cursor: default; display: flex; align-items: center; gap: 4px; }
|
||||
.nav-dropdown-trigger::after { content: ''; display: inline-block; width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 4px solid currentColor; opacity: 0.5; }
|
||||
.nav-dropdown-menu { display: none; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); background: #fff; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 0; min-width: 180px; box-shadow: 0 4px 16px rgba(0,0,0,0.08); z-index: 60; padding-top: 12px; }
|
||||
.nav-dropdown-menu--right { right: 0; left: auto; transform: none; }
|
||||
.nav-dropdown-menu::before { content: ''; position: absolute; top: -8px; left: 0; right: 0; height: 8px; }
|
||||
.nav-dropdown:hover .nav-dropdown-menu { display: block; }
|
||||
.nav-dropdown-item { display: block; padding: 6px 16px; font-size: 0.825rem; color: var(--text-secondary); font-weight: 500; white-space: nowrap; }
|
||||
|
|
@ -145,14 +169,14 @@ code { font-size: 0.875em; }
|
|||
.grid-4-equal { display: grid; grid-template-columns: repeat(4, 1fr); gap: 24px; text-align: center; }
|
||||
|
||||
/* === TABLES === */
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 0.9375rem; }
|
||||
.data-table th { text-align: left; font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-tertiary); padding: 8px 12px; border-bottom: 2px solid var(--border); }
|
||||
.data-table th:first-child, .data-table td:first-child { padding-left: 0; }
|
||||
.data-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); color: var(--text); }
|
||||
.data-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* === CODE BLOCKS === */
|
||||
.code-block { background: var(--bg-inverse); border-radius: var(--radius); padding: 1.5rem; font-family: var(--font-mono); font-size: 0.875rem; overflow-x: auto; line-height: 1.7; color: #d1d5db; }
|
||||
.code-block { background: var(--bg-inverse); border-radius: var(--radius); padding: 1.5rem; font-family: var(--font-mono); font-size: 0.9375rem; overflow-x: auto; line-height: 1.7; color: #d1d5db; }
|
||||
.code-block .prompt { color: #fca5a5; }
|
||||
.code-block .comment { color: #737373; }
|
||||
.code-block .highlight { color: #fca5a5; }
|
||||
|
|
@ -167,7 +191,7 @@ code { font-size: 0.875em; }
|
|||
|
||||
/* === CHECKLISTS === */
|
||||
.checklist { list-style: none; }
|
||||
.checklist li { display: flex; align-items: flex-start; gap: 0.75rem; font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.75rem; }
|
||||
.checklist li { display: flex; align-items: flex-start; gap: 0.75rem; font-size: 0.9375rem; color: var(--text-secondary); margin-bottom: 0.75rem; }
|
||||
.checklist li::before { content: ''; width: 0.5rem; height: 0.5rem; flex-shrink: 0; background: var(--brand-black); margin-top: 0.375rem; }
|
||||
.checklist.red li::before { background: var(--brand-red); }
|
||||
|
||||
|
|
@ -188,6 +212,7 @@ code { font-size: 0.875em; }
|
|||
.price-card.featured { border-color: rgba(10,10,10,0.2); background: rgba(10,10,10,0.02); position: relative; }
|
||||
.price-amount { font-size: 3rem; font-weight: 800; color: var(--text); line-height: 1; }
|
||||
.price-period { font-size: 1rem; color: var(--text-secondary); font-weight: 400; }
|
||||
/* .price-grid is now .container-lg — keeping for backwards compatibility */
|
||||
.price-grid { max-width: 900px; margin: 0 auto; }
|
||||
.price-badge { position: absolute; top: -0.75rem; right: 1.5rem; }
|
||||
|
||||
|
|
@ -235,12 +260,21 @@ code { font-size: 0.875em; }
|
|||
.prose a:hover { text-decoration: underline; }
|
||||
|
||||
/* === FOOTER === */
|
||||
.footer { padding: 1.5rem 0; }
|
||||
.footer-inner { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 1.5rem; border-top: 1px solid var(--border); padding-top: 1.5rem; }
|
||||
.footer-links { display: flex; align-items: center; gap: 1rem; font-size: 0.875rem; color: var(--text-tertiary); }
|
||||
.footer-links a { color: var(--text-tertiary); transition: color 100ms ease; }
|
||||
.footer-links a:hover { color: var(--text-secondary); }
|
||||
.footer-copy { text-align: center; font-size: 0.75rem; color: var(--text-tertiary); margin-top: 0.75rem; }
|
||||
.footer { padding: 3rem 0 1.5rem; }
|
||||
.footer-grid { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr 1fr; gap: 2rem; padding-bottom: 2rem; border-bottom: 1px solid var(--border); }
|
||||
.footer-brand { font-family: var(--font-sans); font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; font-size: 0.8125rem; color: var(--text); display: inline-flex; align-items: center; gap: 6px; margin-bottom: 8px; text-decoration: none; }
|
||||
.footer-brand-square { display: inline-block; width: 12px; height: 12px; background: var(--brand-black); border-radius: 2px; }
|
||||
.footer-tagline { font-size: 0.8125rem; color: var(--text-tertiary); line-height: 1.6; max-width: 240px; margin: 0; }
|
||||
.footer-col-label { font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-tertiary); margin: 0 0 12px; }
|
||||
.footer-col-links { display: flex; flex-direction: column; gap: 8px; font-size: 0.8125rem; }
|
||||
.footer-col-links a { color: var(--text-secondary); text-decoration: none; transition: color 100ms ease; }
|
||||
.footer-col-links a:hover { color: var(--text); }
|
||||
.footer-col-links span { color: var(--text-tertiary); }
|
||||
.footer-bottom { display: flex; justify-content: space-between; align-items: center; padding-top: 1.25rem; flex-wrap: wrap; gap: 1rem; }
|
||||
.footer-copy { font-size: 0.8125rem; color: var(--text-tertiary); margin: 0; }
|
||||
.footer-social { display: flex; gap: 1rem; font-size: 0.8125rem; }
|
||||
.footer-social a { color: var(--text-tertiary); text-decoration: none; transition: color 100ms ease; }
|
||||
.footer-social a:hover { color: var(--text-secondary); }
|
||||
|
||||
/* === UTILITIES: Spacing === */
|
||||
.mt-2 { margin-top: 0.5rem; } .mb-2 { margin-bottom: 0.5rem; }
|
||||
|
|
@ -253,16 +287,22 @@ code { font-size: 0.875em; }
|
|||
|
||||
/* === UTILITIES: Text === */
|
||||
.text-center { text-align: center; }
|
||||
.text-sm { font-size: 0.875rem; }
|
||||
.text-xs { font-size: 0.75rem; }
|
||||
.text-sm { font-size: 0.9375rem; }
|
||||
.text-xs { font-size: 0.8125rem; }
|
||||
.text-secondary { color: var(--text-secondary); }
|
||||
.text-tertiary { color: var(--text-tertiary); }
|
||||
.text-accent { color: var(--brand-red); }
|
||||
.font-mono { font-family: var(--font-mono); }
|
||||
|
||||
/* Form input label — uppercase, slightly tracked, used above text inputs */
|
||||
.form-label { display: block; font-size: 0.8125rem; font-weight: 500; color: var(--text-tertiary); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
|
||||
/* === UTILITIES: Layout === */
|
||||
.w-full { width: 100%; }
|
||||
.mx-auto { margin-left: auto; margin-right: auto; }
|
||||
.max-w-sm { max-width: 520px; }
|
||||
.max-w-md { max-width: 720px; }
|
||||
.max-w-lg { max-width: 900px; }
|
||||
|
||||
/* === ANIMATIONS === */
|
||||
@keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.3; transform: scale(1.8); } }
|
||||
|
|
@ -283,7 +323,8 @@ code { font-size: 0.875em; }
|
|||
.nav-links { display: none; position: fixed; top: 64px; left: 0; right: 0; bottom: 0; background: #fff; flex-direction: column; align-items: flex-start; padding: 24px; gap: 1.25rem; font-size: 1rem; overflow-y: auto; }
|
||||
.nav-links.open { display: flex; }
|
||||
.nav-hamburger { display: flex; }
|
||||
.nav-dropdown-menu { position: static; transform: none; box-shadow: none; border: none; padding-left: 16px; min-width: 0; }
|
||||
.nav-dropdown-menu,
|
||||
.nav-dropdown-menu--right { position: static; right: auto; left: auto; transform: none; box-shadow: none; border: none; padding-left: 16px; min-width: 0; }
|
||||
.nav-dropdown:hover .nav-dropdown-menu { display: none; }
|
||||
.nav-dropdown.open .nav-dropdown-menu { display: block; }
|
||||
.nav-dropdown-trigger::after { display: inline-block; }
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ var tmplFS embed.FS
|
|||
//go:embed *.svg *.css *.png
|
||||
var static embed.FS
|
||||
|
||||
//go:embed fonts/*.woff2
|
||||
var fontsFS embed.FS
|
||||
|
||||
var templates *template.Template
|
||||
var devMode bool
|
||||
var db *sql.DB
|
||||
|
|
@ -396,6 +399,17 @@ func main() {
|
|||
}
|
||||
defer db.Close()
|
||||
|
||||
// Open corporate.db for onboarding (TLW). If it doesn't exist (e.g. dev
|
||||
// without admin running), the onboarding endpoints will return an error
|
||||
// but the rest of the site keeps working.
|
||||
if tlwErr := initTLW(); tlwErr != nil {
|
||||
log.Printf("TLW disabled: %v (onboarding endpoints will fail)", tlwErr)
|
||||
} else {
|
||||
defer corpDB.Close()
|
||||
}
|
||||
initOnboardingSecret()
|
||||
initOAuth()
|
||||
|
||||
// Migrations
|
||||
db.Exec(`ALTER TABLE pops ADD COLUMN backup_city TEXT DEFAULT ''`)
|
||||
db.Exec(`ALTER TABLE pops ADD COLUMN backup_distance_km INTEGER DEFAULT 0`)
|
||||
|
|
@ -796,6 +810,9 @@ Entries:
|
|||
http.HandleFunc("/privacy", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "privacy", Title: "Privacy Policy — clavitor"})
|
||||
})
|
||||
http.HandleFunc("/cookies", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "cookies", Title: "Cookie Policy — clavitor"})
|
||||
})
|
||||
http.HandleFunc("/terms", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "terms", Title: "Terms of Service — clavitor"})
|
||||
})
|
||||
|
|
@ -858,6 +875,76 @@ Entries:
|
|||
http.HandleFunc("/signup", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "signup", Title: "Sign up — clavitor"})
|
||||
})
|
||||
http.HandleFunc("/onboarding/profile", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" {
|
||||
handleOnboardingProfile(w, r)
|
||||
return
|
||||
}
|
||||
st := getOnboardingState(r)
|
||||
if st == nil {
|
||||
http.Redirect(w, r, "/signup", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
render(w, PageData{Page: "onboarding-profile", Title: "Tell us about yourself — clavitor"})
|
||||
})
|
||||
http.HandleFunc("/onboarding/plan", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" {
|
||||
handleOnboardingPlan(w, r)
|
||||
return
|
||||
}
|
||||
st := getOnboardingState(r)
|
||||
if st == nil || st.AddressID == "" {
|
||||
http.Redirect(w, r, "/signup", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
render(w, PageData{Page: "onboarding-plan", Title: "Pick your plan — clavitor"})
|
||||
})
|
||||
http.HandleFunc("/onboarding/done", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "onboarding-done", Title: "Welcome — clavitor"})
|
||||
})
|
||||
http.HandleFunc("/onboarding/email", handleSignupEmail)
|
||||
|
||||
// New designer onboarding flow (preview pages — backend not yet wired).
|
||||
http.HandleFunc("/onboarding/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "onboarding-login", Title: "Sign in — clavitor"})
|
||||
})
|
||||
http.HandleFunc("/onboarding/details", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "onboarding-details", Title: "Your details — clavitor"})
|
||||
})
|
||||
http.HandleFunc("/onboarding/product", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "onboarding-product", Title: "Pick your plan — clavitor"})
|
||||
})
|
||||
http.HandleFunc("/onboarding/terms", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "onboarding-terms", Title: "Review terms — clavitor"})
|
||||
})
|
||||
http.HandleFunc("/onboarding/checkout", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "onboarding-checkout", Title: "Checkout — clavitor"})
|
||||
})
|
||||
|
||||
// OAuth start/callback for each provider
|
||||
for _, name := range []string{"google", "apple", "meta"} {
|
||||
http.HandleFunc("/auth/"+name+"/start", handleOAuthStart)
|
||||
http.HandleFunc("/auth/"+name+"/callback", handleOAuthCallback)
|
||||
}
|
||||
|
||||
// Self-hosted fonts. Strong cache — woff2 files are content-addressable
|
||||
// in spirit (any change ships with a new binary build).
|
||||
http.HandleFunc("/fonts/", func(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/fonts/")
|
||||
if name == "" || strings.Contains(name, "/") || strings.Contains(name, "..") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data, err := fontsFS.ReadFile("fonts/" + name)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "font/woff2")
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Write(data)
|
||||
})
|
||||
http.HandleFunc("/styleguide", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, PageData{Page: "styleguide", Title: "clavitor — Styleguide"})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,293 @@
|
|||
// Social login (Google, Apple, Meta).
|
||||
//
|
||||
// All three implement the OAuth 2.0 / OIDC authorization-code flow.
|
||||
// Each provider needs three env vars:
|
||||
//
|
||||
// GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URL
|
||||
// APPLE_CLIENT_ID, APPLE_CLIENT_SECRET, APPLE_REDIRECT_URL
|
||||
// META_CLIENT_ID, META_CLIENT_SECRET, META_REDIRECT_URL
|
||||
//
|
||||
// If a provider's vars aren't set, its /auth/<provider>/start route returns
|
||||
// a "not configured" message and the button on /signup is functionally inert
|
||||
// (still rendered for layout consistency).
|
||||
//
|
||||
// All three providers, on success, drop the user into the same onboarding flow
|
||||
// as email signup: a customer is created in the TLW, the session cookie is set,
|
||||
// and the user is redirected to /onboarding/profile.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// oauthProvider holds everything we need to talk to one OIDC/OAuth2 provider.
|
||||
type oauthProvider struct {
|
||||
Name string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
AuthURL string // authorization endpoint
|
||||
TokenURL string // token endpoint
|
||||
Scope string // space-separated
|
||||
UserinfoURL string // optional — for providers without id_token email
|
||||
EmailFromIDT bool // if true, decode email from id_token instead of userinfo
|
||||
}
|
||||
|
||||
var oauthProviders map[string]*oauthProvider
|
||||
|
||||
func initOAuth() {
|
||||
oauthProviders = map[string]*oauthProvider{
|
||||
"google": {
|
||||
Name: "google",
|
||||
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
|
||||
RedirectURL: os.Getenv("GOOGLE_REDIRECT_URL"),
|
||||
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
TokenURL: "https://oauth2.googleapis.com/token",
|
||||
Scope: "openid email profile",
|
||||
UserinfoURL: "https://openidconnect.googleapis.com/v1/userinfo",
|
||||
},
|
||||
"apple": {
|
||||
Name: "apple",
|
||||
ClientID: os.Getenv("APPLE_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("APPLE_CLIENT_SECRET"),
|
||||
RedirectURL: os.Getenv("APPLE_REDIRECT_URL"),
|
||||
AuthURL: "https://appleid.apple.com/auth/authorize",
|
||||
TokenURL: "https://appleid.apple.com/auth/token",
|
||||
Scope: "name email",
|
||||
EmailFromIDT: true,
|
||||
},
|
||||
"meta": {
|
||||
Name: "meta",
|
||||
ClientID: os.Getenv("META_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("META_CLIENT_SECRET"),
|
||||
RedirectURL: os.Getenv("META_REDIRECT_URL"),
|
||||
AuthURL: "https://www.facebook.com/v18.0/dialog/oauth",
|
||||
TokenURL: "https://graph.facebook.com/v18.0/oauth/access_token",
|
||||
Scope: "email public_profile",
|
||||
UserinfoURL: "https://graph.facebook.com/me?fields=email,name",
|
||||
},
|
||||
}
|
||||
for name, p := range oauthProviders {
|
||||
if p.configured() {
|
||||
log.Printf("OAuth: %s configured", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *oauthProvider) configured() bool {
|
||||
return p.ClientID != "" && p.ClientSecret != "" && p.RedirectURL != ""
|
||||
}
|
||||
|
||||
// handleOAuthStart: GET /auth/{provider}/start
|
||||
// Redirects the browser to the provider's consent screen.
|
||||
func handleOAuthStart(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/auth/")
|
||||
name = strings.TrimSuffix(name, "/start")
|
||||
p, ok := oauthProviders[name]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !p.configured() {
|
||||
http.Error(w, fmt.Sprintf("%s sign-in is not configured yet — try email", name), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// CSRF state — random nonce stored in a short-lived cookie that the
|
||||
// callback verifies before exchanging the code.
|
||||
state := randomState()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "clv_oauth_state_" + name,
|
||||
Value: state,
|
||||
Path: "/",
|
||||
MaxAge: 600,
|
||||
HttpOnly: true,
|
||||
Secure: !devMode,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("client_id", p.ClientID)
|
||||
q.Set("redirect_uri", p.RedirectURL)
|
||||
q.Set("response_type", "code")
|
||||
q.Set("scope", p.Scope)
|
||||
q.Set("state", state)
|
||||
if name == "apple" {
|
||||
q.Set("response_mode", "form_post")
|
||||
}
|
||||
http.Redirect(w, r, p.AuthURL+"?"+q.Encode(), http.StatusFound)
|
||||
}
|
||||
|
||||
// handleOAuthCallback: GET (or POST for Apple) /auth/{provider}/callback
|
||||
// Exchanges the code for a token, fetches the email, and starts the onboarding session.
|
||||
func handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/auth/")
|
||||
name = strings.TrimSuffix(name, "/callback")
|
||||
p, ok := oauthProviders[name]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !p.configured() {
|
||||
http.Error(w, "provider not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Apple uses form_post — read params from form. Others use query string.
|
||||
var code, state string
|
||||
if r.Method == "POST" {
|
||||
r.ParseForm()
|
||||
code = r.FormValue("code")
|
||||
state = r.FormValue("state")
|
||||
} else {
|
||||
code = r.URL.Query().Get("code")
|
||||
state = r.URL.Query().Get("state")
|
||||
}
|
||||
if code == "" {
|
||||
http.Error(w, "missing code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify CSRF state cookie
|
||||
c, err := r.Cookie("clv_oauth_state_" + name)
|
||||
if err != nil || c.Value == "" || c.Value != state {
|
||||
http.Error(w, "invalid state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange the code for a token
|
||||
tokenForm := url.Values{}
|
||||
tokenForm.Set("grant_type", "authorization_code")
|
||||
tokenForm.Set("code", code)
|
||||
tokenForm.Set("client_id", p.ClientID)
|
||||
tokenForm.Set("client_secret", p.ClientSecret)
|
||||
tokenForm.Set("redirect_uri", p.RedirectURL)
|
||||
|
||||
req, err := http.NewRequest("POST", p.TokenURL, strings.NewReader(tokenForm.Encode()))
|
||||
if err != nil {
|
||||
http.Error(w, "token request failed", 500)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("oauth %s token exchange failed: %v", name, err)
|
||||
http.Error(w, "token exchange failed", 502)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
log.Printf("oauth %s token endpoint %d: %s", name, resp.StatusCode, string(body))
|
||||
http.Error(w, "token exchange rejected", 502)
|
||||
return
|
||||
}
|
||||
var tokResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
}
|
||||
json.Unmarshal(body, &tokResp)
|
||||
|
||||
// Get the email
|
||||
var email string
|
||||
if p.EmailFromIDT && tokResp.IDToken != "" {
|
||||
email = emailFromIDToken(tokResp.IDToken)
|
||||
} else if p.UserinfoURL != "" && tokResp.AccessToken != "" {
|
||||
email = fetchEmailFromUserinfo(p.UserinfoURL, tokResp.AccessToken)
|
||||
}
|
||||
if email == "" {
|
||||
log.Printf("oauth %s: could not get email from token/userinfo", name)
|
||||
http.Error(w, "could not retrieve email from provider", 502)
|
||||
return
|
||||
}
|
||||
email = strings.ToLower(email)
|
||||
|
||||
// TLW: create or fetch the customer for this email
|
||||
if corpDB == nil {
|
||||
http.Error(w, "onboarding offline", 503)
|
||||
return
|
||||
}
|
||||
ourID, _, found := findCustomerByEmail(email)
|
||||
if !found {
|
||||
res := tlwCreateCustomer(map[string]interface{}{"email": email})
|
||||
if res.Err != nil {
|
||||
log.Printf("oauth %s: tlwCreateCustomer for %s: %v", name, email, res.Err)
|
||||
http.Error(w, "could not create account", 500)
|
||||
return
|
||||
}
|
||||
ourID = res.OurID
|
||||
}
|
||||
|
||||
setOnboardingCookie(w, &onboardingState{
|
||||
Email: email,
|
||||
Provider: name,
|
||||
CustomerID: ourID,
|
||||
})
|
||||
// Clear the CSRF state cookie
|
||||
http.SetCookie(w, &http.Cookie{Name: "clv_oauth_state_" + name, Value: "", Path: "/", MaxAge: -1})
|
||||
|
||||
http.Redirect(w, r, "/onboarding/profile", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func randomState() string {
|
||||
buf := make([]byte, 24)
|
||||
rand.Read(buf)
|
||||
return base64.RawURLEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// emailFromIDToken decodes the unverified payload of a JWT id_token.
|
||||
// We trust it because the token came directly from the provider's token
|
||||
// endpoint over TLS — there's no third party to forge it. (For paranoid
|
||||
// production use, swap in a JWKS-verifying library.)
|
||||
func emailFromIDToken(idToken string) string {
|
||||
parts := strings.Split(idToken, ".")
|
||||
if len(parts) != 3 {
|
||||
return ""
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
// Some providers use std (padded) base64
|
||||
payload, err = base64.StdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
var claims struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
json.Unmarshal(payload, &claims)
|
||||
return claims.Email
|
||||
}
|
||||
|
||||
func fetchEmailFromUserinfo(endpoint, accessToken string) string {
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var info struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
json.Unmarshal(body, &info)
|
||||
return info.Email
|
||||
}
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
// Onboarding flow: signup → profile → plan → checkout
|
||||
//
|
||||
// Session is a signed cookie containing the in-progress onboarding state.
|
||||
// HMAC-SHA256 keyed with ONBOARDING_SECRET (auto-generated if missing).
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const onboardingCookieName = "clv_onb"
|
||||
const onboardingCookieMaxAge = 3600 // 1 hour to complete the flow
|
||||
|
||||
// onboardingSecret is the HMAC key for signing the session cookie.
|
||||
// Loaded from env or generated at startup (sessions are short-lived so
|
||||
// regeneration on restart only kicks people back to step 1).
|
||||
var onboardingSecret []byte
|
||||
|
||||
func initOnboardingSecret() {
|
||||
if s := readEnv("ONBOARDING_SECRET"); s != "" {
|
||||
onboardingSecret = []byte(s)
|
||||
return
|
||||
}
|
||||
buf := make([]byte, 32)
|
||||
rand.Read(buf)
|
||||
onboardingSecret = buf
|
||||
log.Println("onboarding: generated ephemeral session secret (set ONBOARDING_SECRET to persist)")
|
||||
}
|
||||
|
||||
// onboardingState is what we carry across pages in the signed cookie.
|
||||
// Kept small — Paddle billing fields are written to corporate.db immediately.
|
||||
type onboardingState struct {
|
||||
Email string `json:"email"`
|
||||
Provider string `json:"provider"` // "email", "google", "apple", "meta"
|
||||
CustomerID string `json:"customer_id,omitempty"` // ctm_xxx after step 1
|
||||
AddressID string `json:"address_id,omitempty"` // add_xxx after step 2
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
func setOnboardingCookie(w http.ResponseWriter, st *onboardingState) {
|
||||
st.CreatedAt = time.Now().Unix()
|
||||
payload, _ := json.Marshal(st)
|
||||
encoded := base64.RawURLEncoding.EncodeToString(payload)
|
||||
mac := hmac.New(sha256.New, onboardingSecret)
|
||||
mac.Write([]byte(encoded))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
value := encoded + "." + sig
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: onboardingCookieName,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: onboardingCookieMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: !devMode,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func getOnboardingState(r *http.Request) *onboardingState {
|
||||
c, err := r.Cookie(onboardingCookieName)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
parts := strings.SplitN(c.Value, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil
|
||||
}
|
||||
mac := hmac.New(sha256.New, onboardingSecret)
|
||||
mac.Write([]byte(parts[0]))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(expected), []byte(parts[1])) {
|
||||
return nil
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var st onboardingState
|
||||
if err := json.Unmarshal(payload, &st); err != nil {
|
||||
return nil
|
||||
}
|
||||
// Expire stale sessions
|
||||
if time.Now().Unix()-st.CreatedAt > onboardingCookieMaxAge {
|
||||
return nil
|
||||
}
|
||||
return &st
|
||||
}
|
||||
|
||||
func clearOnboardingCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: onboardingCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: !devMode,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// readEnv is a thin wrapper to make testing easier later.
|
||||
func readEnv(key string) string {
|
||||
return os.Getenv(key)
|
||||
}
|
||||
|
||||
// --- handlers ---
|
||||
|
||||
// handleSignupStart: GET /signup — renders the email/social choice page.
|
||||
// (Wired in main.go via the existing /signup route.)
|
||||
|
||||
// handleSignupEmail: POST /onboarding/email — user typed an email address.
|
||||
// Creates the customer in DB+Paddle, sets the cookie, redirects to /onboarding/profile.
|
||||
func handleSignupEmail(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
if corpDB == nil {
|
||||
jsonError(w, "onboarding offline (corporate.db not available)", 503)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "invalid json", 400)
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||
if !looksLikeEmail(email) {
|
||||
jsonError(w, "invalid email", 400)
|
||||
return
|
||||
}
|
||||
|
||||
// If a customer with this email exists, reuse it (resume onboarding).
|
||||
ourID, _, found := findCustomerByEmail(email)
|
||||
if !found {
|
||||
res := tlwCreateCustomer(map[string]interface{}{"email": email})
|
||||
if res.Err != nil {
|
||||
log.Printf("onboarding: tlwCreateCustomer failed for %s: %v", email, res.Err)
|
||||
jsonError(w, "could not create account", 500)
|
||||
return
|
||||
}
|
||||
ourID = res.OurID
|
||||
}
|
||||
|
||||
setOnboardingCookie(w, &onboardingState{
|
||||
Email: email,
|
||||
Provider: "email",
|
||||
CustomerID: ourID,
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"ok":true,"next":"/onboarding/profile"}`))
|
||||
}
|
||||
|
||||
// handleOnboardingProfile: POST /onboarding/profile — name + address.
|
||||
// Updates the customer name and creates the billing address.
|
||||
func handleOnboardingProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
st := getOnboardingState(r)
|
||||
if st == nil || st.CustomerID == "" {
|
||||
jsonError(w, "session expired", 401)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Address1 string `json:"address1"`
|
||||
City string `json:"city"`
|
||||
Region string `json:"region"`
|
||||
Zip string `json:"zip"`
|
||||
CountryCode string `json:"country_code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "invalid json", 400)
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.CountryCode == "" {
|
||||
jsonError(w, "name and country are required", 400)
|
||||
return
|
||||
}
|
||||
if len(req.CountryCode) != 2 {
|
||||
jsonError(w, "country must be a 2-letter ISO code", 400)
|
||||
return
|
||||
}
|
||||
|
||||
// Update the customer name in our DB. (Paddle update is best-effort:
|
||||
// the customer was created with email only; we PATCH it on the next
|
||||
// /customers/{id} call. For now, just update locally — Paddle picks up
|
||||
// the name when we send the address creation.)
|
||||
corpDB.Exec(`UPDATE customers SET name = ?, updated_at = ? WHERE id = ?`,
|
||||
req.Name, time.Now().Unix(), st.CustomerID)
|
||||
|
||||
// Create the address via TLW (writes to DB + Paddle).
|
||||
addr := map[string]interface{}{
|
||||
"customer_id": st.CustomerID,
|
||||
"country_code": strings.ToUpper(req.CountryCode),
|
||||
}
|
||||
if req.City != "" {
|
||||
addr["city"] = req.City
|
||||
}
|
||||
if req.Region != "" {
|
||||
addr["region"] = req.Region
|
||||
}
|
||||
if req.Zip != "" {
|
||||
addr["zip_code"] = req.Zip
|
||||
}
|
||||
if req.Address1 != "" {
|
||||
addr["description"] = req.Address1
|
||||
}
|
||||
res := tlwCreateAddress(addr)
|
||||
if res.Err != nil {
|
||||
log.Printf("onboarding: tlwCreateAddress failed: %v", res.Err)
|
||||
jsonError(w, "could not save address", 500)
|
||||
return
|
||||
}
|
||||
|
||||
st.AddressID = res.OurID
|
||||
setOnboardingCookie(w, st)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"ok":true,"next":"/onboarding/plan"}`))
|
||||
}
|
||||
|
||||
// handleOnboardingPlan: POST /onboarding/plan — user picked a subscription tier.
|
||||
// Looks up the Paddle price ID, creates a transaction, returns the checkout URL.
|
||||
func handleOnboardingPlan(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
st := getOnboardingState(r)
|
||||
if st == nil || st.CustomerID == "" || st.AddressID == "" {
|
||||
jsonError(w, "session expired", 401)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Plan string `json:"plan"` // e.g. "personal", "family", "pro"
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "invalid json", 400)
|
||||
return
|
||||
}
|
||||
priceID := lookupPriceID(req.Plan, "usd")
|
||||
if priceID == "" {
|
||||
jsonError(w, "unknown plan", 400)
|
||||
return
|
||||
}
|
||||
|
||||
// Need the Paddle IDs for both customer and address to create a transaction.
|
||||
var paddleCust, paddleAddr string
|
||||
corpDB.QueryRow(`SELECT COALESCE(paddle_id,'') FROM customers WHERE id = ?`, st.CustomerID).Scan(&paddleCust)
|
||||
corpDB.QueryRow(`SELECT COALESCE(paddle_id,'') FROM addresses WHERE id = ?`, st.AddressID).Scan(&paddleAddr)
|
||||
if paddleCust == "" || paddleAddr == "" {
|
||||
// Paddle wasn't configured / sync failed. Don't fail — let the user
|
||||
// reach the success page; the operator can retry the sync from admin.
|
||||
clearOnboardingCookie(w)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"ok":true,"checkout_url":"/onboarding/done?pending=1"}`))
|
||||
return
|
||||
}
|
||||
|
||||
_, checkoutURL, err := tlwCreateTransaction(paddleCust, paddleAddr, priceID)
|
||||
if err != nil {
|
||||
log.Printf("onboarding: tlwCreateTransaction failed: %v", err)
|
||||
jsonError(w, "could not create checkout", 500)
|
||||
return
|
||||
}
|
||||
|
||||
// Onboarding state is no longer needed — Paddle takes over.
|
||||
clearOnboardingCookie(w)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
resp := map[string]interface{}{"ok": true, "checkout_url": checkoutURL}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// lookupPriceID maps a plan name to a Paddle price ID. Hardcoded for now —
|
||||
// will move to DB-backed config later. Mirrors admin/paddle_checkout.go.
|
||||
func lookupPriceID(plan, currency string) string {
|
||||
key := strings.ToLower(plan) + "_yearly_" + strings.ToLower(currency)
|
||||
priceIDs := map[string]string{
|
||||
"personal_yearly_usd": "pri_01knejm7ft2ska5r4qff2gm9r4",
|
||||
"family_yearly_usd": "pri_01knejm7xs9kqt0vn61dx6q808",
|
||||
"pro_yearly_usd": "pri_01knejm8djq4p63rmsxze7by58",
|
||||
}
|
||||
return priceIDs[key]
|
||||
}
|
||||
|
||||
func looksLikeEmail(s string) bool {
|
||||
if len(s) < 3 || len(s) > 254 {
|
||||
return false
|
||||
}
|
||||
at := strings.Index(s, "@")
|
||||
if at < 1 || at == len(s)-1 {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(s[at:], ".") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func jsonError(w http.ResponseWriter, msg string, status int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprintf(w, `{"ok":false,"error":%q}`, msg)
|
||||
}
|
||||
|
||||
|
|
@ -13,9 +13,8 @@
|
|||
<meta name="twitter:card" content="summary">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Figtree:wght@400..700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="preload" href="/fonts/ibm-plex-sans-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/fonts/jetbrains-mono-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/clavitor.css">
|
||||
{{if eq .Page "install"}}{{template "install-head"}}{{end}}
|
||||
{{if eq .Page "styleguide"}}{{template "styleguide-head"}}{{end}}
|
||||
|
|
@ -105,6 +104,21 @@
|
|||
</div>
|
||||
</div>
|
||||
<a href="/pricing" class="nav-link{{if eq .ActiveNav "pricing"}} active{{end}}">Pricing</a>
|
||||
<div class="nav-dropdown nav-dropdown--locale">
|
||||
<span class="nav-link nav-dropdown-trigger" id="localeTrigger">🌐 EN / $</span>
|
||||
<div class="nav-dropdown-menu nav-dropdown-menu--right">
|
||||
<div style="padding:8px 16px;font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.08em;">Language</div>
|
||||
<a href="/" class="nav-dropdown-item active" data-lang="en">🇺🇸 English</a>
|
||||
<a href="/de" class="nav-dropdown-item" data-lang="de">🇩🇪 Deutsch</a>
|
||||
<a href="/fr" class="nav-dropdown-item" data-lang="fr">🇫🇷 Français</a>
|
||||
<div style="border-top:1px solid var(--border);margin:8px 0;"></div>
|
||||
<div style="padding:8px 16px;font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.08em;">Currency</div>
|
||||
<a href="#" class="nav-dropdown-item active" data-currency="USD">USD $</a>
|
||||
<a href="#" class="nav-dropdown-item" data-currency="EUR">EUR €</a>
|
||||
<a href="#" class="nav-dropdown-item" data-currency="CHF">CHF</a>
|
||||
<a href="#" class="nav-dropdown-item" data-currency="GBP">GBP £</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="#" class="nav-link btn btn-ghost">Sign in</a>
|
||||
<a href="/hosted" class="btn btn-primary">Get hosted — <s>$20</s> $12/yr</a>
|
||||
</div>
|
||||
|
|
@ -116,6 +130,7 @@
|
|||
{{else if eq .Page "pricing"}}{{template "pricing" .}}
|
||||
{{else if eq .Page "privacy"}}{{template "privacy" .}}
|
||||
{{else if eq .Page "terms"}}{{template "terms" .}}
|
||||
{{else if eq .Page "cookies"}}{{template "cookies" .}}
|
||||
{{else if eq .Page "sources"}}{{template "sources" .}}
|
||||
{{else if eq .Page "upgrade"}}{{template "upgrade" .}}
|
||||
{{else if eq .Page "developers"}}{{template "developers" .}}
|
||||
|
|
@ -128,13 +143,20 @@
|
|||
{{else if eq .Page "codex"}}{{template "codex" .}}
|
||||
{{else if eq .Page "openclaw"}}{{template "openclaw" .}}
|
||||
{{else if eq .Page "openclaw-cn"}}{{template "openclaw-cn" .}}
|
||||
{{else if eq .Page "pricing-new"}}{{template "pricing-new" .}}
|
||||
{{else if eq .Page "install-new"}}{{template "install-new" .}}
|
||||
{{else if eq .Page "for-consumer"}}{{template "for-consumer" .}}
|
||||
{{else if eq .Page "for-smb"}}{{template "for-smb" .}}
|
||||
{{else if eq .Page "for-mme"}}{{template "for-mme" .}}
|
||||
{{else if eq .Page "for-enterprise"}}{{template "for-enterprise" .}}
|
||||
{{else if eq .Page "for-msp"}}{{template "for-msp" .}}
|
||||
{{else if eq .Page "onboarding-profile"}}{{template "onboarding-profile" .}}
|
||||
{{else if eq .Page "onboarding-plan"}}{{template "onboarding-plan" .}}
|
||||
{{else if eq .Page "onboarding-done"}}{{template "onboarding-done" .}}
|
||||
{{else if eq .Page "onboarding-login"}}{{template "onboarding-login" .}}
|
||||
{{else if eq .Page "onboarding-details"}}{{template "onboarding-details" .}}
|
||||
{{else if eq .Page "onboarding-product"}}{{template "onboarding-product" .}}
|
||||
{{else if eq .Page "onboarding-terms"}}{{template "onboarding-terms" .}}
|
||||
{{else if eq .Page "onboarding-checkout"}}{{template "onboarding-checkout" .}}
|
||||
{{end}}
|
||||
{{if ne .Page "styleguide"}}{{template "footer"}}{{end}}
|
||||
{{if eq .Page "hosted" "glass"}}{{template "ping-script"}}{{end}}
|
||||
|
|
@ -145,7 +167,67 @@
|
|||
{{else if eq .Page "status"}}{{template "status-script"}}
|
||||
{{else if eq .Page "signup"}}{{template "signup-script"}}
|
||||
{{else if eq .Page "install"}}{{template "install-script"}}
|
||||
{{else if eq .Page "onboarding-profile"}}{{template "onboarding-profile-script"}}
|
||||
{{else if eq .Page "onboarding-plan"}}{{template "onboarding-plan-script"}}
|
||||
{{end}}
|
||||
<script>document.querySelectorAll('.nav-dropdown-trigger').forEach(t=>t.addEventListener('click',()=>t.parentElement.classList.toggle('open')))</script>
|
||||
<script>
|
||||
document.querySelectorAll('.nav-dropdown-trigger').forEach(t=>t.addEventListener('click',()=>t.parentElement.classList.toggle('open')));
|
||||
|
||||
// Locale selector
|
||||
(function() {
|
||||
const localeTrigger = document.getElementById('localeTrigger');
|
||||
if (!localeTrigger) return;
|
||||
|
||||
const dropdown = localeTrigger.parentElement;
|
||||
const langItems = dropdown.querySelectorAll('[data-lang]');
|
||||
const currencyItems = dropdown.querySelectorAll('[data-currency]');
|
||||
|
||||
// Load saved preferences
|
||||
const saved = JSON.parse(localStorage.getItem('clavitor-locale') || '{}');
|
||||
const currentLang = saved.lang || 'en';
|
||||
const currentCurrency = saved.currency || 'USD';
|
||||
|
||||
function updateDisplay() {
|
||||
const lang = dropdown.querySelector('[data-lang].active')?.dataset.lang || currentLang;
|
||||
const currency = dropdown.querySelector('[data-currency].active')?.dataset.currency || currentCurrency;
|
||||
const langFlags = { en: '🇺🇸', de: '🇩🇪', fr: '🇫🇷' };
|
||||
localeTrigger.textContent = `${langFlags[lang] || '🌐'} ${lang.toUpperCase()} / ${currency}`;
|
||||
}
|
||||
|
||||
// Set initial active states
|
||||
langItems.forEach(el => {
|
||||
if (el.dataset.lang === currentLang) el.classList.add('active');
|
||||
else el.classList.remove('active');
|
||||
});
|
||||
currencyItems.forEach(el => {
|
||||
if (el.dataset.currency === currentCurrency) el.classList.add('active');
|
||||
else el.classList.remove('active');
|
||||
});
|
||||
updateDisplay();
|
||||
|
||||
// Handle language selection
|
||||
langItems.forEach(el => el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
langItems.forEach(i => i.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
localStorage.setItem('clavitor-locale', JSON.stringify({ lang: el.dataset.lang, currency: currentCurrency }));
|
||||
updateDisplay();
|
||||
// Navigate to language path
|
||||
if (el.dataset.lang === 'en') window.location.href = '/';
|
||||
else window.location.href = '/' + el.dataset.lang;
|
||||
}));
|
||||
|
||||
// Handle currency selection
|
||||
currencyItems.forEach(el => el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
currencyItems.forEach(i => i.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
localStorage.setItem('clavitor-locale', JSON.stringify({ lang: currentLang, currency: el.dataset.currency }));
|
||||
updateDisplay();
|
||||
// Refresh page to apply currency (or fetch rates via JS)
|
||||
window.location.reload();
|
||||
}));
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@
|
|||
<div class="card mb-6">
|
||||
<h3 class="mb-4">1. Create a token</h3>
|
||||
<p class="mb-4">Open your vault → <strong>Tokens</strong> → <strong>Create</strong>. Give it a label like “Claude Code”. Copy the token — it’s shown only once.</p>
|
||||
<p style="font-size:0.8125rem;color:var(--muted)">Each token is an independent API key. Create one per agent or project.</p>
|
||||
<p class="text-sm text-tertiary">Each token is an independent API key. Create one per agent or project.</p>
|
||||
</div>
|
||||
|
||||
<div class="card mb-6">
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
<p class="mb-4">In your terminal, run:</p>
|
||||
<div class="code-block">claude mcp add clavitor --transport http --url http://localhost:1984/mcp \
|
||||
--header "Authorization: Bearer clavitor_your_token_here"</div>
|
||||
<p class="mt-4" style="font-size:0.8125rem;color:var(--muted)">That’s it. Claude Code picks up the new server automatically.</p>
|
||||
<p class="mt-4 text-sm text-tertiary">That’s it. Claude Code picks up the new server automatically.</p>
|
||||
</div>
|
||||
|
||||
<div class="card mb-6">
|
||||
|
|
@ -66,7 +66,7 @@ URL: http://localhost:1984/mcp</pre></div>
|
|||
<div class="card mb-6" style="border-color:var(--border-gold)">
|
||||
<h3 class="mb-4">Using hosted <span class="vaultname">clav<span class="n">itor</span></span>?</h3>
|
||||
<p class="mb-4">Your MCP URL includes your unique vault identifier. You can find the exact URL in your <strong>Account Information</strong> page after signing up.</p>
|
||||
<p style="font-size:0.875rem;color:var(--muted)">It looks like: <code>https://clavitor.com/<em>your_vault_id</em>/mcp</code></p>
|
||||
<p class="text-sm text-tertiary">It looks like: <code>https://clavitor.ai/<em>your_vault_id</em>/mcp</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -78,13 +78,13 @@ URL: http://localhost:1984/mcp</pre></div>
|
|||
<div class="card card-hover alt">
|
||||
<h3 class="mb-3">“Deploy to production”</h3>
|
||||
<p>Claude looks up your server credentials, SSH key, and any required API tokens — then does the deployment.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem"><pre>get_credential("aws-production")
|
||||
<div class="code-block mt-3"><pre>get_credential("aws-production")
|
||||
get_totp("aws") → 283941 (expires in 22s)</pre></div>
|
||||
</div>
|
||||
<div class="card card-hover alt">
|
||||
<h3 class="mb-3">“Log in to GitHub and check the CI”</h3>
|
||||
<p>Claude finds the credential, generates a live TOTP code, and completes the 2FA flow. No phone needed.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem"><pre>get_credential("github")
|
||||
<div class="code-block mt-3"><pre>get_credential("github")
|
||||
get_totp("github") → 847203 (expires in 14s)</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -92,17 +92,17 @@ get_totp("github") → 847203 (expires in 14s)</pre></div>
|
|||
<div class="card card-hover">
|
||||
<h3 class="mb-3">“Find my database credentials”</h3>
|
||||
<p>Full-text search across all entries — titles, URLs, usernames, notes.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem">search_vault("postgres")</div>
|
||||
<div class="code-block mt-3">search_vault("postgres")</div>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">“What’s expiring soon?”</h3>
|
||||
<p>Claude checks for credentials, cards, or documents expiring within any timeframe.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem">check_expiring(30)</div>
|
||||
<div class="code-block mt-3">check_expiring(30)</div>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">“Show me everything”</h3>
|
||||
<p>List all entries the agent has access to. Useful for inventory or onboarding a new project.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem">list_credentials()</div>
|
||||
<div class="code-block mt-3">list_credentials()</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ Authorization = "Bearer clavitor_your_token_here"</pre></div>
|
|||
<div class="card mb-6" style="border-color:var(--border-gold)">
|
||||
<h3 class="mb-4">Using hosted <span class="vaultname">clav<span class="n">itor</span></span>?</h3>
|
||||
<p class="mb-4">Your URL includes your unique vault identifier. You can find the exact URL in your <strong>Account Information</strong> page after signing up.</p>
|
||||
<p style="font-size:0.875rem;color:var(--muted)">It looks like: <code>https://clavitor.com/<em>your_vault_id</em>/mcp</code> or <code>.../<em>your_vault_id</em>/api/</code></p>
|
||||
<p class="text-sm text-tertiary">It looks like: <code>https://clavitor.ai/<em>your_vault_id</em>/mcp</code> or <code>.../<em>your_vault_id</em>/api/</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ GET /api/ext/totp/{id} # get live TOTP code
|
|||
GET /api/generate?length=32 # generate random password</pre></div>
|
||||
</div>
|
||||
|
||||
<p style="font-size:0.875rem;color:var(--muted)">All endpoints require <code>Authorization: Bearer clavitor_...</code></p>
|
||||
<p class="text-sm text-tertiary">All endpoints require <code>Authorization: Bearer clavitor_...</code></p>
|
||||
</div>
|
||||
|
||||
<div class="section container">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
{{define "cookies"}}
|
||||
<div class="hero container">
|
||||
<p class="label mb-3">Legal</p>
|
||||
<h1 class="mb-6">Cookie Policy</h1>
|
||||
<p class="lead mb-8">Two cookies. Both strictly necessary. No tracking, no analytics, no third parties — ever.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">No banner. Here's why.</h2>
|
||||
<p class="mb-4">EU law (ePrivacy Directive Art. 5(3) and GDPR) requires consent before storing or accessing information on a user's device <em>unless</em> that information is "strictly necessary for the provision of a service explicitly requested by the user." Session cookies, authentication cookies, and CSRF security cookies are explicitly named in the exemption.</p>
|
||||
<p class="mb-6">Clavitor uses exactly two cookies. Both are strictly necessary. Both are exempt from the consent requirement. There is nothing to consent to, so we do not show a banner asking for consent — that would be misleading.</p>
|
||||
|
||||
<h2 class="mb-4">The two cookies</h2>
|
||||
|
||||
<table class="data-table mb-8">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Lifetime</th><th>Purpose</th><th>Flags</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>clv_onb</code></td>
|
||||
<td>1 hour</td>
|
||||
<td>Carries your in-progress signup state across the three onboarding pages (signup → profile → plan). Without this cookie, the form on page 2 would have no idea who page 1 was. The value is HMAC-signed so it cannot be forged or modified by anyone but us.</td>
|
||||
<td><code>HttpOnly</code><br><code>Secure</code><br><code>SameSite=Lax</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>clv_oauth_state_*</code></td>
|
||||
<td>10 minutes</td>
|
||||
<td>One per provider (Google, Apple, Meta) when you click a social sign-in button. Holds a random CSRF nonce that the OAuth callback verifies before exchanging the code — the standard defence against CSRF attacks on OAuth flows. Deleted the moment the callback completes.</td>
|
||||
<td><code>HttpOnly</code><br><code>Secure</code><br><code>SameSite=Lax</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="mb-4">Why these are strictly necessary</h2>
|
||||
<p class="mb-4"><strong><code>clv_onb</code></strong> is required to provide the signup feature you explicitly requested by clicking "Get started". Without it, the multi-step form cannot remember which account you're creating. There is no functional alternative.</p>
|
||||
<p class="mb-6"><strong><code>clv_oauth_state_*</code></strong> is a security cookie required to safely complete a social sign-in. Without it, your OAuth callback could be hijacked by a CSRF attack. The exemption explicitly covers "user-input cookies" and security cookies of this kind (see WP29 Opinion 04/2012, EDPB Guidelines 03/2022).</p>
|
||||
|
||||
<h2 class="mb-4">What we do NOT use</h2>
|
||||
<div class="card alt mb-6">
|
||||
<ul class="mb-0">
|
||||
<li><strong>Analytics:</strong> No Google Analytics, Plausible, Fathom, or self-hosted analytics. Zero.</li>
|
||||
<li><strong>Marketing pixels:</strong> No Meta Pixel, no LinkedIn Insight, no retargeting.</li>
|
||||
<li><strong>Third-party scripts:</strong> No CDN-hosted libraries. No Google Fonts (we self-host the woff2 files).</li>
|
||||
<li><strong>Local storage / IndexedDB / sessionStorage:</strong> none. EU law treats these the same as cookies, and we use none of them.</li>
|
||||
<li><strong>Fingerprinting:</strong> none.</li>
|
||||
<li><strong>"Remember me" cookies:</strong> none. Future authentication will use WebAuthn passkeys, which your browser stores separately from cookies.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-4">How to control them</h2>
|
||||
<p class="mb-4">Both cookies are deleted automatically — <code>clv_oauth_state_*</code> after the OAuth callback, <code>clv_onb</code> after one hour or when you finish signup. You can delete them manually any time through your browser's cookie settings:</p>
|
||||
<ul class="mb-6">
|
||||
<li><strong>Chrome / Edge / Brave:</strong> Settings → Privacy and security → Cookies and other site data</li>
|
||||
<li><strong>Firefox:</strong> Settings → Privacy & Security → Cookies and Site Data</li>
|
||||
<li><strong>Safari:</strong> Preferences → Privacy → Cookies and website data</li>
|
||||
</ul>
|
||||
<p class="mb-6 text-secondary"><strong>Note:</strong> Blocking <code>clv_onb</code> mid-signup will reset you to step 1. Blocking <code>clv_oauth_state_*</code> will prevent social sign-in from completing.</p>
|
||||
|
||||
<h2 class="mb-4">Changes to this policy</h2>
|
||||
<p class="mb-4">If we ever add a third cookie, we will update this page and the privacy policy. We will not add tracking or analytics cookies, ever — that's a brand commitment, not just a legal one.</p>
|
||||
|
||||
<p class="text-sm text-tertiary">Last updated: April 2026</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
{{define "dpa"}}
|
||||
<div class="hero container">
|
||||
<p class="label mb-3">Legal</p>
|
||||
<h1 class="mb-6">Data Processing Agreement</h1>
|
||||
<p class="lead mb-8">Standard contractual clauses for GDPR and Swiss FADP compliance. Automatically applies to all hosted (paid) subscriptions.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container prose-width">
|
||||
<h2 class="mb-4">1. Definitions</h2>
|
||||
<p class="mb-4"><strong>"Controller"</strong> means the natural person who creates and owns the data within their Clavitor vault. You are always the Controller of your own credentials and personal data.</p>
|
||||
<p class="mb-4"><strong>"Processor"</strong> means Clavitor.ai, the entity that provides hosting infrastructure, encryption orchestration, and data storage services on behalf of the Controller.</p>
|
||||
<p class="mb-4"><strong>"Data Subject"</strong> means the natural person whose personal data is processed — this may be you (the Controller) or others whose data you store in your vault (family members, employees, clients).</p>
|
||||
<p class="mb-4"><strong>"Personal Data"</strong> means any information relating to an identified or identifiable natural person stored in your vault, including but not limited to: credentials, passwords, API keys, payment card data, identity documents, and contact information.</p>
|
||||
<p class="mb-6"><strong>"Processing"</strong> means any operation performed on Personal Data, including collection, storage, encryption, transmission, backup, and deletion.</p>
|
||||
|
||||
<h2 class="mb-4">2. Processing Details</h2>
|
||||
<table class="data-table mb-6">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Subject matter</strong></td>
|
||||
<td>Encrypted credential vault hosting and related services</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Duration</strong></td>
|
||||
<td>For the term of your subscription, plus 30 days for compliance backups (not restorable)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Nature and purpose</strong></td>
|
||||
<td>Storage of encrypted data; authentication orchestration; backup and disaster recovery; technical support (with zero-knowledge limitations)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Type of Personal Data</strong></td>
|
||||
<td>User credentials, authentication tokens, payment card data, identity documents, secure notes, TOTP seeds, metadata</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Categories of Data Subjects</strong></td>
|
||||
<td>Controller (account holder) and third parties whose data Controller chooses to store</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="mb-4">3. Obligations of the Processor</h2>
|
||||
<p class="mb-4"><strong>3.1 Process only on documented instructions.</strong> Clavitor processes Personal Data only to provide the hosted vault service as described in our Terms of Service. We do not use data for our own purposes, train AI models, derive insights, or monetize beyond subscription fees.</p>
|
||||
<p class="mb-4"><strong>3.2 Ensure confidentiality.</strong> All Clavitor personnel with potential access to infrastructure are bound by confidentiality agreements. Access is granted on principle of least privilege and logged.</p>
|
||||
<p class="mb-4"><strong>3.3 Implement security measures.</strong> We implement:</p>
|
||||
<ul class="mb-4">
|
||||
<li>End-to-end encryption: Data encrypted at rest and in transit</li>
|
||||
<li>Tiered encryption (L2/L3): Identity fields encrypted with WebAuthn PRF — not decryptable by us</li>
|
||||
<li>Zero-knowledge architecture: We cannot decrypt vault contents; only metadata (entry IDs, types, timestamps) is readable</li>
|
||||
<li>WebAuthn/passkey authentication: No passwords stored server-side</li>
|
||||
<li>Geographic distribution: 21 POPs with encrypted replication</li>
|
||||
<li>Incident response: 24/7 monitoring, automated alerts, documented breach procedures</li>
|
||||
</ul>
|
||||
<p class="mb-4"><strong>3.4 Subprocessor transparency.</strong> We use only the subprocessors listed in our <a href="/subprocessors">Subprocessor List</a>. We notify subscribers 30 days before adding any new subprocessor.</p>
|
||||
<p class="mb-4"><strong>3.5 Assist with Data Subject rights.</strong> Upon your request, we will assist you in responding to requests from Data Subjects exercising rights under GDPR/FADP (access, rectification, erasure, portability, restriction, objection). Note: Due to encryption architecture, we cannot access or modify encrypted vault contents; assistance is limited to account-level operations.</p>
|
||||
<p class="mb-4"><strong>3.6 Assist with security obligations.</strong> We provide security documentation, penetration test summaries (NDA required for details), and audit logs on request.</p>
|
||||
<p class="mb-4"><strong>3.7 Delete or return data.</strong> Upon subscription termination, we delete all active data immediately per our <a href="/terms">Cancellation Policy</a>. Compliance backups are retained for 30 days only and then destroyed. Data cannot be returned in decrypted form (we don't have keys).</p>
|
||||
<p class="mb-4"><strong>3.8 Audit and inspection.</strong> Upon 30 days written notice, you may audit our compliance with this DPA. Audits are conducted at our Zürich headquarters or virtually. We provide relevant documentation; direct infrastructure access requires security clearance. Self-hosted Community Edition users perform their own audits.</p>
|
||||
<p class="mb-4"><strong>3.9 Notify of breaches.</strong> We notify you within 24 hours of discovering any breach affecting your Personal Data. We will never delay notification for investigation or legal review.</p>
|
||||
<p class="mb-6"><strong>3.10 Document processing activities.</strong> We maintain records of processing activities and make summaries available upon request.</p>
|
||||
|
||||
<h2 class="mb-4">4. Obligations of the Controller</h2>
|
||||
<p class="mb-4">You warrant that:</p>
|
||||
<ul class="mb-4">
|
||||
<li>You have lawful basis to process Personal Data in your vault</li>
|
||||
<li>You have provided appropriate privacy notices to Data Subjects whose data you store</li>
|
||||
<li>You will not store data in violation of applicable laws (e.g., child pornography, terrorism-related data)</li>
|
||||
<li>You will promptly notify us of any Data Subject requests or regulatory inquiries</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="mb-4">5. Data Location and Transfer</h2>
|
||||
<p class="mb-4">Your vault data is stored encrypted at the Point of Presence (POP) geographically nearest to your access pattern. Primary and backup POPs are in different regions for resilience. The complete list of 21 POPs with cities, providers, and compliance certifications is maintained in our <a href="/pops">POP database</a>.</p>
|
||||
<p class="mb-4">Infrastructure providers used for POPs include: Amazon Web Services (primary provider for most regions), ISHosting (Istanbul, Almaty), HostAfrica (Lagos, Nairobi), and Hostkey (Zürich POP). Zürich HQ operations (billing, administrative) also use Hostkey.</p>
|
||||
<p class="mb-4">All POPs are either:</p>
|
||||
<ul class="mb-4">
|
||||
<li>In jurisdictions with adequacy decisions (EU, EEA, Switzerland, UK, Canada, etc.)</li>
|
||||
<li>Bound by Standard Contractual Clauses (SCCs) where no adequacy decision exists</li>
|
||||
</ul>
|
||||
<p class="mb-6">Due to our encryption architecture (zero-knowledge), even data stored in non-adequate jurisdictions is technically protected. We cannot decrypt it; neither can local authorities. DNS resolution is handled by Cloudflare; no vault data ever passes through their network.</p>
|
||||
|
||||
<h2 class="mb-4">6. Encryption and Technical Measures</h2>
|
||||
<p class="mb-4">Our architecture is designed so that we <em>cannot</em> access your sensitive data even if compelled:</p>
|
||||
<ul class="mb-6">
|
||||
<li><strong>L1 (Metadata):</strong> Entry IDs, titles (if unmarked), types, timestamps — visible to us for service operation</li>
|
||||
<li><strong>L2 (Standard fields):</strong> Passwords, usernames, API keys — encrypted with server-held keys, decryptable only with your active session</li>
|
||||
<li><strong>L3 (Identity fields):</strong> Credit cards, CVV, passport numbers, SSNs — encrypted with WebAuthn PRF, a key derived from your biometric/device that <strong>never leaves your possession</strong>. We mathematically cannot decrypt these.</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="mb-4">7. Contact</h2>
|
||||
<p class="mb-4">For DPA-related inquiries:</p>
|
||||
<p class="mb-2"><strong>Data Protection Officer (DPO)</strong><br>
|
||||
Clavitor.ai<br>
|
||||
c/o Johan Jongsma<br>
|
||||
<a href="mailto:privacy@clavitor.ai">privacy@clavitor.ai</a></p>
|
||||
<p class="mb-6">Zürich, Switzerland</p>
|
||||
|
||||
<h2 class="mb-4">8. Effective Date and Changes</h2>
|
||||
<p class="mb-4">This DPA is effective as of your subscription start date and remains in effect until termination. Changes are notified 30 days in advance. Continued use constitutes acceptance.</p>
|
||||
|
||||
<p class="text-sm text-tertiary">Last updated: April 2026 | Version 1.0</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -1,52 +1,53 @@
|
|||
{{define "footer"}}
|
||||
<footer class="footer" style="padding:3rem 0 1.5rem">
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div style="display:grid;grid-template-columns:2fr 1fr 1fr 1fr 1fr;gap:2rem;padding-bottom:2rem;border-bottom:1px solid var(--border)">
|
||||
<div class="footer-grid">
|
||||
<div>
|
||||
<a href="/" style="font-family:var(--font-sans);font-weight:700;letter-spacing:0.15em;text-transform:uppercase;font-size:0.8rem;color:var(--text);display:inline-flex;align-items:center;gap:6px;margin-bottom:8px"><span style="display:inline-block;width:12px;height:12px;background:var(--brand-black);border-radius:2px"></span>clavitor</a>
|
||||
<p style="font-size:0.75rem;color:var(--text-tertiary);line-height:1.6;max-width:240px">Black-box credential issuance for AI agents. Your agent gets what it needs. Your secrets stay yours.</p>
|
||||
<a href="/" class="footer-brand"><span class="footer-brand-square"></span>clavitor</a>
|
||||
<p class="footer-tagline">Black-box credential issuance for AI agents. Your agent gets what it needs. Your secrets stay yours.</p>
|
||||
</div>
|
||||
<div>
|
||||
<p style="font-family:var(--font-mono);font-size:0.65rem;font-weight:500;letter-spacing:0.12em;text-transform:uppercase;color:var(--text-tertiary);margin-bottom:12px">Product</p>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;font-size:0.8rem">
|
||||
<a href="/hosted" style="color:var(--text-secondary)">Hosted</a>
|
||||
<a href="/install" style="color:var(--text-secondary)">Self-host</a>
|
||||
<a href="/pricing" style="color:var(--text-secondary)">Pricing</a>
|
||||
<a href="/integrations/claude-code" style="color:var(--text-secondary)">Claude Code</a>
|
||||
<a href="/integrations/codex" style="color:var(--text-secondary)">Codex</a>
|
||||
<a href="/integrations/openclaw" style="color:var(--text-secondary)">OpenClaw</a>
|
||||
<p class="footer-col-label">Product</p>
|
||||
<div class="footer-col-links">
|
||||
<a href="/hosted">Hosted</a>
|
||||
<a href="/install">Self-host</a>
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="/integrations/claude-code">Claude Code</a>
|
||||
<a href="/integrations/codex">Codex</a>
|
||||
<a href="/integrations/openclaw">OpenClaw</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p style="font-family:var(--font-mono);font-size:0.65rem;font-weight:500;letter-spacing:0.12em;text-transform:uppercase;color:var(--text-tertiary);margin-bottom:12px">Solutions</p>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;font-size:0.8rem">
|
||||
<a href="/for/consumer" style="color:var(--text-secondary)">Consumer</a>
|
||||
<a href="/for/smb" style="color:var(--text-secondary)">SMB</a>
|
||||
<a href="/for/enterprise" style="color:var(--text-secondary)">Enterprise</a>
|
||||
<a href="/for/msp" style="color:var(--text-secondary)">MSP</a>
|
||||
<p class="footer-col-label">Solutions</p>
|
||||
<div class="footer-col-links">
|
||||
<a href="/for/consumer">Consumer</a>
|
||||
<a href="/for/smb">SMB</a>
|
||||
<a href="/for/enterprise">Enterprise</a>
|
||||
<a href="/for/msp">MSP</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p style="font-family:var(--font-mono);font-size:0.65rem;font-weight:500;letter-spacing:0.12em;text-transform:uppercase;color:var(--text-tertiary);margin-bottom:12px">Network</p>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;font-size:0.8rem">
|
||||
<a href="/status" style="color:var(--text-secondary)">Status</a>
|
||||
<a href="/glass" style="color:var(--text-secondary)">Looking Glass</a>
|
||||
<p class="footer-col-label">Network</p>
|
||||
<div class="footer-col-links">
|
||||
<a href="/status">Status</a>
|
||||
<a href="/glass">Looking Glass</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p style="font-family:var(--font-mono);font-size:0.65rem;font-weight:500;letter-spacing:0.12em;text-transform:uppercase;color:var(--text-tertiary);margin-bottom:12px">Legal</p>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;font-size:0.8rem">
|
||||
<a href="/privacy" style="color:var(--text-secondary)">Privacy</a>
|
||||
<a href="/terms" style="color:var(--text-secondary)">Terms</a>
|
||||
<span style="color:var(--text-tertiary)">Elastic License 2.0</span>
|
||||
<p class="footer-col-label">Legal</p>
|
||||
<div class="footer-col-links">
|
||||
<a href="/privacy">Privacy</a>
|
||||
<a href="/terms">Terms</a>
|
||||
<a href="/cookies">Cookies</a>
|
||||
<span>Elastic License 2.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding-top:1.25rem;flex-wrap:wrap;gap:1rem">
|
||||
<p style="font-size:0.7rem;color:var(--text-tertiary);margin:0">© 2026 Clavitor LLC. Built for humans with AI assistants.</p>
|
||||
<div style="display:flex;gap:1rem;font-size:0.8rem">
|
||||
<a href="https://x.com/clavitorai" style="color:var(--text-tertiary)">X</a>
|
||||
<a href="#" style="color:var(--text-tertiary)">Discord</a>
|
||||
<div class="footer-bottom">
|
||||
<p class="footer-copy">© 2026 Clavitor LLC. Built for humans with AI assistants.</p>
|
||||
<div class="footer-social">
|
||||
<a href="https://x.com/clavitorai">X</a>
|
||||
<a href="#">Discord</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
<div class="price-card">
|
||||
<p class="label mb-4">Personal</p>
|
||||
<div class="price-amount mb-2">$12<span class="price-period">/yr</span></div>
|
||||
<p class="text-sm text-secondary mb-6">1 vault · 5 agents · 2 devices</p>
|
||||
<p class="text-sm text-secondary mb-6">1 person · 1 vault · 5 agents</p>
|
||||
<a href="/signup" class="btn btn-ghost btn-block mb-6">Get started</a>
|
||||
<p class="text-sm mb-4">For one person with a couple of AI agents — Claude Code, a deploy script, a browser extension.</p>
|
||||
<ul class="checklist"><li>Three-tier encryption</li><li>WebAuthn PRF</li><li>All regions</li><li>Unlimited entries</li><li>99.999% SLA</li></ul>
|
||||
|
|
@ -45,8 +45,8 @@
|
|||
<div class="price-card featured">
|
||||
<span class="badge recommended price-badge">Recommended</span>
|
||||
<p class="label accent mb-4">Family</p>
|
||||
<div class="price-amount mb-2">$29<span class="price-period">/yr</span></div>
|
||||
<p class="text-sm text-secondary mb-6">1 vault · 15 agents · 6 devices</p>
|
||||
<div class="price-amount mb-2">$24<span class="price-period">/yr</span></div>
|
||||
<p class="text-sm text-secondary mb-6">~6 people · 1 vault · 15 agents</p>
|
||||
<a href="/signup" class="btn btn-primary btn-block mb-6">Get started</a>
|
||||
<p class="text-sm mb-4">For power users running multiple agents across workstations, phones, and tablets — or a household sharing one vault.</p>
|
||||
<ul class="checklist"><li>Three-tier encryption</li><li>WebAuthn PRF</li><li>All regions</li><li>Unlimited entries</li><li>99.999% SLA</li></ul>
|
||||
|
|
@ -54,13 +54,13 @@
|
|||
<div class="price-card">
|
||||
<p class="label mb-4">Pro</p>
|
||||
<div class="price-amount mb-2">$49<span class="price-period">/yr</span></div>
|
||||
<p class="text-sm text-secondary mb-6">1 vault · 50 agents · unlimited devices</p>
|
||||
<p class="text-sm text-secondary mb-6">1 person · 1 vault · 50 agents</p>
|
||||
<a href="/signup" class="btn btn-ghost btn-block mb-6">Get started</a>
|
||||
<p class="text-sm mb-4">For developers and freelancers with agents across CI/CD, infrastructure, monitoring, and client projects.</p>
|
||||
<ul class="checklist"><li>Three-tier encryption</li><li>WebAuthn PRF</li><li>All regions</li><li>Unlimited entries</li><li>99.999% SLA</li></ul>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-8 text-sm text-tertiary text-center">Self-host (Community Edition) is free. Forever. <a href="/install">Self-host guide →</a></p>
|
||||
<p class="mt-8 text-sm text-tertiary text-center"><strong>Price for life:</strong> Your rate never increases as long as you stay subscribed. Self-host (Community Edition) is free. Forever. <a href="/install">Self-host guide →</a></p>
|
||||
</div>
|
||||
|
||||
{{end}}
|
||||
|
|
@ -70,14 +70,14 @@
|
|||
<div class="section container">
|
||||
<h2 class="mb-4">Enterprise pricing</h2>
|
||||
<p class="lead mb-8">Per-human pricing. Agents unlimited.</p>
|
||||
<div class="price-card featured" style="max-width:400px">
|
||||
<div class="price-card featured max-w-sm mx-auto">
|
||||
<p class="label accent mb-4">Enterprise</p>
|
||||
<div class="price-amount mb-2">$6<span class="price-period">/user/mo</span></div>
|
||||
<p class="text-sm text-secondary mb-6">$72/user/yr · 1,000+ users</p>
|
||||
<a href="mailto:sales@clavitor.ai" class="btn btn-primary btn-block mb-6">Contact sales</a>
|
||||
<ul class="checklist"><li>Everything in Mid-Market</li><li>SIEM integration</li><li>SLA with compensation</li><li>SOC 2 · ISO 27001</li><li>Dedicated account manager</li><li>Custom region / deployment</li></ul>
|
||||
</div>
|
||||
<p class="text-sm text-tertiary mt-4">Enterprise features require Clavitor Hosted. SCIM, SIEM, centralized audit, management plane, and SLA with compensation are not available in the Community Edition.</p>
|
||||
<p class="text-sm text-tertiary mt-4"><strong>Price for life:</strong> Your per-user rate never increases as long as you maintain your subscription. We may raise rates for new enterprise customers, but never for existing. Enterprise features require Clavitor Hosted. SCIM, SIEM, centralized audit, management plane, and SLA with compensation are not available in the Community Edition.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
|
|
|||
|
|
@ -56,14 +56,14 @@
|
|||
<div class="section container">
|
||||
<h2 class="mb-4">Mid-Market pricing</h2>
|
||||
<p class="lead mb-8">Per-human pricing. Agents unlimited.</p>
|
||||
<div class="price-card featured" style="max-width:400px">
|
||||
<div class="price-card featured max-w-sm mx-auto">
|
||||
<p class="label accent mb-4">Mid-Market</p>
|
||||
<div class="price-amount mb-2">$3<span class="price-period">/user/mo</span></div>
|
||||
<p class="text-sm text-secondary mb-6">$36/user/yr · 500+ users</p>
|
||||
<a href="mailto:sales@clavitor.ai" class="btn btn-primary btn-block mb-6">Contact sales</a>
|
||||
<ul class="checklist"><li>SCIM directory sync</li><li>Centralized audit export</li><li>Priority support</li><li>Direct invoicing (Net 60)</li><li>Three-tier encryption</li><li>Cross-hemisphere backup</li><li>99.999% SLA</li></ul>
|
||||
</div>
|
||||
<p class="text-sm text-tertiary mt-4">Mid-Market requires Clavitor Hosted. SCIM, centralized audit, and management plane are not available in the Community Edition.</p>
|
||||
<p class="text-sm text-tertiary mt-4"><strong>Price for life:</strong> Your per-user rate never increases as long as you maintain your subscription. Mid-Market requires Clavitor Hosted. SCIM, centralized audit, and management plane are not available in the Community Edition.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
|
|
|||
|
|
@ -52,20 +52,20 @@
|
|||
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">Team pricing</h2>
|
||||
<p class="lead mb-8">One company vault + one personal vault per employee. Unlimited tokens and devices. Annual or monthly.</p>
|
||||
<p class="lead mb-8">One company vault + one personal vault per employee. Agents scale with team size. 7-day free trial on all plans.</p>
|
||||
<table class="data-table mb-4">
|
||||
<thead>
|
||||
<tr><th>Plan</th><th style="text-align:right">People</th><th style="text-align:right">Annual</th><th style="text-align:right">Monthly</th></tr>
|
||||
<tr><th>Plan</th><th style="text-align:right">People</th><th style="text-align:right">Vaults</th><th style="text-align:right">Agents</th><th style="text-align:right">Annual</th><th style="text-align:right">Monthly</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Team 10</td><td style="text-align:right">10</td><td style="text-align:right">$249</td><td style="text-align:right">$24.90</td></tr>
|
||||
<tr><td>Team 25</td><td style="text-align:right">25</td><td style="text-align:right">$499</td><td style="text-align:right">$49.90</td></tr>
|
||||
<tr><td>Team 100</td><td style="text-align:right">100</td><td style="text-align:right">$1,499</td><td style="text-align:right">$149.90</td></tr>
|
||||
<tr><td>Team 250</td><td style="text-align:right">250</td><td style="text-align:right">$2,999</td><td style="text-align:right">$299.90</td></tr>
|
||||
<tr><td>Team 500</td><td style="text-align:right">500</td><td style="text-align:right">$4,999</td><td style="text-align:right">$499.90</td></tr>
|
||||
<tr><td>Team 10</td><td style="text-align:right">10</td><td style="text-align:right">11</td><td style="text-align:right">100</td><td style="text-align:right">$249</td><td style="text-align:right">—</td></tr>
|
||||
<tr><td>Team 25</td><td style="text-align:right">25</td><td style="text-align:right">26</td><td style="text-align:right">250</td><td style="text-align:right">$499</td><td style="text-align:right">—</td></tr>
|
||||
<tr><td>Team 100</td><td style="text-align:right">100</td><td style="text-align:right">101</td><td style="text-align:right">1,000</td><td style="text-align:right">$1,499</td><td style="text-align:right">$149</td></tr>
|
||||
<tr><td>Team 250</td><td style="text-align:right">250</td><td style="text-align:right">251</td><td style="text-align:right">2,500</td><td style="text-align:right">$2,999</td><td style="text-align:right">$299</td></tr>
|
||||
<tr><td>Team 500</td><td style="text-align:right">500</td><td style="text-align:right">501</td><td style="text-align:right">5,000</td><td style="text-align:right">$4,999</td><td style="text-align:right">$499</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm text-tertiary">Monthly = annual / 10. Management plane included.</p>
|
||||
<p class="text-sm text-tertiary">Monthly billing available for Team 100+. Team 10/25 are annual-only. <strong>Price for life:</strong> Your rate never increases as long as you stay subscribed.</p>
|
||||
<p class="text-sm text-tertiary mt-4">Team plans require Clavitor Hosted. <a href="/install">Community Edition</a> supports single-vault self-hosting for individual use.</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
</div>
|
||||
|
||||
<div class="section container" style="padding-top:24px">
|
||||
<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:var(--radius-sm);padding:16px 20px;margin-bottom:24px;font-size:0.85rem;color:#854d0e">
|
||||
<strong style="text-transform:uppercase;letter-spacing:0.06em;font-size:0.75rem">Dubai (me-central-1)</strong><br>
|
||||
<div class="text-sm" style="background:#fffbeb;border:1px solid #fde68a;border-radius:var(--radius-sm);padding:16px 20px;margin-bottom:24px;color:#854d0e">
|
||||
<strong class="text-xs" style="text-transform:uppercase;letter-spacing:0.06em">Dubai (me-central-1)</strong><br>
|
||||
The AWS UAE region remains offline following drone strikes on March 1, 2026. No customer data was affected. We are awaiting updates from AWS on restoration, expected in the coming weeks.
|
||||
</div>
|
||||
<div class="glass-grid">
|
||||
|
|
|
|||
|
|
@ -11,14 +11,14 @@
|
|||
<div class="map-wrap">
|
||||
<svg id="worldmap" viewBox="0 0 1000 460" xmlns="http://www.w3.org/2000/svg">
|
||||
<image href="/worldmap.svg" x="0" y="0" width="1000" height="460"/>
|
||||
<text x="500" y="440" font-family="Figtree,sans-serif" font-size="18" font-weight="700" fill="#0A0A0A" text-anchor="middle" opacity="0.35" letter-spacing="0.3em">CLAVITOR GLOBAL PRESENCE</text>
|
||||
<text x="500" y="440" font-family="IBM Plex Sans,sans-serif" font-size="18" font-weight="700" fill="#0A0A0A" text-anchor="middle" opacity="0.35" letter-spacing="0.3em">CLAVITOR GLOBAL PRESENCE</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mt-4" style="display:flex;justify-content:space-between;align-items:baseline;gap:16px;flex-wrap:wrap">
|
||||
<p class="text-sm text-tertiary" style="margin:0">We have strategically chosen our datacenter locations so that almost every place on the planet gets an answer within 60 ms. That's a convenience target, not a requirement — Clavitor works perfectly fine on slower connections, it just won't feel quite as instant. <a href="/glass">See how fast it is from your location.</a> If you'd like a region closer to you, <a href="mailto:support@clavitor.com">reach out</a> and we'll work on it.</p>
|
||||
<p class="text-sm text-tertiary" style="margin:0">We have strategically chosen our datacenter locations so that almost every place on the planet gets an answer within 60 ms. That's a convenience target, not a requirement — Clavitor works perfectly fine on slower connections, it just won't feel quite as instant. <a href="/glass">See how fast it is from your location.</a> If you'd like a region closer to you, <a href="mailto:support@clavitor.ai">reach out</a> and we'll work on it.</p>
|
||||
<span style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button id="backup-toggle" class="btn btn-ghost" style="white-space:nowrap;font-size:0.75rem;padding:6px 12px">Show backup routes</button>
|
||||
<a href="/glass" class="btn btn-ghost" style="white-space:nowrap;font-size:0.75rem;padding:6px 12px">See network performance from your location →</a>
|
||||
<button id="backup-toggle" class="btn btn-ghost btn-sm" style="white-space:nowrap">Show backup routes</button>
|
||||
<a href="/glass" class="btn btn-ghost btn-sm" style="white-space:nowrap">See network performance from your location →</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-12"></div>
|
||||
|
|
@ -159,7 +159,7 @@
|
|||
<!-- CTA -->
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">Ready?</h2>
|
||||
<p class="lead mb-6"><s>$20</s> $12/yr. 7-day money-back. Every feature included.</p>
|
||||
<p class="lead mb-6"><s>$20</s> $12/yr. 7-day money-back. Every feature included. <strong>Price for life</strong> — your rate never increases.</p>
|
||||
<div class="btn-row">
|
||||
<a href="/signup" class="btn btn-primary">Get started</a>
|
||||
<a href="/pricing" class="btn btn-ghost">Compare plans →</a>
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ ghp_a3f8...</pre>
|
|||
<!-- Center vault -->
|
||||
<rect x="160" y="140" width="80" height="80" rx="12" fill="#f5f5f5" stroke="#737373" stroke-width="1.5"/>
|
||||
<rect x="175" y="152" width="20" height="20" rx="0" fill="#0A0A0A"/>
|
||||
<text x="200" y="190" font-family="Figtree, sans-serif" font-size="10" fill="#0A0A0A" text-anchor="middle" font-weight="700" letter-spacing="0.25em">CLAVITOR</text>
|
||||
<text x="200" y="190" font-family="IBM Plex Sans, sans-serif" font-size="10" fill="#0A0A0A" text-anchor="middle" font-weight="700" letter-spacing="0.25em">CLAVITOR</text>
|
||||
|
||||
<!-- Agent 1 — dev -->
|
||||
<circle cx="80" cy="60" r="32" fill="#0A0A0A" fill-opacity="0.08" stroke="#0A0A0A" stroke-width="1"/>
|
||||
|
|
@ -455,7 +455,7 @@ ghp_a3f8...</pre>
|
|||
<div class="code-block mb-6">
|
||||
<p class="code-label">Terminal</p>
|
||||
<div><span class="comment"># Self-host in 30 seconds</span></div>
|
||||
<div><span class="prompt">$</span> curl -fsSL clavitor.com/install.sh | sh</div>
|
||||
<div><span class="prompt">$</span> curl -fsSL clavitor.ai/install.sh | sh</div>
|
||||
<div><span class="prompt">$</span> clavitor</div>
|
||||
<div class="comment"># Running on http://localhost:1984</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@
|
|||
<div class="step-body">
|
||||
<h3>Install Clavitor</h3>
|
||||
<div class="code-block">
|
||||
<div><span class="prompt">$</span> curl -fsSL clavitor.com/install.sh | sh</div>
|
||||
<div><span class="prompt">$</span> curl -fsSL clavitor.ai/install.sh | sh</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,842 @@
|
|||
{{define "onboarding-checkout"}}
|
||||
<div class="onboarding-container">
|
||||
<div class="onboarding-card onboarding-card--wide">
|
||||
<!-- Progress -->
|
||||
<div class="onboarding-progress">
|
||||
<div class="progress-step completed clickable" onclick="goToStep('/onboarding/product')">
|
||||
<div class="progress-box">✓</div>
|
||||
<span class="progress-label">Product</span>
|
||||
</div>
|
||||
<div class="progress-line completed"></div>
|
||||
<div class="progress-step completed clickable" onclick="goToStep('/onboarding/login')">
|
||||
<div class="progress-box">✓</div>
|
||||
<span class="progress-label">Account</span>
|
||||
</div>
|
||||
<div class="progress-line completed"></div>
|
||||
<div class="progress-step completed clickable" onclick="goToStep('/onboarding/details')">
|
||||
<div class="progress-box">✓</div>
|
||||
<span class="progress-label">Details</span>
|
||||
</div>
|
||||
<div class="progress-line completed"></div>
|
||||
<div class="progress-step completed clickable" onclick="goToStep('/onboarding/terms')">
|
||||
<div class="progress-box">✓</div>
|
||||
<span class="progress-label">Terms</span>
|
||||
</div>
|
||||
<div class="progress-line completed"></div>
|
||||
<div class="progress-step active">
|
||||
<div class="progress-box">5</div>
|
||||
<span class="progress-label">Payment</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="onboarding-header">
|
||||
<p class="label accent mb-3">Step 5 of 5</p>
|
||||
<h1 class="mb-4">Start your 7-day trial</h1>
|
||||
<p class="lead">No credit card required. After 7 days, you'll be charged $12/year. Cancel anytime.</p>
|
||||
</div>
|
||||
|
||||
<!-- Order Summary -->
|
||||
<div class="order-summary">
|
||||
<div class="summary-row summary-header">
|
||||
<span>Order Summary</span>
|
||||
<a href="/onboarding/product" class="btn-text">Change</a>
|
||||
</div>
|
||||
|
||||
<div class="summary-row">
|
||||
<div class="summary-item">
|
||||
<div class="summary-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
</div>
|
||||
<div class="summary-info">
|
||||
<div class="summary-title">Clavitor Hosted</div>
|
||||
<div class="summary-meta">US-East region • Annual billing</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-price">$12<span>/year</span></div>
|
||||
</div>
|
||||
|
||||
<div class="summary-divider"></div>
|
||||
|
||||
<div class="summary-row summary-total">
|
||||
<span>Total after trial</span>
|
||||
<span class="total-price">$12<span>/year</span></span>
|
||||
</div>
|
||||
<div class="summary-note">
|
||||
<svg width="16" height="16" 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>
|
||||
<span>You won't be charged today. Trial ends in 7 days.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Methods -->
|
||||
<div class="payment-section">
|
||||
<h3 class="section-title">Payment method</h3>
|
||||
|
||||
<div class="payment-options">
|
||||
<label class="payment-option" data-method="card">
|
||||
<input type="radio" name="payment" value="card" checked>
|
||||
<div class="payment-content">
|
||||
<div class="payment-radio">
|
||||
<div class="radio-box">
|
||||
<span class="radio-check">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="payment-icon">
|
||||
<svg width="24" height="24" 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>
|
||||
</div>
|
||||
<div class="payment-info">
|
||||
<div class="payment-label">Credit or debit card</div>
|
||||
<div class="payment-hint">Visa, Mastercard, Amex, Discover</div>
|
||||
</div>
|
||||
<div class="payment-icons">
|
||||
<svg width="32" height="20" viewBox="0 0 48 32"><rect fill="#1A1F71" x="0" y="0" width="48" height="32" rx="3"/><text x="24" y="21" font-family="sans-serif" font-size="11" font-weight="700" fill="white" text-anchor="middle">VISA</text></svg>
|
||||
<svg width="32" height="20" viewBox="0 0 48 32"><rect fill="#FF5F00" x="0" y="0" width="48" height="32" rx="3"/><circle fill="#EB001B" cx="18" cy="16" r="8"/><circle fill="#F79E1B" cx="30" cy="16" r="8"/></svg>
|
||||
<svg width="32" height="20" viewBox="0 0 48 32"><rect fill="#016FD0" x="0" y="0" width="48" height="32" rx="3"/><text x="24" y="21" font-family="sans-serif" font-size="10" font-weight="700" fill="white" text-anchor="middle">AMEX</text></svg>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="payment-option" data-method="paypal">
|
||||
<input type="radio" name="payment" value="paypal">
|
||||
<div class="payment-content">
|
||||
<div class="payment-radio">
|
||||
<div class="radio-box">
|
||||
<span class="radio-check">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="payment-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path fill="#003087" d="M7.076 21.337H2.47a.667.667 0 0 1-.66-.598L.006 3.12a.667.667 0 0 1 .66-.76h7.965c3.36 0 6.103 2.362 6.021 5.388-.057 2.102-1.658 3.694-3.693 4.021-1.53.255-3.18.379-4.926.379H7.08l.24 2.489-.013.058.005-.012z"/><path fill="#0070E0" d="M17.541 9.686c-.058.167-.124.334-.198.5-.67 1.484-1.95 2.5-3.49 2.86l-.05.011a8.64 8.64 0 0 1-1.753.205h-2.32l-.025.159-.548 3.5-.058.37h4.008a.7.7 0 0 0 .69-.59l.028-.14.54-3.44.029-.185a.7.7 0 0 1 .69-.59h.41c1.534 0 2.81-.464 3.783-1.338.267-.246.507-.52.72-.821.022.023.043.047.065.07.217.235.407.49.57.764.43.719.657 1.538.657 2.406 0 1.552-.698 2.938-1.814 3.862l-.13.108c-.336.281-.69.548-1.058.798-1.428.95-3.152 1.495-5.028 1.495H7.08l.24 2.489.26.027c2.79.297 5.208.198 7.24-.874 1.38-.752 2.476-1.897 3.238-3.388.486-.963.76-2.078.76-3.26 0-.866-.176-1.69-.498-2.442a5.61 5.61 0 0 0-.521-.996z"/><path fill="#FFC439" d="M10.358 9.686h4.178a8.64 8.64 0 0 0 1.753-.205l.05-.011c1.54-.36 2.82-1.376 3.49-2.86.074-.166.14-.333.198-.5.07-.2.135-.405.196-.616.261-.92.41-1.894.41-2.904 0-2.473-1.258-4.474-3.398-5.386C15.98.618 14.41.56 12.704.56H5.947a.667.667 0 0 0-.66.76l1.804 17.619a.667.667 0 0 0 .66.598h4.126l.548-3.5.025-.159h2.32c1.753 0 3.403-.124 4.926-.379 2.035-.327 3.636-1.919 3.693-4.021.082-3.026-2.661-5.388-6.021-5.388H7.626l.24 2.489h2.493z"/></svg>
|
||||
</div>
|
||||
<div class="payment-info">
|
||||
<div class="payment-label">PayPal</div>
|
||||
<div class="payment-hint">Fast and secure checkout</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="payment-option" data-method="apple">
|
||||
<input type="radio" name="payment" value="apple">
|
||||
<div class="payment-content">
|
||||
<div class="payment-radio">
|
||||
<div class="radio-box">
|
||||
<span class="radio-check">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="payment-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.74.82 0 2.1-.93 3.72-.8 1.25.1 2.16.55 2.85 1.37-2.57 1.54-2.14 4.5.44 5.38-.49 1.37-1.03 2.74-1.09 2.28zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/></svg>
|
||||
</div>
|
||||
<div class="payment-info">
|
||||
<div class="payment-label">Apple Pay</div>
|
||||
<div class="payment-hint">Pay with Face ID or Touch ID</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Form (shown when card selected) -->
|
||||
<div id="cardForm" class="card-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Card number</label>
|
||||
<input type="text" class="form-input" id="cardNumber" placeholder="1234 5678 9012 3456" maxlength="19">
|
||||
<div class="card-brand" id="cardBrand"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row two-col">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Expiry date</label>
|
||||
<input type="text" class="form-input" id="expiryDate" placeholder="MM / YY" maxlength="7">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Security code <span class="cvv-hint" data-tooltip="3 digits on back of card, 4 on front for Amex">?</span></label>
|
||||
<input type="text" class="form-input" id="cvv" placeholder="123" maxlength="4">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name on card</label>
|
||||
<input type="text" class="form-input" id="cardName" placeholder="JOHN DOE">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trial Notice -->
|
||||
<div class="trial-box">
|
||||
<div class="trial-header">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
<span>Your trial starts now</span>
|
||||
</div>
|
||||
<div class="trial-body">
|
||||
<ul>
|
||||
<li>Full access to all features immediately</li>
|
||||
<li>Reminder email 2 days before trial ends</li>
|
||||
<li>Cancel anytime — no questions asked</li>
|
||||
<li>After trial: $12/year (billed annually)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="form-actions payment-actions">
|
||||
<button type="button" class="btn btn-ghost" onclick="history.back()">Back</button>
|
||||
<button type="submit" class="btn btn-primary btn-large" id="startTrialBtn" onclick="startTrial()">
|
||||
Start free trial →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="payment-security">
|
||||
<svg width="14" height="14" 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>
|
||||
<span>Payment processed by Paddle. We never store your card details.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.onboarding-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 48px 24px;
|
||||
background: linear-gradient(180deg, #fafafa 0%, #f5f5f5 100%);
|
||||
}
|
||||
|
||||
.onboarding-container::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.onboarding-card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 48px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.onboarding-card--wide {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.onboarding-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.progress-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-step.active .progress-dot {
|
||||
background: var(--brand-black);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.progress-step.completed .progress-dot {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.progress-box {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.progress-step.active .progress-box {
|
||||
background: var(--brand-black);
|
||||
color: white;
|
||||
border-color: var(--brand-black);
|
||||
}
|
||||
|
||||
.progress-step.completed .progress-box {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.progress-step.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progress-step.clickable:hover .progress-box {
|
||||
border-color: var(--brand-black);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.progress-step.clickable:hover .progress-label {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.progress-step.active .progress-label {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progress-step.completed .progress-label {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.progress-line {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background: var(--border);
|
||||
margin: 0 8px;
|
||||
margin-bottom: 24px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.progress-line.completed {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.onboarding-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
/* Order Summary */
|
||||
.order-summary {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-row + .summary-row {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.summary-header {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-tertiary);
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--brand-black);
|
||||
}
|
||||
|
||||
.summary-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.summary-meta {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.summary-price {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.summary-price span {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.summary-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.summary-total {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.summary-total .total-price {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summary-total .total-price span {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.summary-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.summary-note svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Payment Section */
|
||||
.payment-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 16px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.payment-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.payment-option {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.payment-option input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.payment-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: white;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.payment-option:hover .payment-content {
|
||||
border-color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.payment-option input:checked + .payment-content {
|
||||
border-color: var(--brand-black);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.payment-radio {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.radio-box {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.payment-option input:checked + .payment-content .radio-box {
|
||||
border-color: var(--brand-black);
|
||||
background: var(--brand-black);
|
||||
}
|
||||
|
||||
.radio-check {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
transform: scale(0);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.payment-option input:checked + .payment-content .radio-check {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.payment-icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.payment-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.payment-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.payment-hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.payment-icons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.payment-icons svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Card Form */
|
||||
.card-form {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-row.two-col {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.form-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cvv-hint {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
line-height: 16px;
|
||||
cursor: help;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 16px;
|
||||
font-family: var(--font-mono);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-black);
|
||||
box-shadow: 0 0 0 3px rgba(10,10,10,0.04);
|
||||
}
|
||||
|
||||
.card-brand {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* Trial Box */
|
||||
.trial-box {
|
||||
background: linear-gradient(135deg, var(--brand-violet-soft) 0%, transparent 100%);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(124, 58, 237, 0.2);
|
||||
overflow: hidden;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.trial-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background: rgba(124, 58, 237, 0.08);
|
||||
color: var(--brand-violet);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.trial-header svg {
|
||||
color: var(--brand-violet);
|
||||
}
|
||||
|
||||
.trial-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.trial-body ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.trial-body li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.trial-body li::before {
|
||||
content: '✓';
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-actions .btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.form-actions .btn-large {
|
||||
height: 52px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.payment-security {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.payment-security svg {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--brand-black);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 640px) {
|
||||
.onboarding-card {
|
||||
padding: 32px 24px;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.onboarding-container {
|
||||
padding: 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.progress-line {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.payment-icons {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-row.two-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.payment-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.payment-actions .btn:first-child {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Payment method selection
|
||||
document.querySelectorAll('input[name="payment"]').forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
const cardForm = document.getElementById('cardForm');
|
||||
const cardFormVisible = this.value === 'card';
|
||||
|
||||
if (cardFormVisible) {
|
||||
cardForm.style.display = 'block';
|
||||
} else {
|
||||
cardForm.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Card number formatting
|
||||
document.getElementById('cardNumber').addEventListener('input', function(e) {
|
||||
let value = e.target.value.replace(/\D/g, '');
|
||||
let formatted = '';
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (i > 0 && i % 4 === 0) formatted += ' ';
|
||||
formatted += value[i];
|
||||
}
|
||||
|
||||
e.target.value = formatted;
|
||||
|
||||
// Detect card brand
|
||||
const brand = document.getElementById('cardBrand');
|
||||
if (value.startsWith('4')) {
|
||||
brand.innerHTML = '<span style="color:#1A1F71;font-weight:700">VISA</span>';
|
||||
} else if (value.startsWith('5')) {
|
||||
brand.innerHTML = '<span style="font-weight:700">Mastercard</span>';
|
||||
} else if (value.startsWith('3')) {
|
||||
brand.innerHTML = '<span style="color:#016FD0;font-weight:700">Amex</span>';
|
||||
} else {
|
||||
brand.innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Expiry formatting
|
||||
document.getElementById('expiryDate').addEventListener('input', function(e) {
|
||||
let value = e.target.value.replace(/\D/g, '');
|
||||
|
||||
if (value.length >= 2) {
|
||||
value = value.slice(0, 2) + ' / ' + value.slice(2);
|
||||
}
|
||||
|
||||
e.target.value = value;
|
||||
});
|
||||
|
||||
function goToStep(url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
function startTrial() {
|
||||
const btn = document.getElementById('startTrialBtn');
|
||||
const paymentMethod = document.querySelector('input[name="payment"]:checked').value;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Processing...';
|
||||
|
||||
// Simulate processing (replace with actual Paddle/Stripe integration)
|
||||
setTimeout(() => {
|
||||
// Success - redirect to done page
|
||||
window.location.href = '/onboarding/done';
|
||||
}, 1500);
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,480 @@
|
|||
{{define "onboarding-details"}}
|
||||
<div class="onboarding-container">
|
||||
<div class="onboarding-card">
|
||||
<!-- Progress -->
|
||||
<div class="onboarding-progress">
|
||||
<div class="progress-step completed clickable" onclick="goToStep('/onboarding/product')">
|
||||
<div class="progress-box">✓</div>
|
||||
<span class="progress-label">Product</span>
|
||||
</div>
|
||||
<div class="progress-line completed"></div>
|
||||
<div class="progress-step completed clickable" onclick="goToStep('/onboarding/login')">
|
||||
<div class="progress-box">✓</div>
|
||||
<span class="progress-label">Account</span>
|
||||
</div>
|
||||
<div class="progress-line completed"></div>
|
||||
<div class="progress-step active">
|
||||
<div class="progress-box">3</div>
|
||||
<span class="progress-label">Details</span>
|
||||
</div>
|
||||
<div class="progress-line"></div>
|
||||
<div class="progress-step">
|
||||
<div class="progress-box">4</div>
|
||||
<span class="progress-label">Terms</span>
|
||||
</div>
|
||||
<div class="progress-line"></div>
|
||||
<div class="progress-step">
|
||||
<div class="progress-box">5</div>
|
||||
<span class="progress-label">Payment</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="onboarding-header">
|
||||
<p class="label accent mb-3">Step 3 of 5</p>
|
||||
<h1 class="mb-4">Your details</h1>
|
||||
<p class="lead">We need this for billing and compliance. Exact match with Paddle requirements.</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form id="detailsForm" onsubmit="return saveDetails(event)">
|
||||
<!-- Personal -->
|
||||
<div class="form-section">
|
||||
<h3 class="form-section-title">Personal</h3>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group half">
|
||||
<label class="form-label">First name <span class="required">*</span></label>
|
||||
<input type="text" class="form-input" name="firstName" required placeholder="Jane">
|
||||
</div>
|
||||
<div class="form-group half">
|
||||
<label class="form-label">Last name <span class="required">*</span></label>
|
||||
<input type="text" class="form-input" name="lastName" required placeholder="Doe">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Company (optional but recommended) -->
|
||||
<div class="form-section">
|
||||
<h3 class="form-section-title">Organization</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Company name</label>
|
||||
<input type="text" class="form-input" name="companyName" placeholder="Acme Inc. (optional)">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<!--
|
||||
NOTE FOR NEXT AGENT: Tax ID/VAT field visibility by country.
|
||||
Per Paddle requirements: Show for EU countries (VAT reverse charge),
|
||||
AU (GST), NZ, NO, CH. Check Paddle docs for latest list.
|
||||
Mandatory for business customers in these regions when B2B.
|
||||
-->
|
||||
<div class="form-group half" id="taxIdGroup">
|
||||
<label class="form-label">Tax ID / VAT number</label>
|
||||
<input type="text" class="form-input" name="taxId" placeholder="EU123456789 (if applicable)">
|
||||
</div>
|
||||
<div class="form-group half">
|
||||
<label class="form-label">Company size</label>
|
||||
<select class="form-input form-select" name="companySize">
|
||||
<option value="">Select...</option>
|
||||
<option value="1">Just me</option>
|
||||
<option value="2-10">2–10 people</option>
|
||||
<option value="11-50">11–50 people</option>
|
||||
<option value="51-200">51–200 people</option>
|
||||
<option value="201+">201+ people</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Billing Address (Paddle required) -->
|
||||
<div class="form-section">
|
||||
<h3 class="form-section-title">Billing address <span class="required">*</span></h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Street address <span class="required">*</span></label>
|
||||
<input type="text" class="form-input" name="addressLine1" required placeholder="123 Main Street">
|
||||
<input type="text" class="form-input mt-2" name="addressLine2" placeholder="Suite 100, Floor 2 (optional)">
|
||||
</div>
|
||||
|
||||
<div class="form-row three-col">
|
||||
<div class="form-group">
|
||||
<label class="form-label">City <span class="required">*</span></label>
|
||||
<input type="text" class="form-input" name="city" required placeholder="Zürich">
|
||||
</div>
|
||||
<!--
|
||||
NOTE FOR NEXT AGENT: State field mandatory for US, CA, AU.
|
||||
Per Paddle: Required for tax calculation in these countries.
|
||||
Show as required (*) when country is US/CA/AU.
|
||||
-->
|
||||
<div class="form-group" id="stateGroup">
|
||||
<label class="form-label">State / Province <span id="stateRequired" class="required hidden">*</span></label>
|
||||
<input type="text" class="form-input" name="state" placeholder="ZH (if applicable)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">ZIP / Postal code <span class="required">*</span></label>
|
||||
<input type="text" class="form-input" name="postalCode" required placeholder="8001">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Country <span class="required">*</span></label>
|
||||
<select class="form-input form-select" name="country" required id="countrySelect">
|
||||
<option value="">Select country...</option>
|
||||
<option value="CH" selected>Switzerland</option>
|
||||
<option value="US">United States</option>
|
||||
<option value="DE">Germany</option>
|
||||
<option value="GB">United Kingdom</option>
|
||||
<option value="FR">France</option>
|
||||
<option value="NL">Netherlands</option>
|
||||
<option value="AT">Austria</option>
|
||||
<option value="IT">Italy</option>
|
||||
<option value="ES">Spain</option>
|
||||
<option value="CA">Canada</option>
|
||||
<option value="AU">Australia</option>
|
||||
<option value="JP">Japan</option>
|
||||
<option value="SG">Singapore</option>
|
||||
<!-- Full list would be here -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="vat-info" id="vatInfo">
|
||||
<div class="info-box">
|
||||
<svg width="16" height="16" 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>
|
||||
<span>VAT will be calculated based on your country. Businesses in EU can enter VAT ID for reverse charge.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-ghost" onclick="history.back()">Back</button>
|
||||
<button type="submit" class="btn btn-primary">Continue →</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.onboarding-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 48px 24px;
|
||||
background: linear-gradient(180deg, #fafafa 0%, #f5f5f5 100%);
|
||||
}
|
||||
|
||||
.onboarding-container::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.onboarding-card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 48px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.onboarding-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.progress-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-step.active .progress-dot {
|
||||
background: var(--brand-black);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.progress-step.completed .progress-dot {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.progress-box {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.progress-step.active .progress-box {
|
||||
background: var(--brand-black);
|
||||
color: white;
|
||||
border-color: var(--brand-black);
|
||||
}
|
||||
|
||||
.progress-step.completed .progress-box {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.progress-step.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progress-step.clickable:hover .progress-box {
|
||||
border-color: var(--brand-black);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.progress-step.clickable:hover .progress-label {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.progress-step.active .progress-label {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progress-step.completed .progress-label {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.progress-line {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background: var(--border);
|
||||
margin: 0 8px;
|
||||
margin-bottom: 24px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.progress-line.completed {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.onboarding-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 40px;
|
||||
padding-bottom: 32px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-section:last-of-type {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--brand-red);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-row.three-col {
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-black);
|
||||
box-shadow: 0 0 0 3px rgba(10,10,10,0.04);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%23737373' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 14px center;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.form-input.mt-2 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.vat-info {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.form-actions .btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.onboarding-card {
|
||||
padding: 32px 24px;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.onboarding-container {
|
||||
padding: 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.form-row,
|
||||
.form-row.three-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.progress-line {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.form-actions .btn:first-child {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Country-specific VAT logic
|
||||
document.getElementById('countrySelect').addEventListener('change', function(e) {
|
||||
const euCountries = ['DE', 'FR', 'IT', 'ES', 'NL', 'AT', 'BE', 'DK', 'FI', 'SE', 'PL', 'CZ', 'HU', 'RO', 'BG', 'HR', 'SI', 'SK', 'LT', 'LV', 'EE', 'IE', 'PT', 'GR', 'CY', 'MT', 'LU'];
|
||||
|
||||
const isEU = euCountries.includes(e.target.value);
|
||||
const vatInfo = document.getElementById('vatInfo');
|
||||
|
||||
if (isEU) {
|
||||
vatInfo.querySelector('span').textContent = 'VAT will be added at checkout. If you have a valid VAT ID, enter it above for reverse charge.';
|
||||
} else if (e.target.value === 'CH') {
|
||||
vatInfo.querySelector('span').textContent = 'Swiss VAT (8.1%) will be added at checkout.';
|
||||
} else if (e.target.value === 'US') {
|
||||
vatInfo.querySelector('span').textContent = 'Sales tax will be calculated based on your state.';
|
||||
} else {
|
||||
vatInfo.querySelector('span').textContent = 'Local taxes may apply based on your jurisdiction.';
|
||||
}
|
||||
});
|
||||
|
||||
function goToStep(url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
function saveDetails(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
// Store in session/localStorage or send to API
|
||||
console.log('Saving details:', data);
|
||||
|
||||
// Navigate to next step (terms)
|
||||
window.location.href = '/onboarding/terms';
|
||||
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,539 @@
|
|||
{{define "onboarding-done"}}
|
||||
<div class="onboarding-container onboarding-container--centered">
|
||||
<div class="onboarding-card onboarding-card--success">
|
||||
<!-- Success Animation -->
|
||||
<div class="success-animation">
|
||||
<div class="success-circle">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<div class="success-particles">
|
||||
<span></span><span></span><span></span><span></span><span></span>
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="success-header">
|
||||
<h1>Welcome to Clavitor</h1>
|
||||
<p class="success-subtitle">Your vault is ready. Your trial starts now.</p>
|
||||
</div>
|
||||
|
||||
<!-- What's Next -->
|
||||
<div class="next-steps">
|
||||
<h3 class="next-steps-title">What happens next</h3>
|
||||
|
||||
<div class="step-list">
|
||||
<div class="step-item active">
|
||||
<div class="step-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">Account activated</div>
|
||||
<div class="step-meta">Trial started · 7 days free</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-item">
|
||||
<div class="step-icon step-icon--pending">2</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">Install extension or app</div>
|
||||
<div class="step-meta">Browser extension · Mobile app · CLI</div>
|
||||
<div class="step-actions">
|
||||
<a href="/download" class="btn btn-secondary btn-sm">Get apps</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-item">
|
||||
<div class="step-icon step-icon--pending">3</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">Create your first vault</div>
|
||||
<div class="step-meta">Set up encryption · Add credentials</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-item">
|
||||
<div class="step-icon step-icon--pending">4</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">Trial reminder</div>
|
||||
<div class="step-meta">Email in 5 days · No charge until day 7</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="quick-actions">
|
||||
<a href="https://vault.clavitor.ai" class="action-card action-card--primary" onclick="handlePortalRedirect(event)">
|
||||
<div class="action-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
</div>
|
||||
<div class="action-content">
|
||||
<div class="action-title">Open your vault</div>
|
||||
<div class="action-hint">vault.clavitor.ai</div>
|
||||
</div>
|
||||
<div class="action-arrow">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="action-grid">
|
||||
<a href="/download" class="action-card action-card--secondary">
|
||||
<div class="action-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</div>
|
||||
<div class="action-content">
|
||||
<div class="action-title">Download apps</div>
|
||||
<div class="action-hint">All platforms</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/docs" class="action-card action-card--secondary">
|
||||
<div class="action-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||
</div>
|
||||
<div class="action-content">
|
||||
<div class="action-title">Read docs</div>
|
||||
<div class="action-hint">Getting started</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trial Info -->
|
||||
<div class="trial-info">
|
||||
<div class="info-box info-box--subtle">
|
||||
<svg width="16" height="16" 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 class="info-content">
|
||||
<strong>Trial ends in 7 days</strong>
|
||||
<p>You'll receive a reminder email 2 days before. Cancel anytime from your account — no questions asked. After the trial: $12/year.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Support -->
|
||||
<div class="support-footer">
|
||||
<p>Questions? <a href="mailto:support@clavitor.ai">support@clavitor.ai</a> or <a href="/help">Help Center</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.onboarding-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 48px 24px;
|
||||
background: linear-gradient(180deg, #fafafa 0%, #f5f5f5 100%);
|
||||
}
|
||||
|
||||
.onboarding-container::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.onboarding-container--centered {
|
||||
justify-content: center;
|
||||
padding: 64px 24px;
|
||||
}
|
||||
|
||||
.onboarding-card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 48px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.onboarding-card--success {
|
||||
max-width: 520px;
|
||||
text-align: center;
|
||||
padding: 56px;
|
||||
}
|
||||
|
||||
/* Success Animation */
|
||||
.success-animation {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 32px;
|
||||
}
|
||||
|
||||
.success-circle {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
animation: success-pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
@keyframes success-pop {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.success-particles {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.success-particles span {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--brand-violet);
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.success-particles span:nth-child(1) { top: 0; left: 50%; transform: translateX(-50%); animation: particle-1 0.6s 0.2s ease-out forwards; }
|
||||
.success-particles span:nth-child(2) { top: 14%; right: 14%; animation: particle-2 0.6s 0.25s ease-out forwards; }
|
||||
.success-particles span:nth-child(3) { top: 50%; right: 0; transform: translateY(-50%); animation: particle-3 0.6s 0.3s ease-out forwards; }
|
||||
.success-particles span:nth-child(4) { bottom: 14%; right: 14%; animation: particle-4 0.6s 0.35s ease-out forwards; }
|
||||
.success-particles span:nth-child(5) { bottom: 0; left: 50%; transform: translateX(-50%); animation: particle-5 0.6s 0.4s ease-out forwards; }
|
||||
.success-particles span:nth-child(6) { bottom: 14%; left: 14%; animation: particle-6 0.6s 0.45s ease-out forwards; }
|
||||
.success-particles span:nth-child(7) { top: 50%; left: 0; transform: translateY(-50%); animation: particle-7 0.6s 0.5s ease-out forwards; }
|
||||
.success-particles span:nth-child(8) { top: 14%; left: 14%; animation: particle-8 0.6s 0.55s ease-out forwards; }
|
||||
|
||||
@keyframes particle-1 { to { transform: translateX(-50%) translateY(-30px); opacity: 1; } }
|
||||
@keyframes particle-2 { to { transform: translate(20px, -20px); opacity: 1; } }
|
||||
@keyframes particle-3 { to { transform: translateY(-50%) translateX(30px); opacity: 1; } }
|
||||
@keyframes particle-4 { to { transform: translate(20px, 20px); opacity: 1; } }
|
||||
@keyframes particle-5 { to { transform: translateX(-50%) translateY(30px); opacity: 1; } }
|
||||
@keyframes particle-6 { to { transform: translate(-20px, 20px); opacity: 1; } }
|
||||
@keyframes particle-7 { to { transform: translateY(-50%) translateX(-30px); opacity: 1; } }
|
||||
@keyframes particle-8 { to { transform: translate(-20px, -20px); opacity: 1; } }
|
||||
|
||||
/* Header */
|
||||
.success-header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.success-subtitle {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
/* Next Steps */
|
||||
.next-steps {
|
||||
text-align: left;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.next-steps-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.step-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.step-item.active .step-icon {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-icon--pending {
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.step-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.step-meta {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Quick Actions */
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--border);
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
border-color: var(--brand-black);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.action-card--primary {
|
||||
background: var(--brand-black);
|
||||
border-color: var(--brand-black);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-card--primary:hover {
|
||||
background: #262626;
|
||||
border-color: #262626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-card--secondary {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-card--secondary .action-icon {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.action-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.action-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-hint {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.action-arrow {
|
||||
color: white;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Trial Info */
|
||||
.trial-info {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.info-box--subtle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.info-box--subtle svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.info-box--subtle strong {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-box--subtle p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Support Footer */
|
||||
.support-footer {
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.support-footer a {
|
||||
color: var(--brand-black);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Loading state for redirect */
|
||||
.action-card--primary.redirecting {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.action-card--primary.redirecting .action-arrow {
|
||||
animation: pulse-arrow 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-arrow {
|
||||
0%, 100% { opacity: 0.6; transform: translateX(0); }
|
||||
50% { opacity: 1; transform: translateX(4px); }
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 640px) {
|
||||
.onboarding-card--success {
|
||||
padding: 40px 24px;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.onboarding-container--centered {
|
||||
padding: 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.success-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.action-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.next-steps {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Auto-redirect after 5 seconds
|
||||
let redirectTimer;
|
||||
const REDIRECT_DELAY = 5000;
|
||||
|
||||
function handlePortalRedirect(e) {
|
||||
// Let the click happen, but show visual feedback
|
||||
const card = e.currentTarget;
|
||||
card.classList.add('redirecting');
|
||||
card.querySelector('.action-title').textContent = 'Opening vault...';
|
||||
|
||||
// Clear any existing timer
|
||||
if (redirectTimer) {
|
||||
clearTimeout(redirectTimer);
|
||||
}
|
||||
}
|
||||
|
||||
// Start auto-redirect timer
|
||||
function startAutoRedirect() {
|
||||
const primaryAction = document.querySelector('.action-card--primary');
|
||||
|
||||
redirectTimer = setTimeout(() => {
|
||||
primaryAction.classList.add('redirecting');
|
||||
primaryAction.querySelector('.action-title').textContent = 'Opening vault...';
|
||||
|
||||
// Actually redirect
|
||||
window.location.href = primaryAction.href;
|
||||
}, REDIRECT_DELAY);
|
||||
}
|
||||
|
||||
// Start the timer when page loads
|
||||
startAutoRedirect();
|
||||
|
||||
// Cancel auto-redirect if user interacts with anything
|
||||
['click', 'touchstart', 'keydown'].forEach(event => {
|
||||
document.addEventListener(event, () => {
|
||||
if (redirectTimer) {
|
||||
clearTimeout(redirectTimer);
|
||||
redirectTimer = null;
|
||||
}
|
||||
}, { once: true });
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,478 @@
|
|||
{{define "onboarding-login"}}
|
||||
<div class="onboarding-container">
|
||||
<div class="onboarding-card">
|
||||
<!-- Progress -->
|
||||
<div class="onboarding-progress">
|
||||
<div class="progress-step completed clickable" onclick="goToStep('/onboarding/product')">
|
||||
<div class="progress-box">✓</div>
|
||||
<span class="progress-label">Product</span>
|
||||
</div>
|
||||
<div class="progress-line completed"></div>
|
||||
<div class="progress-step active">
|
||||
<div class="progress-box">2</div>
|
||||
<span class="progress-label">Account</span>
|
||||
</div>
|
||||
<div class="progress-line"></div>
|
||||
<div class="progress-step">
|
||||
<div class="progress-box">3</div>
|
||||
<span class="progress-label">Details</span>
|
||||
</div>
|
||||
<div class="progress-line"></div>
|
||||
<div class="progress-step">
|
||||
<div class="progress-box">4</div>
|
||||
<span class="progress-label">Terms</span>
|
||||
</div>
|
||||
<div class="progress-line"></div>
|
||||
<div class="progress-step">
|
||||
<div class="progress-box">5</div>
|
||||
<span class="progress-label">Payment</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="onboarding-header">
|
||||
<p class="label accent mb-3">Step 2 of 5</p>
|
||||
<h1 class="mb-4">Create your vault</h1>
|
||||
<p class="lead">Choose how you'd like to sign in.</p>
|
||||
</div>
|
||||
|
||||
<!-- Social Login Grid -->
|
||||
<div class="social-grid">
|
||||
<button class="social-btn" onclick="socialLogin('google')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
||||
<span>Continue with Google</span>
|
||||
</button>
|
||||
|
||||
<button class="social-btn" onclick="socialLogin('apple')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.74.82 0 2.1-.93 3.72-.8 1.25.1 2.16.55 2.85 1.37-2.57 1.54-2.14 4.5.44 5.38-.49 1.37-1.03 2.74-1.09 2.28zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/></svg>
|
||||
<span>Continue with Apple</span>
|
||||
</button>
|
||||
|
||||
<button class="social-btn" onclick="socialLogin('meta')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#0866FF"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm3.5 15.5h-2.1v-3.3h-2.8v3.3H8.5v-7h2.1v3.1h2.8v-3.1h2.1v7z"/></svg>
|
||||
<span>Continue with Meta</span>
|
||||
</button>
|
||||
|
||||
<button class="social-btn" onclick="socialLogin('microsoft')">
|
||||
<svg width="20" height="20" viewBox="0 0 21 21"><rect fill="#f25022" x="0" y="0" width="10" height="10"/><rect fill="#00a4ef" x="0" y="11" width="10" height="10"/><rect fill="#7fba00" x="11" y="0" width="10" height="10"/><rect fill="#ffb900" x="11" y="11" width="10" height="10"/></svg>
|
||||
<span>Continue with Microsoft</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="divider-or">
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
<!-- Email Login -->
|
||||
<form id="emailForm" class="email-form" onsubmit="return sendEmailCode(event)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Work email</label>
|
||||
<input type="email" class="form-input" placeholder="you@company.com" required id="emailInput">
|
||||
<p class="form-hint">We'll send a 6-digit verification code to this address.</p>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Send verification code</button>
|
||||
</form>
|
||||
|
||||
<!-- 2FA Code Entry (shown after email submit) -->
|
||||
<div id="codeEntry" class="code-entry hidden">
|
||||
<p class="mb-4">We sent a code to <strong id="emailDisplay"></strong></p>
|
||||
<div class="code-inputs">
|
||||
<input type="text" maxlength="1" class="code-digit" data-index="0">
|
||||
<input type="text" maxlength="1" class="code-digit" data-index="1">
|
||||
<input type="text" maxlength="1" class="code-digit" data-index="2">
|
||||
<input type="text" maxlength="1" class="code-digit" data-index="3">
|
||||
<input type="text" maxlength="1" class="code-digit" data-index="4">
|
||||
<input type="text" maxlength="1" class="code-digit" data-index="5">
|
||||
</div>
|
||||
<div class="code-actions">
|
||||
<button class="btn-text" onclick="resendCode()">Resend code</button>
|
||||
<span class="text-tertiary">·</span>
|
||||
<button class="btn-text" onclick="useDifferentEmail()">Use different email</button>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block mt-6" onclick="verifyCode()">Verify & continue</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<p class="onboarding-footer">
|
||||
By continuing, you agree to our <a href="/terms" class="text-accent">Terms of Service</a> and <a href="/privacy" class="text-accent">Privacy Policy</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.onboarding-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 48px 24px;
|
||||
background: linear-gradient(180deg, #fafafa 0%, #f5f5f5 100%);
|
||||
}
|
||||
|
||||
.onboarding-container::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.onboarding-card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 48px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.onboarding-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.progress-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-step.active .progress-dot {
|
||||
background: var(--brand-black);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.progress-step.completed .progress-dot {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.progress-box {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.progress-step.active .progress-box {
|
||||
background: var(--brand-black);
|
||||
color: white;
|
||||
border-color: var(--brand-black);
|
||||
}
|
||||
|
||||
.progress-step.completed .progress-box {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.progress-step.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progress-step.clickable:hover .progress-box {
|
||||
border-color: var(--brand-black);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.progress-step.clickable:hover .progress-label {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.progress-step.active .progress-label {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progress-line {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background: var(--border);
|
||||
margin: 0 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.onboarding-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo-lockup-sm {
|
||||
display: inline-flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo-lockup-sm .logo-lockup-square {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.logo-lockup-sm .logo-lockup-wordmark {
|
||||
font-size: 24px;
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
.social-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.social-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: white;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.social-btn:hover {
|
||||
border-color: var(--brand-black);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.social-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.divider-or {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 24px 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.divider-or::before,
|
||||
.divider-or::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.email-form {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-black);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.code-entry {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.code-entry.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.code-inputs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.code-digit {
|
||||
width: 48px;
|
||||
height: 56px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.code-digit:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-black);
|
||||
}
|
||||
|
||||
.code-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--brand-black);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.btn-text:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.onboarding-footer {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.onboarding-footer a {
|
||||
color: var(--brand-black);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.onboarding-card {
|
||||
padding: 32px 24px;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.onboarding-container {
|
||||
padding: 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.progress-line {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.code-digit {
|
||||
width: 40px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function goToStep(url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
function socialLogin(provider) {
|
||||
// Redirect to OAuth
|
||||
window.location.href = '/auth/' + provider + '/start';
|
||||
}
|
||||
|
||||
function sendEmailCode(e) {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById('emailInput').value;
|
||||
document.getElementById('emailDisplay').textContent = email;
|
||||
|
||||
// Show code entry, hide email form
|
||||
document.getElementById('emailForm').classList.add('hidden');
|
||||
document.getElementById('codeEntry').classList.remove('hidden');
|
||||
|
||||
// Focus first digit
|
||||
document.querySelector('.code-digit[data-index="0"]').focus();
|
||||
|
||||
// TODO: Actually send the email via API
|
||||
console.log('Sending 2FA code to:', email);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auto-advance code inputs
|
||||
document.querySelectorAll('.code-digit').forEach((input, index) => {
|
||||
input.addEventListener('input', (e) => {
|
||||
if (e.target.value.length === 1) {
|
||||
const next = document.querySelector('.code-digit[data-index="' + (index + 1) + '"]');
|
||||
if (next) next.focus();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Backspace' && !e.target.value) {
|
||||
const prev = document.querySelector('.code-digit[data-index="' + (index - 1) + '"]');
|
||||
if (prev) prev.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function verifyCode() {
|
||||
const code = Array.from(document.querySelectorAll('.code-digit')).map(i => i.value).join('');
|
||||
if (code.length === 6) {
|
||||
window.location.href = '/onboarding/details';
|
||||
}
|
||||
}
|
||||
|
||||
function resendCode() {
|
||||
const email = document.getElementById('emailDisplay').textContent;
|
||||
console.log('Resending code to:', email);
|
||||
// Show toast or feedback
|
||||
}
|
||||
|
||||
function useDifferentEmail() {
|
||||
document.getElementById('emailForm').classList.remove('hidden');
|
||||
document.getElementById('codeEntry').classList.add('hidden');
|
||||
document.getElementById('emailInput').focus();
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
</content>
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
{{define "onboarding-plan"}}
|
||||
<div class="container-lg" style="padding:4rem 1rem">
|
||||
<p class="label accent mb-3" style="text-align:center">Step 3 of 3</p>
|
||||
<h1 class="mb-4" style="text-align:center">Pick your plan</h1>
|
||||
<p class="lead mb-8" style="text-align:center">7-day free trial on all plans. Cancel anytime. Annual billing for individuals and small teams; monthly available for 100+. <strong>Price for life:</strong> Your rate never increases as long as you stay subscribed.</p>
|
||||
|
||||
<div id="plan-error" class="mb-4" style="display:none;color:var(--brand-red);text-align:center"></div>
|
||||
|
||||
<div class="grid-3">
|
||||
<div class="price-card">
|
||||
<p class="label mb-4">Personal</p>
|
||||
<div class="price-amount mb-2">$12<span class="price-period">/yr</span></div>
|
||||
<p class="text-sm text-secondary mb-6">1 person · 1 vault · 5 agents</p>
|
||||
<button data-plan="personal" class="btn btn-ghost btn-block plan-pick">Choose Personal</button>
|
||||
</div>
|
||||
<div class="price-card featured">
|
||||
<span class="badge recommended price-badge">Recommended</span>
|
||||
<p class="label accent mb-4">Family</p>
|
||||
<div class="price-amount mb-2">$24<span class="price-period">/yr</span></div>
|
||||
<p class="text-sm text-secondary mb-6">~6 people · 1 vault · 15 agents</p>
|
||||
<button data-plan="family" class="btn btn-primary btn-block plan-pick">Choose Family</button>
|
||||
</div>
|
||||
<div class="price-card">
|
||||
<p class="label mb-4">Pro</p>
|
||||
<div class="price-amount mb-2">$49<span class="price-period">/yr</span></div>
|
||||
<p class="text-sm text-secondary mb-6">1 person · 1 vault · 50 agents</p>
|
||||
<button data-plan="pro" class="btn btn-ghost btn-block plan-pick">Choose Pro</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-sm text-secondary mb-4">Need more? <a href="/pricing">View team plans</a> (10–500 people)</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "onboarding-plan-script"}}
|
||||
<script>
|
||||
document.querySelectorAll('.plan-pick').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const errEl = document.getElementById('plan-error');
|
||||
const plan = this.dataset.plan;
|
||||
errEl.style.display = 'none';
|
||||
document.querySelectorAll('.plan-pick').forEach(b => b.disabled = true);
|
||||
this.textContent = 'Redirecting...';
|
||||
try {
|
||||
const r = await fetch('/onboarding/plan', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({plan})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok && d.checkout_url) {
|
||||
window.location = d.checkout_url;
|
||||
} else {
|
||||
errEl.textContent = d.error || 'Could not start checkout.';
|
||||
errEl.style.display = 'block';
|
||||
document.querySelectorAll('.plan-pick').forEach(b => b.disabled = false);
|
||||
}
|
||||
} catch(err) {
|
||||
errEl.textContent = 'Connection error. Try again.';
|
||||
errEl.style.display = 'block';
|
||||
document.querySelectorAll('.plan-pick').forEach(b => b.disabled = false);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,702 @@
|
|||
{{define "onboarding-product"}}
|
||||
<div class="onboarding-container">
|
||||
<div class="onboarding-card">
|
||||
<!-- Progress -->
|
||||
<div class="onboarding-progress">
|
||||
<div class="progress-step active">
|
||||
<div class="progress-box">1</div>
|
||||
<span class="progress-label">Product</span>
|
||||
</div>
|
||||
<div class="progress-line"></div>
|
||||
<div class="progress-step">
|
||||
<div class="progress-box">2</div>
|
||||
<span class="progress-label">Account</span>
|
||||
</div>
|
||||
<div class="progress-line"></div>
|
||||
<div class="progress-step">
|
||||
<div class="progress-box">3</div>
|
||||
<span class="progress-label">Details</span>
|
||||
</div>
|
||||
<div class="progress-line"></div>
|
||||
<div class="progress-step">
|
||||
<div class="progress-box">4</div>
|
||||
<span class="progress-label">Terms</span>
|
||||
</div>
|
||||
<div class="progress-line"></div>
|
||||
<div class="progress-step">
|
||||
<div class="progress-box">5</div>
|
||||
<span class="progress-label">Payment</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="onboarding-header">
|
||||
<p class="label accent mb-3">Step 1 of 5</p>
|
||||
<h1 class="mb-4">Select your setup</h1>
|
||||
<p class="lead">Choose where your vault lives and what you need.</p>
|
||||
</div>
|
||||
|
||||
<form id="productForm" onsubmit="return saveProduct(event)">
|
||||
<!-- Product Selection -->
|
||||
<div class="product-selection">
|
||||
<div class="product-card" data-product="hosted" onclick="selectProduct(this)">
|
||||
<div class="product-badge recommended">Recommended</div>
|
||||
<div class="product-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
</div>
|
||||
<div class="product-content">
|
||||
<h3 class="product-title">Clavitor Hosted</h3>
|
||||
<p class="product-price">$12<span class="product-period">/year</span></p>
|
||||
<p class="product-desc">We run your vault across 4 regions. Zero maintenance. Your encryption keys never leave your browser.</p>
|
||||
<ul class="product-features">
|
||||
<li>✓ 4 regions worldwide</li>
|
||||
<li>✓ Daily encrypted backups</li>
|
||||
<li>✓ 99.9% SLA</li>
|
||||
<li>✓ Email support</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="product-select-indicator">
|
||||
<div class="radio-box">
|
||||
<span class="radio-check">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Self-hosted removed from signup flow - users should download directly from /download -->
|
||||
|
||||
<div class="product-card disabled" data-product="enterprise">
|
||||
<div class="product-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
</div>
|
||||
<div class="product-content">
|
||||
<h3 class="product-title">Enterprise</h3>
|
||||
<p class="product-price">Custom</p>
|
||||
<p class="product-desc">Dedicated infrastructure, custom regions, SLAs, and advanced security controls.</p>
|
||||
<ul class="product-features">
|
||||
<li>✓ Dedicated single-tenant infrastructure</li>
|
||||
<li>✓ Custom regions (bring your own DC)</li>
|
||||
<li>✓ SAML SSO</li>
|
||||
<li>✓ Audit logs (1 year retention)</li>
|
||||
<li>✓ 99.99% SLA</li>
|
||||
<li>✓ Dedicated support engineer</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="product-contact">
|
||||
<button type="button" class="btn btn-ghost btn-sm" onclick="contactSales()">Contact sales</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Region Selection (only for hosted) -->
|
||||
<!--
|
||||
NOTE FOR NEXT AGENT: Use the /glass endpoint to get response times
|
||||
and order regions by latency from user's location.
|
||||
Endpoint: GET /glass/regions returns {regions: [{id, name, flag, latency_ms}]}
|
||||
Sort by latency_ms ascending and render in that order.
|
||||
-->
|
||||
<div id="regionSection" class="region-section">
|
||||
<h3 class="form-section-title">Select region</h3>
|
||||
<p class="region-desc">Your vault data stays in your chosen region. We never replicate across borders.</p>
|
||||
|
||||
<div class="region-grid">
|
||||
<label class="region-card" data-region="us-east">
|
||||
<input type="radio" name="region" value="us-east" checked>
|
||||
<div class="region-content">
|
||||
<div class="region-flag">🇺🇸</div>
|
||||
<div class="region-info">
|
||||
<div class="region-name">US-East</div>
|
||||
<div class="region-location">Virginia</div>
|
||||
</div>
|
||||
<div class="region-status">
|
||||
<span class="status-dot active"></span>
|
||||
<span class="status-text">12ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="region-card" data-region="eu-west">
|
||||
<input type="radio" name="region" value="eu-west">
|
||||
<div class="region-content">
|
||||
<div class="region-flag">🇮🇪</div>
|
||||
<div class="region-info">
|
||||
<div class="region-name">EU-West</div>
|
||||
<div class="region-location">Dublin</div>
|
||||
</div>
|
||||
<div class="region-status">
|
||||
<span class="status-dot active"></span>
|
||||
<span class="status-text">89ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="region-card" data-region="eu-central">
|
||||
<input type="radio" name="region" value="eu-central">
|
||||
<div class="region-content">
|
||||
<div class="region-flag">🇩🇪</div>
|
||||
<div class="region-info">
|
||||
<div class="region-name">EU-Central</div>
|
||||
<div class="region-location">Frankfurt</div>
|
||||
</div>
|
||||
<div class="region-status">
|
||||
<span class="status-dot active"></span>
|
||||
<span class="status-text">95ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="region-card" data-region="asia-southeast">
|
||||
<input type="radio" name="region" value="asia-southeast">
|
||||
<div class="region-content">
|
||||
<div class="region-flag">🇸🇬</div>
|
||||
<div class="region-info">
|
||||
<div class="region-name">Asia-Southeast</div>
|
||||
<div class="region-location">Singapore</div>
|
||||
</div>
|
||||
<div class="region-status">
|
||||
<span class="status-dot active"></span>
|
||||
<span class="status-text">156ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="region-notice">
|
||||
<div class="info-box accent">
|
||||
<svg width="16" height="16" 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>
|
||||
<span>Your choice is permanent. Each vault is an isolated database — we don't migrate between regions for security.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-ghost" onclick="history.back()">Back</button>
|
||||
<button type="submit" class="btn btn-primary" id="continueBtn">Continue →</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.onboarding-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 48px 24px;
|
||||
background: linear-gradient(180deg, #fafafa 0%, #f5f5f5 100%);
|
||||
}
|
||||
|
||||
.onboarding-container::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.onboarding-card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 48px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.onboarding-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.progress-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-step.active .progress-dot {
|
||||
background: var(--brand-black);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.progress-step.completed .progress-dot {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.progress-box {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.progress-step.active .progress-box {
|
||||
background: var(--brand-black);
|
||||
color: white;
|
||||
border-color: var(--brand-black);
|
||||
}
|
||||
|
||||
.progress-step.completed .progress-box {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.progress-step.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progress-step.clickable:hover .progress-box {
|
||||
border-color: var(--brand-black);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.progress-step.clickable:hover .progress-label {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.progress-step.active .progress-label {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progress-step.completed .progress-label {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.progress-line {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background: var(--border);
|
||||
margin: 0 8px;
|
||||
margin-bottom: 24px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.progress-line.completed {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.onboarding-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.product-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.product-card:hover:not(.disabled) {
|
||||
border-color: var(--brand-black);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.product-card.selected {
|
||||
border-color: var(--brand-black);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.product-card.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.product-badge {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: 20px;
|
||||
background: var(--brand-black);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.product-badge.recommended {
|
||||
background: var(--brand-black);
|
||||
}
|
||||
|
||||
.product-icon {
|
||||
flex-shrink: 0;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.product-card.selected .product-icon {
|
||||
background: var(--brand-black);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.product-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.product-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.product-period {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.product-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.product-features {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 24px;
|
||||
}
|
||||
|
||||
.product-features li {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.product-select-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.radio-box {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.product-card.selected .radio-box {
|
||||
border-color: var(--brand-black);
|
||||
background: var(--brand-black);
|
||||
}
|
||||
|
||||
.radio-check {
|
||||
color: white;
|
||||
transform: scale(0);
|
||||
transition: transform 0.2s;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.product-card.selected .radio-check {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.product-card.selected .radio-dot {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.product-contact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.region-section {
|
||||
margin-bottom: 40px;
|
||||
padding: 24px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.region-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.region-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.region-card {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.region-card input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.region-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 10px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.region-card:hover .region-content {
|
||||
border-color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.region-card input:checked + .region-content {
|
||||
border-color: var(--brand-black);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.region-flag {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.region-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.region-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.region-location {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.region-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.region-notice .info-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--brand-black);
|
||||
}
|
||||
|
||||
.info-box.accent {
|
||||
border-color: var(--brand-black);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-actions .btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.region-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.onboarding-card {
|
||||
padding: 32px 24px;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.onboarding-container {
|
||||
padding: 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.product-select-indicator {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
}
|
||||
|
||||
.product-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.region-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.progress-line {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.form-actions .btn:first-child {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function goToStep(url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
let selectedProduct = 'hosted';
|
||||
|
||||
function selectProduct(element) {
|
||||
if (element.classList.contains('disabled')) return;
|
||||
|
||||
// Remove selected from all
|
||||
document.querySelectorAll('.product-card').forEach(card => {
|
||||
card.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Add selected to clicked
|
||||
element.classList.add('selected');
|
||||
selectedProduct = element.dataset.product;
|
||||
|
||||
// Show/hide region section
|
||||
const regionSection = document.getElementById('regionSection');
|
||||
if (selectedProduct === 'hosted') {
|
||||
regionSection.classList.remove('region-hidden');
|
||||
} else {
|
||||
regionSection.classList.add('region-hidden');
|
||||
}
|
||||
|
||||
// Continue button always goes to next step (account/login)
|
||||
const continueBtn = document.getElementById('continueBtn');
|
||||
continueBtn.textContent = 'Continue →';
|
||||
}
|
||||
|
||||
function contactSales() {
|
||||
window.location.href = '/contact-sales';
|
||||
}
|
||||
|
||||
function saveProduct(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const region = document.querySelector('input[name="region"]:checked')?.value;
|
||||
|
||||
console.log('Selected:', selectedProduct, region);
|
||||
|
||||
// Continue to account/login (Step 2)
|
||||
window.location.href = '/onboarding/login';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Initialize
|
||||
selectProduct(document.querySelector('[data-product="hosted"]'));
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
{{define "onboarding-profile"}}
|
||||
<div class="container-sm" style="padding:4rem 1rem">
|
||||
<p class="label accent mb-3" style="text-align:center">Step 2 of 3</p>
|
||||
<h1 class="mb-4" style="text-align:center">A few details</h1>
|
||||
<p class="lead mb-8" style="text-align:center">Your billing address is required by Paddle to charge tax correctly. We never share it.</p>
|
||||
|
||||
<div id="profile-error" class="mb-4" style="display:none;color:var(--brand-red);text-align:center"></div>
|
||||
|
||||
<form id="profile-form" style="display:flex;flex-direction:column;gap:14px">
|
||||
<label>
|
||||
<span class="form-label">Name</span>
|
||||
<input type="text" name="name" required autocomplete="name"
|
||||
style="width:100%;padding:12px 14px;border:1px solid var(--border);border-radius:6px;font-family:inherit;font-size:0.9rem">
|
||||
</label>
|
||||
<label>
|
||||
<span class="form-label">Address (optional)</span>
|
||||
<input type="text" name="address1" autocomplete="street-address"
|
||||
style="width:100%;padding:12px 14px;border:1px solid var(--border);border-radius:6px;font-family:inherit;font-size:0.9rem">
|
||||
</label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
|
||||
<label>
|
||||
<span class="form-label">City (optional)</span>
|
||||
<input type="text" name="city" autocomplete="address-level2"
|
||||
style="width:100%;padding:12px 14px;border:1px solid var(--border);border-radius:6px;font-family:inherit;font-size:0.9rem">
|
||||
</label>
|
||||
<label>
|
||||
<span class="form-label">Zip / postal (optional)</span>
|
||||
<input type="text" name="zip" autocomplete="postal-code"
|
||||
style="width:100%;padding:12px 14px;border:1px solid var(--border);border-radius:6px;font-family:inherit;font-size:0.9rem">
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
|
||||
<label>
|
||||
<span class="form-label">State / region (optional)</span>
|
||||
<input type="text" name="region" autocomplete="address-level1"
|
||||
style="width:100%;padding:12px 14px;border:1px solid var(--border);border-radius:6px;font-family:inherit;font-size:0.9rem">
|
||||
</label>
|
||||
<label>
|
||||
<span class="form-label">Country</span>
|
||||
<input type="text" name="country_code" required maxlength="2" placeholder="US" autocomplete="country" style="text-transform:uppercase;width:100%;padding:12px 14px;border:1px solid var(--border);border-radius:6px;font-family:inherit;font-size:0.9rem">
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="padding:12px;margin-top:8px">Continue →</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "onboarding-profile-script"}}
|
||||
<script>
|
||||
document.getElementById('profile-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const errEl = document.getElementById('profile-error');
|
||||
const btn = this.querySelector('button');
|
||||
errEl.style.display = 'none';
|
||||
btn.disabled = true; btn.textContent = 'Saving...';
|
||||
const data = {
|
||||
name: this.name.value.trim(),
|
||||
address1: this.address1.value.trim(),
|
||||
city: this.city.value.trim(),
|
||||
region: this.region.value.trim(),
|
||||
zip: this.zip.value.trim(),
|
||||
country_code: this.country_code.value.trim().toUpperCase(),
|
||||
};
|
||||
try {
|
||||
const r = await fetch('/onboarding/profile', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok) {
|
||||
window.location = d.next || '/onboarding/plan';
|
||||
} else {
|
||||
errEl.textContent = d.error || 'Could not save.';
|
||||
errEl.style.display = 'block';
|
||||
btn.disabled = false; btn.textContent = 'Continue \u2192';
|
||||
}
|
||||
} catch(err) {
|
||||
errEl.textContent = 'Connection error. Try again.';
|
||||
errEl.style.display = 'block';
|
||||
btn.disabled = false; btn.textContent = 'Continue \u2192';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,537 @@
|
|||
{{define "onboarding-terms"}}
|
||||
<div class="onboarding-container">
|
||||
<div class="onboarding-card onboarding-card--wide">
|
||||
<!-- Progress -->
|
||||
<div class="onboarding-progress">
|
||||
<div class="progress-step completed clickable" onclick="goToStep('/onboarding/product')">
|
||||
<div class="progress-box">✓</div>
|
||||
<span class="progress-label">Product</span>
|
||||
</div>
|
||||
<div class="progress-line completed"></div>
|
||||
<div class="progress-step completed clickable" onclick="goToStep('/onboarding/login')">
|
||||
<div class="progress-box">✓</div>
|
||||
<span class="progress-label">Account</span>
|
||||
</div>
|
||||
<div class="progress-line completed"></div>
|
||||
<div class="progress-step completed clickable" onclick="goToStep('/onboarding/details')">
|
||||
<div class="progress-box">✓</div>
|
||||
<span class="progress-label">Details</span>
|
||||
</div>
|
||||
<div class="progress-line completed"></div>
|
||||
<div class="progress-step active">
|
||||
<div class="progress-box">4</div>
|
||||
<span class="progress-label">Terms</span>
|
||||
</div>
|
||||
<div class="progress-line"></div>
|
||||
<div class="progress-step">
|
||||
<div class="progress-box">5</div>
|
||||
<span class="progress-label">Payment</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="onboarding-header">
|
||||
<p class="label accent mb-3">Step 4 of 5</p>
|
||||
<h1 class="mb-4">One last thing</h1>
|
||||
<p class="lead">Auto-renewal protects your vault. Here's why it's required.</p>
|
||||
</div>
|
||||
|
||||
<form id="termsForm" onsubmit="return acceptTerms(event)">
|
||||
<!-- Why Auto-Renewal -->
|
||||
<div class="terms-section">
|
||||
<h3 class="terms-section-title">Why auto-renewal is required</h3>
|
||||
|
||||
<div class="explanation-cards">
|
||||
<div class="explanation-card">
|
||||
<div class="explanation-icon">
|
||||
<svg width="24" height="24" 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>
|
||||
<h4>Protects your data</h4>
|
||||
<p>If your subscription lapses, your vault enters a 7-day deletion countdown. We'd rather refund an accidental renewal than delete your credentials because your card expired.</p>
|
||||
</div>
|
||||
|
||||
<div class="explanation-card">
|
||||
<div class="explanation-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
</div>
|
||||
<h4>14-day regret period</h4>
|
||||
<p>If auto-renewal charges you and you didn't intend to continue, you have <strong>14 days</strong> for a full refund. No questions. Just cancel or email support@clavitor.ai.</p>
|
||||
</div>
|
||||
|
||||
<div class="explanation-card">
|
||||
<div class="explanation-icon">
|
||||
<svg width="24" height="24" 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>
|
||||
<h4>Price for life guarantee</h4>
|
||||
<p>Your $12/year rate (or whatever you pay today) is locked forever. Even if we raise prices for new customers in 2030, you'll still pay what you paid today.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terms Checklist -->
|
||||
<div class="terms-section">
|
||||
<div class="checkbox-list">
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" required id="agreeTerms">
|
||||
<div class="checkbox-ui">
|
||||
<div class="checkbox-box">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<div class="checkbox-content">
|
||||
<span class="checkbox-label">I agree to the <a href="/terms" target="_blank">Terms of Service</a> and <a href="/privacy" target="_blank">Privacy Policy</a></span>
|
||||
<span class="checkbox-hint">Including the auto-renewal and data deletion policies</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" required id="agreeAutoRenew">
|
||||
<div class="checkbox-ui">
|
||||
<div class="checkbox-box">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<div class="checkbox-content">
|
||||
<span class="checkbox-label">I consent to auto-renewal</span>
|
||||
<span class="checkbox-hint">My subscription will automatically renew annually. I understand I can disable this anytime, and I have a 14-day refund window after each renewal.</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" id="agreeMarketing">
|
||||
<div class="checkbox-ui">
|
||||
<div class="checkbox-box">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<div class="checkbox-content">
|
||||
<span class="checkbox-label">Send me security updates (optional)</span>
|
||||
<span class="checkbox-hint">Feature announcements, security advisories, and operational notices. No marketing spam.</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancellation Preview -->
|
||||
<div class="terms-notice">
|
||||
<div class="notice-box">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
<div class="notice-content">
|
||||
<strong>You can turn off auto-renewal anytime</strong>
|
||||
<p>In your account settings. If you do, your vault stays active until the end of your current billing period, then enters a 7-day grace period before deletion. You'll get a warning email before anything is deleted.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-ghost" onclick="history.back()">Back</button>
|
||||
<button type="submit" class="btn btn-primary" id="continueBtn" disabled>Continue to payment →</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.onboarding-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 48px 24px;
|
||||
background: linear-gradient(180deg, #fafafa 0%, #f5f5f5 100%);
|
||||
}
|
||||
|
||||
.onboarding-container::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.onboarding-card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 48px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.onboarding-card--wide {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.onboarding-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.progress-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-step.active .progress-dot {
|
||||
background: var(--brand-black);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.progress-step.completed .progress-dot {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.progress-box {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.progress-step.active .progress-box {
|
||||
background: var(--brand-black);
|
||||
color: white;
|
||||
border-color: var(--brand-black);
|
||||
}
|
||||
|
||||
.progress-step.completed .progress-box {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.progress-step.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progress-step.clickable:hover .progress-box {
|
||||
border-color: var(--brand-black);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.progress-step.clickable:hover .progress-label {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.progress-step.active .progress-label {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progress-step.completed .progress-label {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.progress-line {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background: var(--border);
|
||||
margin: 0 8px;
|
||||
margin-bottom: 24px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.progress-line.completed {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.onboarding-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.terms-section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.terms-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 24px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.explanation-cards {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.explanation-card {
|
||||
padding: 24px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.explanation-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: var(--brand-black);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.explanation-card h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.explanation-card p {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.checkbox-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-item input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.checkbox-ui {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.checkbox-item:hover .checkbox-ui {
|
||||
border-color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.checkbox-item input:focus + .checkbox-ui {
|
||||
outline: 2px solid var(--brand-black);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.checkbox-item input:checked + .checkbox-ui {
|
||||
border-color: var(--brand-black);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.checkbox-box {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.checkbox-box svg {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.checkbox-item input:checked + .checkbox-ui .checkbox-box {
|
||||
background: var(--brand-black);
|
||||
border-color: var(--brand-black);
|
||||
}
|
||||
|
||||
.checkbox-item input:checked + .checkbox-ui .checkbox-box svg {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.checkbox-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.checkbox-label a {
|
||||
color: var(--brand-black);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.checkbox-hint {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.terms-notice {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.notice-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
border-left: 4px solid var(--brand-black);
|
||||
}
|
||||
|
||||
.notice-box svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.notice-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notice-content strong {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.notice-content p {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-actions .btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.form-actions .btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.onboarding-card {
|
||||
padding: 32px 24px;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.onboarding-container {
|
||||
padding: 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.explanation-cards {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.explanation-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.checkbox-ui {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.progress-line {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.form-actions .btn:first-child {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Enable/disable continue button based on required checkboxes
|
||||
const agreeTerms = document.getElementById('agreeTerms');
|
||||
const agreeAutoRenew = document.getElementById('agreeAutoRenew');
|
||||
const continueBtn = document.getElementById('continueBtn');
|
||||
|
||||
function updateButton() {
|
||||
continueBtn.disabled = !(agreeTerms.checked && agreeAutoRenew.checked);
|
||||
}
|
||||
|
||||
agreeTerms.addEventListener('change', updateButton);
|
||||
agreeAutoRenew.addEventListener('change', updateButton);
|
||||
|
||||
function goToStep(url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
function acceptTerms(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const data = {
|
||||
termsAgreed: true,
|
||||
autoRenewConsent: true,
|
||||
marketingConsent: document.getElementById('agreeMarketing').checked
|
||||
};
|
||||
|
||||
console.log('Terms accepted:', data);
|
||||
|
||||
// Proceed to payment (Step 5)
|
||||
window.location.href = '/onboarding/checkout';
|
||||
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -66,7 +66,7 @@ keys = clavitor.search_vault("ssh")</pre></div>
|
|||
<div class="card mb-6" style="border-color:var(--border-gold)">
|
||||
<h3 class="mb-4">使用托管版 <span class="vaultname">clav<span class="n">itor</span></span>?</h3>
|
||||
<p class="mb-4">你的 MCP URL 包含唯一的保管库标识。注册后可在<strong>账户信息</strong>页面找到完整 URL。</p>
|
||||
<p style="font-size:0.875rem;color:var(--muted)">格式: <code>https://clavitor.com/<em>your_vault_id</em>/mcp</code></p>
|
||||
<p class="text-sm text-tertiary">格式: <code>https://clavitor.ai/<em>your_vault_id</em>/mcp</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -78,13 +78,13 @@ keys = clavitor.search_vault("ssh")</pre></div>
|
|||
<div class="card card-hover alt">
|
||||
<h3 class="mb-3">“部署到生产环境”</h3>
|
||||
<p>智能体查找服务器凭据、SSH 密钥和所需的 API 令牌 — 然后执行部署。</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem"><pre>get_credential("aws-production")
|
||||
<div class="code-block mt-3"><pre>get_credential("aws-production")
|
||||
get_totp("aws") → 283941 (expires in 22s)</pre></div>
|
||||
</div>
|
||||
<div class="card card-hover alt">
|
||||
<h3 class="mb-3">“登录 GitHub 检查 CI”</h3>
|
||||
<p>智能体找到凭据,生成实时 TOTP 验证码,完成双因素认证。不需要手机。</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem"><pre>get_credential("github")
|
||||
<div class="code-block mt-3"><pre>get_credential("github")
|
||||
get_totp("github") → 847203 (expires in 14s)</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -92,17 +92,17 @@ get_totp("github") → 847203 (expires in 14s)</pre></div>
|
|||
<div class="card card-hover">
|
||||
<h3 class="mb-3">“找到数据库凭据”</h3>
|
||||
<p>全文搜索所有条目 — 标题、URL、用户名、备注。</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem">search_vault("postgres")</div>
|
||||
<div class="code-block mt-3">search_vault("postgres")</div>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">“什么快过期了?”</h3>
|
||||
<p>检查即将过期的凭据、银行卡或文档。</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem">check_expiring(30)</div>
|
||||
<div class="code-block mt-3">check_expiring(30)</div>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">“列出所有条目”</h3>
|
||||
<p>列出智能体可见的所有条目。适合盘点或项目交接。</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem">list_credentials()</div>
|
||||
<div class="code-block mt-3">list_credentials()</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ keys = clavitor.search_vault("ssh")</pre></div>
|
|||
<div class="card mb-6" style="border-color:var(--border-gold)">
|
||||
<h3 class="mb-4">Using hosted <span class="vaultname">clav<span class="n">itor</span></span>?</h3>
|
||||
<p class="mb-4">Your MCP URL includes your unique vault identifier. You can find the exact URL in your <strong>Account Information</strong> page after signing up.</p>
|
||||
<p style="font-size:0.875rem;color:var(--muted)">It looks like: <code>https://clavitor.com/<em>your_vault_id</em>/mcp</code></p>
|
||||
<p class="text-sm text-tertiary">It looks like: <code>https://clavitor.ai/<em>your_vault_id</em>/mcp</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -74,13 +74,13 @@ keys = clavitor.search_vault("ssh")</pre></div>
|
|||
<div class="card card-hover alt">
|
||||
<h3 class="mb-3">“Deploy to production”</h3>
|
||||
<p>Your agent looks up server credentials, SSH key, and any required API tokens — then does the deployment.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem"><pre>get_credential("aws-production")
|
||||
<div class="code-block mt-3"><pre>get_credential("aws-production")
|
||||
get_totp("aws") → 283941 (expires in 22s)</pre></div>
|
||||
</div>
|
||||
<div class="card card-hover alt">
|
||||
<h3 class="mb-3">“Log in to GitHub and check the CI”</h3>
|
||||
<p>Your agent finds the credential, generates a live TOTP code, and completes the 2FA flow. No phone needed.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem"><pre>get_credential("github")
|
||||
<div class="code-block mt-3"><pre>get_credential("github")
|
||||
get_totp("github") → 847203 (expires in 14s)</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -88,17 +88,17 @@ get_totp("github") → 847203 (expires in 14s)</pre></div>
|
|||
<div class="card card-hover">
|
||||
<h3 class="mb-3">“Find my database credentials”</h3>
|
||||
<p>Full-text search across all entries — titles, URLs, usernames, notes.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem">search_vault("postgres")</div>
|
||||
<div class="code-block mt-3">search_vault("postgres")</div>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">“What’s expiring soon?”</h3>
|
||||
<p>Check for credentials, cards, or documents expiring within any timeframe.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem">check_expiring(30)</div>
|
||||
<div class="code-block mt-3">check_expiring(30)</div>
|
||||
</div>
|
||||
<div class="card card-hover">
|
||||
<h3 class="mb-3">“Show me everything”</h3>
|
||||
<p>List all entries the agent has access to. Useful for inventory or onboarding.</p>
|
||||
<div class="code-block mt-3" style="font-size:0.8125rem">list_credentials()</div>
|
||||
<div class="code-block mt-3">list_credentials()</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,153 +0,0 @@
|
|||
{{define "pricing-new"}}
|
||||
<div class="hero container">
|
||||
<p class="label mb-3">Pricing</p>
|
||||
<h1 class="mb-4">One product. One price list.</h1>
|
||||
<p class="lead">Same vault. Same encryption. Same architecture. Every tier.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Individual -->
|
||||
<div class="section container">
|
||||
<div class="grid-3 price-grid">
|
||||
|
||||
<div class="price-card">
|
||||
<p class="label mb-4">Personal</p>
|
||||
<div class="price-amount mb-2">$12<span class="price-period">/yr</span></div>
|
||||
<p class="text-sm text-secondary mb-6">1 vault · 5 agents · 2 devices</p>
|
||||
<a href="/signup" class="btn btn-ghost btn-block mb-6">Get started</a>
|
||||
<p class="text-sm mb-4">For one person with a couple of AI agents — Claude Code, a deploy script, a browser extension.</p>
|
||||
<ul class="checklist"><li>Three-tier encryption</li><li>WebAuthn PRF</li><li>All regions</li><li>Cross-hemisphere backup</li><li>Unlimited entries</li><li>99.999% SLA</li></ul>
|
||||
</div>
|
||||
|
||||
<div class="price-card featured">
|
||||
<span class="badge recommended price-badge">Recommended</span>
|
||||
<p class="label accent mb-4">Family</p>
|
||||
<div class="price-amount mb-2">$29<span class="price-period">/yr</span></div>
|
||||
<p class="text-sm text-secondary mb-6">1 vault · 15 agents · 6 devices</p>
|
||||
<a href="/signup" class="btn btn-primary btn-block mb-6">Get started</a>
|
||||
<p class="text-sm mb-4">For power users running multiple agents across workstations, phones, and tablets — or a household sharing one vault.</p>
|
||||
<ul class="checklist"><li>Three-tier encryption</li><li>WebAuthn PRF</li><li>All regions</li><li>Cross-hemisphere backup</li><li>Unlimited entries</li><li>99.999% SLA</li></ul>
|
||||
</div>
|
||||
|
||||
<div class="price-card">
|
||||
<p class="label mb-4">Pro</p>
|
||||
<div class="price-amount mb-2">$49<span class="price-period">/yr</span></div>
|
||||
<p class="text-sm text-secondary mb-6">1 vault · 50 agents · unlimited devices</p>
|
||||
<a href="/signup" class="btn btn-ghost btn-block mb-6">Get started</a>
|
||||
<p class="text-sm mb-4">For developers and freelancers with agents across CI/CD, infrastructure, monitoring, and client projects.</p>
|
||||
<ul class="checklist"><li>Three-tier encryption</li><li>WebAuthn PRF</li><li>All regions</li><li>Cross-hemisphere backup</li><li>Unlimited entries</li><li>99.999% SLA</li></ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="mt-8 text-sm text-tertiary text-center">Launch pricing — locked for life. <a href="/install">Community Edition</a> is free, forever.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Team -->
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">Teams</h2>
|
||||
<p class="lead mb-8">One company vault + one personal vault per employee. Unlimited tokens and devices. Annual or monthly.</p>
|
||||
|
||||
<table class="data-table mb-4">
|
||||
<thead>
|
||||
<tr><th>Plan</th><th style="text-align:right">People</th><th style="text-align:right">Annual</th><th style="text-align:right">Monthly</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Team 10</td><td style="text-align:right">10</td><td style="text-align:right">$249</td><td style="text-align:right">$24.90</td></tr>
|
||||
<tr><td>Team 25</td><td style="text-align:right">25</td><td style="text-align:right">$499</td><td style="text-align:right">$49.90</td></tr>
|
||||
<tr><td>Team 100</td><td style="text-align:right">100</td><td style="text-align:right">$1,499</td><td style="text-align:right">$149.90</td></tr>
|
||||
<tr><td>Team 250</td><td style="text-align:right">250</td><td style="text-align:right">$2,999</td><td style="text-align:right">$299.90</td></tr>
|
||||
<tr><td>Team 500</td><td style="text-align:right">500</td><td style="text-align:right">$4,999</td><td style="text-align:right">$499.90</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="text-sm text-tertiary">Monthly = annual / 10. Management plane included.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Enterprise -->
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">Enterprise</h2>
|
||||
<p class="lead mb-8">Per-user pricing. Unlimited agents. Direct invoicing.</p>
|
||||
|
||||
<table class="data-table mb-8">
|
||||
<thead>
|
||||
<tr><th></th><th>Mid-Market</th><th>Enterprise</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td style="font-weight:600">Price</td><td>$3/user/mo ($36/yr)</td><td>$6/user/mo ($72/yr)</td></tr>
|
||||
<tr><td style="font-weight:600">Minimum</td><td>500 users</td><td>1,000 users</td></tr>
|
||||
<tr><td style="font-weight:600">SCIM directory sync</td><td>✓</td><td>✓</td></tr>
|
||||
<tr><td style="font-weight:600">Centralized audit export</td><td>✓</td><td>✓</td></tr>
|
||||
<tr><td style="font-weight:600">Priority support</td><td>✓</td><td>✓</td></tr>
|
||||
<tr><td style="font-weight:600">Direct invoicing (Net 60)</td><td>✓</td><td>✓</td></tr>
|
||||
<tr><td style="font-weight:600">SIEM integration</td><td>—</td><td>✓</td></tr>
|
||||
<tr><td style="font-weight:600">SLA with compensation</td><td>—</td><td>✓</td></tr>
|
||||
<tr><td style="font-weight:600">SOC 2 · ISO 27001</td><td>—</td><td>✓</td></tr>
|
||||
<tr><td style="font-weight:600">Dedicated account manager</td><td>—</td><td>✓</td></tr>
|
||||
<tr><td style="font-weight:600">Custom region / deployment</td><td>—</td><td>✓</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- MSP -->
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">Managed Service Providers</h2>
|
||||
<p class="lead mb-4">Deploy credential issuance across your client base. Reseller compensation included.</p>
|
||||
<p class="mb-8">Vaults belong to the client — not the MSP. Client leaves? Their vaults go with them. Your technicians get scoped access across client vaults. Bulk provisioning, cross-client audit, one invoice.</p>
|
||||
<a href="mailto:sales@clavitor.ai" class="btn btn-ghost">Contact sales →</a>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Editions -->
|
||||
<div class="section container">
|
||||
<h2 class="mb-4">Two editions. Same vault.</h2>
|
||||
<p class="lead mb-8">The core vault is open source. The platform around it is what you pay for.</p>
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<p class="label red mb-4">Community Edition</p>
|
||||
<div style="font-size:1.5rem;font-weight:800;color:var(--text);margin-bottom:8px">Free</div>
|
||||
<p class="text-sm text-secondary mb-6">Open source · Elastic License 2.0 · Self-hosted</p>
|
||||
<ul class="checklist mb-6"><li>Single vault</li><li>Three-tier encryption (L1/L2/L3)</li><li>WebAuthn PRF</li><li>WebAuthn-gated admin ops</li><li>CLI + browser extension</li><li>Manual scope assignment</li><li>Unlimited entries</li></ul>
|
||||
<a href="/install" class="btn btn-ghost btn-block">Self-host guide →</a>
|
||||
</div>
|
||||
<div class="card alt">
|
||||
<p class="label accent mb-4">Hosted</p>
|
||||
<div style="font-size:1.5rem;font-weight:800;color:var(--text);margin-bottom:8px">From $12/yr</div>
|
||||
<p class="text-sm text-secondary mb-6">Closed source · Hosted · Managed</p>
|
||||
<ul class="checklist mb-6"><li>Everything in Community</li><li>Auto-scope · API key recognition</li><li>Management plane (multi-vault)</li><li>Global network (21+ regions)</li><li>Cross-hemisphere backup</li><li>99.999% SLA on reads</li><li>SCIM · SIEM · Centralized audit</li></ul>
|
||||
<a href="/signup" class="btn btn-primary btn-block">Get started →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Bottom -->
|
||||
<div class="section container">
|
||||
<div class="grid-3">
|
||||
<div>
|
||||
<h3 class="mb-2">Every hosted plan includes</h3>
|
||||
<ul class="checklist"><li>Three-tier encryption</li><li>WebAuthn PRF</li><li>{{len .Pops}} regions</li><li>Cross-hemisphere backup</li><li>99.999% SLA on reads</li><li>Unlimited entries</li></ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-2">Five nines</h3>
|
||||
<p class="text-sm text-secondary mb-3">Every hosted vault is replicated to an inland backup on the opposite hemisphere. Reads and TOTP never stop. Writes pause during failover.</p>
|
||||
<p class="text-sm text-secondary">SLA not met? Full refund for that month.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-2">Community Edition</h3>
|
||||
<p class="text-sm text-secondary mb-3">Self-hosted. Single vault. Full three-tier encryption. CLI and browser extension. Free forever. Elastic License 2.0.</p>
|
||||
<p class="text-sm text-secondary mb-3">Regions, replication, auto-scope, and API key recognition require the hosted infrastructure.</p>
|
||||
<a href="/install" class="btn btn-ghost btn-sm">Self-host guide →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -22,8 +22,8 @@
|
|||
<div class="price-card featured">
|
||||
<span class="badge recommended price-badge">Recommended</span>
|
||||
<p class="label accent mb-4">Hosted</p>
|
||||
<div class="price-amount mb-2"><s>$20</s> $12<span class="price-period">/year</span></div>
|
||||
<p class="mb-6">7-day money-back, no questions, instant.</p>
|
||||
<div class="price-amount mb-2">$12<span class="price-period">/year</span></div>
|
||||
<p class="mb-6">7-day free trial. Cancel anytime.</p>
|
||||
<a href="/signup" class="btn btn-primary btn-block mb-8">Get started</a>
|
||||
<p class="label accent mb-4">Everything in self-hosted, plus</p>
|
||||
<ul class="checklist"><li>Managed infrastructure</li><li>Daily encrypted backups</li><li>{{len .Pops}} regions across every continent</li><li>Automatic updates & patches</li><li>TLS included</li><li>Email support</li></ul>
|
||||
|
|
@ -40,7 +40,10 @@
|
|||
|
||||
<div class="prose">
|
||||
<h3>Why so cheap?</h3>
|
||||
<p>AI agents are everywhere — and so are the security risks. We set a price that's within reach for everyone, whether you're in the US, Nigeria, or the Philippines. $12/yr is launch pricing; regular price is $20/yr. Join now and your price is locked for life.</p>
|
||||
<p>AI agents are everywhere — and so are the security risks. We set a price that's within reach for everyone, whether you're in the US, Nigeria, or the Philippines. $12/yr for Personal, $24/yr for Family (up to 6 people), $49/yr for Pro power users.</p>
|
||||
|
||||
<h3>Will my price go up?</h3>
|
||||
<p><strong>Never.</strong> Your price is locked for life. Pay $12/yr today, pay $12/yr in 2035. We may raise prices for <em>new</em> customers — but your rate never changes. Competitors raise prices on existing customers regularly — 1Password, Bitwarden, others have hiked rates 20–50%. We don't. Your price in your currency, forever, as long as you stay subscribed. Cancel and come back later? You'll pay whatever the then-current rate is. Stay with us? You're protected. Your price is locked in your currency — no exchange rate adjustments, ever.</p>
|
||||
|
||||
<h3>Does self-hosted get every feature?</h3>
|
||||
<p>Yes. Every feature ships in both versions. Hosted adds managed infrastructure and backups — not functionality.</p>
|
||||
|
|
@ -52,7 +55,16 @@
|
|||
<p>Yes. Export your vault at any time as encrypted JSON. Import it anywhere. Your data is always portable.</p>
|
||||
|
||||
<h3>Can I get a refund?</h3>
|
||||
<p>Yes. 7-day money-back, no questions asked, instant refund.</p>
|
||||
<p>Yes — three ways. <strong>Free trial:</strong> 7-day trial on all paid plans; cancel anytime during trial at no charge. <strong>Regret period:</strong> Annual subscriptions auto-renew by default. If you didn't intend to renew, you have 14 days after the renewal to cancel for full refund — no questions. <strong>First purchase:</strong> 7-day money-back on your very first purchase. Monthly plans do not carry the 14-day regret period (just cancel before next charge).</p>
|
||||
|
||||
<h3>Why is auto-renewal the default?</h3>
|
||||
<p>Because a deleted vault is permanent. We'd rather refund an accidental renewal than delete your passwords because a card expired. The 14-day regret period gives you a safety net if you forget to cancel. You can disable auto-renewal anytime — your vault stays active until your billing period ends.</p>
|
||||
|
||||
<h3>What if my payment fails?</h3>
|
||||
<p>You have 14 days. Our processor (Paddle) retries automatically. We'll email you immediately. Update your card in those 14 days and nothing changes. After 14 days, we send the 7-day deletion warning. Day 22: vaults permanently deleted.</p>
|
||||
|
||||
<h3>What about larger teams?</h3>
|
||||
<p>Teams of 10–25 are annual-only ($249–$498/yr). Teams of 100+ can choose monthly ($149–$499/mo) or annual. All plans include a 7-day free trial.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
</ul>
|
||||
|
||||
<h2>What this policy covers</h2>
|
||||
<p>This privacy policy applies to the hosted Clavitor service at clavitor.com. If you self-host Clavitor, your data never touches our servers and this policy doesn't apply to you — your privacy is entirely in your own hands.</p>
|
||||
<p>This privacy policy applies to the hosted Clavitor service at clavitor.ai. If you self-host Clavitor, your data never touches our servers and this policy doesn't apply to you — your privacy is entirely in your own hands.</p>
|
||||
|
||||
<h2>Data we store</h2>
|
||||
<p>When you use hosted Clavitor, we store:</p>
|
||||
|
|
@ -52,14 +52,28 @@
|
|||
<h2>Law enforcement</h2>
|
||||
<p>If compelled by valid legal process, we can only provide: your email address, account creation date, and encrypted vault data. Credential fields are encrypted with your vault key (which we do not store). Identity fields are encrypted client-side. In practice, we have very little useful information to provide. The Zürich jurisdiction provides additional legal protections against foreign government requests.</p>
|
||||
|
||||
<h2>Account deletion</h2>
|
||||
<p>You can delete your account and all associated data at any time from the web interface. Deletion is immediate and irreversible. Backups containing your data are rotated out within 30 days.</p>
|
||||
<h2>Account deletion & cancellation</h2>
|
||||
<p>You may cancel your subscription or delete your account at any time. Cancellation takes effect at the start of your next billing period; until that date, your service continues normally.</p>
|
||||
|
||||
<p><strong>Deletion upon cancellation.</strong> When cancellation takes effect, your vaults are <em>immediately and permanently deleted</em> from our active systems. This deletion is instant and irreversible.</p>
|
||||
|
||||
<p><strong>Compliance retention.</strong> To satisfy legal and regulatory requirements, we retain encrypted backups for a maximum of 30 days after deletion. These backups exist solely for compliance purposes and are <em>not available for restoration</em>. You cannot request your data from these backups. After 30 days, even these copies are permanently destroyed.</p>
|
||||
|
||||
<p><strong>Your warning.</strong> We will email you 7 days before your cancellation takes effect, reminding you to export your vault data if you have not already done so. This is your final opportunity to preserve your information.</p>
|
||||
|
||||
<p><strong>Immediate termination for cause.</strong> In cases of abuse, illegal activity, or security threats, we may terminate immediately without the 7-day warning. The same deletion and backup policies apply.</p>
|
||||
|
||||
<p><strong>14-day regret period for annual renewals.</strong> As described in our Terms of Service Section 6, annual subscriptions that auto-renew carry a 14-day post-renewal cancellation window. If you did not intend to renew, cancel within 14 days of the renewal charge for a full refund.</p>
|
||||
|
||||
<p><strong>Price for life.</strong> Your subscription rate will never increase as long as your subscription remains active. This commitment applies to the currency and tier at which you originally subscribed. See Terms of Service Section 5 for full details.</p>
|
||||
|
||||
<p><strong>Self-hosted exception.</strong> This policy applies only to hosted (paid) service. Self-hosted Community Edition users manage their own data retention and deletion.</p>
|
||||
|
||||
<h2>Changes to this policy</h2>
|
||||
<p>We'll notify registered users by email before making material changes to this policy. The current version is always available at this URL.</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>Questions about this policy? Email <a href="mailto:privacy@clavitor.com">privacy@clavitor.com</a>.</p>
|
||||
<p>Questions about this policy? Email <a href="mailto:privacy@clavitor.ai">privacy@clavitor.ai</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,75 @@
|
|||
{{define "signup"}}
|
||||
<div class="hero container" style="min-height:60vh;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center">
|
||||
<div class="logo-lockup-square" style="width:64px;height:64px;margin-bottom:2rem"></div>
|
||||
<h1 class="mb-4">Coming soon</h1>
|
||||
<p class="lead mb-6">We're not quite ready yet. Leave your email and we'll notify you when signups open.</p>
|
||||
<form id="notify-form" style="display:flex;gap:8px;max-width:400px;width:100%">
|
||||
<input type="email" name="email" required placeholder="you@example.com" style="flex:1;padding:10px 14px;border:1px solid var(--border);border-radius:6px;font-family:inherit;font-size:0.9rem">
|
||||
<button type="submit" class="btn btn-primary">Notify me</button>
|
||||
<div class="container-sm" style="padding:4rem 1remm">
|
||||
<div class="logo-lockup-square" style="width:48px;height:48px;margin:0 auto 2rem"></div>
|
||||
<h1 class="mb-4" style="text-align:center">Create your vault</h1>
|
||||
<p class="lead mb-8" style="text-align:center">One vault. Every agent. Your secrets stay yours.</p>
|
||||
|
||||
<div id="signup-error" class="mb-4" style="display:none;color:var(--brand-red);text-align:center"></div>
|
||||
|
||||
<!-- Social providers (real flow when env vars are configured) -->
|
||||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:24px">
|
||||
<a href="/auth/google/start" id="btn-google" class="btn btn-ghost" style="display:flex;align-items:center;justify-content:center;gap:10px;padding:12px">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.258h2.908c1.702-1.567 2.684-3.874 2.684-6.615z" fill="#4285F4"/><path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z" fill="#34A853"/><path d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/><path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z" fill="#EA4335"/></svg>
|
||||
Continue with Google
|
||||
</a>
|
||||
<a href="/auth/apple/start" id="btn-apple" class="btn btn-ghost" style="display:flex;align-items:center;justify-content:center;gap:10px;padding:12px">
|
||||
<svg width="16" height="18" viewBox="0 0 14 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M11.34 8.51c.02 2.27 1.99 3.02 2.01 3.03-.02.05-.31 1.07-1.03 2.13-.62.92-1.27 1.83-2.29 1.85-1 .02-1.32-.59-2.46-.59s-1.5.58-2.45.61c-.98.04-1.73-1-2.36-1.91C1.5 11.79.5 8.4 1.83 6.12c.66-1.13 1.85-1.85 3.13-1.87.97-.02 1.89.65 2.48.65.59 0 1.71-.81 2.88-.69.49.02 1.86.2 2.74 1.49-.07.04-1.64.96-1.62 2.86M9.31 3.06C9.83 2.43 10.18 1.55 10.08.68 9.34.71 8.43 1.18 7.9 1.81c-.47.55-.89 1.45-.78 2.31.83.06 1.67-.42 2.19-1.06"/></svg>
|
||||
Continue with Apple
|
||||
</a>
|
||||
<a href="/auth/meta/start" id="btn-meta" class="btn btn-ghost" style="display:flex;align-items:center;justify-content:center;gap:10px;padding:12px">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#1877F2"><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>
|
||||
Continue with Meta
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div style="display:flex;align-items:center;gap:12px;margin:24px 0;color:var(--text-tertiary);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.08em">
|
||||
<div style="flex:1;height:1px;background:var(--border)"></div>
|
||||
or
|
||||
<div style="flex:1;height:1px;background:var(--border)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<form id="signup-email-form">
|
||||
<input type="email" name="email" required placeholder="you@example.com" autocomplete="email"
|
||||
style="width:100%;padding:12px 14px;border:1px solid var(--border);border-radius:6px;font-family:inherit;font-size:0.9rem;margin-bottom:12px">
|
||||
<button type="submit" class="btn btn-primary" style="width:100%;padding:12px">Continue with email</button>
|
||||
</form>
|
||||
<p id="notify-msg" class="mt-4" style="display:none"></p>
|
||||
<p class="mt-8"><a href="/" class="btn btn-ghost">← Back to home</a></p>
|
||||
|
||||
<p class="mt-6" style="text-align:center;font-size:0.75rem;color:var(--text-tertiary)">
|
||||
By continuing you agree to our <a href="/terms">Terms</a> and <a href="/privacy">Privacy Policy</a>.
|
||||
</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "signup-script"}}
|
||||
<script>
|
||||
document.getElementById('notify-form').addEventListener('submit', async function(e) {
|
||||
document.getElementById('signup-email-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const email = this.email.value;
|
||||
const msg = document.getElementById('notify-msg');
|
||||
const email = this.email.value.trim();
|
||||
const errEl = document.getElementById('signup-error');
|
||||
const btn = this.querySelector('button');
|
||||
btn.disabled = true; btn.textContent = 'Sending...';
|
||||
errEl.style.display = 'none';
|
||||
btn.disabled = true; btn.textContent = 'Creating account...';
|
||||
try {
|
||||
const r = await fetch('/notify', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({email})});
|
||||
const r = await fetch('/onboarding/email', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({email})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok) {
|
||||
msg.textContent = 'Got it. We\'ll let you know.';
|
||||
msg.style.color = '#16a34a';
|
||||
this.style.display = 'none';
|
||||
window.location = d.next || '/onboarding/profile';
|
||||
} else {
|
||||
msg.textContent = d.error || 'Something went wrong.';
|
||||
msg.style.color = 'var(--brand-red)';
|
||||
btn.disabled = false; btn.textContent = 'Notify me';
|
||||
errEl.textContent = d.error || 'Could not create account.';
|
||||
errEl.style.display = 'block';
|
||||
btn.disabled = false; btn.textContent = 'Continue with email';
|
||||
}
|
||||
} catch(err) {
|
||||
msg.textContent = 'Connection error. Try again.';
|
||||
msg.style.color = 'var(--brand-red)';
|
||||
btn.disabled = false; btn.textContent = 'Notify me';
|
||||
errEl.textContent = 'Connection error. Try again.';
|
||||
errEl.style.display = 'block';
|
||||
btn.disabled = false; btn.textContent = 'Continue with email';
|
||||
}
|
||||
msg.style.display = 'block';
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
{{define "subprocessors"}}
|
||||
<div class="hero container">
|
||||
<p class="label mb-3">Legal</p>
|
||||
<h1 class="mb-6">Subprocessors</h1>
|
||||
<p class="lead mb-8">Third parties that process data on behalf of Clavitor. All are GDPR-compliant and contractually bound to data protection standards equivalent to our own.</p>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section container prose-width">
|
||||
<h2 class="mb-4">Infrastructure & Hosting</h2>
|
||||
<p class="mb-6">Clavitor operates 21 Points of Presence (POPs) across six continents. Your vault data is stored encrypted at the POP nearest to you, with backups to geographically distant POPs for resilience. The complete list of POPs with cities, providers, and compliance certifications is maintained in our <a href="/pops">POP database</a>.</p>
|
||||
|
||||
<table class="data-table mb-8">
|
||||
<thead>
|
||||
<tr><th>Provider</th><th>Scope</th><th>Data Type</th><th>Certifications</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Amazon Web Services (AWS)</strong></td>
|
||||
<td>21 Points of Presence (POPs) across six continents. <a href="/pops">Full list in database</a>.</td>
|
||||
<td>Encrypted vault data, metadata, logs</td>
|
||||
<td>SOC 2 Type II, ISO 27001, GDPR</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Hostkey</strong></td>
|
||||
<td>Zürich (2 servers: HQ operations + POP)</td>
|
||||
<td>Administrative operations, billing infrastructure (HQ); Encrypted vault data (POP)</td>
|
||||
<td>ISO 27001, GDPR</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>ISHosting</strong></td>
|
||||
<td>Istanbul (Turkey), Almaty (Kazakhstan)</td>
|
||||
<td>Encrypted vault data — regional POPs</td>
|
||||
<td>Regional compliance</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>HostAfrica</strong></td>
|
||||
<td>Lagos (Nigeria), Nairobi (Kenya)</td>
|
||||
<td>Encrypted vault data — regional POPs</td>
|
||||
<td>Regional compliance</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Cloudflare</strong></td>
|
||||
<td>Global DNS resolution</td>
|
||||
<td>Domain resolution only — no vault data</td>
|
||||
<td>SOC 2 Type II, ISO 27001, GDPR</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="mb-4">Payment Processing</h2>
|
||||
<table class="data-table mb-8">
|
||||
<thead>
|
||||
<tr><th>Provider</th><th>Function</th><th>Data Processed</th><th>Certifications</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Paddle</strong></td>
|
||||
<td>Subscription billing, payment processing</td>
|
||||
<td>Payment method (tokenized), billing address, invoice data</td>
|
||||
<td>PCI DSS Level 1, SOC 2 Type II, GDPR</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="mb-4">Infrastructure Services</h2>
|
||||
<table class="data-table mb-8">
|
||||
<thead>
|
||||
<tr><th>Function</th><th>Provider</th><th>Data</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>DNS resolution</td>
|
||||
<td><strong>Cloudflare</strong></td>
|
||||
<td>Domain queries only — no vault data ever touches Cloudflare</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Transactional email</td>
|
||||
<td>Self-hosted (Zürich HQ)</td>
|
||||
<td>Email address, vault-related notifications</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="mb-4">What We Don't Use</h2>
|
||||
<p class="mb-4">We deliberately avoid common subprocessors that compromise privacy:</p>
|
||||
<ul class="mb-6">
|
||||
<li><strong>No Google:</strong> No Analytics, no Fonts, no reCAPTCHA, no Firebase</li>
|
||||
<li><strong>No Meta/Facebook:</strong> No tracking pixels, no social plugins</li>
|
||||
<li><strong>No third-party CDNs:</strong> All assets served from our own POPs (Cloudflare is DNS-only, never proxy/CDN)</li>
|
||||
<li><strong>No marketing platforms:</strong> No Mailchimp, HubSpot, or similar</li>
|
||||
<li><strong>No cloud logging:</strong> Logs stay within our infrastructure</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="mb-4">Updates</h2>
|
||||
<p class="mb-4">We notify all active subscribers 30 days before adding any new subprocessor. For critical security updates, shorter notice may apply with immediate notification.</p>
|
||||
<p class="text-sm text-tertiary">Last updated: April 2026</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -22,32 +22,73 @@
|
|||
<h2>4. Acceptable use</h2>
|
||||
<p>You may not use the Service to store illegal content, conduct attacks, or violate applicable law. We reserve the right to suspend accounts that violate these terms.</p>
|
||||
|
||||
<h2>5. Payment</h2>
|
||||
<p>Hosted service is billed annually at $20/year (promotional pricing may apply). You have 7 days from payment to request a full refund — no questions asked, instant. After 7 days, no refunds are issued.</p>
|
||||
<h2>5. Payment, Trial & Price Lock</h2>
|
||||
<p>Hosted service is billed annually (or monthly for Team 100+ plans). All paid plans include a 7-day free trial; you may cancel anytime during the trial at no charge. After the trial or upon annual renewal, you have 7 days from any payment to request a full refund — no questions asked, instant. After 7 days, no refunds are issued.</p>
|
||||
<p>Pricing tiers are published at <a href="/pricing">clavitor.ai/pricing</a> and incorporated by reference. Annual plans (Personal, Family, Pro, Team 10, Team 25) are billed once per year. Monthly billing is available for Team 100 and above.</p>
|
||||
|
||||
<h2>6. Data ownership</h2>
|
||||
<p><strong>Price for life guarantee.</strong> The price you pay when you subscribe is your price forever — as long as your subscription remains active. We will never increase <em>your</em> rate. Not after a year. Not after five years. Not after inflation hits 20%. We may raise prices for <em>new</em> subscribers, but your rate is locked in your currency, at your tier, for the lifetime of your active subscription.</p>
|
||||
|
||||
<p><strong>How it works.</strong> If you subscribed at $12/year for Personal, you pay $12/year in 2030. If you subscribed Team 100 at $1,499/year, you pay $1,499/year in 2035. This applies to your specific tier and currency. If you upgrade to a higher tier, you pay the <em>then-current</em> rate for that new tier — and <em>that</em> price locks for life. If you downgrade, you pay the then-current rate for the lower tier.</p>
|
||||
|
||||
<p><strong>The catch.</strong> Price-for-life applies only to <em>active</em> subscriptions. If you cancel, you forfeit your locked rate. If you re-subscribe later, you pay the then-current published rate. This is our incentive to keep you happy — and your incentive to stay subscribed.</p>
|
||||
|
||||
<p><strong>All currencies — exchange rate locked.</strong> This guarantee applies whether you pay in USD, EUR, GBP, CHF, or any currency we support. Your locked price is in the currency of your original subscription. <em>We do not adjust for exchange rate fluctuations in either direction.</em> If you subscribed at €11/year, you pay €11/year forever — even if the Euro strengthens or weakens against other currencies. We absorb the foreign exchange risk; your price never changes.</p>
|
||||
|
||||
<p><strong>No price increases for existing customers.</strong> While prices for new customers may change over time based on market conditions and cost evolution, we commit to never increasing rates for existing, active subscribers. Your initial rate in your currency remains constant for the lifetime of your continuous subscription.</p>
|
||||
|
||||
<h2>6. Auto-Renewal & Payment Failure</h2>
|
||||
|
||||
<p><strong>Auto-renewal is default.</strong> All subscriptions automatically renew at the end of each billing period (annual or monthly). This is not a convenience feature for us — it is a protective measure for you. A lapsed payment triggers the same deletion policy as cancellation. We would rather refund an accidental renewal than delete your vault because a card expired.</p>
|
||||
|
||||
<p><strong>14-day regret period for annual renewals.</strong> If your annual subscription auto-renews and you did not intend to continue, you have 14 days from the renewal date to cancel and receive a full refund. No questions asked, no usage check. This is your "regret the auto-renewal" window. Simply email support@clavitor.ai or cancel through your account settings. We will reverse the charge within 5 business days. Monthly renewals do not include this window — they can be stopped anytime by canceling before the next charge.</p>
|
||||
|
||||
<p><strong>How to turn it off.</strong> You may disable auto-renewal at any time in your account settings. Doing so converts your subscription to "manual renewal." Your vault remains active until the end of your current billing period. At that point, if not renewed, the cancellation and deletion policy (Section 10) takes effect — including the 7-day warning email before deletion. Disabling auto-renewal does not trigger the 14-day regret period; that applies only to auto-renewals that have already occurred.</p>
|
||||
|
||||
<p><strong>Failed payments & the 14-day retry window.</strong> If your payment method fails at renewal:</p>
|
||||
<ul>
|
||||
<li><strong>Day 0:</strong> Renewal fails. We email you immediately. Your vault stays active.</li>
|
||||
<li><strong>Days 1–14:</strong> Our payment processor (Paddle) automatically retries. You may update your payment method during this window. Your vault remains fully active.</li>
|
||||
<li><strong>Day 15:</strong> After 14 days of failed retries, your subscription enters cancellation status. The 7-day warning email is sent immediately.</li>
|
||||
<li><strong>Day 22:</strong> Cancellation takes effect. Your vaults are permanently deleted per Section 10.</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>No "suspended" state.</strong> We do not freeze, pause, or suspend vaults for non-payment. A vault is either active (full access) or deleted (permanent). This binary approach protects our architecture: we do not retain decrypted access credentials for dormant accounts.</p>
|
||||
|
||||
<p><strong>Reactivation is new.</strong> If your vault is deleted after non-payment, you may create a new subscription. Your old vaults cannot be restored — even from compliance backups, which are destroyed 30 days post-deletion per Section 10. The 14-day regret period for annual renewals does not apply to reactivation after deletion; once deleted, the data is gone.</p>
|
||||
|
||||
<h2>7. Data ownership</h2>
|
||||
<p>Your vault data is yours. We claim no rights to it. You can export or delete it at any time.</p>
|
||||
|
||||
<h2>7. Service availability</h2>
|
||||
<h2>8. Service availability</h2>
|
||||
<p>We aim for high availability but make no uptime guarantees. Scheduled maintenance will be announced in advance. We are not liable for data loss or unavailability beyond making reasonable efforts to maintain backups.</p>
|
||||
|
||||
<h2>8. Encryption limitations</h2>
|
||||
<h2>9. Encryption limitations</h2>
|
||||
<p>Credential fields (server-encrypted) provide strong encryption at rest and in transit. Identity fields (client-encrypted) provide an additional layer that even we cannot break. However, no system is perfectly secure. You use the Service at your own risk.</p>
|
||||
|
||||
<h2>9. Termination</h2>
|
||||
<p>You may delete your account at any time. We may suspend accounts that violate these terms. Upon termination, your data is deleted from active systems immediately and purged from backups within 30 days.</p>
|
||||
<h2>10. Cancellation & Data Deletion</h2>
|
||||
<p><strong>How cancellation works.</strong> You may cancel your subscription at any time through your account settings or by emailing support@clavitor.ai. Cancellation takes effect at the start of your next billing period — your service continues normally until that date, then ends.</p>
|
||||
|
||||
<h2>10. Limitation of liability</h2>
|
||||
<p><strong>What happens at cancellation.</strong> When your cancellation takes effect, all your vaults are <em>immediately and permanently deleted</em> from our active systems. This deletion is instant and irreversible. There is no grace period, no "trash bin," and no ability to undo this action once begun.</p>
|
||||
|
||||
<p><strong>Your warning.</strong> We will email you 7 days before your cancellation takes effect, reminding you to export your vault data. This is your final opportunity to preserve your passwords and credentials. We strongly recommend exporting before you cancel.</p>
|
||||
|
||||
<p><strong>Compliance backups.</strong> To meet our legal and regulatory obligations, we retain encrypted backups for a maximum of 30 days after deletion. These backups exist solely for compliance purposes and are <em>not available for restoration</em>. You cannot request your data from these backups. After 30 days, even these compliance copies are permanently destroyed.</p>
|
||||
|
||||
<p><strong>When we terminate you.</strong> We may suspend or terminate accounts that violate these terms. In cases of abuse, illegal activity, or security threats, we may terminate immediately without the 7-day warning. The same deletion and backup policies apply.</p>
|
||||
|
||||
<p><strong>Self-hosted users.</strong> This cancellation policy applies only to hosted (paid) service. Self-hosted Community Edition users manage their own data retention and deletion.</p>
|
||||
|
||||
<h2>11. Limitation of liability</h2>
|
||||
<p>The Service is provided "as is." To the maximum extent permitted by applicable law, we are not liable for indirect, incidental, or consequential damages arising from your use of the Service.</p>
|
||||
|
||||
<h2>11. Governing law</h2>
|
||||
<h2>12. Governing law</h2>
|
||||
<p>These terms are governed by the laws of Switzerland. Disputes will be resolved in the courts of Zürich, Switzerland.</p>
|
||||
|
||||
<h2>12. Changes</h2>
|
||||
<h2>13. Changes</h2>
|
||||
<p>We'll notify users by email before making material changes to these terms.</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>Questions? Email <a href="mailto:legal@clavitor.com">legal@clavitor.com</a>.</p>
|
||||
<p>Questions? Email <a href="mailto:legal@clavitor.ai">legal@clavitor.ai</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@
|
|||
<p>Your old vault encrypted everything the same way. Clavitor assigns each field to the right tier automatically. Your SSN gets hardware-key protection. Your API keys stay agent-accessible. The right security for each piece of data.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 class="mb-2">API keys auto-detected <span class="badge" style="font-size:0.6rem;vertical-align:middle">hosted</span></h3>
|
||||
<h3 class="mb-2">API keys auto-detected <span class="badge" style="vertical-align:middle">hosted</span></h3>
|
||||
<p>On hosted plans, Clavitor recognizes API keys by pattern during import — OpenAI, AWS, GitHub, Stripe, and more. They're separated into their own category automatically, so your agents can find and use them without digging through generic password entries.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,309 @@
|
|||
// Two-Level Write (TLW) — writes to corporate.db AND Paddle in one call.
|
||||
//
|
||||
// NOTE: This is a minimal subset of admin/sync.go for the public web server's
|
||||
// onboarding flow. Both files share the same corporate.db and Paddle API.
|
||||
// When the public web server and admin server merge, this file goes away and
|
||||
// admin/sync.go becomes the canonical implementation.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// corpDB is the connection to corporate.db (customers, addresses, subscriptions).
|
||||
// It's separate from `db` (clavitor.db, which holds POPs/telemetry/etc).
|
||||
var corpDB *sql.DB
|
||||
|
||||
// tlwSandbox is true when we should hit the Paddle sandbox API instead of live.
|
||||
// Auto-detected from CLAVITOR_PADDLE_LIVE env var (default: sandbox).
|
||||
var tlwSandbox = os.Getenv("CLAVITOR_PADDLE_LIVE") != "1"
|
||||
|
||||
// paddleAPIKey is loaded from PADDLE_API_KEY env var. Empty = TLW writes to DB
|
||||
// only and skips Paddle (useful for local dev without sandbox credentials).
|
||||
var paddleAPIKey = os.Getenv("PADDLE_API_KEY")
|
||||
|
||||
// initTLW opens corporate.db and configures the TLW.
|
||||
// Refuses to create the file or the schema — both must already exist.
|
||||
// If either is missing, returns an error and onboarding stays disabled.
|
||||
// Called from main() after the regular db is opened.
|
||||
func initTLW() error {
|
||||
// `mode=rw` makes sqlite refuse to create a missing file (otherwise
|
||||
// the driver would silently create an empty one — exactly the
|
||||
// foot-gun we want to avoid).
|
||||
if _, err := os.Stat("corporate.db"); err != nil {
|
||||
return fmt.Errorf("corporate.db not found (must be provisioned externally): %w", err)
|
||||
}
|
||||
|
||||
var err error
|
||||
corpDB, err = sql.Open("sqlite3", "corporate.db?mode=rw&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open corporate.db: %w", err)
|
||||
}
|
||||
if err := corpDB.Ping(); err != nil {
|
||||
return fmt.Errorf("ping corporate.db: %w", err)
|
||||
}
|
||||
|
||||
// Verify the schema is present. We do not create it.
|
||||
var hasCustomers int
|
||||
corpDB.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='customers'`).Scan(&hasCustomers)
|
||||
if hasCustomers == 0 {
|
||||
corpDB.Close()
|
||||
corpDB = nil
|
||||
return fmt.Errorf("corporate.db is missing the customers table — schema must be applied externally")
|
||||
}
|
||||
|
||||
if tlwSandbox {
|
||||
log.Println("TLW: Paddle SANDBOX mode")
|
||||
} else {
|
||||
log.Println("TLW: Paddle LIVE mode")
|
||||
}
|
||||
if paddleAPIKey == "" {
|
||||
log.Println("TLW: PADDLE_API_KEY not set — DB-only mode (Paddle calls skipped)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// paddleBaseURL returns the API base URL based on sandbox mode.
|
||||
func paddleBaseURL() string {
|
||||
if tlwSandbox {
|
||||
return "https://sandbox-api.paddle.com"
|
||||
}
|
||||
return "https://api.paddle.com"
|
||||
}
|
||||
|
||||
// tlwResult is what every TLW Create call returns.
|
||||
type tlwResult struct {
|
||||
OurID string // Our internal ID (we generate this)
|
||||
PaddleID string // Paddle's returned ID (empty if Paddle skipped or failed)
|
||||
Synced bool // True if Paddle accepted the write
|
||||
Err error // DB error or last Paddle error
|
||||
}
|
||||
|
||||
// tlwCreateCustomer writes a new customer to corporate.db and Paddle.
|
||||
// Required fields in data: email. Optional: name, locale, marketing_consent.
|
||||
func tlwCreateCustomer(data map[string]interface{}) tlwResult {
|
||||
if data["email"] == nil || data["email"] == "" {
|
||||
return tlwResult{Err: fmt.Errorf("email required")}
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
ourID := fmt.Sprintf("ctm_%d", time.Now().UnixNano())
|
||||
row := map[string]interface{}{
|
||||
"id": ourID,
|
||||
"email": data["email"],
|
||||
"name": data["name"],
|
||||
"locale": firstNonEmpty(data["locale"], "en"),
|
||||
"status": "active",
|
||||
"level": 1,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"sync_status": "pending",
|
||||
"pending_since": now,
|
||||
}
|
||||
|
||||
if err := tlwInsert("customers", row); err != nil {
|
||||
return tlwResult{OurID: ourID, Err: fmt.Errorf("db insert: %w", err)}
|
||||
}
|
||||
|
||||
// Push to Paddle
|
||||
if paddleAPIKey == "" {
|
||||
return tlwResult{OurID: ourID, Synced: false}
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"email": data["email"],
|
||||
"locale": row["locale"],
|
||||
}
|
||||
if data["name"] != nil && data["name"] != "" {
|
||||
payload["name"] = data["name"]
|
||||
}
|
||||
resp, err := paddleCall("POST", "/customers", payload)
|
||||
if err != nil {
|
||||
corpDB.Exec(`UPDATE customers SET sync_status='error', sync_error=? WHERE id=?`, err.Error(), ourID)
|
||||
return tlwResult{OurID: ourID, Err: err}
|
||||
}
|
||||
paddleID := extractPaddleID(resp)
|
||||
corpDB.Exec(`UPDATE customers SET paddle_id=?, sync_status='synced', sync_error=NULL, last_paddle_sync_at=? WHERE id=?`,
|
||||
paddleID, time.Now().Unix(), ourID)
|
||||
return tlwResult{OurID: ourID, PaddleID: paddleID, Synced: true}
|
||||
}
|
||||
|
||||
// tlwCreateAddress writes a billing address tied to a customer.
|
||||
// Required: customer_id, country_code. Optional: city, region, zip_code, description.
|
||||
func tlwCreateAddress(data map[string]interface{}) tlwResult {
|
||||
if data["customer_id"] == nil || data["customer_id"] == "" {
|
||||
return tlwResult{Err: fmt.Errorf("customer_id required")}
|
||||
}
|
||||
if data["country_code"] == nil || data["country_code"] == "" {
|
||||
return tlwResult{Err: fmt.Errorf("country_code required")}
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
ourID := fmt.Sprintf("add_%d", time.Now().UnixNano())
|
||||
row := map[string]interface{}{
|
||||
"id": ourID,
|
||||
"customer_id": data["customer_id"],
|
||||
"description": data["description"],
|
||||
"city": data["city"],
|
||||
"region": data["region"],
|
||||
"country_code": data["country_code"],
|
||||
"zip_code": data["zip_code"],
|
||||
"status": "active",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"sync_status": "pending",
|
||||
"pending_since": now,
|
||||
}
|
||||
if err := tlwInsert("addresses", row); err != nil {
|
||||
return tlwResult{OurID: ourID, Err: fmt.Errorf("db insert: %w", err)}
|
||||
}
|
||||
|
||||
if paddleAPIKey == "" {
|
||||
return tlwResult{OurID: ourID, Synced: false}
|
||||
}
|
||||
|
||||
// Need the Paddle customer ID to call /customers/{id}/addresses
|
||||
var paddleCustomerID string
|
||||
corpDB.QueryRow(`SELECT paddle_id FROM customers WHERE id = ?`, data["customer_id"]).Scan(&paddleCustomerID)
|
||||
if paddleCustomerID == "" {
|
||||
corpDB.Exec(`UPDATE addresses SET sync_status='error', sync_error=? WHERE id=?`,
|
||||
"customer not synced to paddle yet", ourID)
|
||||
return tlwResult{OurID: ourID, Err: fmt.Errorf("customer has no paddle_id yet")}
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"country_code": data["country_code"],
|
||||
}
|
||||
for _, k := range []string{"city", "region", "zip_code", "description"} {
|
||||
if v, ok := data[k]; ok && v != "" {
|
||||
payload[k] = v
|
||||
}
|
||||
}
|
||||
resp, err := paddleCall("POST", "/customers/"+paddleCustomerID+"/addresses", payload)
|
||||
if err != nil {
|
||||
corpDB.Exec(`UPDATE addresses SET sync_status='error', sync_error=? WHERE id=?`, err.Error(), ourID)
|
||||
return tlwResult{OurID: ourID, Err: err}
|
||||
}
|
||||
paddleID := extractPaddleID(resp)
|
||||
corpDB.Exec(`UPDATE addresses SET paddle_id=?, sync_status='synced', sync_error=NULL, last_paddle_sync_at=? WHERE id=?`,
|
||||
paddleID, time.Now().Unix(), ourID)
|
||||
return tlwResult{OurID: ourID, PaddleID: paddleID, Synced: true}
|
||||
}
|
||||
|
||||
// tlwCreateTransaction creates a Paddle transaction (used to generate a checkout URL).
|
||||
// Returns the Paddle transaction ID and the hosted checkout URL.
|
||||
// Unlike customer/address, transactions are not stored in corporate.db until the
|
||||
// webhook fires — we just need the checkout URL to redirect the user.
|
||||
func tlwCreateTransaction(paddleCustomerID, paddleAddressID, priceID string) (txnID, checkoutURL string, err error) {
|
||||
if paddleAPIKey == "" {
|
||||
// Dev/local mode: synthesize a placeholder
|
||||
return "txn_local_dev", "/onboarding/done?dev=1", nil
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"customer_id": paddleCustomerID,
|
||||
"address_id": paddleAddressID,
|
||||
"items": []map[string]interface{}{
|
||||
{"price_id": priceID, "quantity": 1},
|
||||
},
|
||||
"collection_mode": "automatic",
|
||||
}
|
||||
resp, err := paddleCall("POST", "/transactions", payload)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if data, ok := resp["data"].(map[string]interface{}); ok {
|
||||
if id, ok := data["id"].(string); ok {
|
||||
txnID = id
|
||||
}
|
||||
if cp, ok := data["checkout"].(map[string]interface{}); ok {
|
||||
if u, ok := cp["url"].(string); ok {
|
||||
checkoutURL = u
|
||||
}
|
||||
}
|
||||
}
|
||||
if txnID == "" {
|
||||
return "", "", fmt.Errorf("paddle did not return transaction id")
|
||||
}
|
||||
return txnID, checkoutURL, nil
|
||||
}
|
||||
|
||||
// findCustomerByEmail returns the local customer ID and Paddle ID (if any).
|
||||
// Used to identify accounts by email — the primary lookup.
|
||||
func findCustomerByEmail(email string) (ourID, paddleID string, found bool) {
|
||||
row := corpDB.QueryRow(`SELECT id, COALESCE(paddle_id,'') FROM customers WHERE email = ? LIMIT 1`, email)
|
||||
if err := row.Scan(&ourID, &paddleID); err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
return ourID, paddleID, true
|
||||
}
|
||||
|
||||
// --- private helpers ---
|
||||
|
||||
func tlwInsert(table string, data map[string]interface{}) error {
|
||||
cols := []string{}
|
||||
placeholders := []string{}
|
||||
values := []interface{}{}
|
||||
for k, v := range data {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
cols = append(cols, k)
|
||||
placeholders = append(placeholders, "?")
|
||||
values = append(values, v)
|
||||
}
|
||||
stmt := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
|
||||
table, strings.Join(cols, ","), strings.Join(placeholders, ","))
|
||||
_, err := corpDB.Exec(stmt, values...)
|
||||
return err
|
||||
}
|
||||
|
||||
func paddleCall(method, endpoint string, payload map[string]interface{}) (map[string]interface{}, error) {
|
||||
url := paddleBaseURL() + endpoint
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(method, url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+paddleAPIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("paddle %s %s -> %d: %s", method, endpoint, resp.StatusCode, string(respBody))
|
||||
}
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal(respBody, &result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func extractPaddleID(resp map[string]interface{}) string {
|
||||
if data, ok := resp["data"].(map[string]interface{}); ok {
|
||||
if id, ok := data["id"].(string); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...interface{}) interface{} {
|
||||
for _, v := range vals {
|
||||
if v != nil && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Loading…
Reference in New Issue