Rebrand to Cohorta + full UI redesign + registration with email verification
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
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:
parent
7b6a7c7347
commit
5491d2d73d
26 changed files with 2992 additions and 1011 deletions
45
.gitea/workflows/deploy.yml
Normal file
45
.gitea/workflows/deploy.yml
Normal 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
15
Dockerfile.frontend
Normal 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
|
||||
55
backend/app/models/app_settings.py
Normal file
55
backend/app/models/app_settings.py
Normal 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()
|
||||
58
backend/app/models/credit_transaction.py
Normal file
58
backend/app/models/credit_transaction.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
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():
|
||||
|
|
@ -14,59 +23,168 @@ async def register():
|
|||
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')
|
||||
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
|
||||
|
||||
# Check if user already exists
|
||||
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'):
|
||||
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
|
||||
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
|
||||
# Find user in database
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
116
backend/app/routes/billing.py
Normal file
116
backend/app/routes/billing.py
Normal 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
|
||||
108
backend/app/services/email_service.py
Normal file
108
backend/app/services/email_service.py
Normal 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 · 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,
|
||||
)
|
||||
60
backend/app/services/stripe_service.py
Normal file
60
backend/app/services/stripe_service.py
Normal 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
67
deploy-cohorta.sh
Executable 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"
|
||||
|
|
@ -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
35
nginx.conf
Normal 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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 |
70
src/App.tsx
70
src/App.tsx
|
|
@ -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,7 +29,6 @@ const queryClient = new QueryClient();
|
|||
const App = () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||
<MsalProvider>
|
||||
<AuthProvider>
|
||||
<WebSocketProvider>
|
||||
<NavigationProvider>
|
||||
|
|
@ -37,66 +37,24 @@ const App = () => (
|
|||
<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="/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>
|
||||
<MyUsage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
<Route path="/billing" element={<ProtectedRoute><Billing /></ProtectedRoute>} />
|
||||
<Route path="/usage" element={<ProtectedRoute><MyUsage /></ProtectedRoute>} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</TooltipProvider>
|
||||
</NavigationProvider>
|
||||
</WebSocketProvider>
|
||||
</AuthProvider>
|
||||
</MsalProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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-sf text-lg font-semibold mb-2">{title}</h3>
|
||||
<p className="text-gray-600 text-sm">{description}</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%)' }} />
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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 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="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 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="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>
|
||||
|
||||
<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="text-sm font-semibold leading-6 text-gray-900 hover:text-primary button-transition">
|
||||
Set up focus groups <span aria-hidden="true">→</span>
|
||||
<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>
|
||||
</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."}
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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%)' }} />
|
||||
{/* 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>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,257 +1,274 @@
|
|||
|
||||
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');
|
||||
{/* 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',
|
||||
}}
|
||||
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
|
||||
Cohorta
|
||||
</span>
|
||||
</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}
|
||||
>
|
||||
<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">
|
||||
{/* 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>
|
||||
))}
|
||||
|
||||
{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>
|
||||
))}
|
||||
|
||||
{/* Admin link — mobile */}
|
||||
{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 */}
|
||||
<div className="pt-2 border-t border-[hsl(222_38%_16%)]">
|
||||
{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"
|
||||
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="mr-3 h-5 w-5" />
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<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"
|
||||
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="mr-3 h-5 w-5" />
|
||||
Login
|
||||
<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>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
113
src/components/admin/AnalyticsTab.tsx
Normal file
113
src/components/admin/AnalyticsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
src/components/admin/CreditSettingsTab.tsx
Normal file
130
src/components/admin/CreditSettingsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
267
src/index.css
267
src/index.css
|
|
@ -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,98 +56,118 @@
|
|||
}
|
||||
|
||||
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;
|
||||
background: linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hover-transition {
|
||||
@apply transition-all duration-300 ease-in-out;
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.button-transition {
|
||||
@apply transition-all duration-200 ease-out;
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* Sidebar icon styles */
|
||||
.sidebar-icon {
|
||||
@apply h-4 w-4 text-slate-500 mr-3 mt-0.5 flex-shrink-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);
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
@apply flex items-start;
|
||||
/* Animated glow orb */
|
||||
.glow-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidebar-sub-item {
|
||||
@apply text-sm text-muted-foreground;
|
||||
}
|
||||
|
||||
|
||||
/* Persona card overlay styles */
|
||||
/* Persona card */
|
||||
.persona-card {
|
||||
@apply relative overflow-hidden;
|
||||
min-height: 360px;
|
||||
|
|
@ -194,19 +178,44 @@
|
|||
}
|
||||
|
||||
.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
219
src/pages/Billing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,175 +1,380 @@
|
|||
|
||||
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">
|
||||
{/* 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">
|
||||
<h2 className="text-3xl font-sf font-bold sm:text-4xl">
|
||||
Why Synthetic Personas?
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
better research
|
||||
</span>
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-gray-600 max-w-3xl mx-auto">
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<FeatureCard
|
||||
title="Scalable Research"
|
||||
description="Create and test with thousands of synthetic personas, each with unique demographic profiles and behaviors."
|
||||
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-Driven Focus Groups"
|
||||
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 and visualizations that highlight key insights and patterns."
|
||||
description="Generate comprehensive reports with sentiment analysis, key themes, and actionable recommendations."
|
||||
icon={LayoutDashboard}
|
||||
gradient="from-[#06B6D4] to-[#8B5CF6]"
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Diverse Perspectives"
|
||||
description="Access synthetic personas from various backgrounds, ensuring representation across age, gender, and location."
|
||||
icon={Users}
|
||||
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="Dynamic Discussions"
|
||||
description="AI moderators guide conversations naturally, following up on interesting points without bias."
|
||||
icon={Sparkles}
|
||||
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="Comprehensive Reporting"
|
||||
description="Export detailed reports with sentiment analysis, key themes, and actionable recommendations."
|
||||
icon={LayoutDashboard}
|
||||
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 */}
|
||||
<section className="py-20 px-6 bg-gradient-to-b from-white to-slate-50">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* 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">
|
||||
<h2 className="text-3xl font-sf font-bold sm:text-4xl">
|
||||
How It Works
|
||||
<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="mt-4 text-lg text-gray-600 max-w-3xl mx-auto">
|
||||
Just three simple steps to gather valuable insights from synthetic personas.
|
||||
<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="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 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 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">
|
||||
<div className="text-center mt-14">
|
||||
<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"
|
||||
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
|
||||
Get Started Free
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</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>
|
||||
{/* 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>
|
||||
|
||||
<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.
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,132 +1,121 @@
|
|||
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');
|
||||
if (token) navigate(from, { replace: true });
|
||||
} catch {
|
||||
// error handled in login()
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Error handling is done in login function already
|
||||
console.error('Login error in form handler:', error);
|
||||
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}
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4 relative overflow-hidden"
|
||||
style={{ background: 'hsl(222 47% 4%)' }}
|
||||
>
|
||||
{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>
|
||||
{/* 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)' }}
|
||||
/>
|
||||
|
||||
{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
|
||||
{/* 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>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<Form {...form}>
|
||||
|
|
@ -136,16 +125,17 @@ export default function Login() {
|
|||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormLabel className="text-[hsl(210_40%_75%)] text-sm font-medium">Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter your username"
|
||||
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 />
|
||||
<FormMessage className="text-red-400 text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -155,70 +145,83 @@ export default function Login() {
|
|||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormLabel className="text-[hsl(210_40%_75%)] text-sm font-medium">Password</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="Enter your password"
|
||||
type="password"
|
||||
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 />
|
||||
<FormMessage className="text-red-400 text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading || isMsalLoading}>
|
||||
{isLoading ? "Signing in..." : "Sign In"}
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
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 });
|
||||
<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',
|
||||
}}
|
||||
className="text-sm text-gray-500"
|
||||
>
|
||||
Use offline mode
|
||||
</Button>
|
||||
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>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
369
src/pages/Register.tsx
Normal file
369
src/pages/Register.tsx
Normal 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
158
src/pages/VerifyEmail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue