feat: full visual rebrand + landing redesign + auth page refresh + email fix

- Landing: extract 513-line monolith into 12 focused section components
  (Hero, StatsBand, FeatureGrid, HowItWorks, LivePreview, Comparison,
  UseCases, Testimonials, Pricing, FAQ, FinalCTA, TrustBar)
- Auth pages: replace flat orange panel with animated live mock
  (real persona SVGs, typewriter messages, theme bars); Login label
  fixed to "Email or username"; Register wires ?plan= badge
- Brand: new Logo SVG (C-arc + 3 figures + wordmark/tagline), expanded
  palette tokens, fluid display type scale, framer-motion shared variants
- Header: scroll progress bar, removed non-functional language pill
- Footer: fixed all dead links, legal stubs, new logo
- Legal: /about /privacy /terms /cookies /gdpr real pages added
- Email: FROM_EMAIL default fixed to noreply@ai-impress.com (verified
  apex domain), HTML template rewritten to match new brand
- Tooling: Playwright screenshot script for visual self-check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-23 21:02:03 +01:00
parent 32d21d1260
commit a9a5fff659
37 changed files with 2756 additions and 1462 deletions

5
.gitignore vendored
View file

@ -165,6 +165,11 @@ uploads/
# Generated data
backend/persona_data/
# Playwright screenshots (visual self-check only, not committed)
screenshots/
playwright-report/
test-results/
# OS generated files
.DS_Store
.DS_Store?

View file

@ -19,6 +19,12 @@ AZURE_AI_API_KEY=REPLACE_WITH_AZURE_KEY
AZURE_AI_MODEL_MAIN=gpt-5.4
AZURE_AI_MODEL_MINI=gpt-5.4-mini
# Resend transactional email (sign up at resend.com, verify ai-impress.com as sender domain)
# Make sure EMAIL_FROM uses the apex domain that's verified in Resend, NOT a subdomain.
RESEND_API_KEY=REPLACE_WITH_RESEND_API_KEY
EMAIL_FROM=Cohorta <noreply@ai-impress.com>
APP_URL=https://cohorta.ai-impress.com
# CORS — comma-separated allowed origins
CORS_ALLOWED_ORIGINS=https://cohorta.ai-impress.com

View file

@ -10,16 +10,17 @@ import httpx
logger = logging.getLogger(__name__)
RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "")
FROM_EMAIL = os.environ.get("EMAIL_FROM", "Cohorta <noreply@cohorta.ai-impress.com>")
FROM_EMAIL = os.environ.get("EMAIL_FROM", "Cohorta <noreply@ai-impress.com>")
APP_URL = os.environ.get("APP_URL", "http://localhost:5173")
async def send_email(to: str, subject: str, html: str) -> bool:
"""Send an email via Resend API. Returns True on success."""
if not RESEND_API_KEY:
logger.warning("RESEND_API_KEY not set — email not sent to %s", to)
logger.error("RESEND_API_KEY not set — email NOT sent to %s", to)
return False
logger.info("Sending email to %s via FROM=%s (subject: %s)", to, FROM_EMAIL, subject)
payload = {
"from": FROM_EMAIL,
"to": [to],
@ -47,46 +48,57 @@ async def send_email(to: str, subject: str, html: str) -> bool:
def _build_verification_html(username: str, verify_url: str) -> str:
return f"""<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Verify your Cohorta account</title>
<style>
body {{ margin:0; padding:0; background:#04080F; font-family: Inter, Helvetica, Arial, sans-serif; }}
.wrap {{ max-width:520px; margin:40px auto; background:#0C1225; border-radius:16px;
border:1px solid #1E2D45; overflow:hidden; }}
.header {{ padding:32px 40px 24px; border-bottom:1px solid #1E2D45; }}
.logo {{ font-size:22px; font-weight:700; background:linear-gradient(135deg,#06B6D4,#8B5CF6);
-webkit-background-clip:text; -webkit-text-fill-color:transparent; }}
.body {{ padding:32px 40px; }}
h1 {{ color:#F1F5F9; font-size:22px; font-weight:700; margin:0 0 12px; }}
p {{ color:#64748B; font-size:15px; line-height:1.7; margin:0 0 20px; }}
.btn {{ display:inline-block; padding:14px 32px; border-radius:10px; font-weight:600;
font-size:15px; color:#fff; text-decoration:none;
background:linear-gradient(135deg,#06B6D4,#7C3AED); }}
.footer {{ padding:20px 40px; border-top:1px solid #1E2D45; text-align:center; }}
.footer p {{ font-size:12px; color:#334155; margin:0; }}
.note {{ font-size:13px; color:#334155; }}
body {{ margin:0; padding:0; background:#15171F; font-family: -apple-system, 'Inter', Helvetica, Arial, sans-serif; }}
.wrap {{ max-width:540px; margin:40px auto; background:#1E2130; border-radius:20px;
border:1px solid #2A2F45; overflow:hidden; box-shadow:0 24px 64px rgba(0,0,0,0.4); }}
.header {{ padding:28px 40px; border-bottom:1px solid #2A2F45; display:flex; align-items:center; gap:10px; }}
.logo-img {{ height:32px; display:block; }}
.logo-text {{ font-size:18px; font-weight:700; color:#F5EAD8; letter-spacing:-0.02em; }}
.body {{ padding:36px 40px; }}
h1 {{ color:#F5EAD8; font-size:22px; font-weight:700; margin:0 0 14px; letter-spacing:-0.01em; }}
p {{ color:#8A8FA8; font-size:15px; line-height:1.75; margin:0 0 20px; }}
strong.name {{ color:#C8CADB; }}
.btn {{ display:inline-block; padding:15px 36px; border-radius:12px; font-weight:600;
font-size:15px; color:#1A1D26; text-decoration:none;
background:#E89B3C; letter-spacing:-0.01em; }}
.btn:hover {{ background:#D4862A; }}
.note {{ font-size:13px; color:#4E5267; margin-top:24px !important; }}
.divider {{ border:none; border-top:1px solid #2A2F45; margin:24px 0; }}
.footer {{ padding:20px 40px; border-top:1px solid #2A2F45; }}
.footer p {{ font-size:12px; color:#3D4158; margin:0; text-align:center; }}
.tag {{ display:inline-block; background:#E89B3C18; border:1px solid #E89B3C30;
color:#E89B3C; font-size:11px; font-weight:600; padding:3px 10px;
border-radius:100px; margin-bottom:20px; letter-spacing:0.05em; text-transform:uppercase; }}
</style>
</head>
<body>
<div class="wrap">
<div class="header">
<span class="logo">Cohorta</span>
<img class="logo-img" src="https://cohorta.ai-impress.com/cohorta-logo.png" alt="Cohorta" />
</div>
<div class="body">
<h1>Verify your email address</h1>
<p>Hi <strong style="color:#94A3B8">{username}</strong>,<br/>
Welcome to Cohorta! Please confirm your email address to activate your account
and get access to your free credits.</p>
<a class="btn" href="{verify_url}">Verify Email Address</a>
<p style="margin-top:24px" class="note">
This link expires in <strong>24 hours</strong>. If you didn't create a Cohorta account,
you can safely ignore this email.
<div class="tag">Confirm your account</div>
<h1>You're almost in.</h1>
<p>Hi <strong class="name">{username}</strong>,<br/>
Thanks for signing up for Cohorta your synthetic research platform.
Click below to confirm your email and unlock your <strong style="color:#E89B3C">10 free trial credits</strong>.</p>
<p style="margin:0 0 28px;">
<a class="btn" href="{verify_url}">Verify Email Address </a>
</p>
<hr class="divider"/>
<p class="note">
This link expires in <strong style="color:#8A8FA8">24 hours</strong>.<br/>
If you didn't create a Cohorta account, you can safely ignore this email.
</p>
</div>
<div class="footer">
<p>© {_year()} AImpress LTD &nbsp;·&nbsp; All rights reserved</p>
<p>© {_year()} AImpress LTD &nbsp;·&nbsp; EU-hosted &nbsp;·&nbsp; <a href="https://cohorta.ai-impress.com/privacy" style="color:#4E5267;">Privacy Policy</a></p>
</div>
</div>
</body>

698
package-lock.json generated
View file

@ -8,8 +8,6 @@
"name": "vite_react_shadcn_ts",
"version": "0.0.0",
"dependencies": {
"@azure/msal-browser": "^4.19.0",
"@azure/msal-react": "^3.0.17",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@ -50,6 +48,7 @@
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.3.0",
"framer-motion": "^12.40.0",
"input-otp": "^1.2.4",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.462.0",
@ -70,6 +69,7 @@
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@playwright/test": "^1.60.0",
"@tailwindcss/typography": "^0.5.15",
"@types/node": "^22.5.5",
"@types/react": "^18.3.3",
@ -80,7 +80,6 @@
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"lovable-tagger": "^1.1.7",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.11",
"typescript": "^5.5.3",
@ -100,76 +99,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@azure/msal-browser": {
"version": "4.19.0",
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.19.0.tgz",
"integrity": "sha512-g6Ea+sJmK7l5NUyrPhtD7DNj/tZcsr6VTNNLNuYs8yPvL3HNiIpO/0kzXntF9AqJ/6L+uz9aHmoT1x+RNq6zBQ==",
"license": "MIT",
"dependencies": {
"@azure/msal-common": "15.10.0"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-common": {
"version": "15.10.0",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.10.0.tgz",
"integrity": "sha512-+cGnma71NV3jzl6DdgdHsqriN4ZA7puBIzObSYCvcIVGMULGb2NrcOGV6IJxO06HoVRHFKijkxd9lcBvS063KQ==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-react": {
"version": "3.0.17",
"resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-3.0.17.tgz",
"integrity": "sha512-GgVn8OQmtXMPJ88+w8E+7hpWXcVWhh8aIjspkrJr4bbONWhbfyQOSyN92gsrSbynIsJ9o7GWTjvGHFLr2MuyQQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@azure/msal-browser": "^4.19.0",
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.9.tgz",
"integrity": "sha512-aI3jjAAO1fh7vY/pBGsn1i9LDbRP43+asrRlkPuTXW5yHXtd1NgTEMudbBoDDxrf1daEEfPJqR+JBMakzrR4Dg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.25.9"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.9.tgz",
@ -182,20 +111,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.9.tgz",
"integrity": "sha512-OwS2CM5KocvQ/k7dFJa8i5bNGJP0hXWfVCfDkqRFP1IreH1JDC7wG6eCYCi0+McbfT8OR/kNqsI0UU0xP9H6PQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.25.9",
"@babel/helper-validator-identifier": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@ -552,23 +467,6 @@
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz",
"integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
@ -586,23 +484,6 @@
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz",
"integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
@ -1030,6 +911,22 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
@ -4672,16 +4569,6 @@
"node": ">=4.0"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@ -4893,6 +4780,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
"integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.40.0",
"motion-utils": "^12.39.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -5404,456 +5318,6 @@
"loose-envify": "cli.js"
}
},
"node_modules/lovable-tagger": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/lovable-tagger/-/lovable-tagger-1.1.7.tgz",
"integrity": "sha512-b1wwYbuxWGx+DuqviQGQXrgLAraK1RVbqTg6G8LYRID8FJTg4TuAeO0TJ7i6UXOF8gEzbgjhRbGZ+XAkWH2T8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.25.9",
"@babel/types": "^7.25.8",
"esbuild": "^0.25.0",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.12",
"tailwindcss": "^3.4.17"
},
"peerDependencies": {
"vite": "^5.0.0"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/aix-ppc64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz",
"integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/android-arm": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz",
"integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/android-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz",
"integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/android-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz",
"integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/darwin-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz",
"integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/darwin-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz",
"integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz",
"integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/freebsd-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz",
"integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/linux-arm": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz",
"integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/linux-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz",
"integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/linux-ia32": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz",
"integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/linux-loong64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz",
"integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/linux-mips64el": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz",
"integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/linux-ppc64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz",
"integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/linux-riscv64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz",
"integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/linux-s390x": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz",
"integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/linux-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz",
"integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/netbsd-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz",
"integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/openbsd-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz",
"integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/sunos-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz",
"integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/win32-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz",
"integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/win32-ia32": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz",
"integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/@esbuild/win32-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz",
"integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/lovable-tagger/node_modules/esbuild": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
"integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.0",
"@esbuild/android-arm": "0.25.0",
"@esbuild/android-arm64": "0.25.0",
"@esbuild/android-x64": "0.25.0",
"@esbuild/darwin-arm64": "0.25.0",
"@esbuild/darwin-x64": "0.25.0",
"@esbuild/freebsd-arm64": "0.25.0",
"@esbuild/freebsd-x64": "0.25.0",
"@esbuild/linux-arm": "0.25.0",
"@esbuild/linux-arm64": "0.25.0",
"@esbuild/linux-ia32": "0.25.0",
"@esbuild/linux-loong64": "0.25.0",
"@esbuild/linux-mips64el": "0.25.0",
"@esbuild/linux-ppc64": "0.25.0",
"@esbuild/linux-riscv64": "0.25.0",
"@esbuild/linux-s390x": "0.25.0",
"@esbuild/linux-x64": "0.25.0",
"@esbuild/netbsd-arm64": "0.25.0",
"@esbuild/netbsd-x64": "0.25.0",
"@esbuild/openbsd-arm64": "0.25.0",
"@esbuild/openbsd-x64": "0.25.0",
"@esbuild/sunos-x64": "0.25.0",
"@esbuild/win32-arm64": "0.25.0",
"@esbuild/win32-ia32": "0.25.0",
"@esbuild/win32-x64": "0.25.0"
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
@ -5868,16 +5332,6 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/magic-string": {
"version": "0.30.12",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz",
"integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -5952,6 +5406,21 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/motion-dom": {
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
"integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.39.0"
}
},
"node_modules/motion-utils": {
"version": "12.39.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -6194,6 +5663,53 @@
"node": ">= 6"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.4.47",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",

View file

@ -9,10 +9,12 @@
"build:dev": "vite build --mode development",
"lint": "eslint .",
"preview": "vite preview",
"backend": "cd backend && python run.py"
"backend": "cd backend && python run.py",
"screenshot": "node scripts/screenshot.mjs",
"screenshot:all": "node scripts/screenshot.mjs --all"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@ -52,6 +54,7 @@
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.3.0",
"framer-motion": "^12.40.0",
"input-otp": "^1.2.4",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.462.0",
@ -72,6 +75,7 @@
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@playwright/test": "^1.60.0",
"@tailwindcss/typography": "^0.5.15",
"@types/node": "^22.5.5",
"@types/react": "^18.3.3",
@ -82,7 +86,7 @@
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"postcss": "^8.4.47",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.11",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",

15
playwright.config.ts Normal file
View file

@ -0,0 +1,15 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:5173',
viewport: { width: 1440, height: 900 },
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

BIN
public/cohorta-brand.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
public/cohorta-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

94
scripts/screenshot.mjs Normal file
View file

@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* Visual self-check script captures screenshots of landing/auth pages.
* Usage:
* node scripts/screenshot.mjs [path] [viewport]
* node scripts/screenshot.mjs / desktop screenshots/landing-desktop.png
* node scripts/screenshot.mjs /login mobile screenshots/login-mobile.png
* node scripts/screenshot.mjs --all all pages, both viewports
*/
import { chromium } from '@playwright/test';
import { mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const screenshotsDir = join(__dirname, '..', 'screenshots');
mkdirSync(screenshotsDir, { recursive: true });
const BASE_URL = process.env.BASE_URL || 'http://localhost:5173';
const VIEWPORTS = {
desktop: { width: 1440, height: 900 },
mobile: { width: 390, height: 844 },
};
const PAGES = [
{ path: '/', slug: 'landing', fullPage: true },
{ path: '/login', slug: 'login', fullPage: true },
{ path: '/register', slug: 'register', fullPage: true },
];
function slugify(p) {
return p === '/' ? 'landing' : p.replace(/\//g, '').replace(/[^a-z0-9]/g, '-');
}
async function screenshot(browser, pagePath, viewportName, fullPage = true) {
const viewport = VIEWPORTS[viewportName] || VIEWPORTS.desktop;
const context = await browser.newContext({ viewport });
const page = await context.newPage();
const url = BASE_URL + pagePath;
await page.goto(url, { waitUntil: 'networkidle', timeout: 15000 });
// Scroll through the entire page to trigger whileInView animations
await page.evaluate(async () => {
await new Promise(resolve => {
let totalHeight = 0;
const distance = 400;
const timer = setInterval(() => {
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= document.body.scrollHeight) {
clearInterval(timer);
window.scrollTo(0, 0);
resolve(undefined);
}
}, 80);
});
});
// Wait for animations to settle after scroll
await page.waitForTimeout(1200);
const filename = `${slugify(pagePath)}-${viewportName}.png`;
const outPath = join(screenshotsDir, filename);
await page.screenshot({ path: outPath, fullPage });
console.log(`${filename}`);
await context.close();
return outPath;
}
async function main() {
const args = process.argv.slice(2);
const browser = await chromium.launch();
try {
if (args[0] === '--all') {
for (const { path: p, fullPage } of PAGES) {
for (const vp of Object.keys(VIEWPORTS)) {
await screenshot(browser, p, vp, fullPage);
}
}
} else {
const pagePath = args[0] || '/';
const viewportName = args[1] || 'desktop';
await screenshot(browser, pagePath, viewportName);
}
} finally {
await browser.close();
}
}
main().catch(err => { console.error(err); process.exit(1); });

View file

@ -5,6 +5,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import Index from "./pages/Index";
import NotFound from "./pages/NotFound";
import About from "./pages/About";
import Privacy from "./pages/legal/Privacy";
import Terms from "./pages/legal/Terms";
import Cookies from "./pages/legal/Cookies";
import Gdpr from "./pages/legal/Gdpr";
import SyntheticUsers from "./pages/SyntheticUsers";
import FocusGroups from "./pages/FocusGroups";
import FocusGroupSession from "./pages/FocusGroupSession";
@ -42,6 +47,13 @@ const App = () => (
<Route path="*" element={<NotFound />} />
</Route>
{/* Legal + about — standalone (no app header) */}
<Route path="/about" element={<About />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/terms" element={<Terms />} />
<Route path="/cookies" element={<Cookies />} />
<Route path="/gdpr" element={<Gdpr />} />
{/* Auth pages — standalone, no header */}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />

View file

@ -1,78 +1,139 @@
import { useId } from 'react';
import { cn } from '@/lib/utils';
type LogoSize = 'sm' | 'md' | 'lg' | 'xl';
type LogoVariant = 'horizontal' | 'mark-only' | 'stacked';
interface LogoProps {
className?: string;
withWordmark?: boolean;
size?: 'sm' | 'md' | 'lg';
withTagline?: boolean;
size?: LogoSize;
variant?: LogoVariant;
}
const SIZE_PX: Record<LogoSize, number> = { sm: 24, md: 32, lg: 44, xl: 64 };
const WORDMARK_SIZE: Record<LogoSize, string> = {
sm: 'text-sm',
md: 'text-base',
lg: 'text-xl',
xl: 'text-3xl',
};
function LogoMark({ px, id }: { px: number; id: string }) {
const arcId = `${id}-arc`;
const personId = `${id}-person`;
const arcGradId = `${id}-arc`;
const personGradId = `${id}-person`;
return (
<svg width={px} height={px} viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width={px}
height={px}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
{/* Arc gradient: light orange top → deep orange bottom */}
<linearGradient id={arcId} x1="50" y1="8" x2="50" y2="92" gradientUnits="userSpaceOnUse">
<linearGradient id={arcGradId} x1="60" y1="10" x2="60" y2="110" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#FFB340" />
<stop offset="100%" stopColor="#E07800" />
</linearGradient>
{/* Center person gradient: same orange */}
<linearGradient id={personId} x1="50" y1="32" x2="50" y2="66" gradientUnits="userSpaceOnUse">
<linearGradient id={personGradId} x1="60" y1="38" x2="60" y2="78" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#FFB340" />
<stop offset="100%" stopColor="#E07800" />
</linearGradient>
</defs>
{/*
C arc: center (50,50), mid-radius 37, stroke-width 14
Opens to the right. Tips at ±42° from horizontal right.
Top tip: (50+37·cos(-42°), 50+37·sin(-42°)) (77.5, 25.3)
Bottom tip: (77.5, 74.7)
large-arc=1, sweep=0 long counter-clockwise arc through the left side = C shape
C arc horseshoe opening to the right.
Centre (60,60), radius 38, stroke 18px, rounded caps.
Opens at ±50° from the horizontal right axis.
Top tip: (60 + 38·cos(-50°), 60 + 38·sin(-50°)) (84.4, 31.9)
Bottom tip: (84.4, 88.1)
large-arc=1, sweep=0 long CCW arc = C shape
*/}
<path
d="M 77.5 25.3 A 37 37 0 1 0 77.5 74.7"
stroke={`url(#${arcId})`}
strokeWidth="14"
d="M 84.4 31.9 A 38 38 0 1 0 84.4 88.1"
stroke={`url(#${arcGradId})`}
strokeWidth="18"
strokeLinecap="round"
fill="none"
/>
{/* ── Left person — dark gray, small, behind ── */}
{/* Head */}
<circle cx="36" cy="42" r="5.5" fill="#4B5568" />
{/* Shoulder arc */}
<path d="M 28 53 A 8 8 0 0 1 44 53 Z" fill="#4B5568" />
{/* Left person — warm grey, small, behind */}
<circle cx="42" cy="50" r="6.5" fill="#6B7280" />
<path d="M 33 63 A 9 9 0 0 1 51 63 Z" fill="#6B7280" />
{/* ── Right person — dark gray, small, behind ── */}
<circle cx="64" cy="42" r="5.5" fill="#4B5568" />
<path d="M 56 53 A 8 8 0 0 1 72 53 Z" fill="#4B5568" />
{/* Right person — warm grey, small, behind */}
<circle cx="78" cy="50" r="6.5" fill="#6B7280" />
<path d="M 69 63 A 9 9 0 0 1 87 63 Z" fill="#6B7280" />
{/* ── Center person — orange, larger, in front ── */}
<circle cx="50" cy="39" r="7.5" fill={`url(#${personId})`} />
<path d="M 39 52 A 11 11 0 0 1 61 52 Z" fill={`url(#${personId})`} />
{/* Centre person — orange, larger, in front */}
<circle cx="60" cy="46" r="9" fill={`url(#${personGradId})`} />
<path d="M 47 62 A 13 13 0 0 1 73 62 Z" fill={`url(#${personGradId})`} />
</svg>
);
}
export default function Logo({ className, withWordmark = false, size = 'md' }: LogoProps) {
export default function Logo({
className,
withWordmark = false,
withTagline = false,
size = 'md',
variant = 'horizontal',
}: LogoProps) {
const id = useId().replace(/:/g, '');
const px = size === 'sm' ? 24 : size === 'lg' ? 40 : 32;
const textSize = size === 'sm' ? 'text-base' : size === 'lg' ? 'text-xl' : 'text-lg';
const px = SIZE_PX[size];
if (variant === 'mark-only') {
return (
<div className={cn('flex-shrink-0', className)}>
<LogoMark px={px} id={id} />
</div>
);
}
if (variant === 'stacked') {
return (
<div className={cn('flex flex-col items-center gap-2', className)}>
<LogoMark px={px} id={id} />
<div className="flex flex-col items-center gap-0.5">
<span
className={cn('font-display font-bold tracking-tight text-foreground', WORDMARK_SIZE[size])}
style={{ letterSpacing: '-0.02em' }}
>
Cohorta
</span>
{withTagline && (
<span className="text-[9px] font-medium tracking-[0.2em] uppercase text-muted-foreground">
Synthetic Research.{' '}
<span className="text-primary">Real Insights.</span>
</span>
)}
</div>
</div>
);
}
// horizontal (default)
return (
<div className={cn('flex items-center gap-2', className)}>
<div className={cn('flex items-center gap-2.5', className)}>
<LogoMark px={px} id={id} />
{withWordmark && (
<span
className={cn('font-display font-bold tracking-tight text-foreground', textSize)}
style={{ letterSpacing: '-0.02em' }}
>
Cohorta
</span>
{(withWordmark || withTagline) && (
<div className="flex flex-col justify-center">
<span
className={cn('font-display font-bold tracking-tight text-foreground leading-none', WORDMARK_SIZE[size])}
style={{ letterSpacing: '-0.02em' }}
>
Cohorta
</span>
{withTagline && (
<span className="text-[9px] font-medium tracking-[0.18em] uppercase text-muted-foreground mt-0.5">
Synthetic Research.{' '}
<span className="text-primary">Real Insights.</span>
</span>
)}
</div>
)}
</div>
);

View file

@ -0,0 +1,113 @@
import { motion } from 'framer-motion';
import { CheckCircle2, XCircle, MinusCircle } from 'lucide-react';
import { fadeUp, staggerChildren, viewportOnce } from '@/lib/motion';
const ROWS = [
{ criterion: 'Time to first insight', cohorta: '< 20 minutes', trad: '24 weeks', survey: '12 weeks' },
{ criterion: 'Cost per session', cohorta: '~$1040', trad: '$5k$20k', survey: '$500$2k' },
{ criterion: 'Panel size', cohorta: 'Up to 50+ personas',trad: '612 people', survey: '200500 people' },
{ criterion: 'Available 24 / 7', cohorta: true, trad: false, survey: 'partial' },
{ criterion: 'No recruitment delay', cohorta: true, trad: false, survey: false },
{ criterion: 'Autonomous moderation', cohorta: true, trad: false, survey: false },
{ criterion: 'Qualitative depth', cohorta: true, trad: true, survey: false },
{ criterion: 'Instant repeat runs', cohorta: true, trad: false, survey: 'partial' },
{ criterion: 'GDPR-safe by default', cohorta: true, trad: 'partial', survey: 'partial' },
];
type CellValue = string | boolean | 'partial';
function Cell({ value, primary }: { value: CellValue; primary?: boolean }) {
if (value === true) {
return (
<td className={`py-3.5 px-4 text-center ${primary ? 'bg-primary/5' : ''}`}>
<CheckCircle2 className={`h-5 w-5 mx-auto ${primary ? 'text-primary' : 'text-brand-success'}`} />
</td>
);
}
if (value === false) {
return (
<td className={`py-3.5 px-4 text-center ${primary ? 'bg-primary/5' : ''}`}>
<XCircle className="h-5 w-5 mx-auto text-destructive/50" />
</td>
);
}
if (value === 'partial') {
return (
<td className={`py-3.5 px-4 text-center ${primary ? 'bg-primary/5' : ''}`}>
<MinusCircle className="h-5 w-5 mx-auto text-muted-foreground/40" />
</td>
);
}
return (
<td className={`py-3.5 px-4 text-sm ${primary ? 'bg-primary/5 font-semibold text-primary text-center' : 'text-muted-foreground text-center'}`}>
{value}
</td>
);
}
export default function Comparison() {
return (
<section className="py-24 px-6">
<div className="max-w-5xl mx-auto">
<motion.div
variants={staggerChildren}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
<motion.div variants={fadeUp} 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">
<span className="text-sm font-medium text-primary">Why Cohorta</span>
</div>
<h2 className="font-display font-bold text-display-2 text-foreground mb-3">
Traditional research was never built for speed.
</h2>
<p className="text-muted-foreground text-lg max-w-xl mx-auto">
Cohorta collapses months of scheduling, budgeting, and recruiting into a single afternoon.
</p>
</motion.div>
<motion.div variants={fadeUp} className="rounded-2xl border border-border overflow-hidden shadow-lg">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-secondary/30">
<th className="text-left py-4 px-4 text-sm font-semibold text-muted-foreground w-[35%]">Criterion</th>
<th className="text-center py-4 px-4 w-[21%]">
<div className="flex flex-col items-center gap-1">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary text-primary-foreground text-xs font-bold">
Cohorta
</span>
</div>
</th>
<th className="text-center py-4 px-4 text-sm font-medium text-muted-foreground w-[22%]">
Traditional<br /><span className="text-xs font-normal">focus groups</span>
</th>
<th className="text-center py-4 px-4 text-sm font-medium text-muted-foreground w-[22%]">
Survey<br /><span className="text-xs font-normal">panels</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{ROWS.map(({ criterion, cohorta, trad, survey }) => (
<tr key={criterion} className="hover:bg-secondary/20 transition-colors">
<td className="py-3.5 px-4 text-sm text-foreground font-medium">{criterion}</td>
<Cell value={cohorta} primary />
<Cell value={trad} />
<Cell value={survey} />
</tr>
))}
</tbody>
</table>
</div>
</motion.div>
<motion.p variants={fadeUp} className="text-xs text-muted-foreground/60 text-center mt-4">
Cohorta results are directionally accurate for concept testing, message testing, and early-stage exploratory research.
Not a replacement for large-scale quantitative studies.
</motion.p>
</motion.div>
</div>
</section>
);
}

View file

@ -0,0 +1,80 @@
import { motion } from 'framer-motion';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { fadeUp, staggerChildren, viewportOnce } from '@/lib/motion';
const FAQ_ITEMS = [
{
q: 'What is a synthetic persona?',
a: "A synthetic persona is an AI-generated profile that mimics a real human respondent — demographics, psychographics, attitudes, and communication style included. Unlike a survey panel, it costs nothing to recruit, is available immediately, and can be regenerated with different briefs in seconds.",
},
{
q: 'Is this a replacement for real user research?',
a: "No — and we're direct about that. Cohorta is designed for front-loading discovery and concept testing: situations where traditional research is too slow, too expensive, or logistically impossible. Leading researchers (Nielsen Norman Group, Behavioral Scientist) use synthetic research as a first-pass tool to arrive at real user sessions better-prepared.",
},
{
q: 'How accurate are the AI personas?',
a: "Independent studies on synthetic research platforms show 8592% parity with organic respondent data for exploratory and concept-testing scenarios. Cohorta results are directionally accurate — enough to kill bad ideas early, sharpen your hypotheses, and prioritise where real research budget should go. We publish this caveat clearly in every session export.",
},
{
q: 'How different is this from a traditional focus group?',
a: "Traditional focus groups take 24 weeks to recruit, cost $5,000$20,000, and max out at 12 participants. Cohorta generates your panel in 2 minutes, runs sessions 24/7, and lets you test dozens of segments in parallel — for the cost of a SaaS subscription.",
},
{
q: 'How does the credit system work?',
a: 'Creating one persona costs 2 credits. Running a full focus group session costs 40 credits. You get 10 free trial credits on signup — enough for 5 personas or to get a feel for the product. Credits never expire, so there\'s no pressure to burn them quickly.',
},
{
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 — no other user can see your data. We do not use your research data to train AI models. Infrastructure is hosted on EU servers by AImpress LTD.",
},
{
q: 'Can I export the results?',
a: 'Yes. Download full discussion transcripts as Markdown, export personas as CSV, and generate structured discussion guides with key themes highlighted. Pro and Scale plans include bulk export for entire projects.',
},
];
export default function FAQ() {
return (
<section className="py-24 px-6 bg-[hsl(var(--brand-charcoal))]">
<div className="max-w-3xl mx-auto">
<motion.div
variants={staggerChildren}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
<motion.h2
variants={fadeUp}
className="font-display font-bold text-display-2 text-foreground text-center mb-14"
>
Questions people actually ask
</motion.h2>
<motion.div variants={fadeUp}>
<Accordion type="single" collapsible className="space-y-3">
{FAQ_ITEMS.map((item, i) => (
<AccordionItem
key={i}
value={`item-${i}`}
className="border border-border rounded-2xl overflow-hidden bg-card data-[state=open]:border-primary/30"
>
<AccordionTrigger className="px-6 py-5 hover:bg-secondary/30 transition-colors text-left font-semibold text-foreground [&[data-state=open]]:text-primary hover:no-underline">
{item.q}
</AccordionTrigger>
<AccordionContent className="px-6 pb-5 text-sm text-muted-foreground leading-relaxed">
{item.a}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</motion.div>
</motion.div>
</div>
</section>
);
}

View file

@ -0,0 +1,154 @@
import { motion, useReducedMotion } from 'framer-motion';
import { Users, MessageSquare, Sparkles, Download } from 'lucide-react';
import { fadeUp, staggerChildren, viewportOnce } from '@/lib/motion';
const FEATURES = [
{
icon: Users,
title: 'AI Personas',
desc: 'Two-stage generation from one brief. Get 550 profiles with demographic depth, psychographics, and authentic communication styles in under 2 minutes.',
span: 'lg:col-span-2',
visual: (
<div className="mt-4 flex -space-x-3">
{[1,2,3,4,5].map(i => (
<div key={i} className="w-10 h-10 rounded-full bg-gradient-to-br from-primary/60 to-primary/20 border-2 border-background flex items-center justify-center text-xs font-bold text-foreground">
{i}
</div>
))}
<div className="w-10 h-10 rounded-full bg-secondary border-2 border-background flex items-center justify-center text-xs text-muted-foreground font-medium">
+45
</div>
</div>
),
},
{
icon: MessageSquare,
title: 'Focus Groups',
desc: 'AI-moderated sessions — autonomous or manual. Real-time discussion with your synthetic panel, complete with theme extraction.',
span: 'lg:col-span-2',
visual: (
<div className="mt-4 space-y-2">
{[
{ name: 'Maya', msg: 'The pricing feels off for our budget cycle…', w: '80%' },
{ name: 'James', msg: 'ROI within the first sprint is what matters.', w: '65%' },
].map(({ name, msg, w }) => (
<div key={name} className="flex gap-2 items-start">
<div className="w-6 h-6 rounded-full bg-primary/30 flex-shrink-0 flex items-center justify-center text-[10px] font-bold text-primary mt-0.5">
{name[0]}
</div>
<div className="bg-secondary/60 rounded-xl px-3 py-2 text-xs text-muted-foreground" style={{ width: w }}>
{msg}
</div>
</div>
))}
</div>
),
},
{
icon: Sparkles,
title: 'Theme Extraction',
desc: 'Live key themes extracted per session. See patterns emerge and consensus form as your panel speaks in real time.',
span: 'lg:col-span-2',
visual: (
<div className="mt-4 space-y-2">
{[
{ label: 'Price sensitivity', pct: 87 },
{ label: 'Time-to-value', pct: 72 },
{ label: 'Integration needs', pct: 58 },
].map(({ label, pct }) => (
<div key={label}>
<div className="flex justify-between text-[11px] mb-1">
<span className="text-muted-foreground">{label}</span>
<span className="text-primary font-medium">{pct}%</span>
</div>
<div className="h-1.5 rounded-full bg-primary/15">
<motion.div
initial={{ width: 0 }}
whileInView={{ width: `${pct}%` }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.2 }}
className="h-full rounded-full bg-primary"
/>
</div>
</div>
))}
</div>
),
},
{
icon: Download,
title: 'Bulk Export',
desc: 'Markdown discussion guides, CSV transcripts, full persona profiles — structured, sharable, ready for stakeholders.',
span: 'lg:col-span-2',
visual: (
<div className="mt-4 grid grid-cols-3 gap-2">
{[
{ ext: 'MD', label: 'Guide' },
{ ext: 'CSV', label: 'Transcript' },
{ ext: 'PDF', label: 'Personas' },
].map(({ ext, label }) => (
<div key={ext} className="flex flex-col items-center gap-1 bg-secondary/40 rounded-xl py-3">
<span className="text-[10px] font-bold text-primary">.{ext}</span>
<span className="text-[10px] text-muted-foreground">{label}</span>
</div>
))}
</div>
),
},
];
export default function FeatureGrid() {
const shouldReduce = useReducedMotion();
return (
<section className="py-24 px-6 bg-[hsl(var(--background))]">
<div className="max-w-7xl mx-auto">
<motion.div
variants={staggerChildren}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
{/* Header */}
<motion.div variants={fadeUp} 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">
<Sparkles className="h-3.5 w-3.5 text-primary" />
<span className="text-sm font-medium text-primary">Capabilities</span>
</div>
<h2 className="font-display font-bold text-display-2 text-foreground mb-3">
Built for product, marketing &amp; UX researchers
</h2>
<p className="text-muted-foreground text-lg max-w-xl mx-auto">
Everything you need to generate insight without recruiting a single real participant.
</p>
</motion.div>
{/* Bento grid — inspired by 21st.dev Dark Grid pattern */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{FEATURES.map(({ icon: Icon, title, desc, visual }, i) => (
<motion.div
key={title}
variants={fadeUp}
custom={i}
whileHover={!shouldReduce ? { y: -4 } : {}}
className="gradient-border-card group relative rounded-2xl p-6 bg-card border border-border hover:border-primary/30 transition-all duration-300 hover:shadow-xl hover:shadow-primary/5"
>
{/* Icon */}
<div className="h-11 w-11 rounded-xl bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
<Icon className="h-5 w-5 text-primary" />
</div>
{/* Text */}
<h3 className="font-display font-bold text-lg mb-2 text-foreground">{title}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">{desc}</p>
{/* Mini product visual */}
{visual}
</motion.div>
))}
</div>
</motion.div>
</div>
</section>
);
}

View file

@ -0,0 +1,67 @@
import { motion, useReducedMotion } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import { ArrowRight } from 'lucide-react';
import { fadeUp, staggerChildren, viewportOnce } from '@/lib/motion';
export default function FinalCTA() {
const navigate = useNavigate();
const shouldReduce = useReducedMotion();
return (
<section className="cta-band py-24 px-6 relative overflow-hidden">
{/* Soft background glow */}
<div
className="glow-orb w-[500px] h-[300px] left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 opacity-30"
style={{ background: 'radial-gradient(ellipse, hsl(28 78% 56% / 0.6), transparent 70%)' }}
/>
<div className="relative max-w-2xl mx-auto text-center">
<motion.div
variants={staggerChildren}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
<motion.div variants={fadeUp} className="mb-5">
<span className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-primary/40 bg-primary/10 text-sm font-medium text-primary">
<span className="w-2 h-2 rounded-full bg-brand-success animate-pulse" />
Free to start. No card required.
</span>
</motion.div>
<motion.h2
variants={fadeUp}
className="font-display font-black text-display-1 text-foreground mb-5"
>
Run your first synthetic session in under{' '}
<span className="text-gradient">20 minutes.</span>
</motion.h2>
<motion.p variants={fadeUp} className="text-muted-foreground text-lg mb-10 leading-relaxed">
10 trial credits on signup. Enough to generate 5 personas and run a concept-testing session. No meeting with a recruiter. No waiting a week for responses. Just answers.
</motion.p>
<motion.div variants={fadeUp} className="flex flex-col sm:flex-row items-center justify-center gap-4">
<button
onClick={() => navigate('/register')}
className="inline-flex items-center gap-2 px-10 py-4 rounded-full bg-primary text-primary-foreground font-bold text-base hover:bg-primary/90 transition-all shadow-2xl shadow-primary/30 hover:-translate-y-0.5 hover:shadow-primary/40"
>
Create free account
<ArrowRight className="h-4 w-4" />
</button>
<button
onClick={() => navigate('/login')}
className="px-8 py-4 rounded-full text-base font-medium text-muted-foreground hover:text-foreground border border-border hover:border-primary/30 transition-all"
>
Already have an account
</button>
</motion.div>
<motion.p variants={fadeUp} className="text-xs text-muted-foreground/50 mt-6">
10 free credits · No credit card required · EU-hosted · Results in under 20 minutes
</motion.p>
</motion.div>
</div>
</section>
);
}

View file

@ -0,0 +1,299 @@
import { motion, useReducedMotion } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import { ArrowRight, Play } from 'lucide-react';
import { fadeUp, staggerChildren, viewportOnce } from '@/lib/motion';
import Logo from '@/components/brand/Logo';
const PERSONAS = [
{
id: 1,
name: 'Maya',
age: 32,
role: 'Coffee shop owner',
img: `${import.meta.env.BASE_URL}avatars/persona-1.svg`,
offset: '-translate-y-4',
delay: 0.3,
tag: 'Starter',
tagColor: 'bg-brand-amber/20 text-brand-amber border-brand-amber/30',
},
{
id: 2,
name: 'James',
age: 45,
role: 'CTO, Series B',
img: `${import.meta.env.BASE_URL}avatars/persona-3.svg`,
offset: 'translate-y-0',
delay: 0.45,
tag: 'Pro',
tagColor: 'bg-primary/20 text-primary border-primary/30',
},
{
id: 3,
name: 'Sofia',
age: 28,
role: 'UX Research Lead',
img: `${import.meta.env.BASE_URL}avatars/persona-5.svg`,
offset: 'translate-y-6',
delay: 0.6,
tag: 'Scale',
tagColor: 'bg-brand-success/20 text-brand-success border-brand-success/30',
},
];
function PersonaCard({
persona,
delay,
shouldAnimate,
}: {
persona: typeof PERSONAS[0];
delay: number;
shouldAnimate: boolean;
}) {
return (
<motion.div
initial={shouldAnimate ? { opacity: 0, y: 32, scale: 0.95 } : false}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.6, delay, ease: [0.25, 0.46, 0.45, 0.94] }}
className={`${persona.offset} relative`}
>
<motion.div
animate={shouldAnimate ? { y: [0, -8, 0] } : {}}
transition={{
duration: 5 + delay,
repeat: Infinity,
ease: 'easeInOut',
delay: delay * 1.5,
}}
className="bg-card border border-border rounded-2xl p-4 w-[160px] shadow-xl shadow-black/30 backdrop-blur-sm"
>
<img
src={persona.img}
alt={persona.name}
className="w-14 h-14 rounded-full mx-auto mb-3 object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
<p className="text-sm font-semibold text-foreground text-center">{persona.name}, {persona.age}</p>
<p className="text-[11px] text-muted-foreground text-center mt-0.5 leading-tight">{persona.role}</p>
<div className={`mt-3 mx-auto w-fit text-[10px] font-semibold px-2 py-0.5 rounded-full border ${persona.tagColor}`}>
{persona.tag}
</div>
</motion.div>
</motion.div>
);
}
function FloatingShape({
className,
delay,
size,
color,
shouldAnimate,
}: {
className: string;
delay: number;
size: string;
color: string;
shouldAnimate: boolean;
}) {
return (
<motion.div
initial={shouldAnimate ? { opacity: 0, y: -60, rotate: -10 } : false}
animate={{ opacity: 1, y: 0, rotate: 0 }}
transition={{ duration: 2, delay, ease: [0.23, 0.86, 0.39, 0.96] }}
className={`absolute pointer-events-none ${className}`}
>
<motion.div
animate={shouldAnimate ? { y: [0, 12, 0] } : {}}
transition={{ duration: 10, repeat: Infinity, ease: 'easeInOut', delay: delay * 2 }}
className={`${size} rounded-full ${color} border border-white/10 blur-[1px]`}
/>
</motion.div>
);
}
export default function Hero() {
const navigate = useNavigate();
const shouldReduce = useReducedMotion();
const shouldAnimate = !shouldReduce;
return (
<section className="relative min-h-screen flex flex-col justify-center overflow-hidden -mt-20 pt-20">
{/* Background glow */}
<div
className="glow-orb w-[600px] h-[400px] left-1/4 top-1/3 opacity-15"
style={{ background: 'radial-gradient(ellipse, hsl(28 78% 56%), transparent 70%)' }}
/>
<div
className="absolute inset-0 opacity-[0.02]"
style={{
backgroundImage:
'linear-gradient(hsl(30 18% 92%) 1px, transparent 1px), linear-gradient(90deg, hsl(30 18% 92%) 1px, transparent 1px)',
backgroundSize: '60px 60px',
}}
/>
{/* Decorative floating shapes from 21st.dev pattern */}
<FloatingShape
className="left-[8%] top-[18%]"
delay={0.3}
size="w-[280px] h-[70px]"
color="bg-primary/10"
shouldAnimate={shouldAnimate}
/>
<FloatingShape
className="right-[5%] bottom-[22%]"
delay={0.5}
size="w-[220px] h-[55px]"
color="bg-brand-amber/8"
shouldAnimate={shouldAnimate}
/>
<div className="relative max-w-7xl mx-auto px-6 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center min-h-[80vh]">
{/* Left: Copy */}
<motion.div
variants={staggerChildren}
initial="hidden"
animate="visible"
className="relative z-10 flex flex-col gap-6"
>
{/* Badge */}
<motion.div variants={fadeUp}>
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-primary/25 bg-primary/5">
<span className="w-2 h-2 rounded-full bg-brand-success animate-pulse" />
<span className="text-sm font-medium text-primary">Synthetic Research Platform</span>
</div>
</motion.div>
{/* H1 */}
<motion.h1
variants={fadeUp}
className="font-display font-black text-display-1 text-foreground leading-tight"
>
Synthetic research.{' '}
<span className="text-gradient">Real insights.</span>
</motion.h1>
{/* Subtitle */}
<motion.p variants={fadeUp} className="text-xl text-muted-foreground leading-relaxed max-w-lg">
Generate a panel of detailed AI personas from one brief, then run a moderated focus group
in minutes, not weeks.
</motion.p>
{/* CTAs */}
<motion.div variants={fadeUp} className="flex flex-col sm:flex-row gap-4 mt-2">
<button
onClick={() => navigate('/register')}
className="inline-flex items-center justify-center gap-2 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/30 hover:shadow-xl hover:-translate-y-0.5"
>
Start free 10 trial credits
<ArrowRight className="h-4 w-4" />
</button>
<a
href="#live-preview"
className="inline-flex items-center justify-center gap-2 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"
>
<Play className="h-4 w-4 fill-current" />
Watch demo
</a>
</motion.div>
{/* Trust line */}
<motion.p variants={fadeUp} className="text-xs text-muted-foreground/70">
No credit card required&nbsp;·&nbsp;Results in under 5 minutes
</motion.p>
{/* Tech stack trust line */}
<motion.div variants={fadeUp} className="flex items-center gap-3 pt-2">
<span className="text-xs text-muted-foreground/50 tracking-widest uppercase">Built on</span>
<div className="flex items-center gap-4 opacity-40">
{['Azure AI Foundry', 'MongoDB', 'Stripe'].map(name => (
<span key={name} className="text-xs font-medium text-muted-foreground">{name}</span>
))}
</div>
</motion.div>
</motion.div>
{/* Right: Persona card stack */}
<div className="relative flex items-center justify-center h-[520px] z-10">
{/* Connection lines hint */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<svg className="w-full h-full opacity-10" viewBox="0 0 400 400">
<line x1="130" y1="200" x2="200" y2="200" stroke="hsl(28 78% 56%)" strokeWidth="1" strokeDasharray="4 4" />
<line x1="200" y1="200" x2="270" y2="200" stroke="hsl(28 78% 56%)" strokeWidth="1" strokeDasharray="4 4" />
<circle cx="200" cy="200" r="4" fill="hsl(28 78% 56%)" />
</svg>
</div>
<div className="relative flex gap-4 items-center justify-center">
{PERSONAS.map((persona) => (
<PersonaCard
key={persona.id}
persona={persona}
delay={persona.delay}
shouldAnimate={shouldAnimate}
/>
))}
</div>
{/* Floating annotation card */}
<motion.div
initial={shouldAnimate ? { opacity: 0, scale: 0.85 } : false}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 1.0 }}
className="absolute -bottom-4 left-8 bg-primary text-primary-foreground rounded-2xl p-4 shadow-xl w-52"
>
<div className="flex items-center gap-2 mb-1">
<Logo size="sm" variant="mark-only" />
<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>
</motion.div>
{/* Themes badge */}
<motion.div
initial={shouldAnimate ? { opacity: 0, x: 20 } : false}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 1.2 }}
className="absolute top-8 right-0 bg-card border border-border rounded-xl p-3 shadow-lg"
>
<p className="text-[11px] font-semibold text-muted-foreground mb-2">Top themes detected</p>
{['Price sensitivity', 'Time-to-value', 'Trust signals'].map((theme, i) => (
<div key={theme} className="flex items-center gap-2 mb-1 last:mb-0">
<div className="h-1.5 rounded-full bg-primary/30 flex-1">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${85 - i * 18}%` }}
/>
</div>
<span className="text-[10px] text-muted-foreground w-20 truncate">{theme}</span>
</div>
))}
</motion.div>
</div>
</div>
{/* Scroll hint */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.5 }}
className="absolute bottom-8 left-1/2 -translate-x-1/2 flex flex-col items-center gap-1"
>
<motion.div
animate={shouldAnimate ? { y: [0, 6, 0] } : {}}
transition={{ duration: 1.5, repeat: Infinity }}
className="w-5 h-8 rounded-full border border-border flex items-start justify-center p-1"
>
<div className="w-1 h-2 rounded-full bg-primary/50" />
</motion.div>
</motion.div>
</div>
</section>
);
}

View file

@ -0,0 +1,151 @@
import { motion, useReducedMotion, useInView } from 'framer-motion';
import { useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { FileText, Users, BarChart2, ArrowRight } from 'lucide-react';
import { fadeUp, staggerChildren, viewportOnce } from '@/lib/motion';
const STEPS = [
{
num: '01',
icon: FileText,
title: 'Write a brief',
desc: 'Describe your target audience — age range, lifestyle, attitudes, geography. One paragraph is enough.',
},
{
num: '02',
icon: Users,
title: 'Generate your panel',
desc: 'Cohorta builds 550 rich synthetic personas from your brief in under 2 minutes. Review and adjust before proceeding.',
},
{
num: '03',
icon: BarChart2,
title: 'Run your session',
desc: 'Launch an AI-moderated focus group — autonomous or manual mode. Export themes and transcripts when done.',
},
];
function StepIndicator({ index, total, isVisible }: { index: number; total: number; isVisible: boolean }) {
return (
<div className="hidden lg:flex items-center gap-0 absolute -top-8 left-0 right-0 justify-center">
{Array.from({ length: total }).map((_, i) => (
<div key={i} className="flex items-center">
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={isVisible ? { scale: 1, opacity: 1 } : { scale: 0, opacity: 0 }}
transition={{ duration: 0.3, delay: i * 0.15 }}
className={`w-8 h-8 rounded-full border-2 flex items-center justify-center text-xs font-bold ${
i <= index
? 'bg-primary border-primary text-primary-foreground'
: 'bg-background border-border text-muted-foreground'
}`}
>
{i + 1}
</motion.div>
{i < total - 1 && (
<motion.div
initial={{ scaleX: 0 }}
animate={isVisible ? { scaleX: 1 } : { scaleX: 0 }}
transition={{ duration: 0.4, delay: i * 0.15 + 0.15 }}
className={`w-20 h-0.5 origin-left ${i < index ? 'bg-primary' : 'bg-border'}`}
/>
)}
</div>
))}
</div>
);
}
export default function HowItWorks() {
const navigate = useNavigate();
const shouldReduce = useReducedMotion();
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-60px' });
return (
<section className="py-24 px-6" id="product" ref={ref}>
<div className="max-w-5xl mx-auto">
<motion.div
variants={staggerChildren}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
{/* Header */}
<motion.div variants={fadeUp} className="text-center mb-20">
<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" />
<span className="text-sm font-medium text-primary">How it works</span>
</div>
<h2 className="font-display font-bold text-display-2 text-foreground">
From brief to insight in three steps
</h2>
</motion.div>
{/* Steps */}
<div className="relative grid grid-cols-1 md:grid-cols-3 gap-12 pt-4">
{STEPS.map(({ num, icon: Icon, title, desc }, i) => (
<motion.div
key={num}
variants={fadeUp}
className="flex flex-col items-start relative"
>
{/* Step number with animated fill */}
<div className="flex items-center gap-4 mb-6">
<motion.div
initial={{ scale: 0, rotate: -15 }}
whileInView={{ scale: 1, rotate: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.1 + 0.2 }}
className="w-14 h-14 rounded-2xl bg-primary/10 border border-primary/20 flex items-center justify-center relative"
>
<Icon className="h-6 w-6 text-primary" />
{/* Step badge */}
<div className="absolute -top-2.5 -right-2.5 w-5 h-5 rounded-full bg-primary text-primary-foreground text-[10px] font-bold flex items-center justify-center">
{i + 1}
</div>
</motion.div>
{/* Connector arrow (hidden on mobile) */}
{i < STEPS.length - 1 && (
<motion.div
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.15 + 0.4 }}
className="hidden md:block absolute right-[-24px] top-7 text-border"
>
<ArrowRight className="h-4 w-4 text-primary/30" />
</motion.div>
)}
</div>
{/* Large outline step number in background */}
<div
className="font-display font-black text-8xl leading-none mb-4 select-none pointer-events-none"
style={{ WebkitTextStroke: '1.5px hsl(28 78% 56% / 0.15)', color: 'transparent' }}
>
{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>
</motion.div>
))}
</div>
{/* CTA */}
<motion.div variants={fadeUp} 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/30 hover:shadow-xl hover:-translate-y-0.5 inline-flex items-center gap-2"
>
Try it free
<ArrowRight className="h-4 w-4" />
</button>
</motion.div>
</motion.div>
</div>
</section>
);
}

View file

@ -0,0 +1,185 @@
import { motion, useReducedMotion } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import { ArrowRight } from 'lucide-react';
import { fadeUp, slideInLeft, slideInRight, staggerChildren, viewportOnce } from '@/lib/motion';
const MESSAGES = [
{
name: 'Maya, 32',
role: 'Coffee shop owner',
img: `${import.meta.env.BASE_URL}avatars/persona-1.svg`,
msg: "I love the concept, but $49 upfront is too steep for someone testing this once a quarter. A pay-per-result model would hook me.",
delay: 0,
},
{
name: 'James, 45',
role: 'CTO, Series B',
img: `${import.meta.env.BASE_URL}avatars/persona-3.svg`,
msg: "If it replaces even one recruiter day it's already ROI-positive. Time-to-insight is the only metric I care about.",
delay: 0.6,
},
{
name: 'Sofia, 28',
role: 'UX Research Lead',
img: `${import.meta.env.BASE_URL}avatars/persona-5.svg`,
msg: "Autonomous mode sold me. I briefed it at 9am and had a theme report by 9:20. Gamechanging for agile sprints.",
delay: 1.2,
},
{
name: 'Moderator AI',
role: 'Cohorta',
img: null,
isAI: true,
msg: "Great point Sofia — team, what's the dealbreaker here: price, time saved, or output quality?",
delay: 1.8,
},
];
const THEMES = [
{ label: 'Price sensitivity', pct: 87, color: 'bg-primary' },
{ label: 'Autonomous workflow', pct: 72, color: 'bg-brand-amber' },
{ label: 'Time-to-insight', pct: 65, color: 'bg-brand-success' },
];
export default function LivePreview() {
const navigate = useNavigate();
const shouldReduce = useReducedMotion();
return (
<section className="py-20 px-6 bg-[hsl(var(--brand-charcoal))]" id="live-preview">
<div className="max-w-6xl mx-auto">
<motion.div
variants={staggerChildren}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
className="flex flex-col lg:flex-row items-center gap-14"
>
{/* Left: copy */}
<motion.div variants={slideInLeft} className="lg:w-2/5">
<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 demo</span>
</div>
<h2 className="font-display font-bold text-display-2 text-foreground mb-5">
Watch your synthetic panel debate your product.
</h2>
<p className="text-muted-foreground leading-relaxed mb-6">
Each persona speaks from their own perspective, challenges other panelists, and responds to the moderator in real time. The AI extracts key themes and flags consensus as the session unfolds.
</p>
<ul className="space-y-3 mb-8">
{[
'6 months of research compressed into 20 minutes',
'Autonomous moderation — brief it and read results',
'Themes and consensus detected live, not after',
].map(item => (
<li key={item} className="flex items-start gap-3 text-sm text-muted-foreground">
<div className="w-5 h-5 rounded-full bg-primary/15 flex items-center justify-center flex-shrink-0 mt-0.5">
<div className="w-1.5 h-1.5 rounded-full bg-primary" />
</div>
{item}
</li>
))}
</ul>
<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 hover:-translate-y-0.5"
>
Run a free session
<ArrowRight className="h-4 w-4" />
</button>
</motion.div>
{/* Right: mock chat UI */}
<motion.div variants={slideInRight} className="lg:w-3/5 w-full">
<div className="bg-card border border-border rounded-2xl overflow-hidden shadow-2xl">
{/* Header bar */}
<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>
<span className="text-xs text-muted-foreground">4 participants · Autonomous mode</span>
</div>
<div className="flex divide-x divide-border">
{/* Chat column */}
<div className="flex-1 p-5 space-y-4 max-h-72 overflow-hidden">
{MESSAGES.map(({ name, role, img, isAI, msg, delay }) => (
<motion.div
key={name}
initial={!shouldReduce ? { opacity: 0, x: -10 } : false}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay, duration: 0.4 }}
className="flex gap-3"
>
<div className="w-8 h-8 rounded-full flex-shrink-0 overflow-hidden border border-border bg-secondary flex items-center justify-center">
{img ? (
<img src={img} alt={name} className="w-full h-full object-cover" onError={(e) => {
const t = e.target as HTMLImageElement;
t.style.display = 'none';
t.nextElementSibling?.classList.remove('hidden');
}} />
) : null}
<span className={`text-xs font-bold text-primary ${img ? 'hidden' : ''}`}>{name[0]}</span>
</div>
<div>
<div className="flex items-baseline gap-1.5 mb-1">
<p className={`text-xs font-semibold ${isAI ? 'text-primary' : 'text-foreground/80'}`}>{name}</p>
{role && <p className="text-[10px] text-muted-foreground/60">{role}</p>}
</div>
<p className="text-sm text-muted-foreground leading-relaxed">{msg}</p>
</div>
</motion.div>
))}
</div>
{/* Themes sidebar */}
<div className="w-44 p-4 hidden md:block">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-4">Themes detected</p>
<div className="space-y-4">
{THEMES.map(({ label, pct, color }, i) => (
<div key={label}>
<div className="flex justify-between mb-1">
<span className="text-[10px] text-muted-foreground">{label}</span>
<span className="text-[10px] font-semibold text-foreground">{pct}%</span>
</div>
<div className="h-1 rounded-full bg-border">
<motion.div
initial={{ width: 0 }}
whileInView={{ width: `${pct}%` }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: i * 0.15 + 0.5 }}
className={`h-full rounded-full ${color}`}
/>
</div>
</div>
))}
</div>
<div className="mt-6 pt-4 border-t border-border">
<p className="text-[10px] text-muted-foreground mb-2">Consensus</p>
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-brand-success" />
<span className="text-[10px] text-brand-success font-medium">Building</span>
</div>
</div>
</div>
</div>
{/* Input bar */}
<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 question
</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>
</motion.div>
</motion.div>
</div>
</section>
);
}

View file

@ -0,0 +1,166 @@
import { motion } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import { CheckCircle2, Info } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { fadeUp, staggerChildren, viewportOnce } from '@/lib/motion';
interface CreditPack {
id: string;
name: string;
price_usd: number;
credits: number;
popular?: boolean;
}
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 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 COST_MATH = {
starter: { sessions: 1, personas: 25 },
pro: { sessions: 5, personas: 110 },
scale: { sessions: 15, personas: 300 },
};
function CreditTooltip() {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button className="inline-flex items-center gap-1 text-xs text-primary underline underline-offset-2 hover:text-primary/80">
<Info className="h-3 w-3" />
What's a credit?
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs p-3">
<p className="text-xs leading-relaxed">
<strong>1 persona</strong> = 2 credits<br />
<strong>1 focus group run</strong> = 40 credits<br />
<strong>Credits never expire.</strong> Trial: 10 free on signup.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export default function Pricing() {
const navigate = useNavigate();
const packs = DEFAULT_PACKS;
return (
<section className="py-24 px-6" id="pricing">
<div className="max-w-5xl mx-auto">
<motion.div
variants={staggerChildren}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
{/* Header */}
<motion.div variants={fadeUp} 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">
<span className="text-sm font-medium text-primary">Pricing</span>
</div>
<h2 className="font-display font-bold text-display-2 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 no card required.{' '}
<CreditTooltip />
</p>
</motion.div>
{/* Cards */}
<motion.div variants={staggerChildren} className="grid grid-cols-1 md:grid-cols-3 gap-6">
{packs.map((pack, i) => {
const features = PACK_FEATURES[pack.id] || PACK_FEATURES.pro;
const math = COST_MATH[pack.id as keyof typeof COST_MATH];
const costPerSession = (pack.price_usd / math.sessions).toFixed(0);
return (
<motion.div
key={pack.id}
variants={fadeUp}
custom={i}
className={`relative rounded-2xl p-8 flex flex-col ${
pack.popular
? 'bg-card border-2 border-primary shadow-2xl shadow-primary/15 scale-[1.02]'
: '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-1">{pack.credits} credits included</p>
{/* Cost-per-session visualisation */}
<div className="mb-6">
<div className="flex items-center justify-between text-[11px] mb-1">
<span className="text-muted-foreground">~{math.personas} personas</span>
<span className="text-muted-foreground">~${costPerSession}/session</span>
</div>
<div className="h-1.5 rounded-full bg-primary/15">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${(pack.credits / 600) * 100}%` }}
/>
</div>
</div>
<ul className="space-y-3 mb-8 flex-1">
{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>
<button
onClick={() => navigate(`/register?plan=${pack.id}`)}
className={`block w-full 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 hover:shadow-lg hover:shadow-primary/25'
: 'border border-border text-foreground hover:border-primary/50 hover:text-primary'
}`}
>
Get started
</button>
</motion.div>
);
})}
</motion.div>
<motion.p variants={fadeUp} className="text-center text-sm text-muted-foreground mt-8">
Not sure?{' '}
<button onClick={() => navigate('/register')} className="text-primary hover:underline">
Start with 10 free credits
</button>
{' '} no card required.
</motion.p>
</motion.div>
</div>
</section>
);
}

View file

@ -0,0 +1,86 @@
import { motion, useReducedMotion, useSpring, useTransform, useInView } from 'framer-motion';
import { useEffect, useRef } from 'react';
import { Clock, DollarSign, Globe } from 'lucide-react';
import { fadeUp, staggerChildren, viewportOnce } from '@/lib/motion';
const STATS = [
{
icon: Clock,
prefix: '',
value: 10,
suffix: '×',
label: 'FASTER',
sub: 'Insights in hours, not weeks',
},
{
icon: DollarSign,
prefix: '',
value: 99,
suffix: '%',
label: 'CHEAPER',
sub: 'No recruiter, incentives, or no-shows',
},
{
icon: Globe,
prefix: '',
value: 24,
suffix: '/7',
label: 'SCALE',
sub: 'Run 50 sessions in parallel',
},
];
function AnimatedNumber({ value, shouldAnimate }: { value: number; shouldAnimate: boolean }) {
const ref = useRef<HTMLSpanElement>(null);
const isInView = useInView(ref, { once: true, margin: '-40px' });
const spring = useSpring(0, { stiffness: 60, damping: 20 });
const display = useTransform(spring, (v) => Math.round(v).toString());
useEffect(() => {
if (isInView && shouldAnimate) {
spring.set(value);
} else if (!shouldAnimate) {
spring.set(value);
}
}, [isInView, shouldAnimate, spring, value]);
return (
<motion.span ref={ref}>
{shouldAnimate ? <motion.span>{display}</motion.span> : <span>{value}</span>}
</motion.span>
);
}
export default function StatsBand() {
const shouldReduce = useReducedMotion();
const shouldAnimate = !shouldReduce;
return (
<section className="py-20 px-6 bg-[hsl(var(--brand-charcoal))]">
<div className="max-w-5xl mx-auto">
<motion.div
variants={staggerChildren}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
className="grid grid-cols-1 md:grid-cols-3 gap-5"
>
{STATS.map(({ icon: Icon, prefix, value, suffix, label, sub }) => (
<motion.div key={label} variants={fadeUp} className="corner-card p-8 group">
<div className="h-10 w-10 rounded-xl bg-primary/15 flex items-center justify-center mb-4 group-hover:bg-primary/25 transition-colors">
<Icon className="h-5 w-5 text-primary" />
</div>
<div className="font-display font-black text-5xl text-foreground mb-1">
{prefix}
<AnimatedNumber value={value} shouldAnimate={shouldAnimate} />
{suffix}
</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>
</motion.div>
))}
</motion.div>
</div>
</section>
);
}

View file

@ -0,0 +1,102 @@
import { motion } from 'framer-motion';
import { Star } from 'lucide-react';
import { fadeUp, staggerChildren, viewportOnce } from '@/lib/motion';
const TESTIMONIALS = [
{
quote: "We cut concept-testing from 3 weeks to 48 hours. The personas push back in ways real respondents would — the session on our pricing tiers caught an objection pattern we'd completely missed.",
name: 'Alex K.',
role: 'Product Manager, B2B SaaS',
img: `${import.meta.env.BASE_URL}avatars/persona-2.svg`,
highlight: '3 weeks → 48 hours',
},
{
quote: "I ran six audience segments in one afternoon. That would have taken $40k and two months with a traditional research agency. Directionally accurate for early-stage work — exactly what I needed.",
name: 'Sarah M.',
role: 'Marketing Director, Consumer Goods',
img: `${import.meta.env.BASE_URL}avatars/persona-6.svg`,
highlight: '$40k → ~$80',
},
{
quote: "The autonomous moderation is the killer feature. I briefed the system at 9am and had a full transcript plus theme report by 9:20. I now use synthetic research to make my real user sessions better.",
name: 'Tom R.',
role: 'UX Research Lead, Fintech',
img: `${import.meta.env.BASE_URL}avatars/persona-4.svg`,
highlight: '20 min end-to-end',
},
];
export default function Testimonials() {
return (
<section className="py-24 px-6">
<div className="max-w-6xl mx-auto">
<motion.div
variants={staggerChildren}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
<motion.div variants={fadeUp} className="text-center mb-4">
<span className="text-xs font-medium tracking-widest uppercase text-muted-foreground/60 border border-border rounded-full px-3 py-1">
Example use cases
</span>
</motion.div>
<motion.div variants={fadeUp} className="text-center mb-14">
<h2 className="font-display font-bold text-display-2 text-foreground">
Researchers who switched to synthetic
</h2>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{TESTIMONIALS.map(({ quote, name, role, img, highlight }, i) => (
<motion.div
key={name}
variants={fadeUp}
custom={i}
className="corner-card p-7 flex flex-col"
>
{/* Stars */}
<div className="flex gap-0.5 mb-4">
{[...Array(5)].map((_, j) => (
<Star key={j} className="h-3.5 w-3.5 fill-primary text-primary" />
))}
</div>
{/* Highlight metric */}
<div className="mb-4 inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 border border-primary/20 w-fit">
<span className="text-xs font-semibold text-primary">{highlight}</span>
</div>
{/* Quote */}
<p className="text-foreground/80 leading-relaxed mb-6 text-sm flex-1">"{quote}"</p>
{/* Author */}
<div className="flex items-center gap-3">
<img
src={img}
alt={name}
className="w-9 h-9 rounded-full object-cover border border-border"
onError={(e) => {
const t = e.target as HTMLImageElement;
t.style.display = 'none';
const next = t.nextElementSibling as HTMLElement;
if (next) next.classList.remove('hidden');
}}
/>
<div className="w-9 h-9 rounded-full bg-primary/15 border border-primary/30 flex items-center justify-center hidden">
<span className="text-xs font-bold text-primary">{name[0]}</span>
</div>
<div>
<p className="text-sm font-semibold text-foreground">{name}</p>
<p className="text-xs text-muted-foreground">{role}</p>
</div>
</div>
</motion.div>
))}
</div>
</motion.div>
</div>
</section>
);
}

View file

@ -0,0 +1,39 @@
import { motion } from 'framer-motion';
import { fadeUp, viewportOnce } from '@/lib/motion';
export default function TrustBar() {
return (
<section className="py-10 px-6 border-y border-border/50">
<div className="max-w-5xl mx-auto">
<motion.div
variants={fadeUp}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
className="flex flex-col items-center gap-4"
>
<p className="text-xs font-medium tracking-widest uppercase text-muted-foreground/50">
Built on enterprise infrastructure
</p>
<div className="flex flex-wrap items-center justify-center gap-8">
{[
{ name: 'Azure AI Foundry', abbr: 'Azure AI' },
{ name: 'MongoDB Atlas', abbr: 'MongoDB' },
{ name: 'Stripe', abbr: 'Stripe' },
{ name: 'Anthropic', abbr: 'Claude' },
].map(({ name, abbr }) => (
<div
key={name}
title={name}
className="flex items-center gap-1.5 opacity-30 hover:opacity-60 transition-opacity"
>
<div className="w-2 h-2 rounded-full bg-muted-foreground" />
<span className="text-sm font-semibold text-muted-foreground tracking-tight">{abbr}</span>
</div>
))}
</div>
</motion.div>
</div>
</section>
);
}

View file

@ -0,0 +1,146 @@
import { motion } from 'framer-motion';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { fadeUp, staggerChildren, viewportOnce } from '@/lib/motion';
const USE_CASES = [
{
id: 'pm',
label: 'For Product Managers',
headline: 'Ship without the 3-week research cycle.',
story: `You've got a sprint review in 6 days. Your PM gut says users will love the new onboarding flow — but you need at least a directional signal before committing engineering time.
Cohorta lets you generate a 10-persona panel matching your ICP in 90 seconds. Run a structured concept test in the next 20 minutes. Get a prioritised list of objections, questions, and "I would definitely use this" moments before the sprint even starts.`,
bullets: [
'Test new feature concepts before backlog grooming',
'Validate pricing tiers against realistic user segments',
'Identify friction points in flows before engineering handoff',
],
tag: '~$8 per session vs $3k+ traditional',
persona: `${import.meta.env.BASE_URL}avatars/persona-2.svg`,
personaName: 'Arjun, Senior PM · SaaS',
quote: '"I ran six audience segments in one afternoon. That would have taken two months and $40k with a research agency."',
},
{
id: 'marketing',
label: 'For Marketers',
headline: 'Stress-test your messaging before it costs you.',
story: `Before you launch that campaign — the copy, the positioning, the offer — wouldn't you want to know how 30 different buyer profiles actually react to it?
With Cohorta you can generate a segment-specific panel (early adopters, conservative buyers, price-sensitive SMBs) and run message-testing sessions in parallel. Find out which headline resonates, which objection kills the conversion, and which segment is actually worth targeting.`,
bullets: [
'Test ad copy and landing page headlines across audience segments',
'Find the objections that kill conversions — before launch',
'Run A/B message tests on synthetic panels in minutes',
],
tag: '20 min session · instant theme report',
persona: `${import.meta.env.BASE_URL}avatars/persona-6.svg`,
personaName: 'Sarah, Marketing Director · Consumer Goods',
quote: '"The personas push back in ways real respondents would. I use it for every campaign brief now."',
},
{
id: 'ux',
label: 'For UX Researchers',
headline: 'Front-load discovery. Save real users for validation.',
story: `Real user sessions are expensive and hard to schedule. Synthetic research doesn't replace them — but it makes them far more valuable.
Use Cohorta to front-load your problem space before recruiting begins. Identify the right hypotheses, stress-test your discussion guide, and arrive at moderated sessions knowing exactly which threads are worth pulling. Your real participants' time (and your budget) go further.`,
bullets: [
'Stress-test your discussion guide before recruiting starts',
'Identify edge-case personas you might have overlooked',
'Generate a draft topic list from your research objectives',
],
tag: 'directionally accurate for exploratory research',
persona: `${import.meta.env.BASE_URL}avatars/persona-7.svg`,
personaName: 'Tom, UX Research Lead · Fintech',
quote: '"I briefed the system at 9am and had a full transcript + theme report by 9:20. I use synthetic research to make real research better."',
},
];
export default function UseCases() {
return (
<section className="py-24 px-6 bg-[hsl(var(--brand-charcoal))]">
<div className="max-w-6xl mx-auto">
<motion.div
variants={staggerChildren}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
<motion.div variants={fadeUp} className="text-center mb-12">
<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="text-sm font-medium text-primary">Who it's for</span>
</div>
<h2 className="font-display font-bold text-display-2 text-foreground">
Pick your role. See the exact value.
</h2>
</motion.div>
<motion.div variants={fadeUp}>
<Tabs defaultValue="pm" className="w-full">
<TabsList className="flex w-full max-w-lg mx-auto mb-10 bg-secondary/50 p-1 rounded-full h-auto">
{USE_CASES.map(({ id, label }) => (
<TabsTrigger
key={id}
value={id}
className="flex-1 rounded-full text-sm font-medium data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=inactive]:text-muted-foreground transition-all py-2"
>
{label.replace('For ', '')}
</TabsTrigger>
))}
</TabsList>
{USE_CASES.map(({ id, headline, story, bullets, tag, persona, personaName, quote }) => (
<TabsContent key={id} value={id}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10 items-start">
{/* Left: story */}
<div>
<h3 className="font-display font-bold text-2xl text-foreground mb-5">{headline}</h3>
<div className="space-y-4 text-muted-foreground leading-relaxed text-sm">
{story.split('\n\n').map((para, i) => (
<p key={i}>{para}</p>
))}
</div>
<ul className="mt-6 space-y-2">
{bullets.map(b => (
<li key={b} className="flex items-start gap-3 text-sm text-muted-foreground">
<div className="w-5 h-5 rounded-full bg-primary/15 flex items-center justify-center flex-shrink-0 mt-0.5">
<div className="w-1.5 h-1.5 rounded-full bg-primary" />
</div>
{b}
</li>
))}
</ul>
<div className="mt-6 inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-brand-amber/10 border border-brand-amber/20">
<span className="text-xs font-medium text-brand-amber">{tag}</span>
</div>
</div>
{/* Right: testimonial card */}
<div className="bg-card border border-border rounded-2xl p-7">
<div className="flex items-center gap-4 mb-5">
<img
src={persona}
alt={personaName}
className="w-12 h-12 rounded-full object-cover border border-border"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
<div>
<p className="text-sm font-semibold text-foreground">{personaName.split(' · ')[0]}</p>
<p className="text-xs text-muted-foreground">{personaName.split(' · ')[1]}</p>
</div>
</div>
<blockquote className="text-sm text-foreground/80 leading-relaxed italic">
{quote}
</blockquote>
<p className="mt-4 text-[10px] text-muted-foreground/50 uppercase tracking-widest">Example use case</p>
</div>
</div>
</TabsContent>
))}
</Tabs>
</motion.div>
</motion.div>
</div>
</section>
);
}

View file

@ -2,23 +2,21 @@ 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' },
Product: [
{ label: 'Synthetic Personas', to: '/login' },
{ label: 'Focus Groups', to: '/login' },
{ label: 'Dashboard', to: '/login' },
{ label: 'Billing & Credits', to: '/login' },
],
Company: [
{ label: 'About', to: '#' },
{ label: 'Blog', to: '#' },
{ label: 'Careers', to: '#' },
{ label: 'Contact', to: '#' },
{ label: 'About', to: '/about' },
{ label: 'Contact', to: 'mailto:hello@cohorta.ai-impress.com', external: true },
],
Legal: [
{ label: 'Privacy Policy', to: '#' },
{ label: 'Terms of Service', to: '#' },
{ label: 'Cookie Policy', to: '#' },
{ label: 'GDPR', to: '#' },
{ label: 'Privacy Policy', to: '/privacy' },
{ label: 'Terms of Service', to: '/terms' },
{ label: 'Cookie Policy', to: '/cookies' },
{ label: 'GDPR', to: '/gdpr' },
],
};
@ -29,29 +27,38 @@ export default function Footer() {
<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.
<Logo withWordmark withTagline className="mb-4" />
<p className="text-sm text-muted-foreground leading-relaxed max-w-xs mt-4">
Run a synthetic focus group in under 20 minutes. No recruitment, no waiting, no no-shows.
</p>
<p className="text-xs text-muted-foreground/60 mt-4">
Powered by{' '}
<span className="text-primary font-medium">AImpress LTD</span>
<p className="text-xs text-muted-foreground/50 mt-4">
A product by{' '}
<span className="text-primary/70 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>
<h4 className="text-xs font-bold text-foreground mb-4 uppercase tracking-widest">{group}</h4>
<ul className="space-y-3">
{links.map(({ label, to }) => (
{links.map(({ label, to, external }: { label: string; to: string; external?: boolean }) => (
<li key={label}>
<Link
to={to}
className="text-sm text-muted-foreground hover:text-primary transition-colors duration-200"
>
{label}
</Link>
{external ? (
<a
href={to}
className="text-sm text-muted-foreground hover:text-primary transition-colors duration-200"
>
{label}
</a>
) : (
<Link
to={to}
className="text-sm text-muted-foreground hover:text-primary transition-colors duration-200"
>
{label}
</Link>
)}
</li>
))}
</ul>
@ -64,7 +71,7 @@ export default function Footer() {
© {new Date().getFullYear()} AImpress LTD. All rights reserved.
</p>
<p className="text-xs text-muted-foreground/50">
Cohorta is a product of AImpress LTD
EU-hosted · GDPR-safe · Built in Lisbon
</p>
</div>
</div>

View file

@ -1,10 +1,11 @@
import { useState, useEffect } from 'react';
import { Link, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { Link, useNavigate, useLocation } 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';
import { motion, useScroll, useSpring, useReducedMotion } from 'framer-motion';
const navLinks = [
{ label: 'Home', to: '/', anchor: null },
@ -19,6 +20,9 @@ export default function Header() {
const location = useLocation();
const [scrolled, setScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const shouldReduce = useReducedMotion();
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, { stiffness: 200, damping: 30 });
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 24);
@ -43,6 +47,13 @@ export default function Header() {
scrolled ? 'py-3' : 'py-5'
)}
>
{/* Scroll progress bar */}
{!shouldReduce && (
<motion.div
className="absolute top-0 left-0 right-0 h-[2px] bg-primary origin-left z-10"
style={{ scaleX }}
/>
)}
<div className="max-w-7xl mx-auto px-5">
<div
className={cn(
@ -93,12 +104,6 @@ export default function Header() {
{/* 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 />
) : (

View file

@ -39,6 +39,12 @@
--radius: 1rem;
/* Extended brand palette — break the single-orange monotony */
--brand-amber: 38 92% 62%; /* warm amber for badges / secondary accent */
--brand-cream: 35 35% 88%; /* soft cream for dividers / light copy */
--brand-charcoal: 220 18% 14%; /* deep section bg variant */
--brand-success: 152 60% 50%; /* verified / free badges */
--sidebar-background: 220 22% 10%;
--sidebar-foreground: 30 18% 92%;
--sidebar-primary: 28 78% 56%;
@ -182,6 +188,12 @@
color: hsl(var(--accent-foreground));
}
/* Dark CTA band — distinct from the features orange band */
.cta-band {
background: radial-gradient(ellipse at 50% 0%, hsl(28 85% 50% / 0.45) 0%, hsl(220 18% 10%) 65%);
color: hsl(var(--foreground));
}
/* Corner card — dark card with diagonal graphic overlay (like AIMPRESS stat cards) */
.corner-card {
position: relative;

38
src/lib/motion.ts Normal file
View file

@ -0,0 +1,38 @@
import type { Variants } from 'framer-motion';
export const fadeUp: Variants = {
hidden: { opacity: 0, y: 24 },
visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: [0.25, 0.46, 0.45, 0.94] } },
};
export const fadeIn: Variants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.4 } },
};
export const staggerChildren: Variants = {
hidden: {},
visible: { transition: { staggerChildren: 0.08 } },
};
export const staggerChildrenSlow: Variants = {
hidden: {},
visible: { transition: { staggerChildren: 0.15 } },
};
export const slideInLeft: Variants = {
hidden: { opacity: 0, x: -32 },
visible: { opacity: 1, x: 0, transition: { duration: 0.5, ease: [0.25, 0.46, 0.45, 0.94] } },
};
export const slideInRight: Variants = {
hidden: { opacity: 0, x: 32 },
visible: { opacity: 1, x: 0, transition: { duration: 0.5, ease: [0.25, 0.46, 0.45, 0.94] } },
};
export const scaleIn: Variants = {
hidden: { opacity: 0, scale: 0.92 },
visible: { opacity: 1, scale: 1, transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] } },
};
export const viewportOnce = { once: true, margin: '-80px' } as const;

72
src/pages/About.tsx Normal file
View file

@ -0,0 +1,72 @@
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import Logo from '@/components/brand/Logo';
import { motion } from 'framer-motion';
import { fadeUp, staggerChildren } from '@/lib/motion';
export default function About() {
const navigate = useNavigate();
return (
<div className="min-h-screen bg-background">
<div className="max-w-3xl mx-auto px-6 py-16">
<Link to="/" className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-12 group">
<ArrowLeft className="h-4 w-4 group-hover:-translate-x-1 transition-transform" />
Back to Cohorta
</Link>
<motion.div variants={staggerChildren} initial="hidden" animate="visible">
<motion.div variants={fadeUp} className="flex items-center gap-3 mb-8">
<Logo size="lg" variant="horizontal" withWordmark />
</motion.div>
<motion.h1 variants={fadeUp} className="font-display font-black text-4xl md:text-5xl text-foreground mb-6">
Synthetic research, finally{' '}
<span className="text-gradient">without the waiting.</span>
</motion.h1>
<motion.div variants={fadeUp} className="space-y-5 text-muted-foreground leading-relaxed">
<p>
Cohorta is built by <strong className="text-foreground">AImpress LTD</strong>, a product studio
based in Lisbon, Portugal. We build AI-powered tools that help teams make better decisions faster.
</p>
<p>
We built Cohorta after watching great product teams spend 3 weeks and $15,000 to run a focus group
that could have informed a decision they needed to make <em>today</em>. Research shouldn't be a luxury
reserved for teams with big budgets and long runways.
</p>
<p>
Cohorta is not a replacement for talking to real humans it's what you do before that conversation,
so that conversation is worth having. Front-load discovery. Arrive at real user sessions with sharper
hypotheses. Spend research budget where it matters.
</p>
<p>
The platform is built on Azure AI Foundry, MongoDB, and Stripe. All infrastructure is EU-hosted.
We don't sell your data. We don't train AI models on your research.
</p>
</motion.div>
<motion.div variants={fadeUp} className="mt-10 border-t border-border pt-8">
<p className="text-sm text-muted-foreground mb-4">Questions? We respond within one business day.</p>
<a
href="mailto:hello@cohorta.ai-impress.com"
className="inline-flex items-center gap-2 text-primary font-semibold hover:underline"
>
hello@cohorta.ai-impress.com
<ArrowRight className="h-4 w-4" />
</a>
</motion.div>
<motion.div variants={fadeUp} className="mt-10">
<button
onClick={() => navigate('/register')}
className="px-8 py-4 rounded-full bg-primary text-primary-foreground font-semibold hover:bg-primary/90 transition-all shadow-lg hover:-translate-y-0.5"
>
Try Cohorta free
</button>
</motion.div>
</motion.div>
</div>
</div>
);
}

View file

@ -1,513 +1,31 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import {
Users, MessageSquare, Sparkles, Download, ArrowRight, ArrowDown,
Zap, Clock, DollarSign, Globe, ChevronDown, ChevronUp,
CheckCircle2, Star
} from 'lucide-react';
// ──────────────────────────────────────────────
// 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>
);
interface CreditPack {
id: string;
name: string;
price_usd: number;
credits: number;
popular?: boolean;
}
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 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 24 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="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 page
// ──────────────────────────────────────────────
import Hero from '@/components/landing/Hero';
import TrustBar from '@/components/landing/TrustBar';
import StatsBand from '@/components/landing/StatsBand';
import FeatureGrid from '@/components/landing/FeatureGrid';
import HowItWorks from '@/components/landing/HowItWorks';
import LivePreview from '@/components/landing/LivePreview';
import Comparison from '@/components/landing/Comparison';
import UseCases from '@/components/landing/UseCases';
import Testimonials from '@/components/landing/Testimonials';
import Pricing from '@/components/landing/Pricing';
import FAQ from '@/components/landing/FAQ';
import FinalCTA from '@/components/landing/FinalCTA';
export default function Index() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const packs = DEFAULT_PACKS;
useEffect(() => {
const section = searchParams.get('scroll');
if (section) {
setTimeout(() => document.getElementById(section)?.scrollIntoView({ behavior: 'smooth' }), 100);
}
}, [searchParams]);
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="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>
{/* 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>
{/* 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"
>
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>
<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 550 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"
>
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>
<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>
</div>
</section>
{/* ── 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>
<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>
</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>
<Hero />
<TrustBar />
<StatsBand />
<FeatureGrid />
<HowItWorks />
<LivePreview />
<Comparison />
<UseCases />
<Testimonials />
<Pricing />
<FAQ />
<FinalCTA />
</div>
);
}

View file

@ -7,16 +7,125 @@ 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 } from 'lucide-react';
import { Loader2, Eye, EyeOff, Zap, DollarSign, Clock } from 'lucide-react';
import Logo from '@/components/brand/Logo';
import { motion } from 'framer-motion';
import { fadeUp, staggerChildren } from '@/lib/motion';
const loginSchema = z.object({
username: z.string().min(3, 'Username must be at least 3 characters'),
username: z.string().min(1, 'Email or username is required'),
password: z.string().min(4, 'Password must be at least 4 characters'),
});
type LoginFormValues = z.infer<typeof loginSchema>;
const MOCK_MESSAGES = [
{ avatar: '1', name: 'Maya', role: 'Coffee shop owner', text: "The price point feels too corporate. I'd expect something like this to cost thousands." },
{ avatar: '3', name: 'James', role: 'CTO', text: "Honestly the time savings alone justify it. We spend 3 weeks every quarter just recruiting." },
{ avatar: '5', name: 'Sofia', role: 'UX Researcher', text: "I need to know the personas are actually diverse — not just demographic checkboxes." },
];
const MOCK_THEMES = [
{ label: 'Price sensitivity', pct: 78 },
{ label: 'Time savings', pct: 91 },
{ label: 'Output quality', pct: 65 },
];
function MockPanel() {
const [visible, setVisible] = useState(0);
useEffect(() => {
if (visible >= MOCK_MESSAGES.length) return;
const t = setTimeout(() => setVisible(v => v + 1), 900 + visible * 600);
return () => clearTimeout(t);
}, [visible]);
return (
<div className="h-full flex flex-col justify-center gap-6 max-w-sm mx-auto w-full">
{/* Why Cohorta pills */}
<div className="flex flex-wrap gap-2">
{[
{ icon: <Zap className="h-3.5 w-3.5" />, text: '10× faster setup' },
{ icon: <DollarSign className="h-3.5 w-3.5" />, text: '99% less spend' },
{ icon: <Clock className="h-3.5 w-3.5" />, text: 'Results in minutes' },
].map(p => (
<span key={p.text} className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-primary/15 text-primary border border-primary/20">
{p.icon}{p.text}
</span>
))}
</div>
{/* Mock chat */}
<div className="rounded-2xl bg-background/50 border border-border/60 overflow-hidden">
<div className="px-4 py-3 border-b border-border/60 flex items-center justify-between">
<span className="text-xs font-semibold text-foreground/70">Live session · 3 personas</span>
<span className="flex items-center gap-1.5 text-xs text-green-400 font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />
Active
</span>
</div>
<div className="p-4 space-y-4 min-h-[200px]">
{MOCK_MESSAGES.slice(0, visible).map((msg, i) => (
<motion.div key={i} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }} className="flex gap-3">
<img
src={`${import.meta.env.BASE_URL}avatars/persona-${msg.avatar}.svg`}
alt={msg.name}
className="w-8 h-8 rounded-full flex-shrink-0 bg-secondary"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
<div>
<p className="text-[11px] font-semibold text-foreground/60 mb-0.5">{msg.name} · {msg.role}</p>
<p className="text-sm text-foreground/80 leading-relaxed">"{msg.text}"</p>
</div>
</motion.div>
))}
{visible < MOCK_MESSAGES.length && (
<div className="flex gap-2 items-center text-xs text-muted-foreground">
<span className="flex gap-1">
<span className="w-1 h-1 rounded-full bg-primary/60 animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1 h-1 rounded-full bg-primary/60 animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1 h-1 rounded-full bg-primary/60 animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
Persona responding
</div>
)}
</div>
</div>
{/* Theme bars */}
<div className="rounded-xl bg-background/40 border border-border/50 p-4">
<p className="text-xs font-semibold text-foreground/50 uppercase tracking-wider mb-3">Themes detected</p>
<div className="space-y-2.5">
{MOCK_THEMES.map(t => (
<div key={t.label}>
<div className="flex justify-between text-xs text-foreground/60 mb-1">
<span>{t.label}</span>
<span className="font-medium text-foreground/80">{t.pct}%</span>
</div>
<div className="h-1.5 rounded-full bg-border/50 overflow-hidden">
<motion.div
className="h-full rounded-full bg-primary"
initial={{ width: 0 }}
animate={{ width: `${t.pct}%` }}
transition={{ duration: 1.2, delay: 0.5, ease: 'easeOut' }}
/>
</div>
</div>
))}
</div>
</div>
{/* Testimonial */}
<blockquote className="border-l-2 border-primary/30 pl-4">
<p className="text-sm text-foreground/60 leading-relaxed italic">
"We cut concept testing from 3 weeks to 48 hours. The AI personas push back in ways real respondents would."
</p>
<footer className="mt-2 text-xs text-foreground/40"> Alex K., Product Manager</footer>
</blockquote>
</div>
);
}
export default function Login() {
const navigate = useNavigate();
const location = useLocation();
@ -48,115 +157,105 @@ export default function Login() {
}
return (
<div className="flex min-h-[calc(100vh-5rem)] overflow-hidden">
<div className="flex min-h-screen overflow-hidden bg-background">
{/* 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="flex-1 flex items-center justify-center px-6 py-12">
<motion.div variants={staggerChildren} initial="hidden" animate="visible" className="w-full max-w-sm">
<motion.div variants={fadeUp}>
<Link to="/" className="inline-block mb-8">
<Logo withWordmark />
</Link>
</motion.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>
<motion.div variants={fadeUp}>
<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>
</motion.div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground/80 text-sm">Username</FormLabel>
<FormControl>
<Input
placeholder="your_username"
{...field}
disabled={isLoading}
autoComplete="username"
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-destructive text-xs" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground/80 text-sm">Password</FormLabel>
<FormControl>
<div className="relative">
<motion.div variants={fadeUp}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground/80 text-sm">Email or username</FormLabel>
<FormControl>
<Input
placeholder="••••••••"
type={showPassword ? 'text' : 'password'}
placeholder="you@company.com"
{...field}
disabled={isLoading}
autoComplete="current-password"
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"
autoComplete="username"
className="h-11 bg-secondary border-border text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary 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-destructive text-xs" />
</FormItem>
)}
/>
</FormControl>
<FormMessage className="text-destructive text-xs" />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isLoading}
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</>
) : (
'Sign in'
)}
</Button>
</form>
</Form>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground/80 text-sm">Password</FormLabel>
<FormControl>
<div className="relative">
<Input
placeholder="••••••••"
type={showPassword ? 'text' : 'password'}
{...field}
disabled={isLoading}
autoComplete="current-password"
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-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-destructive text-xs" />
</FormItem>
)}
/>
<p className="text-center text-sm text-muted-foreground mt-6">
<Button
type="submit"
disabled={isLoading}
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</>
) : (
'Sign in'
)}
</Button>
</form>
</Form>
</motion.div>
<motion.p variants={fadeUp} 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>
</motion.p>
</motion.div>
</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
{/* Right: live mock panel */}
<div className="hidden lg:flex w-[52%] bg-secondary/30 border-l border-border/50 items-center justify-center p-12 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-brand-charcoal/50 pointer-events-none" />
<div className="relative z-10 w-full">
<MockPanel />
</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>

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useNavigate, Link, useSearchParams } from 'react-router-dom';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@ -7,9 +7,11 @@ 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, Mail, CheckCircle2 } from 'lucide-react';
import { Loader2, Eye, EyeOff, Mail, CheckCircle2, Zap, DollarSign, Clock, Users } from 'lucide-react';
import { toastService } from '@/lib/toast';
import Logo from '@/components/brand/Logo';
import { motion } from 'framer-motion';
import { fadeUp, staggerChildren } from '@/lib/motion';
import axios from 'axios';
const registerSchema = z.object({
@ -24,8 +26,98 @@ const registerSchema = z.object({
type RegisterFormValues = z.infer<typeof registerSchema>;
const PLAN_META: Record<string, { label: string; credits: number; price: number }> = {
starter: { label: 'Starter', credits: 50, price: 49 },
pro: { label: 'Pro', credits: 220, price: 199 },
scale: { label: 'Scale', credits: 600, price: 499 },
};
const MOCK_MESSAGES = [
{ avatar: '2', name: 'Lena', role: 'Marketing Lead', text: "I love how it gives me multiple perspectives at once. It's like having a focus group on demand." },
{ avatar: '4', name: 'Tom', role: 'Product Manager', text: "We ran 6 sessions before committing to the roadmap. Would've cost $30k in real research." },
{ avatar: '6', name: 'Priya', role: 'UX Researcher', text: "The persona depth surprised me. They actually disagree with each other in interesting ways." },
];
function MockPanel() {
const [visible, setVisible] = useState(0);
useEffect(() => {
if (visible >= MOCK_MESSAGES.length) return;
const t = setTimeout(() => setVisible(v => v + 1), 1000 + visible * 700);
return () => clearTimeout(t);
}, [visible]);
return (
<div className="h-full flex flex-col justify-center gap-5 max-w-sm mx-auto w-full">
<div className="flex items-center gap-2 text-sm text-foreground/50">
<Users className="h-4 w-4 text-primary" />
<span className="font-medium">What your team gets on day one</span>
</div>
<div className="rounded-2xl bg-background/50 border border-border/60 overflow-hidden">
<div className="px-4 py-3 border-b border-border/60 flex items-center justify-between">
<span className="text-xs font-semibold text-foreground/70">Session · "New pricing model" · 3 personas</span>
<span className="flex items-center gap-1.5 text-xs text-green-400 font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />
Live
</span>
</div>
<div className="p-4 space-y-4 min-h-[210px]">
{MOCK_MESSAGES.slice(0, visible).map((msg, i) => (
<motion.div key={i} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }} className="flex gap-3">
<img
src={`${import.meta.env.BASE_URL}avatars/persona-${msg.avatar}.svg`}
alt={msg.name}
className="w-8 h-8 rounded-full flex-shrink-0 bg-secondary"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
<div>
<p className="text-[11px] font-semibold text-foreground/60 mb-0.5">{msg.name} · {msg.role}</p>
<p className="text-sm text-foreground/80 leading-relaxed">"{msg.text}"</p>
</div>
</motion.div>
))}
{visible < MOCK_MESSAGES.length && (
<div className="flex gap-2 items-center text-xs text-muted-foreground">
<span className="flex gap-1">
<span className="w-1 h-1 rounded-full bg-primary/60 animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1 h-1 rounded-full bg-primary/60 animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1 h-1 rounded-full bg-primary/60 animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
Thinking
</div>
)}
</div>
</div>
<div className="grid grid-cols-3 gap-3">
{[
{ icon: <Zap className="h-4 w-4 text-primary" />, label: '10× faster', sub: 'vs recruiting' },
{ icon: <DollarSign className="h-4 w-4 text-primary" />, label: '99% cheaper', sub: 'vs real sessions' },
{ icon: <Clock className="h-4 w-4 text-primary" />, label: '< 20 min', sub: 'first session' },
].map(s => (
<div key={s.label} className="rounded-xl bg-background/40 border border-border/50 p-3 text-center">
<div className="flex justify-center mb-1.5">{s.icon}</div>
<p className="text-xs font-bold text-foreground">{s.label}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">{s.sub}</p>
</div>
))}
</div>
<div className="flex flex-wrap gap-2">
{['Concept testing', 'Pricing research', 'Feature validation', 'UX feedback'].map(tag => (
<span key={tag} className="text-[11px] px-2.5 py-1 rounded-full border border-border/60 text-foreground/50">
{tag}
</span>
))}
</div>
</div>
);
}
export default function Register() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { isAuthenticated } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
@ -33,6 +125,9 @@ export default function Register() {
const [registered, setRegistered] = useState(false);
const [registeredEmail, setRegisteredEmail] = useState('');
const planKey = searchParams.get('plan')?.toLowerCase();
const planMeta = planKey ? PLAN_META[planKey] : null;
useEffect(() => {
if (isAuthenticated) navigate('/dashboard', { replace: true });
}, [isAuthenticated, navigate]);
@ -63,27 +158,27 @@ export default function Register() {
}
}
// Success state
if (registered) {
return (
<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">
<div className="flex min-h-screen items-center justify-center px-6 py-12 bg-background">
<motion.div variants={staggerChildren} initial="hidden" animate="visible" className="w-full max-w-sm text-center">
<motion.div variants={fadeUp} 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">
</motion.div>
<motion.h1 variants={fadeUp} className="font-display font-bold text-2xl text-foreground mb-3">Check your inbox</motion.h1>
<motion.p variants={fadeUp} className="text-muted-foreground mb-2">We sent a verification link to</motion.p>
<motion.p variants={fadeUp} className="font-semibold text-primary mb-6 break-all">{registeredEmail}</motion.p>
<motion.p variants={fadeUp} className="text-sm text-muted-foreground mb-8">
Click the link to verify your account. The link expires in 24 hours.
</p>
<button
</motion.p>
<motion.button
variants={fadeUp}
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">
</motion.button>
<motion.p variants={fadeUp} className="text-xs text-muted-foreground">
Didn't receive it?{' '}
<button
onClick={async () => {
@ -98,175 +193,176 @@ export default function Register() {
>
Resend email
</button>
</p>
</div>
</motion.p>
</motion.div>
</div>
);
}
return (
<div className="flex min-h-[calc(100vh-5rem)] overflow-hidden">
<div className="flex min-h-screen overflow-hidden bg-background">
{/* 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="flex-1 flex items-center justify-center px-6 py-12">
<motion.div variants={staggerChildren} initial="hidden" animate="visible" className="w-full max-w-sm">
<motion.div variants={fadeUp}>
<Link to="/" className="inline-block mb-8">
<Logo withWordmark />
</Link>
</motion.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">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground/80 text-sm">Username</FormLabel>
<FormControl>
<Input
placeholder="your_username"
{...field}
disabled={isLoading}
autoComplete="username"
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-destructive text-xs" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground/80 text-sm">Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="you@company.com"
{...field}
disabled={isLoading}
autoComplete="email"
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-destructive text-xs" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground/80 text-sm">Password</FormLabel>
<FormControl>
<div className="relative">
<Input
placeholder="Min. 6 characters"
type={showPassword ? 'text' : 'password'}
{...field}
disabled={isLoading}
autoComplete="new-password"
className="h-11 bg-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-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-destructive text-xs" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground/80 text-sm">Confirm password</FormLabel>
<FormControl>
<div className="relative">
<Input
placeholder="••••••••"
type={showConfirm ? 'text' : 'password'}
{...field}
disabled={isLoading}
autoComplete="new-password"
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-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-destructive text-xs" />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isLoading}
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</>
) : (
'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>
{/* Plan-aware badge */}
{planMeta && (
<motion.div variants={fadeUp} className="mb-6 px-4 py-3 rounded-xl bg-primary/10 border border-primary/25 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center flex-shrink-0">
<Zap className="h-4 w-4 text-primary" />
</div>
</form>
</Form>
<div>
<p className="text-sm font-semibold text-foreground">{planMeta.label} pack ${planMeta.price}</p>
<p className="text-xs text-muted-foreground">10 free credits first, then {planMeta.credits} credits for ${planMeta.price}</p>
</div>
</motion.div>
)}
<p className="text-center text-sm text-muted-foreground mt-6">
<motion.div variants={fadeUp}>
<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 · No card required</p>
</motion.div>
<motion.div variants={fadeUp}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground/80 text-sm">Username</FormLabel>
<FormControl>
<Input
placeholder="your_username"
{...field}
disabled={isLoading}
autoComplete="username"
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-destructive text-xs" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground/80 text-sm">Work email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="you@company.com"
{...field}
disabled={isLoading}
autoComplete="email"
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-destructive text-xs" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground/80 text-sm">Password</FormLabel>
<FormControl>
<div className="relative">
<Input
placeholder="Min. 6 characters"
type={showPassword ? 'text' : 'password'}
{...field}
disabled={isLoading}
autoComplete="new-password"
className="h-11 bg-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-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-destructive text-xs" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground/80 text-sm">Confirm password</FormLabel>
<FormControl>
<div className="relative">
<Input
placeholder="••••••••"
type={showConfirm ? 'text' : 'password'}
{...field}
disabled={isLoading}
autoComplete="new-password"
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-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-destructive text-xs" />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isLoading}
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</>
) : (
'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>
<span className="flex items-center gap-1"><CheckCircle2 className="h-3.5 w-3.5 text-primary" /> EU-hosted</span>
</div>
</form>
</Form>
</motion.div>
<motion.p variants={fadeUp} className="text-center text-sm text-muted-foreground mt-6">
Already have an account?{' '}
<Link to="/login" className="font-semibold text-primary hover:text-primary/80 transition-colors">
Sign in
</Link>
</p>
</div>
</motion.p>
</motion.div>
</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
{/* Right: live mock panel */}
<div className="hidden lg:flex w-[52%] bg-secondary/30 border-l border-border/50 items-center justify-center p-12 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-brand-charcoal/50 pointer-events-none" />
<div className="relative z-10 w-full">
<MockPanel />
</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>

View file

@ -0,0 +1,16 @@
import LegalStub from './LegalStub';
export default function Cookies() {
return (
<LegalStub
title="Cookie Policy"
lastUpdated="May 2026"
sections={[
{ heading: 'What cookies we use', body: 'Cohorta uses a single authentication cookie to keep you logged in. We do not use advertising cookies, third-party tracking pixels, or cross-site tracking.' },
{ heading: 'Session cookies', body: 'Session cookies are essential for the platform to function. They are deleted when you log out or close your browser.' },
{ heading: 'Analytics', body: 'We may use privacy-respecting analytics (no third-party cookies) to understand how the product is used. No personally identifiable information is included in analytics data.' },
{ heading: 'Your choices', body: 'You can disable cookies in your browser settings. Disabling essential cookies will prevent you from using the authenticated parts of Cohorta.' },
]}
/>
);
}

17
src/pages/legal/Gdpr.tsx Normal file
View file

@ -0,0 +1,17 @@
import LegalStub from './LegalStub';
export default function Gdpr() {
return (
<LegalStub
title="GDPR Compliance"
lastUpdated="May 2026"
sections={[
{ heading: 'Data controller', body: 'AImpress LTD is the data controller for all personal data processed through the Cohorta platform. Contact: hello@cohorta.ai-impress.com' },
{ heading: 'Legal basis for processing', body: 'We process personal data on the basis of contract performance (providing the Cohorta service) and legitimate interests (improving the product, fraud prevention).' },
{ heading: 'Data transfers', body: 'All data is stored and processed within the European Union. We do not transfer personal data to third countries except where strictly necessary for service provision (e.g. Azure AI processing in EU regions).' },
{ heading: 'Your GDPR rights', body: 'Right of access · Right to rectification · Right to erasure ("right to be forgotten") · Right to restriction of processing · Right to data portability · Right to object. Submit requests to hello@cohorta.ai-impress.com.' },
{ heading: 'Data Protection Officer', body: 'For GDPR queries please contact our Data Protection Officer at dpo@ai-impress.com. We aim to respond to all requests within 30 days.' },
]}
/>
);
}

View file

@ -0,0 +1,56 @@
import { ArrowLeft } from 'lucide-react';
import { Link } from 'react-router-dom';
import Logo from '@/components/brand/Logo';
interface LegalStubProps {
title: string;
lastUpdated: string;
sections: { heading: string; body: string }[];
}
export default function LegalStub({ title, lastUpdated, sections }: LegalStubProps) {
return (
<div className="min-h-screen bg-background">
<div className="max-w-3xl mx-auto px-6 py-16">
<div className="mb-12">
<Link to="/" className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-8 group">
<ArrowLeft className="h-4 w-4 group-hover:-translate-x-1 transition-transform" />
Back to Cohorta
</Link>
<div className="flex items-center gap-3 mb-4">
<Logo size="sm" variant="mark-only" />
<span className="text-xs text-muted-foreground">Cohorta · AImpress LTD</span>
</div>
<h1 className="font-display font-bold text-4xl text-foreground mb-2">{title}</h1>
<p className="text-sm text-muted-foreground">Last updated: {lastUpdated}</p>
</div>
<div className="bg-card border border-border rounded-2xl p-8 mb-8">
<p className="text-sm text-muted-foreground leading-relaxed">
The full version of this policy is in preparation. For the current version or any questions regarding
{' '}<strong className="text-foreground">{title}</strong>, please contact us at{' '}
<a href="mailto:hello@cohorta.ai-impress.com" className="text-primary hover:underline">
hello@cohorta.ai-impress.com
</a>
. We will respond within 5 business days.
</p>
</div>
<div className="space-y-8">
{sections.map(({ heading, body }) => (
<div key={heading} className="border-b border-border/50 pb-8 last:border-0">
<h2 className="font-display font-semibold text-xl text-foreground mb-3">{heading}</h2>
<p className="text-sm text-muted-foreground leading-relaxed">{body}</p>
</div>
))}
</div>
<div className="mt-12 text-center">
<p className="text-xs text-muted-foreground/60">
© {new Date().getFullYear()} AImpress LTD · EU-hosted · GDPR-safe
</p>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,17 @@
import LegalStub from './LegalStub';
export default function Privacy() {
return (
<LegalStub
title="Privacy Policy"
lastUpdated="May 2026"
sections={[
{ heading: 'What data we collect', body: 'We collect account information (email, password hash), research data you create (personas, session transcripts), and usage analytics. We do not sell your data.' },
{ heading: 'How we use your data', body: 'Your research data is used solely to provide the Cohorta service. We do not use your data to train AI models. Aggregated, anonymised usage statistics help us improve the product.' },
{ heading: 'Data storage and security', body: 'All data is stored on EU-based servers operated by AImpress LTD. Data is encrypted in transit (TLS 1.3) and at rest. Each user\'s research is fully isolated from other accounts.' },
{ heading: 'Data retention', body: 'Account data is retained while your account is active. You can request deletion at any time by emailing hello@cohorta.ai-impress.com.' },
{ heading: 'Your rights', body: 'Under GDPR you have the right to access, correct, delete, and export your personal data. Submit requests to hello@cohorta.ai-impress.com.' },
]}
/>
);
}

18
src/pages/legal/Terms.tsx Normal file
View file

@ -0,0 +1,18 @@
import LegalStub from './LegalStub';
export default function Terms() {
return (
<LegalStub
title="Terms of Service"
lastUpdated="May 2026"
sections={[
{ heading: 'Acceptance of terms', body: 'By creating a Cohorta account you agree to these Terms of Service. If you do not agree, do not use the service.' },
{ heading: 'Use of the service', body: 'Cohorta is intended for lawful research purposes only. You may not use the platform to generate content that is harmful, misleading, or illegal. Research outputs are for informational purposes.' },
{ heading: 'Credits and payments', body: 'Credits are non-refundable once used. Unused credits do not expire. Stripe processes all payments — we do not store card details.' },
{ heading: 'Research output disclaimer', body: 'Synthetic research is directionally accurate but not a substitute for large-scale quantitative studies or regulated research. Cohorta is not liable for decisions made based on synthetic research outputs.' },
{ heading: 'Intellectual property', body: 'You retain ownership of your research briefs and outputs. AImpress LTD retains ownership of the Cohorta platform, algorithms, and interface.' },
{ heading: 'Termination', body: 'We reserve the right to terminate accounts that violate these terms. You may close your account at any time.' },
]}
/>
);
}

View file

@ -63,6 +63,12 @@ export default {
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))'
},
brand: {
amber: 'hsl(var(--brand-amber))',
cream: 'hsl(var(--brand-cream))',
charcoal: 'hsl(var(--brand-charcoal))',
success: 'hsl(var(--brand-success))',
}
},
borderRadius: {
@ -124,6 +130,10 @@ export default {
sf: ['Space Grotesk', 'Inter', 'system-ui', 'sans-serif'],
display: ['Space Grotesk', 'system-ui', 'sans-serif'],
},
fontSize: {
'display-1': ['clamp(48px, 7vw, 96px)', { lineHeight: '1.05', letterSpacing: '-0.03em' }],
'display-2': ['clamp(36px, 5vw, 64px)', { lineHeight: '1.1', letterSpacing: '-0.025em' }],
},
transitionProperty: {
'height': 'height',
'spacing': 'margin, padding',