diff --git a/.gitignore b/.gitignore index 77de6c30..23b37986 100644 --- a/.gitignore +++ b/.gitignore @@ -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? diff --git a/backend/.env.example b/backend/.env.example index 1983057f..15721408 100755 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 +APP_URL=https://cohorta.ai-impress.com + # CORS — comma-separated allowed origins CORS_ALLOWED_ORIGINS=https://cohorta.ai-impress.com diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index d21f78d2..393f8602 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -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 ") +FROM_EMAIL = os.environ.get("EMAIL_FROM", "Cohorta ") 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""" - + + Verify your Cohorta account
- + Cohorta
-

Verify your email address

-

Hi {username},
- Welcome to Cohorta! Please confirm your email address to activate your account - and get access to your free credits.

- Verify Email Address -

- This link expires in 24 hours. If you didn't create a Cohorta account, - you can safely ignore this email. +

Confirm your account
+

You're almost in.

+

Hi {username},
+ Thanks for signing up for Cohorta — your synthetic research platform. + Click below to confirm your email and unlock your 10 free trial credits.

+

+ Verify Email Address → +

+
+

+ This link expires in 24 hours.
+ If you didn't create a Cohorta account, you can safely ignore this email.

diff --git a/package-lock.json b/package-lock.json index 3d408e4e..beedefcc 100755 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 261f58ab..86f510a2 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..16b5d505 --- /dev/null +++ b/playwright.config.ts @@ -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'] }, + }, + ], +}); diff --git a/public/cohorta-brand.png b/public/cohorta-brand.png new file mode 100644 index 00000000..e0d3e52c Binary files /dev/null and b/public/cohorta-brand.png differ diff --git a/public/cohorta-logo.png b/public/cohorta-logo.png new file mode 100644 index 00000000..bc7dca31 Binary files /dev/null and b/public/cohorta-logo.png differ diff --git a/scripts/screenshot.mjs b/scripts/screenshot.mjs new file mode 100644 index 00000000..be37ae0d --- /dev/null +++ b/scripts/screenshot.mjs @@ -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); }); diff --git a/src/App.tsx b/src/App.tsx index 8994cbe6..9a0bc04f 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = () => ( } /> + {/* Legal + about — standalone (no app header) */} + } /> + } /> + } /> + } /> + } /> + {/* Auth pages — standalone, no header */} } /> } /> diff --git a/src/components/brand/Logo.tsx b/src/components/brand/Logo.tsx index 4cf2ecbd..519f7577 100644 --- a/src/components/brand/Logo.tsx +++ b/src/components/brand/Logo.tsx @@ -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 = { sm: 24, md: 32, lg: 44, xl: 64 }; +const WORDMARK_SIZE: Record = { + 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 ( - + ); } -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 ( +
+ +
+ ); + } + + if (variant === 'stacked') { + return ( +
+ +
+ + Cohorta + + {withTagline && ( + + Synthetic Research.{' '} + Real Insights. + + )} +
+
+ ); + } + + // horizontal (default) return ( -
+
- {withWordmark && ( - - Cohorta - + {(withWordmark || withTagline) && ( +
+ + Cohorta + + {withTagline && ( + + Synthetic Research.{' '} + Real Insights. + + )} +
)}
); diff --git a/src/components/landing/Comparison.tsx b/src/components/landing/Comparison.tsx new file mode 100644 index 00000000..132b7a06 --- /dev/null +++ b/src/components/landing/Comparison.tsx @@ -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: '2–4 weeks', survey: '1–2 weeks' }, + { criterion: 'Cost per session', cohorta: '~$10–40', trad: '$5k–$20k', survey: '$500–$2k' }, + { criterion: 'Panel size', cohorta: 'Up to 50+ personas',trad: '6–12 people', survey: '200–500 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 ( + + + + ); + } + if (value === false) { + return ( + + + + ); + } + if (value === 'partial') { + return ( + + + + ); + } + return ( + + {value} + + ); +} + +export default function Comparison() { + return ( +
+
+ + +
+ Why Cohorta +
+

+ Traditional research was never built for speed. +

+

+ Cohorta collapses months of scheduling, budgeting, and recruiting into a single afternoon. +

+
+ + +
+ + + + + + + + + + + {ROWS.map(({ criterion, cohorta, trad, survey }) => ( + + + + + + + ))} + +
Criterion +
+ + ✦ Cohorta + +
+
+ Traditional
focus groups +
+ Survey
panels +
{criterion}
+
+
+ + + Cohorta results are directionally accurate for concept testing, message testing, and early-stage exploratory research. + Not a replacement for large-scale quantitative studies. + +
+
+
+ ); +} diff --git a/src/components/landing/FAQ.tsx b/src/components/landing/FAQ.tsx new file mode 100644 index 00000000..eef8a493 --- /dev/null +++ b/src/components/landing/FAQ.tsx @@ -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 85–92% 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 2–4 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 ( +
+
+ + + Questions people actually ask + + + + + {FAQ_ITEMS.map((item, i) => ( + + + {item.q} + + + {item.a} + + + ))} + + + +
+
+ ); +} diff --git a/src/components/landing/FeatureGrid.tsx b/src/components/landing/FeatureGrid.tsx new file mode 100644 index 00000000..3653501f --- /dev/null +++ b/src/components/landing/FeatureGrid.tsx @@ -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 5–50 profiles with demographic depth, psychographics, and authentic communication styles in under 2 minutes.', + span: 'lg:col-span-2', + visual: ( +
+ {[1,2,3,4,5].map(i => ( +
+ {i} +
+ ))} +
+ +45 +
+
+ ), + }, + { + 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: ( +
+ {[ + { 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 }) => ( +
+
+ {name[0]} +
+
+ {msg} +
+
+ ))} +
+ ), + }, + { + 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: ( +
+ {[ + { label: 'Price sensitivity', pct: 87 }, + { label: 'Time-to-value', pct: 72 }, + { label: 'Integration needs', pct: 58 }, + ].map(({ label, pct }) => ( +
+
+ {label} + {pct}% +
+
+ +
+
+ ))} +
+ ), + }, + { + 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: ( +
+ {[ + { ext: 'MD', label: 'Guide' }, + { ext: 'CSV', label: 'Transcript' }, + { ext: 'PDF', label: 'Personas' }, + ].map(({ ext, label }) => ( +
+ .{ext} + {label} +
+ ))} +
+ ), + }, +]; + +export default function FeatureGrid() { + const shouldReduce = useReducedMotion(); + + return ( +
+
+ + {/* Header */} + +
+ + Capabilities +
+

+ Built for product, marketing & UX researchers +

+

+ Everything you need to generate insight — without recruiting a single real participant. +

+
+ + {/* Bento grid — inspired by 21st.dev Dark Grid pattern */} +
+ {FEATURES.map(({ icon: Icon, title, desc, visual }, i) => ( + + {/* Icon */} +
+ +
+ + {/* Text */} +

{title}

+

{desc}

+ + {/* Mini product visual */} + {visual} +
+ ))} +
+
+
+
+ ); +} diff --git a/src/components/landing/FinalCTA.tsx b/src/components/landing/FinalCTA.tsx new file mode 100644 index 00000000..57eca665 --- /dev/null +++ b/src/components/landing/FinalCTA.tsx @@ -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 ( +
+ {/* Soft background glow */} +
+ +
+ + + + + Free to start. No card required. + + + + + Run your first synthetic session in under{' '} + 20 minutes. + + + + 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. + + + + + + + + + 10 free credits · No credit card required · EU-hosted · Results in under 20 minutes + + +
+
+ ); +} diff --git a/src/components/landing/Hero.tsx b/src/components/landing/Hero.tsx new file mode 100644 index 00000000..95d7477a --- /dev/null +++ b/src/components/landing/Hero.tsx @@ -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 ( + + + {persona.name} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +

{persona.name}, {persona.age}

+

{persona.role}

+
+ {persona.tag} +
+
+
+ ); +} + +function FloatingShape({ + className, + delay, + size, + color, + shouldAnimate, +}: { + className: string; + delay: number; + size: string; + color: string; + shouldAnimate: boolean; +}) { + return ( + + + + ); +} + +export default function Hero() { + const navigate = useNavigate(); + const shouldReduce = useReducedMotion(); + const shouldAnimate = !shouldReduce; + + return ( +
+ {/* Background glow */} +
+
+ + {/* Decorative floating shapes from 21st.dev pattern */} + + + +
+
+ + {/* Left: Copy */} + + {/* Badge */} + +
+ + Synthetic Research Platform +
+
+ + {/* H1 */} + + Synthetic research.{' '} + Real insights. + + + {/* Subtitle */} + + Generate a panel of detailed AI personas from one brief, then run a moderated focus group + — in minutes, not weeks. + + + {/* CTAs */} + + + + + Watch demo + + + + {/* Trust line */} + + No credit card required · Results in under 5 minutes + + + {/* Tech stack trust line */} + + Built on +
+ {['Azure AI Foundry', 'MongoDB', 'Stripe'].map(name => ( + {name} + ))} +
+
+
+ + {/* Right: Persona card stack */} +
+ {/* Connection lines hint */} +
+ + + + + +
+ +
+ {PERSONAS.map((persona) => ( + + ))} +
+ + {/* Floating annotation card */} + +
+ + Cohorta +
+

+ Generate.
Moderate.
Decide. +

+
+ + {/* Themes badge */} + +

Top themes detected

+ {['Price sensitivity', 'Time-to-value', 'Trust signals'].map((theme, i) => ( +
+
+
+
+ {theme} +
+ ))} + +
+
+ + {/* Scroll hint */} + + +
+ + +
+
+ ); +} diff --git a/src/components/landing/HowItWorks.tsx b/src/components/landing/HowItWorks.tsx new file mode 100644 index 00000000..85e25500 --- /dev/null +++ b/src/components/landing/HowItWorks.tsx @@ -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 5–50 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 ( +
+ {Array.from({ length: total }).map((_, i) => ( +
+ + {i + 1} + + {i < total - 1 && ( + + )} +
+ ))} +
+ ); +} + +export default function HowItWorks() { + const navigate = useNavigate(); + const shouldReduce = useReducedMotion(); + const ref = useRef(null); + const isInView = useInView(ref, { once: true, margin: '-60px' }); + + return ( +
+
+ + {/* Header */} + +
+ + How it works +
+

+ From brief to insight in three steps +

+
+ + {/* Steps */} +
+ {STEPS.map(({ num, icon: Icon, title, desc }, i) => ( + + {/* Step number with animated fill */} +
+ + + {/* Step badge */} +
+ {i + 1} +
+
+ + {/* Connector arrow (hidden on mobile) */} + {i < STEPS.length - 1 && ( + + + + )} +
+ + {/* Large outline step number in background */} +
+ {num} +
+ +

{title}

+

{desc}

+
+ ))} +
+ + {/* CTA */} + + + +
+
+
+ ); +} diff --git a/src/components/landing/LivePreview.tsx b/src/components/landing/LivePreview.tsx new file mode 100644 index 00000000..a228ce23 --- /dev/null +++ b/src/components/landing/LivePreview.tsx @@ -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 ( +
+
+ + {/* Left: copy */} + +
+ + Live session demo +
+

+ Watch your synthetic panel debate your product. +

+

+ 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. +

+
    + {[ + '6 months of research compressed into 20 minutes', + 'Autonomous moderation — brief it and read results', + 'Themes and consensus detected live, not after', + ].map(item => ( +
  • +
    +
    +
    + {item} +
  • + ))} +
+ +
+ + {/* Right: mock chat UI */} + +
+ {/* Header bar */} +
+
+ + Session: Product Concept Test A +
+ 4 participants · Autonomous mode +
+ +
+ {/* Chat column */} +
+ {MESSAGES.map(({ name, role, img, isAI, msg, delay }) => ( + +
+ {img ? ( + {name} { + const t = e.target as HTMLImageElement; + t.style.display = 'none'; + t.nextElementSibling?.classList.remove('hidden'); + }} /> + ) : null} + {name[0]} +
+
+
+

{name}

+ {role &&

{role}

} +
+

{msg}

+
+
+ ))} +
+ + {/* Themes sidebar */} +
+

Themes detected

+
+ {THEMES.map(({ label, pct, color }, i) => ( +
+
+ {label} + {pct}% +
+
+ +
+
+ ))} +
+
+

Consensus

+
+
+ Building +
+
+
+
+ + {/* Input bar */} +
+
+ Ask a follow-up question… +
+ +
+
+ + +
+
+ ); +} diff --git a/src/components/landing/Pricing.tsx b/src/components/landing/Pricing.tsx new file mode 100644 index 00000000..ce0f03c7 --- /dev/null +++ b/src/components/landing/Pricing.tsx @@ -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 = { + 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 ( + + + + + + +

+ 1 persona = 2 credits
+ 1 focus group run = 40 credits
+ Credits never expire. Trial: 10 free on signup. +

+
+
+
+ ); +} + +export default function Pricing() { + const navigate = useNavigate(); + const packs = DEFAULT_PACKS; + + return ( +
+
+ + {/* Header */} + +
+ Pricing +
+

+ Pay per project, not per seat. +

+

+ Credits never expire. Start with 10 free — no card required.{' '} + +

+
+ + {/* Cards */} + + {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 ( + + {pack.popular && ( +
+ + Most popular + +
+ )} + +

{pack.name}

+ +
+ ${pack.price_usd} + one-time +
+ +

{pack.credits} credits included

+ + {/* Cost-per-session visualisation */} +
+
+ ~{math.personas} personas + ~${costPerSession}/session +
+
+
+
+
+ +
    + {features.map(f => ( +
  • + + {f} +
  • + ))} +
+ + + + ); + })} + + + + Not sure?{' '} + + {' '}— no card required. + + +
+
+ ); +} diff --git a/src/components/landing/StatsBand.tsx b/src/components/landing/StatsBand.tsx new file mode 100644 index 00000000..e8f0e4cd --- /dev/null +++ b/src/components/landing/StatsBand.tsx @@ -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(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 ( + + {shouldAnimate ? {display} : {value}} + + ); +} + +export default function StatsBand() { + const shouldReduce = useReducedMotion(); + const shouldAnimate = !shouldReduce; + + return ( +
+
+ + {STATS.map(({ icon: Icon, prefix, value, suffix, label, sub }) => ( + +
+ +
+
+ {prefix} + + {suffix} +
+
{label}
+

{sub}

+
+ ))} +
+
+
+ ); +} diff --git a/src/components/landing/Testimonials.tsx b/src/components/landing/Testimonials.tsx new file mode 100644 index 00000000..39d16cd3 --- /dev/null +++ b/src/components/landing/Testimonials.tsx @@ -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 ( +
+
+ + + + Example use cases + + + + +

+ Researchers who switched to synthetic +

+
+ +
+ {TESTIMONIALS.map(({ quote, name, role, img, highlight }, i) => ( + + {/* Stars */} +
+ {[...Array(5)].map((_, j) => ( + + ))} +
+ + {/* Highlight metric */} +
+ {highlight} +
+ + {/* Quote */} +

"{quote}"

+ + {/* Author */} +
+ {name} { + const t = e.target as HTMLImageElement; + t.style.display = 'none'; + const next = t.nextElementSibling as HTMLElement; + if (next) next.classList.remove('hidden'); + }} + /> +
+ {name[0]} +
+
+

{name}

+

{role}

+
+
+
+ ))} +
+
+
+
+ ); +} diff --git a/src/components/landing/TrustBar.tsx b/src/components/landing/TrustBar.tsx new file mode 100644 index 00000000..158d31f5 --- /dev/null +++ b/src/components/landing/TrustBar.tsx @@ -0,0 +1,39 @@ +import { motion } from 'framer-motion'; +import { fadeUp, viewportOnce } from '@/lib/motion'; + +export default function TrustBar() { + return ( +
+
+ +

+ Built on enterprise infrastructure +

+
+ {[ + { name: 'Azure AI Foundry', abbr: 'Azure AI' }, + { name: 'MongoDB Atlas', abbr: 'MongoDB' }, + { name: 'Stripe', abbr: 'Stripe' }, + { name: 'Anthropic', abbr: 'Claude' }, + ].map(({ name, abbr }) => ( +
+
+ {abbr} +
+ ))} +
+ +
+
+ ); +} diff --git a/src/components/landing/UseCases.tsx b/src/components/landing/UseCases.tsx new file mode 100644 index 00000000..21635bae --- /dev/null +++ b/src/components/landing/UseCases.tsx @@ -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 ( +
+
+ + +
+ Who it's for +
+

+ Pick your role. See the exact value. +

+
+ + + + + {USE_CASES.map(({ id, label }) => ( + + {label.replace('For ', '')} + + ))} + + + {USE_CASES.map(({ id, headline, story, bullets, tag, persona, personaName, quote }) => ( + +
+ {/* Left: story */} +
+

{headline}

+
+ {story.split('\n\n').map((para, i) => ( +

{para}

+ ))} +
+
    + {bullets.map(b => ( +
  • +
    +
    +
    + {b} +
  • + ))} +
+
+ {tag} +
+
+ + {/* Right: testimonial card */} +
+
+ {personaName} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+

{personaName.split(' · ')[0]}

+

{personaName.split(' · ')[1]}

+
+
+
+ {quote} +
+

Example use case

+
+
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index 6344cf24..6804ef9c 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -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() {
{/* Brand */}
- -

- AI-powered synthetic research platform. Generate personas, run focus groups, extract insights — in minutes. + +

+ Run a synthetic focus group in under 20 minutes. No recruitment, no waiting, no no-shows.

-

- Powered by{' '} - AImpress LTD +

+ A product by{' '} + AImpress LTD

{/* Link columns */} {Object.entries(footerLinks).map(([group, links]) => (
-

{group}

+

{group}

    - {links.map(({ label, to }) => ( + {links.map(({ label, to, external }: { label: string; to: string; external?: boolean }) => (
  • - - {label} - + {external ? ( + + {label} + + ) : ( + + {label} + + )}
  • ))}
@@ -64,7 +71,7 @@ export default function Footer() { © {new Date().getFullYear()} AImpress LTD. All rights reserved.

- Cohorta is a product of AImpress LTD + EU-hosted · GDPR-safe · Built in Lisbon

diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index f6e32263..21d914da 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -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 && ( + + )}
- {/* Language switcher */} -
- 🌐 - Eng -
- {isAuthenticated ? ( ) : ( diff --git a/src/index.css b/src/index.css index 03d52f5e..80cb4418 100755 --- a/src/index.css +++ b/src/index.css @@ -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; diff --git a/src/lib/motion.ts b/src/lib/motion.ts new file mode 100644 index 00000000..e67fe67e --- /dev/null +++ b/src/lib/motion.ts @@ -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; diff --git a/src/pages/About.tsx b/src/pages/About.tsx new file mode 100644 index 00000000..fa3cb6a8 --- /dev/null +++ b/src/pages/About.tsx @@ -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 ( +
+
+ + + Back to Cohorta + + + + + + + + + Synthetic research, finally{' '} + without the waiting. + + + +

+ Cohorta is built by AImpress LTD, a product studio + based in Lisbon, Portugal. We build AI-powered tools that help teams make better decisions faster. +

+

+ 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 today. Research shouldn't be a luxury + reserved for teams with big budgets and long runways. +

+

+ 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. +

+

+ 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. +

+
+ + +

Questions? We respond within one business day.

+ + hello@cohorta.ai-impress.com + + +
+ + + + +
+
+
+ ); +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index b9e6fec6..2db35c30 100755 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -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 }) => ( -
- {label && ( -
- {label} -
- )} -
-
-); - -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 = { - starter: ['50 credits', '~25 AI personas', '1 focus group run', 'Export transcripts', 'Email support'], - pro: ['220 credits', '~110 AI personas', '5 focus group runs', 'Bulk export', 'Priority support', 'Advanced analytics'], - scale: ['600 credits', '~300 AI personas', '15 focus group runs', 'Unlimited exports', 'Dedicated support', 'Custom prompts', 'API access'], -}; - -const FAQ_ITEMS = [ - { - q: 'What is a synthetic persona?', - a: 'A synthetic persona is an AI-generated profile that mimics a real human respondent — complete with demographics, psychographics, attitudes, and communication style. Unlike survey panels, synthetic personas are available instantly, cost nothing per respondent, and scale to thousands.', - }, - { - q: 'How is this different from a real focus group?', - a: 'Traditional focus groups take 2–4 weeks to recruit, cost $5,000–$20,000, and max out at 12 participants. Cohorta generates your panel in minutes, runs sessions 24/7, and lets you test dozens of segments in parallel — all for the cost of a SaaS subscription.', - }, - { - q: 'How accurate are the AI personas?', - a: 'Our two-stage generation pipeline builds each persona from a detailed audience brief, creating internally consistent profiles with demographic context, psychographic depth, and realistic response patterns. Results are directionally accurate for concept testing, message testing, and exploratory research.', - }, - { - q: 'Is my research data secure?', - a: "All data is encrypted in transit (TLS 1.3) and at rest. Each user's personas and sessions are fully isolated. We do not use your research data to train AI models. Infrastructure is hosted on EU servers by AImpress LTD.", - }, - { - q: 'How does the credit system work?', - a: 'Creating a persona costs 2 credits. Running a full focus group session costs 40 credits. You get 10 free trial credits on signup. Purchase credit packs as needed — credits never expire.', - }, - { - q: 'Can I export the results?', - a: 'Yes. Download full discussion transcripts as Markdown, export personas as CSV, and generate structured discussion guides. Pro and Scale plans include bulk export.', - }, -]; - -// ────────────────────────────────────────────── -// FAQ accordion item -// ────────────────────────────────────────────── - -function FAQItem({ q, a }: { q: string; a: string }) { - const [open, setOpen] = useState(false); - return ( -
setOpen(v => !v)} - > -
-

{q}

- {open - ? - : - } -
- {open && ( -
-

{a}

-
- )} -
- ); -} - -// ────────────────────────────────────────────── -// 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 (
- - {/* ── 1. HERO ── */} -
-
-
- -
- {/* Outline hero text */} -
- - COHORTA - -
- - {/* Persona orbs */} -
-
- -
-
- - Cohorta -
-

Generate.
Moderate.
Decide.

-
-
-
- -
-
- -
-
- - {/* Copy + CTA */} -
-

- Skip recruiting. Run{' '} - synthetic focus groups{' '} - in minutes — at any scale. -

-
- - - Log in - -
-

No credit card required • 10 free credits on signup

-
- -
- -
-
-
- - {/* ── 2. STATS TRIPLET ── */} -
-
- {[ - { 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 }) => ( -
-
- -
-
{stat}
-
{label}
-

{sub}

-
- ))} -
-
- - {/* ── 3. ORANGE BAND — FEATURES ── */} -
-
-
-

- Built for product, marketing & UX researchers -

-

- Everything you need to generate insight — without recruiting a single real participant. -

-
-
- {[ - { 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 }) => ( -
-
- -
-

{title}

-

{desc}

-
- - Learn more -
-
- ))} -
-
-
- - {/* ── 4. HOW IT WORKS ── */} -
-
-
-
- - How it works -
-

- From brief to insight in three steps -

-
-
- {[ - { - num: '01', title: 'Write a brief', - desc: 'Describe your target audience — age range, lifestyle, attitudes, geography. One paragraph is enough.', - }, - { - num: '02', title: 'Generate your panel', - desc: 'Cohorta builds 5–50 rich synthetic personas from your brief in under 2 minutes. Review and adjust before proceeding.', - }, - { - num: '03', title: 'Run your session', - desc: 'Launch an AI-moderated focus group — autonomous or manual mode. Export themes and transcripts when done.', - }, - ].map(({ num, title, desc }) => ( -
-
- {num} -
-

{title}

-

{desc}

-
- ))} -
-
- -
-
-
- - {/* ── 5. LIVE PREVIEW ── */} -
-
-
-
- - Live session -
-

- Watch your synthetic panel debate your product. -

-

- 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. -

- -
- {/* Mock chat UI */} -
-
-
-
- - Session: Product Concept Test A -
- 4 participants -
-
- {[ - { 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 }) => ( -
-
- {name[0]} -
-
-

{name}

-

{msg}

-
-
- ))} -
-
-
- Ask a follow-up… -
- -
-
-
-
-
- - {/* ── 6. TESTIMONIALS ── */} -
-
-
-

- Researchers who switched to synthetic -

-
-
- {[ - { - 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 }) => ( -
-
- {[...Array(5)].map((_, i) => )} -
-

"{quote}"

-
-
- {initials} -
-
-

{name}

-

{role}

-
-
-
- ))} -
-
-
- - {/* ── 7. PRICING ── */} -
-
-
-
- - Pricing -
-

- Pay per project, not per seat -

-

Credits never expire. Start with 10 free.

-
-
- {packs.map(pack => { - const features = PACK_FEATURES[pack.id] || PACK_FEATURES['pro']; - return ( -
- {pack.popular && ( -
- - Most popular - -
- )} -

{pack.name}

-
- ${pack.price_usd} - one-time -
-

{pack.credits} credits included

-
    - {features.map(f => ( -
  • - - {f} -
  • - ))} -
- - Get started - -
- ); - })} -
-

- Not sure?{' '} - Start with 10 free credits - {' '}— no card required. -

-
-
- - {/* ── 8. FAQ ── */} -
-
-

- Frequently asked questions -

-
- {FAQ_ITEMS.map(item => )} -
-
-
- - {/* ── 9. FINAL CTA BANNER ── */} -
-
-

- Try Cohorta free. -

-

- 10 credits on signup. No credit card required. Results in under 5 minutes. -

- -
-
- + + + + + + + + + + + +
); } diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 98639923..ef8e39d2 100755 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -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; +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 ( +
+ {/* Why Cohorta pills */} +
+ {[ + { icon: , text: '10× faster setup' }, + { icon: , text: '99% less spend' }, + { icon: , text: 'Results in minutes' }, + ].map(p => ( + + {p.icon}{p.text} + + ))} +
+ + {/* Mock chat */} +
+
+ Live session · 3 personas + + + Active + +
+
+ {MOCK_MESSAGES.slice(0, visible).map((msg, i) => ( + + {msg.name} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+

{msg.name} · {msg.role}

+

"{msg.text}"

+
+
+ ))} + {visible < MOCK_MESSAGES.length && ( +
+ + + + + + Persona responding… +
+ )} +
+
+ + {/* Theme bars */} +
+

Themes detected

+
+ {MOCK_THEMES.map(t => ( +
+
+ {t.label} + {t.pct}% +
+
+ +
+
+ ))} +
+
+ + {/* Testimonial */} +
+

+ "We cut concept testing from 3 weeks to 48 hours. The AI personas push back in ways real respondents would." +

+
— Alex K., Product Manager
+
+
+ ); +} + export default function Login() { const navigate = useNavigate(); const location = useLocation(); @@ -48,115 +157,105 @@ export default function Login() { } return ( -
+
{/* Left: form */} -
-
- - - +
+ + + + + + -

Welcome back

-

Sign in to your Cohorta account

+ +

Welcome back

+

Sign in to your Cohorta account

+
-
- - ( - - Username - - - - - - )} - /> - - ( - - Password - -
+ + + + ( + + Email or username + - -
-
- -
- )} - /> + + + + )} + /> - - - + ( + + Password + +
+ + +
+
+ +
+ )} + /> -

+ + + + + + No account?{' '} Create one free -

-
+ +
- {/* Right: orange panel */} -
- {/* Outline display */} -
- COHORTA + {/* Right: live mock panel */} +
+
+
+
-
-

- "We cut concept testing from 3 weeks to 48 hours. The AI personas push back in ways real respondents would." -

-

— Alex K., Product Manager

-
- - {/* Decorative orbs */} -
-
diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index f9027248..aebffd95 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -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; +const PLAN_META: Record = { + 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 ( +
+
+ + What your team gets on day one +
+ +
+
+ Session · "New pricing model" · 3 personas + + + Live + +
+
+ {MOCK_MESSAGES.slice(0, visible).map((msg, i) => ( + + {msg.name} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+

{msg.name} · {msg.role}

+

"{msg.text}"

+
+
+ ))} + {visible < MOCK_MESSAGES.length && ( +
+ + + + + + Thinking… +
+ )} +
+
+ +
+ {[ + { icon: , label: '10× faster', sub: 'vs recruiting' }, + { icon: , label: '99% cheaper', sub: 'vs real sessions' }, + { icon: , label: '< 20 min', sub: 'first session' }, + ].map(s => ( +
+
{s.icon}
+

{s.label}

+

{s.sub}

+
+ ))} +
+ +
+ {['Concept testing', 'Pricing research', 'Feature validation', 'UX feedback'].map(tag => ( + + {tag} + + ))} +
+
+ ); +} + 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 ( -
-
-
+
+ + -
-

Check your inbox

-

We sent a verification link to

-

{registeredEmail}

-

+ + Check your inbox + We sent a verification link to + {registeredEmail} + Click the link to verify your account. The link expires in 24 hours. -

- -

+ + Didn't receive it?{' '} -

-
+ +
); } return ( -
+
{/* Left: form */} -
-
- - - +
+ + + + + + -

Create your account

-

- Free to start. 10 credits on signup. -

- -
- - ( - - Username - - - - - - )} - /> - - ( - - Email - - - - - - )} - /> - - ( - - Password - -
- - -
-
- -
- )} - /> - - ( - - Confirm password - -
- - -
-
- -
- )} - /> - - - -
- 10 free credits - No card required + {/* Plan-aware badge */} + {planMeta && ( + +
+
- - +
+

{planMeta.label} pack — ${planMeta.price}

+

10 free credits first, then {planMeta.credits} credits for ${planMeta.price}

+
+
+ )} -

+ +

Create your account

+

Free to start · 10 credits on signup · No card required

+ + + +
+ + ( + + Username + + + + + + )} + /> + + ( + + Work email + + + + + + )} + /> + + ( + + Password + +
+ + +
+
+ +
+ )} + /> + + ( + + Confirm password + +
+ + +
+
+ +
+ )} + /> + + + +
+ 10 free credits + No card required + EU-hosted +
+ + +
+ + Already have an account?{' '} Sign in -

-
+ +
- {/* Right: orange panel */} -
-
- COHORTA + {/* Right: live mock panel */} +
+
+
+
-
- {[ - '10 free credits on signup', - 'AI personas in under 2 minutes', - 'Run your first focus group today', - 'No credit card required', - ].map(item => ( -
- -

{item}

-
- ))} -
-
-
diff --git a/src/pages/legal/Cookies.tsx b/src/pages/legal/Cookies.tsx new file mode 100644 index 00000000..45bbb5d4 --- /dev/null +++ b/src/pages/legal/Cookies.tsx @@ -0,0 +1,16 @@ +import LegalStub from './LegalStub'; + +export default function Cookies() { + return ( + + ); +} diff --git a/src/pages/legal/Gdpr.tsx b/src/pages/legal/Gdpr.tsx new file mode 100644 index 00000000..a1fde5ba --- /dev/null +++ b/src/pages/legal/Gdpr.tsx @@ -0,0 +1,17 @@ +import LegalStub from './LegalStub'; + +export default function Gdpr() { + return ( + + ); +} diff --git a/src/pages/legal/LegalStub.tsx b/src/pages/legal/LegalStub.tsx new file mode 100644 index 00000000..69991ae6 --- /dev/null +++ b/src/pages/legal/LegalStub.tsx @@ -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 ( +
+
+
+ + + Back to Cohorta + +
+ + Cohorta · AImpress LTD +
+

{title}

+

Last updated: {lastUpdated}

+
+ +
+

+ The full version of this policy is in preparation. For the current version or any questions regarding + {' '}{title}, please contact us at{' '} + + hello@cohorta.ai-impress.com + + . We will respond within 5 business days. +

+
+ +
+ {sections.map(({ heading, body }) => ( +
+

{heading}

+

{body}

+
+ ))} +
+ +
+

+ © {new Date().getFullYear()} AImpress LTD · EU-hosted · GDPR-safe +

+
+
+
+ ); +} diff --git a/src/pages/legal/Privacy.tsx b/src/pages/legal/Privacy.tsx new file mode 100644 index 00000000..f64cbf69 --- /dev/null +++ b/src/pages/legal/Privacy.tsx @@ -0,0 +1,17 @@ +import LegalStub from './LegalStub'; + +export default function Privacy() { + return ( + + ); +} diff --git a/src/pages/legal/Terms.tsx b/src/pages/legal/Terms.tsx new file mode 100644 index 00000000..bcd9fb14 --- /dev/null +++ b/src/pages/legal/Terms.tsx @@ -0,0 +1,18 @@ +import LegalStub from './LegalStub'; + +export default function Terms() { + return ( + + ); +} diff --git a/tailwind.config.ts b/tailwind.config.ts index aa303861..d18f3b2e 100755 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -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',