feat(i18n): login, register, focus group setup, persona editor — EN/UK/RU

- Login.tsx: auth.login_heading, login_subtitle, email_or_username_label,
  password_label, signing_in, sign_in_button, no_account_q, create_one_free
- Register.tsx: full form labels, check-inbox screen, toast messages,
  data processing consent, plan badge credits (all 3 locales)
- SetupTab.tsx: session name, research brief, topics, duration, model,
  thinking depth, verbosity, materials — new focus_group_setup namespace
- PersonaEditor.tsx: all form labels, section headings, OCEAN traits,
  goals/frustrations/motivations, think-feel-do, scenarios, toast messages —
  new persona_editor namespace
- Locale files: 80+ new keys across EN/UK/RU

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-24 17:39:44 +01:00
parent 802c004ca4
commit ab28ebd765
7 changed files with 741 additions and 365 deletions

View file

@ -30,6 +30,7 @@ import {
} from "@/components/ui/accordion";
import AssetUploader from '@/components/AssetUploader';
import { InputStrengthIndicator } from "@/components/ui/InputStrengthIndicator";
import { useTranslation } from 'react-i18next';
interface SetupTabProps {
form: UseFormReturn<any>;
@ -50,6 +51,7 @@ export function SetupTab({
onAssetsChange,
onCopyGuideClick,
}: SetupTabProps) {
const { t } = useTranslation();
const selectedModel = form.watch("llm_model");
return (
@ -61,11 +63,11 @@ export function SetupTab({
name="focusGroupName"
render={({ field }) => (
<FormItem>
<FormLabel>Session name</FormLabel>
<FormLabel>{t('focus_group_setup.session_name_label')}</FormLabel>
<FormControl>
<Input placeholder="Give it a clear, searchable name" {...field} />
<Input placeholder={t('focus_group_setup.session_name_placeholder')} {...field} />
</FormControl>
<p className="text-xs text-muted-foreground">You can always rename it later</p>
<p className="text-xs text-muted-foreground">{t('focus_group_setup.session_name_hint')}</p>
<FormMessage />
</FormItem>
)}
@ -77,11 +79,11 @@ export function SetupTab({
name="researchBrief"
render={({ field }) => (
<FormItem>
<FormLabel>What are you researching?</FormLabel>
<FormLabel>{t('focus_group_setup.research_brief_label')}</FormLabel>
<FormControl>
<div className="relative">
<Textarea
placeholder="Describe what you want to learn and why it matters to your team"
placeholder={t('focus_group_setup.research_brief_placeholder')}
className="h-36 pb-7"
{...field}
/>
@ -90,7 +92,7 @@ export function SetupTab({
</div>
</div>
</FormControl>
<p className="text-xs text-muted-foreground">The more specific, the better the discussion</p>
<p className="text-xs text-muted-foreground">{t('focus_group_setup.research_brief_hint')}</p>
<FormMessage />
</FormItem>
)}
@ -102,11 +104,11 @@ export function SetupTab({
name="discussionTopics"
render={({ field }) => (
<FormItem>
<FormLabel>Key topics to explore</FormLabel>
<FormLabel>{t('focus_group_setup.topics_label')}</FormLabel>
<FormControl>
<div className="relative">
<Textarea
placeholder="E.g. first impressions, pricing reactions, feature priorities"
placeholder={t('focus_group_setup.topics_placeholder')}
className="h-24 pb-7"
{...field}
/>
@ -125,19 +127,19 @@ export function SetupTab({
name="duration"
render={({ field }) => (
<FormItem>
<FormLabel>Session length</FormLabel>
<FormLabel>{t('focus_group_setup.duration_label')}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
<SelectValue placeholder={t('focus_group_setup.duration_placeholder')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="30">30 minutes</SelectItem>
<SelectItem value="45">45 minutes</SelectItem>
<SelectItem value="60">60 minutes</SelectItem>
<SelectItem value="90">90 minutes</SelectItem>
<SelectItem value="120">120 minutes</SelectItem>
<SelectItem value="30">{t('focus_group_setup.duration_30')}</SelectItem>
<SelectItem value="45">{t('focus_group_setup.duration_45')}</SelectItem>
<SelectItem value="60">{t('focus_group_setup.duration_60')}</SelectItem>
<SelectItem value="90">{t('focus_group_setup.duration_90')}</SelectItem>
<SelectItem value="120">{t('focus_group_setup.duration_120')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@ -148,7 +150,7 @@ export function SetupTab({
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="advanced" className="border rounded-lg px-3">
<AccordionTrigger className="text-sm font-medium py-3 hover:no-underline">
Advanced settings
{t('focus_group_setup.advanced_settings')}
</AccordionTrigger>
<AccordionContent className="space-y-4 pb-3">
<FormField
@ -156,16 +158,16 @@ export function SetupTab({
name="llm_model"
render={({ field }) => (
<FormItem>
<FormLabel>Model</FormLabel>
<FormLabel>{t('focus_group_setup.model_label')}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select AI model" />
<SelectValue placeholder={t('focus_group_setup.model_placeholder')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="gpt-5.4">GPT-5.4 (Recommended)</SelectItem>
<SelectItem value="gpt-5.4-mini">GPT-5.4 Mini (Faster, lower cost)</SelectItem>
<SelectItem value="gpt-5.4">{t('focus_group_setup.model_gpt54')}</SelectItem>
<SelectItem value="gpt-5.4-mini">{t('focus_group_setup.model_gpt54_mini')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@ -180,18 +182,18 @@ export function SetupTab({
name="reasoning_effort"
render={({ field }) => (
<FormItem>
<FormLabel>Thinking depth</FormLabel>
<FormLabel>{t('focus_group_setup.thinking_depth_label')}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select reasoning effort" />
<SelectValue placeholder={t('focus_group_setup.thinking_depth_placeholder')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="minimal">Minimal Fast responses</SelectItem>
<SelectItem value="low">Low Quick thinking</SelectItem>
<SelectItem value="medium">Medium Balanced (default)</SelectItem>
<SelectItem value="high">High Deep reasoning</SelectItem>
<SelectItem value="minimal">{t('focus_group_setup.thinking_minimal')}</SelectItem>
<SelectItem value="low">{t('focus_group_setup.thinking_low')}</SelectItem>
<SelectItem value="medium">{t('focus_group_setup.thinking_medium')}</SelectItem>
<SelectItem value="high">{t('focus_group_setup.thinking_high')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@ -204,17 +206,17 @@ export function SetupTab({
name="verbosity"
render={({ field }) => (
<FormItem>
<FormLabel>Response length</FormLabel>
<FormLabel>{t('focus_group_setup.response_length_label')}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select verbosity level" />
<SelectValue placeholder={t('focus_group_setup.response_length_placeholder')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="low">Low Concise responses</SelectItem>
<SelectItem value="medium">Medium Balanced length (default)</SelectItem>
<SelectItem value="high">High Detailed responses</SelectItem>
<SelectItem value="low">{t('focus_group_setup.verbosity_low')}</SelectItem>
<SelectItem value="medium">{t('focus_group_setup.verbosity_medium')}</SelectItem>
<SelectItem value="high">{t('focus_group_setup.verbosity_high')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@ -232,7 +234,7 @@ export function SetupTab({
{/* Asset Uploader */}
<div>
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-2 block">
Attach materials (optional)
{t('focus_group_setup.materials_label')}
</label>
<AssetUploader
focusGroupId={draftFocusGroupId}
@ -249,8 +251,8 @@ export function SetupTab({
maxAssets={10}
maxFileSize={10}
allowedTypes={['image/*', 'application/pdf', 'video/*', 'text/*', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']}
label="Attach materials"
description="Mockups, decks, or any materials the moderator should reference"
label={t('focus_group_setup.materials_label')}
description={t('focus_group_setup.materials_description')}
enableRenaming={true}
/>
</div>
@ -271,7 +273,7 @@ export function SetupTab({
className="min-w-32"
>
<Copy className="mr-2 h-4 w-4" />
Import Discussion Guide from Other Project
{t('focus_group_setup.copy_guide_button')}
</Button>
<Button
type="button"
@ -280,7 +282,7 @@ export function SetupTab({
className="min-w-32"
>
<MessageSquare className="mr-2 h-4 w-4" />
{isGenerating ? "Generating..." : "Generate Discussion Guide"}
{isGenerating ? t('focus_group_setup.generating_button') : t('focus_group_setup.generate_guide_button')}
</Button>
</div>
</>

File diff suppressed because it is too large Load diff

View file

@ -80,7 +80,29 @@
"privacy": "Privacy Policy",
"free_credits": "50 free credits",
"no_card": "No card required",
"uk_hosted": "UK hosted"
"uk_hosted": "UK hosted",
"email_or_username_label": "Email or username",
"sign_in_button": "Sign in",
"signing_in": "Signing in…",
"no_account_q": "No account?",
"create_one_free": "Create one free",
"work_email_label": "Work email",
"creating_account": "Creating account…",
"create_free_button": "Create free account →",
"data_processing_consent": "I consent to the processing of my personal data in accordance with the",
"uk_gdpr_act": "(UK GDPR / Data Protection Act 2018)",
"plan_badge_credits": "50 free credits first, then {{credits}} credits for ${{price}}",
"toast_account_created": "Account created!",
"toast_account_created_desc": "Check your email to verify.",
"toast_register_failed": "Registration failed. Please try again.",
"toast_resend_success": "Verification email resent",
"toast_resend_error": "Could not resend. Try again later.",
"check_inbox_heading": "Check your inbox",
"check_inbox_sent_to": "We sent a verification link to",
"check_inbox_verify": "Click the link to verify your account. The link expires in 24 hours.",
"check_inbox_continue": "Continue to Dashboard",
"check_inbox_not_received": "Didn't receive it?",
"check_inbox_resend": "Resend email"
},
"language": {
"select": "Language"
@ -342,5 +364,118 @@
"toast_no_themes": "No new themes were generated",
"toast_themes_error": "Failed to generate key themes",
"toast_message_not_found": "Message not found"
},
"focus_group_setup": {
"session_name_label": "Session name",
"session_name_placeholder": "Give it a clear, searchable name",
"session_name_hint": "You can always rename it later",
"research_brief_label": "What are you researching?",
"research_brief_placeholder": "Describe what you want to learn and why it matters to your team",
"research_brief_hint": "The more specific, the better the discussion",
"topics_label": "Key topics to explore",
"topics_placeholder": "E.g. first impressions, pricing reactions, feature priorities",
"duration_label": "Session length",
"duration_placeholder": "Select duration",
"duration_30": "30 minutes",
"duration_45": "45 minutes",
"duration_60": "60 minutes",
"duration_90": "90 minutes",
"duration_120": "120 minutes",
"advanced_settings": "Advanced settings",
"model_label": "Model",
"model_placeholder": "Select AI model",
"model_gpt54": "GPT-5.4 (Recommended)",
"model_gpt54_mini": "GPT-5.4 Mini (Faster, lower cost)",
"thinking_depth_label": "Thinking depth",
"thinking_depth_placeholder": "Select reasoning effort",
"thinking_minimal": "Minimal — Fast responses",
"thinking_low": "Low — Quick thinking",
"thinking_medium": "Medium — Balanced (default)",
"thinking_high": "High — Deep reasoning",
"response_length_label": "Response length",
"response_length_placeholder": "Select verbosity level",
"verbosity_low": "Low — Concise responses",
"verbosity_medium": "Medium — Balanced length (default)",
"verbosity_high": "High — Detailed responses",
"materials_label": "Attach materials (optional)",
"materials_description": "Mockups, decks, or any materials the moderator should reference",
"copy_guide_button": "Import Discussion Guide from Other Project",
"generate_guide_button": "Generate Discussion Guide",
"generating_button": "Generating..."
},
"persona_editor": {
"edit_title": "Edit Persona",
"save_button": "Save Changes",
"saving_button": "Saving...",
"tab_general": "General",
"tab_attitudinal": "Attitudinal Profile",
"tab_personality": "Personality",
"tab_scenarios": "Scenarios",
"label_name": "Name",
"label_occupation": "Occupation",
"section_demographics": "Demographics",
"label_age": "Age",
"label_gender": "Gender",
"label_ethnicity": "Ethnicity",
"label_education": "Education",
"label_social_grade": "Social Grade",
"label_household_income": "Household Income",
"label_household_composition": "Household Composition",
"section_location": "Location",
"label_location": "Location",
"label_living_situation": "Living Situation",
"section_interests": "Interests",
"section_media": "Media",
"section_digital_behavior": "Digital Behavior",
"label_tech_savviness": "Tech Savviness",
"label_brand_loyalty": "Brand Loyalty",
"label_price_sensitivity": "Price Sensitivity",
"label_environmental_concern": "Environmental Concern",
"label_device_usage": "Device Usage",
"label_shopping_habits": "Shopping Habits",
"section_additional": "Additional Information",
"label_brand_preferences": "Brand Preferences",
"label_communication_preferences": "Communication Preferences",
"label_additional_info": "Additional Information",
"section_goals": "Goals",
"add_goal": "Add Goal",
"section_frustrations": "Frustrations",
"add_frustration": "Add Frustration",
"section_motivations": "Motivations",
"add_motivation": "Add Motivation",
"section_think_feel_do": "Think, Feel, Do",
"label_thinks": "Thinks",
"add_thought": "Add Thought",
"label_feels": "Feels",
"add_feeling": "Add Feeling",
"label_does": "Does",
"add_action": "Add Action",
"section_ocean": "OCEAN Personality Traits",
"label_openness": "Openness to Experience",
"hint_openness": "Creativity, curiosity, and openness to new ideas",
"label_conscientiousness": "Conscientiousness",
"hint_conscientiousness": "Organization, responsibility, and self-discipline",
"label_extraversion": "Extraversion",
"hint_extraversion": "Sociability, assertiveness, and talkativeness",
"label_agreeableness": "Agreeableness",
"hint_agreeableness": "Compassion, cooperation, and concern for others",
"label_neuroticism": "Neuroticism",
"hint_neuroticism": "Emotional reactivity, anxiety, and sensitivity to stress",
"section_scenarios": "Usage Scenarios",
"label_scenario_section_title": "Scenario Section Title",
"hint_scenario_section_title": "Custom title for the scenarios section (e.g., \"Customer Journey\", \"Use Cases\")",
"add_scenario": "Add Scenario",
"auth_required_heading": "Authentication Required",
"auth_required_desc": "Login is required to save personas to the database. You can either:",
"auth_required_option_login": "Log in to save this persona to the database",
"auth_required_option_cancel": "Cancel to discard your changes",
"toast_login_cancelled": "Login canceled — Persona changes not saved",
"toast_save_success": "Persona saved to database successfully",
"toast_save_error_after_login": "Failed to save to database after login",
"toast_saved_to_db": "Persona saved to database",
"toast_updated": "Persona updated successfully",
"toast_created": "Persona created successfully",
"toast_auth_error_local": "Authentication error — saving locally instead",
"toast_save_failed": "Failed to save persona"
}
}

View file

@ -80,7 +80,29 @@
"privacy": "Политикой конфиденциальности",
"free_credits": "50 бесплатных кредитов",
"no_card": "Карта не нужна",
"uk_hosted": "Хостинг в Великобритании"
"uk_hosted": "Хостинг в Великобритании",
"email_or_username_label": "Email или имя пользователя",
"sign_in_button": "Войти",
"signing_in": "Вход…",
"no_account_q": "Нет аккаунта?",
"create_one_free": "Создайте бесплатно",
"work_email_label": "Рабочий email",
"creating_account": "Создание аккаунта…",
"create_free_button": "Создать бесплатный аккаунт →",
"data_processing_consent": "Я согласен на обработку моих персональных данных в соответствии с",
"uk_gdpr_act": "(UK GDPR / Закон о защите данных 2018)",
"plan_badge_credits": "Сначала 50 бесплатных кредитов, затем {{credits}} кредитов за ${{price}}",
"toast_account_created": "Аккаунт создан!",
"toast_account_created_desc": "Проверьте вашу электронную почту.",
"toast_register_failed": "Ошибка регистрации. Попробуйте ещё раз.",
"toast_resend_success": "Письмо подтверждения отправлено повторно",
"toast_resend_error": "Не удалось отправить. Попробуйте позже.",
"check_inbox_heading": "Проверьте вашу почту",
"check_inbox_sent_to": "Мы отправили ссылку для подтверждения на",
"check_inbox_verify": "Нажмите ссылку для подтверждения аккаунта. Ссылка действительна 24 часа.",
"check_inbox_continue": "Перейти в панель",
"check_inbox_not_received": "Не получили?",
"check_inbox_resend": "Отправить повторно"
},
"language": {
"select": "Язык"
@ -342,5 +364,118 @@
"toast_no_themes": "Новых тем не найдено",
"toast_themes_error": "Не удалось сгенерировать ключевые темы",
"toast_message_not_found": "Сообщение не найдено"
},
"focus_group_setup": {
"session_name_label": "Название сессии",
"session_name_placeholder": "Дайте понятное, поисковое название",
"session_name_hint": "Вы всегда сможете переименовать позже",
"research_brief_label": "Что вы исследуете?",
"research_brief_placeholder": "Опишите, что хотите узнать и почему это важно для вашей команды",
"research_brief_hint": "Чем конкретнее, тем лучше дискуссия",
"topics_label": "Ключевые темы для обсуждения",
"topics_placeholder": "Например, первое впечатление, реакция на цены, приоритеты функций",
"duration_label": "Продолжительность сессии",
"duration_placeholder": "Выберите продолжительность",
"duration_30": "30 минут",
"duration_45": "45 минут",
"duration_60": "60 минут",
"duration_90": "90 минут",
"duration_120": "120 минут",
"advanced_settings": "Расширенные настройки",
"model_label": "Модель",
"model_placeholder": "Выберите модель ИИ",
"model_gpt54": "GPT-5.4 (Рекомендовано)",
"model_gpt54_mini": "GPT-5.4 Mini (Быстрее, ниже стоимость)",
"thinking_depth_label": "Глубина мышления",
"thinking_depth_placeholder": "Выберите уровень рассуждений",
"thinking_minimal": "Минимальный — Быстрые ответы",
"thinking_low": "Низкий — Быстрое мышление",
"thinking_medium": "Средний — Сбалансированный (по умолч.)",
"thinking_high": "Высокий — Глубокое рассуждение",
"response_length_label": "Длина ответов",
"response_length_placeholder": "Выберите уровень детализации",
"verbosity_low": "Низкая — Краткие ответы",
"verbosity_medium": "Средняя — Сбалансированная (по умолч.)",
"verbosity_high": "Высокая — Подробные ответы",
"materials_label": "Прикрепить материалы (необязательно)",
"materials_description": "Макеты, презентации или любые материалы для модератора",
"copy_guide_button": "Импортировать гайд из другого проекта",
"generate_guide_button": "Сгенерировать гайд дискуссии",
"generating_button": "Генерация..."
},
"persona_editor": {
"edit_title": "Редактировать персону",
"save_button": "Сохранить изменения",
"saving_button": "Сохранение...",
"tab_general": "Общее",
"tab_attitudinal": "Профиль установок",
"tab_personality": "Личность",
"tab_scenarios": "Сценарии",
"label_name": "Имя",
"label_occupation": "Должность",
"section_demographics": "Демография",
"label_age": "Возраст",
"label_gender": "Пол",
"label_ethnicity": "Этническость",
"label_education": "Образование",
"label_social_grade": "Социальный класс",
"label_household_income": "Доход домохозяйства",
"label_household_composition": "Состав домохозяйства",
"section_location": "Местонахождение",
"label_location": "Местонахождение",
"label_living_situation": "Условия проживания",
"section_interests": "Интересы",
"section_media": "СМИ",
"section_digital_behavior": "Цифровое поведение",
"label_tech_savviness": "Техническая грамотность",
"label_brand_loyalty": "Лояльность к бренду",
"label_price_sensitivity": "Ценовая чувствительность",
"label_environmental_concern": "Экологическая осведомлённость",
"label_device_usage": "Использование устройств",
"label_shopping_habits": "Привычки шопинга",
"section_additional": "Дополнительная информация",
"label_brand_preferences": "Предпочтения брендов",
"label_communication_preferences": "Коммуникационные предпочтения",
"label_additional_info": "Дополнительная информация",
"section_goals": "Цели",
"add_goal": "Добавить цель",
"section_frustrations": "Разочарования",
"add_frustration": "Добавить разочарование",
"section_motivations": "Мотивации",
"add_motivation": "Добавить мотивацию",
"section_think_feel_do": "Думает, чувствует, делает",
"label_thinks": "Думает",
"add_thought": "Добавить мысль",
"label_feels": "Чувствует",
"add_feeling": "Добавить чувство",
"label_does": "Делает",
"add_action": "Добавить действие",
"section_ocean": "Черты личности OCEAN",
"label_openness": "Открытость к опыту",
"hint_openness": "Творчество, любознательность и открытость к новым идеям",
"label_conscientiousness": "Добросовестность",
"hint_conscientiousness": "Организованность, ответственность и самодисциплина",
"label_extraversion": "Экстраверсия",
"hint_extraversion": "Общительность, уверенность и разговорчивость",
"label_agreeableness": "Доброжелательность",
"hint_agreeableness": "Сочувствие, кооперация и забота о других",
"label_neuroticism": "Нейротизм",
"hint_neuroticism": "Эмоциональная реактивность, тревожность и чувствительность к стрессу",
"section_scenarios": "Сценарии использования",
"label_scenario_section_title": "Название секции сценариев",
"hint_scenario_section_title": "Собственное название для секции (например, «Путь клиента», «Случаи использования»)",
"add_scenario": "Добавить сценарий",
"auth_required_heading": "Требуется аутентификация",
"auth_required_desc": "Для сохранения персоны в базу данных необходим вход. Вы можете:",
"auth_required_option_login": "Войти и сохранить персону",
"auth_required_option_cancel": "Отменить и отклонить изменения",
"toast_login_cancelled": "Вход отменён — изменения персоны не сохранены",
"toast_save_success": "Персона успешно сохранена в базу данных",
"toast_save_error_after_login": "Не удалось сохранить в базу данных после входа",
"toast_saved_to_db": "Персона сохранена в базу данных",
"toast_updated": "Персона успешно обновлена",
"toast_created": "Персона успешно создана",
"toast_auth_error_local": "Ошибка аутентификации — сохранено локально",
"toast_save_failed": "Не удалось сохранить персону"
}
}

View file

@ -80,7 +80,29 @@
"privacy": "Політикою конфіденційності",
"free_credits": "50 безкоштовних кредитів",
"no_card": "Картка не потрібна",
"uk_hosted": "Хостинг у Великій Британії"
"uk_hosted": "Хостинг у Великій Британії",
"email_or_username_label": "Email або ім'я користувача",
"sign_in_button": "Увійти",
"signing_in": "Вхід…",
"no_account_q": "Немає акаунту?",
"create_one_free": "Створіть безкоштовно",
"work_email_label": "Робочий email",
"creating_account": "Створення акаунту…",
"create_free_button": "Створити безкоштовний акаунт →",
"data_processing_consent": "Я погоджуюсь на обробку моїх персональних даних відповідно до",
"uk_gdpr_act": "(UK GDPR / Закон про захист даних 2018)",
"plan_badge_credits": "Спочатку 50 безкоштовних кредитів, потім {{credits}} кредитів за ${{price}}",
"toast_account_created": "Акаунт створено!",
"toast_account_created_desc": "Перевірте вашу електронну пошту.",
"toast_register_failed": "Помилка реєстрації. Спробуйте ще раз.",
"toast_resend_success": "Лист підтвердження надіслано повторно",
"toast_resend_error": "Не вдалося надіслати. Спробуйте пізніше.",
"check_inbox_heading": "Перевірте вашу пошту",
"check_inbox_sent_to": "Ми надіслали посилання для підтвердження на",
"check_inbox_verify": "Натисніть посилання, щоб підтвердити акаунт. Посилання дійсне 24 години.",
"check_inbox_continue": "Перейти до панелі",
"check_inbox_not_received": "Не отримали?",
"check_inbox_resend": "Надіслати повторно"
},
"language": {
"select": "Мова"
@ -342,5 +364,118 @@
"toast_no_themes": "Нових тем не знайдено",
"toast_themes_error": "Не вдалося згенерувати ключові теми",
"toast_message_not_found": "Повідомлення не знайдено"
},
"focus_group_setup": {
"session_name_label": "Назва сесії",
"session_name_placeholder": "Дайте зрозумілу, пошукову назву",
"session_name_hint": "Ви завжди зможете перейменувати пізніше",
"research_brief_label": "Що ви досліджуєте?",
"research_brief_placeholder": "Опишіть, що хочете дізнатися та чому це важливо для вашої команди",
"research_brief_hint": "Чим конкретніше, тим краща дискусія",
"topics_label": "Ключові теми для обговорення",
"topics_placeholder": "Наприклад, перше враження, реакція на ціни, пріоритети функцій",
"duration_label": "Тривалість сесії",
"duration_placeholder": "Оберіть тривалість",
"duration_30": "30 хвилин",
"duration_45": "45 хвилин",
"duration_60": "60 хвилин",
"duration_90": "90 хвилин",
"duration_120": "120 хвилин",
"advanced_settings": "Розширені налаштування",
"model_label": "Модель",
"model_placeholder": "Оберіть модель ШІ",
"model_gpt54": "GPT-5.4 (Рекомендовано)",
"model_gpt54_mini": "GPT-5.4 Mini (Швидше, нижча вартість)",
"thinking_depth_label": "Глибина мислення",
"thinking_depth_placeholder": "Оберіть рівень розмірковування",
"thinking_minimal": "Мінімальний — Швидкі відповіді",
"thinking_low": "Низький — Швидке мислення",
"thinking_medium": "Середній — Збалансований (за замовч.)",
"thinking_high": "Високий — Глибоке розмірковування",
"response_length_label": "Довжина відповідей",
"response_length_placeholder": "Оберіть рівень детальності",
"verbosity_low": "Низька — Стислі відповіді",
"verbosity_medium": "Середня — Збалансована (за замовч.)",
"verbosity_high": "Висока — Детальні відповіді",
"materials_label": "Прикріпити матеріали (необов'язково)",
"materials_description": "Макети, презентації або будь-які матеріали для модератора",
"copy_guide_button": "Імпортувати гайд з іншого проекту",
"generate_guide_button": "Згенерувати гайд дискусії",
"generating_button": "Генерація..."
},
"persona_editor": {
"edit_title": "Редагувати персону",
"save_button": "Зберегти зміни",
"saving_button": "Збереження...",
"tab_general": "Загальне",
"tab_attitudinal": "Профіль ставлень",
"tab_personality": "Особистість",
"tab_scenarios": "Сценарії",
"label_name": "Ім'я",
"label_occupation": "Посада",
"section_demographics": "Демографія",
"label_age": "Вік",
"label_gender": "Стать",
"label_ethnicity": "Етнічність",
"label_education": "Освіта",
"label_social_grade": "Соціальний клас",
"label_household_income": "Дохід домогосподарства",
"label_household_composition": "Склад домогосподарства",
"section_location": "Місцезнаходження",
"label_location": "Місцезнаходження",
"label_living_situation": "Умови проживання",
"section_interests": "Інтереси",
"section_media": "ЗМІ",
"section_digital_behavior": "Цифрова поведінка",
"label_tech_savviness": "Технічна грамотність",
"label_brand_loyalty": "Лояльність до бренду",
"label_price_sensitivity": "Цінова чутливість",
"label_environmental_concern": "Екологічна свідомість",
"label_device_usage": "Використання пристроїв",
"label_shopping_habits": "Звички шопінгу",
"section_additional": "Додаткова інформація",
"label_brand_preferences": "Переваги брендів",
"label_communication_preferences": "Комунікаційні переваги",
"label_additional_info": "Додаткова інформація",
"section_goals": "Цілі",
"add_goal": "Додати ціль",
"section_frustrations": "Розчарування",
"add_frustration": "Додати розчарування",
"section_motivations": "Мотивації",
"add_motivation": "Додати мотивацію",
"section_think_feel_do": "Думає, відчуває, робить",
"label_thinks": "Думає",
"add_thought": "Додати думку",
"label_feels": "Відчуває",
"add_feeling": "Додати відчуття",
"label_does": "Робить",
"add_action": "Додати дію",
"section_ocean": "Риси особистості OCEAN",
"label_openness": "Відкритість до досвіду",
"hint_openness": "Творчість, цікавість та відкритість до нових ідей",
"label_conscientiousness": "Сумлінність",
"hint_conscientiousness": "Організованість, відповідальність та самодисципліна",
"label_extraversion": "Екстраверсія",
"hint_extraversion": "Товариськість, впевненість та балакучість",
"label_agreeableness": "Доброзичливість",
"hint_agreeableness": "Співчуття, кооперація та турбота про інших",
"label_neuroticism": "Нейротизм",
"hint_neuroticism": "Емоційна реактивність, тривожність та чутливість до стресу",
"section_scenarios": "Сценарії використання",
"label_scenario_section_title": "Назва секції сценаріїв",
"hint_scenario_section_title": "Власна назва для секції (наприклад, «Шлях клієнта», «Випадки використання»)",
"add_scenario": "Додати сценарій",
"auth_required_heading": "Потрібна аутентифікація",
"auth_required_desc": "Для збереження персони в базу даних потрібен вхід. Ви можете:",
"auth_required_option_login": "Увійти та зберегти персону",
"auth_required_option_cancel": "Скасувати та відхилити зміни",
"toast_login_cancelled": "Вхід скасовано — зміни персони не збережені",
"toast_save_success": "Персону успішно збережено в базу даних",
"toast_save_error_after_login": "Не вдалося зберегти в базу даних після входу",
"toast_saved_to_db": "Персону збережено в базу даних",
"toast_updated": "Персону успішно оновлено",
"toast_created": "Персону успішно створено",
"toast_auth_error_local": "Помилка аутентифікації — збережено локально",
"toast_save_failed": "Не вдалося зберегти персону"
}
}

View file

@ -12,6 +12,7 @@ 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';
import { useTranslation } from 'react-i18next';
const loginSchema = z.object({
username: z.string().min(1, 'Email or username is required'),
@ -33,12 +34,13 @@ const MOCK_THEMES = [
];
function MockPanel() {
const { t } = useTranslation();
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);
const timer = setTimeout(() => setVisible(v => v + 1), 900 + visible * 600);
return () => clearTimeout(timer);
}, [visible]);
return (
@ -97,17 +99,17 @@ function MockPanel() {
<div className="rounded-xl bg-background/40 border border-border/50 p-4">
<p className="text-xs font-semibold text-foreground/50 uppercase tracking-wider mb-3">Themes detected</p>
<div className="space-y-2.5">
{MOCK_THEMES.map(t => (
<div key={t.label}>
{MOCK_THEMES.map(theme => (
<div key={theme.label}>
<div className="flex justify-between text-xs text-foreground/60 mb-1">
<span>{t.label}</span>
<span className="font-medium text-foreground/80">{t.pct}%</span>
<span>{theme.label}</span>
<span className="font-medium text-foreground/80">{theme.pct}%</span>
</div>
<div className="h-1.5 rounded-full bg-border/50 overflow-hidden">
<motion.div
className="h-full rounded-full bg-primary"
initial={{ width: 0 }}
animate={{ width: `${t.pct}%` }}
animate={{ width: `${theme.pct}%` }}
transition={{ duration: 1.2, delay: 0.5, ease: 'easeOut' }}
/>
</div>
@ -131,6 +133,7 @@ export default function Login() {
const navigate = useNavigate();
const location = useLocation();
const { login, isAuthenticated } = useAuth();
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
@ -171,8 +174,8 @@ export default function Login() {
</motion.div>
<motion.div variants={fadeUp}>
<h1 className="font-display font-bold text-3xl text-foreground mb-1">Welcome back</h1>
<p className="text-muted-foreground text-sm mb-8">Sign in to your Cohorta account</p>
<h1 className="font-display font-bold text-3xl text-foreground mb-1">{t('auth.login_heading')}</h1>
<p className="text-muted-foreground text-sm mb-8">{t('auth.login_subtitle')}</p>
</motion.div>
<motion.div variants={fadeUp}>
@ -183,10 +186,10 @@ export default function Login() {
name="username"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground/80 text-sm">Email or username</FormLabel>
<FormLabel className="text-foreground/80 text-sm">{t('auth.email_or_username_label')}</FormLabel>
<FormControl>
<Input
placeholder="you@company.com"
placeholder={t('auth.email_placeholder')}
{...field}
disabled={isLoading}
autoComplete="username"
@ -203,7 +206,7 @@ export default function Login() {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground/80 text-sm">Password</FormLabel>
<FormLabel className="text-foreground/80 text-sm">{t('auth.password_label')}</FormLabel>
<FormControl>
<div className="relative">
<Input
@ -234,9 +237,9 @@ export default function Login() {
className="w-full h-11 font-semibold bg-primary text-primary-foreground hover:bg-primary/90 mt-2"
>
{isLoading ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Signing in</>
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />{t('auth.signing_in')}</>
) : (
'Sign in'
t('auth.sign_in_button')
)}
</Button>
</form>
@ -244,9 +247,9 @@ export default function Login() {
</motion.div>
<motion.p variants={fadeUp} className="text-center text-sm text-muted-foreground mt-6">
No account?{' '}
{t('auth.no_account_q')}{' '}
<Link to="/register" className="font-semibold text-primary hover:text-primary/80 transition-colors">
Create one free
{t('auth.create_one_free')}
</Link>
</motion.p>
</motion.div>

View file

@ -15,6 +15,7 @@ import Logo from '@/components/brand/Logo';
import { motion } from 'framer-motion';
import { fadeUp, staggerChildren } from '@/lib/motion';
import axios from 'axios';
import { useTranslation } from 'react-i18next';
const registerSchema = z.object({
username: z.string().min(3, 'At least 3 characters').max(30, 'Max 30 characters').regex(/^[a-zA-Z0-9_]+$/, 'Letters, numbers, underscores only'),
@ -47,8 +48,8 @@ function MockPanel() {
useEffect(() => {
if (visible >= MOCK_MESSAGES.length) return;
const t = setTimeout(() => setVisible(v => v + 1), 1000 + visible * 700);
return () => clearTimeout(t);
const timer = setTimeout(() => setVisible(v => v + 1), 1000 + visible * 700);
return () => clearTimeout(timer);
}, [visible]);
return (
@ -123,6 +124,7 @@ export default function Register() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { isAuthenticated } = useAuth();
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
@ -155,10 +157,10 @@ export default function Register() {
setRegistered(true);
if (res.data.access_token) {
localStorage.setItem('auth_token', res.data.access_token);
toastService.success('Account created!', { description: 'Check your email to verify.' });
toastService.success(t('auth.toast_account_created'), { description: t('auth.toast_account_created_desc') });
}
} catch (err: any) {
toastService.error(err.response?.data?.message || 'Registration failed. Please try again.');
toastService.error(err.response?.data?.message || t('auth.toast_register_failed'));
} finally {
setIsLoading(false);
}
@ -171,33 +173,33 @@ export default function Register() {
<motion.div variants={fadeUp} className="w-16 h-16 rounded-2xl bg-primary/15 border border-primary/25 flex items-center justify-center mx-auto mb-6">
<Mail className="h-8 w-8 text-primary" />
</motion.div>
<motion.h1 variants={fadeUp} className="font-display font-bold text-2xl text-foreground mb-3">Check your inbox</motion.h1>
<motion.p variants={fadeUp} className="text-muted-foreground mb-2">We sent a verification link to</motion.p>
<motion.h1 variants={fadeUp} className="font-display font-bold text-2xl text-foreground mb-3">{t('auth.check_inbox_heading')}</motion.h1>
<motion.p variants={fadeUp} className="text-muted-foreground mb-2">{t('auth.check_inbox_sent_to')}</motion.p>
<motion.p variants={fadeUp} className="font-semibold text-primary mb-6 break-all">{registeredEmail}</motion.p>
<motion.p variants={fadeUp} className="text-sm text-muted-foreground mb-8">
Click the link to verify your account. The link expires in 24 hours.
{t('auth.check_inbox_verify')}
</motion.p>
<motion.button
variants={fadeUp}
onClick={() => navigate('/dashboard')}
className="w-full py-3 rounded-xl font-semibold bg-primary text-primary-foreground hover:bg-primary/90 transition-all mb-4"
>
Continue to Dashboard
{t('auth.check_inbox_continue')}
</motion.button>
<motion.p variants={fadeUp} className="text-xs text-muted-foreground">
Didn't receive it?{' '}
{t('auth.check_inbox_not_received')}{' '}
<button
onClick={async () => {
try {
await axios.post('/api/auth/resend-verification', { email: registeredEmail });
toastService.success('Verification email resent');
toastService.success(t('auth.toast_resend_success'));
} catch {
toastService.error('Could not resend. Try again later.');
toastService.error(t('auth.toast_resend_error'));
}
}}
className="text-primary hover:text-primary/80 transition-colors"
>
Resend email
{t('auth.check_inbox_resend')}
</button>
</motion.p>
</motion.div>
@ -226,14 +228,14 @@ export default function Register() {
</div>
<div>
<p className="text-sm font-semibold text-foreground">{planMeta.label} pack ${planMeta.price}</p>
<p className="text-xs text-muted-foreground">50 free credits first, then {planMeta.credits} credits for ${planMeta.price}</p>
<p className="text-xs text-muted-foreground">{t('auth.plan_badge_credits', { credits: planMeta.credits, price: planMeta.price })}</p>
</div>
</motion.div>
)}
<motion.div variants={fadeUp}>
<h1 className="font-display font-bold text-3xl text-foreground mb-1">Create your account</h1>
<p className="text-muted-foreground text-sm mb-8">Free to start · 50 credits on signup · No card required</p>
<h1 className="font-display font-bold text-3xl text-foreground mb-1">{t('auth.register_heading')}</h1>
<p className="text-muted-foreground text-sm mb-8">{t('auth.register_subtitle')}</p>
</motion.div>
<motion.div variants={fadeUp}>
@ -244,10 +246,10 @@ export default function Register() {
name="username"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground/80 text-sm">Username</FormLabel>
<FormLabel className="text-foreground/80 text-sm">{t('auth.username_label')}</FormLabel>
<FormControl>
<Input
placeholder="your_username"
placeholder={t('auth.username_placeholder')}
{...field}
disabled={isLoading}
autoComplete="username"
@ -264,11 +266,11 @@ export default function Register() {
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground/80 text-sm">Work email</FormLabel>
<FormLabel className="text-foreground/80 text-sm">{t('auth.work_email_label')}</FormLabel>
<FormControl>
<Input
type="email"
placeholder="you@company.com"
placeholder={t('auth.email_placeholder')}
{...field}
disabled={isLoading}
autoComplete="email"
@ -285,11 +287,11 @@ export default function Register() {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground/80 text-sm">Password</FormLabel>
<FormLabel className="text-foreground/80 text-sm">{t('auth.password_label')}</FormLabel>
<FormControl>
<div className="relative">
<Input
placeholder="Min. 6 characters"
placeholder={t('auth.password_placeholder')}
type={showPassword ? 'text' : 'password'}
{...field}
disabled={isLoading}
@ -312,7 +314,7 @@ export default function Register() {
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground/80 text-sm">Confirm password</FormLabel>
<FormLabel className="text-foreground/80 text-sm">{t('auth.confirm_password_label')}</FormLabel>
<FormControl>
<div className="relative">
<Input
@ -350,13 +352,13 @@ export default function Register() {
</FormControl>
<div className="leading-snug">
<FormLabel className="text-sm text-foreground/80 font-normal cursor-pointer">
I agree to the{' '}
{t('auth.agree_to')}{' '}
<Link to="/terms" target="_blank" className="text-primary hover:text-primary/80 underline underline-offset-2">
Terms of Service
{t('auth.terms')}
</Link>{' '}
and{' '}
{t('auth.and')}{' '}
<Link to="/privacy" target="_blank" className="text-primary hover:text-primary/80 underline underline-offset-2">
Privacy Policy
{t('auth.privacy')}
</Link>
</FormLabel>
<FormMessage className="text-destructive text-xs mt-0.5" />
@ -381,11 +383,11 @@ export default function Register() {
</FormControl>
<div className="leading-snug">
<FormLabel className="text-sm text-foreground/80 font-normal cursor-pointer">
I consent to the processing of my personal data in accordance with the{' '}
{t('auth.data_processing_consent')}{' '}
<Link to="/privacy" target="_blank" className="text-primary hover:text-primary/80 underline underline-offset-2">
Privacy Policy
{t('auth.privacy')}
</Link>{' '}
(UK GDPR / Data Protection Act 2018)
{t('auth.uk_gdpr_act')}
</FormLabel>
<FormMessage className="text-destructive text-xs mt-0.5" />
</div>
@ -399,25 +401,25 @@ export default function Register() {
className="w-full h-11 font-semibold bg-primary text-primary-foreground hover:bg-primary/90 mt-2"
>
{isLoading ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Creating account</>
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />{t('auth.creating_account')}</>
) : (
'Create free account →'
t('auth.create_free_button')
)}
</Button>
<div className="flex items-center justify-center gap-4 text-xs text-muted-foreground pt-1">
<span className="flex items-center gap-1"><CheckCircle2 className="h-3.5 w-3.5 text-primary" /> 50 free credits</span>
<span className="flex items-center gap-1"><CheckCircle2 className="h-3.5 w-3.5 text-primary" /> No card required</span>
<span className="flex items-center gap-1"><CheckCircle2 className="h-3.5 w-3.5 text-primary" /> UK hosted</span>
<span className="flex items-center gap-1"><CheckCircle2 className="h-3.5 w-3.5 text-primary" /> {t('auth.free_credits')}</span>
<span className="flex items-center gap-1"><CheckCircle2 className="h-3.5 w-3.5 text-primary" /> {t('auth.no_card')}</span>
<span className="flex items-center gap-1"><CheckCircle2 className="h-3.5 w-3.5 text-primary" /> {t('auth.uk_hosted')}</span>
</div>
</form>
</Form>
</motion.div>
<motion.p variants={fadeUp} className="text-center text-sm text-muted-foreground mt-6">
Already have an account?{' '}
{t('auth.have_account')}{' '}
<Link to="/login" className="font-semibold text-primary hover:text-primary/80 transition-colors">
Sign in
{t('auth.sign_in')}
</Link>
</motion.p>
</motion.div>