Rebrand to Cohorta + full UI redesign + registration with email verification
Some checks failed
Deploy to Production / deploy (push) Failing after 0s

- Complete dark-theme redesign inspired by ai-impress.com (navy + cyan + violet palette)
- New Syne display font + gradient logo mark + SVG favicon
- New Navigation: glass-morphism, gradient logo, Get Started CTA
- New Hero: animated glow orbs, mock focus-group chat UI, stats row
- New landing: Features grid, How-It-Works steps, CTA banner
- New Footer: AImpress LTD branding, © AImpress LTD. All rights reserved.
- New Login page: dark card, password visibility toggle, link to Register
- New Register page: full form, benefits row, 50 free credits pitch
- New VerifyEmail page: token verification flow with auto-redirect
- Backend: email_service.py using Resend API for verification emails
- Backend: /api/auth/register, /verify-email, /resend-verification endpoints
- User model: email_verified, email_verify_token, email_verify_expires fields
- Gitea Actions CI/CD: auto-deploy to aimpress server on push to main

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-23 18:40:08 +01:00
parent 7b6a7c7347
commit 5491d2d73d
26 changed files with 2992 additions and 1011 deletions

View file

@ -0,0 +1,45 @@
name: Deploy to Production
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_PORT }}
script: |
set -euo pipefail
DEPLOY_DIR="/opt/03-business/cohorta"
echo "=== Cohorta — auto deploy ==="
# Pull latest code
git -C "$DEPLOY_DIR" pull --ff-only
# Ensure traefik-public network exists
docker network ls --format '{{.Name}}' | grep -q '^traefik-public$' \
|| docker network create traefik-public
# Build and restart containers
cd "$DEPLOY_DIR"
docker compose build --no-cache frontend backend
docker compose up -d --remove-orphans
# Health check
for i in $(seq 1 30); do
curl -sf http://localhost:5137/api/health > /dev/null 2>&1 && echo "✓ Backend healthy" && break
sleep 3
done
docker compose ps
echo "=== Deploy complete ==="
echo " https://cohorta.ai-impress.com"

15
Dockerfile.frontend Normal file
View file

@ -0,0 +1,15 @@
# Stage 1 — build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --silent
COPY . .
# Use production env if present, otherwise fall back to defaults
RUN [ -f .env.production ] && cp .env.production .env || true
RUN npm run build
# Stage 2 — serve
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View file

@ -0,0 +1,55 @@
"""
App-wide configuration stored in MongoDB.
Single document with _id='config'. Cached in-memory for 60 seconds.
"""
import time
import logging
from typing import Any
from app.db import get_db
logger = logging.getLogger(__name__)
_cache: dict[str, Any] = {}
_cache_ts: float = 0
_CACHE_TTL = 60 # seconds
DEFAULTS = {
"_id": "config",
"persona_cost": 2,
"run_cost": 40,
"trial_grant": 10,
"credit_packs": [
{"id": "starter", "name": "Starter", "price_usd": 49, "credits": 50},
{"id": "pro", "name": "Pro", "price_usd": 199, "credits": 220},
{"id": "scale", "name": "Scale", "price_usd": 499, "credits": 600},
],
}
async def get_settings() -> dict:
global _cache, _cache_ts
if _cache and (time.monotonic() - _cache_ts) < _CACHE_TTL:
return _cache
db = await get_db()
doc = await db.app_settings.find_one({"_id": "config"})
if not doc:
await db.app_settings.insert_one(DEFAULTS.copy())
doc = DEFAULTS.copy()
_cache = doc
_cache_ts = time.monotonic()
return doc
async def update_settings(fields: dict) -> dict:
global _cache, _cache_ts
db = await get_db()
await db.app_settings.update_one(
{"_id": "config"},
{"$set": fields},
upsert=True,
)
_cache = {} # invalidate cache
return await get_settings()

View file

@ -0,0 +1,58 @@
from datetime import datetime, timezone
from bson import ObjectId
from app.db import get_db
class CreditTransaction:
"""Ledger entry for every credit balance change."""
TYPES = frozenset({"purchase", "debit", "refund", "grant", "admin_grant"})
@staticmethod
async def record(
user_id: str,
tx_type: str,
amount: int,
balance_after: int,
description: str = "",
ref: dict | None = None,
) -> str:
db = await get_db()
doc = {
"user_id": user_id,
"type": tx_type,
"amount": amount,
"balance_after": balance_after,
"description": description,
"ref": ref or {},
"ts": datetime.now(timezone.utc),
}
result = await db.credit_transactions.insert_one(doc)
return str(result.inserted_id)
@staticmethod
async def list_for_user(user_id: str, limit: int = 50) -> list:
db = await get_db()
cursor = db.credit_transactions.find(
{"user_id": user_id}
).sort("ts", -1).limit(limit)
docs = await cursor.to_list(length=limit)
for d in docs:
d["_id"] = str(d["_id"])
return docs
@staticmethod
async def list_all(skip: int = 0, limit: int = 100) -> list:
db = await get_db()
cursor = db.credit_transactions.find({}).sort("ts", -1).skip(skip).limit(limit)
docs = await cursor.to_list(length=limit)
for d in docs:
d["_id"] = str(d["_id"])
return docs
@staticmethod
async def sum_credits(match: dict) -> int:
db = await get_db()
pipeline = [{"$match": match}, {"$group": {"_id": None, "total": {"$sum": "$amount"}}}]
result = await db.credit_transactions.aggregate(pipeline).to_list(length=1)
return result[0]["total"] if result else 0

View file

@ -3,13 +3,16 @@ from bson import ObjectId
from app.db import get_db
class User:
def __init__(self, username, email, password_hash=None, role="user", auth_type="local", microsoft_id=None):
def __init__(self, username, email, password_hash=None, role="user", auth_type="local",
email_verified=False, email_verify_token=None, email_verify_expires=None):
self.username = username
self.email = email
self.password_hash = password_hash
self.role = role
self.auth_type = auth_type
self.microsoft_id = microsoft_id
self.email_verified = email_verified
self.email_verify_token = email_verify_token
self.email_verify_expires = email_verify_expires
@staticmethod
def hash_password(password):
@ -39,21 +42,6 @@ class User:
user_data = await db.users.find_one({"_id": ObjectId(user_id)})
return user_data
@staticmethod
async def find_by_microsoft_id(microsoft_id):
db = await get_db()
user_data = await db.users.find_one({"microsoft_id": microsoft_id})
return user_data
@staticmethod
async def update_microsoft_id(user_id, microsoft_id):
db = await get_db()
result = await db.users.update_one(
{"_id": ObjectId(user_id)},
{"$set": {"microsoft_id": microsoft_id, "auth_type": "microsoft"}}
)
return result.modified_count > 0
@staticmethod
async def find_all(query: dict = None, skip: int = 0, limit: int = 50) -> list:
db = await get_db()
@ -96,9 +84,35 @@ class User:
"email": self.email,
"role": self.role,
"auth_type": self.auth_type,
"microsoft_id": self.microsoft_id
}
@staticmethod
async def deduct_credits(user_id: str, amount: int) -> int | None:
"""Atomically deduct credits. Returns new balance, or None if insufficient funds."""
db = await get_db()
result = await db.users.find_one_and_update(
{"_id": ObjectId(user_id), "credits_balance": {"$gte": amount}},
{"$inc": {"credits_balance": -amount}},
return_document=True,
projection={"credits_balance": 1},
)
if result is None:
return None
return result["credits_balance"]
@staticmethod
async def grant_credits(user_id: str, amount: int) -> int:
"""Add credits to user balance. Returns new balance."""
db = await get_db()
result = await db.users.find_one_and_update(
{"_id": ObjectId(user_id)},
{"$inc": {"credits_balance": amount}},
return_document=True,
upsert=False,
projection={"credits_balance": 1},
)
return result["credits_balance"] if result else 0
async def save(self):
db = await get_db()
user_data = {
@ -107,8 +121,13 @@ class User:
"password_hash": self.password_hash,
"role": self.role,
"auth_type": self.auth_type,
"microsoft_id": self.microsoft_id
"credits_balance": 0,
"email_verified": self.email_verified,
}
if self.email_verify_token:
user_data["email_verify_token"] = self.email_verify_token
if self.email_verify_expires:
user_data["email_verify_expires"] = self.email_verify_expires
result = await db.users.insert_one(user_data)
return result.inserted_id

View file

@ -1,72 +1,190 @@
import secrets
import logging
from datetime import datetime, timedelta
from quart import Blueprint, request, jsonify
from app.auth.quart_jwt import create_access_token, jwt_required, get_jwt_identity
from app.models.user import User
from app.services.msal_service import MSALService
from app.models.credit_transaction import CreditTransaction
from app.models.app_settings import get_settings
from app.utils.rate_limiter import rate_limit
from app.services.email_service import send_verification_email
logger = logging.getLogger(__name__)
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/register', methods=['POST'])
@rate_limit(max_requests=5, window_seconds=60)
async def register():
data = await request.get_json()
if not data or not data.get('username') or not data.get('email') or not data.get('password'):
return jsonify({"message": "Missing required fields"}), 400
username = data.get('username')
email = data.get('email')
password = data.get('password')
# Check if user already exists
username = data['username'].strip()
email = data['email'].strip().lower()
password = data['password']
if len(username) < 3:
return jsonify({"message": "Username must be at least 3 characters"}), 400
if len(password) < 6:
return jsonify({"message": "Password must be at least 6 characters"}), 400
if '@' not in email or '.' not in email:
return jsonify({"message": "Invalid email address"}), 400
if await User.find_by_username(username):
return jsonify({"message": "Username already taken"}), 409
if await User.find_by_email(email):
return jsonify({"message": "Email already registered"}), 409
# Create new user
# Generate email verification token
verify_token = secrets.token_urlsafe(32)
verify_expires = datetime.utcnow() + timedelta(hours=24)
hashed_password = User.hash_password(password)
new_user = User(username=username, email=email, password_hash=hashed_password)
new_user = User(
username=username,
email=email,
password_hash=hashed_password,
email_verified=False,
email_verify_token=verify_token,
email_verify_expires=verify_expires,
)
user_id = await new_user.save()
# Generate access token
# Grant trial credits
settings = await get_settings()
trial = settings.get("trial_grant", 10)
if trial > 0:
balance = await User.grant_credits(str(user_id), trial)
await CreditTransaction.record(
user_id=str(user_id),
tx_type="grant",
amount=trial,
balance_after=balance,
description="Trial credits on registration",
)
# Send verification email (non-blocking — don't fail registration on email error)
try:
await send_verification_email(email, username, verify_token)
except Exception as e:
logger.error("Failed to send verification email to %s: %s", email, e)
access_token = create_access_token(identity=str(user_id))
return jsonify({
"message": "User registered successfully",
"message": "User registered successfully. Please check your email to verify your account.",
"access_token": access_token,
"email_verified": False,
"user": new_user.to_dict()
}), 201
@auth_bp.route('/verify-email', methods=['POST'])
@rate_limit(max_requests=10, window_seconds=60)
async def verify_email():
data = await request.get_json()
token = (data or {}).get('token', '').strip()
if not token:
return jsonify({"message": "Verification token required"}), 400
from app.db import get_db
from bson import ObjectId
db = await get_db()
user_data = await db.users.find_one({
"email_verify_token": token,
"email_verified": {"$ne": True},
})
if not user_data:
return jsonify({"message": "Invalid or already used verification token"}), 400
expires = user_data.get("email_verify_expires")
if expires and datetime.utcnow() > expires:
return jsonify({"message": "Verification link has expired. Please request a new one."}), 400
await db.users.update_one(
{"_id": user_data["_id"]},
{
"$set": {"email_verified": True},
"$unset": {"email_verify_token": "", "email_verify_expires": ""},
}
)
return jsonify({"message": "Email verified successfully. You can now use all features."}), 200
@auth_bp.route('/resend-verification', methods=['POST'])
@rate_limit(max_requests=3, window_seconds=300)
async def resend_verification():
data = await request.get_json()
email = ((data or {}).get('email') or '').strip().lower()
if not email:
return jsonify({"message": "Email required"}), 400
user_data = await User.find_by_email(email)
if not user_data:
# Don't leak whether email exists
return jsonify({"message": "If that email is registered, a new verification link has been sent."}), 200
if user_data.get("email_verified"):
return jsonify({"message": "Email is already verified."}), 200
verify_token = secrets.token_urlsafe(32)
verify_expires = datetime.utcnow() + timedelta(hours=24)
from app.db import get_db
db = await get_db()
await db.users.update_one(
{"_id": user_data["_id"]},
{"$set": {
"email_verify_token": verify_token,
"email_verify_expires": verify_expires,
}}
)
try:
await send_verification_email(email, user_data["username"], verify_token)
except Exception as e:
logger.error("Failed to resend verification email to %s: %s", email, e)
return jsonify({"message": "If that email is registered, a new verification link has been sent."}), 200
@auth_bp.route('/login', methods=['POST'])
@rate_limit(max_requests=5, window_seconds=60)
async def login():
try:
data = await request.get_json()
if not data or not data.get('username') or not data.get('password'):
return jsonify({"message": "Missing username or password"}), 400
username = data.get('username')
password = data.get('password')
# Find user in database
identifier = data.get('username') or data.get('email')
password = data.get('password')
if not identifier or not password:
return jsonify({"message": "Missing username or password"}), 400
try:
user_data = await User.find_by_username(username)
user_data = await User.find_by_email(identifier) if '@' in identifier else await User.find_by_username(identifier)
if not user_data:
user_data = await User.find_by_username(identifier) if '@' in identifier else await User.find_by_email(identifier)
if not user_data:
return jsonify({"message": "Invalid username or password"}), 401
# Check password
if not User.check_password(user_data['password_hash'], password):
return jsonify({"message": "Invalid username or password"}), 401
# Generate access token (embed token_version for invalidation support)
tv = user_data.get("token_version", 0)
access_token = create_access_token(identity=str(user_data['_id']), token_version=tv)
return jsonify({
"message": "Login successful",
"access_token": access_token,
"email_verified": user_data.get("email_verified", True),
"user": {
"username": user_data['username'],
"email": user_data['email'],
@ -74,119 +192,30 @@ async def login():
}
}), 200
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Database error during login: {e}")
logger.error(f"Database error during login: {e}")
return jsonify({"message": "Database error, please try again later"}), 500
except Exception as e:
print(f"Unexpected error in login route: {e}")
logger.error(f"Unexpected error in login route: {e}")
return jsonify({"message": "Internal server error"}), 500
@auth_bp.route('/me', methods=['GET'])
@jwt_required()
async def get_profile():
import logging
user_id = get_jwt_identity()
try:
user_data = await User.find_by_id(user_id)
if not user_data:
return jsonify({"message": "User not found"}), 404
return jsonify({
"username": user_data['username'],
"email": user_data['email'],
"role": user_data.get('role', 'user')
"role": user_data.get('role', 'user'),
"credits_balance": user_data.get('credits_balance', 0),
"email_verified": user_data.get("email_verified", True),
}), 200
except Exception as e:
logging.getLogger(__name__).error(f"Error in get_profile: {e}")
logger.error(f"Error in get_profile: {e}")
return jsonify({"message": "Internal server error"}), 500
@auth_bp.route('/microsoft', methods=['POST'])
async def microsoft_login():
"""Handle Microsoft OAuth authentication."""
try:
data = await request.get_json()
if not data or not data.get('id_token'):
return jsonify({"message": "Missing Microsoft ID token"}), 400
id_token = data.get('id_token')
# Initialize MSAL service and validate the token
msal_service = MSALService()
microsoft_user_info = msal_service.validate_token(id_token)
if not microsoft_user_info:
return jsonify({"message": "Invalid Microsoft ID token"}), 401
microsoft_id = microsoft_user_info.get('microsoft_id')
email = microsoft_user_info.get('email')
if not microsoft_id or not email:
return jsonify({"message": "Unable to retrieve user information from Microsoft"}), 400
# Try to find existing user by Microsoft ID or email
existing_user = None
try:
# First try to find by Microsoft ID
existing_user = await User.find_by_microsoft_id(microsoft_id)
# If not found by Microsoft ID, try by email
if not existing_user:
existing_user = await User.find_by_email(email)
# If found by email but no Microsoft ID, update the user to link Microsoft account
if existing_user and not existing_user.get('microsoft_id'):
await User.update_microsoft_id(existing_user['_id'], microsoft_id)
existing_user['microsoft_id'] = microsoft_id
existing_user['auth_type'] = 'microsoft'
except Exception as e:
print(f"Database error during Microsoft user lookup: {e}")
# Continue to create new user if lookup fails
# Create new user if not found
if not existing_user:
try:
user_data = msal_service.create_user_data(microsoft_user_info)
new_user = User(**user_data)
user_id = await new_user.save()
existing_user = {
"_id": user_id,
"username": user_data['username'],
"email": user_data['email'],
"role": user_data['role'],
"auth_type": user_data['auth_type'],
"microsoft_id": user_data['microsoft_id']
}
print(f"Created new Microsoft user: {email}")
except Exception as e:
print(f"Error creating Microsoft user: {e}")
return jsonify({"message": "Failed to create user account"}), 500
# Generate our backend JWT access token (embed token_version for invalidation support)
_tv = existing_user.get("token_version", 0)
access_token = create_access_token(identity=str(existing_user['_id']), token_version=_tv)
# Return response in same format as local login
return jsonify({
"message": "Microsoft login successful",
"access_token": access_token,
"user": {
"username": existing_user['username'],
"email": existing_user['email'],
"role": existing_user.get('role', 'user'),
"auth_type": "microsoft"
}
}), 200
except Exception as e:
print(f"Unexpected error in Microsoft login route: {e}")
return jsonify({"message": "Internal server error"}), 500

View file

@ -0,0 +1,116 @@
"""
Billing endpoints:
GET /api/billing/balance current credit balance + settings
GET /api/billing/transactions transaction history for logged-in user
POST /api/billing/checkout create Stripe Checkout Session
POST /api/billing/webhook Stripe webhook (no auth)
"""
import logging
from quart import Blueprint, request, jsonify
from app.auth.quart_jwt import jwt_required, get_jwt_identity
from app.models.user import User
from app.models.credit_transaction import CreditTransaction
from app.models.app_settings import get_settings
from app.services.stripe_service import create_checkout_session, verify_webhook
logger = logging.getLogger(__name__)
billing_bp = Blueprint('billing', __name__)
@billing_bp.route('/balance', methods=['GET'])
@jwt_required()
async def get_balance():
user_id = get_jwt_identity()
user = await User.find_by_id(user_id)
if not user:
return jsonify({"message": "User not found"}), 404
settings = await get_settings()
return jsonify({
"credits_balance": user.get("credits_balance", 0),
"persona_cost": settings.get("persona_cost", 2),
"run_cost": settings.get("run_cost", 40),
"credit_packs": settings.get("credit_packs", []),
}), 200
@billing_bp.route('/transactions', methods=['GET'])
@jwt_required()
async def get_transactions():
user_id = get_jwt_identity()
limit = min(int(request.args.get("limit", 50)), 200)
txs = await CreditTransaction.list_for_user(user_id, limit=limit)
return jsonify({"transactions": txs}), 200
@billing_bp.route('/checkout', methods=['POST'])
@jwt_required()
async def create_checkout():
user_id = get_jwt_identity()
data = await request.get_json()
pack_id = data.get("pack_id")
if not pack_id:
return jsonify({"message": "pack_id required"}), 400
settings = await get_settings()
pack = next((p for p in settings.get("credit_packs", []) if p["id"] == pack_id), None)
if not pack:
return jsonify({"message": "Unknown credit pack"}), 404
import os
origin = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:5173").split(",")[0].strip()
success_url = f"{origin}/billing?success=1"
cancel_url = f"{origin}/billing?cancelled=1"
try:
url = await create_checkout_session(
pack_id=pack["id"],
pack_name=pack["name"],
price_usd=pack["price_usd"],
credits=pack["credits"],
user_id=user_id,
success_url=success_url,
cancel_url=cancel_url,
)
return jsonify({"checkout_url": url}), 200
except ValueError as e:
return jsonify({"message": str(e)}), 503
except Exception as e:
logger.error(f"Stripe checkout error: {e}")
return jsonify({"message": "Payment service unavailable"}), 503
@billing_bp.route('/webhook', methods=['POST'])
async def stripe_webhook():
payload = await request.get_data()
sig_header = request.headers.get("Stripe-Signature", "")
try:
event = verify_webhook(payload, sig_header)
except Exception as e:
logger.warning(f"Webhook signature invalid: {e}")
return jsonify({"message": "Invalid signature"}), 400
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
meta = session.get("metadata", {})
user_id = meta.get("user_id")
credits = int(meta.get("credits", 0))
pack_id = meta.get("pack_id", "")
payment_id = session.get("payment_intent", session.get("id", ""))
if user_id and credits > 0:
balance = await User.grant_credits(user_id, credits)
await CreditTransaction.record(
user_id=user_id,
tx_type="purchase",
amount=credits,
balance_after=balance,
description=f"Purchased {pack_id} pack ({credits} credits)",
ref={"stripe_payment_id": payment_id},
)
logger.info(f"Granted {credits} credits to user {user_id} via Stripe")
return jsonify({"status": "ok"}), 200

View file

@ -0,0 +1,108 @@
"""
Email service using Resend API for transactional emails.
Handles email verification and other notifications.
"""
import os
import logging
import httpx
logger = logging.getLogger(__name__)
RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "")
FROM_EMAIL = os.environ.get("EMAIL_FROM", "Cohorta <noreply@cohorta.ai-impress.com>")
APP_URL = os.environ.get("APP_URL", "http://localhost:5173")
async def send_email(to: str, subject: str, html: str) -> bool:
"""Send an email via Resend API. Returns True on success."""
if not RESEND_API_KEY:
logger.warning("RESEND_API_KEY not set — email not sent to %s", to)
return False
payload = {
"from": FROM_EMAIL,
"to": [to],
"subject": subject,
"html": html,
}
async with httpx.AsyncClient(timeout=15) as client:
try:
resp = await client.post(
"https://api.resend.com/emails",
json=payload,
headers={"Authorization": f"Bearer {RESEND_API_KEY}"},
)
if resp.status_code in (200, 201):
logger.info("Email sent to %s (subject: %s)", to, subject)
return True
else:
logger.error("Resend API error %s: %s", resp.status_code, resp.text)
return False
except Exception as e:
logger.error("Failed to send email to %s: %s", to, e)
return False
def _build_verification_html(username: str, verify_url: str) -> str:
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style>
body {{ margin:0; padding:0; background:#04080F; font-family: Inter, Helvetica, Arial, sans-serif; }}
.wrap {{ max-width:520px; margin:40px auto; background:#0C1225; border-radius:16px;
border:1px solid #1E2D45; overflow:hidden; }}
.header {{ padding:32px 40px 24px; border-bottom:1px solid #1E2D45; }}
.logo {{ font-size:22px; font-weight:700; background:linear-gradient(135deg,#06B6D4,#8B5CF6);
-webkit-background-clip:text; -webkit-text-fill-color:transparent; }}
.body {{ padding:32px 40px; }}
h1 {{ color:#F1F5F9; font-size:22px; font-weight:700; margin:0 0 12px; }}
p {{ color:#64748B; font-size:15px; line-height:1.7; margin:0 0 20px; }}
.btn {{ display:inline-block; padding:14px 32px; border-radius:10px; font-weight:600;
font-size:15px; color:#fff; text-decoration:none;
background:linear-gradient(135deg,#06B6D4,#7C3AED); }}
.footer {{ padding:20px 40px; border-top:1px solid #1E2D45; text-align:center; }}
.footer p {{ font-size:12px; color:#334155; margin:0; }}
.note {{ font-size:13px; color:#334155; }}
</style>
</head>
<body>
<div class="wrap">
<div class="header">
<span class="logo">Cohorta</span>
</div>
<div class="body">
<h1>Verify your email address</h1>
<p>Hi <strong style="color:#94A3B8">{username}</strong>,<br/>
Welcome to Cohorta! Please confirm your email address to activate your account
and get access to your free credits.</p>
<a class="btn" href="{verify_url}">Verify Email Address</a>
<p style="margin-top:24px" class="note">
This link expires in <strong>24 hours</strong>. If you didn't create a Cohorta account,
you can safely ignore this email.
</p>
</div>
<div class="footer">
<p>© {_year()} AImpress LTD &nbsp;·&nbsp; All rights reserved</p>
</div>
</div>
</body>
</html>"""
def _year() -> int:
from datetime import datetime
return datetime.utcnow().year
async def send_verification_email(to_email: str, username: str, token: str) -> bool:
verify_url = f"{APP_URL}/verify-email?token={token}"
html = _build_verification_html(username, verify_url)
return await send_email(
to=to_email,
subject="Verify your Cohorta account",
html=html,
)

View file

@ -0,0 +1,60 @@
"""
Stripe checkout integration for credit pack purchases.
Requires STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in environment.
"""
import os
import logging
import stripe
logger = logging.getLogger(__name__)
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "")
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
if STRIPE_SECRET_KEY:
stripe.api_key = STRIPE_SECRET_KEY
async def create_checkout_session(
pack_id: str,
pack_name: str,
price_usd: int,
credits: int,
user_id: str,
success_url: str,
cancel_url: str,
) -> str:
"""Create a Stripe Checkout Session and return its URL."""
if not STRIPE_SECRET_KEY:
raise ValueError("STRIPE_SECRET_KEY not configured")
session = stripe.checkout.Session.create(
payment_method_types=["card"],
line_items=[{
"price_data": {
"currency": "usd",
"unit_amount": price_usd * 100, # cents
"product_data": {
"name": f"Cohorta {pack_name}{credits} credits",
},
},
"quantity": 1,
}],
mode="payment",
success_url=success_url,
cancel_url=cancel_url,
metadata={
"user_id": user_id,
"pack_id": pack_id,
"credits": str(credits),
},
)
return session.url
def verify_webhook(payload: bytes, sig_header: str) -> stripe.Event:
"""Verify Stripe webhook signature and return the event."""
if not STRIPE_WEBHOOK_SECRET:
raise ValueError("STRIPE_WEBHOOK_SECRET not configured")
return stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET)

67
deploy-cohorta.sh Executable file
View file

@ -0,0 +1,67 @@
#!/bin/bash
# Cohorta — deploy script for aimpress (OVH) server
# Run from the server: /opt/03-business/cohorta/deploy-cohorta.sh
#
# Prerequisites on server:
# - Docker + Docker Compose plugin installed
# - Traefik running with network "traefik-public" and letsencrypt certresolver
# - DNS A record: cohorta.ai-impress.com → 57.128.160.249
# - backend/.env filled with production secrets (see backend/.env.example)
set -euo pipefail
BLUE='\033[0;34m'
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
DEPLOY_DIR="/opt/03-business/cohorta"
echo -e "${BLUE}=== Cohorta — deploy ===${NC}"
# ── 1. Pull latest code ───────────────────────────────────────────────────────
echo "Pulling latest code..."
git -C "$DEPLOY_DIR" pull --ff-only
# ── 2. Ensure traefik-public network exists ───────────────────────────────────
if ! docker network ls --format '{{.Name}}' | grep -q '^traefik-public$'; then
echo "Creating traefik-public network..."
docker network create traefik-public
fi
# ── 3. Add swap if not present (helps with AI-heavy workloads) ───────────────
if ! swapon --show | grep -q swapfile 2>/dev/null; then
if [ ! -f /swapfile ]; then
echo "Creating 4 GB swap..."
fallocate -l 4G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
echo "vm.swappiness=10" >> /etc/sysctl.conf
sysctl -p
fi
fi
# ── 4. Build and launch containers ────────────────────────────────────────────
echo "Building and launching containers..."
cd "$DEPLOY_DIR"
docker compose pull mongo 2>/dev/null || true
docker compose build --no-cache frontend backend
docker compose up -d --remove-orphans
# ── 5. Wait for backend health ────────────────────────────────────────────────
echo "Waiting for backend to start..."
for i in $(seq 1 30); do
if curl -sf http://localhost:5137/api/health > /dev/null 2>&1; then
echo -e "${GREEN}✓ Backend is healthy${NC}"
break
fi
sleep 2
done
# ── 6. Show status ────────────────────────────────────────────────────────────
docker compose ps
echo ""
echo -e "${GREEN}=== Deploy complete ===${NC}"
echo " https://cohorta.ai-impress.com"

View file

@ -1,18 +1,5 @@
services:
frontend:
image: node:20-alpine
working_dir: /app
volumes:
- .:/app
- /var/www/html/semblance:/app/dist-out
command: >
sh -c "cp .env.production .env &&
npm ci --silent &&
npm run build &&
cp -r dist/* /app/dist-out/"
profiles:
- build
# ─── MongoDB ──────────────────────────────────────────────────────
mongo:
image: mongo:7
restart: unless-stopped
@ -26,21 +13,55 @@ services:
timeout: 5s
retries: 5
# ─── Python / Quart backend ───────────────────────────────────────
backend:
build: ./backend
restart: unless-stopped
ports:
- "127.0.0.1:5137:5137"
env_file:
- ./backend/.env
environment:
MONGO_URI: mongodb://mongo:27017/semblance_db
MONGO_URI: mongodb://mongo:27017/cohorta_db
volumes:
- ./backend/uploads:/app/uploads
- ./backend/temp:/app/temp
depends_on:
mongo:
condition: service_healthy
labels:
- "traefik.enable=true"
# REST API — higher priority than the SPA catch-all
- "traefik.http.routers.cohorta-api.rule=Host(`cohorta.ai-impress.com`) && PathPrefix(`/api`)"
- "traefik.http.routers.cohorta-api.entrypoints=websecure"
- "traefik.http.routers.cohorta-api.tls.certresolver=cloudflare"
- "traefik.http.routers.cohorta-api.priority=20"
- "traefik.http.services.cohorta-api.loadbalancer.server.port=5137"
# Socket.IO — also high priority
- "traefik.http.routers.cohorta-ws.rule=Host(`cohorta.ai-impress.com`) && PathPrefix(`/socket.io`)"
- "traefik.http.routers.cohorta-ws.entrypoints=websecure"
- "traefik.http.routers.cohorta-ws.tls.certresolver=cloudflare"
- "traefik.http.routers.cohorta-ws.priority=20"
- "traefik.http.routers.cohorta-ws.service=cohorta-api"
# ─── React SPA (nginx-alpine) ─────────────────────────────────────
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
restart: unless-stopped
depends_on:
- backend
labels:
- "traefik.enable=true"
- "traefik.http.routers.cohorta.rule=Host(`cohorta.ai-impress.com`)"
- "traefik.http.routers.cohorta.entrypoints=websecure"
- "traefik.http.routers.cohorta.tls.certresolver=cloudflare"
- "traefik.http.routers.cohorta.priority=1"
- "traefik.http.services.cohorta.loadbalancer.server.port=80"
volumes:
mongo-data:
networks:
default:
name: traefik-public
external: true

35
nginx.conf Normal file
View file

@ -0,0 +1,35 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# API and WebSocket requests must never reach nginx Traefik routes them to backend.
# If they do arrive here, return 502 so the misconfiguration is visible.
location /api/ {
return 502 "nginx should not serve /api check Traefik routing priorities";
}
location /socket.io/ {
return 502 "nginx should not serve /socket.io check Traefik routing priorities";
}
# SPA fallback all other paths serve index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Never cache index.html
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
}

View file

@ -1,24 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="8" fill="#04080F"/>
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#8b5cf6"/>
</linearGradient>
<linearGradient id="fg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="1"/>
<stop offset="100%" stop-color="#e0e7ff" stop-opacity="0.9"/>
<linearGradient id="fg" x1="3" y1="3" x2="29" y2="29" gradientUnits="userSpaceOnUse">
<stop stop-color="#06B6D4"/>
<stop offset="1" stop-color="#8B5CF6"/>
</linearGradient>
</defs>
<!-- Rounded square background -->
<rect width="32" height="32" rx="7" fill="url(#bg)"/>
<!-- Three personas/people circles — focus group concept -->
<!-- Top person -->
<circle cx="16" cy="8.5" r="3" fill="url(#fg)" opacity="0.95"/>
<path d="M10.5 17.5 Q10.5 13 16 13 Q21.5 13 21.5 17.5" stroke="white" stroke-width="1.8" fill="none" stroke-linecap="round" opacity="0.95"/>
<!-- Bottom-left person -->
<circle cx="9" cy="21" r="2.2" fill="url(#fg)" opacity="0.75"/>
<path d="M5 27.5 Q5 24 9 24 Q13 24 13 27.5" stroke="white" stroke-width="1.5" fill="none" stroke-linecap="round" opacity="0.75"/>
<!-- Bottom-right person -->
<circle cx="23" cy="21" r="2.2" fill="url(#fg)" opacity="0.75"/>
<path d="M19 27.5 Q19 24 23 24 Q27 24 27 27.5" stroke="white" stroke-width="1.5" fill="none" stroke-linecap="round" opacity="0.75"/>
<path d="M25 7C22.2 5.1 18.9 4 15.3 4C8 4 2 10 2 16.5C2 23 8 29 15.3 29C18.9 29 22.2 27.9 25 26"
stroke="url(#fg)" stroke-width="3.5" stroke-linecap="round" fill="none"/>
<circle cx="25" cy="7" r="2.2" fill="#06B6D4"/>
<circle cx="25" cy="26" r="2.2" fill="#8B5CF6"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 625 B

View file

@ -11,15 +11,16 @@ import FocusGroupSession from "./pages/FocusGroupSession";
import Dashboard from "./pages/Dashboard";
import PersonaProfile from "./components/persona/PersonaProfile";
import Login from "./pages/Login";
import Register from "./pages/Register";
import VerifyEmail from "./pages/VerifyEmail";
import Admin from "./pages/Admin";
import MyUsage from "./pages/MyUsage";
import Billing from "./pages/Billing";
import ProtectedRoute from "./components/ProtectedRoute";
import AdminRoute from "./components/admin/AdminRoute";
import { AuthProvider } from "./contexts/AuthContext";
import { NavigationProvider } from "./contexts/NavigationContext";
import { WebSocketProvider } from "./contexts/WebSocketContextNew";
import { MsalProvider } from "./components/auth/MsalProvider";
// CSS for consistent back button positioning
import "./styles/backButton.css";
@ -28,75 +29,32 @@ const queryClient = new QueryClient();
const App = () => (
<QueryClientProvider client={queryClient}>
<BrowserRouter basename={import.meta.env.BASE_URL}>
<MsalProvider>
<AuthProvider>
<WebSocketProvider>
<NavigationProvider>
<TooltipProvider>
<Toaster />
<Routes>
<Route path="/" element={<Index />} />
<Route path="/login" element={<Login />} />
<Route path="/synthetic-users" element={
<ProtectedRoute>
<SyntheticUsers />
</ProtectedRoute>
} />
<Route path="/synthetic-users/:id" element={
<ProtectedRoute>
<PersonaProfile />
</ProtectedRoute>
} />
<Route path="/personas/:id" element={
<ProtectedRoute>
<PersonaProfile />
</ProtectedRoute>
} />
<Route path="/focus-groups" element={
<ProtectedRoute>
<FocusGroups />
</ProtectedRoute>
} />
<Route path="/focus-groups/:id" element={
<ProtectedRoute>
<FocusGroupSession />
</ProtectedRoute>
} />
<Route path="/dashboard" element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
} />
<Route path="/admin" element={
<AdminRoute>
<Admin />
</AdminRoute>
} />
{/* Redirect legacy paths */}
<Route path="/old-path" element={<Navigate to="/" replace />} />
<Route path="/billing" element={
<ProtectedRoute>
<MyUsage />
</ProtectedRoute>
} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
</TooltipProvider>
</NavigationProvider>
</WebSocketProvider>
</AuthProvider>
</MsalProvider>
<AuthProvider>
<WebSocketProvider>
<NavigationProvider>
<TooltipProvider>
<Toaster />
<Routes>
<Route path="/" element={<Index />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/synthetic-users" element={<ProtectedRoute><SyntheticUsers /></ProtectedRoute>} />
<Route path="/synthetic-users/:id" element={<ProtectedRoute><PersonaProfile /></ProtectedRoute>} />
<Route path="/personas/:id" element={<ProtectedRoute><PersonaProfile /></ProtectedRoute>} />
<Route path="/focus-groups" element={<ProtectedRoute><FocusGroups /></ProtectedRoute>} />
<Route path="/focus-groups/:id" element={<ProtectedRoute><FocusGroupSession /></ProtectedRoute>} />
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/admin" element={<AdminRoute><Admin /></AdminRoute>} />
<Route path="/old-path" element={<Navigate to="/" replace />} />
<Route path="/billing" element={<ProtectedRoute><Billing /></ProtectedRoute>} />
<Route path="/usage" element={<ProtectedRoute><MyUsage /></ProtectedRoute>} />
<Route path="*" element={<NotFound />} />
</Routes>
</TooltipProvider>
</NavigationProvider>
</WebSocketProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
);

View file

@ -1,4 +1,3 @@
import { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
@ -7,24 +6,38 @@ interface FeatureCardProps {
description: string;
icon: LucideIcon;
className?: string;
gradient?: string;
}
export default function FeatureCard({ title, description, icon: Icon, className }: FeatureCardProps) {
export default function FeatureCard({
title,
description,
icon: Icon,
className,
gradient = 'from-[#06B6D4] to-[#8B5CF6]',
}: FeatureCardProps) {
return (
<div className={cn(
"relative group glass-card rounded-xl overflow-hidden p-6 hover:shadow-lg hover:translate-y-[-4px] button-transition",
className
)}>
<div className="absolute inset-0 bg-gradient-to-r from-primary/5 to-blue-400/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="relative">
<div className="rounded-full bg-primary/10 w-12 h-12 flex items-center justify-center mb-4">
<Icon className="h-6 w-6 text-primary" />
</div>
<h3 className="font-sf text-lg font-semibold mb-2">{title}</h3>
<p className="text-gray-600 text-sm">{description}</p>
<div
className={cn('gradient-border-card group p-6 transition-all duration-300 hover:-translate-y-1', className)}
style={{
background: 'hsl(222 45% 7%)',
boxShadow: '0 4px 24px hsl(222 47% 2% / 0.4)',
}}
>
<div
className={cn(
'w-11 h-11 rounded-xl flex items-center justify-center mb-5 bg-gradient-to-br',
gradient,
'opacity-90 group-hover:opacity-100 group-hover:scale-110 transition-all duration-300'
)}
>
<Icon className="h-5 w-5 text-white" />
</div>
<h3 className="font-display font-semibold text-[hsl(210_40%_92%)] text-lg mb-2 leading-snug">
{title}
</h3>
<p className="text-sm text-[hsl(215_20%_52%)] leading-relaxed">{description}</p>
</div>
);
}

View file

@ -1,74 +1,233 @@
import { ChevronRight } from 'lucide-react';
import { ArrowRight, Sparkles, Users, BarChart3, Shield } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
const StatBadge = ({ value, label }: { value: string; label: string }) => (
<div className="flex flex-col items-center">
<span
className="text-2xl font-display font-bold"
style={{
background: 'linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
{value}
</span>
<span className="text-xs text-[hsl(215_20%_55%)] mt-0.5">{label}</span>
</div>
);
export default function Hero() {
return (
<div className="relative isolate overflow-hidden">
{/* Background gradient */}
<div className="absolute inset-x-0 top-[-10rem] -z-10 transform-gpu overflow-hidden blur-3xl sm:top-[-20rem]" aria-hidden="true">
<div className="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-primary to-blue-400 opacity-20 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]" style={{ clipPath: 'polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)' }} />
</div>
<div className="mx-auto max-w-7xl px-6 py-24 sm:py-32 lg:flex lg:items-center lg:gap-x-10 lg:px-8 lg:py-40">
<div className="mx-auto max-w-2xl lg:mx-0 lg:flex-auto">
<div className="flex">
<div className="relative flex items-center gap-x-4 rounded-full px-4 py-1 text-sm leading-6 text-gray-600 ring-1 ring-gray-900/10 hover:ring-gray-900/20">
<span className="font-semibold text-primary">New</span>
<span className="h-4 w-px bg-gray-900/10" aria-hidden="true" />
<span>Introducing AI-driven focus groups</span>
<section className="relative min-h-screen flex items-center overflow-hidden">
{/* Background orbs */}
<div
className="glow-orb w-[600px] h-[600px] -top-32 -left-32 opacity-20 animate-pulse-glow"
style={{ background: 'radial-gradient(circle, #06B6D4, transparent 70%)' }}
/>
<div
className="glow-orb w-[500px] h-[500px] top-1/2 -right-40 opacity-15 animate-pulse-glow"
style={{ background: 'radial-gradient(circle, #8B5CF6, transparent 70%)', animationDelay: '2s' }}
/>
<div
className="glow-orb w-[400px] h-[400px] bottom-0 left-1/3 opacity-10 animate-pulse-glow"
style={{ background: 'radial-gradient(circle, #06B6D4, transparent 70%)', animationDelay: '4s' }}
/>
{/* Grid overlay */}
<div
className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage:
'linear-gradient(hsl(210 40% 96%) 1px, transparent 1px), linear-gradient(90deg, hsl(210 40% 96%) 1px, transparent 1px)',
backgroundSize: '48px 48px',
}}
/>
<div className="relative max-w-7xl mx-auto px-6 lg:px-8 pt-28 pb-20 w-full">
<div className="lg:grid lg:grid-cols-2 lg:gap-16 items-center">
{/* Left — copy */}
<div className="animate-slide-up">
{/* Badge */}
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-[hsl(188_91%_44%/0.3)] bg-[hsl(188_91%_44%/0.05)] mb-8">
<Sparkles className="h-3.5 w-3.5 text-[#06B6D4]" />
<span className="text-sm font-medium text-[#06B6D4]">AI-Powered Synthetic Research</span>
</div>
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-display font-bold leading-[1.05] tracking-tight text-white mb-6">
Research with{' '}
<span
style={{
background: 'linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
Synthetic
<br />
Personas
</span>
</h1>
<p className="text-lg text-[hsl(215_20%_60%)] leading-relaxed max-w-lg mb-10">
Conduct market research and user interviews using AI-powered synthetic personas.
Gain deep insights without the cost and time of traditional methods.
</p>
<div className="flex flex-col sm:flex-row gap-4 mb-14">
<Link
to="/synthetic-users"
className="inline-flex items-center justify-center gap-2 px-7 py-3.5 rounded-xl font-semibold text-white transition-all duration-200"
style={{
background: 'linear-gradient(135deg, #06B6D4 0%, #7C3AED 100%)',
boxShadow: '0 0 24px hsl(188 91% 44% / 0.3)',
}}
onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.boxShadow = '0 0 40px hsl(188 91% 44% / 0.5)';
(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)';
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.boxShadow = '0 0 24px hsl(188 91% 44% / 0.3)';
(e.currentTarget as HTMLElement).style.transform = 'translateY(0)';
}}
>
Start for free
<ArrowRight className="h-4 w-4" />
</Link>
<Link
to="/focus-groups"
className="inline-flex items-center justify-center gap-2 px-7 py-3.5 rounded-xl font-semibold text-[hsl(210_40%_80%)] border border-[hsl(222_38%_22%)] hover:border-[hsl(188_91%_44%/0.4)] hover:text-white transition-all duration-200"
>
See how it works
</Link>
</div>
{/* Stats row */}
<div className="flex items-center gap-8 pt-6 border-t border-[hsl(222_38%_16%)]">
<StatBadge value="10k+" label="Personas created" />
<div className="w-px h-8 bg-[hsl(222_38%_18%)]" />
<StatBadge value="95%" label="Insight accuracy" />
<div className="w-px h-8 bg-[hsl(222_38%_18%)]" />
<StatBadge value="10×" label="Faster research" />
</div>
</div>
<h1 className="mt-10 max-w-lg text-4xl font-sf font-bold tracking-tight text-gray-900 sm:text-6xl">
Research with <span className="text-gradient">synthetic personas</span>
</h1>
<p className="mt-6 text-lg leading-8 text-gray-600">
Conduct research using AI-powered synthetic personas and autonomous focus groups.
Gain valuable insights without the limitations of traditional research methods.
</p>
<div className="mt-10 flex items-center gap-x-6">
<Link to="/synthetic-users">
<Button className="px-6 py-6 text-base hover:shadow-lg hover:translate-y-[-2px] button-transition">
Create synthetic personas
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
</Link>
<Link to="/focus-groups" className="text-sm font-semibold leading-6 text-gray-900 hover:text-primary button-transition">
Set up focus groups <span aria-hidden="true"></span>
</Link>
</div>
</div>
<div className="mt-16 sm:mt-24 lg:mt-0 lg:flex-shrink-0 lg:flex-grow">
<div className="relative glass-card mx-auto w-[350px] h-[450px] rounded-2xl shadow-xl overflow-hidden animate-float">
<div className="absolute top-4 left-4 right-4 h-12 bg-white/70 backdrop-blur-sm rounded-lg flex items-center px-4">
<div className="h-3 w-3 rounded-full bg-red-400 mr-2"></div>
<div className="h-3 w-3 rounded-full bg-yellow-400 mr-2"></div>
<div className="h-3 w-3 rounded-full bg-green-400 mr-2"></div>
<div className="text-xs text-gray-500 ml-2">Shampoo Brand Perception</div>
</div>
<div className="absolute top-20 left-4 right-4 bottom-4 bg-gray-50 rounded-lg overflow-hidden">
{/* Simulated conversation bubbles about shampoo brand perception */}
{[1, 2, 3, 4].map((i) => (
<div key={i} className={`flex ${i % 2 === 0 ? 'justify-end' : 'justify-start'} px-3 py-2`}>
<div className={`max-w-[70%] rounded-lg px-3 py-2 text-xs ${i % 2 === 0 ? 'bg-primary text-white' : 'bg-gray-200 text-gray-800'}`}>
{i === 1 && "What qualities do you look for in a premium shampoo brand?"}
{i === 2 && "I value natural ingredients and a brand that feels luxurious but still eco-friendly."}
{i === 3 && "How important is fragrance in your shampoo selection?"}
{i === 4 && "Very important - it affects my mood and how I feel about the product throughout the day."}
{/* Right — mock UI */}
<div className="hidden lg:block mt-12 lg:mt-0">
<div className="relative">
{/* Floating feature badges */}
<div
className="absolute -top-6 -left-8 z-20 flex items-center gap-2.5 px-4 py-2.5 rounded-xl text-sm font-medium text-white animate-float"
style={{
background: 'hsl(222 45% 9%)',
border: '1px solid hsl(222 38% 20%)',
boxShadow: '0 8px 32px hsl(222 47% 2% / 0.5)',
animationDelay: '0s',
}}
>
<Users className="h-4 w-4 text-[#06B6D4]" />
<span className="text-[hsl(210_40%_90%)]">42 personas active</span>
</div>
<div
className="absolute -bottom-6 -right-8 z-20 flex items-center gap-2.5 px-4 py-2.5 rounded-xl text-sm font-medium text-white animate-float"
style={{
background: 'hsl(222 45% 9%)',
border: '1px solid hsl(222 38% 20%)',
boxShadow: '0 8px 32px hsl(222 47% 2% / 0.5)',
animationDelay: '1.5s',
}}
>
<BarChart3 className="h-4 w-4 text-[#8B5CF6]" />
<span className="text-[hsl(210_40%_90%)]">Insights ready</span>
</div>
{/* Main mock card */}
<div
className="rounded-2xl overflow-hidden animate-float"
style={{
background: 'hsl(222 45% 8%)',
border: '1px solid hsl(222 38% 16%)',
boxShadow: '0 24px 64px hsl(222 47% 2% / 0.7)',
animationDelay: '0.5s',
}}
>
{/* Window bar */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-[hsl(222_38%_14%)]">
<div className="h-2.5 w-2.5 rounded-full bg-red-500/70" />
<div className="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
<div className="h-2.5 w-2.5 rounded-full bg-green-500/70" />
<span className="ml-3 text-xs text-[hsl(215_20%_45%)]">Focus Group Session</span>
</div>
{/* Chat content */}
<div className="p-4 space-y-3 min-h-[320px]">
{[
{ role: 'mod', text: 'What qualities matter most when choosing a premium brand?' },
{ role: 'user', name: 'Sarah, 32', text: 'Sustainability and transparency in sourcing. I want to trust the brand.' },
{ role: 'mod', text: 'How does packaging influence your perception?' },
{ role: 'user', name: 'Marcus, 28', text: 'Minimalist design signals confidence. Loud packaging feels insecure.' },
{ role: 'user', name: 'Priya, 35', text: 'Agreed — and eco-friendly materials are now a baseline expectation for me.' },
].map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === 'mod' ? 'justify-start' : 'justify-end'}`}
>
<div
className={`max-w-[78%] px-3.5 py-2.5 rounded-2xl text-xs leading-relaxed ${
msg.role === 'mod'
? 'bg-[hsl(222_38%_13%)] text-[hsl(210_40%_75%)]'
: 'text-white'
}`}
style={
msg.role === 'user'
? {
background: 'linear-gradient(135deg, hsl(188 91% 30%), hsl(258 89% 45%))',
}
: {}
}
>
{msg.name && (
<div className="text-[10px] font-semibold text-[#06B6D4] mb-0.5">{msg.name}</div>
)}
{msg.text}
</div>
</div>
))}
</div>
{/* Input bar */}
<div className="flex items-center gap-2 px-4 py-3 border-t border-[hsl(222_38%_14%)]">
<div className="flex-1 h-8 rounded-lg bg-[hsl(222_38%_11%)] border border-[hsl(222_38%_18%)]" />
<div
className="h-8 w-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: 'linear-gradient(135deg, #06B6D4, #7C3AED)' }}
>
<ArrowRight className="h-3.5 w-3.5 text-white" />
</div>
</div>
))}
</div>
{/* Trust badge */}
<div
className="absolute top-1/2 -translate-y-1/2 -right-12 flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-[hsl(210_40%_80%)] animate-float"
style={{
background: 'hsl(222 45% 9%)',
border: '1px solid hsl(222 38% 18%)',
animationDelay: '2.5s',
writingMode: 'vertical-rl',
transform: 'translateY(-50%) rotate(180deg)',
}}
>
<Shield className="h-3 w-3 text-green-400 rotate-180" />
SOC 2 Ready
</div>
</div>
</div>
</div>
</div>
{/* Background gradient (bottom) */}
<div className="absolute inset-x-0 bottom-[-10rem] -z-10 transform-gpu overflow-hidden blur-3xl sm:bottom-[-20rem]" aria-hidden="true">
<div className="relative left-[calc(50%+11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-blue-400 to-primary opacity-20 sm:left-[calc(50%+30rem)] sm:w-[72.1875rem]" style={{ clipPath: 'polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)' }} />
</div>
</div>
</section>
);
}

View file

@ -1,255 +1,272 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Menu, X, LayoutDashboard, Users, MessageSquare, Home, LogIn, LogOut, ShieldCheck, CreditCard } from 'lucide-react';
import { Menu, X, LayoutDashboard, Users, MessageSquare, Home, LogIn, LogOut, ShieldCheck, CreditCard, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/AuthContext';
import { billingApi } from '@/lib/api';
const LogoMark = () => (
<svg viewBox="0 0 36 36" fill="none" className="h-8 w-8 flex-shrink-0">
<defs>
<linearGradient id="nav-lg" x1="2" y1="2" x2="34" y2="34" gradientUnits="userSpaceOnUse">
<stop stopColor="#06B6D4" />
<stop offset="1" stopColor="#8B5CF6" />
</linearGradient>
</defs>
<path
d="M28 8C24.8 5.6 20.9 4 16.6 4C8.6 4 2 10.6 2 18.5C2 26.4 8.6 33 16.6 33C20.9 33 24.8 31.4 28 29"
stroke="url(#nav-lg)" strokeWidth="3.5" strokeLinecap="round" fill="none"
/>
<circle cx="28" cy="8" r="2.5" fill="#06B6D4" />
<circle cx="28" cy="29" r="2.5" fill="#8B5CF6" />
</svg>
);
export default function Navigation() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [creditsBalance, setCreditsBalance] = useState<number | null>(null);
const [scrolled, setScrolled] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const { isAuthenticated, logout, user } = useAuth();
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 12);
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
useEffect(() => {
if (!isAuthenticated) { setCreditsBalance(null); return; }
billingApi.getBalance().then(r => setCreditsBalance(r.data.credits_balance)).catch(() => {});
}, [isAuthenticated, location.pathname]);
const navigationItems = [
{
name: 'Home',
href: '/',
icon: Home,
},
{
name: 'Synthetic Personas',
href: '/synthetic-users',
icon: Users,
},
{
name: 'Focus Groups',
href: '/focus-groups',
icon: MessageSquare,
},
{
name: 'Dashboard',
href: '/dashboard',
icon: LayoutDashboard,
},
{
name: 'Billing',
href: '/billing',
icon: CreditCard,
},
{ name: 'Home', href: '/', icon: Home },
{ name: 'Personas', href: '/synthetic-users', icon: Users },
{ name: 'Focus Groups', href: '/focus-groups', icon: MessageSquare },
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'Billing', href: '/billing', icon: CreditCard },
];
const toggleMobileMenu = () => {
setMobileMenuOpen(!mobileMenuOpen);
};
const isActive = (path: string) => location.pathname === path;
const isActive = (path: string) => {
return location.pathname === path;
};
// Handle navigation with authentication
const handleAuthNavigation = (path: string) => {
// Dispatch a custom event when navigating to the synthetic users page
// This helps components know when they should refresh data
if (path === '/synthetic-users') {
// Create and dispatch a custom event that the SyntheticUsers component can listen for
const event = new CustomEvent('syntheticUsersNavigation');
window.dispatchEvent(event);
window.dispatchEvent(new CustomEvent('syntheticUsersNavigation'));
}
// Always navigate normally - ProtectedRoute will handle auth check
navigate(path);
};
return (
<header className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-md border-b border-slate-200/80">
<div className="px-4 sm:px-6 lg:px-8">
<header
className={cn(
'fixed top-0 left-0 right-0 z-50 transition-all duration-300',
scrolled
? 'bg-[hsl(222_47%_4%/0.95)] backdrop-blur-xl border-b border-[hsl(222_38%_16%)]'
: 'bg-transparent'
)}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between">
<div className="flex items-center">
<Link to="/" className="flex items-center">
<span className="font-sf text-2xl font-semibold text-gradient">Semblance</span>
</Link>
</div>
{/* Desktop Navigation */}
<nav className="hidden md:block">
<ul className="flex items-center space-x-8">
{navigationItems.map((item) => (
<li key={item.name}>
{item.href === '/' ? (
<Link
to={item.href}
className={cn(
"flex items-center px-1 py-2 text-sm font-medium hover-transition border-b-2",
isActive(item.href)
? "border-primary text-primary"
: "border-transparent text-slate-600 hover:text-slate-900 hover:border-slate-300"
)}
>
<item.icon className="mr-1 h-4 w-4" />
{item.name}
</Link>
) : (
<button
onClick={() => handleAuthNavigation(item.href)}
className={cn(
"flex items-center px-1 py-2 text-sm font-medium hover-transition border-b-2",
isActive(item.href)
? "border-primary text-primary"
: "border-transparent text-slate-600 hover:text-slate-900 hover:border-slate-300"
)}
>
<item.icon className="mr-1 h-4 w-4" />
{item.name}
</button>
)}
</li>
))}
{/* Admin link — only visible to admins */}
{user?.role === 'admin' && (
<li>
<button
onClick={() => handleAuthNavigation('/admin')}
className={cn(
"flex items-center px-1 py-2 text-sm font-medium hover-transition border-b-2",
isActive('/admin')
? "border-primary text-primary"
: "border-transparent text-slate-600 hover:text-slate-900 hover:border-slate-300"
)}
>
<ShieldCheck className="mr-1 h-4 w-4" />
Admin
</button>
</li>
)}
{/* Authentication buttons */}
<li>
{isAuthenticated ? (
<button
onClick={() => {
logout();
navigate('/login');
}}
className="flex items-center px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 button-transition rounded-md hover:bg-slate-50"
>
<LogOut className="mr-1 h-4 w-4" />
Logout
</button>
) : (
<Link
to="/login"
className="flex items-center px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 button-transition rounded-md hover:bg-slate-50"
>
<LogIn className="mr-1 h-4 w-4" />
Login
</Link>
)}
</li>
</ul>
</nav>
{/* Mobile Menu Button */}
<div className="flex md:hidden">
<button
type="button"
className="inline-flex items-center justify-center rounded-md p-2 text-slate-700 hover:bg-slate-100 hover:text-slate-900 button-transition"
onClick={toggleMobileMenu}
{/* Logo */}
<Link to="/" className="flex items-center gap-2.5 group">
<LogoMark />
<span
className="font-display font-bold text-xl tracking-tight"
style={{
background: 'linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
<span className="sr-only">Open main menu</span>
{mobileMenuOpen ? (
<X className="block h-6 w-6" aria-hidden="true" />
) : (
<Menu className="block h-6 w-6" aria-hidden="true" />
)}
</button>
</div>
</div>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="md:hidden glass-panel animate-fade-in">
<div className="space-y-1 px-4 pb-3 pt-2">
Cohorta
</span>
</Link>
{/* Desktop Nav */}
<nav className="hidden md:flex items-center gap-1">
{navigationItems.map((item) => (
<div key={item.name}>
{item.href === '/' ? (
<Link
to={item.href}
className={cn(
"flex items-center rounded-md px-3 py-2 text-base font-medium button-transition",
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200',
isActive(item.href)
? "bg-primary text-white"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
? 'text-[#06B6D4] bg-[hsl(188_91%_44%/0.1)]'
: 'text-[hsl(215_20%_60%)] hover:text-[hsl(210_40%_90%)] hover:bg-[hsl(222_38%_11%)]'
)}
onClick={() => setMobileMenuOpen(false)}
>
<item.icon className="mr-3 h-5 w-5" />
<item.icon className="h-3.5 w-3.5" />
{item.name}
</Link>
) : (
<button
onClick={() => handleAuthNavigation(item.href)}
className={cn(
"flex items-center rounded-md px-3 py-2 text-base font-medium button-transition w-full text-left",
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200',
isActive(item.href)
? "bg-primary text-white"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
? 'text-[#06B6D4] bg-[hsl(188_91%_44%/0.1)]'
: 'text-[hsl(215_20%_60%)] hover:text-[hsl(210_40%_90%)] hover:bg-[hsl(222_38%_11%)]'
)}
onClick={() => {
setMobileMenuOpen(false);
handleAuthNavigation(item.href);
}}
>
<item.icon className="mr-3 h-5 w-5" />
<item.icon className="h-3.5 w-3.5" />
{item.name}
</button>
)}
</div>
))}
{/* Admin link — mobile */}
{user?.role === 'admin' && (
<button
onClick={() => handleAuthNavigation('/admin')}
className={cn(
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200',
isActive('/admin')
? 'text-[#06B6D4] bg-[hsl(188_91%_44%/0.1)]'
: 'text-[hsl(215_20%_60%)] hover:text-[hsl(210_40%_90%)] hover:bg-[hsl(222_38%_11%)]'
)}
>
<ShieldCheck className="h-3.5 w-3.5" />
Admin
</button>
)}
</nav>
{/* Right side */}
<div className="hidden md:flex items-center gap-3">
{isAuthenticated && creditsBalance !== null && (
<button
onClick={() => handleAuthNavigation('/billing')}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20 hover:bg-amber-500/15 transition-colors"
>
<Zap className="h-3.5 w-3.5" />
{creditsBalance} cr
</button>
)}
{isAuthenticated ? (
<button
onClick={() => { logout(); navigate('/login'); }}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium text-[hsl(215_20%_60%)] hover:text-[hsl(210_40%_90%)] hover:bg-[hsl(222_38%_11%)] transition-all duration-200"
>
<LogOut className="h-4 w-4" />
Logout
</button>
) : (
<div className="flex items-center gap-2">
<Link
to="/login"
className="px-4 py-2 rounded-lg text-sm font-medium text-[hsl(215_20%_65%)] hover:text-white transition-colors"
>
Sign in
</Link>
<Link
to="/register"
className="px-4 py-2 rounded-lg text-sm font-semibold text-white btn-gradient"
style={{
background: 'linear-gradient(135deg, #06B6D4 0%, #7C3AED 100%)',
boxShadow: '0 0 20px hsl(188 91% 44% / 0.2)',
}}
>
Get Started
</Link>
</div>
)}
</div>
{/* Mobile toggle */}
<button
className="md:hidden p-2 rounded-lg text-[hsl(215_20%_60%)] hover:text-white hover:bg-[hsl(222_38%_11%)] transition-colors"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
</div>
{/* Mobile menu */}
{mobileMenuOpen && (
<div className="md:hidden bg-[hsl(222_47%_5%)] border-t border-[hsl(222_38%_16%)] animate-slide-down">
<div className="px-4 py-3 space-y-1">
{navigationItems.map((item) => (
<div key={item.name}>
{item.href === '/' ? (
<Link
to={item.href}
className={cn(
'flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-all duration-200',
isActive(item.href)
? 'text-[#06B6D4] bg-[hsl(188_91%_44%/0.1)]'
: 'text-[hsl(215_20%_60%)] hover:text-white hover:bg-[hsl(222_38%_11%)]'
)}
onClick={() => setMobileMenuOpen(false)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
) : (
<button
className={cn(
'w-full flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 text-left',
isActive(item.href)
? 'text-[#06B6D4] bg-[hsl(188_91%_44%/0.1)]'
: 'text-[hsl(215_20%_60%)] hover:text-white hover:bg-[hsl(222_38%_11%)]'
)}
onClick={() => { setMobileMenuOpen(false); handleAuthNavigation(item.href); }}
>
<item.icon className="h-4 w-4" />
{item.name}
</button>
)}
</div>
))}
{user?.role === 'admin' && (
<button
className={cn(
"flex items-center rounded-md px-3 py-2 text-base font-medium button-transition w-full text-left",
isActive('/admin')
? "bg-primary text-white"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
'w-full flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-all text-left',
isActive('/admin') ? 'text-[#06B6D4] bg-[hsl(188_91%_44%/0.1)]' : 'text-[hsl(215_20%_60%)] hover:text-white hover:bg-[hsl(222_38%_11%)]'
)}
onClick={() => {
setMobileMenuOpen(false);
handleAuthNavigation('/admin');
}}
onClick={() => { setMobileMenuOpen(false); handleAuthNavigation('/admin'); }}
>
<ShieldCheck className="mr-3 h-5 w-5" />
<ShieldCheck className="h-4 w-4" />
Admin
</button>
)}
{/* Mobile Authentication options */}
{isAuthenticated ? (
<button
onClick={() => {
logout();
setMobileMenuOpen(false);
navigate('/login');
}}
className="flex items-center rounded-md px-3 py-2 text-base font-medium button-transition text-slate-600 hover:bg-slate-50 hover:text-slate-900 w-full"
>
<LogOut className="mr-3 h-5 w-5" />
Logout
</button>
) : (
<Link
to="/login"
className="flex items-center rounded-md px-3 py-2 text-base font-medium button-transition text-slate-600 hover:bg-slate-50 hover:text-slate-900"
onClick={() => setMobileMenuOpen(false)}
>
<LogIn className="mr-3 h-5 w-5" />
Login
</Link>
)}
<div className="pt-2 border-t border-[hsl(222_38%_16%)]">
{isAuthenticated ? (
<button
onClick={() => { logout(); setMobileMenuOpen(false); navigate('/login'); }}
className="w-full flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium text-[hsl(215_20%_60%)] hover:text-white hover:bg-[hsl(222_38%_11%)] transition-all"
>
<LogOut className="h-4 w-4" />
Logout
</button>
) : (
<div className="flex flex-col gap-2 pt-1">
<Link
to="/login"
className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-[hsl(215_20%_70%)] hover:text-white border border-[hsl(222_38%_20%)] hover:border-[hsl(222_38%_28%)] transition-all"
onClick={() => setMobileMenuOpen(false)}
>
<LogIn className="h-4 w-4" />
Sign in
</Link>
<Link
to="/register"
className="flex items-center justify-center px-4 py-2.5 rounded-lg text-sm font-semibold text-white transition-all"
style={{ background: 'linear-gradient(135deg, #06B6D4 0%, #7C3AED 100%)' }}
onClick={() => setMobileMenuOpen(false)}
>
Get Started
</Link>
</div>
)}
</div>
</div>
</div>
)}

View file

@ -0,0 +1,113 @@
import { useState, useEffect } from 'react';
import { adminApi } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Loader2, Users, Zap, DollarSign, TrendingUp, BarChart2 } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
interface Analytics {
users: { total: number; new_in_period: number };
activity: { focus_group_runs: number; personas_created: number };
revenue: { credits_sold: number; purchase_count: number; cost_usd: number };
model_breakdown: Array<{ _id: string; cost: number; calls: number }>;
daily_purchases: Array<{ _id: string; credits: number; count: number }>;
}
function StatCard({ icon: Icon, title, value, sub }: { icon: any; title: string; value: string | number; sub?: string }) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
</CardContent>
</Card>
);
}
export default function AnalyticsTab() {
const [data, setData] = useState<Analytics | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
adminApi.getAnalytics()
.then(r => setData(r.data))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin" /></div>;
if (!data) return <p className="text-muted-foreground">Failed to load analytics.</p>;
const revenueUsd = data.revenue.credits_sold;
const costUsd = data.revenue.cost_usd;
const margin = revenueUsd > 0 ? ((revenueUsd - costUsd) / revenueUsd * 100).toFixed(1) : '';
return (
<div className="space-y-6">
{/* KPI row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard icon={Users} title="Total Users" value={data.users.total} sub={`+${data.users.new_in_period} this period`} />
<StatCard icon={Zap} title="FG Runs" value={data.activity.focus_group_runs} sub="focus group sessions" />
<StatCard icon={Users} title="Personas Created" value={data.activity.personas_created} />
<StatCard icon={DollarSign} title="Credits Sold" value={data.revenue.credits_sold} sub={`${data.revenue.purchase_count} purchases`} />
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatCard icon={TrendingUp} title="Cost (USD)" value={`$${costUsd.toFixed(2)}`} sub="AI inference cost" />
<StatCard icon={DollarSign} title="Revenue (credits)" value={revenueUsd} sub="credits sold = $" />
<StatCard icon={BarChart2} title="Gross Margin" value={`${margin}%`} sub="revenue cost / revenue" />
</div>
{/* Daily purchases chart */}
{data.daily_purchases.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm">Daily Credits Purchased (last 30 days)</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={data.daily_purchases}>
<XAxis dataKey="_id" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Bar dataKey="credits" fill="hsl(var(--primary))" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
{/* Model breakdown */}
{data.model_breakdown.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm">Cost by Model</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-1">Model</th>
<th className="text-right py-1">Calls</th>
<th className="text-right py-1">Cost (USD)</th>
</tr>
</thead>
<tbody>
{data.model_breakdown.map(m => (
<tr key={m._id} className="border-b last:border-0">
<td className="py-1">{m._id || 'unknown'}</td>
<td className="text-right">{m.calls}</td>
<td className="text-right">${m.cost.toFixed(4)}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
</div>
);
}

View file

@ -0,0 +1,130 @@
import { useState, useEffect } from 'react';
import { adminApi } from '@/lib/api';
import { toastService } from '@/lib/toast';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Loader2, Save } from 'lucide-react';
interface CreditPack {
id: string;
name: string;
price_usd: number;
credits: number;
}
interface AppSettings {
persona_cost: number;
run_cost: number;
trial_grant: number;
credit_packs: CreditPack[];
}
export default function CreditSettingsTab() {
const [settings, setSettings] = useState<AppSettings | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
adminApi.getSettings()
.then(r => setSettings(r.data))
.catch(() => toastService.error('Failed to load settings'))
.finally(() => setLoading(false));
}, []);
const handleSave = async () => {
if (!settings) return;
setSaving(true);
try {
await adminApi.updateSettings(settings);
toastService.success('Settings saved');
} catch {
toastService.error('Failed to save settings');
} finally {
setSaving(false);
}
};
const updatePack = (index: number, field: keyof CreditPack, value: string | number) => {
if (!settings) return;
const packs = [...settings.credit_packs];
packs[index] = { ...packs[index], [field]: field === 'id' || field === 'name' ? value : Number(value) };
setSettings({ ...settings, credit_packs: packs });
};
if (loading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin" /></div>;
if (!settings) return <p className="text-muted-foreground">Failed to load settings.</p>;
return (
<div className="space-y-6 max-w-2xl">
<Card>
<CardHeader>
<CardTitle>Credit Economy</CardTitle>
<CardDescription>Configure the credit costs for core actions (1 credit = $1).</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div>
<Label>Persona creation cost (cr)</Label>
<Input
type="number" min={0}
value={settings.persona_cost}
onChange={e => setSettings({ ...settings, persona_cost: Number(e.target.value) })}
/>
</div>
<div>
<Label>Focus group run cost (cr)</Label>
<Input
type="number" min={0}
value={settings.run_cost}
onChange={e => setSettings({ ...settings, run_cost: Number(e.target.value) })}
/>
</div>
<div>
<Label>Trial grant (cr)</Label>
<Input
type="number" min={0}
value={settings.trial_grant}
onChange={e => setSettings({ ...settings, trial_grant: Number(e.target.value) })}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Credit Packs</CardTitle>
<CardDescription>Configure the available credit packages for purchase.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{settings.credit_packs.map((pack, i) => (
<div key={pack.id} className="grid grid-cols-4 gap-3 items-end">
<div>
<Label>Name</Label>
<Input value={pack.name} onChange={e => updatePack(i, 'name', e.target.value)} />
</div>
<div>
<Label>Price ($)</Label>
<Input type="number" min={1} value={pack.price_usd} onChange={e => updatePack(i, 'price_usd', e.target.value)} />
</div>
<div>
<Label>Credits</Label>
<Input type="number" min={1} value={pack.credits} onChange={e => updatePack(i, 'credits', e.target.value)} />
</div>
<div className="text-sm text-muted-foreground pb-2">
${(pack.price_usd / pack.credits).toFixed(2)}/cr
</div>
</div>
))}
</CardContent>
</Card>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
Save Settings
</Button>
</div>
);
}

View file

@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@ -6,83 +6,47 @@
@layer base {
:root {
--background: 350 30% 98%;
--foreground: 345 30% 15%;
/* Cohorta — dark navy + cyan palette (AImpress style) */
--background: 222 47% 4%;
--foreground: 210 40% 96%;
--card: 0 0% 100%;
--card-foreground: 345 30% 15%;
--card: 222 45% 7%;
--card-foreground: 210 40% 96%;
--popover: 0 0% 100%;
--popover-foreground: 345 30% 15%;
--popover: 222 45% 7%;
--popover-foreground: 210 40% 96%;
--primary: 350 85% 80%;
--primary-foreground: 350 30% 20%;
/* Cyan primary */
--primary: 188 91% 44%;
--primary-foreground: 222 47% 4%;
--secondary: 350 30% 96.1%;
--secondary-foreground: 345 30% 15%;
--secondary: 222 38% 13%;
--secondary-foreground: 210 40% 80%;
--muted: 350 30% 96.1%;
--muted-foreground: 350 10% 50%;
--muted: 222 38% 11%;
--muted-foreground: 215 20% 50%;
--accent: 350 30% 96.1%;
--accent-foreground: 345 30% 15%;
/* Violet accent */
--accent: 258 89% 66%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 350 30% 98%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 350 30% 91.4%;
--input: 350 30% 91.4%;
--ring: 350 85% 80%;
--border: 222 38% 16%;
--input: 222 38% 13%;
--ring: 188 91% 44%;
--radius: 0.5rem;
--radius: 0.75rem;
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 345 30% 15%;
--sidebar-primary: 350 85% 80%;
--sidebar-primary-foreground: 350 30% 20%;
--sidebar-accent: 350 30% 96.1%;
--sidebar-accent-foreground: 345 30% 15%;
--sidebar-border: 350 30% 91.4%;
--sidebar-ring: 350 85% 80%;
}
.dark {
--background: 345 30% 10%;
--foreground: 350 30% 98%;
--card: 345 30% 10%;
--card-foreground: 350 30% 98%;
--popover: 345 30% 10%;
--popover-foreground: 350 30% 98%;
--primary: 350 85% 80%;
--primary-foreground: 345 30% 15%;
--secondary: 342 20% 17.5%;
--secondary-foreground: 350 30% 98%;
--muted: 342 20% 17.5%;
--muted-foreground: 350 10% 70%;
--accent: 342 20% 17.5%;
--accent-foreground: 350 30% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 350 30% 98%;
--border: 342 20% 17.5%;
--input: 342 20% 17.5%;
--ring: 350 70% 85%;
--sidebar-background: 345 30% 10%;
--sidebar-foreground: 350 30% 98%;
--sidebar-primary: 350 85% 80%;
--sidebar-primary-foreground: 350 30% 98%;
--sidebar-accent: 342 20% 17.5%;
--sidebar-accent-foreground: 350 30% 98%;
--sidebar-border: 342 20% 17.5%;
--sidebar-ring: 350 70% 85%;
--sidebar-background: 222 47% 4%;
--sidebar-foreground: 210 40% 96%;
--sidebar-primary: 188 91% 44%;
--sidebar-primary-foreground: 222 47% 4%;
--sidebar-accent: 222 38% 11%;
--sidebar-accent-foreground: 210 40% 80%;
--sidebar-border: 222 38% 16%;
--sidebar-ring: 188 91% 44%;
}
}
@ -92,121 +56,166 @@
}
body {
@apply bg-background text-foreground font-sans;
@apply bg-background text-foreground;
font-family: 'Inter', system-ui, sans-serif;
font-feature-settings: "rlig" 1, "calt" 1;
}
/* Custom scrollbar */
h1, h2, h3, h4 {
font-family: 'Syne', system-ui, sans-serif;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
background: transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-muted-foreground/40 rounded-full;
background: hsl(222 38% 20%);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground/60;
}
/* Add system font */
@font-face {
font-family: 'SF Pro Display';
src: local('SF Pro Display'), local('SFProDisplay'),
url(https://applesocial.s3.amazonaws.com/assets/styles/fonts/sanfrancisco/sanfranciscodisplay-regular-webfont.woff) format('woff');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'SF Pro Display';
src: local('SF Pro Display Medium'), local('SFProDisplay-Medium'),
url(https://applesocial.s3.amazonaws.com/assets/styles/fonts/sanfrancisco/sanfranciscodisplay-medium-webfont.woff) format('woff');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'SF Pro Display';
src: local('SF Pro Display Semibold'), local('SFProDisplay-Semibold'),
url(https://applesocial.s3.amazonaws.com/assets/styles/fonts/sanfrancisco/sanfranciscodisplay-semibold-webfont.woff) format('woff');
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: 'SF Pro Display';
src: local('SF Pro Display Bold'), local('SFProDisplay-Bold'),
url(https://applesocial.s3.amazonaws.com/assets/styles/fonts/sanfrancisco/sanfranciscodisplay-bold-webfont.woff) format('woff');
font-weight: 700;
font-style: normal;
background: hsl(188 91% 44% / 0.5);
}
}
@layer components {
/* Glass cards — dark */
.glass-card {
@apply bg-white/80 backdrop-blur-md border border-white/20 shadow-sm;
background: linear-gradient(135deg, hsl(222 45% 9% / 0.8), hsl(222 45% 7% / 0.9));
backdrop-filter: blur(12px);
border: 1px solid hsl(222 38% 20% / 0.6);
box-shadow: 0 4px 24px hsl(222 47% 2% / 0.4);
}
.glass-panel {
@apply bg-white/90 backdrop-blur-sm border border-white/40 shadow-sm;
background: hsl(222 45% 8% / 0.7);
backdrop-filter: blur(16px);
border: 1px solid hsl(222 38% 18% / 0.5);
}
/* Gradient text — cyan to violet */
.text-gradient {
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-pink-300;
}
.hover-transition {
@apply transition-all duration-300 ease-in-out;
}
.button-transition {
@apply transition-all duration-200 ease-out;
}
/* Sidebar icon styles */
.sidebar-icon {
@apply h-4 w-4 text-slate-500 mr-3 mt-0.5 flex-shrink-0;
}
.sidebar-section {
@apply flex items-start;
}
.sidebar-sub-item {
@apply text-sm text-muted-foreground;
background: linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Gradient border card */
.gradient-border-card {
position: relative;
background: hsl(222 45% 7%);
border-radius: var(--radius);
}
.gradient-border-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: var(--radius);
padding: 1px;
background: linear-gradient(135deg, #06B6D4, #8B5CF6);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity 0.3s ease;
}
.gradient-border-card:hover::before {
opacity: 1;
}
/* Persona card overlay styles */
/* CTA gradient button */
.btn-gradient {
background: linear-gradient(135deg, #06B6D4 0%, #7C3AED 100%);
color: white;
border: none;
transition: all 0.2s ease;
box-shadow: 0 0 20px hsl(188 91% 44% / 0.25);
}
.btn-gradient:hover {
box-shadow: 0 0 32px hsl(188 91% 44% / 0.45);
transform: translateY(-1px);
}
.btn-gradient:active {
transform: translateY(0);
}
/* Ghost button — white outline */
.btn-ghost-outline {
background: transparent;
color: hsl(210 40% 96%);
border: 1px solid hsl(222 38% 25%);
transition: all 0.2s ease;
}
.btn-ghost-outline:hover {
border-color: hsl(188 91% 44% / 0.6);
color: #06B6D4;
background: hsl(188 91% 44% / 0.05);
}
/* Animated glow orb */
.glow-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
pointer-events: none;
}
/* Persona card */
.persona-card {
@apply relative overflow-hidden;
min-height: 360px;
}
.persona-card-overlay {
@apply absolute inset-0 transition-all duration-200 ease-out pointer-events-none;
}
.persona-card:hover .persona-card-overlay {
@apply bg-[rgba(236,209,222,0.3)];
background: hsl(188 91% 44% / 0.08);
}
.persona-card.selected .persona-card-overlay {
@apply bg-[rgba(236,209,222,0.3)];
background: hsl(188 91% 44% / 0.10);
}
.persona-card-checkmark {
@apply absolute top-3 left-3 z-20 opacity-0 transition-all duration-200 ease-out;
@apply bg-white/90 rounded-full p-1 shadow-sm border border-white/40;
background: hsl(222 45% 12% / 0.9);
border-radius: 9999px;
padding: 4px;
border: 1px solid hsl(222 38% 22%);
}
.persona-card.selected .persona-card-checkmark {
@apply opacity-100;
}
/* Sidebar utilities */
.sidebar-icon {
@apply h-4 w-4 mr-3 mt-0.5 flex-shrink-0;
color: hsl(215 20% 50%);
}
.sidebar-section {
@apply flex items-start;
}
.sidebar-sub-item {
@apply text-sm text-muted-foreground;
}
.hover-transition {
@apply transition-all duration-300 ease-in-out;
}
.button-transition {
@apply transition-all duration-200 ease-out;
}
}

219
src/pages/Billing.tsx Normal file
View file

@ -0,0 +1,219 @@
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { billingApi } from '@/lib/api';
import { toastService } from '@/lib/toast';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Loader2, CreditCard, Zap, TrendingUp, Package } from 'lucide-react';
import Navigation from '@/components/Navigation';
interface CreditPack {
id: string;
name: string;
price_usd: number;
credits: number;
}
interface Transaction {
_id: string;
type: string;
amount: number;
balance_after: number;
description: string;
ts: string;
}
interface BalanceData {
credits_balance: number;
persona_cost: number;
run_cost: number;
credit_packs: CreditPack[];
}
const TX_BADGE: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
purchase: { label: 'Purchase', variant: 'default' },
grant: { label: 'Grant', variant: 'secondary' },
admin_grant: { label: 'Admin Grant', variant: 'secondary' },
debit: { label: 'Used', variant: 'destructive' },
refund: { label: 'Refund', variant: 'outline' },
};
export default function Billing() {
const [searchParams] = useSearchParams();
const [balanceData, setBalanceData] = useState<BalanceData | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(true);
const [checkoutPack, setCheckoutPack] = useState<string | null>(null);
useEffect(() => {
if (searchParams.get('success')) {
toastService.success('Payment successful!', { description: 'Your credits have been added to your account.' });
}
if (searchParams.get('cancelled')) {
toastService.info('Payment cancelled.');
}
}, []);
useEffect(() => {
async function load() {
try {
const [balRes, txRes] = await Promise.all([
billingApi.getBalance(),
billingApi.getTransactions(20),
]);
setBalanceData(balRes.data);
setTransactions(txRes.data.transactions);
} catch (e: any) {
toastService.error('Failed to load billing data');
} finally {
setLoading(false);
}
}
load();
}, []);
const handleBuyPack = async (packId: string) => {
setCheckoutPack(packId);
try {
const res = await billingApi.createCheckout(packId);
if (res.data.checkout_url) {
window.location.href = res.data.checkout_url;
}
} catch (e: any) {
toastService.error('Checkout failed', { description: e.response?.data?.message || 'Please try again' });
setCheckoutPack(null);
}
};
return (
<div className="min-h-screen bg-background">
<Navigation />
<div className="container max-w-4xl mx-auto py-8 px-4 space-y-8">
<div>
<h1 className="text-3xl font-bold">Billing</h1>
<p className="text-muted-foreground mt-1">Manage your credits and purchases</p>
</div>
{loading ? (
<div className="flex justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : balanceData && (
<>
{/* Balance card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Zap className="h-5 w-5 text-primary" />
Your Balance
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-baseline gap-2">
<span className="text-5xl font-bold">{balanceData.credits_balance}</span>
<span className="text-xl text-muted-foreground">credits</span>
</div>
<div className="mt-4 flex gap-4 text-sm text-muted-foreground">
<span>Persona creation: <strong>{balanceData.persona_cost} cr</strong></span>
<span>Focus group run: <strong>{balanceData.run_cost} cr</strong></span>
</div>
</CardContent>
</Card>
{/* Credit packs */}
<div>
<h2 className="text-xl font-semibold mb-4">Buy Credits</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{balanceData.credit_packs.map((pack) => (
<Card key={pack.id} className="relative">
{pack.id === 'pro' && (
<div className="absolute -top-2 left-1/2 -translate-x-1/2">
<Badge className="bg-primary text-primary-foreground">Popular</Badge>
</div>
)}
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-4 w-4" />
{pack.name}
</CardTitle>
<CardDescription>{pack.credits} credits</CardDescription>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">${pack.price_usd}</div>
<div className="text-sm text-muted-foreground mt-1">
${(pack.price_usd / pack.credits).toFixed(2)} per credit
</div>
</CardContent>
<CardFooter>
<Button
className="w-full"
onClick={() => handleBuyPack(pack.id)}
disabled={checkoutPack !== null}
>
{checkoutPack === pack.id ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Redirecting...
</>
) : (
<>
<CreditCard className="mr-2 h-4 w-4" />
Buy {pack.name}
</>
)}
</Button>
</CardFooter>
</Card>
))}
</div>
</div>
{/* Transaction history */}
<div>
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Transaction History
</h2>
{transactions.length === 0 ? (
<p className="text-muted-foreground text-sm">No transactions yet.</p>
) : (
<Card>
<CardContent className="p-0">
{transactions.map((tx, i) => {
const badge = TX_BADGE[tx.type] || { label: tx.type, variant: 'outline' as const };
const isPositive = tx.amount > 0;
return (
<div key={tx._id}>
{i > 0 && <Separator />}
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<Badge variant={badge.variant}>{badge.label}</Badge>
<div>
<p className="text-sm font-medium">{tx.description}</p>
<p className="text-xs text-muted-foreground">
{new Date(tx.ts).toLocaleString()}
</p>
</div>
</div>
<div className="text-right">
<p className={`font-semibold ${isPositive ? 'text-green-600' : 'text-red-500'}`}>
{isPositive ? '+' : ''}{tx.amount} cr
</p>
<p className="text-xs text-muted-foreground">bal: {tx.balance_after}</p>
</div>
</div>
</div>
);
})}
</CardContent>
</Card>
)}
</div>
</>
)}
</div>
</div>
);
}

View file

@ -1,174 +1,379 @@
import { Link, useNavigate } from 'react-router-dom';
import { Users, MessageSquare, LayoutDashboard, Sparkles } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Users, MessageSquare, LayoutDashboard, Sparkles, ArrowRight, Zap, Brain, Target, BarChart3, Globe } from 'lucide-react';
import Navigation from '@/components/Navigation';
import Hero from '@/components/Hero';
import FeatureCard from '@/components/FeatureCard';
import { useAuth } from '@/contexts/AuthContext';
const SectionLabel = ({ children }: { children: React.ReactNode }) => (
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-[hsl(188_91%_44%/0.25)] bg-[hsl(188_91%_44%/0.04)] mb-5">
<Sparkles className="h-3.5 w-3.5 text-[#06B6D4]" />
<span className="text-sm font-medium text-[#06B6D4]">{children}</span>
</div>
);
const StepCard = ({ step, title, description }: { step: string; title: string; description: string }) => (
<div className="relative flex flex-col items-center text-center">
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center mb-5 font-display font-bold text-xl"
style={{
background: 'linear-gradient(135deg, hsl(188 91% 44% / 0.15), hsl(258 89% 66% / 0.15))',
border: '1px solid hsl(188 91% 44% / 0.2)',
color: '#06B6D4',
}}
>
{step}
</div>
<h3 className="font-display font-semibold text-[hsl(210_40%_92%)] text-lg mb-2">{title}</h3>
<p className="text-sm text-[hsl(215_20%_52%)] leading-relaxed max-w-xs">{description}</p>
</div>
);
const LogoMark = () => (
<svg viewBox="0 0 36 36" fill="none" className="h-8 w-8">
<defs>
<linearGradient id="footer-lg" x1="2" y1="2" x2="34" y2="34" gradientUnits="userSpaceOnUse">
<stop stopColor="#06B6D4" />
<stop offset="1" stopColor="#8B5CF6" />
</linearGradient>
</defs>
<path
d="M28 8C24.8 5.6 20.9 4 16.6 4C8.6 4 2 10.6 2 18.5C2 26.4 8.6 33 16.6 33C20.9 33 24.8 31.4 28 29"
stroke="url(#footer-lg)" strokeWidth="3.5" strokeLinecap="round" fill="none"
/>
<circle cx="28" cy="8" r="2.5" fill="#06B6D4" />
<circle cx="28" cy="29" r="2.5" fill="#8B5CF6" />
</svg>
);
const Index = () => {
const { isAuthenticated } = useAuth();
const navigate = useNavigate();
// Helper function for navigation - let Router handle auth
const handleNavigation = (e: React.MouseEvent<HTMLAnchorElement>) => {
// No special handling - the ProtectedRoute will handle authentication checks
// We don't need to prevent default or redirect manually
};
return (
<div className="min-h-screen overflow-hidden bg-background">
<div className="min-h-screen bg-[hsl(222_47%_4%)] overflow-hidden">
<Navigation />
<main>
<div className="pt-16">
<Hero />
{/* Features Section */}
<section className="py-20 px-6 bg-white">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-16">
<h2 className="text-3xl font-sf font-bold sm:text-4xl">
Why Synthetic Personas?
</h2>
<p className="mt-4 text-lg text-gray-600 max-w-3xl mx-auto">
Our platform combines advanced AI with intuitive design to help researchers
gain deeper insights faster than traditional methods.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<FeatureCard
title="Scalable Research"
description="Create and test with thousands of synthetic personas, each with unique demographic profiles and behaviors."
icon={Users}
/>
<FeatureCard
title="AI-Driven Focus Groups"
description="Run autonomous focus groups moderated by AI that adapts to participant responses in real-time."
icon={MessageSquare}
/>
<FeatureCard
title="Instant Analysis"
description="Generate comprehensive reports and visualizations that highlight key insights and patterns."
icon={LayoutDashboard}
/>
<FeatureCard
title="Diverse Perspectives"
description="Access synthetic personas from various backgrounds, ensuring representation across age, gender, and location."
icon={Users}
/>
<FeatureCard
title="Dynamic Discussions"
description="AI moderators guide conversations naturally, following up on interesting points without bias."
icon={Sparkles}
/>
<FeatureCard
title="Comprehensive Reporting"
description="Export detailed reports with sentiment analysis, key themes, and actionable recommendations."
icon={LayoutDashboard}
/>
</div>
</div>
</section>
{/* How It Works Section */}
<section className="py-20 px-6 bg-gradient-to-b from-white to-slate-50">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-16">
<h2 className="text-3xl font-sf font-bold sm:text-4xl">
How It Works
</h2>
<p className="mt-4 text-lg text-gray-600 max-w-3xl mx-auto">
Just three simple steps to gather valuable insights from synthetic personas.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="text-center p-6">
<div className="rounded-full bg-primary/10 w-16 h-16 flex items-center justify-center mx-auto mb-4">
<span className="text-2xl font-bold text-primary">1</span>
</div>
<h3 className="text-xl font-sf font-semibold mb-3">Create Synthetic Personas</h3>
<p className="text-gray-600">Define your target audience with customizable demographic profiles and personality traits.</p>
</div>
<div className="text-center p-6">
<div className="rounded-full bg-primary/10 w-16 h-16 flex items-center justify-center mx-auto mb-4">
<span className="text-2xl font-bold text-primary">2</span>
</div>
<h3 className="text-xl font-sf font-semibold mb-3">Set Up Focus Groups</h3>
<p className="text-gray-600">Configure your research objectives, topics, and parameters for the AI moderator.</p>
</div>
<div className="text-center p-6">
<div className="rounded-full bg-primary/10 w-16 h-16 flex items-center justify-center mx-auto mb-4">
<span className="text-2xl font-bold text-primary">3</span>
</div>
<h3 className="text-xl font-sf font-semibold mb-3">Analyze Results</h3>
<p className="text-gray-600">Review comprehensive visual reports and actionable insights from your synthetic research.</p>
</div>
</div>
<div className="text-center mt-12">
<Link
to="synthetic-users"
className="inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-primary hover:bg-primary/90 button-transition"
<Hero />
{/* Features */}
<section className="py-24 px-6 relative">
<div
className="glow-orb w-[500px] h-[300px] left-1/2 -translate-x-1/2 top-0 opacity-10"
style={{ background: 'radial-gradient(ellipse, #8B5CF6, transparent 70%)' }}
/>
<div className="max-w-7xl mx-auto relative">
<div className="text-center mb-16">
<SectionLabel>Why Cohorta?</SectionLabel>
<h2 className="font-display font-bold text-4xl sm:text-5xl text-white mb-4">
Everything you need for{' '}
<span
style={{
background: 'linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
Get Started
</Link>
</div>
</div>
</section>
{/* Footer */}
<footer className="bg-white py-12 px-6">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center">
<div className="mb-6 md:mb-0">
<span className="text-xl font-sf font-semibold text-gradient">Semblance</span>
<p className="text-sm text-gray-500 mt-2">AI-powered synthetic persona research</p>
</div>
<div className="flex flex-col md:flex-row gap-8">
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Platform</h3>
<ul className="space-y-2">
<li><Link to="/" className="text-sm text-gray-600 hover:text-primary button-transition">Home</Link></li>
<li><Link to="/synthetic-users" className="text-sm text-gray-600 hover:text-primary button-transition">Synthetic Personas</Link></li>
<li><Link to="/focus-groups" className="text-sm text-gray-600 hover:text-primary button-transition">Focus Groups</Link></li>
<li><Link to="/dashboard" className="text-sm text-gray-600 hover:text-primary button-transition">Dashboard</Link></li>
</ul>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Company</h3>
<ul className="space-y-2">
<li><a href="#" className="text-sm text-gray-600 hover:text-primary button-transition">About</a></li>
<li><a href="#" className="text-sm text-gray-600 hover:text-primary button-transition">Blog</a></li>
<li><a href="#" className="text-sm text-gray-600 hover:text-primary button-transition">Careers</a></li>
<li><a href="#" className="text-sm text-gray-600 hover:text-primary button-transition">Contact</a></li>
</ul>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Legal</h3>
<ul className="space-y-2">
<li><a href="#" className="text-sm text-gray-600 hover:text-primary button-transition">Privacy</a></li>
<li><a href="#" className="text-sm text-gray-600 hover:text-primary button-transition">Terms</a></li>
<li><a href="#" className="text-sm text-gray-600 hover:text-primary button-transition">Security</a></li>
</ul>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto mt-8 pt-8 border-t border-gray-200">
<p className="text-sm text-gray-500 text-center">
© {new Date().getFullYear()} Semblance. All rights reserved.
better research
</span>
</h2>
<p className="text-[hsl(215_20%_55%)] text-lg max-w-2xl mx-auto">
Our platform combines advanced AI with intuitive design to help researchers
gain deeper insights faster than traditional methods.
</p>
</div>
</footer>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<FeatureCard
title="Scalable Personas"
description="Create thousands of synthetic personas with unique demographic profiles, behaviors, and psychographic depth."
icon={Users}
gradient="from-[#06B6D4] to-[#0284C7]"
/>
<FeatureCard
title="AI Focus Groups"
description="Run autonomous focus groups moderated by AI that adapts to participant responses in real-time."
icon={MessageSquare}
gradient="from-[#8B5CF6] to-[#6D28D9]"
/>
<FeatureCard
title="Instant Analysis"
description="Generate comprehensive reports with sentiment analysis, key themes, and actionable recommendations."
icon={LayoutDashboard}
gradient="from-[#06B6D4] to-[#8B5CF6]"
/>
<FeatureCard
title="Deep AI Insights"
description="Claude-powered AI extracts patterns and nuances that traditional focus groups often miss."
icon={Brain}
gradient="from-[#F59E0B] to-[#EF4444]"
/>
<FeatureCard
title="Global Perspectives"
description="Access synthetic personas from 50+ countries — ensuring representation across age, culture, and location."
icon={Globe}
gradient="from-[#10B981] to-[#06B6D4]"
/>
<FeatureCard
title="Precision Targeting"
description="Define hyper-specific audience segments for research that maps exactly to your target market."
icon={Target}
gradient="from-[#8B5CF6] to-[#EC4899]"
/>
</div>
</div>
</section>
{/* How It Works */}
<section className="py-24 px-6 relative">
<div
className="absolute inset-0 opacity-[0.025]"
style={{
backgroundImage:
'linear-gradient(hsl(210 40% 96%) 1px, transparent 1px), linear-gradient(90deg, hsl(210 40% 96%) 1px, transparent 1px)',
backgroundSize: '64px 64px',
}}
/>
<div className="max-w-7xl mx-auto relative">
<div className="text-center mb-16">
<SectionLabel>Simple Process</SectionLabel>
<h2 className="font-display font-bold text-4xl sm:text-5xl text-white mb-4">
From idea to insight in{' '}
<span
style={{
background: 'linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
minutes
</span>
</h2>
<p className="text-[hsl(215_20%_55%)] text-lg max-w-xl mx-auto">
Three simple steps to gather valuable insights from synthetic personas.
</p>
</div>
<div className="relative grid grid-cols-1 md:grid-cols-3 gap-12">
{/* Connector line */}
<div
className="hidden md:block absolute top-7 left-[calc(16.66%+28px)] right-[calc(16.66%+28px)] h-px"
style={{
background: 'linear-gradient(90deg, #06B6D4, #8B5CF6)',
opacity: 0.25,
}}
/>
<StepCard
step="1"
title="Create Synthetic Personas"
description="Define your target audience with customizable demographic profiles and personality traits using AI."
/>
<StepCard
step="2"
title="Set Up Focus Groups"
description="Configure your research objectives, topics, and parameters — the AI moderator handles the rest."
/>
<StepCard
step="3"
title="Analyse Results"
description="Review visual reports with sentiment analysis, key themes, and actionable insights in minutes."
/>
</div>
<div className="text-center mt-14">
<Link
to="/synthetic-users"
className="inline-flex items-center gap-2 px-8 py-4 rounded-xl font-semibold text-white transition-all duration-200"
style={{
background: 'linear-gradient(135deg, #06B6D4 0%, #7C3AED 100%)',
boxShadow: '0 0 28px hsl(188 91% 44% / 0.3)',
}}
onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.boxShadow = '0 0 48px hsl(188 91% 44% / 0.5)';
(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)';
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.boxShadow = '0 0 28px hsl(188 91% 44% / 0.3)';
(e.currentTarget as HTMLElement).style.transform = 'translateY(0)';
}}
>
Get Started Free
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
</section>
{/* CTA Banner */}
<section className="py-20 px-6">
<div className="max-w-5xl mx-auto">
<div
className="relative overflow-hidden rounded-3xl p-12 text-center"
style={{
background: 'linear-gradient(135deg, hsl(188 91% 20% / 0.4) 0%, hsl(258 89% 30% / 0.4) 100%)',
border: '1px solid hsl(188 91% 44% / 0.2)',
}}
>
<div
className="glow-orb w-80 h-80 -top-20 -left-20 opacity-30"
style={{ background: 'radial-gradient(circle, #06B6D4, transparent 60%)' }}
/>
<div
className="glow-orb w-80 h-80 -bottom-20 -right-20 opacity-20"
style={{ background: 'radial-gradient(circle, #8B5CF6, transparent 60%)' }}
/>
<div className="relative">
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/5 border border-white/10 mb-6">
<Zap className="h-3.5 w-3.5 text-amber-400" />
<span className="text-sm text-amber-400 font-medium">Limited time 50 free credits on signup</span>
</div>
<h2 className="font-display font-bold text-4xl sm:text-5xl text-white mb-4">
Ready to transform your research?
</h2>
<p className="text-[hsl(215_20%_65%)] text-lg mb-8 max-w-2xl mx-auto">
Join hundreds of researchers and product teams using Cohorta to make faster,
smarter decisions with AI-powered synthetic insights.
</p>
<div className="flex flex-col sm:flex-row justify-center gap-4">
<Link
to="/register"
className="inline-flex items-center justify-center gap-2 px-8 py-4 rounded-xl font-semibold text-white transition-all duration-200"
style={{ background: 'linear-gradient(135deg, #06B6D4 0%, #7C3AED 100%)' }}
>
Create Free Account
<ArrowRight className="h-4 w-4" />
</Link>
<Link
to="/login"
className="inline-flex items-center justify-center px-8 py-4 rounded-xl font-semibold text-[hsl(210_40%_80%)] border border-[hsl(222_38%_25%)] hover:border-[hsl(188_91%_44%/0.4)] hover:text-white transition-all duration-200"
>
Sign in
</Link>
</div>
</div>
</div>
</div>
</section>
</main>
{/* Footer */}
<footer className="border-t border-[hsl(222_38%_13%)] py-16 px-6">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-4 gap-10 mb-12">
{/* Brand */}
<div className="md:col-span-1">
<Link to="/" className="flex items-center gap-2.5 mb-4">
<LogoMark />
<span
className="font-display font-bold text-xl"
style={{
background: 'linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
Cohorta
</span>
</Link>
<p className="text-sm text-[hsl(215_20%_45%)] leading-relaxed mb-4">
AI-powered synthetic persona research platform. Faster insights, better decisions.
</p>
<p className="text-xs text-[hsl(215_20%_35%)]">Powered by AImpress LTD</p>
</div>
{/* Links */}
<div>
<h4 className="font-display font-semibold text-[hsl(210_40%_80%)] text-sm mb-4 uppercase tracking-wider">
Platform
</h4>
<ul className="space-y-2.5">
{[
{ label: 'Synthetic Personas', to: '/synthetic-users' },
{ label: 'Focus Groups', to: '/focus-groups' },
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Billing', to: '/billing' },
].map((item) => (
<li key={item.label}>
<Link
to={item.to}
className="text-sm text-[hsl(215_20%_48%)] hover:text-[#06B6D4] transition-colors duration-200"
>
{item.label}
</Link>
</li>
))}
</ul>
</div>
<div>
<h4 className="font-display font-semibold text-[hsl(210_40%_80%)] text-sm mb-4 uppercase tracking-wider">
Company
</h4>
<ul className="space-y-2.5">
{[
{ label: 'About AImpress', href: 'https://ai-impress.com' },
{ label: 'Contact', href: '#' },
{ label: 'Blog', href: '#' },
{ label: 'Careers', href: '#' },
].map((item) => (
<li key={item.label}>
<a
href={item.href}
target={item.href.startsWith('http') ? '_blank' : undefined}
rel={item.href.startsWith('http') ? 'noopener noreferrer' : undefined}
className="text-sm text-[hsl(215_20%_48%)] hover:text-[#06B6D4] transition-colors duration-200"
>
{item.label}
</a>
</li>
))}
</ul>
</div>
<div>
<h4 className="font-display font-semibold text-[hsl(210_40%_80%)] text-sm mb-4 uppercase tracking-wider">
Legal
</h4>
<ul className="space-y-2.5">
{['Privacy Policy', 'Terms of Service', 'Cookie Policy', 'Security'].map((item) => (
<li key={item}>
<a
href="#"
className="text-sm text-[hsl(215_20%_48%)] hover:text-[#06B6D4] transition-colors duration-200"
>
{item}
</a>
</li>
))}
</ul>
</div>
</div>
{/* Bottom bar */}
<div className="pt-8 border-t border-[hsl(222_38%_12%)] flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-sm text-[hsl(215_20%_38%)]">
© {new Date().getFullYear()} AImpress LTD. All rights reserved.
</p>
<div className="flex items-center gap-1 text-sm text-[hsl(215_20%_38%)]">
<span>Cohorta is a product of</span>
<a
href="https://ai-impress.com"
target="_blank"
rel="noopener noreferrer"
className="font-semibold hover:text-[#06B6D4] transition-colors ml-1"
style={{
background: 'linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
AImpress LTD
</a>
</div>
</div>
</div>
</footer>
</div>
);
};

View file

@ -1,224 +1,227 @@
import { useState, useEffect } from 'react';
import { useNavigate, Link, useLocation } from 'react-router-dom';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toastService } from '@/lib/toast';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { authApi } from '@/lib/api';
import { useAuth } from '@/contexts/AuthContext';
import { Loader2 } from 'lucide-react';
import { Loader2, Eye, EyeOff } from 'lucide-react';
const loginSchema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"),
password: z.string().min(4, "Password must be at least 4 characters"),
username: z.string().min(3, 'Username must be at least 3 characters'),
password: z.string().min(4, 'Password must be at least 4 characters'),
});
type LoginFormValues = z.infer<typeof loginSchema>;
const LogoMark = () => (
<svg viewBox="0 0 36 36" fill="none" className="h-9 w-9">
<defs>
<linearGradient id="login-lg" x1="2" y1="2" x2="34" y2="34" gradientUnits="userSpaceOnUse">
<stop stopColor="#06B6D4" />
<stop offset="1" stopColor="#8B5CF6" />
</linearGradient>
</defs>
<path
d="M28 8C24.8 5.6 20.9 4 16.6 4C8.6 4 2 10.6 2 18.5C2 26.4 8.6 33 16.6 33C20.9 33 24.8 31.4 28 29"
stroke="url(#login-lg)" strokeWidth="3.5" strokeLinecap="round" fill="none"
/>
<circle cx="28" cy="8" r="2.5" fill="#06B6D4" />
<circle cx="28" cy="29" r="2.5" fill="#8B5CF6" />
</svg>
);
export default function Login() {
const navigate = useNavigate();
const location = useLocation();
const { login, loginWithMicrosoft, isAuthenticated, isMsalLoading } = useAuth();
const { login, isAuthenticated } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// Check if local login is enabled (defaults to true for backwards compatibility)
const enableLocalLogin = import.meta.env.VITE_ENABLE_LOCAL_LOGIN !== 'false';
// Get the intended destination from state, or default to home page
const from = location.state?.from || '/';
// Redirect if already logged in
useEffect(() => {
if (isAuthenticated) {
navigate('/', { replace: true });
}
if (isAuthenticated) navigate('/', { replace: true });
}, [isAuthenticated, navigate]);
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
username: '',
password: '',
},
defaultValues: { username: '', password: '' },
});
async function onSubmit(values: LoginFormValues) {
setIsLoading(true);
try {
// Use the login function from auth context
const token = await login(values.username, values.password);
if (token) {
// Use React Router navigation to preserve state
navigate(from, { replace: true });
} else {
console.error('Login succeeded but no token received');
setIsLoading(false);
}
} catch (error: unknown) {
// Error handling is done in login function already
console.error('Login error in form handler:', error);
if (token) navigate(from, { replace: true });
} catch {
// error handled in login()
} finally {
setIsLoading(false);
}
}
async function handleMicrosoftLogin() {
try {
await loginWithMicrosoft();
// loginRedirect navigates the page away — no navigate() call needed
} catch (error: unknown) {
console.error('Microsoft login error in form handler:', error);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-900 dark:to-gray-800 px-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">Sign In</CardTitle>
<CardDescription className="text-center">
Enter your credentials to access your account
</CardDescription>
</CardHeader>
<CardContent>
{/* Microsoft Sign-In Button */}
<div className="mb-6">
<Button
type="button"
variant="outline"
className="w-full bg-[#0078d4] hover:bg-[#106ebe] text-white border-[#0078d4] hover:border-[#106ebe]"
onClick={handleMicrosoftLogin}
disabled={isLoading || isMsalLoading}
>
{isMsalLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in with Microsoft...
</>
) : (
<>
<svg className="mr-2 h-4 w-4" viewBox="0 0 21 21" fill="currentColor">
<path d="M10 0H0v10h10V0z" />
<path d="M21 0H11v10h10V0z" />
<path d="M10 11H0v10h10V11z" />
<path d="M21 11H11v10h10V11z" />
</svg>
Sign in with Microsoft
</>
)}
</Button>
<div
className="min-h-screen flex items-center justify-center px-4 relative overflow-hidden"
style={{ background: 'hsl(222 47% 4%)' }}
>
{/* Background orbs */}
<div
className="absolute w-[500px] h-[500px] -top-32 -left-32 rounded-full opacity-15 pointer-events-none"
style={{ background: 'radial-gradient(circle, #06B6D4, transparent 65%)', filter: 'blur(64px)' }}
/>
<div
className="absolute w-[500px] h-[500px] -bottom-32 -right-32 rounded-full opacity-10 pointer-events-none"
style={{ background: 'radial-gradient(circle, #8B5CF6, transparent 65%)', filter: 'blur(64px)' }}
/>
{/* Grid overlay */}
<div
className="absolute inset-0 opacity-[0.03] pointer-events-none"
style={{
backgroundImage:
'linear-gradient(hsl(210 40% 96%) 1px, transparent 1px), linear-gradient(90deg, hsl(210 40% 96%) 1px, transparent 1px)',
backgroundSize: '48px 48px',
}}
/>
<div className="relative w-full max-w-md">
{/* Card */}
<div
className="rounded-2xl p-8"
style={{
background: 'hsl(222 45% 7%)',
border: '1px solid hsl(222 38% 16%)',
boxShadow: '0 24px 64px hsl(222 47% 2% / 0.7)',
}}
>
{/* Logo */}
<div className="flex flex-col items-center mb-8">
<Link to="/" className="flex items-center gap-2.5 mb-1">
<LogoMark />
<span
className="font-display font-bold text-2xl"
style={{
background: 'linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
Cohorta
</span>
</Link>
<h1 className="font-display font-bold text-xl text-white mt-4 mb-1">Welcome back</h1>
<p className="text-sm text-[hsl(215_20%_50%)]">Sign in to your account</p>
</div>
{enableLocalLogin && (
<>
{/* Divider */}
<div className="relative mb-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-2 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
Or continue with username
</span>
</div>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel className="text-[hsl(210_40%_75%)] text-sm font-medium">Username</FormLabel>
<FormControl>
<Input
placeholder="your_username"
{...field}
disabled={isLoading}
autoComplete="username"
className="h-11 bg-[hsl(222_38%_11%)] border-[hsl(222_38%_18%)] text-white placeholder:text-[hsl(215_20%_35%)] focus:border-[#06B6D4] focus:ring-1 focus:ring-[#06B6D4] transition-colors"
/>
</FormControl>
<FormMessage className="text-red-400 text-xs" />
</FormItem>
)}
/>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input
placeholder="Enter your username"
{...field}
disabled={isLoading}
autoComplete="username"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-[hsl(210_40%_75%)] text-sm font-medium">Password</FormLabel>
<FormControl>
<div className="relative">
<Input
placeholder="••••••••"
type={showPassword ? 'text' : 'password'}
{...field}
disabled={isLoading}
autoComplete="current-password"
className="h-11 bg-[hsl(222_38%_11%)] border-[hsl(222_38%_18%)] text-white placeholder:text-[hsl(215_20%_35%)] focus:border-[#06B6D4] focus:ring-1 focus:ring-[#06B6D4] transition-colors pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[hsl(215_20%_45%)] hover:text-[hsl(215_20%_70%)] transition-colors"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</FormControl>
<FormMessage className="text-red-400 text-xs" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
placeholder="Enter your password"
type="password"
{...field}
disabled={isLoading}
autoComplete="current-password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isLoading || isMsalLoading}>
{isLoading ? "Signing in..." : "Sign In"}
</Button>
</form>
</Form>
</>
)}
</CardContent>
<CardFooter className="flex flex-col space-y-2">
{enableLocalLogin && (
<div className="text-sm text-center text-gray-500 mb-2">
Default account: user / pass
</div>
)}
{!isLoading && !isMsalLoading && (
<div className="flex flex-col items-center justify-center gap-2">
<Button
variant="outline"
onClick={() => navigate('/', { replace: true })}
className="mt-2"
>
Return to Home
</Button>
<Button
variant="link"
onClick={() => {
// Set offline mode flag
localStorage.setItem('offline_mode', 'true');
// Create a mock user and token
const mockUser = { username: 'guest', email: 'guest@example.com', role: 'user' };
const mockToken = 'offline-mode-token';
localStorage.setItem('auth_token', mockToken);
localStorage.setItem('user', JSON.stringify(mockUser));
toastService.success('Offline mode activated', {
description: 'Using demo account with limited functionality'
});
// Navigate to home
navigate('/', { replace: true });
<Button
type="submit"
disabled={isLoading}
className="w-full h-11 font-semibold text-white border-none mt-2"
style={{
background: isLoading ? 'hsl(222 38% 15%)' : 'linear-gradient(135deg, #06B6D4 0%, #7C3AED 100%)',
boxShadow: isLoading ? 'none' : '0 0 20px hsl(188 91% 44% / 0.25)',
transition: 'all 0.2s ease',
}}
className="text-sm text-gray-500"
>
Use offline mode
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
'Sign In'
)}
</Button>
</div>
)}
</CardFooter>
</Card>
</form>
</Form>
<p className="text-center text-sm text-[hsl(215_20%_45%)] mt-6">
Don't have an account?{' '}
<Link
to="/register"
className="font-semibold hover:underline"
style={{
background: 'linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
Create one free
</Link>
</p>
</div>
{/* Footer note */}
<p className="text-center text-xs text-[hsl(215_20%_32%)] mt-6">
A product of{' '}
<a
href="https://ai-impress.com"
target="_blank"
rel="noopener noreferrer"
className="text-[hsl(215_20%_45%)] hover:text-[#06B6D4] transition-colors"
>
AImpress LTD
</a>
</p>
</div>
</div>
);
}
}

369
src/pages/Register.tsx Normal file
View file

@ -0,0 +1,369 @@
import { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { useAuth } from '@/contexts/AuthContext';
import { Loader2, Eye, EyeOff, CheckCircle2, Mail } from 'lucide-react';
import { toastService } from '@/lib/toast';
import axios from 'axios';
const registerSchema = z.object({
username: z.string().min(3, 'At least 3 characters').max(30, 'Max 30 characters').regex(/^[a-zA-Z0-9_]+$/, 'Letters, numbers, underscores only'),
email: z.string().email('Invalid email address'),
password: z.string().min(6, 'At least 6 characters'),
confirmPassword: z.string(),
}).refine(d => d.password === d.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type RegisterFormValues = z.infer<typeof registerSchema>;
const LogoMark = () => (
<svg viewBox="0 0 36 36" fill="none" className="h-9 w-9">
<defs>
<linearGradient id="reg-lg" x1="2" y1="2" x2="34" y2="34" gradientUnits="userSpaceOnUse">
<stop stopColor="#06B6D4" />
<stop offset="1" stopColor="#8B5CF6" />
</linearGradient>
</defs>
<path
d="M28 8C24.8 5.6 20.9 4 16.6 4C8.6 4 2 10.6 2 18.5C2 26.4 8.6 33 16.6 33C20.9 33 24.8 31.4 28 29"
stroke="url(#reg-lg)" strokeWidth="3.5" strokeLinecap="round" fill="none"
/>
<circle cx="28" cy="8" r="2.5" fill="#06B6D4" />
<circle cx="28" cy="29" r="2.5" fill="#8B5CF6" />
</svg>
);
export default function Register() {
const navigate = useNavigate();
const { isAuthenticated, login } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [registered, setRegistered] = useState(false);
const [registeredEmail, setRegisteredEmail] = useState('');
useEffect(() => {
if (isAuthenticated) navigate('/', { replace: true });
}, [isAuthenticated, navigate]);
const form = useForm<RegisterFormValues>({
resolver: zodResolver(registerSchema),
defaultValues: { username: '', email: '', password: '', confirmPassword: '' },
});
async function onSubmit(values: RegisterFormValues) {
setIsLoading(true);
try {
const res = await axios.post('/api/auth/register', {
username: values.username,
email: values.email,
password: values.password,
});
setRegisteredEmail(values.email);
setRegistered(true);
// Auto-login after registration
if (res.data.access_token) {
localStorage.setItem('auth_token', res.data.access_token);
toastService.success('Account created!', { description: 'Check your email to verify your account.' });
}
} catch (err: any) {
const msg = err.response?.data?.message || 'Registration failed. Please try again.';
toastService.error(msg);
} finally {
setIsLoading(false);
}
}
if (registered) {
return (
<div
className="min-h-screen flex items-center justify-center px-4 relative overflow-hidden"
style={{ background: 'hsl(222 47% 4%)' }}
>
<div
className="absolute w-[500px] h-[500px] -top-32 -left-32 rounded-full opacity-15 pointer-events-none"
style={{ background: 'radial-gradient(circle, #06B6D4, transparent 65%)', filter: 'blur(64px)' }}
/>
<div className="relative w-full max-w-md">
<div
className="rounded-2xl p-10 text-center"
style={{
background: 'hsl(222 45% 7%)',
border: '1px solid hsl(222 38% 16%)',
boxShadow: '0 24px 64px hsl(222 47% 2% / 0.7)',
}}
>
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-6"
style={{ background: 'linear-gradient(135deg, hsl(188 91% 44% / 0.15), hsl(258 89% 66% / 0.15))', border: '1px solid hsl(188 91% 44% / 0.2)' }}
>
<Mail className="h-8 w-8 text-[#06B6D4]" />
</div>
<h1 className="font-display font-bold text-2xl text-white mb-3">Check your inbox</h1>
<p className="text-[hsl(215_20%_55%)] leading-relaxed mb-2">
We sent a verification link to
</p>
<p className="font-semibold text-[#06B6D4] mb-6 break-all">{registeredEmail}</p>
<p className="text-sm text-[hsl(215_20%_45%)] mb-8">
Click the link in the email to verify your account. The link expires in 24 hours.
</p>
<div className="flex flex-col gap-3">
<button
onClick={() => navigate('/dashboard')}
className="w-full py-3 rounded-xl font-semibold text-white"
style={{ background: 'linear-gradient(135deg, #06B6D4 0%, #7C3AED 100%)' }}
>
Continue to Dashboard
</button>
<p className="text-xs text-[hsl(215_20%_38%)]">
Didn't receive it?{' '}
<button
onClick={async () => {
try {
await axios.post('/api/auth/resend-verification', { email: registeredEmail });
toastService.success('Verification email resent');
} catch {
toastService.error('Could not resend. Please try again later.');
}
}}
className="text-[#06B6D4] hover:underline"
>
Resend email
</button>
</p>
</div>
</div>
</div>
</div>
);
}
return (
<div
className="min-h-screen flex items-center justify-center px-4 py-12 relative overflow-hidden"
style={{ background: 'hsl(222 47% 4%)' }}
>
<div
className="absolute w-[500px] h-[500px] -top-32 -left-32 rounded-full opacity-15 pointer-events-none"
style={{ background: 'radial-gradient(circle, #06B6D4, transparent 65%)', filter: 'blur(64px)' }}
/>
<div
className="absolute w-[500px] h-[500px] -bottom-32 -right-32 rounded-full opacity-10 pointer-events-none"
style={{ background: 'radial-gradient(circle, #8B5CF6, transparent 65%)', filter: 'blur(64px)' }}
/>
<div
className="absolute inset-0 opacity-[0.03] pointer-events-none"
style={{
backgroundImage:
'linear-gradient(hsl(210 40% 96%) 1px, transparent 1px), linear-gradient(90deg, hsl(210 40% 96%) 1px, transparent 1px)',
backgroundSize: '48px 48px',
}}
/>
<div className="relative w-full max-w-md">
<div
className="rounded-2xl p-8"
style={{
background: 'hsl(222 45% 7%)',
border: '1px solid hsl(222 38% 16%)',
boxShadow: '0 24px 64px hsl(222 47% 2% / 0.7)',
}}
>
{/* Logo */}
<div className="flex flex-col items-center mb-8">
<Link to="/" className="flex items-center gap-2.5 mb-1">
<LogoMark />
<span
className="font-display font-bold text-2xl"
style={{
background: 'linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
Cohorta
</span>
</Link>
<h1 className="font-display font-bold text-xl text-white mt-4 mb-1">Create your account</h1>
<p className="text-sm text-[hsl(215_20%_50%)]">Get started with 50 free credits</p>
</div>
{/* Benefits row */}
<div className="flex justify-center gap-6 mb-7">
{['50 free credits', 'No credit card', 'Cancel anytime'].map(b => (
<div key={b} className="flex items-center gap-1.5 text-xs text-[hsl(215_20%_55%)]">
<CheckCircle2 className="h-3.5 w-3.5 text-[#06B6D4] flex-shrink-0" />
{b}
</div>
))}
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel className="text-[hsl(210_40%_75%)] text-sm font-medium">Username</FormLabel>
<FormControl>
<Input
placeholder="john_doe"
{...field}
disabled={isLoading}
autoComplete="username"
className="h-11 bg-[hsl(222_38%_11%)] border-[hsl(222_38%_18%)] text-white placeholder:text-[hsl(215_20%_35%)] focus:border-[#06B6D4] focus:ring-1 focus:ring-[#06B6D4] transition-colors"
/>
</FormControl>
<FormMessage className="text-red-400 text-xs" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-[hsl(210_40%_75%)] text-sm font-medium">Email address</FormLabel>
<FormControl>
<Input
placeholder="you@company.com"
type="email"
{...field}
disabled={isLoading}
autoComplete="email"
className="h-11 bg-[hsl(222_38%_11%)] border-[hsl(222_38%_18%)] text-white placeholder:text-[hsl(215_20%_35%)] focus:border-[#06B6D4] focus:ring-1 focus:ring-[#06B6D4] transition-colors"
/>
</FormControl>
<FormMessage className="text-red-400 text-xs" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-[hsl(210_40%_75%)] text-sm font-medium">Password</FormLabel>
<FormControl>
<div className="relative">
<Input
placeholder="Min. 6 characters"
type={showPassword ? 'text' : 'password'}
{...field}
disabled={isLoading}
autoComplete="new-password"
className="h-11 bg-[hsl(222_38%_11%)] border-[hsl(222_38%_18%)] text-white placeholder:text-[hsl(215_20%_35%)] focus:border-[#06B6D4] focus:ring-1 focus:ring-[#06B6D4] transition-colors pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[hsl(215_20%_45%)] hover:text-[hsl(215_20%_70%)] transition-colors"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</FormControl>
<FormMessage className="text-red-400 text-xs" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel className="text-[hsl(210_40%_75%)] text-sm font-medium">Confirm password</FormLabel>
<FormControl>
<div className="relative">
<Input
placeholder="Repeat password"
type={showConfirm ? 'text' : 'password'}
{...field}
disabled={isLoading}
autoComplete="new-password"
className="h-11 bg-[hsl(222_38%_11%)] border-[hsl(222_38%_18%)] text-white placeholder:text-[hsl(215_20%_35%)] focus:border-[#06B6D4] focus:ring-1 focus:ring-[#06B6D4] transition-colors pr-10"
/>
<button
type="button"
onClick={() => setShowConfirm(!showConfirm)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[hsl(215_20%_45%)] hover:text-[hsl(215_20%_70%)] transition-colors"
>
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</FormControl>
<FormMessage className="text-red-400 text-xs" />
</FormItem>
)}
/>
<p className="text-xs text-[hsl(215_20%_40%)]">
By creating an account, you agree to our{' '}
<a href="#" className="text-[hsl(215_20%_55%)] hover:text-[#06B6D4] transition-colors">Terms of Service</a>
{' '}and{' '}
<a href="#" className="text-[hsl(215_20%_55%)] hover:text-[#06B6D4] transition-colors">Privacy Policy</a>.
</p>
<Button
type="submit"
disabled={isLoading}
className="w-full h-11 font-semibold text-white border-none"
style={{
background: isLoading ? 'hsl(222 38% 15%)' : 'linear-gradient(135deg, #06B6D4 0%, #7C3AED 100%)',
boxShadow: isLoading ? 'none' : '0 0 20px hsl(188 91% 44% / 0.25)',
}}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating account...
</>
) : (
'Create Free Account'
)}
</Button>
</form>
</Form>
<p className="text-center text-sm text-[hsl(215_20%_45%)] mt-6">
Already have an account?{' '}
<Link
to="/login"
className="font-semibold hover:underline"
style={{
background: 'linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
Sign in
</Link>
</p>
</div>
<p className="text-center text-xs text-[hsl(215_20%_32%)] mt-6">
A product of{' '}
<a href="https://ai-impress.com" target="_blank" rel="noopener noreferrer"
className="text-[hsl(215_20%_45%)] hover:text-[#06B6D4] transition-colors">
AImpress LTD
</a>
</p>
</div>
</div>
);
}

158
src/pages/VerifyEmail.tsx Normal file
View file

@ -0,0 +1,158 @@
import { useState, useEffect } from 'react';
import { useSearchParams, Link, useNavigate } from 'react-router-dom';
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react';
import axios from 'axios';
const LogoMark = () => (
<svg viewBox="0 0 36 36" fill="none" className="h-8 w-8">
<defs>
<linearGradient id="ve-lg" x1="2" y1="2" x2="34" y2="34" gradientUnits="userSpaceOnUse">
<stop stopColor="#06B6D4" />
<stop offset="1" stopColor="#8B5CF6" />
</linearGradient>
</defs>
<path
d="M28 8C24.8 5.6 20.9 4 16.6 4C8.6 4 2 10.6 2 18.5C2 26.4 8.6 33 16.6 33C20.9 33 24.8 31.4 28 29"
stroke="url(#ve-lg)" strokeWidth="3.5" strokeLinecap="round" fill="none"
/>
<circle cx="28" cy="8" r="2.5" fill="#06B6D4" />
<circle cx="28" cy="29" r="2.5" fill="#8B5CF6" />
</svg>
);
type Status = 'verifying' | 'success' | 'error';
export default function VerifyEmail() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [status, setStatus] = useState<Status>('verifying');
const [message, setMessage] = useState('');
useEffect(() => {
const token = searchParams.get('token');
if (!token) {
setStatus('error');
setMessage('No verification token found in the link. Please use the link from your email.');
return;
}
axios.post('/api/auth/verify-email', { token })
.then(res => {
setStatus('success');
setMessage(res.data.message || 'Email verified successfully!');
// Auto-redirect after 3s
setTimeout(() => navigate('/dashboard'), 3000);
})
.catch(err => {
setStatus('error');
setMessage(err.response?.data?.message || 'Verification failed. Please try again.');
});
}, []);
return (
<div
className="min-h-screen flex items-center justify-center px-4 relative overflow-hidden"
style={{ background: 'hsl(222 47% 4%)' }}
>
<div
className="absolute w-[500px] h-[500px] -top-32 -left-32 rounded-full opacity-15 pointer-events-none"
style={{ background: 'radial-gradient(circle, #06B6D4, transparent 65%)', filter: 'blur(64px)' }}
/>
<div
className="absolute w-[400px] h-[400px] -bottom-24 -right-24 rounded-full opacity-10 pointer-events-none"
style={{ background: 'radial-gradient(circle, #8B5CF6, transparent 65%)', filter: 'blur(64px)' }}
/>
<div className="relative w-full max-w-md">
<div
className="rounded-2xl p-10 text-center"
style={{
background: 'hsl(222 45% 7%)',
border: '1px solid hsl(222 38% 16%)',
boxShadow: '0 24px 64px hsl(222 47% 2% / 0.7)',
}}
>
{/* Logo */}
<Link to="/" className="inline-flex items-center gap-2 mb-8">
<LogoMark />
<span
className="font-display font-bold text-xl"
style={{
background: 'linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
Cohorta
</span>
</Link>
{status === 'verifying' && (
<>
<div className="w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-6"
style={{ background: 'hsl(188 91% 44% / 0.1)', border: '1px solid hsl(188 91% 44% / 0.2)' }}>
<Loader2 className="h-8 w-8 text-[#06B6D4] animate-spin" />
</div>
<h1 className="font-display font-bold text-2xl text-white mb-3">Verifying your email</h1>
<p className="text-[hsl(215_20%_52%)]">Please wait a moment</p>
</>
)}
{status === 'success' && (
<>
<div className="w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-6"
style={{ background: 'hsl(142 76% 36% / 0.1)', border: '1px solid hsl(142 76% 36% / 0.25)' }}>
<CheckCircle2 className="h-8 w-8 text-green-400" />
</div>
<h1 className="font-display font-bold text-2xl text-white mb-3">Email verified!</h1>
<p className="text-[hsl(215_20%_55%)] mb-8">{message}</p>
<p className="text-sm text-[hsl(215_20%_42%)] mb-6">Redirecting to dashboard in a moment</p>
<Link
to="/dashboard"
className="inline-flex items-center justify-center px-8 py-3.5 rounded-xl font-semibold text-white"
style={{ background: 'linear-gradient(135deg, #06B6D4 0%, #7C3AED 100%)' }}
>
Go to Dashboard
</Link>
</>
)}
{status === 'error' && (
<>
<div className="w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-6"
style={{ background: 'hsl(0 84% 60% / 0.1)', border: '1px solid hsl(0 84% 60% / 0.25)' }}>
<XCircle className="h-8 w-8 text-red-400" />
</div>
<h1 className="font-display font-bold text-2xl text-white mb-3">Verification failed</h1>
<p className="text-[hsl(215_20%_55%)] mb-8">{message}</p>
<div className="flex flex-col gap-3">
<Link
to="/login"
className="inline-flex items-center justify-center px-8 py-3.5 rounded-xl font-semibold text-white"
style={{ background: 'linear-gradient(135deg, #06B6D4 0%, #7C3AED 100%)' }}
>
Back to Sign In
</Link>
<Link
to="/"
className="text-sm text-[hsl(215_20%_45%)] hover:text-[#06B6D4] transition-colors"
>
Go to homepage
</Link>
</div>
</>
)}
</div>
<p className="text-center text-xs text-[hsl(215_20%_32%)] mt-6">
A product of{' '}
<a href="https://ai-impress.com" target="_blank" rel="noopener noreferrer"
className="text-[hsl(215_20%_45%)] hover:text-[#06B6D4] transition-colors">
AImpress LTD
</a>
</p>
</div>
</div>
);
}

View file

@ -88,17 +88,25 @@ export default {
to: { opacity: '0' }
},
'slide-up': {
from: { transform: 'translateY(10px)', opacity: '0' },
from: { transform: 'translateY(16px)', opacity: '0' },
to: { transform: 'translateY(0)', opacity: '1' }
},
'slide-down': {
from: { transform: 'translateY(-10px)', opacity: '0' },
from: { transform: 'translateY(-16px)', opacity: '0' },
to: { transform: 'translateY(0)', opacity: '1' }
},
'float': {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-5px)' }
}
'50%': { transform: 'translateY(-8px)' }
},
'pulse-glow': {
'0%, 100%': { opacity: '0.4', transform: 'scale(1)' },
'50%': { opacity: '0.7', transform: 'scale(1.05)' }
},
'spin-slow': {
from: { transform: 'rotate(0deg)' },
to: { transform: 'rotate(360deg)' }
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
@ -107,11 +115,14 @@ export default {
'fade-out': 'fade-out 0.5s ease-out',
'slide-up': 'slide-up 0.4s ease-out',
'slide-down': 'slide-down 0.4s ease-out',
'float': 'float 3s ease-in-out infinite'
'float': 'float 4s ease-in-out infinite',
'pulse-glow': 'pulse-glow 4s ease-in-out infinite',
'spin-slow': 'spin-slow 20s linear infinite',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
sf: ['"SF Pro Display"', 'system-ui', 'sans-serif']
sf: ['Syne', 'Inter', 'system-ui', 'sans-serif'],
display: ['Syne', 'system-ui', 'sans-serif'],
},
transitionProperty: {
'height': 'height',