feat: complete AIMPRESS visual rebrand — warm palette, new landing, real dashboard
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
- Replace cyan/violet design tokens with warm dark slate + orange (#E89B3C) palette - Add Space Grotesk display font; new utilities: .outline-display, .orange-band, .corner-card, .persona-orb - New brand components: Logo (hexagonal SVG), Header (pill nav + glass blur), Footer (4-col), PublicLayout, AppLayout, UserDropdown - Rewrite Index.tsx as full sales funnel: Hero → Stats → Orange band → How it works → Pricing (API) → FAQ → Final CTA - Rewrite Dashboard.tsx with real API data: credits balance, MTD spend, personas count, focus groups count, active tasks, recent transactions - Rewrite auth pages (Login, Register, VerifyEmail, NotFound, Billing) with two-column orange-panel layout - Replace hardcoded mock numbers in Dashboard with billingApi / personasApi / focusGroupsApi / usageApi calls - Delete legacy components: Navigation.tsx, Hero.tsx, FeatureCard.tsx - Add nested layout routing in App.tsx: PublicLayout for guests, AppLayout for protected routes - Color sweep inner pages: replace all purple-500/600 with primary token - Purge all semblance / Oliver / optical-dev references; rename semblance_app_documentation.md → cohorta_app_documentation.md; update backend scripts to cohorta_db Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
dc2ad5d1a3
commit
9d2f1f2c7d
39 changed files with 2036 additions and 1743 deletions
16
README.md
16
README.md
|
|
@ -1,4 +1,4 @@
|
|||
# Semblance Synthetic Society
|
||||
# Cohorta
|
||||
|
||||
An AI-powered platform for creating synthetic personas and running autonomous focus group sessions. Build realistic consumer profiles, moderate live discussions or let AI drive the conversation, and extract actionable themes and insights — all in real time.
|
||||
|
||||
|
|
@ -157,7 +157,7 @@ npm run dev
|
|||
│ │ └── utils/ # Prompt loader, discussion guide schema
|
||||
│ └── prompts/ # LLM prompt templates (20 markdown files)
|
||||
├── deploy.sh # Production deployment script
|
||||
├── semblance.service # systemd service file
|
||||
├── cohorta.service # systemd service file
|
||||
├── start.sh # Local development start script
|
||||
├── .env.development # Development environment config
|
||||
└── .env.production # Production environment config
|
||||
|
|
@ -185,10 +185,10 @@ The `llm_service.py` module provides a unified interface across multiple AI prov
|
|||
|
||||
| Setting | Development | Production |
|
||||
|---|---|---|
|
||||
| **Base Path** | `/` | `/semblance/` |
|
||||
| **API Base URL** | `/api` (proxied to `:5137`) | `https://optical-dev.oliver.solutions/semblance_back/api` |
|
||||
| **WebSocket Path** | `/socket.io/` | `/semblance_back/socket.io/` |
|
||||
| **MSAL Redirect** | `http://localhost:5173/` | `https://optical-dev.oliver.solutions/semblance` |
|
||||
| **Base Path** | `/` | `/` |
|
||||
| **API Base URL** | `/api` (proxied to `:5137`) | `https://cohorta.ai-impress.com_back/api` |
|
||||
| **WebSocket Path** | `/socket.io/` | `/api/socket.io/` |
|
||||
| **MSAL Redirect** | `http://localhost:5173/` | `https://cohorta.ai-impress.com` |
|
||||
| **Local Login** | Enabled | Disabled |
|
||||
|
||||
Environment files:
|
||||
|
|
@ -218,7 +218,7 @@ This script handles:
|
|||
3. Installing backend dependencies
|
||||
4. Building the frontend
|
||||
5. Deploying built assets to the web server directory
|
||||
6. Restarting the `semblance.service` systemd unit
|
||||
6. Restarting the `cohorta.service` systemd unit
|
||||
|
||||
### Manual Backend Start (Production)
|
||||
|
||||
|
|
@ -228,7 +228,7 @@ source venv/bin/activate
|
|||
hypercorn "app:create_app()" --bind 0.0.0.0:5137
|
||||
```
|
||||
|
||||
The included `semblance.service` file can be installed as a systemd unit for process management.
|
||||
The included `cohorta.service` file can be installed as a systemd unit for process management.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Semblance Synthetic Society Backend
|
||||
# Cohorta Backend
|
||||
|
||||
This is the Python backend for the Semblance Synthetic Society project. It provides API endpoints for authentication, personas, and focus groups.
|
||||
This is the Python backend for the Cohorta project. It provides API endpoints for authentication, personas, and focus groups.
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
|
|||
|
|
@ -40,9 +40,9 @@ async def get_db_connection():
|
|||
# Try each set of standard credentials
|
||||
for creds in standard_credentials:
|
||||
try:
|
||||
uri = f"mongodb://{creds['user']}:{creds['pass']}@{mongo_host}:{mongo_port}/semblance_db?authSource={creds['db']}"
|
||||
uri = f"mongodb://{creds['user']}:{creds['pass']}@{mongo_host}:{mongo_port}/cohorta_db?authSource={creds['db']}"
|
||||
motor_client = AsyncIOMotorClient(uri, serverSelectionTimeoutMS=2000)
|
||||
database = motor_client.semblance_db
|
||||
database = motor_client.cohorta_db
|
||||
# Test the connection
|
||||
await database.command('ping')
|
||||
logger.info(f"Connected to MongoDB with credentials: {creds['user']}")
|
||||
|
|
@ -53,7 +53,7 @@ async def get_db_connection():
|
|||
# Try without authentication
|
||||
try:
|
||||
motor_client = AsyncIOMotorClient(f'mongodb://{mongo_host}:{mongo_port}', serverSelectionTimeoutMS=5000)
|
||||
database = motor_client.semblance_db
|
||||
database = motor_client.cohorta_db
|
||||
await database.command('ping')
|
||||
# Test write access
|
||||
test_result = await database.test_collection.insert_one({"test": "migration_test"})
|
||||
|
|
@ -66,9 +66,9 @@ async def get_db_connection():
|
|||
# Try with environment variables
|
||||
if mongo_user and mongo_pass:
|
||||
try:
|
||||
uri = f"mongodb://{mongo_user}:{mongo_pass}@{mongo_host}:{mongo_port}/semblance_db?authSource=admin"
|
||||
uri = f"mongodb://{mongo_user}:{mongo_pass}@{mongo_host}:{mongo_port}/cohorta_db?authSource=admin"
|
||||
motor_client = AsyncIOMotorClient(uri, serverSelectionTimeoutMS=5000)
|
||||
database = motor_client.semblance_db
|
||||
database = motor_client.cohorta_db
|
||||
await database.command('ping')
|
||||
logger.info(f"Connected to MongoDB with env credentials: {mongo_user}")
|
||||
return motor_client, database
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Usage:
|
|||
|
||||
Environment:
|
||||
MONGO_URI — connection string (falls back to localhost:27017 without auth)
|
||||
DB_NAME — database name (default: semblance_db)
|
||||
DB_NAME — database name (default: cohorta_db)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
|
@ -107,7 +107,7 @@ def _estimate_cost(prompt_tokens: int, completion_tokens: int, model: str) -> di
|
|||
|
||||
def connect():
|
||||
mongo_uri = os.environ.get("MONGO_URI", "mongodb://localhost:27017")
|
||||
db_name = os.environ.get("DB_NAME", "semblance_db")
|
||||
db_name = os.environ.get("DB_NAME", "cohorta_db")
|
||||
try:
|
||||
client = MongoClient(mongo_uri, serverSelectionTimeoutMS=5000)
|
||||
client.admin.command("ping")
|
||||
|
|
|
|||
|
|
@ -427,8 +427,8 @@ class ArchDocTemplate(BaseDocTemplate):
|
|||
filename, pagesize=A4,
|
||||
leftMargin=MARGIN_LEFT, rightMargin=MARGIN_RIGHT,
|
||||
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOTTOM,
|
||||
title="Semblance Technical Architecture",
|
||||
author="Semblance",
|
||||
title="Cohorta Technical Architecture",
|
||||
author="Cohorta",
|
||||
)
|
||||
frame = Frame(
|
||||
MARGIN_LEFT, MARGIN_BOTTOM, CONTENT_WIDTH, FRAME_HEIGHT, id="main",
|
||||
|
|
@ -484,7 +484,7 @@ class ArchDocTemplate(BaseDocTemplate):
|
|||
# Title
|
||||
c.setFillColor(white)
|
||||
c.setFont(FONTS["heading"], 44)
|
||||
c.drawCentredString(w / 2, 440, "Semblance")
|
||||
c.drawCentredString(w / 2, 440, "Cohorta")
|
||||
|
||||
# Subtitle
|
||||
c.setFont(FONTS["body"], 20)
|
||||
|
|
@ -624,11 +624,11 @@ DIAGRAMS = {
|
|||
"deployment_architecture": """graph TB
|
||||
User["User Browser"]
|
||||
|
||||
subgraph Production["Production Server (optical-dev.oliver.solutions)"]
|
||||
subgraph Production["Production Server (cohorta.ai-impress.com)"]
|
||||
Nginx["Nginx<br/>Reverse Proxy"]
|
||||
|
||||
subgraph Static["Static Assets"]
|
||||
Vite["Vite Build<br/>/semblance/"]
|
||||
Vite["Vite Build"]
|
||||
end
|
||||
|
||||
subgraph App["Application Server"]
|
||||
|
|
@ -647,8 +647,8 @@ DIAGRAMS = {
|
|||
end
|
||||
|
||||
User -->|"HTTPS"| Nginx
|
||||
Nginx -->|"/semblance/*"| Vite
|
||||
Nginx -->|"/semblance_back/*"| Hypercorn
|
||||
Nginx -->|"/*"| Vite
|
||||
Nginx -->|"/*"| Hypercorn
|
||||
Hypercorn --> Quart
|
||||
Hypercorn --> SocketIO
|
||||
Quart --> Mongo
|
||||
|
|
@ -1232,7 +1232,7 @@ def build_chapter_2(rendered):
|
|||
|
||||
e.append(h2("Deployment Topology", "ch2_deploy"))
|
||||
e.append(p(
|
||||
"In production, the application is deployed at <b>optical-dev.oliver.solutions</b> behind an "
|
||||
"In production, the application is deployed at <b>cohorta.ai-impress.com</b> behind an "
|
||||
"Nginx reverse proxy that routes requests to either the static frontend assets or the backend "
|
||||
"application server."
|
||||
))
|
||||
|
|
@ -1247,12 +1247,12 @@ def build_chapter_2(rendered):
|
|||
e.append(styled_table(
|
||||
["Setting", "Development", "Production"],
|
||||
[
|
||||
["Base Path", "/", "/semblance/"],
|
||||
["API Base URL", "/api", "https://optical-dev.oliver.solutions/semblance_back/api"],
|
||||
["WebSocket Path", "/socket.io/", "/semblance_back/socket.io/"],
|
||||
["Base Path", "/", "/"],
|
||||
["API Base URL", "/api", "https://cohorta.ai-impress.com/api"],
|
||||
["WebSocket Path", "/socket.io/", "/socket.io/"],
|
||||
["Frontend Port", "5173 (Vite dev server)", "Static assets via Nginx"],
|
||||
["Backend Port", "5137 (Hypercorn)", "5137 (proxied via Nginx)"],
|
||||
["MSAL Redirect", "http://localhost:5173/", "https://optical-dev.oliver.solutions/semblance"],
|
||||
["
|
||||
],
|
||||
col_widths=[110, (CONTENT_WIDTH - 118) / 2, (CONTENT_WIDTH - 118) / 2],
|
||||
))
|
||||
|
|
@ -2201,11 +2201,11 @@ def build_chapter_11(rendered):
|
|||
|
||||
def build_architecture_doc(output_path):
|
||||
print("=" * 60)
|
||||
print("Building Semblance Technical Architecture Document")
|
||||
print("Building Cohorta Technical Architecture Document")
|
||||
print("=" * 60)
|
||||
|
||||
# Create temp directory for Mermaid diagrams
|
||||
diagram_dir = tempfile.mkdtemp(prefix="semblance_diagrams_")
|
||||
diagram_dir = tempfile.mkdtemp(prefix="cohorta_diagrams_")
|
||||
print(f"\n Diagram directory: {diagram_dir}")
|
||||
|
||||
# Step 1: Render Mermaid diagrams
|
||||
|
|
@ -2267,6 +2267,6 @@ def build_architecture_doc(output_path):
|
|||
if __name__ == "__main__":
|
||||
output = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"semblance_architecture.pdf"
|
||||
"cohorta_architecture.pdf"
|
||||
)
|
||||
build_architecture_doc(output)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ def get_script_db():
|
|||
if mongo_uri:
|
||||
try:
|
||||
client = MongoClient(mongo_uri, serverSelectionTimeoutMS=5000)
|
||||
db = client.semblance_db
|
||||
db = client.cohorta_db
|
||||
db.command('ping')
|
||||
print("Successfully connected to MongoDB using MONGO_URI")
|
||||
return client, db
|
||||
|
|
@ -33,7 +33,7 @@ def get_script_db():
|
|||
# Try connecting without auth first
|
||||
try:
|
||||
client = MongoClient('mongodb://localhost:27017', serverSelectionTimeoutMS=2000)
|
||||
db = client.semblance_db
|
||||
db = client.cohorta_db
|
||||
db.command('ping')
|
||||
print("Successfully connected to MongoDB without authentication")
|
||||
return client, db
|
||||
|
|
@ -47,9 +47,9 @@ def get_script_db():
|
|||
|
||||
try:
|
||||
from urllib.parse import quote_plus
|
||||
uri = f"mongodb://{quote_plus(username)}:{quote_plus(password)}@localhost:27017/semblance_db?authSource=admin"
|
||||
uri = f"mongodb://{quote_plus(username)}:{quote_plus(password)}@localhost:27017/cohorta_db?authSource=admin"
|
||||
client = MongoClient(uri, serverSelectionTimeoutMS=5000)
|
||||
db = client.semblance_db
|
||||
db = client.cohorta_db
|
||||
db.command('ping')
|
||||
print("Successfully connected to MongoDB with credentials")
|
||||
return client, db
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Standalone script to populate MongoDB with sample data for the Semblance Synthetic Society app.
|
||||
Standalone script to populate MongoDB with sample data for the Cohorta Synthetic Focus Groups app.
|
||||
This script doesn't rely on the backend modules and connects directly to MongoDB.
|
||||
"""
|
||||
|
||||
|
|
@ -377,7 +377,7 @@ def connect_to_mongodb():
|
|||
if mongo_uri:
|
||||
try:
|
||||
client = MongoClient(mongo_uri, serverSelectionTimeoutMS=5000)
|
||||
db = client.semblance_db
|
||||
db = client.cohorta_db
|
||||
db.command('ping')
|
||||
print("Successfully connected to MongoDB using MONGO_URI")
|
||||
return client, db
|
||||
|
|
@ -388,7 +388,7 @@ def connect_to_mongodb():
|
|||
# Try connecting without auth (development environments)
|
||||
try:
|
||||
client = MongoClient('mongodb://localhost:27017', serverSelectionTimeoutMS=2000)
|
||||
db = client.semblance_db
|
||||
db = client.cohorta_db
|
||||
db.command('ping')
|
||||
print("Successfully connected to MongoDB without authentication")
|
||||
return client, db
|
||||
|
|
@ -402,9 +402,9 @@ def connect_to_mongodb():
|
|||
|
||||
try:
|
||||
from urllib.parse import quote_plus
|
||||
uri = f"mongodb://{quote_plus(username)}:{quote_plus(password)}@localhost:27017/semblance_db?authSource=admin"
|
||||
uri = f"mongodb://{quote_plus(username)}:{quote_plus(password)}@localhost:27017/cohorta_db?authSource=admin"
|
||||
client = MongoClient(uri, serverSelectionTimeoutMS=5000)
|
||||
db = client.semblance_db
|
||||
db = client.cohorta_db
|
||||
db.command('ping')
|
||||
print("Successfully connected to MongoDB with provided credentials")
|
||||
return client, db
|
||||
|
|
|
|||
|
|
@ -61,11 +61,11 @@ else
|
|||
echo -e "${GREEN}MongoDB is already running.${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Creating semblance_db database and collections...${NC}"
|
||||
echo -e "${YELLOW}Creating cohorta_db database and collections...${NC}"
|
||||
|
||||
# S-M3: Use mongosh instead of deprecated mongo CLI
|
||||
mongosh --eval '
|
||||
db = db.getSiblingDB("semblance_db");
|
||||
db = db.getSiblingDB("cohorta_db");
|
||||
db.createCollection("users");
|
||||
db.createCollection("personas");
|
||||
db.createCollection("focus_groups");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Semblance Synthetic Society - Application Documentation
|
||||
# Cohorta - Application Documentation
|
||||
|
||||
## Table of Contents
|
||||
1. [Application Overview](#application-overview)
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
## Application Overview
|
||||
|
||||
Semblance Synthetic Society is an AI-powered platform for creating and managing synthetic personas for focus groups and market research. It enables researchers to:
|
||||
Cohorta is an AI-powered platform for creating and managing synthetic personas for focus groups and market research. It enables researchers to:
|
||||
|
||||
- Create detailed synthetic personas with demographic profiles and personality traits
|
||||
- Organize personas into focus groups
|
||||
|
|
@ -432,7 +432,7 @@ gunicorn -w 4 "app:create_app()"
|
|||
|
||||
Frontend (`.env`):
|
||||
```
|
||||
VITE_API_BASE_URL=/semblance_back/api
|
||||
VITE_API_BASE_URL=/api
|
||||
```
|
||||
|
||||
Backend:
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/semblance/(.*)",
|
||||
"destination": "/semblance/index.html"
|
||||
"source": "/(.*)",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
51
src/App.tsx
51
src/App.tsx
|
|
@ -21,7 +21,8 @@ import AdminRoute from "./components/admin/AdminRoute";
|
|||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
import { NavigationProvider } from "./contexts/NavigationContext";
|
||||
import { WebSocketProvider } from "./contexts/WebSocketContextNew";
|
||||
// CSS for consistent back button positioning
|
||||
import PublicLayout from "./components/layout/PublicLayout";
|
||||
import AppLayout from "./components/layout/AppLayout";
|
||||
import "./styles/backButton.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
|
@ -35,21 +36,41 @@ const App = () => (
|
|||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/synthetic-users" element={<ProtectedRoute><SyntheticUsers /></ProtectedRoute>} />
|
||||
<Route path="/synthetic-users/:id" element={<ProtectedRoute><PersonaProfile /></ProtectedRoute>} />
|
||||
<Route path="/personas/:id" element={<ProtectedRoute><PersonaProfile /></ProtectedRoute>} />
|
||||
<Route path="/focus-groups" element={<ProtectedRoute><FocusGroups /></ProtectedRoute>} />
|
||||
<Route path="/focus-groups/:id" element={<ProtectedRoute><FocusGroupSession /></ProtectedRoute>} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="/admin" element={<AdminRoute><Admin /></AdminRoute>} />
|
||||
{/* Public pages — with landing header + footer */}
|
||||
<Route element={<PublicLayout />}>
|
||||
<Route path="/" element={<Index />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
|
||||
{/* App pages — with app header, no footer */}
|
||||
<Route element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/synthetic-users" element={<SyntheticUsers />} />
|
||||
<Route path="/synthetic-users/:id" element={<PersonaProfile />} />
|
||||
<Route path="/personas/:id" element={<PersonaProfile />} />
|
||||
<Route path="/focus-groups" element={<FocusGroups />} />
|
||||
<Route path="/focus-groups/:id" element={<FocusGroupSession />} />
|
||||
<Route path="/billing" element={<Billing />} />
|
||||
<Route path="/usage" element={<MyUsage />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin */}
|
||||
<Route path="/admin" element={
|
||||
<AdminRoute>
|
||||
<AppLayout>
|
||||
<Admin />
|
||||
</AppLayout>
|
||||
</AdminRoute>
|
||||
} />
|
||||
|
||||
<Route path="/old-path" element={<Navigate to="/" replace />} />
|
||||
<Route path="/billing" element={<ProtectedRoute><Billing /></ProtectedRoute>} />
|
||||
<Route path="/usage" element={<ProtectedRoute><MyUsage /></ProtectedRoute>} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</TooltipProvider>
|
||||
</NavigationProvider>
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
import { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FeatureCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
className?: string;
|
||||
gradient?: string;
|
||||
}
|
||||
|
||||
export default function FeatureCard({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
className,
|
||||
gradient = 'from-[#06B6D4] to-[#8B5CF6]',
|
||||
}: FeatureCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('gradient-border-card group p-6 transition-all duration-300 hover:-translate-y-1', className)}
|
||||
style={{
|
||||
background: 'hsl(222 45% 7%)',
|
||||
boxShadow: '0 4px 24px hsl(222 47% 2% / 0.4)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-11 h-11 rounded-xl flex items-center justify-center mb-5 bg-gradient-to-br',
|
||||
gradient,
|
||||
'opacity-90 group-hover:opacity-100 group-hover:scale-110 transition-all duration-300'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
|
||||
<h3 className="font-display font-semibold text-[hsl(210_40%_92%)] text-lg mb-2 leading-snug">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-[hsl(215_20%_52%)] leading-relaxed">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
import { ArrowRight, Sparkles, Users, BarChart3, Shield } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
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 (
|
||||
<section className="relative min-h-screen flex items-center overflow-hidden">
|
||||
{/* Background orbs */}
|
||||
<div
|
||||
className="glow-orb w-[600px] h-[600px] -top-32 -left-32 opacity-20 animate-pulse-glow"
|
||||
style={{ background: 'radial-gradient(circle, #06B6D4, transparent 70%)' }}
|
||||
/>
|
||||
<div
|
||||
className="glow-orb w-[500px] h-[500px] top-1/2 -right-40 opacity-15 animate-pulse-glow"
|
||||
style={{ background: 'radial-gradient(circle, #8B5CF6, transparent 70%)', animationDelay: '2s' }}
|
||||
/>
|
||||
<div
|
||||
className="glow-orb w-[400px] h-[400px] bottom-0 left-1/3 opacity-10 animate-pulse-glow"
|
||||
style={{ background: 'radial-gradient(circle, #06B6D4, transparent 70%)', animationDelay: '4s' }}
|
||||
/>
|
||||
|
||||
{/* Grid overlay */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(hsl(210 40% 96%) 1px, transparent 1px), linear-gradient(90deg, hsl(210 40% 96%) 1px, transparent 1px)',
|
||||
backgroundSize: '48px 48px',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative max-w-7xl mx-auto px-6 lg:px-8 pt-28 pb-20 w-full">
|
||||
<div className="lg:grid lg:grid-cols-2 lg:gap-16 items-center">
|
||||
{/* Left — copy */}
|
||||
<div className="animate-slide-up">
|
||||
{/* Badge */}
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-[hsl(188_91%_44%/0.3)] bg-[hsl(188_91%_44%/0.05)] mb-8">
|
||||
<Sparkles className="h-3.5 w-3.5 text-[#06B6D4]" />
|
||||
<span className="text-sm font-medium text-[#06B6D4]">AI-Powered Synthetic Research</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-display font-bold leading-[1.05] tracking-tight text-white mb-6">
|
||||
Research with{' '}
|
||||
<span
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
Synthetic
|
||||
<br />
|
||||
Personas
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-lg text-[hsl(215_20%_60%)] leading-relaxed max-w-lg mb-10">
|
||||
Conduct market research and user interviews using AI-powered synthetic personas.
|
||||
Gain deep insights without the cost and time of traditional methods.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-14">
|
||||
<Link
|
||||
to="/synthetic-users"
|
||||
className="inline-flex items-center justify-center gap-2 px-7 py-3.5 rounded-xl font-semibold text-white transition-all duration-200"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #06B6D4 0%, #7C3AED 100%)',
|
||||
boxShadow: '0 0 24px hsl(188 91% 44% / 0.3)',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
(e.currentTarget as HTMLElement).style.boxShadow = '0 0 40px hsl(188 91% 44% / 0.5)';
|
||||
(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
(e.currentTarget as HTMLElement).style.boxShadow = '0 0 24px hsl(188 91% 44% / 0.3)';
|
||||
(e.currentTarget as HTMLElement).style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
Start for free
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/focus-groups"
|
||||
className="inline-flex items-center justify-center gap-2 px-7 py-3.5 rounded-xl font-semibold text-[hsl(210_40%_80%)] border border-[hsl(222_38%_22%)] hover:border-[hsl(188_91%_44%/0.4)] hover:text-white transition-all duration-200"
|
||||
>
|
||||
See how it works
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center gap-8 pt-6 border-t border-[hsl(222_38%_16%)]">
|
||||
<StatBadge value="10k+" label="Personas created" />
|
||||
<div className="w-px h-8 bg-[hsl(222_38%_18%)]" />
|
||||
<StatBadge value="95%" label="Insight accuracy" />
|
||||
<div className="w-px h-8 bg-[hsl(222_38%_18%)]" />
|
||||
<StatBadge value="10×" label="Faster research" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — mock UI */}
|
||||
<div className="hidden lg:block mt-12 lg:mt-0">
|
||||
<div className="relative">
|
||||
{/* Floating feature badges */}
|
||||
<div
|
||||
className="absolute -top-6 -left-8 z-20 flex items-center gap-2.5 px-4 py-2.5 rounded-xl text-sm font-medium text-white animate-float"
|
||||
style={{
|
||||
background: 'hsl(222 45% 9%)',
|
||||
border: '1px solid hsl(222 38% 20%)',
|
||||
boxShadow: '0 8px 32px hsl(222 47% 2% / 0.5)',
|
||||
animationDelay: '0s',
|
||||
}}
|
||||
>
|
||||
<Users className="h-4 w-4 text-[#06B6D4]" />
|
||||
<span className="text-[hsl(210_40%_90%)]">42 personas active</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute -bottom-6 -right-8 z-20 flex items-center gap-2.5 px-4 py-2.5 rounded-xl text-sm font-medium text-white animate-float"
|
||||
style={{
|
||||
background: 'hsl(222 45% 9%)',
|
||||
border: '1px solid hsl(222 38% 20%)',
|
||||
boxShadow: '0 8px 32px hsl(222 47% 2% / 0.5)',
|
||||
animationDelay: '1.5s',
|
||||
}}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 text-[#8B5CF6]" />
|
||||
<span className="text-[hsl(210_40%_90%)]">Insights ready</span>
|
||||
</div>
|
||||
|
||||
{/* Main mock card */}
|
||||
<div
|
||||
className="rounded-2xl overflow-hidden animate-float"
|
||||
style={{
|
||||
background: 'hsl(222 45% 8%)',
|
||||
border: '1px solid hsl(222 38% 16%)',
|
||||
boxShadow: '0 24px 64px hsl(222 47% 2% / 0.7)',
|
||||
animationDelay: '0.5s',
|
||||
}}
|
||||
>
|
||||
{/* Window bar */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-[hsl(222_38%_14%)]">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-red-500/70" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-green-500/70" />
|
||||
<span className="ml-3 text-xs text-[hsl(215_20%_45%)]">Focus Group Session</span>
|
||||
</div>
|
||||
|
||||
{/* Chat content */}
|
||||
<div className="p-4 space-y-3 min-h-[320px]">
|
||||
{[
|
||||
{ role: 'mod', text: 'What qualities matter most when choosing a premium brand?' },
|
||||
{ role: 'user', name: 'Sarah, 32', text: 'Sustainability and transparency in sourcing. I want to trust the brand.' },
|
||||
{ role: 'mod', text: 'How does packaging influence your perception?' },
|
||||
{ role: 'user', name: 'Marcus, 28', text: 'Minimalist design signals confidence. Loud packaging feels insecure.' },
|
||||
{ role: 'user', name: 'Priya, 35', text: 'Agreed — and eco-friendly materials are now a baseline expectation for me.' },
|
||||
].map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${msg.role === 'mod' ? 'justify-start' : 'justify-end'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[78%] px-3.5 py-2.5 rounded-2xl text-xs leading-relaxed ${
|
||||
msg.role === 'mod'
|
||||
? 'bg-[hsl(222_38%_13%)] text-[hsl(210_40%_75%)]'
|
||||
: 'text-white'
|
||||
}`}
|
||||
style={
|
||||
msg.role === 'user'
|
||||
? {
|
||||
background: 'linear-gradient(135deg, hsl(188 91% 30%), hsl(258 89% 45%))',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{msg.name && (
|
||||
<div className="text-[10px] font-semibold text-[#06B6D4] mb-0.5">{msg.name}</div>
|
||||
)}
|
||||
{msg.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-t border-[hsl(222_38%_14%)]">
|
||||
<div className="flex-1 h-8 rounded-lg bg-[hsl(222_38%_11%)] border border-[hsl(222_38%_18%)]" />
|
||||
<div
|
||||
className="h-8 w-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: 'linear-gradient(135deg, #06B6D4, #7C3AED)' }}
|
||||
>
|
||||
<ArrowRight className="h-3.5 w-3.5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trust badge */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 -right-12 flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-[hsl(210_40%_80%)] animate-float"
|
||||
style={{
|
||||
background: 'hsl(222 45% 9%)',
|
||||
border: '1px solid hsl(222 38% 18%)',
|
||||
animationDelay: '2.5s',
|
||||
writingMode: 'vertical-rl',
|
||||
transform: 'translateY(-50%) rotate(180deg)',
|
||||
}}
|
||||
>
|
||||
<Shield className="h-3 w-3 text-green-400 rotate-180" />
|
||||
SOC 2 Ready
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,275 +0,0 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
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: '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 isActive = (path: string) => location.pathname === path;
|
||||
|
||||
const handleAuthNavigation = (path: string) => {
|
||||
if (path === '/synthetic-users') {
|
||||
window.dispatchEvent(new CustomEvent('syntheticUsersNavigation'));
|
||||
}
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* 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',
|
||||
}}
|
||||
>
|
||||
Cohorta
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{navigationItems.map((item) => (
|
||||
<div key={item.name}>
|
||||
{item.href === '/' ? (
|
||||
<Link
|
||||
to={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-2 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-[hsl(210_40%_90%)] hover:bg-[hsl(222_38%_11%)]'
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-3.5 w-3.5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleAuthNavigation(item.href)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-2 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-[hsl(210_40%_90%)] hover:bg-[hsl(222_38%_11%)]'
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<button
|
||||
className={cn(
|
||||
'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'); }}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Admin
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="pt-2 border-t border-[hsl(222_38%_16%)]">
|
||||
{isAuthenticated ? (
|
||||
<button
|
||||
onClick={() => { logout(); setMobileMenuOpen(false); navigate('/login'); }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium text-[hsl(215_20%_60%)] hover:text-white hover:bg-[hsl(222_38%_11%)] transition-all"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<Link
|
||||
to="/login"
|
||||
className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-[hsl(215_20%_70%)] hover:text-white border border-[hsl(222_38%_20%)] hover:border-[hsl(222_38%_28%)] transition-all"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<LogIn className="h-4 w-4" />
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
to="/register"
|
||||
className="flex items-center justify-center px-4 py-2.5 rounded-lg text-sm font-semibold text-white transition-all"
|
||||
style={{ background: 'linear-gradient(135deg, #06B6D4 0%, #7C3AED 100%)' }}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
47
src/components/brand/Logo.tsx
Normal file
47
src/components/brand/Logo.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LogoProps {
|
||||
className?: string;
|
||||
withWordmark?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const sizes = { sm: 'h-6 w-6', md: 'h-8 w-8', lg: 'h-10 w-10' };
|
||||
|
||||
export default function Logo({ className, withWordmark = false, size = 'md' }: LogoProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2.5', className)}>
|
||||
<svg viewBox="0 0 36 36" fill="none" className={sizes[size]}>
|
||||
<defs>
|
||||
<linearGradient id="logo-lg" x1="2" y1="2" x2="34" y2="34" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="hsl(28 78% 56%)" />
|
||||
<stop offset="1" stopColor="hsl(38 90% 65%)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Hexagonal mark */}
|
||||
<path
|
||||
d="M18 3L31 10.5V25.5L18 33L5 25.5V10.5L18 3Z"
|
||||
stroke="url(#logo-lg)"
|
||||
strokeWidth="2.5"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M13 18L16.5 22.5L23 13.5"
|
||||
stroke="url(#logo-lg)"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{withWordmark && (
|
||||
<span
|
||||
className="font-display font-bold text-lg tracking-tight text-foreground"
|
||||
style={{ letterSpacing: '-0.02em' }}
|
||||
>
|
||||
Cohorta
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/components/brand/UserDropdown.tsx
Normal file
61
src/components/brand/UserDropdown.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { User, CreditCard, BarChart2, LogOut } from 'lucide-react';
|
||||
|
||||
export default function UserDropdown() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const initials = user.username?.slice(0, 2).toUpperCase() || 'U';
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-2.5 px-3 py-1.5 rounded-full border border-border bg-card hover:border-primary/40 transition-all duration-200 focus:outline-none">
|
||||
<div className="h-7 w-7 rounded-full bg-primary/15 border border-primary/30 flex items-center justify-center">
|
||||
<span className="text-xs font-semibold text-primary">{initials}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground hidden sm:block max-w-[120px] truncate">
|
||||
{user.username}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-sm font-medium text-foreground truncate">{user.username}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate('/dashboard')}>
|
||||
<User className="h-4 w-4 mr-2 text-primary" />
|
||||
My account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate('/billing')}>
|
||||
<CreditCard className="h-4 w-4 mr-2 text-primary" />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate('/usage')}>
|
||||
<BarChart2 className="h-4 w-4 mr-2 text-primary" />
|
||||
Usage
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => { logout(); navigate('/'); }}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,74 +1,461 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { billingApi, personasApi, focusGroupsApi, authApi } from '@/lib/api';
|
||||
import { useMyUsage } from '@/hooks/useMyUsage';
|
||||
import {
|
||||
Users, MessageSquare, Zap, CreditCard, BarChart2, ArrowRight,
|
||||
Plus, CheckCircle2, AlertCircle, Clock, TrendingUp, Activity, Loader2
|
||||
} from 'lucide-react';
|
||||
import { toastService } from '@/lib/toast';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import api from '@/lib/api';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Users, MessageSquare, Lightbulb } from 'lucide-react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import Navigation from '@/components/Navigation';
|
||||
import DashboardHeader from '@/components/dashboard/DashboardHeader';
|
||||
import StatCard from '@/components/dashboard/StatCard';
|
||||
import OverviewTab from '@/components/dashboard/OverviewTab';
|
||||
import UsersTab from '@/components/dashboard/UsersTab';
|
||||
import FocusGroupsTab from '@/components/dashboard/FocusGroupsTab';
|
||||
// ──────────────────────────────────────────────
|
||||
// Types
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
interface BalanceData {
|
||||
credits_balance: number;
|
||||
persona_cost: number;
|
||||
run_cost: number;
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
_id: string;
|
||||
type: string;
|
||||
amount: number;
|
||||
balance_after: number;
|
||||
created_at: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Persona {
|
||||
_id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface FocusGroup {
|
||||
_id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface UserTask {
|
||||
task_id: string;
|
||||
task_type: string;
|
||||
status: string;
|
||||
progress?: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
const TX_TYPE_LABELS: Record<string, string> = {
|
||||
purchase: 'Purchase',
|
||||
grant: 'Free grant',
|
||||
admin_grant: 'Admin grant',
|
||||
debit: 'Used',
|
||||
refund: 'Refund',
|
||||
};
|
||||
|
||||
const TX_TYPE_COLORS: Record<string, string> = {
|
||||
purchase: 'text-primary',
|
||||
grant: 'text-green-400',
|
||||
admin_grant: 'text-green-400',
|
||||
debit: 'text-red-400',
|
||||
refund: 'text-blue-400',
|
||||
};
|
||||
|
||||
function formatDate(iso: string) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Stat Card
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
function StatCard({
|
||||
icon: Icon, title, value, sub, action, actionLabel, loading,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
value: React.ReactNode;
|
||||
sub?: string;
|
||||
action?: () => void;
|
||||
actionLabel?: string;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="corner-card p-6 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-10 w-10 rounded-xl bg-primary/15 flex items-center justify-center">
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
{action && actionLabel && (
|
||||
<button
|
||||
onClick={action}
|
||||
className="text-xs text-primary hover:text-primary/80 font-medium transition-colors"
|
||||
>
|
||||
{actionLabel} →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-widest mb-1">{title}</p>
|
||||
{loading
|
||||
? <div className="h-8 w-24 bg-secondary/60 rounded animate-pulse" />
|
||||
: <div className="font-display font-bold text-3xl text-foreground">{value}</div>
|
||||
}
|
||||
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Quick action card
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
function QuickAction({ icon: Icon, title, desc, to }: {
|
||||
icon: React.ElementType; title: string; desc: string; to: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="corner-card p-6 flex items-center gap-4 hover:border-primary/40 transition-all group"
|
||||
>
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 flex items-center justify-center flex-shrink-0">
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-foreground text-sm mb-0.5">{title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{desc}</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors ml-auto flex-shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Main component
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
const Dashboard = () => {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { data: usageData, isLoading: usageLoading } = useMyUsage();
|
||||
|
||||
const [balance, setBalance] = useState<BalanceData | null>(null);
|
||||
const [balanceLoading, setBalanceLoading] = useState(true);
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [txLoading, setTxLoading] = useState(true);
|
||||
const [personasCount, setPersonasCount] = useState<number | null>(null);
|
||||
const [fgCount, setFgCount] = useState<number | null>(null);
|
||||
const [activeFgCount, setActiveFgCount] = useState<number>(0);
|
||||
const [recentPersonas, setRecentPersonas] = useState<Persona[]>([]);
|
||||
const [activeTasks, setActiveTasks] = useState<UserTask[]>([]);
|
||||
const [quotaExceeded, setQuotaExceeded] = useState(false);
|
||||
const [resendingEmail, setResendingEmail] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
// Balance
|
||||
billingApi.getBalance()
|
||||
.then(r => { setBalance(r.data); setBalanceLoading(false); })
|
||||
.catch(() => setBalanceLoading(false));
|
||||
|
||||
// Transactions (last 5)
|
||||
billingApi.getTransactions(5)
|
||||
.then(r => { setTransactions(r.data.transactions || []); setTxLoading(false); })
|
||||
.catch(() => setTxLoading(false));
|
||||
|
||||
// Personas
|
||||
personasApi.getAll()
|
||||
.then(r => {
|
||||
const all: Persona[] = r.data.personas || r.data || [];
|
||||
setPersonasCount(all.length);
|
||||
setRecentPersonas(all.slice(0, 5));
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Focus groups
|
||||
focusGroupsApi.getAll()
|
||||
.then(r => {
|
||||
const all: FocusGroup[] = r.data.focus_groups || r.data || [];
|
||||
setFgCount(all.length);
|
||||
setActiveFgCount(all.filter(fg => fg.status === 'active' || fg.status === 'in_progress').length);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Active tasks
|
||||
api.get('/tasks/user/me')
|
||||
.then(r => {
|
||||
const tasks: UserTask[] = r.data.tasks || r.data || [];
|
||||
setActiveTasks(tasks.filter(t => t.status === 'running' || t.status === 'pending'));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
// Listen for quota exceeded events
|
||||
const onQuota = () => setQuotaExceeded(true);
|
||||
window.addEventListener('quota_exceeded', onQuota);
|
||||
return () => window.removeEventListener('quota_exceeded', onQuota);
|
||||
}, [loadData]);
|
||||
|
||||
const handleResendEmail = async () => {
|
||||
setResendingEmail(true);
|
||||
try {
|
||||
await authApi.resendVerification();
|
||||
toastService.success('Verification email sent', { description: 'Check your inbox' });
|
||||
} catch {
|
||||
toastService.error('Failed to send email');
|
||||
} finally {
|
||||
setResendingEmail(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<Navigation />
|
||||
|
||||
<main className="pt-20 pb-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||
<DashboardHeader
|
||||
title="Dashboard"
|
||||
description="Monitor and analyze your research insights"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
<StatCard
|
||||
title="Total Synthetic Users"
|
||||
value={48}
|
||||
changePercentage={12}
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10">
|
||||
|
||||
{/* ── Greeting ── */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="font-display font-bold text-3xl md:text-4xl text-foreground">
|
||||
Welcome back, <span className="text-primary">{user?.username}</span>
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
{user?.role === 'admin' && (
|
||||
<Badge variant="outline" className="border-primary/40 text-primary text-xs">Admin</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="border-border text-muted-foreground text-xs">
|
||||
{balance?.credits_balance ?? '…'} credits remaining
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/focus-groups')}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-full bg-primary text-primary-foreground text-sm font-semibold hover:bg-primary/90 transition-all shadow-sm flex-shrink-0"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New focus group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Banners ── */}
|
||||
{user && !(user as any).email_verified && (
|
||||
<div className="flex items-center justify-between gap-4 bg-primary/10 border border-primary/25 rounded-xl px-5 py-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
<p className="text-sm text-foreground">
|
||||
Please verify your email address to unlock all features.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleResendEmail}
|
||||
disabled={resendingEmail}
|
||||
className="text-xs font-semibold text-primary hover:text-primary/80 flex-shrink-0 flex items-center gap-1"
|
||||
>
|
||||
{resendingEmail && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Resend email
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{quotaExceeded && (
|
||||
<div className="flex items-center justify-between gap-4 bg-destructive/10 border border-destructive/25 rounded-xl px-5 py-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0" />
|
||||
<p className="text-sm text-foreground">Credit quota exceeded. Top up to continue.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/billing')}
|
||||
className="text-xs font-semibold text-destructive hover:text-destructive/80 flex-shrink-0"
|
||||
>
|
||||
Top up →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Stat row ── */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard
|
||||
icon={Zap}
|
||||
title="Credits"
|
||||
value={balance?.credits_balance?.toLocaleString() ?? '—'}
|
||||
sub={`${balance?.persona_cost ?? 2} cr/persona • ${balance?.run_cost ?? 40} cr/run`}
|
||||
action={() => navigate('/billing')}
|
||||
actionLabel="Top up"
|
||||
loading={balanceLoading}
|
||||
/>
|
||||
<StatCard
|
||||
icon={TrendingUp}
|
||||
title="MTD Spend"
|
||||
value={usageData ? `$${(usageData.total_cost_usd ?? 0).toFixed(2)}` : '—'}
|
||||
sub="Month-to-date LLM cost"
|
||||
action={() => navigate('/usage')}
|
||||
actionLabel="Details"
|
||||
loading={usageLoading}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Users}
|
||||
title="Personas"
|
||||
value={personasCount !== null ? personasCount.toLocaleString() : '—'}
|
||||
sub="Total synthetic personas"
|
||||
action={() => navigate('/synthetic-users')}
|
||||
actionLabel="View all"
|
||||
loading={personasCount === null}
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
title="Active Focus Groups"
|
||||
value={7}
|
||||
changePercentage={5}
|
||||
icon={MessageSquare}
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
title="Research Insights"
|
||||
value={124}
|
||||
changePercentage={18}
|
||||
icon={Lightbulb}
|
||||
title="Focus Groups"
|
||||
value={fgCount !== null ? fgCount.toLocaleString() : '—'}
|
||||
sub={activeFgCount > 0 ? `${activeFgCount} active now` : 'None active'}
|
||||
action={() => navigate('/focus-groups')}
|
||||
actionLabel="View all"
|
||||
loading={fgCount === null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="glass-panel rounded-xl p-6"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3 mb-6">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="users">Synthetic Users</TabsTrigger>
|
||||
<TabsTrigger value="focus-groups">Focus Groups</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<OverviewTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="users">
|
||||
<UsersTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="focus-groups">
|
||||
<FocusGroupsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
|
||||
{/* ── Quick actions ── */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
|
||||
<QuickAction icon={Users} title="Create personas" desc="Generate AI personas from an audience brief" to="/synthetic-users" />
|
||||
<QuickAction icon={MessageSquare} title="Start a focus group" desc="Run an AI-moderated research session" to="/focus-groups" />
|
||||
<QuickAction icon={CreditCard} title="View transactions" desc="Credit history and purchase packs" to="/billing" />
|
||||
</div>
|
||||
|
||||
{/* ── Active tasks + Recent activity ── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
|
||||
{/* Active tasks */}
|
||||
<div className="bg-card border border-border rounded-2xl p-6">
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<Activity className="h-4 w-4 text-primary" />
|
||||
<h2 className="font-semibold text-foreground text-sm uppercase tracking-wide">Running tasks</h2>
|
||||
</div>
|
||||
{activeTasks.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle2 className="h-8 w-8 text-primary/40 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No active tasks</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{activeTasks.map(task => (
|
||||
<div key={task.task_id} className="flex items-center gap-3 bg-secondary/40 rounded-xl px-4 py-3">
|
||||
<Loader2 className="h-4 w-4 text-primary animate-spin flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{task.task_type.replace(/_/g, ' ')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(task.created_at)}</p>
|
||||
</div>
|
||||
{task.progress !== undefined && (
|
||||
<span className="text-xs font-semibold text-primary">{task.progress}%</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent transactions */}
|
||||
<div className="bg-card border border-border rounded-2xl p-6">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-primary" />
|
||||
<h2 className="font-semibold text-foreground text-sm uppercase tracking-wide">Recent transactions</h2>
|
||||
</div>
|
||||
<Link to="/billing" className="text-xs text-primary hover:text-primary/80 font-medium">
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
{txLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-10 bg-secondary/40 rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : transactions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No transactions yet</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{transactions.map(tx => (
|
||||
<div key={tx._id} className="flex items-center justify-between py-2.5 px-3 rounded-xl hover:bg-secondary/40 transition-colors">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{TX_TYPE_LABELS[tx.type] || tx.type}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(tx.created_at)}</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-4">
|
||||
<p className={`text-sm font-semibold ${TX_TYPE_COLORS[tx.type] || 'text-foreground'}`}>
|
||||
{tx.amount > 0 ? '+' : ''}{tx.amount} cr
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{tx.balance_after} cr left</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Recent personas ── */}
|
||||
{recentPersonas.length > 0 && (
|
||||
<div className="bg-card border border-border rounded-2xl p-6">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
<h2 className="font-semibold text-foreground text-sm uppercase tracking-wide">Recent personas</h2>
|
||||
</div>
|
||||
<Link to="/synthetic-users" className="text-xs text-primary hover:text-primary/80 font-medium">
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{recentPersonas.map(p => (
|
||||
<Link
|
||||
key={p._id}
|
||||
to={`/synthetic-users/${p._id}`}
|
||||
className="flex items-center gap-2.5 px-4 py-2.5 bg-secondary/50 rounded-xl hover:bg-secondary hover:border-primary/30 border border-transparent transition-all group"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs font-bold text-primary">{p.name?.[0] || '?'}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground group-hover:text-primary transition-colors">{p.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Admin link ── */}
|
||||
{user?.role === 'admin' && (
|
||||
<div className="mt-6 border border-border rounded-2xl p-5 flex items-center justify-between bg-card">
|
||||
<div className="flex items-center gap-3">
|
||||
<BarChart2 className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<p className="font-semibold text-foreground text-sm">Admin panel</p>
|
||||
<p className="text-xs text-muted-foreground">Manage users, usage, pricing, and analytics</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to="/admin"
|
||||
className="px-4 py-2 rounded-xl text-xs font-semibold border border-primary/40 text-primary hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
Open admin →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const ActionIcon = ({ action }: { action: string }) => {
|
|||
case 'participant_respond':
|
||||
return <Users className="h-4 w-4 text-green-500" />;
|
||||
case 'participant_interaction':
|
||||
return <Users className="h-4 w-4 text-purple-500" />;
|
||||
return <Users className="h-4 w-4 text-primary" />;
|
||||
case 'probe_trigger':
|
||||
return <Zap className="h-4 w-4 text-orange-500" />;
|
||||
case 'end_session':
|
||||
|
|
@ -172,7 +172,7 @@ const ReasoningPanel = ({ reasoningHistory, isVisible, onToggle, isAiMode = fals
|
|||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="h-4 w-4 text-purple-600" />
|
||||
<Brain className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium text-sm">
|
||||
{isAiMode ? "AI Decision Reasoning" : "AI Moderator Logic"}
|
||||
</span>
|
||||
|
|
|
|||
125
src/components/layout/AppLayout.tsx
Normal file
125
src/components/layout/AppLayout.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Link, NavLink, useNavigate, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { billingApi } from '@/lib/api';
|
||||
import Logo from '@/components/brand/Logo';
|
||||
import UserDropdown from '@/components/brand/UserDropdown';
|
||||
import { Users, MessageSquare, LayoutDashboard, CreditCard, ShieldCheck, Zap, Menu, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const appNavItems = [
|
||||
{ label: 'Dashboard', to: '/dashboard', icon: LayoutDashboard },
|
||||
{ label: 'Personas', to: '/synthetic-users', icon: Users },
|
||||
{ label: 'Focus Groups', to: '/focus-groups', icon: MessageSquare },
|
||||
{ label: 'Billing', to: '/billing', icon: CreditCard },
|
||||
];
|
||||
|
||||
export default function AppLayout({ children }: { children?: React.ReactNode }) {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [credits, setCredits] = useState<number | null>(null);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
billingApi.getBalance().then(r => setCredits(r.data.credits_balance)).catch(() => {});
|
||||
}, [isAuthenticated]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
{/* App header */}
|
||||
<header className="sticky top-0 z-40 bg-[hsl(220_22%_10%/0.95)] backdrop-blur-xl border-b border-border/60">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between gap-4">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex-shrink-0">
|
||||
<Logo withWordmark size="sm" />
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{appNavItems.map(({ label, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200',
|
||||
isActive
|
||||
? 'text-primary bg-primary/10'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
{user?.role === 'admin' && (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200',
|
||||
isActive
|
||||
? 'text-primary bg-primary/10'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
|
||||
)
|
||||
}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Admin
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-2">
|
||||
{credits !== null && (
|
||||
<button
|
||||
onClick={() => navigate('/billing')}
|
||||
className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20 text-xs font-semibold text-primary hover:bg-primary/15 transition-colors"
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
{credits.toLocaleString()} cr
|
||||
</button>
|
||||
)}
|
||||
<UserDropdown />
|
||||
<button
|
||||
className="md:hidden p-2 rounded-lg text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setMobileOpen(v => !v)}
|
||||
>
|
||||
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{mobileOpen && (
|
||||
<div className="md:hidden border-t border-border/40 px-4 py-3 flex flex-col gap-1 animate-slide-down">
|
||||
{appNavItems.map(({ label, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-sm font-medium',
|
||||
isActive ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
|
||||
)
|
||||
}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Page content — supports both nested <Outlet /> and explicit children */}
|
||||
<main className="flex-1">
|
||||
{children ?? <Outlet />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/layout/Footer.tsx
Normal file
73
src/components/layout/Footer.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import Logo from '@/components/brand/Logo';
|
||||
|
||||
const footerLinks = {
|
||||
Platform: [
|
||||
{ label: 'Synthetic Personas', to: '/synthetic-users' },
|
||||
{ label: 'Focus Groups', to: '/focus-groups' },
|
||||
{ label: 'Dashboard', to: '/dashboard' },
|
||||
{ label: 'Billing & Credits', to: '/billing' },
|
||||
],
|
||||
Company: [
|
||||
{ label: 'About', to: '#' },
|
||||
{ label: 'Blog', to: '#' },
|
||||
{ label: 'Careers', to: '#' },
|
||||
{ label: 'Contact', to: '#' },
|
||||
],
|
||||
Legal: [
|
||||
{ label: 'Privacy Policy', to: '#' },
|
||||
{ label: 'Terms of Service', to: '#' },
|
||||
{ label: 'Cookie Policy', to: '#' },
|
||||
{ label: 'GDPR', to: '#' },
|
||||
],
|
||||
};
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-[hsl(220_25%_8%)] border-t border-border/50">
|
||||
<div className="max-w-7xl mx-auto px-6 py-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-10">
|
||||
{/* Brand */}
|
||||
<div className="md:col-span-1">
|
||||
<Logo withWordmark className="mb-4" />
|
||||
<p className="text-sm text-muted-foreground leading-relaxed max-w-xs">
|
||||
AI-powered synthetic research platform. Generate personas, run focus groups, extract insights — in minutes.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-4">
|
||||
Powered by{' '}
|
||||
<span className="text-primary font-medium">AImpress LTD</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Link columns */}
|
||||
{Object.entries(footerLinks).map(([group, links]) => (
|
||||
<div key={group}>
|
||||
<h4 className="text-sm font-semibold text-foreground mb-4 uppercase tracking-widest">{group}</h4>
|
||||
<ul className="space-y-3">
|
||||
{links.map(({ label, to }) => (
|
||||
<li key={label}>
|
||||
<Link
|
||||
to={to}
|
||||
className="text-sm text-muted-foreground hover:text-primary transition-colors duration-200"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/40 mt-12 pt-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
© {new Date().getFullYear()} AImpress LTD. All rights reserved.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/50">
|
||||
Cohorta is a product of AImpress LTD
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
157
src/components/layout/Header.tsx
Normal file
157
src/components/layout/Header.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import Logo from '@/components/brand/Logo';
|
||||
import UserDropdown from '@/components/brand/UserDropdown';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const navLinks = [
|
||||
{ label: 'Home', to: '/' },
|
||||
{ label: 'Product', to: '/#product' },
|
||||
{ label: 'Pricing', to: '/#pricing' },
|
||||
{ label: 'Blog', to: '/blog' },
|
||||
{ label: 'Contact', to: '/contact' },
|
||||
];
|
||||
|
||||
export default function Header() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 24);
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'fixed top-0 left-0 right-0 z-50 transition-all duration-300',
|
||||
scrolled ? 'py-3' : 'py-5'
|
||||
)}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-5">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between px-5 py-2.5 rounded-full transition-all duration-300',
|
||||
scrolled
|
||||
? 'bg-[hsl(220_22%_11%/0.92)] backdrop-blur-xl border border-border/60 shadow-lg'
|
||||
: 'bg-transparent'
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex-shrink-0">
|
||||
<Logo withWordmark />
|
||||
</Link>
|
||||
|
||||
{/* Pill nav — desktop */}
|
||||
<nav className="hidden md:flex items-center">
|
||||
<div className="flex items-center gap-0.5 bg-[hsl(220_20%_16%/0.8)] border border-border/50 rounded-full px-1.5 py-1.5">
|
||||
{navLinks.map(({ label, to }) => (
|
||||
<NavLink
|
||||
key={label}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'px-4 py-1.5 rounded-full text-sm font-medium transition-all duration-200',
|
||||
isActive && to !== '/#product' && to !== '/#pricing'
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)
|
||||
}
|
||||
end={to === '/'}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
{/* Language switcher */}
|
||||
<div className="flex items-center gap-1 px-3 py-1.5 rounded-full border border-border/50 text-sm text-muted-foreground bg-[hsl(220_20%_16%/0.7)]">
|
||||
<span className="text-xs">🌐</span>
|
||||
<span>Eng</span>
|
||||
</div>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<UserDropdown />
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
to="/login"
|
||||
className="px-4 py-2 rounded-full text-sm font-medium text-foreground border border-border/60 hover:border-primary/40 hover:text-primary transition-all duration-200"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => navigate('/register')}
|
||||
className="px-4 py-2 rounded-full text-sm font-semibold bg-primary text-primary-foreground hover:bg-primary/90 transition-all duration-200 shadow-sm"
|
||||
>
|
||||
Get started
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile toggle */}
|
||||
<button
|
||||
className="md:hidden p-2 rounded-full text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setMobileOpen(v => !v)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{mobileOpen && (
|
||||
<div className="md:hidden mt-2 bg-[hsl(220_22%_13%/0.97)] backdrop-blur-xl border border-border/60 rounded-2xl p-4 shadow-xl animate-slide-down">
|
||||
<nav className="flex flex-col gap-1 mb-4">
|
||||
{navLinks.map(({ label, to }) => (
|
||||
<Link
|
||||
key={label}
|
||||
to={to}
|
||||
className="px-4 py-2.5 rounded-xl text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-all"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
{isAuthenticated ? (
|
||||
<Link to="/dashboard" onClick={() => setMobileOpen(false)}>
|
||||
<button className="w-full px-4 py-2.5 rounded-xl text-sm font-semibold bg-primary text-primary-foreground">
|
||||
My account
|
||||
</button>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
to="/login"
|
||||
className="px-4 py-2.5 rounded-xl text-sm font-medium text-center text-foreground border border-border"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
to="/register"
|
||||
className="px-4 py-2.5 rounded-xl text-sm font-semibold text-center bg-primary text-primary-foreground"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
Get started free
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
15
src/components/layout/PublicLayout.tsx
Normal file
15
src/components/layout/PublicLayout.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Outlet } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
|
||||
export default function PublicLayout() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-background">
|
||||
<Header />
|
||||
<main className="flex-1 pt-20">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ export function PersonaPersonality({ persona }: PersonaPersonalityProps) {
|
|||
<span className="font-medium">{oceanData[0].value}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-purple-500 rounded-full" style={{ width: `${oceanData[0].value}%` }}></div>
|
||||
<div className="h-full bg-primary rounded-full" style={{ width: `${oceanData[0].value}%` }}></div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{oceanData[0].value > 75 ? 'Highly creative and curious' :
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Navigation from '@/components/Navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
|
|
@ -211,7 +210,7 @@ export default function PersonaProfile() {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<Navigation />
|
||||
|
||||
|
||||
<main className="pt-20 pb-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||
{isEditing ? (
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import Navigation from '@/components/Navigation';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export function PersonaProfileSkeleton() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<Navigation />
|
||||
|
||||
|
||||
<main className="pt-20 pb-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||
{/* Header with back button and title */}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export function PersonaSidebar({ persona }: PersonaSidebarProps) {
|
|||
<span>{persona.brandLoyalty}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-purple-500 rounded-full" style={{ width: `${persona.brandLoyalty}%` }}></div>
|
||||
<div className="h-full bg-primary rounded-full" style={{ width: `${persona.brandLoyalty}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
164
src/index.css
164
src/index.css
|
|
@ -1,4 +1,4 @@
|
|||
@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');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
|
|
@ -6,47 +6,47 @@
|
|||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Cohorta — dark navy + cyan palette (AImpress style) */
|
||||
--background: 222 47% 4%;
|
||||
--foreground: 210 40% 96%;
|
||||
/* Cohorta — AIMPRESS rebrand: warm dark + orange */
|
||||
--background: 220 22% 12%;
|
||||
--foreground: 30 18% 92%;
|
||||
|
||||
--card: 222 45% 7%;
|
||||
--card-foreground: 210 40% 96%;
|
||||
--card: 220 20% 16%;
|
||||
--card-foreground: 30 18% 92%;
|
||||
|
||||
--popover: 222 45% 7%;
|
||||
--popover-foreground: 210 40% 96%;
|
||||
--popover: 220 22% 14%;
|
||||
--popover-foreground: 30 18% 92%;
|
||||
|
||||
/* Cyan primary */
|
||||
--primary: 188 91% 44%;
|
||||
--primary-foreground: 222 47% 4%;
|
||||
/* Orange primary */
|
||||
--primary: 28 78% 56%;
|
||||
--primary-foreground: 220 25% 10%;
|
||||
|
||||
--secondary: 222 38% 13%;
|
||||
--secondary-foreground: 210 40% 80%;
|
||||
--secondary: 220 18% 20%;
|
||||
--secondary-foreground: 30 12% 78%;
|
||||
|
||||
--muted: 222 38% 11%;
|
||||
--muted-foreground: 215 20% 50%;
|
||||
--muted: 220 16% 22%;
|
||||
--muted-foreground: 30 10% 58%;
|
||||
|
||||
/* Violet accent */
|
||||
--accent: 258 89% 66%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
/* Orange accent — used for full-bleed orange band sections */
|
||||
--accent: 28 85% 60%;
|
||||
--accent-foreground: 220 25% 10%;
|
||||
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--border: 222 38% 16%;
|
||||
--input: 222 38% 13%;
|
||||
--ring: 188 91% 44%;
|
||||
--border: 220 16% 24%;
|
||||
--input: 220 18% 18%;
|
||||
--ring: 28 78% 56%;
|
||||
|
||||
--radius: 0.75rem;
|
||||
--radius: 1rem;
|
||||
|
||||
--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%;
|
||||
--sidebar-background: 220 22% 10%;
|
||||
--sidebar-foreground: 30 18% 92%;
|
||||
--sidebar-primary: 28 78% 56%;
|
||||
--sidebar-primary-foreground: 220 25% 10%;
|
||||
--sidebar-accent: 220 16% 20%;
|
||||
--sidebar-accent-foreground: 30 12% 78%;
|
||||
--sidebar-border: 220 16% 22%;
|
||||
--sidebar-ring: 28 78% 56%;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: 'Syne', system-ui, sans-serif;
|
||||
font-family: 'Space Grotesk', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
|
|
@ -75,42 +75,42 @@
|
|||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(222 38% 20%);
|
||||
background: hsl(220 16% 28%);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(188 91% 44% / 0.5);
|
||||
background: hsl(28 78% 56% / 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Glass cards — dark */
|
||||
/* Glass cards — warm dark */
|
||||
.glass-card {
|
||||
background: linear-gradient(135deg, hsl(222 45% 9% / 0.8), hsl(222 45% 7% / 0.9));
|
||||
background: linear-gradient(135deg, hsl(220 20% 18% / 0.85), hsl(220 22% 14% / 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);
|
||||
border: 1px solid hsl(220 16% 26% / 0.6);
|
||||
box-shadow: 0 4px 24px hsl(220 25% 6% / 0.45);
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: hsl(222 45% 8% / 0.7);
|
||||
background: hsl(220 20% 16% / 0.75);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid hsl(222 38% 18% / 0.5);
|
||||
border: 1px solid hsl(220 16% 24% / 0.5);
|
||||
}
|
||||
|
||||
/* Gradient text — cyan to violet */
|
||||
/* Gradient text — orange to warm amber */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%);
|
||||
background: linear-gradient(135deg, hsl(28 78% 56%) 0%, hsl(38 90% 65%) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Gradient border card */
|
||||
/* Gradient border card — orange glow on hover */
|
||||
.gradient-border-card {
|
||||
position: relative;
|
||||
background: hsl(222 45% 7%);
|
||||
background: hsl(220 20% 16%);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.gradient-border-card::before {
|
||||
|
|
@ -119,7 +119,7 @@
|
|||
inset: 0;
|
||||
border-radius: var(--radius);
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, #06B6D4, #8B5CF6);
|
||||
background: linear-gradient(135deg, hsl(28 78% 56%), hsl(38 90% 65%));
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
|
|
@ -130,33 +130,34 @@
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
/* CTA gradient button */
|
||||
/* CTA primary button */
|
||||
.btn-gradient {
|
||||
background: linear-gradient(135deg, #06B6D4 0%, #7C3AED 100%);
|
||||
color: white;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 0 20px hsl(188 91% 44% / 0.25);
|
||||
box-shadow: 0 0 20px hsl(28 78% 56% / 0.3);
|
||||
}
|
||||
.btn-gradient:hover {
|
||||
box-shadow: 0 0 32px hsl(188 91% 44% / 0.45);
|
||||
background: hsl(28 78% 62%);
|
||||
box-shadow: 0 0 32px hsl(28 78% 56% / 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-gradient:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Ghost button — white outline */
|
||||
/* Ghost button — subtle outline */
|
||||
.btn-ghost-outline {
|
||||
background: transparent;
|
||||
color: hsl(210 40% 96%);
|
||||
border: 1px solid hsl(222 38% 25%);
|
||||
color: hsl(var(--foreground));
|
||||
border: 1px solid hsl(220 16% 30%);
|
||||
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);
|
||||
border-color: hsl(28 78% 56% / 0.6);
|
||||
color: hsl(28 78% 65%);
|
||||
background: hsl(28 78% 56% / 0.06);
|
||||
}
|
||||
|
||||
/* Animated glow orb */
|
||||
|
|
@ -167,6 +168,49 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Outline display text — huge hero-scale, stroke only */
|
||||
.outline-display {
|
||||
color: transparent;
|
||||
-webkit-text-stroke: 2px hsl(var(--foreground) / 0.15);
|
||||
text-stroke: 2px hsl(var(--foreground) / 0.15);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Full-bleed orange band sections */
|
||||
.orange-band {
|
||||
background: hsl(var(--accent));
|
||||
color: hsl(var(--accent-foreground));
|
||||
}
|
||||
|
||||
/* Corner card — dark card with diagonal graphic overlay (like AIMPRESS stat cards) */
|
||||
.corner-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.corner-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -30%;
|
||||
right: -20%;
|
||||
width: 60%;
|
||||
height: 160%;
|
||||
background: hsl(28 78% 56% / 0.06);
|
||||
border-radius: 50%;
|
||||
transform: rotate(-15deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Persona orb — circular image mask */
|
||||
.persona-orb {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Persona card */
|
||||
.persona-card {
|
||||
@apply relative overflow-hidden;
|
||||
|
|
@ -178,19 +222,19 @@
|
|||
}
|
||||
|
||||
.persona-card:hover .persona-card-overlay {
|
||||
background: hsl(188 91% 44% / 0.08);
|
||||
background: hsl(28 78% 56% / 0.07);
|
||||
}
|
||||
|
||||
.persona-card.selected .persona-card-overlay {
|
||||
background: hsl(188 91% 44% / 0.10);
|
||||
background: hsl(28 78% 56% / 0.10);
|
||||
}
|
||||
|
||||
.persona-card-checkmark {
|
||||
@apply absolute top-3 left-3 z-20 opacity-0 transition-all duration-200 ease-out;
|
||||
background: hsl(222 45% 12% / 0.9);
|
||||
background: hsl(220 22% 14% / 0.9);
|
||||
border-radius: 9999px;
|
||||
padding: 4px;
|
||||
border: 1px solid hsl(222 38% 22%);
|
||||
border: 1px solid hsl(220 16% 26%);
|
||||
}
|
||||
|
||||
.persona-card.selected .persona-card-checkmark {
|
||||
|
|
@ -200,7 +244,7 @@
|
|||
/* Sidebar utilities */
|
||||
.sidebar-icon {
|
||||
@apply h-4 w-4 mr-3 mt-0.5 flex-shrink-0;
|
||||
color: hsl(215 20% 50%);
|
||||
color: hsl(30 10% 55%);
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
|
|
|
|||
|
|
@ -123,8 +123,11 @@ export const authApi = {
|
|||
register: (username: string, email: string, password: string) =>
|
||||
api.post('/auth/register', { username, email, password }),
|
||||
|
||||
getProfile: () =>
|
||||
api.get('/auth/me')
|
||||
getProfile: () =>
|
||||
api.get('/auth/me'),
|
||||
|
||||
resendVerification: () =>
|
||||
api.post('/auth/resend-verification'),
|
||||
};
|
||||
|
||||
// Billing endpoints
|
||||
|
|
|
|||
|
|
@ -2,12 +2,7 @@ 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';
|
||||
import { Loader2, CreditCard, Zap, TrendingUp, Package, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
interface CreditPack {
|
||||
id: string;
|
||||
|
|
@ -23,6 +18,7 @@ interface Transaction {
|
|||
balance_after: number;
|
||||
description: string;
|
||||
ts: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface BalanceData {
|
||||
|
|
@ -32,12 +28,21 @@ interface BalanceData {
|
|||
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' },
|
||||
const TX_LABELS: Record<string, string> = {
|
||||
purchase: 'Purchase', grant: 'Free grant', admin_grant: 'Admin grant', debit: 'Used', refund: 'Refund',
|
||||
};
|
||||
const TX_COLORS: Record<string, string> = {
|
||||
purchase: 'text-primary', grant: 'text-green-400', admin_grant: 'text-green-400', debit: 'text-red-400', refund: 'text-blue-400',
|
||||
};
|
||||
const TX_BG: Record<string, string> = {
|
||||
purchase: 'bg-primary/10 text-primary', grant: 'bg-green-400/10 text-green-400', admin_grant: 'bg-green-400/10 text-green-400',
|
||||
debit: 'bg-red-400/10 text-red-400', refund: 'bg-blue-400/10 text-blue-400',
|
||||
};
|
||||
|
||||
const PACK_FEATURES: Record<string, string[]> = {
|
||||
starter: ['50 credits', '~25 personas', '1 focus group run'],
|
||||
pro: ['220 credits', '~110 personas', '5 focus group runs', 'Bulk export'],
|
||||
scale: ['600 credits', '~300 personas', '15 focus group runs', 'Unlimited exports', 'API access'],
|
||||
};
|
||||
|
||||
export default function Billing() {
|
||||
|
|
@ -49,7 +54,7 @@ export default function Billing() {
|
|||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('success')) {
|
||||
toastService.success('Payment successful!', { description: 'Your credits have been added to your account.' });
|
||||
toastService.success('Payment successful!', { description: 'Credits have been added to your account.' });
|
||||
}
|
||||
if (searchParams.get('cancelled')) {
|
||||
toastService.info('Payment cancelled.');
|
||||
|
|
@ -64,8 +69,8 @@ export default function Billing() {
|
|||
billingApi.getTransactions(20),
|
||||
]);
|
||||
setBalanceData(balRes.data);
|
||||
setTransactions(txRes.data.transactions);
|
||||
} catch (e: any) {
|
||||
setTransactions(txRes.data.transactions || []);
|
||||
} catch {
|
||||
toastService.error('Failed to load billing data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -78,9 +83,7 @@ export default function Billing() {
|
|||
setCheckoutPack(packId);
|
||||
try {
|
||||
const res = await billingApi.createCheckout(packId);
|
||||
if (res.data.checkout_url) {
|
||||
window.location.href = res.data.checkout_url;
|
||||
}
|
||||
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);
|
||||
|
|
@ -89,126 +92,128 @@ export default function Billing() {
|
|||
|
||||
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 className="max-w-4xl mx-auto py-10 px-4 sm:px-6 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>
|
||||
<h1 className="font-display font-bold text-3xl text-foreground">Billing</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage your credits and purchase history</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</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>
|
||||
{/* Balance */}
|
||||
<div className="corner-card p-8 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-xl bg-primary/15 flex items-center justify-center">
|
||||
<Zap className="h-6 w-6 text-primary" />
|
||||
</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>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-widest mb-0.5">Credit balance</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-display font-black text-5xl text-foreground">{balanceData.credits_balance.toLocaleString()}</span>
|
||||
<span className="text-lg text-muted-foreground">credits</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-6 text-sm text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<p className="font-bold text-foreground text-lg">{balanceData.persona_cost} cr</p>
|
||||
<p className="text-xs">per persona</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-bold text-foreground text-lg">{balanceData.run_cost} cr</p>
|
||||
<p className="text-xs">per FG run</p>
|
||||
</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 */}
|
||||
{/* Credit packs */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
Transaction History
|
||||
<h2 className="font-display font-semibold text-xl text-foreground mb-5 flex items-center gap-2">
|
||||
<Package className="h-5 w-5 text-primary" /> Buy credits
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
|
||||
{balanceData.credit_packs.map(pack => {
|
||||
const isPopular = pack.id === 'pro';
|
||||
const features = PACK_FEATURES[pack.id] || [];
|
||||
return (
|
||||
<div
|
||||
key={pack.id}
|
||||
className={`relative rounded-2xl p-7 ${isPopular ? 'bg-card border-2 border-primary shadow-lg shadow-primary/10' : 'bg-card border border-border'}`}
|
||||
>
|
||||
{isPopular && (
|
||||
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2">
|
||||
<span className="px-4 py-1 rounded-full text-xs font-bold bg-primary text-primary-foreground">Most popular</span>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="font-display font-bold text-lg text-foreground mb-1">{pack.name}</h3>
|
||||
<div className="flex items-end gap-1 mb-4">
|
||||
<span className="font-display font-black text-3xl text-foreground">${pack.price_usd}</span>
|
||||
<span className="text-muted-foreground text-sm mb-1">one-time</span>
|
||||
</div>
|
||||
<ul className="space-y-2 mb-6">
|
||||
{features.map(f => (
|
||||
<li key={f} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-primary flex-shrink-0" />{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
onClick={() => handleBuyPack(pack.id)}
|
||||
disabled={checkoutPack !== null}
|
||||
className={`w-full py-2.5 rounded-xl text-sm font-semibold transition-all flex items-center justify-center gap-2 ${
|
||||
isPopular
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'border border-border text-foreground hover:border-primary/50 hover:text-primary'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{checkoutPack === pack.id ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" /> Redirecting…</>
|
||||
) : (
|
||||
<><CreditCard className="h-4 w-4" /> Buy {pack.name}</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transactions */}
|
||||
<div>
|
||||
<h2 className="font-display font-semibold text-xl text-foreground mb-5 flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-primary" /> Transaction history
|
||||
</h2>
|
||||
{transactions.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No transactions yet.</p>
|
||||
<p className="text-muted-foreground text-sm text-center py-8">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 className="bg-card border border-border rounded-2xl overflow-hidden">
|
||||
{transactions.map((tx, i) => (
|
||||
<div key={tx._id} className={`flex items-center justify-between px-5 py-4 ${i < transactions.length - 1 ? 'border-b border-border' : ''} hover:bg-secondary/30 transition-colors`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2.5 py-1 rounded-lg text-xs font-semibold ${TX_BG[tx.type] || 'bg-secondary text-muted-foreground'}`}>
|
||||
{TX_LABELS[tx.type] || tx.type}
|
||||
</span>
|
||||
<div>
|
||||
{tx.description && <p className="text-sm font-medium text-foreground">{tx.description}</p>}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(tx.ts || tx.created_at || '').toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-4">
|
||||
<p className={`text-sm font-semibold ${TX_COLORS[tx.type] || 'text-foreground'}`}>
|
||||
{tx.amount > 0 ? '+' : ''}{tx.amount} cr
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{tx.balance_after} left</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import Navigation from '@/components/Navigation';
|
||||
import ParticipantPanel from '@/components/focus-group-session/ParticipantPanel';
|
||||
import DiscussionPanel from '@/components/focus-group-session/DiscussionPanel';
|
||||
import ThemesPanel from '@/components/focus-group-session/ThemesPanel';
|
||||
|
|
@ -1762,7 +1761,7 @@ const FocusGroupSession = () => {
|
|||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 pt-20 pb-16 px-4">
|
||||
<Navigation />
|
||||
|
||||
<div className="max-w-7xl mx-auto text-center py-12">
|
||||
<div className="flex justify-center items-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
|
|
@ -1777,7 +1776,7 @@ const FocusGroupSession = () => {
|
|||
if (!focusGroup) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 pt-20 pb-16 px-4">
|
||||
<Navigation />
|
||||
|
||||
<div className="max-w-7xl mx-auto text-center py-12">
|
||||
<h1 className="text-2xl font-bold">Focus group not found</h1>
|
||||
<p className="mt-2 text-slate-600">We couldn't find the focus group you're looking for.</p>
|
||||
|
|
@ -1794,7 +1793,7 @@ const FocusGroupSession = () => {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<Navigation />
|
||||
|
||||
|
||||
{/* WebSocket Connection Status Bar */}
|
||||
{useWebSocketEnabled && isStatusBarVisible && (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import Navigation from '@/components/Navigation';
|
||||
import FocusGroupModerator from '@/components/FocusGroupModerator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
@ -66,7 +65,7 @@ const statusColors = {
|
|||
'scheduled': 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
'in-progress': 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
'active': 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
'paused': 'bg-purple-100 text-purple-800 border-purple-200',
|
||||
'paused': 'bg-primary/10 text-primary border-primary/20',
|
||||
'new': 'bg-slate-100 text-slate-800 border-slate-200',
|
||||
'ai_mode': 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
'draft': 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
|
|
@ -286,7 +285,7 @@ const FocusGroups = () => {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<Navigation />
|
||||
|
||||
|
||||
<main className="pt-20 pb-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8">
|
||||
|
|
|
|||
|
|
@ -1,381 +1,519 @@
|
|||
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 { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Users, MessageSquare, Sparkles, Download, ArrowRight, ArrowDown,
|
||||
Zap, Clock, DollarSign, Globe, ChevronDown, ChevronUp,
|
||||
CheckCircle2, Star
|
||||
} from 'lucide-react';
|
||||
import { billingApi } from '@/lib/api';
|
||||
|
||||
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>
|
||||
// ──────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
const AVATAR_COLORS = ['#E89B3C', '#D97706', '#B45309', '#92400E'];
|
||||
|
||||
const PersonaOrb = ({ index, size, label }: { index: number; size: string; label?: string }) => (
|
||||
<div
|
||||
className={`persona-orb ${size} relative flex-shrink-0`}
|
||||
style={{ background: `linear-gradient(135deg, ${AVATAR_COLORS[index % 4]}, ${AVATAR_COLORS[(index + 2) % 4]})` }}
|
||||
>
|
||||
{label && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-2xl font-display font-bold text-white/90">{label}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 rounded-full bg-gradient-to-br from-white/15 to-transparent" />
|
||||
</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>
|
||||
);
|
||||
interface CreditPack {
|
||||
id: string;
|
||||
name: string;
|
||||
price_usd: number;
|
||||
credits: number;
|
||||
popular?: boolean;
|
||||
}
|
||||
|
||||
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 DEFAULT_PACKS: CreditPack[] = [
|
||||
{ id: 'starter', name: 'Starter', price_usd: 49, credits: 50 },
|
||||
{ id: 'pro', name: 'Pro', price_usd: 199, credits: 220, popular: true },
|
||||
{ id: 'scale', name: 'Scale', price_usd: 499, credits: 600 },
|
||||
];
|
||||
|
||||
const Index = () => {
|
||||
const PACK_FEATURES: Record<string, string[]> = {
|
||||
starter: ['50 credits', '~25 AI personas', '1 focus group run', 'Export transcripts', 'Email support'],
|
||||
pro: ['220 credits', '~110 AI personas', '5 focus group runs', 'Bulk export', 'Priority support', 'Advanced analytics'],
|
||||
scale: ['600 credits', '~300 AI personas', '15 focus group runs', 'Unlimited exports', 'Dedicated support', 'Custom prompts', 'API access'],
|
||||
};
|
||||
|
||||
const FAQ_ITEMS = [
|
||||
{
|
||||
q: 'What is a synthetic persona?',
|
||||
a: 'A synthetic persona is an AI-generated profile that mimics a real human respondent — complete with demographics, psychographics, attitudes, and communication style. Unlike survey panels, synthetic personas are available instantly, cost nothing per respondent, and scale to thousands.',
|
||||
},
|
||||
{
|
||||
q: 'How is this different from a real focus group?',
|
||||
a: 'Traditional focus groups take 2–4 weeks to recruit, cost $5,000–$20,000, and max out at 12 participants. Cohorta generates your panel in minutes, runs sessions 24/7, and lets you test dozens of segments in parallel — all for the cost of a SaaS subscription.',
|
||||
},
|
||||
{
|
||||
q: 'How accurate are the AI personas?',
|
||||
a: 'Our two-stage generation pipeline builds each persona from a detailed audience brief, creating internally consistent profiles with demographic context, psychographic depth, and realistic response patterns. Results are directionally accurate for concept testing, message testing, and exploratory research.',
|
||||
},
|
||||
{
|
||||
q: 'Is my research data secure?',
|
||||
a: "All data is encrypted in transit (TLS 1.3) and at rest. Each user's personas and sessions are fully isolated. We do not use your research data to train AI models. Infrastructure is hosted on EU servers by AImpress LTD.",
|
||||
},
|
||||
{
|
||||
q: 'How does the credit system work?',
|
||||
a: 'Creating a persona costs 2 credits. Running a full focus group session costs 40 credits. You get 10 free trial credits on signup. Purchase credit packs as needed — credits never expire.',
|
||||
},
|
||||
{
|
||||
q: 'Can I export the results?',
|
||||
a: 'Yes. Download full discussion transcripts as Markdown, export personas as CSV, and generate structured discussion guides. Pro and Scale plans include bulk export.',
|
||||
},
|
||||
];
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// FAQ accordion item
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
function FAQItem({ q, a }: { q: string; a: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="min-h-screen bg-[hsl(222_47%_4%)] overflow-hidden">
|
||||
<Navigation />
|
||||
<div
|
||||
className="border border-border rounded-2xl overflow-hidden cursor-pointer"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-5 bg-card hover:bg-secondary/40 transition-colors">
|
||||
<p className="font-semibold text-foreground pr-4">{q}</p>
|
||||
{open
|
||||
? <ChevronUp className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
: <ChevronDown className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="px-6 py-5 bg-card/60 border-t border-border">
|
||||
<p className="text-muted-foreground leading-relaxed text-sm">{a}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<main>
|
||||
<Hero />
|
||||
// ──────────────────────────────────────────────
|
||||
// Main page
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
{/* Features */}
|
||||
<section className="py-24 px-6 relative">
|
||||
export default function Index() {
|
||||
const navigate = useNavigate();
|
||||
const [packs, setPacks] = useState<CreditPack[]>(DEFAULT_PACKS);
|
||||
|
||||
useEffect(() => {
|
||||
billingApi.getBalance()
|
||||
.then(r => {
|
||||
const apiPacks: CreditPack[] = (r.data.credit_packs || []).map((p: any) => ({
|
||||
...p,
|
||||
popular: p.id === 'pro',
|
||||
}));
|
||||
if (apiPacks.length >= 2) setPacks(apiPacks);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-background overflow-hidden">
|
||||
|
||||
{/* ── 1. HERO ── */}
|
||||
<section className="relative min-h-screen flex flex-col justify-center overflow-hidden -mt-20 pt-20">
|
||||
<div
|
||||
className="glow-orb w-[700px] h-[400px] left-1/2 -translate-x-1/2 top-1/3 opacity-20"
|
||||
style={{ background: 'radial-gradient(ellipse, hsl(28 78% 56%), transparent 70%)' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.025]"
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(hsl(30 18% 92%) 1px, transparent 1px), linear-gradient(90deg, hsl(30 18% 92%) 1px, transparent 1px)',
|
||||
backgroundSize: '60px 60px',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative max-w-7xl mx-auto px-6 w-full">
|
||||
{/* Outline hero text */}
|
||||
<div
|
||||
className="glow-orb w-[500px] h-[300px] left-1/2 -translate-x-1/2 top-0 opacity-10"
|
||||
style={{ background: 'radial-gradient(ellipse, #8B5CF6, transparent 70%)' }}
|
||||
/>
|
||||
<div className="max-w-7xl mx-auto relative">
|
||||
<div className="text-center mb-16">
|
||||
<SectionLabel>Why Cohorta?</SectionLabel>
|
||||
<h2 className="font-display font-bold text-4xl sm:text-5xl text-white mb-4">
|
||||
Everything you need for{' '}
|
||||
<span
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
better research
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[hsl(215_20%_55%)] text-lg max-w-2xl mx-auto">
|
||||
Our platform combines advanced AI with intuitive design to help researchers
|
||||
gain deeper insights faster than traditional methods.
|
||||
</p>
|
||||
</div>
|
||||
className="absolute inset-x-0 flex items-center justify-center select-none pointer-events-none"
|
||||
style={{ top: '50%', transform: 'translateY(-50%)' }}
|
||||
>
|
||||
<span
|
||||
className="outline-display font-display font-black whitespace-nowrap"
|
||||
style={{ fontSize: 'clamp(72px, 16vw, 220px)', lineHeight: 1 }}
|
||||
>
|
||||
COHORTA
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<FeatureCard
|
||||
title="Scalable Personas"
|
||||
description="Create thousands of synthetic personas with unique demographic profiles, behaviors, and psychographic depth."
|
||||
icon={Users}
|
||||
gradient="from-[#06B6D4] to-[#0284C7]"
|
||||
/>
|
||||
<FeatureCard
|
||||
title="AI Focus Groups"
|
||||
description="Run autonomous focus groups moderated by AI that adapts to participant responses in real-time."
|
||||
icon={MessageSquare}
|
||||
gradient="from-[#8B5CF6] to-[#6D28D9]"
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Instant Analysis"
|
||||
description="Generate comprehensive reports with sentiment analysis, key themes, and actionable recommendations."
|
||||
icon={LayoutDashboard}
|
||||
gradient="from-[#06B6D4] to-[#8B5CF6]"
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Deep AI Insights"
|
||||
description="Claude-powered AI extracts patterns and nuances that traditional focus groups often miss."
|
||||
icon={Brain}
|
||||
gradient="from-[#F59E0B] to-[#EF4444]"
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Global Perspectives"
|
||||
description="Access synthetic personas from 50+ countries — ensuring representation across age, culture, and location."
|
||||
icon={Globe}
|
||||
gradient="from-[#10B981] to-[#06B6D4]"
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Precision Targeting"
|
||||
description="Define hyper-specific audience segments for research that maps exactly to your target market."
|
||||
icon={Target}
|
||||
gradient="from-[#8B5CF6] to-[#EC4899]"
|
||||
/>
|
||||
{/* Persona orbs */}
|
||||
<div className="relative flex items-center justify-center gap-4 md:gap-6 mb-12 h-64 md:h-80 z-10">
|
||||
<div className="relative mt-8 z-10">
|
||||
<PersonaOrb index={0} size="w-44 h-44 md:w-56 md:h-56" label="A" />
|
||||
<div className="absolute -top-4 -left-6 bg-primary text-primary-foreground rounded-2xl p-3 shadow-xl w-44">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="text-xs font-bold uppercase tracking-wide">Cohorta</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold leading-tight">Generate.<br />Moderate.<br />Decide.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10 -mt-8">
|
||||
<PersonaOrb index={1} size="w-36 h-36 md:w-44 md:h-44" label="B" />
|
||||
</div>
|
||||
<div className="relative z-10 mt-4">
|
||||
<PersonaOrb index={2} size="w-40 h-40 md:w-52 md:h-52" label="C" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works */}
|
||||
<section className="py-24 px-6 relative">
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.025]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(hsl(210 40% 96%) 1px, transparent 1px), linear-gradient(90deg, hsl(210 40% 96%) 1px, transparent 1px)',
|
||||
backgroundSize: '64px 64px',
|
||||
}}
|
||||
/>
|
||||
<div className="max-w-7xl mx-auto relative">
|
||||
<div className="text-center mb-16">
|
||||
<SectionLabel>Simple Process</SectionLabel>
|
||||
<h2 className="font-display font-bold text-4xl sm:text-5xl text-white mb-4">
|
||||
From idea to insight in{' '}
|
||||
<span
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
minutes
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[hsl(215_20%_55%)] text-lg max-w-xl mx-auto">
|
||||
Three simple steps to gather valuable insights from synthetic personas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative grid grid-cols-1 md:grid-cols-3 gap-12">
|
||||
{/* Connector line */}
|
||||
<div
|
||||
className="hidden md:block absolute top-7 left-[calc(16.66%+28px)] right-[calc(16.66%+28px)] h-px"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, #06B6D4, #8B5CF6)',
|
||||
opacity: 0.25,
|
||||
}}
|
||||
/>
|
||||
<StepCard
|
||||
step="1"
|
||||
title="Create Synthetic Personas"
|
||||
description="Define your target audience with customizable demographic profiles and personality traits using AI."
|
||||
/>
|
||||
<StepCard
|
||||
step="2"
|
||||
title="Set Up Focus Groups"
|
||||
description="Configure your research objectives, topics, and parameters — the AI moderator handles the rest."
|
||||
/>
|
||||
<StepCard
|
||||
step="3"
|
||||
title="Analyse Results"
|
||||
description="Review visual reports with sentiment analysis, key themes, and actionable insights in minutes."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-14">
|
||||
<Link
|
||||
to="/synthetic-users"
|
||||
className="inline-flex items-center gap-2 px-8 py-4 rounded-xl font-semibold text-white transition-all duration-200"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #06B6D4 0%, #7C3AED 100%)',
|
||||
boxShadow: '0 0 28px hsl(188 91% 44% / 0.3)',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
(e.currentTarget as HTMLElement).style.boxShadow = '0 0 48px hsl(188 91% 44% / 0.5)';
|
||||
(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
(e.currentTarget as HTMLElement).style.boxShadow = '0 0 28px hsl(188 91% 44% / 0.3)';
|
||||
(e.currentTarget as HTMLElement).style.transform = 'translateY(0)';
|
||||
}}
|
||||
{/* Copy + CTA */}
|
||||
<div className="relative z-10 text-center max-w-3xl mx-auto">
|
||||
<p className="text-xl md:text-2xl text-muted-foreground mb-8 leading-relaxed">
|
||||
Skip recruiting. Run{' '}
|
||||
<span className="text-foreground font-semibold">synthetic focus groups</span>{' '}
|
||||
in minutes — at any scale.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/register')}
|
||||
className="px-8 py-4 rounded-full text-base font-semibold bg-primary text-primary-foreground hover:bg-primary/90 transition-all duration-200 shadow-lg hover:shadow-primary/25 hover:shadow-2xl hover:-translate-y-0.5"
|
||||
>
|
||||
Get Started Free
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
Start free — 10 trial credits
|
||||
</button>
|
||||
<Link
|
||||
to="/login"
|
||||
className="px-8 py-4 rounded-full text-base font-medium border border-border hover:border-primary/40 text-muted-foreground hover:text-foreground transition-all"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-4">No credit card required • 10 free credits on signup</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Banner */}
|
||||
<section className="py-20 px-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div
|
||||
className="relative overflow-hidden rounded-3xl p-12 text-center"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, hsl(188 91% 20% / 0.4) 0%, hsl(258 89% 30% / 0.4) 100%)',
|
||||
border: '1px solid hsl(188 91% 44% / 0.2)',
|
||||
}}
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce z-10">
|
||||
<ArrowDown className="h-5 w-5 text-muted-foreground/40" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 2. STATS TRIPLET ── */}
|
||||
<section className="py-20 px-6">
|
||||
<div className="max-w-5xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{[
|
||||
{ icon: Clock, stat: '10×', label: 'FASTER', sub: 'Insights in hours, not weeks' },
|
||||
{ icon: DollarSign, stat: '99%', label: 'CHEAPER', sub: 'No recruiter, incentives, or no-shows' },
|
||||
{ icon: Globe, stat: '24/7', label: 'SCALE', sub: 'Run 50 sessions in parallel' },
|
||||
].map(({ icon: Icon, stat, label, sub }) => (
|
||||
<div key={label} className="corner-card p-8">
|
||||
<div className="h-10 w-10 rounded-xl bg-primary/15 flex items-center justify-center mb-4">
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="font-display font-black text-5xl text-foreground mb-1">{stat}</div>
|
||||
<div className="text-xs font-bold tracking-widest text-primary uppercase mb-2">{label}</div>
|
||||
<p className="text-sm text-muted-foreground">{sub}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 3. ORANGE BAND — FEATURES ── */}
|
||||
<section className="orange-band py-20 px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="font-display font-bold text-3xl md:text-4xl mb-3">
|
||||
Built for product, marketing & UX researchers
|
||||
</h2>
|
||||
<p className="text-primary-foreground/70 text-lg max-w-xl mx-auto">
|
||||
Everything you need to generate insight — without recruiting a single real participant.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
{[
|
||||
{ icon: Users, title: 'AI Personas', desc: 'Two-stage generation from one brief. Demographic + psychographic depth in minutes.' },
|
||||
{ icon: MessageSquare, title: 'Focus Groups', desc: 'AI-moderated sessions — autonomous or manual. Real-time chat with your synthetic panel.' },
|
||||
{ icon: Sparkles, title: 'Theme Extraction', desc: 'Live key themes extracted per session. See patterns emerge as your panel speaks.' },
|
||||
{ icon: Download, title: 'Bulk Export', desc: 'Markdown discussion guides, CSV transcripts, full persona profiles — ready to share.' },
|
||||
].map(({ icon: Icon, title, desc }) => (
|
||||
<div key={title} className="bg-primary-foreground/10 border border-primary-foreground/20 rounded-2xl p-6 hover:bg-primary-foreground/15 transition-colors">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary-foreground/15 flex items-center justify-center mb-4">
|
||||
<Icon className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<h3 className="font-display font-bold text-lg mb-2 text-primary-foreground">{title}</h3>
|
||||
<p className="text-sm text-primary-foreground/75 leading-relaxed">{desc}</p>
|
||||
<div className="mt-4 flex items-center gap-1 text-xs font-medium text-primary-foreground/60">
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
<span>Learn more</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 4. HOW IT WORKS ── */}
|
||||
<section className="py-24 px-6" id="product">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-primary/25 bg-primary/5 mb-5">
|
||||
<Zap className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="text-sm font-medium text-primary">How it works</span>
|
||||
</div>
|
||||
<h2 className="font-display font-bold text-3xl md:text-5xl text-foreground">
|
||||
From brief to insight in three steps
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-10">
|
||||
{[
|
||||
{
|
||||
num: '01', title: 'Write a brief',
|
||||
desc: 'Describe your target audience — age range, lifestyle, attitudes, geography. One paragraph is enough.',
|
||||
},
|
||||
{
|
||||
num: '02', title: 'Generate your panel',
|
||||
desc: 'Cohorta builds 5–50 rich synthetic personas from your brief in under 2 minutes. Review and adjust before proceeding.',
|
||||
},
|
||||
{
|
||||
num: '03', title: 'Run your session',
|
||||
desc: 'Launch an AI-moderated focus group — autonomous or manual mode. Export themes and transcripts when done.',
|
||||
},
|
||||
].map(({ num, title, desc }) => (
|
||||
<div key={num} className="flex flex-col items-start">
|
||||
<div
|
||||
className="font-display font-black text-7xl leading-none mb-4 outline-display"
|
||||
style={{ WebkitTextStroke: '2px hsl(28 78% 56% / 0.3)' }}
|
||||
>
|
||||
{num}
|
||||
</div>
|
||||
<h3 className="font-display font-bold text-xl text-foreground mb-3">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center mt-14">
|
||||
<button
|
||||
onClick={() => navigate('/register')}
|
||||
className="px-8 py-4 rounded-full text-base font-semibold bg-primary text-primary-foreground hover:bg-primary/90 transition-all shadow-lg hover:shadow-primary/25 hover:shadow-2xl hover:-translate-y-0.5"
|
||||
>
|
||||
<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>
|
||||
Try it free
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 5. LIVE PREVIEW ── */}
|
||||
<section className="py-20 px-6 bg-[hsl(220_22%_10%)]">
|
||||
<div className="max-w-6xl mx-auto flex flex-col lg:flex-row items-center gap-12">
|
||||
<div className="lg:w-1/2">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-primary/25 bg-primary/5 mb-5">
|
||||
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
|
||||
<span className="text-sm font-medium text-primary">Live session</span>
|
||||
</div>
|
||||
<h2 className="font-display font-bold text-3xl md:text-4xl text-foreground mb-5">
|
||||
Watch your synthetic panel debate your product.
|
||||
</h2>
|
||||
<p className="text-muted-foreground leading-relaxed mb-8">
|
||||
Each persona speaks from their own perspective, challenges other panelists, and responds to the moderator's questions in real time. The AI extracts themes and flags consensus as the session unfolds.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/register')}
|
||||
className="flex items-center gap-2 px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-all"
|
||||
>
|
||||
Run a free session
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Mock chat UI */}
|
||||
<div className="lg:w-1/2 w-full">
|
||||
<div className="bg-card border border-border rounded-2xl overflow-hidden shadow-2xl">
|
||||
<div className="bg-secondary/60 px-5 py-3 flex items-center justify-between border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-primary animate-pulse" />
|
||||
<span className="text-xs font-semibold text-foreground">Session: Product Concept Test A</span>
|
||||
</div>
|
||||
<h2 className="font-display font-bold text-4xl sm:text-5xl text-white mb-4">
|
||||
Ready to transform your research?
|
||||
</h2>
|
||||
<p className="text-[hsl(215_20%_65%)] text-lg mb-8 max-w-2xl mx-auto">
|
||||
Join hundreds of researchers and product teams using Cohorta to make faster,
|
||||
smarter decisions with AI-powered synthetic insights.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row justify-center gap-4">
|
||||
<Link
|
||||
to="/register"
|
||||
className="inline-flex items-center justify-center gap-2 px-8 py-4 rounded-xl font-semibold text-white transition-all duration-200"
|
||||
style={{ background: 'linear-gradient(135deg, #06B6D4 0%, #7C3AED 100%)' }}
|
||||
>
|
||||
Create Free Account
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center justify-center px-8 py-4 rounded-xl font-semibold text-[hsl(210_40%_80%)] border border-[hsl(222_38%_25%)] hover:border-[hsl(188_91%_44%/0.4)] hover:text-white transition-all duration-200"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">4 participants</span>
|
||||
</div>
|
||||
<div className="p-5 space-y-4 max-h-72 overflow-hidden">
|
||||
{[
|
||||
{ name: 'Maya, 32', color: '#E89B3C', msg: 'I love the idea, but $49 feels steep for someone who only does one research project per quarter.' },
|
||||
{ name: 'James, 45', color: '#D97706', msg: "If it replaces even one recruiter day it's already cheaper. Time-to-insight matters more to me." },
|
||||
{ name: 'Sofia, 28', color: '#B45309', msg: "The autonomous mode is what sold me. I don't want to moderate — I want to read results." },
|
||||
{ name: 'Moderator AI', color: 'hsl(28, 78%, 56%)', msg: 'Great point Sofia. Team — how important is moderation control vs. output quality?' },
|
||||
].map(({ name, color, msg }) => (
|
||||
<div key={name} className="flex gap-3">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center text-xs font-bold text-white"
|
||||
style={{ background: color }}
|
||||
>
|
||||
{name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-primary mb-1">{name}</p>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{msg}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-border px-5 py-3 flex items-center gap-2">
|
||||
<div className="flex-1 bg-secondary/50 rounded-xl px-4 py-2.5 text-sm text-muted-foreground/50 select-none">
|
||||
Ask a follow-up…
|
||||
</div>
|
||||
<button className="w-8 h-8 rounded-xl bg-primary flex items-center justify-center flex-shrink-0">
|
||||
<ArrowRight className="h-4 w-4 text-primary-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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>
|
||||
{/* ── 6. TESTIMONIALS ── */}
|
||||
<section className="py-24 px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="font-display font-bold text-3xl md:text-4xl text-foreground">
|
||||
Researchers who switched to synthetic
|
||||
</h2>
|
||||
</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 className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{[
|
||||
{
|
||||
quote: "We cut our concept-testing cycle from 3 weeks to 48 hours. The insights are surprisingly nuanced — the personas push back in ways real respondents would.",
|
||||
name: 'Alex K.', role: 'Product Manager, B2B SaaS', initials: 'AK',
|
||||
},
|
||||
{
|
||||
quote: "I ran six audience segments in one afternoon. That would have taken $40k and two months with traditional research. Directionally accurate for early-stage work.",
|
||||
name: 'Sarah M.', role: 'Marketing Director, Consumer Goods', initials: 'SM',
|
||||
},
|
||||
{
|
||||
quote: "The autonomous moderation is the killer feature. I briefed the system at 9am and had a full transcript + theme report by 9:20. Game-changer for agile research.",
|
||||
name: 'Tom R.', role: 'UX Research Lead, Fintech', initials: 'TR',
|
||||
},
|
||||
].map(({ quote, name, role, initials }) => (
|
||||
<div key={name} className="corner-card p-7">
|
||||
<div className="flex gap-0.5 mb-5">
|
||||
{[...Array(5)].map((_, i) => <Star key={i} className="h-4 w-4 fill-primary text-primary" />)}
|
||||
</div>
|
||||
<p className="text-foreground/80 leading-relaxed mb-6 text-sm">"{quote}"</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-primary/15 border border-primary/30 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-primary">{initials}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">{name}</p>
|
||||
<p className="text-xs text-muted-foreground">{role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
{/* ── 7. PRICING ── */}
|
||||
<section className="py-24 px-6 bg-[hsl(220_22%_10%)]" id="pricing">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-primary/25 bg-primary/5 mb-5">
|
||||
<Zap className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="text-sm font-medium text-primary">Pricing</span>
|
||||
</div>
|
||||
<h2 className="font-display font-bold text-3xl md:text-5xl text-foreground mb-3">
|
||||
Pay per project, not per seat
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-lg">Credits never expire. Start with 10 free.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{packs.map(pack => {
|
||||
const features = PACK_FEATURES[pack.id] || PACK_FEATURES['pro'];
|
||||
return (
|
||||
<div
|
||||
key={pack.id}
|
||||
className={`relative rounded-2xl p-8 ${
|
||||
pack.popular
|
||||
? 'bg-card border-2 border-primary shadow-xl shadow-primary/10'
|
||||
: 'bg-card border border-border'
|
||||
}`}
|
||||
>
|
||||
{pack.popular && (
|
||||
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2">
|
||||
<span className="px-4 py-1 rounded-full text-xs font-bold bg-primary text-primary-foreground shadow-sm">
|
||||
Most popular
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="font-display font-bold text-xl text-foreground mb-1">{pack.name}</h3>
|
||||
<div className="flex items-end gap-1 mb-1">
|
||||
<span className="font-display font-black text-4xl text-foreground">${pack.price_usd}</span>
|
||||
<span className="text-muted-foreground text-sm mb-1.5">one-time</span>
|
||||
</div>
|
||||
<p className="text-xs text-primary font-semibold mb-6">{pack.credits} credits included</p>
|
||||
<ul className="space-y-3 mb-8">
|
||||
{features.map(f => (
|
||||
<li key={f} className="flex items-center gap-2.5 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-primary flex-shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
to={`/register?plan=${pack.id}`}
|
||||
className={`block text-center py-3 px-6 rounded-xl text-sm font-semibold transition-all ${
|
||||
pack.popular
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'border border-border text-foreground hover:border-primary/50 hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-center text-sm text-muted-foreground mt-8">
|
||||
Not sure?{' '}
|
||||
<Link to="/register" className="text-primary hover:underline">Start with 10 free credits</Link>
|
||||
{' '}— no card required.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 8. FAQ ── */}
|
||||
<section className="py-24 px-6">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h2 className="font-display font-bold text-3xl md:text-4xl text-foreground text-center mb-14">
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{FAQ_ITEMS.map(item => <FAQItem key={item.q} {...item} />)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 9. FINAL CTA BANNER ── */}
|
||||
<section className="orange-band py-20 px-6 text-center">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h2 className="font-display font-black text-3xl md:text-5xl mb-4">
|
||||
Try Cohorta free.
|
||||
</h2>
|
||||
<p className="text-primary-foreground/75 text-lg mb-8">
|
||||
10 credits on signup. No credit card required. Results in under 5 minutes.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/register')}
|
||||
className="px-10 py-4 rounded-full bg-primary-foreground text-primary font-bold text-base hover:bg-primary-foreground/90 transition-all shadow-xl hover:-translate-y-0.5 inline-flex items-center gap-2"
|
||||
>
|
||||
Create free account
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ 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 } from 'lucide-react';
|
||||
import Logo from '@/components/brand/Logo';
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(3, 'Username must be at least 3 characters'),
|
||||
|
|
@ -16,23 +17,6 @@ const loginSchema = z.object({
|
|||
|
||||
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();
|
||||
|
|
@ -40,10 +24,10 @@ export default function Login() {
|
|||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const from = location.state?.from || '/';
|
||||
const from = location.state?.from || '/dashboard';
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) navigate('/', { replace: true });
|
||||
if (isAuthenticated) navigate('/dashboard', { replace: true });
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const form = useForm<LoginFormValues>({
|
||||
|
|
@ -64,59 +48,17 @@ export default function Login() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4 relative overflow-hidden"
|
||||
style={{ background: 'hsl(222 47% 4%)' }}
|
||||
>
|
||||
{/* Background orbs */}
|
||||
<div
|
||||
className="absolute w-[500px] h-[500px] -top-32 -left-32 rounded-full opacity-15 pointer-events-none"
|
||||
style={{ background: 'radial-gradient(circle, #06B6D4, transparent 65%)', filter: 'blur(64px)' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute w-[500px] h-[500px] -bottom-32 -right-32 rounded-full opacity-10 pointer-events-none"
|
||||
style={{ background: 'radial-gradient(circle, #8B5CF6, transparent 65%)', filter: 'blur(64px)' }}
|
||||
/>
|
||||
<div className="flex min-h-[calc(100vh-5rem)] overflow-hidden">
|
||||
|
||||
{/* 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',
|
||||
}}
|
||||
/>
|
||||
{/* Left: form */}
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-12 bg-background">
|
||||
<div className="w-full max-w-sm">
|
||||
<Link to="/" className="inline-block mb-8">
|
||||
<Logo withWordmark />
|
||||
</Link>
|
||||
|
||||
<div className="relative w-full max-w-md">
|
||||
{/* Card */}
|
||||
<div
|
||||
className="rounded-2xl p-8"
|
||||
style={{
|
||||
background: 'hsl(222 45% 7%)',
|
||||
border: '1px solid hsl(222 38% 16%)',
|
||||
boxShadow: '0 24px 64px hsl(222 47% 2% / 0.7)',
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<Link to="/" className="flex items-center gap-2.5 mb-1">
|
||||
<LogoMark />
|
||||
<span
|
||||
className="font-display font-bold text-2xl"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
Cohorta
|
||||
</span>
|
||||
</Link>
|
||||
<h1 className="font-display font-bold text-xl text-white mt-4 mb-1">Welcome back</h1>
|
||||
<p className="text-sm text-[hsl(215_20%_50%)]">Sign in to your account</p>
|
||||
</div>
|
||||
<h1 className="font-display font-bold text-3xl text-foreground mb-1">Welcome back</h1>
|
||||
<p className="text-muted-foreground text-sm mb-8">Sign in to your Cohorta account</p>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
|
|
@ -125,17 +67,17 @@ export default function Login() {
|
|||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-[hsl(210_40%_75%)] text-sm font-medium">Username</FormLabel>
|
||||
<FormLabel className="text-foreground/80 text-sm">Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="your_username"
|
||||
{...field}
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
className="h-11 bg-[hsl(222_38%_11%)] border-[hsl(222_38%_18%)] text-white placeholder:text-[hsl(215_20%_35%)] focus:border-[#06B6D4] focus:ring-1 focus:ring-[#06B6D4] transition-colors"
|
||||
className="h-11 bg-secondary border-border text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary transition-colors"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-red-400 text-xs" />
|
||||
<FormMessage className="text-destructive text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -145,7 +87,7 @@ export default function Login() {
|
|||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-[hsl(210_40%_75%)] text-sm font-medium">Password</FormLabel>
|
||||
<FormLabel className="text-foreground/80 text-sm">Password</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
|
|
@ -154,18 +96,18 @@ export default function Login() {
|
|||
{...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"
|
||||
className="h-11 bg-secondary border-border text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary 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"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground 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" />
|
||||
<FormMessage className="text-destructive text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -173,55 +115,50 @@ export default function Login() {
|
|||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full h-11 font-semibold text-white border-none mt-2"
|
||||
style={{
|
||||
background: isLoading ? 'hsl(222 38% 15%)' : 'linear-gradient(135deg, #06B6D4 0%, #7C3AED 100%)',
|
||||
boxShadow: isLoading ? 'none' : '0 0 20px hsl(188 91% 44% / 0.25)',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
className="w-full h-11 font-semibold bg-primary text-primary-foreground hover:bg-primary/90 mt-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Signing in…</>
|
||||
) : (
|
||||
'Sign In'
|
||||
'Sign in'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<p className="text-center text-sm text-[hsl(215_20%_45%)] mt-6">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-semibold hover:underline"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #06B6D4 0%, #8B5CF6 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
<p className="text-center text-sm text-muted-foreground mt-6">
|
||||
No account?{' '}
|
||||
<Link to="/register" className="font-semibold text-primary hover:text-primary/80 transition-colors">
|
||||
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>
|
||||
|
||||
{/* Right: orange panel */}
|
||||
<div className="hidden lg:flex w-1/2 orange-band flex-col items-center justify-center p-16 relative overflow-hidden">
|
||||
{/* Outline display */}
|
||||
<div
|
||||
className="outline-display font-display font-black leading-none mb-10 select-none text-center"
|
||||
style={{
|
||||
fontSize: 'clamp(80px, 12vw, 160px)',
|
||||
WebkitTextStroke: '2px hsl(220 25% 10% / 0.2)',
|
||||
}}
|
||||
>
|
||||
COHORTA
|
||||
</div>
|
||||
<blockquote className="max-w-sm text-center">
|
||||
<p className="text-primary-foreground/80 text-lg leading-relaxed mb-4 font-medium">
|
||||
"We cut concept testing from 3 weeks to 48 hours. The AI personas push back in ways real respondents would."
|
||||
</p>
|
||||
<p className="text-primary-foreground/60 text-sm">— Alex K., Product Manager</p>
|
||||
</blockquote>
|
||||
|
||||
{/* Decorative orbs */}
|
||||
<div className="absolute top-12 right-12 w-28 h-28 rounded-full bg-primary-foreground/10 blur-xl" />
|
||||
<div className="absolute bottom-16 left-8 w-20 h-20 rounded-full bg-primary-foreground/8 blur-xl" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,70 +1,75 @@
|
|||
import { useMyUsage } from '@/hooks/useMyUsage';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Loader2, DollarSign, Zap, Activity } from 'lucide-react';
|
||||
import Navigation from '@/components/Navigation';
|
||||
|
||||
export default function MyUsage() {
|
||||
const { data, isLoading } = useMyUsage();
|
||||
const totals = data?.totals ?? {};
|
||||
const byFeature: any[] = data?.by_feature ?? [];
|
||||
const periodStart = data?.period_start ? new Date(data.period_start).toLocaleDateString() : '—';
|
||||
const periodStart = data?.period_start ? new Date(data.period_start).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) : '—';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navigation />
|
||||
<div className="max-w-4xl mx-auto px-4 py-24">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">My Usage</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Month-to-date since {periodStart}</p>
|
||||
<div className="max-w-4xl mx-auto py-10 px-4 sm:px-6 space-y-8">
|
||||
|
||||
<div>
|
||||
<h1 className="font-display font-bold text-3xl text-foreground">Usage</h1>
|
||||
<p className="text-muted-foreground mt-1">Month-to-date since {periodStart}</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: 'Total Cost (MTD)', value: `$${(totals.total_cost ?? 0).toFixed(4)}`, icon: DollarSign },
|
||||
{ label: 'LLM Calls', value: (totals.calls ?? 0).toLocaleString(), icon: Activity },
|
||||
{ label: 'Total Tokens', value: (((totals.prompt_tokens ?? 0) + (totals.completion_tokens ?? 0)) / 1000).toFixed(1) + 'k', icon: Zap },
|
||||
].map(({ label, value, icon: Icon }) => (
|
||||
<Card key={label}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-1 pt-3 px-4">
|
||||
<CardTitle className="text-xs font-medium text-slate-500">{label}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-slate-400" />
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3">
|
||||
<div className="text-xl font-bold">{value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div key={label} className="corner-card p-6 flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-xl bg-primary/15 flex items-center justify-center flex-shrink-0">
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-widest mb-0.5">{label}</p>
|
||||
<p className="font-display font-bold text-2xl text-foreground">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Feature</TableHead>
|
||||
<TableHead className="text-right">Cost</TableHead>
|
||||
<TableHead className="text-right">Calls</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{byFeature.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-slate-500 py-8">No usage data yet</TableCell>
|
||||
<div>
|
||||
<h2 className="font-display font-semibold text-xl text-foreground mb-4">By Feature</h2>
|
||||
<div className="bg-card border border-border rounded-2xl overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-border hover:bg-transparent">
|
||||
<TableHead className="text-muted-foreground font-medium">Feature</TableHead>
|
||||
<TableHead className="text-right text-muted-foreground font-medium">Cost</TableHead>
|
||||
<TableHead className="text-right text-muted-foreground font-medium">Calls</TableHead>
|
||||
</TableRow>
|
||||
)}
|
||||
{byFeature.map((row: any, i: number) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="text-sm">{row._id ?? '—'}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">${(row.total_cost ?? 0).toFixed(6)}</TableCell>
|
||||
<TableCell className="text-right text-sm">{row.calls}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{byFeature.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground py-10">
|
||||
No usage data yet this month.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{byFeature.map((row: any, i: number) => (
|
||||
<TableRow key={i} className="border-border hover:bg-secondary/30">
|
||||
<TableCell className="text-sm text-foreground font-medium">{row._id ?? '—'}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm text-primary">${(row.total_cost ?? 0).toFixed(6)}</TableCell>
|
||||
<TableCell className="text-right text-sm text-muted-foreground">{row.calls}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,63 +1,48 @@
|
|||
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
const NotFound = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
console.error(
|
||||
"404 Error: User attempted to access non-existent route:",
|
||||
location.pathname
|
||||
);
|
||||
console.error("404:", location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
// Check if this is a persona not found path
|
||||
const isPersonaPath = location.pathname.startsWith("/synthetic-users/");
|
||||
|
||||
// Check if user came from review page
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const fromReview = searchParams.get('fromReview') === 'true';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="text-center p-8 max-w-md bg-white rounded-lg shadow-md">
|
||||
<h1 className="text-4xl font-bold mb-4">404</h1>
|
||||
|
||||
{isPersonaPath ? (
|
||||
<>
|
||||
<p className="text-xl text-gray-600 mb-4">Persona Not Found</p>
|
||||
<p className="text-gray-500 mb-6">
|
||||
The persona you're looking for may have been removed or doesn't exist.
|
||||
</p>
|
||||
{fromReview ? (
|
||||
<Button onClick={() => navigate("/synthetic-users?mode=create&tab=ai&step=review")} className="mb-2 w-full">
|
||||
Return to Review Page
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => navigate("/synthetic-users")} className="mb-2 w-full">
|
||||
View All Personas
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xl text-gray-600 mb-4">Oops! Page not found</p>
|
||||
<p className="text-gray-500 mb-6">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate("/")}
|
||||
className="w-full"
|
||||
<div className="min-h-[calc(100vh-5rem)] flex items-center justify-center px-6 py-16 bg-background">
|
||||
{/* Background glow */}
|
||||
<div
|
||||
className="glow-orb w-[500px] h-[300px] left-1/2 -translate-x-1/2 top-1/3 opacity-15"
|
||||
style={{ background: 'radial-gradient(ellipse, hsl(28 78% 56%), transparent 70%)' }}
|
||||
/>
|
||||
<div className="relative text-center max-w-md">
|
||||
<div
|
||||
className="outline-display font-display font-black leading-none mb-6 select-none"
|
||||
style={{ fontSize: 'clamp(100px, 20vw, 200px)', WebkitTextStroke: '2px hsl(28 78% 56% / 0.25)' }}
|
||||
>
|
||||
Return to Home
|
||||
</Button>
|
||||
404
|
||||
</div>
|
||||
<h1 className="font-display font-bold text-2xl text-foreground mb-3">Page not found</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="px-6 py-2.5 rounded-full border border-border text-muted-foreground hover:border-primary/40 hover:text-foreground text-sm font-medium transition-all"
|
||||
>
|
||||
← Go back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="px-6 py-2.5 rounded-full bg-primary text-primary-foreground text-sm font-semibold hover:bg-primary/90 transition-all flex items-center gap-2"
|
||||
>
|
||||
Home
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ 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 { Loader2, Eye, EyeOff, Mail, CheckCircle2 } from 'lucide-react';
|
||||
import { toastService } from '@/lib/toast';
|
||||
import Logo from '@/components/brand/Logo';
|
||||
import axios from 'axios';
|
||||
|
||||
const registerSchema = z.object({
|
||||
|
|
@ -23,26 +24,9 @@ const registerSchema = z.object({
|
|||
|
||||
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 { isAuthenticated } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
|
@ -50,7 +34,7 @@ export default function Register() {
|
|||
const [registeredEmail, setRegisteredEmail] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) navigate('/', { replace: true });
|
||||
if (isAuthenticated) navigate('/dashboard', { replace: true });
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const form = useForm<RegisterFormValues>({
|
||||
|
|
@ -66,147 +50,74 @@ export default function Register() {
|
|||
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.' });
|
||||
toastService.success('Account created!', { description: 'Check your email to verify.' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || 'Registration failed. Please try again.';
|
||||
toastService.error(msg);
|
||||
toastService.error(err.response?.data?.message || 'Registration failed. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Success state
|
||||
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 className="flex min-h-[calc(100vh-5rem)] items-center justify-center px-6 py-12">
|
||||
<div className="w-full max-w-sm text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/15 border border-primary/25 flex items-center justify-center mx-auto mb-6">
|
||||
<Mail className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="font-display font-bold text-2xl text-foreground mb-3">Check your inbox</h1>
|
||||
<p className="text-muted-foreground mb-2">We sent a verification link to</p>
|
||||
<p className="font-semibold text-primary mb-6 break-all">{registeredEmail}</p>
|
||||
<p className="text-sm text-muted-foreground mb-8">
|
||||
Click the link to verify your account. The link expires in 24 hours.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="w-full py-3 rounded-xl font-semibold bg-primary text-primary-foreground hover:bg-primary/90 transition-all mb-4"
|
||||
>
|
||||
Continue to Dashboard
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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. Try again later.');
|
||||
}
|
||||
}}
|
||||
className="text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
Resend email
|
||||
</button>
|
||||
</p>
|
||||
</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="flex min-h-[calc(100vh-5rem)] overflow-hidden">
|
||||
|
||||
<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>
|
||||
{/* Left: form */}
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-12 bg-background">
|
||||
<div className="w-full max-w-sm">
|
||||
<Link to="/" className="inline-block mb-8">
|
||||
<Logo withWordmark />
|
||||
</Link>
|
||||
|
||||
{/* 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>
|
||||
<h1 className="font-display font-bold text-3xl text-foreground mb-1">Create your account</h1>
|
||||
<p className="text-muted-foreground text-sm mb-8">
|
||||
Free to start. 10 credits on signup.
|
||||
</p>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
|
|
@ -215,17 +126,17 @@ export default function Register() {
|
|||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-[hsl(210_40%_75%)] text-sm font-medium">Username</FormLabel>
|
||||
<FormLabel className="text-foreground/80 text-sm">Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="john_doe"
|
||||
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"
|
||||
className="h-11 bg-secondary border-border text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-red-400 text-xs" />
|
||||
<FormMessage className="text-destructive text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -235,18 +146,18 @@ export default function Register() {
|
|||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-[hsl(210_40%_75%)] text-sm font-medium">Email address</FormLabel>
|
||||
<FormLabel className="text-foreground/80 text-sm">Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="you@company.com"
|
||||
type="email"
|
||||
placeholder="you@company.com"
|
||||
{...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"
|
||||
className="h-11 bg-secondary border-border text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-red-400 text-xs" />
|
||||
<FormMessage className="text-destructive text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -256,7 +167,7 @@ export default function Register() {
|
|||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-[hsl(210_40%_75%)] text-sm font-medium">Password</FormLabel>
|
||||
<FormLabel className="text-foreground/80 text-sm">Password</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
|
|
@ -265,18 +176,15 @@ export default function Register() {
|
|||
{...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"
|
||||
className="h-11 bg-secondary border-border text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary 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"
|
||||
>
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground 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" />
|
||||
<FormMessage className="text-destructive text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -286,84 +194,81 @@ export default function Register() {
|
|||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-[hsl(210_40%_75%)] text-sm font-medium">Confirm password</FormLabel>
|
||||
<FormLabel className="text-foreground/80 text-sm">Confirm password</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="Repeat password"
|
||||
placeholder="••••••••"
|
||||
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"
|
||||
className="h-11 bg-secondary border-border text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary 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"
|
||||
>
|
||||
<button type="button" onClick={() => setShowConfirm(!showConfirm)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground 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" />
|
||||
<FormMessage className="text-destructive 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)',
|
||||
}}
|
||||
className="w-full h-11 font-semibold bg-primary text-primary-foreground hover:bg-primary/90 mt-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating account...
|
||||
</>
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Creating account…</>
|
||||
) : (
|
||||
'Create Free Account'
|
||||
'Create free account'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 text-xs text-muted-foreground pt-1">
|
||||
<span className="flex items-center gap-1"><CheckCircle2 className="h-3.5 w-3.5 text-primary" /> 10 free credits</span>
|
||||
<span className="flex items-center gap-1"><CheckCircle2 className="h-3.5 w-3.5 text-primary" /> No card required</span>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<p className="text-center text-sm text-[hsl(215_20%_45%)] mt-6">
|
||||
<p className="text-center text-sm text-muted-foreground 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',
|
||||
}}
|
||||
>
|
||||
<Link to="/login" className="font-semibold text-primary hover:text-primary/80 transition-colors">
|
||||
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>
|
||||
|
||||
{/* Right: orange panel */}
|
||||
<div className="hidden lg:flex w-1/2 orange-band flex-col items-center justify-center p-16 relative overflow-hidden">
|
||||
<div
|
||||
className="outline-display font-display font-black leading-none mb-10 select-none text-center"
|
||||
style={{ fontSize: 'clamp(80px, 12vw, 160px)', WebkitTextStroke: '2px hsl(220 25% 10% / 0.2)' }}
|
||||
>
|
||||
COHORTA
|
||||
</div>
|
||||
<div className="space-y-4 max-w-xs">
|
||||
{[
|
||||
'10 free credits on signup',
|
||||
'AI personas in under 2 minutes',
|
||||
'Run your first focus group today',
|
||||
'No credit card required',
|
||||
].map(item => (
|
||||
<div key={item} className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-primary-foreground/70 flex-shrink-0" />
|
||||
<p className="text-primary-foreground/80 font-medium">{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute top-12 right-12 w-28 h-28 rounded-full bg-primary-foreground/10 blur-xl" />
|
||||
<div className="absolute bottom-16 left-8 w-20 h-20 rounded-full bg-primary-foreground/8 blur-xl" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigation } from '@/contexts/NavigationContext';
|
||||
import Navigation from '@/components/Navigation';
|
||||
import AIRecruiter from '@/components/AIRecruiter';
|
||||
import UserCreator from '@/components/UserCreator';
|
||||
import UserCard from '@/components/UserCard';
|
||||
|
|
@ -1152,7 +1151,7 @@ const SyntheticUsers = () => {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<Navigation />
|
||||
|
||||
|
||||
<main className="pt-20 pb-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8">
|
||||
|
|
|
|||
|
|
@ -1,30 +1,16 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import Logo from '@/components/brand/Logo';
|
||||
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 { isAuthenticated } = useAuth();
|
||||
const [status, setStatus] = useState<Status>('verifying');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
|
|
@ -32,126 +18,81 @@ export default function VerifyEmail() {
|
|||
const token = searchParams.get('token');
|
||||
if (!token) {
|
||||
setStatus('error');
|
||||
setMessage('No verification token found in the link. Please use the link from your email.');
|
||||
setMessage('No verification token found. 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.');
|
||||
setMessage(err.response?.data?.message || 'Verification failed. The link may have expired.');
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4 relative overflow-hidden"
|
||||
style={{ background: 'hsl(222 47% 4%)' }}
|
||||
>
|
||||
<div className="flex min-h-[calc(100vh-5rem)] items-center justify-center px-6 py-12">
|
||||
{/* Background glow */}
|
||||
<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)' }}
|
||||
className="glow-orb w-[500px] h-[300px] left-1/2 -translate-x-1/2 top-1/3 opacity-15"
|
||||
style={{ background: 'radial-gradient(ellipse, hsl(28 78% 56%), transparent 70%)' }}
|
||||
/>
|
||||
|
||||
<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',
|
||||
}}
|
||||
<div className="relative w-full max-w-sm text-center">
|
||||
<Link to="/" className="inline-block mb-8">
|
||||
<Logo withWordmark />
|
||||
</Link>
|
||||
|
||||
{status === 'verifying' && (
|
||||
<>
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/15 border border-primary/25 flex items-center justify-center mx-auto mb-6">
|
||||
<Loader2 className="h-8 w-8 text-primary animate-spin" />
|
||||
</div>
|
||||
<h1 className="font-display font-bold text-2xl text-foreground mb-3">Verifying your email</h1>
|
||||
<p className="text-muted-foreground">Please wait a moment…</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<div className="w-16 h-16 rounded-2xl bg-green-400/10 border border-green-400/25 flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle2 className="h-8 w-8 text-green-400" />
|
||||
</div>
|
||||
<h1 className="font-display font-bold text-2xl text-foreground mb-3">Email verified!</h1>
|
||||
<p className="text-muted-foreground mb-6">{message}</p>
|
||||
<p className="text-sm text-muted-foreground/60 mb-8">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 bg-primary text-primary-foreground hover:bg-primary/90 transition-all"
|
||||
>
|
||||
Cohorta
|
||||
</span>
|
||||
</Link>
|
||||
Go to Dashboard
|
||||
</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>
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<div className="w-16 h-16 rounded-2xl bg-destructive/10 border border-destructive/25 flex items-center justify-center mx-auto mb-6">
|
||||
<XCircle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<h1 className="font-display font-bold text-2xl text-foreground mb-3">Verification failed</h1>
|
||||
<p className="text-muted-foreground mb-8">{message}</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<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%)' }}
|
||||
to="/login"
|
||||
className="inline-flex items-center justify-center px-8 py-3.5 rounded-xl font-semibold bg-primary text-primary-foreground hover:bg-primary/90 transition-all"
|
||||
>
|
||||
Go to Dashboard
|
||||
Back to Sign In
|
||||
</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>
|
||||
<Link to="/" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
Go to homepage
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -121,8 +121,8 @@ export default {
|
|||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
sf: ['Syne', 'Inter', 'system-ui', 'sans-serif'],
|
||||
display: ['Syne', 'system-ui', 'sans-serif'],
|
||||
sf: ['Space Grotesk', 'Inter', 'system-ui', 'sans-serif'],
|
||||
display: ['Space Grotesk', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
transitionProperty: {
|
||||
'height': 'height',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue