diff --git a/HANDOVER.md b/HANDOVER.md new file mode 100644 index 00000000..ac70c92f --- /dev/null +++ b/HANDOVER.md @@ -0,0 +1,146 @@ +# Cohorta — Session Handover + +## Стан проекту + +Production: https://cohorta.ai-impress.com +Repo: git@git.ai-impress.com:Aimpress/cohorta.git (branch: main) +Stack: React 18 + Vite + TypeScript / Quart (Python async) / MongoDB / Docker + Traefik на OVH (57.128.160.249, ssh alias: `aimpress`, port 1220) + +Deploy: `ssh aimpress "bash /opt/03-business/cohorta/deploy-cohorta.sh"` (тягне git pull + docker build + up) +Push: через HTTPS-токен — `git remote set-url origin https://vadym:@git.ai-impress.com/Aimpress/cohorta.git` + +--- + +## Що зроблено в цій сесії + +| Epic | Статус | +|------|--------| +| Epic 1 — OG-image + базовий SEO | ✅ | +| Epic 2 — Pricing з admin panel | ✅ | +| Epic 3 — Form redesign: SyntheticUsers | ✅ | +| Epic 4 — Form redesign: FocusGroups + rewrite текстів | ✅ | +| Epic 5 — i18n EN/uk/ru + LanguageSwitcher | ✅ **повністю** | +| Epic 6 — SEO full: helmet, JSON-LD, robots, sitemap, llms.txt | ✅ | +| Epic 7 — AI Admin: model catalog tab | ✅ | +| Full report export (GET /api/focus-groups/{id}/report/download) | ✅ | +| Code audit + bug fixes | ✅ | +| Docker .dockerignore + npm overrides fix | ✅ | + +--- + +## Активне завдання: ЗАВЕРШЕНО + +Epic 5 (i18n) повністю реалізований. Всі app pages + лендінг переведені. LanguageSwitcher у AppLayout. Tooltip в Pricing пофіксовано. + +## Наступні кроки + +- Деплой: `ssh aimpress "bash /opt/03-business/cohorta/deploy-cohorta.sh"` +- Можливі покращення: Zod validation messages в Login/Register (низький пріоритет) + +--- + +## Що зроблено в Epic 5 (i18n) + +### Проблема +Лендінг і auth частково перекладені, але: +1. **App pages (після логіну) — 0% перекладу**, весь контент хардкодований англійською +2. **Переключатель мови відсутній** у app layout (є тільки в Header на лендінгу) + +### Архітектура i18n (вже налаштована) +``` +src/i18n/index.ts — i18next init, detection: localStorage(cohorta_lang) → navigator → 'en' +src/i18n/locales/en/common.json — source of truth +src/i18n/locales/uk/common.json +src/i18n/locales/ru/common.json +src/components/LanguageSwitcher.tsx — 3 pill-кнопки EN/UA/RU (вже є) +``` + +Поточні namespace в common.json: `nav`, `hero`, `pricing`, `auth`, `language` + +### Що потрібно зробити (пріоритет) + +#### 1. Додати LanguageSwitcher до app layout +Файл: `src/components/layout/AppLayout.tsx` або `src/pages/*` — знайти де рендериться header для авторизованих сторінок і додати ``. + +#### 2. Лендінг — компоненти (0% i18n) +- `src/components/landing/FAQ.tsx` — 7 Q&A +- `src/components/landing/HowItWorks.tsx` — 3 кроки +- `src/components/landing/Comparison.tsx` — таблиця +- `src/components/landing/Testimonials.tsx` — відгуки +- `src/components/landing/FeatureGrid.tsx` — фічі +- `src/components/landing/TrustBar.tsx` — мітки +- `src/components/layout/Footer.tsx` — секції + tagline + +#### 3. App pages — хардкод +- `src/pages/Dashboard.tsx` — заголовки, мітки +- `src/pages/Admin.tsx` — таби, описи +- `src/pages/MyUsage.tsx` — статистика мітки +- `src/pages/SyntheticUsers.tsx` — toast, placeholder пошуку +- `src/pages/FocusGroups.tsx` — toast, placeholder +- `src/pages/FocusGroupSession.tsx` — 15+ toast, placeholders select-ів +- `src/pages/Billing.tsx` — toast ("Payment successful!", "Credits added…") + +#### 4. Форми +- `src/pages/Login.tsx` + `src/pages/Register.tsx` — Zod validation messages + +### Нові namespace для common.json +``` +faq, how_it_works, comparison, testimonials, features, footer, +dashboard, admin, usage, focus_groups, focus_group_session, billing, synthetic_users +``` + +### Повний список поточних ключів +Читай `src/i18n/locales/en/common.json` — він source of truth. + +--- + +## Інші відкриті питання + +### AdGuard DNS (WiFi недоступність) +AdGuard має wildcard: `*.ai-impress.com` → `192.168.1.225` (локальний Forgejo). +`cohorta.ai-impress.com` потрібно додати override: `57.128.160.249`. + +Конфіг на PVE: `ssh pve "pct exec 102 -- nano /opt/services/adguard/conf/AdGuardHome.yaml"` +Або UI: http://192.168.1.225:8053 → Filters → DNS Rewrites → Add: `cohorta.ai-impress.com` → `57.128.160.249` +Після змін: `ssh pve "pct exec 102 -- docker restart adguard"` + +### Деплой через SSH-ключ (не налаштовано) +`id_rsa` (SHA256:UWsx6bkp) додано в Forgejo але SSH push не працює. +Зараз push через HTTPS токен. Токен: `efde233ee0e4f77116cbd663755e9f23e5e463c5` + +--- + +## Файлова структура (ключові файли) + +``` +src/ + i18n/ + index.ts + locales/en/common.json ← source of truth + locales/uk/common.json + locales/ru/common.json + components/ + LanguageSwitcher.tsx ← вже є, додати в app layout + layout/Header.tsx ← має LanguageSwitcher (лендінг) + landing/ ← потрібен i18n + focus-group-session/SetupTab.tsx ← переписано в Epic 4 + pages/ + Dashboard.tsx, Admin.tsx, MyUsage.tsx ← потрібен i18n + Billing.tsx, FocusGroups.tsx, SyntheticUsers.tsx ← потрібен i18n + FocusGroupSession.tsx ← потрібен i18n (найбільший) +backend/ + app/routes/focus_groups.py ← додано /report/download endpoint + prompts/report-executive-summary.md ← новий промпт для звіту +I18N_TODO.md ← детальний список що хардкодовано +``` + +--- + +## Як почати нову сесію + +``` +Продовжуємо роботу над Cohorta. +Прочитай HANDOVER.md в корені проекту і I18N_TODO.md. +Активне завдання: реалізуй i18n для всіх app pages і додай LanguageSwitcher в app layout. +Почни з: 1) знайти де рендериться layout для авторизованих сторінок, 2) додати switcher, 3) Dashboard.tsx. +``` diff --git a/I18N_TODO.md b/I18N_TODO.md new file mode 100644 index 00000000..fa6bb382 --- /dev/null +++ b/I18N_TODO.md @@ -0,0 +1,70 @@ +# i18n TODO — Cohorta + +Поточне покриття: ~35–40%. Нижче всі компоненти/сторінки з хардкодом. + +--- + +## Лендінг — компоненти (0% перекладу) + +| Файл | Що хардкодено | +|------|---------------| +| `src/components/landing/FAQ.tsx` | 7 питань + відповідей про продукт | +| `src/components/landing/HowItWorks.tsx` | 3 кроки з заголовками та описами | +| `src/components/landing/Comparison.tsx` | Вся таблиця порівнянь | +| `src/components/landing/Testimonials.tsx` | Всі відгуки | +| `src/components/landing/LivePreview.tsx` | Весь контент | +| `src/components/landing/FeatureGrid.tsx` | Всі фічі | +| `src/components/landing/TrustBar.tsx` | Всі мітки | +| `src/components/layout/Footer.tsx` | Секції Product/Company/Legal + tagline | + +--- + +## Сторінки застосунку (після логіну) + +| Файл | Що хардкодено | +|------|---------------| +| `src/pages/Dashboard.tsx` | Всі заголовки, мітки, описи | +| `src/pages/Admin.tsx` | Всі заголовки табів і описи | +| `src/pages/MyUsage.tsx` | Всі мітки статистики | +| `src/pages/SyntheticUsers.tsx` | Toast помилки, placeholder пошуку | +| `src/pages/FocusGroups.tsx` | Toast помилки, placeholder пошуку | +| `src/pages/FocusGroupSession.tsx` | 15+ toast повідомлень, placeholders select-ів | +| `src/pages/Billing.tsx` | Toast: "Payment successful!", "Credits added…", "Checkout failed" | + +--- + +## Форми і компоненти (часткові) + +| Файл | Що хардкодено | +|------|---------------| +| `src/pages/Login.tsx` | Zod validation messages, MOCK_MESSAGES | +| `src/pages/Register.tsx` | Zod messages, plan labels ("Starter"/"Pro"/"Scale"), MOCK_MESSAGES | + +--- + +## Порядок виконання (пріоритет) + +1. **Лендінг** — `FAQ`, `HowItWorks`, `Comparison`, `Testimonials`, `FeatureGrid`, `TrustBar`, `Footer` +2. **App pages** — `Dashboard`, `MyUsage`, `Admin` +3. **Toast / error messages** — `Billing`, `FocusGroups`, `FocusGroupSession`, `SyntheticUsers` +4. **Форми** — Zod messages в `Login` / `Register` + +--- + +## Нові namespace в `common.json` + +``` +faq.* +how_it_works.* +comparison.* +testimonials.* +features.* +footer.* +dashboard.* +usage.* +admin.* +focus_groups.* +focus_group_session.* +billing.* +synthetic_users.* +``` diff --git a/src/components/dashboard/Dashboard.tsx b/src/components/dashboard/Dashboard.tsx index d84b9945..c78ea9f9 100755 --- a/src/components/dashboard/Dashboard.tsx +++ b/src/components/dashboard/Dashboard.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { useNavigate, Link } from 'react-router-dom'; import { useAuth } from '@/contexts/AuthContext'; import { billingApi, personasApi, focusGroupsApi, authApi } from '@/lib/api'; @@ -146,6 +147,7 @@ function QuickAction({ icon: Icon, title, desc, to }: { // ────────────────────────────────────────────── const Dashboard = () => { + const { t } = useTranslation(); const { user } = useAuth(); const navigate = useNavigate(); const { data: usageData, isLoading: usageLoading } = useMyUsage(); @@ -213,9 +215,9 @@ const Dashboard = () => { setResendingEmail(true); try { await authApi.resendVerification((user as any)?.email); - toastService.success('Verification email sent', { description: 'Check your inbox' }); + toastService.success(t('dashboard.toast_verify_sent'), { description: t('dashboard.toast_verify_sent_desc') }); } catch { - toastService.error('Failed to send email'); + toastService.error(t('dashboard.toast_verify_error')); } finally { setResendingEmail(false); } @@ -229,14 +231,14 @@ const Dashboard = () => {

- Welcome back, {user?.username} + {t('dashboard.welcome', 'Welcome back,')}, {user?.username}

{user?.role === 'admin' && ( Admin )} - {balance?.credits_balance ?? '…'} credits remaining + {t('dashboard.credits_remaining', { count: balance?.credits_balance ?? '…' })}
@@ -245,7 +247,7 @@ const Dashboard = () => { className="flex items-center gap-2 px-5 py-2.5 rounded-full bg-primary text-primary-foreground text-sm font-semibold hover:bg-primary/90 transition-all shadow-sm flex-shrink-0" > - New focus group + {t('dashboard.new_focus_group')}
@@ -255,7 +257,7 @@ const Dashboard = () => {

- Please verify your email address to unlock all features. + {t('dashboard.verify_email_banner')}

)} @@ -273,13 +275,13 @@ const Dashboard = () => {
-

Credit quota exceeded. Top up to continue.

+

{t('dashboard.quota_exceeded_banner')}

)} @@ -288,47 +290,47 @@ const Dashboard = () => {
navigate('/billing')} - actionLabel="Top up" + actionLabel={t('dashboard.top_up_action')} loading={balanceLoading} /> navigate('/usage')} - actionLabel="Details" + actionLabel={t('dashboard.details_action')} loading={usageLoading} /> navigate('/synthetic-users')} - actionLabel="View all" + actionLabel={t('dashboard.view_all_action')} loading={personasCount === null} /> 0 ? `${activeFgCount} active now` : 'None active'} + sub={activeFgCount > 0 ? t('dashboard.stat_fg_sub_active', { count: activeFgCount }) : t('dashboard.stat_fg_sub_none')} action={() => navigate('/focus-groups')} - actionLabel="View all" + actionLabel={t('dashboard.view_all_action')} loading={fgCount === null} />
{/* ── Quick actions ── */}
- - - + + +
{/* ── Active tasks + Recent activity ── */} @@ -338,12 +340,12 @@ const Dashboard = () => {
-

Running tasks

+

{t('dashboard.running_tasks')}

{activeTasks.length === 0 ? (
-

No active tasks

+

{t('dashboard.no_active_tasks')}

) : (
@@ -370,10 +372,10 @@ const Dashboard = () => {
-

Recent transactions

+

{t('dashboard.recent_transactions')}

- View all → + {t('dashboard.view_all')} →
{txLoading ? ( @@ -383,7 +385,7 @@ const Dashboard = () => { ))}
) : transactions.length === 0 ? ( -

No transactions yet

+

{t('dashboard.no_transactions')}

) : (
{transactions.map(tx => ( @@ -413,10 +415,10 @@ const Dashboard = () => {
-

Recent personas

+

{t('dashboard.recent_personas')}

- View all → + {t('dashboard.view_all')} →
@@ -442,15 +444,15 @@ const Dashboard = () => {
-

Admin panel

-

Manage users, usage, pricing, and analytics

+

{t('dashboard.admin_panel')}

+

{t('dashboard.admin_panel_desc')}

- Open admin → + {t('dashboard.open_admin')} →
)} diff --git a/src/components/landing/Comparison.tsx b/src/components/landing/Comparison.tsx index 132b7a06..d2235178 100644 --- a/src/components/landing/Comparison.tsx +++ b/src/components/landing/Comparison.tsx @@ -1,19 +1,8 @@ import { motion } from 'framer-motion'; +import { useTranslation } from 'react-i18next'; 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 }) { @@ -46,6 +35,20 @@ function Cell({ value, primary }: { value: CellValue; primary?: boolean }) { } export default function Comparison() { + const { t } = useTranslation(); + + const ROWS: { criterion: string; cohorta: CellValue; trad: CellValue; survey: CellValue }[] = [ + { criterion: t('comparison.criterion_time'), cohorta: t('comparison.cohorta_time'), trad: t('comparison.trad_time'), survey: t('comparison.survey_time') }, + { criterion: t('comparison.criterion_cost'), cohorta: t('comparison.cohorta_cost'), trad: t('comparison.trad_cost'), survey: t('comparison.survey_cost') }, + { criterion: t('comparison.criterion_panel'), cohorta: t('comparison.cohorta_panel'), trad: t('comparison.trad_panel'), survey: t('comparison.survey_panel') }, + { criterion: t('comparison.criterion_availability'), cohorta: true, trad: false, survey: 'partial' }, + { criterion: t('comparison.criterion_no_delay'), cohorta: true, trad: false, survey: false }, + { criterion: t('comparison.criterion_moderation'), cohorta: true, trad: false, survey: false }, + { criterion: t('comparison.criterion_qualitative'), cohorta: true, trad: true, survey: false }, + { criterion: t('comparison.criterion_repeat'), cohorta: true, trad: false, survey: 'partial' }, + { criterion: t('comparison.criterion_gdpr'), cohorta: true, trad: 'partial', survey: 'partial' }, + ]; + return (
@@ -57,13 +60,13 @@ export default function Comparison() { >
- Why Cohorta + {t('comparison.badge')}

- Traditional research was never built for speed. + {t('comparison.headline')}

- Cohorta collapses months of scheduling, budgeting, and recruiting into a single afternoon. + {t('comparison.subtitle')}

@@ -72,19 +75,19 @@ export default function Comparison() { - + @@ -103,8 +106,7 @@ export default function Comparison() { - Cohorta results are directionally accurate for concept testing, message testing, and early-stage exploratory research. - Not a replacement for large-scale quantitative studies. + {t('comparison.disclaimer')} diff --git a/src/components/landing/FAQ.tsx b/src/components/landing/FAQ.tsx index dda5745e..89adad84 100644 --- a/src/components/landing/FAQ.tsx +++ b/src/components/landing/FAQ.tsx @@ -1,4 +1,5 @@ import { motion } from 'framer-motion'; +import { useTranslation } from 'react-i18next'; import { Accordion, AccordionContent, @@ -7,38 +8,10 @@ import { } 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 50 free trial credits on signup — enough to build 5 personas and run a focus group session (5×2 + 40 = 50 cr). 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() { + const { t } = useTranslation(); + const items = [1,2,3,4,5,6,7].map(i => ({ q: t(`faq.q_${i}`), a: t(`faq.a_${i}`) })); + return (
@@ -52,12 +25,12 @@ export default function FAQ() { variants={fadeUp} className="font-display font-bold text-display-2 text-foreground text-center mb-14" > - Questions people actually ask + {t('faq.heading')} - {FAQ_ITEMS.map((item, i) => ( + {items.map((item, i) => ( @@ -23,8 +23,7 @@ const FEATURES = [ }, { icon: MessageSquare, - title: 'Focus Groups', - desc: 'AI-moderated sessions — autonomous or manual. Real-time discussion with your synthetic panel, complete with theme extraction.', + key: 'focus_groups', span: 'lg:col-span-2', visual: (
@@ -46,8 +45,7 @@ const FEATURES = [ }, { 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.', + key: 'theme_extraction', span: 'lg:col-span-2', visual: (
@@ -77,8 +75,7 @@ const FEATURES = [ }, { icon: Download, - title: 'Bulk Export', - desc: 'Markdown discussion guides, CSV transcripts, full persona profiles — structured, sharable, ready for stakeholders.', + key: 'bulk_export', span: 'lg:col-span-2', visual: (
@@ -99,6 +96,7 @@ const FEATURES = [ export default function FeatureGrid() { const shouldReduce = useReducedMotion(); + const { t } = useTranslation(); return (
@@ -113,21 +111,21 @@ export default function FeatureGrid() {
- Capabilities + {t('features.badge')}

- Built for product, marketing & UX researchers + {t('features.headline')}

- Everything you need to generate insight — without recruiting a single real participant. + {t('features.subtitle')}

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

{title}

-

{desc}

+

{t(`features.${key}_title`)}

+

{t(`features.${key}_desc`)}

{/* Mini product visual */} {visual} diff --git a/src/components/landing/HowItWorks.tsx b/src/components/landing/HowItWorks.tsx index 85e25500..d9e6ccb4 100644 --- a/src/components/landing/HowItWorks.tsx +++ b/src/components/landing/HowItWorks.tsx @@ -1,28 +1,14 @@ import { motion, useReducedMotion, useInView } from 'framer-motion'; import { useRef } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; 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.', - }, +const STEP_ICONS = [ + { num: '01', icon: FileText }, + { num: '02', icon: Users }, + { num: '03', icon: BarChart2 }, ]; function StepIndicator({ index, total, isVisible }: { index: number; total: number; isVisible: boolean }) { @@ -61,6 +47,7 @@ export default function HowItWorks() { const shouldReduce = useReducedMotion(); const ref = useRef(null); const isInView = useInView(ref, { once: true, margin: '-60px' }); + const { t } = useTranslation(); return (
@@ -75,16 +62,16 @@ export default function HowItWorks() {
- How it works + {t('how_it_works.badge')}

- From brief to insight in three steps + {t('how_it_works.headline')}

{/* Steps */}
- {STEPS.map(({ num, icon: Icon, title, desc }, i) => ( + {STEP_ICONS.map(({ num, icon: Icon }, i) => ( {/* Connector arrow (hidden on mobile) */} - {i < STEPS.length - 1 && ( + {i < STEP_ICONS.length - 1 && ( -

{title}

-

{desc}

+

{t(`how_it_works.step_${i+1}_title`)}

+

{t(`how_it_works.step_${i+1}_desc`)}

))}
@@ -140,7 +127,7 @@ export default function HowItWorks() { 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 + {t('how_it_works.cta')} diff --git a/src/components/landing/Pricing.tsx b/src/components/landing/Pricing.tsx index 89890a06..4f860e88 100644 --- a/src/components/landing/Pricing.tsx +++ b/src/components/landing/Pricing.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { motion } from 'framer-motion'; import { useNavigate } from 'react-router-dom'; import { CheckCircle2, Info, RefreshCw } from 'lucide-react'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { fadeUp, staggerChildren, viewportOnce } from '@/lib/motion'; import { billingApi } from '@/lib/api'; import { useTranslation } from 'react-i18next'; @@ -41,23 +41,21 @@ function buildFeatures(pack: CreditPack, personaCost: number, runCost: number): function CreditTooltip({ personaCost, runCost }: { personaCost: number; runCost: number }) { const { t } = useTranslation(); return ( - - - - - - -

- {t('pricing.tooltip_persona')} = {personaCost} credits
- {t('pricing.tooltip_run')} = {runCost} credits
- {t('pricing.tooltip_credits_never_expire')} {t('pricing.tooltip_trial')} -

-
-
-
+ + + + + +

+ {t('pricing.tooltip_persona')} = {personaCost} credits
+ {t('pricing.tooltip_run')} = {runCost} credits
+ {t('pricing.tooltip_credits_never_expire')} {t('pricing.tooltip_trial')} +

+
+
); } diff --git a/src/components/landing/Testimonials.tsx b/src/components/landing/Testimonials.tsx index 39d16cd3..7ce81871 100644 --- a/src/components/landing/Testimonials.tsx +++ b/src/components/landing/Testimonials.tsx @@ -1,32 +1,20 @@ import { motion } from 'framer-motion'; +import { useTranslation } from 'react-i18next'; 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', - }, -]; +const AVATAR_INDICES = [2, 6, 4]; export default function Testimonials() { + const { t } = useTranslation(); + const testimonials = [1,2,3].map((i, idx) => ({ + quote: t(`testimonials.quote_${i}`), + name: t(`testimonials.name_${i}`), + role: t(`testimonials.role_${i}`), + highlight: t(`testimonials.highlight_${i}`), + img: `${import.meta.env.BASE_URL}avatars/persona-${AVATAR_INDICES[idx]}.svg`, + })); + return (
@@ -38,18 +26,18 @@ export default function Testimonials() { > - Example use cases + {t('testimonials.section_label')}

- Researchers who switched to synthetic + {t('testimonials.headline')}

- {TESTIMONIALS.map(({ quote, name, role, img, highlight }, i) => ( + {testimonials.map(({ quote, name, role, img, highlight }, i) => ( (null); const [mobileOpen, setMobileOpen] = useState(false); @@ -40,7 +43,7 @@ export default function AppLayout({ children }: { children?: React.ReactNode }) {/* Desktop nav */} {/* Right side */}
+ {credits !== null && (
-

Admin Panel

-

User management, usage analytics, and pricing configuration.

+

{t('admin.title')}

+

{t('admin.subtitle')}

- Users - Analytics - Credits - Usage - Model Pricing - Focus Groups - AI Config + {t('admin.tab_users')} + {t('admin.tab_analytics')} + {t('admin.tab_credits')} + {t('admin.tab_usage')} + {t('admin.tab_pricing')} + {t('admin.tab_focus_groups')} + {t('admin.tab_ai_config')} diff --git a/src/pages/Billing.tsx b/src/pages/Billing.tsx index 07067f7f..25565723 100644 --- a/src/pages/Billing.tsx +++ b/src/pages/Billing.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; import { billingApi } from '@/lib/api'; import { toastService } from '@/lib/toast'; @@ -46,6 +47,7 @@ const PACK_FEATURES: Record = { }; export default function Billing() { + const { t } = useTranslation(); const [searchParams] = useSearchParams(); const [balanceData, setBalanceData] = useState(null); const [transactions, setTransactions] = useState([]); @@ -54,10 +56,10 @@ export default function Billing() { useEffect(() => { if (searchParams.get('success')) { - toastService.success('Payment successful!', { description: 'Credits have been added to your account.' }); + toastService.success(t('billing.toast_payment_success'), { description: t('billing.toast_payment_success_desc') }); } if (searchParams.get('cancelled')) { - toastService.info('Payment cancelled.'); + toastService.info(t('billing.toast_payment_cancelled')); } }, []); @@ -71,7 +73,7 @@ export default function Billing() { setBalanceData(balRes.data); setTransactions(txRes.data.transactions || []); } catch { - toastService.error('Failed to load billing data'); + toastService.error(t('billing.toast_load_error')); } finally { setLoading(false); } @@ -85,7 +87,7 @@ export default function Billing() { const res = await billingApi.createCheckout(packId); if (res.data.checkout_url) window.location.href = res.data.checkout_url; } catch (e: any) { - toastService.error('Checkout failed', { description: e.response?.data?.message || 'Please try again' }); + toastService.error(t('billing.toast_checkout_error'), { description: e.response?.data?.message || t('billing.try_again') }); setCheckoutPack(null); } }; @@ -95,8 +97,8 @@ export default function Billing() {
-

Billing

-

Manage your credits and purchase history

+

{t('billing.title')}

+

{t('billing.subtitle')}

{loading ? ( @@ -112,21 +114,21 @@ export default function Billing() {
-

Credit balance

+

{t('billing.balance_label')}

{balanceData.credits_balance.toLocaleString()} - credits + {t('billing.credits_unit')}

{balanceData.persona_cost} cr

-

per persona

+

{t('billing.per_persona')}

{balanceData.run_cost} cr

-

per FG run

+

{t('billing.per_fg_run')}

@@ -134,7 +136,7 @@ export default function Billing() { {/* Credit packs */}

- Buy credits + {t('billing.buy_credits')}

{balanceData.credit_packs.map(pack => { @@ -147,13 +149,13 @@ export default function Billing() { > {isPopular && (
- Most popular + {t('billing.most_popular')}
)}

{pack.name}

${pack.price_usd} - one-time + {t('billing.one_time')}
    {features.map(f => ( @@ -172,9 +174,9 @@ export default function Billing() { } disabled:opacity-50`} > {checkoutPack === pack.id ? ( - <> Redirecting… + <> {t('billing.redirecting')} ) : ( - <> Buy {pack.name} + <> {t('billing.buy_pack', { name: pack.name })} )}
@@ -186,10 +188,10 @@ export default function Billing() { {/* Transactions */}

- Transaction history + {t('billing.transaction_history')}

{transactions.length === 0 ? ( -

No transactions yet.

+

{t('billing.no_transactions')}

) : (
{transactions.map((tx, i) => ( diff --git a/src/pages/FocusGroupSession.tsx b/src/pages/FocusGroupSession.tsx index 834a1376..cec963c9 100755 --- a/src/pages/FocusGroupSession.tsx +++ b/src/pages/FocusGroupSession.tsx @@ -14,6 +14,7 @@ import { Bot } from 'lucide-react'; import { toastService } from '@/lib/toast'; +import { useTranslation } from 'react-i18next'; import { waitForTaskResult } from '@/lib/taskPolling'; import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; @@ -49,6 +50,7 @@ const FocusGroupSession = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { token, user } = useAuth(); + const { t } = useTranslation(); const [messages, setMessages] = useState([]); const [modeEvents, setModeEvents] = useState([]); @@ -332,13 +334,13 @@ const FocusGroupSession = () => { if (wsConnected && !state.wasConnected) { if (!state.initialConnection) { // Reconnection after disconnection - toastService.success('Real-time updates restored', { + toastService.success(t('focus_group_session.toast_ws_restored'), { description: 'WebSocket connection re-established. You\'ll now receive instant updates.', duration: 4000 }); } else { // Initial successful connection - toastService.success('Live updates enabled', { + toastService.success(t('focus_group_session.toast_ws_enabled'), { description: 'Connected to real-time updates. Changes will appear instantly.', duration: 3000 }); @@ -349,7 +351,7 @@ const FocusGroupSession = () => { // Handle WebSocket disconnection if (!wsConnected && !wsConnecting && state.wasConnected && !state.initialConnection) { - toastService.warning('Connection lost', { + toastService.warning(t('focus_group_session.toast_ws_lost'), { description: 'Real-time updates unavailable. Attempting to reconnect...', duration: 5000 }); @@ -360,7 +362,7 @@ const FocusGroupSession = () => { // Handle WebSocket connection errors if (wsError && !wsConnecting && !wsConnected && !state.initialConnection) { - toastService.error('Connection failed', { + toastService.error(t('focus_group_session.toast_ws_failed'), { description: 'Unable to establish real-time connection. Using periodic updates instead.', duration: 6000 }); @@ -390,7 +392,7 @@ const FocusGroupSession = () => { const state = wsConnectionStateRef.current; if (!state.hasShownFallbackNotification) { - toastService.info('Using periodic updates', { + toastService.info(t('focus_group_session.toast_ws_polling'), { description: 'Real-time updates are not available. Data will refresh automatically every few seconds.', duration: 4000 }); @@ -557,7 +559,7 @@ const FocusGroupSession = () => { if (response?.data) { // Show success toast - toastService.success("Session concluded", { + toastService.success(t('focus_group_session.toast_session_concluded'), { description: "The focus group session has ended with a concluding statement from the moderator." }); @@ -568,7 +570,7 @@ const FocusGroupSession = () => { } } catch (error) { console.error('❌ Error ending session with concluding statement:', error); - toastService.error("Error ending session", { + toastService.error(t('focus_group_session.toast_session_error'), { description: "Failed to add concluding statement, but the session has ended." }); } @@ -707,7 +709,7 @@ const FocusGroupSession = () => { console.error("Error fetching messages:", error); // Keep existing messages if API fails if (messages.length === 0) { - toastService.error("Failed to fetch messages", { + toastService.error(t('focus_group_session.toast_fetch_messages_error'), { description: "Please try again later or restart the session." }); } @@ -810,7 +812,7 @@ const FocusGroupSession = () => { reasoning_effort: (newModel === 'gpt-5.4' || newModel === 'gpt-5.4-mini') ? (reasoningEffort || selectedReasoningEffort) : prev?.reasoning_effort, verbosity: (newModel === 'gpt-5.4' || newModel === 'gpt-5.4-mini') ? (verbosity || selectedVerbosity) : prev?.verbosity } : null); - toastService.success('AI Model Updated', { + toastService.success(t('focus_group_session.toast_model_updated'), { description: `Focus group will now use ${ newModel === 'gpt-5.4' ? 'GPT-5.4' : newModel === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : newModel @@ -820,7 +822,7 @@ const FocusGroupSession = () => { } } catch (error) { console.error('❌ Error updating focus group model:', error); - toastService.error('Failed to update AI model', { + toastService.error(t('focus_group_session.toast_model_update_error'), { description: 'There was an error updating the AI model. Please try again.' }); } finally { @@ -978,7 +980,7 @@ const FocusGroupSession = () => { } else { console.error("Focus group not found with ID:", id); setIsLoading(false); // Stop loading since we've determined it's not found - toastService.error("Focus group not found", { + toastService.error(t('focus_group_session.toast_fg_not_found'), { description: `Could not find focus group with ID: ${id}` }); // Don't navigate immediately, let user see the error message @@ -1072,7 +1074,7 @@ const FocusGroupSession = () => { const startSession = async () => { if (id) { try { - toastService.info("Starting focus group session...", { + toastService.info(t('focus_group_session.toast_starting_session'), { description: "The session is now ready for AI moderation." }); @@ -1113,12 +1115,12 @@ const FocusGroupSession = () => { // The session can still proceed without the initial message } - toastService.success("Focus group session started", { + toastService.success(t('focus_group_session.toast_session_started'), { description: "The discussion has begun. Use the control panel below to moderate." }); } catch (error) { console.error("Error starting session:", error); - toastService.error("Error starting session", { + toastService.error(t('focus_group_session.toast_session_start_error'), { description: "There was a problem connecting to the server." }); } @@ -1138,13 +1140,13 @@ const FocusGroupSession = () => { type: 'question' }); - toastService.info("Added moderator message", { + toastService.info(t('focus_group_session.toast_moderator_added'), { description: "You can now click 'Advance Discussion' to get AI-generated responses." }); } catch (error) { console.error("Error adding moderator message:", error); - toastService.error("Failed to add moderator message", { + toastService.error(t('focus_group_session.toast_moderator_error'), { description: "There was a problem connecting to the server." }); } @@ -1224,7 +1226,7 @@ const FocusGroupSession = () => { element.click(); document.body.removeChild(element); - toastService.success("Transcript downloaded", { + toastService.success(t('focus_group_session.toast_transcript_downloaded'), { description: "The focus group transcript has been saved to your device." }); }; @@ -1389,7 +1391,7 @@ const FocusGroupSession = () => { messageSample: messages.slice(0, 3).map(m => ({ id: m.id, text: m.text.substring(0, 50) })) }); - toastService.warning('Message not found', { + toastService.warning(t('focus_group_session.toast_message_not_found'), { description: 'Could not locate the original message for this quote. The quote may have been paraphrased by the AI.' }); } @@ -1427,7 +1429,7 @@ const FocusGroupSession = () => { } catch (error) { console.error('Error deleting theme:', error); - toastService.error('Failed to delete theme', { + toastService.error(t('focus_group_session.toast_theme_deleted_error'), { description: 'There was an error removing the theme. Please try again.' }); } @@ -1442,12 +1444,12 @@ const FocusGroupSession = () => { // Note: Moderator status will be updated automatically via WebSocket event - toastService.success('Moderator position updated', { + toastService.success(t('focus_group_session.toast_position_updated'), { description: 'The moderator has been moved to the selected section.' }); } catch (error) { console.error('Error setting moderator position:', error); - toastService.error('Failed to update moderator position', { + toastService.error(t('focus_group_session.toast_position_update_error'), { description: 'There was an error updating the moderator position.' }); } @@ -1605,7 +1607,7 @@ const FocusGroupSession = () => { themeGenerationControls.startGeneration(); setIsThemeProgressModalOpen(true); - toastService.info("Analyzing discussion for key themes...", { + toastService.info(t('focus_group_session.toast_analyzing_themes'), { description: "This may take a moment as we process the entire conversation." }); @@ -1625,21 +1627,21 @@ const FocusGroupSession = () => { setThemes(prevThemes => [...prevThemes, ...themes]); setTimeout(() => { themeGenerationControls.completeGeneration(); - toastService.success(`Generated ${themes.length} key themes`, { + toastService.success(t('focus_group_session.toast_themes_generated', { count: themes.length }), { description: "New themes have been added to the analysis." }); }, 3000); } else { setTimeout(() => { themeGenerationControls.completeGeneration(); - toastService.warning("No new themes were generated", { + toastService.warning(t('focus_group_session.toast_no_themes'), { description: "Try again when the discussion has more content." }); }, 3000); } } else if (taskResult.status === 'failed') { themeGenerationControls.failGeneration(taskResult.error || 'Failed'); - toastService.error("Failed to generate key themes", { + toastService.error(t('focus_group_session.toast_themes_error'), { description: taskResult.error || "There was an error analyzing the discussion." }); } @@ -1650,14 +1652,14 @@ const FocusGroupSession = () => { setThemes(prevThemes => [...prevThemes, ...response.data.themes]); setTimeout(() => { themeGenerationControls.completeGeneration(); - toastService.success(`Generated ${response.data.themes.length} key themes`, { + toastService.success(t('focus_group_session.toast_themes_generated', { count: response.data.themes.length }), { description: "New themes have been added to the analysis." }); }, 3000); } else { setTimeout(() => { themeGenerationControls.completeGeneration(); - toastService.warning("No new themes were generated", { + toastService.warning(t('focus_group_session.toast_no_themes'), { description: "Try again when the discussion has more content." }); }, 3000); @@ -1665,7 +1667,7 @@ const FocusGroupSession = () => { } catch (error) { console.error('Error generating key themes:', error); themeGenerationControls.failGeneration('Failed to generate key themes'); - toastService.error("Failed to generate key themes", { + toastService.error(t('focus_group_session.toast_themes_error'), { description: "There was an error analyzing the discussion. Please try again." }); } @@ -1721,7 +1723,7 @@ const FocusGroupSession = () => { } }, 100); } else { - toastService.info('Message not found', { + toastService.info(t('focus_group_session.toast_message_not_found'), { description: 'Could not locate the original message for this note.' }); } @@ -2224,14 +2226,14 @@ const FocusGroupSession = () => { // Close dialog first for immediate feedback setSetPositionDialog({ isOpen: false }); - toastService.success('Moderator position set', { + toastService.success(t('focus_group_session.toast_position_updated'), { description: `Position set to "${setPositionDialog.itemTitle}" in "${setPositionDialog.sectionTitle}"` }); - + } catch (error) { console.error('Error setting moderator position:', error); setSetPositionDialog(prev => ({ ...prev, isLoading: false })); - toastService.error('Failed to set moderator position', { + toastService.error(t('focus_group_session.toast_position_update_error'), { description: 'There was an error setting the moderator position.' }); } diff --git a/src/pages/FocusGroups.tsx b/src/pages/FocusGroups.tsx index 7c5a64b7..448a5376 100755 --- a/src/pages/FocusGroups.tsx +++ b/src/pages/FocusGroups.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import { useNavigate, useLocation } from 'react-router-dom'; import FocusGroupModerator from '@/components/FocusGroupModerator'; import { Button } from '@/components/ui/button'; @@ -90,6 +91,7 @@ interface FocusGroup { } const FocusGroups = () => { + const { t } = useTranslation(); const [mode, setMode] = useState<'view' | 'create'>('view'); const [searchTerm, setSearchTerm] = useState(''); const [focusGroups, setFocusGroups] = useState([]); @@ -131,7 +133,7 @@ const FocusGroups = () => { } catch (error) { console.error("Error fetching focus groups:", error); if (!isMountedCheck || isMounted.current) { - toastService.error("Failed to load focus groups"); + toastService.error(t('focus_groups.toast_load_error')); // Fallback to sample data setFocusGroups(sampleFocusGroups); } @@ -152,7 +154,7 @@ const FocusGroups = () => { } } catch (error) { console.error('Error fetching focus group for edit:', error); - toastService.error("Failed to load focus group for editing"); + toastService.error(t('focus_groups.toast_load_edit_error')); } }; @@ -273,10 +275,10 @@ const FocusGroups = () => { // Reset selection setSelectedGroups([]); - toastService.success(`${selectedGroups.length} focus group${selectedGroups.length > 1 ? 's' : ''} deleted successfully`); + toastService.success(selectedGroups.length === 1 ? t('focus_groups.toast_delete_success_one') : t('focus_groups.toast_delete_success_many', { count: selectedGroups.length })); } catch (error) { console.error("Error deleting focus groups:", error); - toastService.error("Failed to delete focus groups"); + toastService.error(t('focus_groups.toast_delete_error')); } finally { setIsDeletingGroups(false); setDeleteDialogOpen(false); @@ -290,8 +292,8 @@ const FocusGroups = () => {
-

Focus Groups

-

Set up and manage AI-moderated research sessions

+

{t('focus_groups.page_title')}

+

{t('focus_groups.page_subtitle')}

@@ -321,7 +323,7 @@ const FocusGroups = () => {
setSearchTerm(e.target.value)} @@ -535,7 +537,7 @@ const FocusGroups = () => {
) : (
-

No focus groups found matching your search criteria.

+

{t('focus_groups.no_results')}

)}
diff --git a/src/pages/MyUsage.tsx b/src/pages/MyUsage.tsx index 41564105..5b442484 100644 --- a/src/pages/MyUsage.tsx +++ b/src/pages/MyUsage.tsx @@ -1,8 +1,10 @@ +import { useTranslation } from 'react-i18next'; import { useMyUsage } from '@/hooks/useMyUsage'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Loader2, DollarSign, Zap, Activity } from 'lucide-react'; export default function MyUsage() { + const { t } = useTranslation(); const { data, isLoading } = useMyUsage(); const totals = data?.totals ?? {}; const byFeature: any[] = data?.by_feature ?? []; @@ -13,8 +15,8 @@ export default function MyUsage() {
-

Usage

-

Month-to-date since {periodStart}

+

{t('usage.title')}

+

{t('usage.subtitle', { date: periodStart })}

{isLoading ? ( @@ -25,9 +27,9 @@ export default function MyUsage() {
{[ - { label: 'Total Cost (MTD)', value: `$${(totals.total_cost ?? 0).toFixed(4)}`, icon: DollarSign }, - { label: 'LLM Calls', value: (totals.calls ?? 0).toLocaleString(), icon: Activity }, - { label: 'Total Tokens', value: (((totals.prompt_tokens ?? 0) + (totals.completion_tokens ?? 0)) / 1000).toFixed(1) + 'k', icon: Zap }, + { label: t('usage.stat_total_cost'), value: `$${(totals.total_cost ?? 0).toFixed(4)}`, icon: DollarSign }, + { label: t('usage.stat_llm_calls'), value: (totals.calls ?? 0).toLocaleString(), icon: Activity }, + { label: t('usage.stat_total_tokens'), value: (((totals.prompt_tokens ?? 0) + (totals.completion_tokens ?? 0)) / 1000).toFixed(1) + 'k', icon: Zap }, ].map(({ label, value, icon: Icon }) => (
@@ -42,21 +44,21 @@ export default function MyUsage() {
-

By Feature

+

{t('usage.by_feature')}

Criterion{t('comparison.col_criterion')}
- ✦ Cohorta + {t('comparison.col_cohorta')}
- Traditional
focus groups + {t('comparison.col_traditional')}
{t('comparison.col_traditional_sub')}
- Survey
panels + {t('comparison.col_survey')}
{t('comparison.col_survey_sub')}
- Feature - Cost - Calls + {t('usage.col_feature')} + {t('usage.col_cost')} + {t('usage.col_calls')} {byFeature.length === 0 && ( - No usage data yet this month. + {t('usage.no_data')} )} diff --git a/src/pages/SyntheticUsers.tsx b/src/pages/SyntheticUsers.tsx index ae4f78ab..3c1ebc06 100755 --- a/src/pages/SyntheticUsers.tsx +++ b/src/pages/SyntheticUsers.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigation } from '@/contexts/NavigationContext'; import AIRecruiter from '@/components/AIRecruiter'; @@ -71,6 +72,7 @@ interface FilterState { } const SyntheticUsers = () => { + const { t } = useTranslation(); // Helper function ONLY to ensure the body is interactive - memoized with useCallback const ensureBodyInteractive = useCallback(() => { if (document.body.style.pointerEvents === 'none') { @@ -283,7 +285,7 @@ const SyntheticUsers = () => { return processedFolders; } catch (error) { console.error("Error fetching folders:", error); - toastService.error("Failed to load folders"); + toastService.error(t('synthetic_users.toast_load_folders_error')); setFolders([]); return []; } @@ -329,7 +331,7 @@ const SyntheticUsers = () => { } catch (error) { console.error("Error fetching personas:", error); if (isMounted) { - toastService.error("Failed to load personas"); + toastService.error(t('synthetic_users.toast_load_personas_error')); setAllPersonas([]); } } finally { @@ -440,7 +442,7 @@ const SyntheticUsers = () => { const createNewFolder = async (name: string, parentId?: string) => { if (!name.trim()) { - toastService.error("Please enter a folder name"); + toastService.error(t('synthetic_users.toast_folder_name_required')); return; } @@ -460,10 +462,10 @@ const SyntheticUsers = () => { await fetchFolders(); const folderType = parentId ? 'Sub-folder' : 'Folder'; - toastService.success(`${folderType} "${name}" created`); + toastService.success(t('synthetic_users.toast_folder_created', { type: folderType, name })); } catch (error) { console.error("Error creating folder:", error); - const errorMessage = error.response?.data?.message || "Failed to create folder"; + const errorMessage = error.response?.data?.message || t('synthetic_users.toast_folder_create_error'); toastService.error(errorMessage); } }; @@ -477,10 +479,10 @@ const SyntheticUsers = () => { // Refresh folders from server await fetchFolders(); - toastService.success(`Folder renamed to "${newName}"`); + toastService.success(t('synthetic_users.toast_folder_renamed', { name: newName })); } catch (error) { console.error("Error renaming folder:", error); - const errorMessage = error.response?.data?.message || "Failed to rename folder"; + const errorMessage = error.response?.data?.message || t('synthetic_users.toast_folder_rename_error'); toastService.error(errorMessage); } }; @@ -496,10 +498,10 @@ const SyntheticUsers = () => { ? folders.find(f => f._id === newParentId)?.name || 'folder' : 'root level'; - toastService.success(`Folder moved to ${targetName}`); + toastService.success(t('synthetic_users.toast_folder_moved', { target: targetName })); } catch (error) { console.error("Error moving folder:", error); - const errorMessage = error.response?.data?.message || "Failed to move folder"; + const errorMessage = error.response?.data?.message || t('synthetic_users.toast_folder_move_error'); toastService.error(errorMessage); } }; @@ -525,10 +527,10 @@ const SyntheticUsers = () => { setDeleteFolderConfirmOpen(false); setFolderToDelete(null); - toastService.success(`Folder "${folderToDelete.name}" deleted`); + toastService.success(t('synthetic_users.toast_folder_deleted', { name: folderToDelete.name })); } catch (error) { console.error("Error deleting folder:", error); - toastService.error("Failed to delete folder"); + toastService.error(t('synthetic_users.toast_folder_delete_error')); } }; @@ -595,11 +597,11 @@ const SyntheticUsers = () => { const folderList = folderNames.length > 1 ? folderNames.slice(0, -1).join(', ') + ' and ' + folderNames.slice(-1) : folderNames[0]; - toastService.success(`Added ${successfulUpdates.length} persona${successfulUpdates.length !== 1 ? 's' : ''} to ${folderList}`); + toastService.success(successfulUpdates.length === 1 ? t('synthetic_users.toast_personas_added_one', { folders: folderList }) : t('synthetic_users.toast_personas_added_many', { count: successfulUpdates.length, folders: folderList })); } - + if (failedUpdates.length > 0) { - toastService.error(`Failed to add some personas to selected folders.`); + toastService.error(t('synthetic_users.toast_personas_add_partial_error')); } // Clear selection - caller can also handle this if needed @@ -614,7 +616,7 @@ const SyntheticUsers = () => { }; } catch (error) { console.error("Error moving personas to folder:", error); - toastService.error("An unexpected error occurred while adding personas to folder."); + toastService.error(t('synthetic_users.toast_personas_add_error')); return { success: false, error }; } }; @@ -666,13 +668,13 @@ const SyntheticUsers = () => { // Add small delay to prevent UI interaction issues setTimeout(() => { - toastService.success("Persona added to folder"); + toastService.success(t('synthetic_users.toast_persona_added')); }, 100); } } catch (error) { console.error("Failed to update persona folder:", error); setTimeout(() => { - toastService.error("Failed to update persona folder"); + toastService.error(t('synthetic_users.toast_persona_add_error')); }, 100); } }; @@ -696,14 +698,14 @@ const SyntheticUsers = () => { await Promise.all([fetchFolders(), fetchPersonas()]); const folderName = folders.find(f => f._id === selectedFolder)?.name || 'folder'; - toastService.success(`Removed ${selectedIds.length} persona${selectedIds.length !== 1 ? 's' : ''} from ${folderName}`); - + toastService.success(selectedIds.length === 1 ? t('synthetic_users.toast_personas_removed_one', { folder: folderName }) : t('synthetic_users.toast_personas_removed_many', { count: selectedIds.length, folder: folderName })); + // Clear selection setSelectedPersonas(new Set()); } catch (error) { console.error("Error removing personas from folder:", error); console.error("Error details:", error.response?.data || error.message); - toastService.error("Failed to remove personas from folder"); + toastService.error(t('synthetic_users.toast_personas_remove_error')); } }; @@ -781,11 +783,11 @@ const SyntheticUsers = () => { // Show success/failure messages with a small delay setTimeout(() => { if (successfulDeletes.length > 0) { - toastService.success(`Successfully deleted ${successfulDeletes.length} persona${successfulDeletes.length !== 1 ? 's' : ''}`); + toastService.success(successfulDeletes.length === 1 ? t('synthetic_users.toast_delete_success_one') : t('synthetic_users.toast_delete_success_many', { count: successfulDeletes.length })); } - + if (failedDeletes.length > 0) { - toastService.error(`Failed to delete ${failedDeletes.length} persona${failedDeletes.length !== 1 ? 's' : ''}`); + toastService.error(failedDeletes.length === 1 ? t('synthetic_users.toast_delete_error_one') : t('synthetic_users.toast_delete_error_many', { count: failedDeletes.length })); } // Refresh the personas list to ensure consistency @@ -929,7 +931,7 @@ const SyntheticUsers = () => { // Download persona summary for current folder const downloadPersonaSummary = async () => { if (filteredPersonas.length === 0) { - toastService.error("No personas to download"); + toastService.error(t('synthetic_users.toast_no_download')); return; } @@ -957,7 +959,7 @@ const SyntheticUsers = () => { try { // Show initial toast with progress - toastService.info("Generating persona summaries...", { + toastService.info(t('synthetic_users.toast_generating_summaries'), { description: `Processing ${filteredPersonas.length} persona${filteredPersonas.length !== 1 ? 's' : ''} with AI` }); @@ -1219,7 +1221,7 @@ const SyntheticUsers = () => {
setSearchTerm(e.target.value)}