CognxSafeTrack commited on
Commit ·
d80fec4
1
Parent(s): 4f90920
feat(i18n): complete admin app internationalization across all pages
Browse filesReplace all hardcoded French strings with t() calls on 14 pages and
sync 130+ new keys across fr/en/pt locale files. Language switching
now works everywhere in the admin interface.
Pages covered: TrackDaysPage, TrackFormPage, TrackListPage, UserListPage,
KnowledgeBasePage, SettingsPage, AnalyticsPage, AIAgentSetup,
OnboardingWizard, ResetPasswordPage, ClientsManagementView,
ContactsPage, TemplatesPage, TrainingLab.
- apps/admin/src/locales/en.json +415 -12
- apps/admin/src/locales/fr.json +415 -12
- apps/admin/src/locales/pt.json +415 -12
- apps/admin/src/pages/AIAgentSetup.tsx +9 -1
- apps/admin/src/pages/AnalyticsPage.tsx +19 -19
- apps/admin/src/pages/ClientsManagementView.tsx +1 -1
- apps/admin/src/pages/ContactsPage.tsx +14 -14
- apps/admin/src/pages/KnowledgeBasePage.tsx +9 -9
- apps/admin/src/pages/OnboardingWizard.tsx +34 -50
- apps/admin/src/pages/ResetPasswordPage.tsx +4 -4
- apps/admin/src/pages/SettingsPage.tsx +10 -10
- apps/admin/src/pages/TemplatesPage.tsx +2 -5
- apps/admin/src/pages/TrackDaysPage.tsx +13 -13
- apps/admin/src/pages/TrackFormPage.tsx +7 -7
- apps/admin/src/pages/TrackListPage.tsx +18 -18
- apps/admin/src/pages/TrainingLab.tsx +5 -5
- apps/admin/src/pages/UserListPage.tsx +14 -14
apps/admin/src/locales/en.json
CHANGED
|
@@ -35,7 +35,8 @@
|
|
| 35 |
"select_org": "Please select an organization",
|
| 36 |
"clear_filter": "Clear filter",
|
| 37 |
"no_data": "No data available",
|
| 38 |
-
"retry": "Retry"
|
|
|
|
| 39 |
},
|
| 40 |
"nav": {
|
| 41 |
"home": "Home",
|
|
@@ -103,7 +104,26 @@
|
|
| 103 |
"engagement": {
|
| 104 |
"title": "Engagement",
|
| 105 |
"avg_days": "Average training days"
|
| 106 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
},
|
| 108 |
"tracks": {
|
| 109 |
"title": "Courses",
|
|
@@ -113,7 +133,48 @@
|
|
| 113 |
"days": "days",
|
| 114 |
"enrolled": "enrolled",
|
| 115 |
"days_label": "Days",
|
| 116 |
-
"no_days": "No days created yet."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
},
|
| 118 |
"users": {
|
| 119 |
"title": "Users",
|
|
@@ -127,7 +188,21 @@
|
|
| 127 |
"day": "Day",
|
| 128 |
"status": "Status",
|
| 129 |
"joined": "Joined"
|
| 130 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
},
|
| 132 |
"contacts": {
|
| 133 |
"title": "Contacts",
|
|
@@ -135,7 +210,23 @@
|
|
| 135 |
"add": "Add contact",
|
| 136 |
"import": "Import",
|
| 137 |
"no_contacts": "No contacts",
|
| 138 |
-
"search_placeholder": "Search a contact..."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
},
|
| 140 |
"campaigns": {
|
| 141 |
"title": "Campaign History",
|
|
@@ -160,7 +251,17 @@
|
|
| 160 |
"no_documents": "No chunks found.",
|
| 161 |
"import_hint": "Import a document in the AI Agent tab to get started.",
|
| 162 |
"confirm_delete": "Delete this chunk from the knowledge base?",
|
| 163 |
-
"delete_error": "Deletion failed"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
},
|
| 165 |
"ai_setup": {
|
| 166 |
"title": "AI Agent Setup",
|
|
@@ -217,7 +318,11 @@
|
|
| 217 |
"words_covered": "Words covered",
|
| 218 |
"bot_name_label": "Agent name",
|
| 219 |
"bot_name_placeholder": "E.g. Kora, Awa, SupportBot...",
|
| 220 |
-
"bot_name_hint": "The name your agent will use to introduce itself on WhatsApp."
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
},
|
| 222 |
"livefeed": {
|
| 223 |
"title": "Live Feed",
|
|
@@ -227,7 +332,11 @@
|
|
| 227 |
},
|
| 228 |
"training": {
|
| 229 |
"title": "Training Lab",
|
| 230 |
-
"subtitle": "Test and refine your pedagogical AI"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
},
|
| 232 |
"b2b": {
|
| 233 |
"title": "B2B Client Management",
|
|
@@ -259,7 +368,20 @@
|
|
| 259 |
"token_expired_alert": "Your WhatsApp token is invalid or expired. Messages can no longer be sent. Go to Meta Business Manager → System Users to generate a new token, then update your organization.",
|
| 260 |
"api_keys_title": "AI API Keys",
|
| 261 |
"api_keys_locked": "Available from the SCALE plan. By adding your own OpenAI or Google keys, you use your own AI quota — with no limit tied to your Xamlé plan.",
|
| 262 |
-
"api_keys_unlocked": "Your keys are encrypted and stored securely. They replace the platform's shared keys — your AI consumption is no longer deducted from your credit balance."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
},
|
| 264 |
"auth": {
|
| 265 |
"org_id": "Organisation ID",
|
|
@@ -287,7 +409,11 @@
|
|
| 287 |
"reset_set_button": "Set password",
|
| 288 |
"reset_setting": "Updating...",
|
| 289 |
"reset_login_link": "Sign in",
|
| 290 |
-
"reset_back": "Back to sign in"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
},
|
| 292 |
"onboarding": {
|
| 293 |
"title": "Welcome to Xamlé.Studio",
|
|
@@ -315,7 +441,35 @@
|
|
| 315 |
"token_idle_hint": "Generate a \"Never expire\" token in Meta Business Manager → System Users.",
|
| 316 |
"skip_whatsapp": "Configure WhatsApp later",
|
| 317 |
"create_org": "Create organization",
|
| 318 |
-
"creating": "Creating…"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
},
|
| 320 |
"crm": {
|
| 321 |
"stats": {
|
|
@@ -404,7 +558,9 @@
|
|
| 404 |
"status_pending": "Pending",
|
| 405 |
"status_rejected": "Rejected",
|
| 406 |
"status_paused": "Paused",
|
| 407 |
-
"status_disabled": "Disabled"
|
|
|
|
|
|
|
| 408 |
}
|
| 409 |
},
|
| 410 |
"billing": {
|
|
@@ -690,5 +846,252 @@
|
|
| 690 |
"general_q2": "What's the difference between the modes?",
|
| 691 |
"general_q3": "How does billing work?",
|
| 692 |
"general_q4": "Where can I see my statistics?"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 693 |
}
|
| 694 |
}
|
|
|
|
| 35 |
"select_org": "Please select an organization",
|
| 36 |
"clear_filter": "Clear filter",
|
| 37 |
"no_data": "No data available",
|
| 38 |
+
"retry": "Retry",
|
| 39 |
+
"of": "of"
|
| 40 |
},
|
| 41 |
"nav": {
|
| 42 |
"home": "Home",
|
|
|
|
| 104 |
"engagement": {
|
| 105 |
"title": "Engagement",
|
| 106 |
"avg_days": "Average training days"
|
| 107 |
+
},
|
| 108 |
+
"sql_error": "Error executing query",
|
| 109 |
+
"sql_example_1": "How many active users this week?",
|
| 110 |
+
"sql_example_2": "What are the 5 users with the most messages?",
|
| 111 |
+
"sql_example_3": "What is the average completion rate?",
|
| 112 |
+
"sql_example_4": "How many AI credits were consumed this month?",
|
| 113 |
+
"ai_cost_title": "AI Cost by Feature",
|
| 114 |
+
"ai_cost_subtitle": "Real data — source: UsageEvent",
|
| 115 |
+
"col_feature": "Feature",
|
| 116 |
+
"col_calls": "Calls",
|
| 117 |
+
"col_tokens_in": "Tokens in",
|
| 118 |
+
"col_tokens_out": "Tokens out",
|
| 119 |
+
"col_cost": "Cost (USD)",
|
| 120 |
+
"total": "Total",
|
| 121 |
+
"nl_search_title": "Natural language search",
|
| 122 |
+
"nl_search_subtitle": "Ask a question about your data — AI generates the SQL query",
|
| 123 |
+
"nl_search_placeholder": "E.g.: What users have been inactive for 7 days?",
|
| 124 |
+
"search_btn": "Search",
|
| 125 |
+
"view_sql": "View SQL",
|
| 126 |
+
"no_results": "No results"
|
| 127 |
},
|
| 128 |
"tracks": {
|
| 129 |
"title": "Courses",
|
|
|
|
| 133 |
"days": "days",
|
| 134 |
"enrolled": "enrolled",
|
| 135 |
"days_label": "Days",
|
| 136 |
+
"no_days": "No days created yet.",
|
| 137 |
+
"edit_day": "Edit Day",
|
| 138 |
+
"new_day": "New day",
|
| 139 |
+
"day_number": "Day number",
|
| 140 |
+
"day_title": "Title",
|
| 141 |
+
"lesson_text": "Lesson text",
|
| 142 |
+
"lesson_placeholder": "Pedagogical content...",
|
| 143 |
+
"audio_url": "Audio URL (optional)",
|
| 144 |
+
"exercise_type": "Exercise type",
|
| 145 |
+
"exercise_type_text": "Free text",
|
| 146 |
+
"exercise_type_audio": "Audio",
|
| 147 |
+
"exercise_type_button": "Buttons",
|
| 148 |
+
"validation_keyword": "Validation keyword",
|
| 149 |
+
"exercise_prompt": "Exercise prompt",
|
| 150 |
+
"exercise_prompt_placeholder": "Question asked to the student...",
|
| 151 |
+
"no_lesson_text": "No text",
|
| 152 |
+
"form_title_label": "Title",
|
| 153 |
+
"form_description": "Description",
|
| 154 |
+
"form_duration": "Duration (days)",
|
| 155 |
+
"form_language": "Language",
|
| 156 |
+
"form_lang_fr": "French",
|
| 157 |
+
"form_lang_wolof": "Wolof",
|
| 158 |
+
"form_premium": "Premium Training (paid)",
|
| 159 |
+
"form_price": "Price (XOF)",
|
| 160 |
+
"ai_generate_btn": "Generate with AI",
|
| 161 |
+
"ai_generate_first": "Generate your first program with AI",
|
| 162 |
+
"ai_modal_badge": "Content Creator Agent",
|
| 163 |
+
"ai_modal_title": "Generate a program",
|
| 164 |
+
"ai_modal_subtitle": "AI creates the entire curriculum in seconds",
|
| 165 |
+
"ai_description_label": "Program description",
|
| 166 |
+
"ai_description_placeholder": "E.g.: 5-day training on digital marketing basics for SMEs in Senegal...",
|
| 167 |
+
"ai_num_days": "Number of days",
|
| 168 |
+
"ai_language": "Language",
|
| 169 |
+
"ai_lang_fr": "French",
|
| 170 |
+
"ai_lang_en": "English",
|
| 171 |
+
"ai_lang_wol": "Wolof",
|
| 172 |
+
"ai_audience": "Target audience",
|
| 173 |
+
"ai_audience_optional": "optional",
|
| 174 |
+
"ai_audience_placeholder": "E.g.: Beginner entrepreneurs, rural women, students...",
|
| 175 |
+
"ai_generating": "Generating... (15-30s)",
|
| 176 |
+
"ai_generate_submit": "Generate program",
|
| 177 |
+
"ai_error": "AI generation failed"
|
| 178 |
},
|
| 179 |
"users": {
|
| 180 |
"title": "Users",
|
|
|
|
| 188 |
"day": "Day",
|
| 189 |
"status": "Status",
|
| 190 |
"joined": "Joined"
|
| 191 |
+
},
|
| 192 |
+
"confirm_delete": "Delete this user? This action is reversible in the database.",
|
| 193 |
+
"delete_success": "User deleted",
|
| 194 |
+
"delete_error": "Deletion failed",
|
| 195 |
+
"handoff_released": "Handoff released — AI resumes the conversation",
|
| 196 |
+
"handoff_none": "No active handoff for this user",
|
| 197 |
+
"handoff_error": "Failed",
|
| 198 |
+
"load_error": "Error loading users",
|
| 199 |
+
"language_column": "Language",
|
| 200 |
+
"sector_column": "Sector",
|
| 201 |
+
"conversation_btn": "Conversation",
|
| 202 |
+
"delete_title": "Delete user",
|
| 203 |
+
"handoff_active": "Handoff active",
|
| 204 |
+
"release_ai": "Release AI",
|
| 205 |
+
"prev": "Previous"
|
| 206 |
},
|
| 207 |
"contacts": {
|
| 208 |
"title": "Contacts",
|
|
|
|
| 210 |
"add": "Add contact",
|
| 211 |
"import": "Import",
|
| 212 |
"no_contacts": "No contacts",
|
| 213 |
+
"search_placeholder": "Search a contact...",
|
| 214 |
+
"tags_update_error": "Failed to update tags",
|
| 215 |
+
"import_success": "Import successful: {{created}} added, {{updated}} updated, {{errors}} errors.",
|
| 216 |
+
"upload_critical_error": "A critical error occurred during the upload.",
|
| 217 |
+
"ai_generation_error": "AI generation failed.",
|
| 218 |
+
"generation_error_fallback": "Generation error",
|
| 219 |
+
"confirm_delete_one": "Are you sure you want to delete this contact?",
|
| 220 |
+
"delete_error": "Error during deletion.",
|
| 221 |
+
"confirm_delete_many": "Are you sure you want to delete {{count}} contacts?",
|
| 222 |
+
"bulk_delete_success": "Contacts successfully deleted.",
|
| 223 |
+
"bulk_delete_error": "Error during bulk deletion.",
|
| 224 |
+
"csv_name": "Name",
|
| 225 |
+
"csv_phone": "Phone",
|
| 226 |
+
"csv_created": "Created at",
|
| 227 |
+
"message_copied": "Message copied!",
|
| 228 |
+
"copied": "Copied!",
|
| 229 |
+
"generation_completed": "Generation completed successfully"
|
| 230 |
},
|
| 231 |
"campaigns": {
|
| 232 |
"title": "Campaign History",
|
|
|
|
| 251 |
"no_documents": "No chunks found.",
|
| 252 |
"import_hint": "Import a document in the AI Agent tab to get started.",
|
| 253 |
"confirm_delete": "Delete this chunk from the knowledge base?",
|
| 254 |
+
"delete_error": "Deletion failed",
|
| 255 |
+
"reindex_success": "Reindexing started successfully",
|
| 256 |
+
"no_kb_url": "No knowledge base URL configured. Add a URL in Settings.",
|
| 257 |
+
"reindex_error": "Reindexing failed",
|
| 258 |
+
"generate_error": "Generation failed",
|
| 259 |
+
"generate_from_desc": "Generate from description",
|
| 260 |
+
"generate_placeholder": "Describe your activity, products or services… AI will automatically generate a FAQ and index it in the knowledge base.",
|
| 261 |
+
"generating_btn": "Generating…",
|
| 262 |
+
"generate_btn": "Generate",
|
| 263 |
+
"generate_success_count": "{{count}} Q&A generated and indexed",
|
| 264 |
+
"generate_result_summary": "{{count}} Q&A generated · {{chunks}} chunks indexed"
|
| 265 |
},
|
| 266 |
"ai_setup": {
|
| 267 |
"title": "AI Agent Setup",
|
|
|
|
| 318 |
"words_covered": "Words covered",
|
| 319 |
"bot_name_label": "Agent name",
|
| 320 |
"bot_name_placeholder": "E.g. Kora, Awa, SupportBot...",
|
| 321 |
+
"bot_name_hint": "The name your agent will use to introduce itself on WhatsApp.",
|
| 322 |
+
"tone_professional": "Professional",
|
| 323 |
+
"tone_friendly": "Friendly",
|
| 324 |
+
"tone_direct": "Direct",
|
| 325 |
+
"tone_pedagogical": "Pedagogical"
|
| 326 |
},
|
| 327 |
"livefeed": {
|
| 328 |
"title": "Live Feed",
|
|
|
|
| 332 |
},
|
| 333 |
"training": {
|
| 334 |
"title": "Training Lab",
|
| 335 |
+
"subtitle": "Test and refine your pedagogical AI",
|
| 336 |
+
"rules_injected": "Success! {{count}} rules have been injected into the dictionary.",
|
| 337 |
+
"inject_rules": "Inject ({{count}}) Rules",
|
| 338 |
+
"ground_truth_label": "Ground Truth",
|
| 339 |
+
"training_saved": "Training saved!"
|
| 340 |
},
|
| 341 |
"b2b": {
|
| 342 |
"title": "B2B Client Management",
|
|
|
|
| 368 |
"token_expired_alert": "Your WhatsApp token is invalid or expired. Messages can no longer be sent. Go to Meta Business Manager → System Users to generate a new token, then update your organization.",
|
| 369 |
"api_keys_title": "AI API Keys",
|
| 370 |
"api_keys_locked": "Available from the SCALE plan. By adding your own OpenAI or Google keys, you use your own AI quota — with no limit tied to your Xamlé plan.",
|
| 371 |
+
"api_keys_unlocked": "Your keys are encrypted and stored securely. They replace the platform's shared keys — your AI consumption is no longer deducted from your credit balance.",
|
| 372 |
+
"wa_connect_success": "WhatsApp connected successfully ✅",
|
| 373 |
+
"wa_connect_error": "WhatsApp connection failed. Check the token and WABA ID.",
|
| 374 |
+
"mode_edtech": "EdTech",
|
| 375 |
+
"mode_webhook": "Webhook",
|
| 376 |
+
"mode_ai_agent": "AI Agent",
|
| 377 |
+
"mode_pedagogy": "Pedagogy",
|
| 378 |
+
"mode_customer_service": "Customer Service",
|
| 379 |
+
"mode_crm_marketing": "CRM & Campaigns",
|
| 380 |
+
"wa_cancel": "Cancel",
|
| 381 |
+
"wa_reconfigure": "🔄 Reconfigure",
|
| 382 |
+
"wa_connect_btn": "🔗 Connect",
|
| 383 |
+
"wa_connecting": "Connecting...",
|
| 384 |
+
"wa_connect_submit": "Connect WhatsApp"
|
| 385 |
},
|
| 386 |
"auth": {
|
| 387 |
"org_id": "Organisation ID",
|
|
|
|
| 409 |
"reset_set_button": "Set password",
|
| 410 |
"reset_setting": "Updating...",
|
| 411 |
"reset_login_link": "Sign in",
|
| 412 |
+
"reset_back": "Back to sign in",
|
| 413 |
+
"reset_network_error": "Network error. Please try again.",
|
| 414 |
+
"reset_password_mismatch": "Passwords do not match.",
|
| 415 |
+
"reset_password_min_length": "Password must be at least 6 characters.",
|
| 416 |
+
"reset_token_expired": "Invalid or expired token."
|
| 417 |
},
|
| 418 |
"onboarding": {
|
| 419 |
"title": "Welcome to Xamlé.Studio",
|
|
|
|
| 441 |
"token_idle_hint": "Generate a \"Never expire\" token in Meta Business Manager → System Users.",
|
| 442 |
"skip_whatsapp": "Configure WhatsApp later",
|
| 443 |
"create_org": "Create organization",
|
| 444 |
+
"creating": "Creating…",
|
| 445 |
+
"step_org": "Organization",
|
| 446 |
+
"step_admin": "Administrator",
|
| 447 |
+
"org_title": "The organization",
|
| 448 |
+
"org_subtitle": "Name, URL identifier and use case.",
|
| 449 |
+
"org_name_label": "Organization name",
|
| 450 |
+
"slug_label": "URL identifier (slug)",
|
| 451 |
+
"slug_hint": "Auto-generated from name. Editable — lowercase letters, numbers and hyphens only.",
|
| 452 |
+
"mode_label": "Use case",
|
| 453 |
+
"mode_edtech_label": "Training & EdTech",
|
| 454 |
+
"mode_edtech_desc": "Educational courses, exercises, learner tracking via WhatsApp",
|
| 455 |
+
"mode_crm_label": "CRM & Campaigns",
|
| 456 |
+
"mode_crm_desc": "Contact management, broadcast campaigns, marketing follow-ups",
|
| 457 |
+
"mode_ai_label": "AI Agent",
|
| 458 |
+
"mode_ai_desc": "Conversational AI bot to respond autonomously 24/7",
|
| 459 |
+
"mode_customer_service_label": "Customer Service",
|
| 460 |
+
"mode_customer_service_desc": "Manage incoming conversations and escalate to a human agent",
|
| 461 |
+
"admin_title": "The administrator",
|
| 462 |
+
"admin_subtitle": "The first admin account for this organization.",
|
| 463 |
+
"admin_name_label": "Full name",
|
| 464 |
+
"admin_email_label": "Email",
|
| 465 |
+
"admin_pass_label": "Initial password",
|
| 466 |
+
"admin_pass_optional": "optional — auto-generated if empty",
|
| 467 |
+
"admin_pass_placeholder": "Leave empty to auto-generate",
|
| 468 |
+
"admin_pass_hint": "An email with the temporary password is sent to the admin after creation.",
|
| 469 |
+
"wa_title": "WhatsApp Connection",
|
| 470 |
+
"wa_subtitle": "Optional — can be configured later from the organization profile.",
|
| 471 |
+
"create_error": "Creation failed",
|
| 472 |
+
"fb_error": "Facebook login was cancelled or failed."
|
| 473 |
},
|
| 474 |
"crm": {
|
| 475 |
"stats": {
|
|
|
|
| 558 |
"status_pending": "Pending",
|
| 559 |
"status_rejected": "Rejected",
|
| 560 |
"status_paused": "Paused",
|
| 561 |
+
"status_disabled": "Disabled",
|
| 562 |
+
"waba_not_configured": "WhatsApp Business not configured",
|
| 563 |
+
"waba_not_configured_desc": "This organization does not yet have a WhatsApp Business account (WABA) associated. Go to Settings → WhatsApp Integration to configure your number and WABA ID."
|
| 564 |
}
|
| 565 |
},
|
| 566 |
"billing": {
|
|
|
|
| 846 |
"general_q2": "What's the difference between the modes?",
|
| 847 |
"general_q3": "How does billing work?",
|
| 848 |
"general_q4": "Where can I see my statistics?"
|
| 849 |
+
},
|
| 850 |
+
"super_admin": {
|
| 851 |
+
"nav_dashboard": "Dashboard",
|
| 852 |
+
"nav_organizations": "Organizations",
|
| 853 |
+
"nav_users": "Users",
|
| 854 |
+
"nav_whatsapp": "WA Numbers",
|
| 855 |
+
"nav_templates": "WA Templates",
|
| 856 |
+
"nav_profiles": "WA Profiles",
|
| 857 |
+
"nav_monitoring": "Monitoring",
|
| 858 |
+
"nav_billing": "Billing",
|
| 859 |
+
"nav_ai": "AI Insights",
|
| 860 |
+
"nav_audit_logs": "Audit Logs",
|
| 861 |
+
"exit_admin": "Exit admin",
|
| 862 |
+
"logout": "Log out",
|
| 863 |
+
"system_active": "System active",
|
| 864 |
+
"platform_admin": "Platform Admin",
|
| 865 |
+
"super_admin_label": "Super-admin",
|
| 866 |
+
|
| 867 |
+
"dashboard_title": "Platform Dashboard",
|
| 868 |
+
"dashboard_subtitle": "Global overview of all organizations",
|
| 869 |
+
"kpi_organizations": "Organizations",
|
| 870 |
+
"kpi_orgs_active": "{{count}} active",
|
| 871 |
+
"kpi_users": "Users",
|
| 872 |
+
"kpi_messages_24h": "Messages / 24h",
|
| 873 |
+
"kpi_queue_depth": "Queue depth",
|
| 874 |
+
"kpi_queue_failed": "{{count}} failed",
|
| 875 |
+
"kpi_revenue": "Revenue / month",
|
| 876 |
+
"kpi_alerts": "Alerts",
|
| 877 |
+
"system_health": "System health",
|
| 878 |
+
"health_db": "Database",
|
| 879 |
+
"health_redis": "Redis",
|
| 880 |
+
"health_queue": "Queue",
|
| 881 |
+
"active_alerts": "Active alerts ({{count}})",
|
| 882 |
+
"alert_token": "Expiring token — {{orgName}} ({{daysOld}}d)",
|
| 883 |
+
"alert_balance": "Low balance — {{orgName}}: {{balance}} credits",
|
| 884 |
+
"alert_queue_failed": "{{count}} failed jobs in queue",
|
| 885 |
+
|
| 886 |
+
"orgs_title": "Organizations",
|
| 887 |
+
"orgs_total": "{{count}} organizations total",
|
| 888 |
+
"org_new": "New organization",
|
| 889 |
+
"org_search_placeholder": "Search an organization...",
|
| 890 |
+
"org_search_btn": "Search",
|
| 891 |
+
"org_loading": "Loading...",
|
| 892 |
+
"org_empty": "No organization found",
|
| 893 |
+
"col_name": "Name",
|
| 894 |
+
"col_plan": "Plan",
|
| 895 |
+
"col_status": "Status",
|
| 896 |
+
"col_users": "Users",
|
| 897 |
+
"col_credits": "Credits",
|
| 898 |
+
"status_suspended": "Suspended",
|
| 899 |
+
"status_trial": "Trial",
|
| 900 |
+
"status_active": "Active",
|
| 901 |
+
"org_reactivated": "Organization reactivated",
|
| 902 |
+
"org_suspended": "Organization suspended",
|
| 903 |
+
"org_delete_confirm": "Permanently delete \"{{name}}\"? This action cannot be undone.",
|
| 904 |
+
"org_deleted": "\"{{name}}\" deleted",
|
| 905 |
+
"org_updated": "Organization updated",
|
| 906 |
+
"org_created": "Organization created",
|
| 907 |
+
"label_plan": "Plan",
|
| 908 |
+
"label_ai_credits": "AI credits limit",
|
| 909 |
+
"label_crm_active": "CRM active",
|
| 910 |
+
"label_edtech_active": "EdTech active",
|
| 911 |
+
"saving": "Saving...",
|
| 912 |
+
"save": "Save",
|
| 913 |
+
"modal_new_org": "New organization",
|
| 914 |
+
"org_name_placeholder": "Organization name",
|
| 915 |
+
"cancel": "Cancel",
|
| 916 |
+
"creating": "Creating...",
|
| 917 |
+
"create": "Create",
|
| 918 |
+
"err_load_orgs": "Error loading organizations",
|
| 919 |
+
"err_suspend": "Error changing status",
|
| 920 |
+
"err_delete": "Error deleting",
|
| 921 |
+
"err_update": "Error updating",
|
| 922 |
+
"err_create": "Error creating",
|
| 923 |
+
"btn_edit": "Edit",
|
| 924 |
+
"btn_reactivate": "Reactivate",
|
| 925 |
+
"btn_suspend": "Suspend",
|
| 926 |
+
"btn_delete_forever": "Delete permanently",
|
| 927 |
+
"pagination_info": "{{from}}–{{to}} of {{total}}",
|
| 928 |
+
"prev": "Previous",
|
| 929 |
+
"next": "Next",
|
| 930 |
+
|
| 931 |
+
"users_title": "Users",
|
| 932 |
+
"users_total": "{{count}} users total",
|
| 933 |
+
"user_search_placeholder": "Search by name or email...",
|
| 934 |
+
"col_user": "User",
|
| 935 |
+
"col_organization": "Organization",
|
| 936 |
+
"col_role": "Role",
|
| 937 |
+
"col_created_at": "Created on",
|
| 938 |
+
"user_empty": "No user found",
|
| 939 |
+
"role_updated": "Role updated",
|
| 940 |
+
"btn_reset_password": "Reset password",
|
| 941 |
+
"reset_no_email": "This user has no email address",
|
| 942 |
+
"reset_confirm": "Send a reset link to {{email}}?",
|
| 943 |
+
"reset_sent": "Link sent to {{email}}",
|
| 944 |
+
"err_load_users": "Error loading users",
|
| 945 |
+
"err_role_change": "Error changing role",
|
| 946 |
+
"err_reset_password": "Error sending email",
|
| 947 |
+
|
| 948 |
+
"wa_numbers_title": "WhatsApp Numbers",
|
| 949 |
+
"wa_numbers_total": "{{count}} numbers registered",
|
| 950 |
+
"wa_refresh": "Refresh",
|
| 951 |
+
"wa_register_number": "Register a number",
|
| 952 |
+
"wa_no_numbers": "No WhatsApp number registered",
|
| 953 |
+
"col_number": "Number",
|
| 954 |
+
"col_id": "ID",
|
| 955 |
+
"col_added_at": "Added on",
|
| 956 |
+
"wa_register_title": "Register a WhatsApp number",
|
| 957 |
+
"wa_step": "Step {{step}}/2",
|
| 958 |
+
"label_org": "Organization",
|
| 959 |
+
"org_select_placeholder": "Select an organization...",
|
| 960 |
+
"label_phone_number_id": "Phone Number ID",
|
| 961 |
+
"phone_id_hint": "Find this ID in Meta Business Manager > WhatsApp Accounts",
|
| 962 |
+
"label_pin": "Security PIN",
|
| 963 |
+
"pin_hint": "6-digit PIN to secure the number (leave blank = 000000)",
|
| 964 |
+
"wa_select_org_error": "Please select an organization.",
|
| 965 |
+
"wa_phone_id_error": "Phone Number ID must be between 12 and 18 digits.",
|
| 966 |
+
"wa_pin_error": "PIN must be exactly 6 digits.",
|
| 967 |
+
"wa_sending": "Sending...",
|
| 968 |
+
"wa_send_otp": "Send OTP code",
|
| 969 |
+
"wa_reg_error": "Error during registration.",
|
| 970 |
+
"wa_net_error": "Network error.",
|
| 971 |
+
"wa_otp_hint": "Meta sent you an OTP code by SMS or voice call. Enter it below.",
|
| 972 |
+
"label_otp": "OTP code",
|
| 973 |
+
"wa_otp_error": "OTP code must be between 4 and 8 digits.",
|
| 974 |
+
"wa_verifying": "Verifying...",
|
| 975 |
+
"wa_verify": "Verify",
|
| 976 |
+
"wa_otp_invalid": "Invalid or expired OTP code.",
|
| 977 |
+
"wa_number_registered": "Number registered successfully!",
|
| 978 |
+
"wa_back": "Back",
|
| 979 |
+
"err_load_numbers": "Error loading numbers",
|
| 980 |
+
|
| 981 |
+
"tpl_title": "WhatsApp Templates",
|
| 982 |
+
"tpl_orgs_count_one": "{{count}} organization with WhatsApp configured",
|
| 983 |
+
"tpl_orgs_count_other": "{{count}} organizations with WhatsApp configured",
|
| 984 |
+
"tpl_select_hint": "Select an organization to manage its WhatsApp message templates.",
|
| 985 |
+
"tpl_search_placeholder": "Search an organization or WABA ID...",
|
| 986 |
+
"tpl_empty": "No organization with WhatsApp configured",
|
| 987 |
+
"col_waba_id": "WABA ID",
|
| 988 |
+
"col_actions": "Actions",
|
| 989 |
+
"tpl_view": "View templates",
|
| 990 |
+
"tpl_no_templates": "No templates found for this organization.",
|
| 991 |
+
"col_category": "Category",
|
| 992 |
+
"col_language": "Language",
|
| 993 |
+
"tpl_create_btn": "Create template",
|
| 994 |
+
"tpl_create_title": "New WhatsApp template",
|
| 995 |
+
"label_template_name": "Template name",
|
| 996 |
+
"template_name_hint": "Lowercase, digits, underscores only",
|
| 997 |
+
"template_name_error": "Lowercase, digits, underscores only",
|
| 998 |
+
"label_category": "Category",
|
| 999 |
+
"label_language": "Language",
|
| 1000 |
+
"label_header_optional": "Header (optional)",
|
| 1001 |
+
"label_body": "Message body",
|
| 1002 |
+
"label_footer_optional": "Footer (optional)",
|
| 1003 |
+
"label_preview": "Preview",
|
| 1004 |
+
"body_placeholder": "Message body...",
|
| 1005 |
+
"tpl_select_org_placeholder": "Select an organization…",
|
| 1006 |
+
"tpl_submitted": "Template submitted to Meta for approval",
|
| 1007 |
+
"tpl_create_required": "Organization, name and message body are required.",
|
| 1008 |
+
"tpl_name_invalid": "Template name is invalid.",
|
| 1009 |
+
"tpl_creating": "Creating…",
|
| 1010 |
+
"tpl_create_submit": "Create template",
|
| 1011 |
+
"tpl_create_error": "Error creating the template.",
|
| 1012 |
+
|
| 1013 |
+
"profiles_title": "WhatsApp Profiles",
|
| 1014 |
+
"profiles_count_one": "{{count}} profile",
|
| 1015 |
+
"profiles_count_other": "{{count}} profiles",
|
| 1016 |
+
"profile_empty": "No WhatsApp profile found",
|
| 1017 |
+
"label_org_name": "Organization name",
|
| 1018 |
+
"label_logo_url": "Logo URL",
|
| 1019 |
+
"label_primary_color": "Primary color",
|
| 1020 |
+
"profile_updated": "Profile updated",
|
| 1021 |
+
"err_load_profiles": "Error loading profiles",
|
| 1022 |
+
"err_save_profile": "Error saving",
|
| 1023 |
+
"btn_cancel_edit": "Cancel",
|
| 1024 |
+
"btn_save": "Save",
|
| 1025 |
+
"saving_profile": "Saving…",
|
| 1026 |
+
"btn_edit_profile": "Edit",
|
| 1027 |
+
|
| 1028 |
+
"monitoring_title": "Monitoring & Alerts",
|
| 1029 |
+
"monitoring_subtitle": "Real-time system status",
|
| 1030 |
+
"system_health_title": "System health",
|
| 1031 |
+
"health_redis_cache": "Redis / Cache",
|
| 1032 |
+
"health_queue_jobs": "Queue jobs",
|
| 1033 |
+
"queue_failed_detail": "{{count}} failed",
|
| 1034 |
+
"queue_waiting_detail": "{{count}} waiting",
|
| 1035 |
+
"token_expiry_title": "Expiring WhatsApp tokens",
|
| 1036 |
+
"token_no_risk": "No token at risk of expiration",
|
| 1037 |
+
"col_org": "Organization",
|
| 1038 |
+
"col_issued_ago": "Issued",
|
| 1039 |
+
"col_days": "{{count}} days",
|
| 1040 |
+
"low_balance_title": "Low balances (< 100 credits)",
|
| 1041 |
+
"low_balance_none": "No organization with low balance",
|
| 1042 |
+
"credits_label": "{{count}} credits",
|
| 1043 |
+
"err_load_monitoring": "Error loading monitoring",
|
| 1044 |
+
|
| 1045 |
+
"billing_title": "Billing",
|
| 1046 |
+
"billing_transactions": "{{count}} transactions",
|
| 1047 |
+
"billing_add_credits": "Add credits",
|
| 1048 |
+
"billing_no_transactions": "No transactions",
|
| 1049 |
+
"col_date": "Date",
|
| 1050 |
+
"col_type": "Type",
|
| 1051 |
+
"col_description": "Description",
|
| 1052 |
+
"col_amount": "Amount",
|
| 1053 |
+
"col_balance_after": "Balance after",
|
| 1054 |
+
"credits_added": "{{amount}} credits added. New balance: {{balance}}",
|
| 1055 |
+
"modal_add_credits": "Add credits",
|
| 1056 |
+
"label_org_id": "Organization ID",
|
| 1057 |
+
"org_id_placeholder": "org-uuid...",
|
| 1058 |
+
"label_credits_amount": "Amount (credits)",
|
| 1059 |
+
"label_description_optional": "Description (optional)",
|
| 1060 |
+
"credits_desc_placeholder": "Manual top-up...",
|
| 1061 |
+
"adding": "Adding...",
|
| 1062 |
+
"add": "Add",
|
| 1063 |
+
"err_load_transactions": "Error loading transactions",
|
| 1064 |
+
"err_add_credits": "Error adding credits",
|
| 1065 |
+
|
| 1066 |
+
"audit_title": "Audit Logs",
|
| 1067 |
+
"audit_total": "{{count}} entries total",
|
| 1068 |
+
"col_datetime": "Date / Time",
|
| 1069 |
+
"col_action": "Action",
|
| 1070 |
+
"col_actor": "Actor",
|
| 1071 |
+
"col_resource": "Resource",
|
| 1072 |
+
"col_details": "Details",
|
| 1073 |
+
"col_from": "From",
|
| 1074 |
+
"col_to": "To",
|
| 1075 |
+
"audit_search": "Search",
|
| 1076 |
+
"audit_empty": "No logs for these criteria",
|
| 1077 |
+
"audit_show": "View",
|
| 1078 |
+
"audit_hide": "Hide",
|
| 1079 |
+
|
| 1080 |
+
"ai_title": "AI Insights",
|
| 1081 |
+
"ai_subtitle": "Natural language commands to manage the platform",
|
| 1082 |
+
"ai_suggestion_1": "Show me organizations with a low balance",
|
| 1083 |
+
"ai_suggestion_2": "What are the platform statistics?",
|
| 1084 |
+
"ai_suggestion_3": "List the active alerts",
|
| 1085 |
+
"ai_suggestion_4": "Show the 10 latest organizations",
|
| 1086 |
+
"ai_greeting": "Hello! I am your AI assistant for managing the XAMLÉ platform. Ask me a question or give me an instruction.",
|
| 1087 |
+
"ai_input_placeholder": "Type a command... e.g. 'Add 500 credits to org XAMLÉ'",
|
| 1088 |
+
"ai_confirm": "Confirm",
|
| 1089 |
+
"ai_cancelled": "Action cancelled.",
|
| 1090 |
+
"ai_err": "AI error",
|
| 1091 |
+
"ai_err_exec": "Execution error",
|
| 1092 |
+
"ai_err_message": "Sorry, an error occurred.",
|
| 1093 |
+
"ai_err_exec_message": "Error during execution.",
|
| 1094 |
+
"ai_done": "Action completed.",
|
| 1095 |
+
"ai_no_result": "No results found."
|
| 1096 |
}
|
| 1097 |
}
|
apps/admin/src/locales/fr.json
CHANGED
|
@@ -35,7 +35,8 @@
|
|
| 35 |
"select_org": "Veuillez sélectionner une organisation",
|
| 36 |
"clear_filter": "Effacer le filtre",
|
| 37 |
"no_data": "Aucune donnée disponible",
|
| 38 |
-
"retry": "Réessayer"
|
|
|
|
| 39 |
},
|
| 40 |
"nav": {
|
| 41 |
"home": "Accueil",
|
|
@@ -103,7 +104,26 @@
|
|
| 103 |
"engagement": {
|
| 104 |
"title": "Engagement",
|
| 105 |
"avg_days": "Jours de formation en moyenne"
|
| 106 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
},
|
| 108 |
"users": {
|
| 109 |
"title": "Gestion des Utilisateurs",
|
|
@@ -117,7 +137,21 @@
|
|
| 117 |
"day": "Jour",
|
| 118 |
"status": "Statut",
|
| 119 |
"joined": "Inscription"
|
| 120 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
},
|
| 122 |
"contacts": {
|
| 123 |
"title": "Contacts",
|
|
@@ -125,7 +159,23 @@
|
|
| 125 |
"add": "Ajouter un contact",
|
| 126 |
"import": "Importer",
|
| 127 |
"no_contacts": "Aucun contact",
|
| 128 |
-
"search_placeholder": "Rechercher un contact..."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
},
|
| 130 |
"settings": {
|
| 131 |
"title": "Paramètres",
|
|
@@ -153,7 +203,20 @@
|
|
| 153 |
"token_expired_alert": "Votre token WhatsApp est invalide ou expiré. Les messages ne peuvent plus être envoyés. Rendez-vous dans Meta Business Manager → Utilisateurs système pour générer un nouveau token, puis mettez à jour votre organisation.",
|
| 154 |
"api_keys_title": "Clés API Intelligence",
|
| 155 |
"api_keys_locked": "Disponible à partir du plan SCALE. En ajoutant vos propres clés OpenAI ou Google, vous utilisez votre propre quota IA — sans limite liée à votre plan Xamlé.",
|
| 156 |
-
"api_keys_unlocked": "Vos clés sont chiffrées et stockées de façon sécurisée. Elles remplacent les clés partagées de la plateforme — votre consommation IA n'est alors plus décomptée de votre solde de crédits."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
},
|
| 158 |
"auth": {
|
| 159 |
"org_id": "ID Organisation",
|
|
@@ -181,7 +244,11 @@
|
|
| 181 |
"reset_set_button": "Définir le mot de passe",
|
| 182 |
"reset_setting": "Mise à jour...",
|
| 183 |
"reset_login_link": "Se connecter",
|
| 184 |
-
"reset_back": "Retour à la connexion"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
},
|
| 186 |
"onboarding": {
|
| 187 |
"title": "Bienvenue sur Xamlé.Studio",
|
|
@@ -209,7 +276,35 @@
|
|
| 209 |
"token_idle_hint": "Générez un token \"Ne jamais expirer\" dans Meta Business Manager → Utilisateurs système.",
|
| 210 |
"skip_whatsapp": "Configurer WhatsApp plus tard",
|
| 211 |
"create_org": "Créer l'organisation",
|
| 212 |
-
"creating": "Création…"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
},
|
| 214 |
"crm": {
|
| 215 |
"stats": {
|
|
@@ -298,7 +393,9 @@
|
|
| 298 |
"status_pending": "En attente",
|
| 299 |
"status_rejected": "Rejeté",
|
| 300 |
"status_paused": "En pause",
|
| 301 |
-
"status_disabled": "Désactivé"
|
|
|
|
|
|
|
| 302 |
}
|
| 303 |
},
|
| 304 |
"knowledge": {
|
|
@@ -311,11 +408,25 @@
|
|
| 311 |
"no_documents": "Aucun chunk trouvé.",
|
| 312 |
"import_hint": "Importez un document dans l'onglet Agent IA pour commencer.",
|
| 313 |
"confirm_delete": "Supprimer ce chunk de la base de connaissances ?",
|
| 314 |
-
"delete_error": "Échec de la suppression"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
},
|
| 316 |
"training": {
|
| 317 |
"title": "Training Lab",
|
| 318 |
-
"subtitle": "Testez et affinez votre IA pédagogique"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
},
|
| 320 |
"ai_setup": {
|
| 321 |
"title": "Configuration de l'Agent IA",
|
|
@@ -372,7 +483,11 @@
|
|
| 372 |
"words_covered": "Mots couverts",
|
| 373 |
"bot_name_label": "Nom de l'agent",
|
| 374 |
"bot_name_placeholder": "Ex: Kora, Awa, SupportBot...",
|
| 375 |
-
"bot_name_hint": "Le prénom que votre agent utilisera pour se présenter sur WhatsApp."
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
},
|
| 377 |
"tracks": {
|
| 378 |
"title": "Parcours",
|
|
@@ -382,7 +497,48 @@
|
|
| 382 |
"days": "jours",
|
| 383 |
"enrolled": "inscrits",
|
| 384 |
"days_label": "Jours",
|
| 385 |
-
"no_days": "Aucun jour créé."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
},
|
| 387 |
"campaigns": {
|
| 388 |
"title": "Historique des Campagnes",
|
|
@@ -690,5 +846,252 @@
|
|
| 690 |
"general_q2": "Quelle est la différence entre les modes ?",
|
| 691 |
"general_q3": "Comment fonctionne la facturation ?",
|
| 692 |
"general_q4": "Où voir mes statistiques ?"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 693 |
}
|
| 694 |
}
|
|
|
|
| 35 |
"select_org": "Veuillez sélectionner une organisation",
|
| 36 |
"clear_filter": "Effacer le filtre",
|
| 37 |
"no_data": "Aucune donnée disponible",
|
| 38 |
+
"retry": "Réessayer",
|
| 39 |
+
"of": "sur"
|
| 40 |
},
|
| 41 |
"nav": {
|
| 42 |
"home": "Accueil",
|
|
|
|
| 104 |
"engagement": {
|
| 105 |
"title": "Engagement",
|
| 106 |
"avg_days": "Jours de formation en moyenne"
|
| 107 |
+
},
|
| 108 |
+
"sql_error": "Erreur lors de l'exécution de la requête",
|
| 109 |
+
"sql_example_1": "Combien d'utilisateurs actifs cette semaine ?",
|
| 110 |
+
"sql_example_2": "Quels sont les 5 utilisateurs avec le plus de messages ?",
|
| 111 |
+
"sql_example_3": "Quel est le taux de complétion moyen ?",
|
| 112 |
+
"sql_example_4": "Combien de crédits IA ont été consommés ce mois ?",
|
| 113 |
+
"ai_cost_title": "Coût IA par fonctionnalité",
|
| 114 |
+
"ai_cost_subtitle": "Données réelles — source : UsageEvent",
|
| 115 |
+
"col_feature": "Fonctionnalité",
|
| 116 |
+
"col_calls": "Appels",
|
| 117 |
+
"col_tokens_in": "Tokens in",
|
| 118 |
+
"col_tokens_out": "Tokens out",
|
| 119 |
+
"col_cost": "Coût (USD)",
|
| 120 |
+
"total": "Total",
|
| 121 |
+
"nl_search_title": "Recherche en langage naturel",
|
| 122 |
+
"nl_search_subtitle": "Posez une question sur vos données — l'IA génère la requête SQL",
|
| 123 |
+
"nl_search_placeholder": "Ex : Quels sont les utilisateurs inactifs depuis 7 jours ?",
|
| 124 |
+
"search_btn": "Rechercher",
|
| 125 |
+
"view_sql": "Voir SQL",
|
| 126 |
+
"no_results": "Aucun résultat"
|
| 127 |
},
|
| 128 |
"users": {
|
| 129 |
"title": "Gestion des Utilisateurs",
|
|
|
|
| 137 |
"day": "Jour",
|
| 138 |
"status": "Statut",
|
| 139 |
"joined": "Inscription"
|
| 140 |
+
},
|
| 141 |
+
"confirm_delete": "Supprimer cet utilisateur ? Cette action est réversible côté base de données.",
|
| 142 |
+
"delete_success": "Utilisateur supprimé",
|
| 143 |
+
"delete_error": "Échec de la suppression",
|
| 144 |
+
"handoff_released": "Handoff libéré — l'IA reprend la conversation",
|
| 145 |
+
"handoff_none": "Aucun handoff actif pour cet utilisateur",
|
| 146 |
+
"handoff_error": "Échec",
|
| 147 |
+
"load_error": "Erreur chargement utilisateurs",
|
| 148 |
+
"language_column": "Langue",
|
| 149 |
+
"sector_column": "Secteur",
|
| 150 |
+
"conversation_btn": "Conversation",
|
| 151 |
+
"delete_title": "Supprimer l'utilisateur",
|
| 152 |
+
"handoff_active": "Handoff actif",
|
| 153 |
+
"release_ai": "Libérer l'IA",
|
| 154 |
+
"prev": "Précédent"
|
| 155 |
},
|
| 156 |
"contacts": {
|
| 157 |
"title": "Contacts",
|
|
|
|
| 159 |
"add": "Ajouter un contact",
|
| 160 |
"import": "Importer",
|
| 161 |
"no_contacts": "Aucun contact",
|
| 162 |
+
"search_placeholder": "Rechercher un contact...",
|
| 163 |
+
"tags_update_error": "Échec de la mise à jour des tags",
|
| 164 |
+
"import_success": "Import réussi : {{created}} ajoutés, {{updated}} mis à jour, {{errors}} erreurs.",
|
| 165 |
+
"upload_critical_error": "Une erreur critique est survenue lors de l'upload.",
|
| 166 |
+
"ai_generation_error": "Échec de la génération IA.",
|
| 167 |
+
"generation_error_fallback": "Erreur de génération",
|
| 168 |
+
"confirm_delete_one": "Voulez-vous vraiment supprimer ce contact ?",
|
| 169 |
+
"delete_error": "Erreur lors de la suppression.",
|
| 170 |
+
"confirm_delete_many": "Voulez-vous vraiment supprimer {{count}} contacts ?",
|
| 171 |
+
"bulk_delete_success": "Contacts supprimés avec succès.",
|
| 172 |
+
"bulk_delete_error": "Erreur lors de la suppression groupée.",
|
| 173 |
+
"csv_name": "Nom",
|
| 174 |
+
"csv_phone": "Téléphone",
|
| 175 |
+
"csv_created": "Créé le",
|
| 176 |
+
"message_copied": "Message copié !",
|
| 177 |
+
"copied": "Copié !",
|
| 178 |
+
"generation_completed": "Génération terminée avec succès"
|
| 179 |
},
|
| 180 |
"settings": {
|
| 181 |
"title": "Paramètres",
|
|
|
|
| 203 |
"token_expired_alert": "Votre token WhatsApp est invalide ou expiré. Les messages ne peuvent plus être envoyés. Rendez-vous dans Meta Business Manager → Utilisateurs système pour générer un nouveau token, puis mettez à jour votre organisation.",
|
| 204 |
"api_keys_title": "Clés API Intelligence",
|
| 205 |
"api_keys_locked": "Disponible à partir du plan SCALE. En ajoutant vos propres clés OpenAI ou Google, vous utilisez votre propre quota IA — sans limite liée à votre plan Xamlé.",
|
| 206 |
+
"api_keys_unlocked": "Vos clés sont chiffrées et stockées de façon sécurisée. Elles remplacent les clés partagées de la plateforme — votre consommation IA n'est alors plus décomptée de votre solde de crédits.",
|
| 207 |
+
"wa_connect_success": "WhatsApp connecté avec succès ✅",
|
| 208 |
+
"wa_connect_error": "Échec de la connexion WhatsApp. Vérifie le token et le WABA ID.",
|
| 209 |
+
"mode_edtech": "EdTech",
|
| 210 |
+
"mode_webhook": "Webhook",
|
| 211 |
+
"mode_ai_agent": "Agent IA",
|
| 212 |
+
"mode_pedagogy": "Pédagogie",
|
| 213 |
+
"mode_customer_service": "Support Client",
|
| 214 |
+
"mode_crm_marketing": "CRM & Campagnes",
|
| 215 |
+
"wa_cancel": "Annuler",
|
| 216 |
+
"wa_reconfigure": "🔄 Reconfigurer",
|
| 217 |
+
"wa_connect_btn": "🔗 Connecter",
|
| 218 |
+
"wa_connecting": "Connexion...",
|
| 219 |
+
"wa_connect_submit": "Connecter WhatsApp"
|
| 220 |
},
|
| 221 |
"auth": {
|
| 222 |
"org_id": "ID Organisation",
|
|
|
|
| 244 |
"reset_set_button": "Définir le mot de passe",
|
| 245 |
"reset_setting": "Mise à jour...",
|
| 246 |
"reset_login_link": "Se connecter",
|
| 247 |
+
"reset_back": "Retour à la connexion",
|
| 248 |
+
"reset_network_error": "Erreur réseau. Veuillez réessayer.",
|
| 249 |
+
"reset_password_mismatch": "Les mots de passe ne correspondent pas.",
|
| 250 |
+
"reset_password_min_length": "Le mot de passe doit contenir au moins 6 caractères.",
|
| 251 |
+
"reset_token_expired": "Token invalide ou expiré."
|
| 252 |
},
|
| 253 |
"onboarding": {
|
| 254 |
"title": "Bienvenue sur Xamlé.Studio",
|
|
|
|
| 276 |
"token_idle_hint": "Générez un token \"Ne jamais expirer\" dans Meta Business Manager → Utilisateurs système.",
|
| 277 |
"skip_whatsapp": "Configurer WhatsApp plus tard",
|
| 278 |
"create_org": "Créer l'organisation",
|
| 279 |
+
"creating": "Création…",
|
| 280 |
+
"step_org": "Organisation",
|
| 281 |
+
"step_admin": "Administrateur",
|
| 282 |
+
"org_title": "L'organisation",
|
| 283 |
+
"org_subtitle": "Nom, identifiant URL et type d'usage.",
|
| 284 |
+
"org_name_label": "Nom de l'organisation",
|
| 285 |
+
"slug_label": "Identifiant URL (slug)",
|
| 286 |
+
"slug_hint": "Auto-généré depuis le nom. Modifiable — lettres minuscules, chiffres et tirets uniquement.",
|
| 287 |
+
"mode_label": "Type d'usage",
|
| 288 |
+
"mode_edtech_label": "Formation & EdTech",
|
| 289 |
+
"mode_edtech_desc": "Parcours éducatifs, exercices, suivi des apprenants via WhatsApp",
|
| 290 |
+
"mode_crm_label": "CRM & Campagnes",
|
| 291 |
+
"mode_crm_desc": "Gestion de contacts, campagnes broadcast, relances marketing",
|
| 292 |
+
"mode_ai_label": "Agent IA",
|
| 293 |
+
"mode_ai_desc": "Bot conversationnel IA pour répondre en autonomie 24h/24",
|
| 294 |
+
"mode_customer_service_label": "Support Client",
|
| 295 |
+
"mode_customer_service_desc": "Gestion des conversations entrantes et escalade vers un agent humain",
|
| 296 |
+
"admin_title": "L'administrateur",
|
| 297 |
+
"admin_subtitle": "Le premier compte admin de cette organisation.",
|
| 298 |
+
"admin_name_label": "Nom complet",
|
| 299 |
+
"admin_email_label": "Email",
|
| 300 |
+
"admin_pass_label": "Mot de passe initial",
|
| 301 |
+
"admin_pass_optional": "optionnel — généré automatiquement si vide",
|
| 302 |
+
"admin_pass_placeholder": "Laissez vide pour auto-générer",
|
| 303 |
+
"admin_pass_hint": "Un email avec le mot de passe temporaire est envoyé à l'admin après création.",
|
| 304 |
+
"wa_title": "Connexion WhatsApp",
|
| 305 |
+
"wa_subtitle": "Optionnel — peut être configuré plus tard depuis la fiche organisation.",
|
| 306 |
+
"create_error": "Création impossible",
|
| 307 |
+
"fb_error": "La connexion Facebook a été annulée ou a échoué."
|
| 308 |
},
|
| 309 |
"crm": {
|
| 310 |
"stats": {
|
|
|
|
| 393 |
"status_pending": "En attente",
|
| 394 |
"status_rejected": "Rejeté",
|
| 395 |
"status_paused": "En pause",
|
| 396 |
+
"status_disabled": "Désactivé",
|
| 397 |
+
"waba_not_configured": "WhatsApp Business non configuré",
|
| 398 |
+
"waba_not_configured_desc": "Cette organisation n'a pas encore de compte WhatsApp Business (WABA) associé. Rendez-vous dans Paramètres → Intégration WhatsApp pour configurer votre numéro et votre WABA ID."
|
| 399 |
}
|
| 400 |
},
|
| 401 |
"knowledge": {
|
|
|
|
| 408 |
"no_documents": "Aucun chunk trouvé.",
|
| 409 |
"import_hint": "Importez un document dans l'onglet Agent IA pour commencer.",
|
| 410 |
"confirm_delete": "Supprimer ce chunk de la base de connaissances ?",
|
| 411 |
+
"delete_error": "Échec de la suppression",
|
| 412 |
+
"reindex_success": "Réindexation lancée avec succès",
|
| 413 |
+
"no_kb_url": "Aucune URL de base de connaissances configurée. Ajoutez une URL dans Paramètres.",
|
| 414 |
+
"reindex_error": "Échec de la réindexation",
|
| 415 |
+
"generate_error": "Échec de la génération",
|
| 416 |
+
"generate_from_desc": "Générer depuis une description",
|
| 417 |
+
"generate_placeholder": "Décrivez votre activité, vos produits ou services… L'IA génèrera automatiquement une FAQ et l'indexera dans la base de connaissances.",
|
| 418 |
+
"generating_btn": "Génération en cours…",
|
| 419 |
+
"generate_btn": "Générer",
|
| 420 |
+
"generate_success_count": "{{count}} Q&A générées et indexées",
|
| 421 |
+
"generate_result_summary": "{{count}} Q&R générées · {{chunks}} chunks indexés"
|
| 422 |
},
|
| 423 |
"training": {
|
| 424 |
"title": "Training Lab",
|
| 425 |
+
"subtitle": "Testez et affinez votre IA pédagogique",
|
| 426 |
+
"rules_injected": "Succès ! {{count}} règles ont été injectées dans le dictionnaire.",
|
| 427 |
+
"inject_rules": "Injecter ({{count}}) Règles",
|
| 428 |
+
"ground_truth_label": "Vérité Terrain (Ground Truth)",
|
| 429 |
+
"training_saved": "Entraînement enregistré !"
|
| 430 |
},
|
| 431 |
"ai_setup": {
|
| 432 |
"title": "Configuration de l'Agent IA",
|
|
|
|
| 483 |
"words_covered": "Mots couverts",
|
| 484 |
"bot_name_label": "Nom de l'agent",
|
| 485 |
"bot_name_placeholder": "Ex: Kora, Awa, SupportBot...",
|
| 486 |
+
"bot_name_hint": "Le prénom que votre agent utilisera pour se présenter sur WhatsApp.",
|
| 487 |
+
"tone_professional": "Professionnel",
|
| 488 |
+
"tone_friendly": "Amical",
|
| 489 |
+
"tone_direct": "Direct",
|
| 490 |
+
"tone_pedagogical": "Pédagogue"
|
| 491 |
},
|
| 492 |
"tracks": {
|
| 493 |
"title": "Parcours",
|
|
|
|
| 497 |
"days": "jours",
|
| 498 |
"enrolled": "inscrits",
|
| 499 |
"days_label": "Jours",
|
| 500 |
+
"no_days": "Aucun jour créé.",
|
| 501 |
+
"edit_day": "Modifier Jour",
|
| 502 |
+
"new_day": "Nouveau jour",
|
| 503 |
+
"day_number": "Numéro du jour",
|
| 504 |
+
"day_title": "Titre",
|
| 505 |
+
"lesson_text": "Texte de la leçon",
|
| 506 |
+
"lesson_placeholder": "Contenu pédagogique...",
|
| 507 |
+
"audio_url": "URL Audio (optionnel)",
|
| 508 |
+
"exercise_type": "Type exercice",
|
| 509 |
+
"exercise_type_text": "Texte libre",
|
| 510 |
+
"exercise_type_audio": "Audio",
|
| 511 |
+
"exercise_type_button": "Boutons",
|
| 512 |
+
"validation_keyword": "Mot-clé validation",
|
| 513 |
+
"exercise_prompt": "Prompt exercice",
|
| 514 |
+
"exercise_prompt_placeholder": "Question posée à l'étudiant...",
|
| 515 |
+
"no_lesson_text": "Pas de texte",
|
| 516 |
+
"form_title_label": "Titre",
|
| 517 |
+
"form_description": "Description",
|
| 518 |
+
"form_duration": "Durée (jours)",
|
| 519 |
+
"form_language": "Langue",
|
| 520 |
+
"form_lang_fr": "Français",
|
| 521 |
+
"form_lang_wolof": "Wolof",
|
| 522 |
+
"form_premium": "Formation Premium (payante)",
|
| 523 |
+
"form_price": "Prix (XOF)",
|
| 524 |
+
"ai_generate_btn": "Générer avec IA",
|
| 525 |
+
"ai_generate_first": "Générer votre premier programme avec IA",
|
| 526 |
+
"ai_modal_badge": "Agent Créateur de Contenu",
|
| 527 |
+
"ai_modal_title": "Générer un programme",
|
| 528 |
+
"ai_modal_subtitle": "L'IA crée tout le curriculum en quelques secondes",
|
| 529 |
+
"ai_description_label": "Description du programme",
|
| 530 |
+
"ai_description_placeholder": "Ex: Formation de 5 jours sur les bases du marketing digital pour les PME au Sénégal...",
|
| 531 |
+
"ai_num_days": "Nombre de jours",
|
| 532 |
+
"ai_language": "Langue",
|
| 533 |
+
"ai_lang_fr": "Français",
|
| 534 |
+
"ai_lang_en": "Anglais",
|
| 535 |
+
"ai_lang_wol": "Wolof",
|
| 536 |
+
"ai_audience": "Public cible",
|
| 537 |
+
"ai_audience_optional": "optionnel",
|
| 538 |
+
"ai_audience_placeholder": "Ex: Entrepreneurs débutants, femmes rurales, lycéens...",
|
| 539 |
+
"ai_generating": "Génération en cours... (15-30s)",
|
| 540 |
+
"ai_generate_submit": "Générer le programme",
|
| 541 |
+
"ai_error": "Échec de la génération IA"
|
| 542 |
},
|
| 543 |
"campaigns": {
|
| 544 |
"title": "Historique des Campagnes",
|
|
|
|
| 846 |
"general_q2": "Quelle est la différence entre les modes ?",
|
| 847 |
"general_q3": "Comment fonctionne la facturation ?",
|
| 848 |
"general_q4": "Où voir mes statistiques ?"
|
| 849 |
+
},
|
| 850 |
+
"super_admin": {
|
| 851 |
+
"nav_dashboard": "Dashboard",
|
| 852 |
+
"nav_organizations": "Organisations",
|
| 853 |
+
"nav_users": "Utilisateurs",
|
| 854 |
+
"nav_whatsapp": "Numéros WA",
|
| 855 |
+
"nav_templates": "Templates WA",
|
| 856 |
+
"nav_profiles": "Profils WA",
|
| 857 |
+
"nav_monitoring": "Monitoring",
|
| 858 |
+
"nav_billing": "Billing",
|
| 859 |
+
"nav_ai": "AI Insights",
|
| 860 |
+
"nav_audit_logs": "Audit Logs",
|
| 861 |
+
"exit_admin": "Quitter l'admin",
|
| 862 |
+
"logout": "Déconnexion",
|
| 863 |
+
"system_active": "Système actif",
|
| 864 |
+
"platform_admin": "Platform Admin",
|
| 865 |
+
"super_admin_label": "Super-admin",
|
| 866 |
+
|
| 867 |
+
"dashboard_title": "Platform Dashboard",
|
| 868 |
+
"dashboard_subtitle": "Vue globale de toutes les organisations",
|
| 869 |
+
"kpi_organizations": "Organisations",
|
| 870 |
+
"kpi_orgs_active": "{{count}} actives",
|
| 871 |
+
"kpi_users": "Utilisateurs",
|
| 872 |
+
"kpi_messages_24h": "Messages / 24h",
|
| 873 |
+
"kpi_queue_depth": "Queue depth",
|
| 874 |
+
"kpi_queue_failed": "{{count}} échoués",
|
| 875 |
+
"kpi_revenue": "Revenus / mois",
|
| 876 |
+
"kpi_alerts": "Alertes",
|
| 877 |
+
"system_health": "Santé système",
|
| 878 |
+
"health_db": "Base de données",
|
| 879 |
+
"health_redis": "Redis",
|
| 880 |
+
"health_queue": "Queue",
|
| 881 |
+
"active_alerts": "Alertes actives ({{count}})",
|
| 882 |
+
"alert_token": "Token expirant — {{orgName}} ({{daysOld}}j)",
|
| 883 |
+
"alert_balance": "Solde faible — {{orgName}}: {{balance}} crédits",
|
| 884 |
+
"alert_queue_failed": "{{count}} jobs échoués en queue",
|
| 885 |
+
|
| 886 |
+
"orgs_title": "Organisations",
|
| 887 |
+
"orgs_total": "{{count}} organisations au total",
|
| 888 |
+
"org_new": "Nouvelle organisation",
|
| 889 |
+
"org_search_placeholder": "Rechercher une organisation...",
|
| 890 |
+
"org_search_btn": "Rechercher",
|
| 891 |
+
"org_loading": "Chargement...",
|
| 892 |
+
"org_empty": "Aucune organisation trouvée",
|
| 893 |
+
"col_name": "Nom",
|
| 894 |
+
"col_plan": "Plan",
|
| 895 |
+
"col_status": "Statut",
|
| 896 |
+
"col_users": "Utilisateurs",
|
| 897 |
+
"col_credits": "Crédits",
|
| 898 |
+
"status_suspended": "Suspendu",
|
| 899 |
+
"status_trial": "Trial",
|
| 900 |
+
"status_active": "Actif",
|
| 901 |
+
"org_reactivated": "Organisation réactivée",
|
| 902 |
+
"org_suspended": "Organisation suspendue",
|
| 903 |
+
"org_delete_confirm": "Supprimer définitivement \"{{name}}\" ? Cette action ne peut pas être annulée.",
|
| 904 |
+
"org_deleted": "\"{{name}}\" supprimée",
|
| 905 |
+
"org_updated": "Organisation mise à jour",
|
| 906 |
+
"org_created": "Organisation créée",
|
| 907 |
+
"label_plan": "Plan",
|
| 908 |
+
"label_ai_credits": "Limite crédits AI",
|
| 909 |
+
"label_crm_active": "CRM actif",
|
| 910 |
+
"label_edtech_active": "EdTech actif",
|
| 911 |
+
"saving": "Sauvegarde...",
|
| 912 |
+
"save": "Sauvegarder",
|
| 913 |
+
"modal_new_org": "Nouvelle organisation",
|
| 914 |
+
"org_name_placeholder": "Nom de l'organisation",
|
| 915 |
+
"cancel": "Annuler",
|
| 916 |
+
"creating": "Création...",
|
| 917 |
+
"create": "Créer",
|
| 918 |
+
"err_load_orgs": "Erreur chargement organisations",
|
| 919 |
+
"err_suspend": "Erreur modification statut",
|
| 920 |
+
"err_delete": "Erreur suppression",
|
| 921 |
+
"err_update": "Erreur mise à jour",
|
| 922 |
+
"err_create": "Erreur création",
|
| 923 |
+
"btn_edit": "Modifier",
|
| 924 |
+
"btn_reactivate": "Réactiver",
|
| 925 |
+
"btn_suspend": "Suspendre",
|
| 926 |
+
"btn_delete_forever": "Supprimer définitivement",
|
| 927 |
+
"pagination_info": "{{from}}–{{to}} sur {{total}}",
|
| 928 |
+
"prev": "Précédent",
|
| 929 |
+
"next": "Suivant",
|
| 930 |
+
|
| 931 |
+
"users_title": "Utilisateurs",
|
| 932 |
+
"users_total": "{{count}} utilisateurs au total",
|
| 933 |
+
"user_search_placeholder": "Rechercher par nom ou email...",
|
| 934 |
+
"col_user": "Utilisateur",
|
| 935 |
+
"col_organization": "Organisation",
|
| 936 |
+
"col_role": "Rôle",
|
| 937 |
+
"col_created_at": "Créé le",
|
| 938 |
+
"user_empty": "Aucun utilisateur trouvé",
|
| 939 |
+
"role_updated": "Rôle mis à jour",
|
| 940 |
+
"btn_reset_password": "Réinitialiser le mot de passe",
|
| 941 |
+
"reset_no_email": "Cet utilisateur n'a pas d'adresse email",
|
| 942 |
+
"reset_confirm": "Envoyer un lien de réinitialisation à {{email}} ?",
|
| 943 |
+
"reset_sent": "Lien envoyé à {{email}}",
|
| 944 |
+
"err_load_users": "Erreur chargement utilisateurs",
|
| 945 |
+
"err_role_change": "Erreur changement de rôle",
|
| 946 |
+
"err_reset_password": "Erreur envoi email",
|
| 947 |
+
|
| 948 |
+
"wa_numbers_title": "Numéros WhatsApp",
|
| 949 |
+
"wa_numbers_total": "{{count}} numéros enregistrés",
|
| 950 |
+
"wa_refresh": "Actualiser",
|
| 951 |
+
"wa_register_number": "Enregistrer un numéro",
|
| 952 |
+
"wa_no_numbers": "Aucun numéro WhatsApp enregistré",
|
| 953 |
+
"col_number": "Numéro",
|
| 954 |
+
"col_id": "ID",
|
| 955 |
+
"col_added_at": "Ajouté le",
|
| 956 |
+
"wa_register_title": "Enregistrer un numéro WhatsApp",
|
| 957 |
+
"wa_step": "Étape {{step}}/2",
|
| 958 |
+
"label_org": "Organisation",
|
| 959 |
+
"org_select_placeholder": "Sélectionner une organisation...",
|
| 960 |
+
"label_phone_number_id": "Phone Number ID",
|
| 961 |
+
"phone_id_hint": "Trouvez cet ID dans Meta Business Manager > WhatsApp Accounts",
|
| 962 |
+
"label_pin": "PIN de sécurité",
|
| 963 |
+
"pin_hint": "PIN à 6 chiffres pour sécuriser le numéro (laisser vide = 000000)",
|
| 964 |
+
"wa_select_org_error": "Veuillez sélectionner une organisation.",
|
| 965 |
+
"wa_phone_id_error": "Le Phone Number ID doit contenir entre 12 et 18 chiffres.",
|
| 966 |
+
"wa_pin_error": "Le PIN doit contenir exactement 6 chiffres.",
|
| 967 |
+
"wa_sending": "Envoi en cours...",
|
| 968 |
+
"wa_send_otp": "Envoyer le code OTP",
|
| 969 |
+
"wa_reg_error": "Erreur lors de l'enregistrement.",
|
| 970 |
+
"wa_net_error": "Erreur réseau.",
|
| 971 |
+
"wa_otp_hint": "Meta vous a envoyé un code OTP par SMS ou appel vocal sur le numéro. Saisissez-le ci-dessous.",
|
| 972 |
+
"label_otp": "Code OTP",
|
| 973 |
+
"wa_otp_error": "Le code OTP doit contenir entre 4 et 8 chiffres.",
|
| 974 |
+
"wa_verifying": "Vérification...",
|
| 975 |
+
"wa_verify": "Vérifier",
|
| 976 |
+
"wa_otp_invalid": "Code OTP invalide ou expiré.",
|
| 977 |
+
"wa_number_registered": "Numéro enregistré avec succès !",
|
| 978 |
+
"wa_back": "Retour",
|
| 979 |
+
"err_load_numbers": "Erreur chargement numéros",
|
| 980 |
+
|
| 981 |
+
"tpl_title": "WhatsApp Templates",
|
| 982 |
+
"tpl_orgs_count_one": "{{count}} organisation avec WhatsApp configuré",
|
| 983 |
+
"tpl_orgs_count_other": "{{count}} organisations avec WhatsApp configuré",
|
| 984 |
+
"tpl_select_hint": "Sélectionnez une organisation pour gérer ses modèles de message WhatsApp.",
|
| 985 |
+
"tpl_search_placeholder": "Rechercher une organisation ou un WABA ID...",
|
| 986 |
+
"tpl_empty": "Aucune organisation avec WhatsApp configuré",
|
| 987 |
+
"col_waba_id": "WABA ID",
|
| 988 |
+
"col_actions": "Actions",
|
| 989 |
+
"tpl_view": "Voir templates",
|
| 990 |
+
"tpl_no_templates": "Aucun modèle trouvé pour cette organisation.",
|
| 991 |
+
"col_category": "Catégorie",
|
| 992 |
+
"col_language": "Langue",
|
| 993 |
+
"tpl_create_btn": "Créer un template",
|
| 994 |
+
"tpl_create_title": "Nouveau template WhatsApp",
|
| 995 |
+
"label_template_name": "Nom du template",
|
| 996 |
+
"template_name_hint": "Minuscules, chiffres, underscores uniquement",
|
| 997 |
+
"template_name_error": "Minuscules, chiffres, underscores uniquement",
|
| 998 |
+
"label_category": "Catégorie",
|
| 999 |
+
"label_language": "Langue",
|
| 1000 |
+
"label_header_optional": "En-tête (optionnel)",
|
| 1001 |
+
"label_body": "Corps du message",
|
| 1002 |
+
"label_footer_optional": "Pied de page (optionnel)",
|
| 1003 |
+
"label_preview": "Prévisualisation",
|
| 1004 |
+
"body_placeholder": "Corps du message...",
|
| 1005 |
+
"tpl_select_org_placeholder": "Sélectionner une organisation…",
|
| 1006 |
+
"tpl_submitted": "Template soumis à Meta pour approbation",
|
| 1007 |
+
"tpl_create_required": "Organisation, nom et corps du message sont obligatoires.",
|
| 1008 |
+
"tpl_name_invalid": "Le nom du template est invalide.",
|
| 1009 |
+
"tpl_creating": "Création…",
|
| 1010 |
+
"tpl_create_submit": "Créer le template",
|
| 1011 |
+
"tpl_create_error": "Erreur lors de la création du template.",
|
| 1012 |
+
|
| 1013 |
+
"profiles_title": "Profils WhatsApp",
|
| 1014 |
+
"profiles_count_one": "{{count}} profil",
|
| 1015 |
+
"profiles_count_other": "{{count}} profils",
|
| 1016 |
+
"profile_empty": "Aucun profil WhatsApp trouvé",
|
| 1017 |
+
"label_org_name": "Nom de l'organisation",
|
| 1018 |
+
"label_logo_url": "URL du logo",
|
| 1019 |
+
"label_primary_color": "Couleur principale",
|
| 1020 |
+
"profile_updated": "Profil mis à jour",
|
| 1021 |
+
"err_load_profiles": "Erreur chargement des profils",
|
| 1022 |
+
"err_save_profile": "Erreur lors de la sauvegarde",
|
| 1023 |
+
"btn_cancel_edit": "Annuler",
|
| 1024 |
+
"btn_save": "Enregistrer",
|
| 1025 |
+
"saving_profile": "Enregistrement…",
|
| 1026 |
+
"btn_edit_profile": "Modifier",
|
| 1027 |
+
|
| 1028 |
+
"monitoring_title": "Monitoring & Alertes",
|
| 1029 |
+
"monitoring_subtitle": "État en temps réel du système",
|
| 1030 |
+
"system_health_title": "Santé système",
|
| 1031 |
+
"health_redis_cache": "Redis / Cache",
|
| 1032 |
+
"health_queue_jobs": "Queue jobs",
|
| 1033 |
+
"queue_failed_detail": "{{count}} échoués",
|
| 1034 |
+
"queue_waiting_detail": "{{count}} en attente",
|
| 1035 |
+
"token_expiry_title": "Tokens WhatsApp expirants",
|
| 1036 |
+
"token_no_risk": "Aucun token en risque d'expiration",
|
| 1037 |
+
"col_org": "Organisation",
|
| 1038 |
+
"col_issued_ago": "Émis il y a",
|
| 1039 |
+
"col_days": "{{count}} jours",
|
| 1040 |
+
"low_balance_title": "Soldes faibles (< 100 crédits)",
|
| 1041 |
+
"low_balance_none": "Aucune organisation avec solde faible",
|
| 1042 |
+
"credits_label": "{{count}} crédits",
|
| 1043 |
+
"err_load_monitoring": "Erreur chargement monitoring",
|
| 1044 |
+
|
| 1045 |
+
"billing_title": "Billing",
|
| 1046 |
+
"billing_transactions": "{{count}} transactions",
|
| 1047 |
+
"billing_add_credits": "Ajouter crédits",
|
| 1048 |
+
"billing_no_transactions": "Aucune transaction",
|
| 1049 |
+
"col_date": "Date",
|
| 1050 |
+
"col_type": "Type",
|
| 1051 |
+
"col_description": "Description",
|
| 1052 |
+
"col_amount": "Montant",
|
| 1053 |
+
"col_balance_after": "Solde après",
|
| 1054 |
+
"credits_added": "{{amount}} crédits ajoutés. Nouveau solde: {{balance}}",
|
| 1055 |
+
"modal_add_credits": "Ajouter des crédits",
|
| 1056 |
+
"label_org_id": "ID de l'organisation",
|
| 1057 |
+
"org_id_placeholder": "org-uuid...",
|
| 1058 |
+
"label_credits_amount": "Montant (crédits)",
|
| 1059 |
+
"label_description_optional": "Description (optionnel)",
|
| 1060 |
+
"credits_desc_placeholder": "Rechargement manuel...",
|
| 1061 |
+
"adding": "Ajout...",
|
| 1062 |
+
"add": "Ajouter",
|
| 1063 |
+
"err_load_transactions": "Erreur chargement transactions",
|
| 1064 |
+
"err_add_credits": "Erreur ajout de crédits",
|
| 1065 |
+
|
| 1066 |
+
"audit_title": "Audit Logs",
|
| 1067 |
+
"audit_total": "{{count}} entrées au total",
|
| 1068 |
+
"col_datetime": "Date / Heure",
|
| 1069 |
+
"col_action": "Action",
|
| 1070 |
+
"col_actor": "Acteur",
|
| 1071 |
+
"col_resource": "Ressource",
|
| 1072 |
+
"col_details": "Détails",
|
| 1073 |
+
"col_from": "Du",
|
| 1074 |
+
"col_to": "Au",
|
| 1075 |
+
"audit_search": "Rechercher",
|
| 1076 |
+
"audit_empty": "Aucun log pour ces critères",
|
| 1077 |
+
"audit_show": "Voir",
|
| 1078 |
+
"audit_hide": "Masquer",
|
| 1079 |
+
|
| 1080 |
+
"ai_title": "AI Insights",
|
| 1081 |
+
"ai_subtitle": "Commandes en langage naturel pour gérer la plateforme",
|
| 1082 |
+
"ai_suggestion_1": "Montre moi les organisations avec un solde faible",
|
| 1083 |
+
"ai_suggestion_2": "Quelles sont les statistiques de la plateforme ?",
|
| 1084 |
+
"ai_suggestion_3": "Liste les alertes actives",
|
| 1085 |
+
"ai_suggestion_4": "Affiche les 10 dernières organisations",
|
| 1086 |
+
"ai_greeting": "Bonjour ! Je suis votre assistant IA pour la gestion de la plateforme XAMLÉ. Posez-moi une question ou donnez-moi une instruction.",
|
| 1087 |
+
"ai_input_placeholder": "Tapez une commande... ex: 'Ajoute 500 crédits à l'org XAMLÉ'",
|
| 1088 |
+
"ai_confirm": "Confirmer",
|
| 1089 |
+
"ai_cancelled": "Action annulée.",
|
| 1090 |
+
"ai_err": "Erreur AI",
|
| 1091 |
+
"ai_err_exec": "Erreur exécution",
|
| 1092 |
+
"ai_err_message": "Désolé, une erreur est survenue.",
|
| 1093 |
+
"ai_err_exec_message": "Erreur lors de l'exécution.",
|
| 1094 |
+
"ai_done": "Action effectuée.",
|
| 1095 |
+
"ai_no_result": "Aucun résultat trouvé."
|
| 1096 |
}
|
| 1097 |
}
|
apps/admin/src/locales/pt.json
CHANGED
|
@@ -35,7 +35,8 @@
|
|
| 35 |
"select_org": "Por favor selecione uma organização",
|
| 36 |
"clear_filter": "Remover filtro",
|
| 37 |
"no_data": "Sem dados disponíveis",
|
| 38 |
-
"retry": "Tentar novamente"
|
|
|
|
| 39 |
},
|
| 40 |
"nav": {
|
| 41 |
"home": "Início",
|
|
@@ -103,7 +104,26 @@
|
|
| 103 |
"engagement": {
|
| 104 |
"title": "Envolvimento",
|
| 105 |
"avg_days": "Dias de formação em média"
|
| 106 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
},
|
| 108 |
"users": {
|
| 109 |
"title": "Gestão de Utilizadores",
|
|
@@ -117,7 +137,21 @@
|
|
| 117 |
"day": "Dia",
|
| 118 |
"status": "Estado",
|
| 119 |
"joined": "Inscrição"
|
| 120 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
},
|
| 122 |
"contacts": {
|
| 123 |
"title": "Contactos",
|
|
@@ -125,7 +159,23 @@
|
|
| 125 |
"add": "Adicionar contacto",
|
| 126 |
"import": "Importar",
|
| 127 |
"no_contacts": "Sem contactos",
|
| 128 |
-
"search_placeholder": "Procurar um contacto..."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
},
|
| 130 |
"settings": {
|
| 131 |
"title": "Configurações",
|
|
@@ -153,7 +203,20 @@
|
|
| 153 |
"token_expired_alert": "O seu token do WhatsApp é inválido ou expirou. As mensagens já não podem ser enviadas. Vá a Meta Business Manager → Utilizadores do sistema para gerar um novo token e atualize a sua organização.",
|
| 154 |
"api_keys_title": "Chaves API de IA",
|
| 155 |
"api_keys_locked": "Disponível a partir do plano SCALE. Ao adicionar as suas próprias chaves OpenAI ou Google, usa a sua própria quota de IA — sem limite associado ao seu plano Xamlé.",
|
| 156 |
-
"api_keys_unlocked": "As suas chaves estão encriptadas e armazenadas de forma segura. Elas substituem as chaves partilhadas da plataforma — o seu consumo de IA já não é descontado do seu saldo de créditos."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
},
|
| 158 |
"auth": {
|
| 159 |
"org_id": "ID da Organização",
|
|
@@ -181,7 +244,11 @@
|
|
| 181 |
"reset_set_button": "Definir senha",
|
| 182 |
"reset_setting": "Atualizando...",
|
| 183 |
"reset_login_link": "Entrar",
|
| 184 |
-
"reset_back": "Voltar ao login"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
},
|
| 186 |
"onboarding": {
|
| 187 |
"title": "Bem-vindo ao Xamlé.Studio",
|
|
@@ -209,7 +276,35 @@
|
|
| 209 |
"token_idle_hint": "Gere um token \"Nunca expirar\" em Meta Business Manager → Utilizadores do sistema.",
|
| 210 |
"skip_whatsapp": "Configurar WhatsApp mais tarde",
|
| 211 |
"create_org": "Criar organização",
|
| 212 |
-
"creating": "A criar…"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
},
|
| 214 |
"crm": {
|
| 215 |
"stats": {
|
|
@@ -298,7 +393,9 @@
|
|
| 298 |
"status_pending": "Pendente",
|
| 299 |
"status_rejected": "Rejeitado",
|
| 300 |
"status_paused": "Em pausa",
|
| 301 |
-
"status_disabled": "Desativado"
|
|
|
|
|
|
|
| 302 |
}
|
| 303 |
},
|
| 304 |
"knowledge": {
|
|
@@ -311,11 +408,25 @@
|
|
| 311 |
"no_documents": "Nenhum fragmento encontrado.",
|
| 312 |
"import_hint": "Importe um documento no separador Agente IA para começar.",
|
| 313 |
"confirm_delete": "Eliminar este fragmento da base de conhecimento?",
|
| 314 |
-
"delete_error": "Falha ao eliminar"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
},
|
| 316 |
"training": {
|
| 317 |
"title": "Training Lab",
|
| 318 |
-
"subtitle": "Teste e melhore a sua IA pedagógica"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
},
|
| 320 |
"ai_setup": {
|
| 321 |
"title": "Configuração do Agente IA",
|
|
@@ -372,7 +483,11 @@
|
|
| 372 |
"words_covered": "Palavras cobertas",
|
| 373 |
"bot_name_label": "Nome do agente",
|
| 374 |
"bot_name_placeholder": "Ex: Kora, Awa, SupportBot...",
|
| 375 |
-
"bot_name_hint": "O nome que seu agente usará para se apresentar no WhatsApp."
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
},
|
| 377 |
"tracks": {
|
| 378 |
"title": "Cursos",
|
|
@@ -382,7 +497,48 @@
|
|
| 382 |
"days": "dias",
|
| 383 |
"enrolled": "inscritos",
|
| 384 |
"days_label": "Dias",
|
| 385 |
-
"no_days": "Sem dias criados."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
},
|
| 387 |
"campaigns": {
|
| 388 |
"title": "Histórico de Campanhas",
|
|
@@ -690,5 +846,252 @@
|
|
| 690 |
"general_q2": "Qual é a diferença entre os modos?",
|
| 691 |
"general_q3": "Como funciona a faturação?",
|
| 692 |
"general_q4": "Onde posso ver as minhas estatísticas?"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 693 |
}
|
| 694 |
}
|
|
|
|
| 35 |
"select_org": "Por favor selecione uma organização",
|
| 36 |
"clear_filter": "Remover filtro",
|
| 37 |
"no_data": "Sem dados disponíveis",
|
| 38 |
+
"retry": "Tentar novamente",
|
| 39 |
+
"of": "de"
|
| 40 |
},
|
| 41 |
"nav": {
|
| 42 |
"home": "Início",
|
|
|
|
| 104 |
"engagement": {
|
| 105 |
"title": "Envolvimento",
|
| 106 |
"avg_days": "Dias de formação em média"
|
| 107 |
+
},
|
| 108 |
+
"sql_error": "Erro ao executar a consulta",
|
| 109 |
+
"sql_example_1": "Quantos usuários ativos esta semana?",
|
| 110 |
+
"sql_example_2": "Quais são os 5 usuários com mais mensagens?",
|
| 111 |
+
"sql_example_3": "Qual é a taxa média de conclusão?",
|
| 112 |
+
"sql_example_4": "Quantos créditos de IA foram consumidos este mês?",
|
| 113 |
+
"ai_cost_title": "Custo de IA por funcionalidade",
|
| 114 |
+
"ai_cost_subtitle": "Dados reais — fonte: UsageEvent",
|
| 115 |
+
"col_feature": "Funcionalidade",
|
| 116 |
+
"col_calls": "Chamadas",
|
| 117 |
+
"col_tokens_in": "Tokens in",
|
| 118 |
+
"col_tokens_out": "Tokens out",
|
| 119 |
+
"col_cost": "Custo (USD)",
|
| 120 |
+
"total": "Total",
|
| 121 |
+
"nl_search_title": "Pesquisa em linguagem natural",
|
| 122 |
+
"nl_search_subtitle": "Faça uma pergunta sobre seus dados — a IA gera a consulta SQL",
|
| 123 |
+
"nl_search_placeholder": "Ex.: Quais usuários ficaram inativos por 7 dias?",
|
| 124 |
+
"search_btn": "Pesquisar",
|
| 125 |
+
"view_sql": "Ver SQL",
|
| 126 |
+
"no_results": "Nenhum resultado"
|
| 127 |
},
|
| 128 |
"users": {
|
| 129 |
"title": "Gestão de Utilizadores",
|
|
|
|
| 137 |
"day": "Dia",
|
| 138 |
"status": "Estado",
|
| 139 |
"joined": "Inscrição"
|
| 140 |
+
},
|
| 141 |
+
"confirm_delete": "Excluir este usuário? Esta ação é reversível no banco de dados.",
|
| 142 |
+
"delete_success": "Usuário excluído",
|
| 143 |
+
"delete_error": "Falha ao excluir",
|
| 144 |
+
"handoff_released": "Handoff liberado — IA retoma a conversa",
|
| 145 |
+
"handoff_none": "Nenhum handoff ativo para este usuário",
|
| 146 |
+
"handoff_error": "Falha",
|
| 147 |
+
"load_error": "Erro ao carregar usuários",
|
| 148 |
+
"language_column": "Idioma",
|
| 149 |
+
"sector_column": "Setor",
|
| 150 |
+
"conversation_btn": "Conversa",
|
| 151 |
+
"delete_title": "Excluir usuário",
|
| 152 |
+
"handoff_active": "Handoff ativo",
|
| 153 |
+
"release_ai": "Liberar IA",
|
| 154 |
+
"prev": "Anterior"
|
| 155 |
},
|
| 156 |
"contacts": {
|
| 157 |
"title": "Contactos",
|
|
|
|
| 159 |
"add": "Adicionar contacto",
|
| 160 |
"import": "Importar",
|
| 161 |
"no_contacts": "Sem contactos",
|
| 162 |
+
"search_placeholder": "Procurar um contacto...",
|
| 163 |
+
"tags_update_error": "Falha ao atualizar as tags",
|
| 164 |
+
"import_success": "Importação bem-sucedida: {{created}} adicionados, {{updated}} atualizados, {{errors}} erros.",
|
| 165 |
+
"upload_critical_error": "Ocorreu um erro crítico durante o upload.",
|
| 166 |
+
"ai_generation_error": "Falha na geração IA.",
|
| 167 |
+
"generation_error_fallback": "Erro de geração",
|
| 168 |
+
"confirm_delete_one": "Tem a certeza que deseja eliminar este contacto?",
|
| 169 |
+
"delete_error": "Erro ao eliminar.",
|
| 170 |
+
"confirm_delete_many": "Tem a certeza que deseja eliminar {{count}} contactos?",
|
| 171 |
+
"bulk_delete_success": "Contactos eliminados com sucesso.",
|
| 172 |
+
"bulk_delete_error": "Erro ao eliminar em massa.",
|
| 173 |
+
"csv_name": "Nome",
|
| 174 |
+
"csv_phone": "Telefone",
|
| 175 |
+
"csv_created": "Criado em",
|
| 176 |
+
"message_copied": "Mensagem copiada!",
|
| 177 |
+
"copied": "Copiado!",
|
| 178 |
+
"generation_completed": "Geração concluída com sucesso"
|
| 179 |
},
|
| 180 |
"settings": {
|
| 181 |
"title": "Configurações",
|
|
|
|
| 203 |
"token_expired_alert": "O seu token do WhatsApp é inválido ou expirou. As mensagens já não podem ser enviadas. Vá a Meta Business Manager → Utilizadores do sistema para gerar um novo token e atualize a sua organização.",
|
| 204 |
"api_keys_title": "Chaves API de IA",
|
| 205 |
"api_keys_locked": "Disponível a partir do plano SCALE. Ao adicionar as suas próprias chaves OpenAI ou Google, usa a sua própria quota de IA — sem limite associado ao seu plano Xamlé.",
|
| 206 |
+
"api_keys_unlocked": "As suas chaves estão encriptadas e armazenadas de forma segura. Elas substituem as chaves partilhadas da plataforma — o seu consumo de IA já não é descontado do seu saldo de créditos.",
|
| 207 |
+
"wa_connect_success": "WhatsApp conectado com sucesso ✅",
|
| 208 |
+
"wa_connect_error": "Falha na conexão com WhatsApp. Verifique o token e o WABA ID.",
|
| 209 |
+
"mode_edtech": "EdTech",
|
| 210 |
+
"mode_webhook": "Webhook",
|
| 211 |
+
"mode_ai_agent": "Agente IA",
|
| 212 |
+
"mode_pedagogy": "Pedagogia",
|
| 213 |
+
"mode_customer_service": "Suporte ao Cliente",
|
| 214 |
+
"mode_crm_marketing": "CRM & Campanhas",
|
| 215 |
+
"wa_cancel": "Cancelar",
|
| 216 |
+
"wa_reconfigure": "🔄 Reconfigurar",
|
| 217 |
+
"wa_connect_btn": "🔗 Conectar",
|
| 218 |
+
"wa_connecting": "Conectando...",
|
| 219 |
+
"wa_connect_submit": "Conectar WhatsApp"
|
| 220 |
},
|
| 221 |
"auth": {
|
| 222 |
"org_id": "ID da Organização",
|
|
|
|
| 244 |
"reset_set_button": "Definir senha",
|
| 245 |
"reset_setting": "Atualizando...",
|
| 246 |
"reset_login_link": "Entrar",
|
| 247 |
+
"reset_back": "Voltar ao login",
|
| 248 |
+
"reset_network_error": "Erro de rede. Por favor, tente novamente.",
|
| 249 |
+
"reset_password_mismatch": "As senhas não coincidem.",
|
| 250 |
+
"reset_password_min_length": "A senha deve ter pelo menos 6 caracteres.",
|
| 251 |
+
"reset_token_expired": "Token inválido ou expirado."
|
| 252 |
},
|
| 253 |
"onboarding": {
|
| 254 |
"title": "Bem-vindo ao Xamlé.Studio",
|
|
|
|
| 276 |
"token_idle_hint": "Gere um token \"Nunca expirar\" em Meta Business Manager → Utilizadores do sistema.",
|
| 277 |
"skip_whatsapp": "Configurar WhatsApp mais tarde",
|
| 278 |
"create_org": "Criar organização",
|
| 279 |
+
"creating": "A criar…",
|
| 280 |
+
"step_org": "Organização",
|
| 281 |
+
"step_admin": "Administrador",
|
| 282 |
+
"org_title": "A organização",
|
| 283 |
+
"org_subtitle": "Nome, identificador URL e tipo de uso.",
|
| 284 |
+
"org_name_label": "Nome da organização",
|
| 285 |
+
"slug_label": "Identificador URL (slug)",
|
| 286 |
+
"slug_hint": "Gerado automaticamente a partir do nome. Editável — letras minúsculas, números e hífens apenas.",
|
| 287 |
+
"mode_label": "Tipo de uso",
|
| 288 |
+
"mode_edtech_label": "Formação & EdTech",
|
| 289 |
+
"mode_edtech_desc": "Cursos educacionais, exercícios, acompanhamento de alunos via WhatsApp",
|
| 290 |
+
"mode_crm_label": "CRM & Campanhas",
|
| 291 |
+
"mode_crm_desc": "Gestão de contatos, campanhas broadcast, follow-ups de marketing",
|
| 292 |
+
"mode_ai_label": "Agente IA",
|
| 293 |
+
"mode_ai_desc": "Bot de IA conversacional para responder autonomamente 24h/dia",
|
| 294 |
+
"mode_customer_service_label": "Suporte ao Cliente",
|
| 295 |
+
"mode_customer_service_desc": "Gerenciar conversas recebidas e escalar para um agente humano",
|
| 296 |
+
"admin_title": "O administrador",
|
| 297 |
+
"admin_subtitle": "A primeira conta admin desta organização.",
|
| 298 |
+
"admin_name_label": "Nome completo",
|
| 299 |
+
"admin_email_label": "E-mail",
|
| 300 |
+
"admin_pass_label": "Senha inicial",
|
| 301 |
+
"admin_pass_optional": "opcional — gerada automaticamente se vazia",
|
| 302 |
+
"admin_pass_placeholder": "Deixe vazio para gerar automaticamente",
|
| 303 |
+
"admin_pass_hint": "Um e-mail com a senha temporária é enviado ao admin após a criação.",
|
| 304 |
+
"wa_title": "Conexão WhatsApp",
|
| 305 |
+
"wa_subtitle": "Opcional — pode ser configurado depois no perfil da organização.",
|
| 306 |
+
"create_error": "Criação impossível",
|
| 307 |
+
"fb_error": "Login no Facebook foi cancelado ou falhou."
|
| 308 |
},
|
| 309 |
"crm": {
|
| 310 |
"stats": {
|
|
|
|
| 393 |
"status_pending": "Pendente",
|
| 394 |
"status_rejected": "Rejeitado",
|
| 395 |
"status_paused": "Em pausa",
|
| 396 |
+
"status_disabled": "Desativado",
|
| 397 |
+
"waba_not_configured": "WhatsApp Business não configurado",
|
| 398 |
+
"waba_not_configured_desc": "Esta organização ainda não tem uma conta WhatsApp Business (WABA) associada. Aceda a Configurações → Integração WhatsApp para configurar o seu número e WABA ID."
|
| 399 |
}
|
| 400 |
},
|
| 401 |
"knowledge": {
|
|
|
|
| 408 |
"no_documents": "Nenhum fragmento encontrado.",
|
| 409 |
"import_hint": "Importe um documento no separador Agente IA para começar.",
|
| 410 |
"confirm_delete": "Eliminar este fragmento da base de conhecimento?",
|
| 411 |
+
"delete_error": "Falha ao eliminar",
|
| 412 |
+
"reindex_success": "Re-indexação iniciada com sucesso",
|
| 413 |
+
"no_kb_url": "Nenhuma URL de base de conhecimento configurada. Adicione uma URL nas Configurações.",
|
| 414 |
+
"reindex_error": "Falha na re-indexação",
|
| 415 |
+
"generate_error": "Falha na geração",
|
| 416 |
+
"generate_from_desc": "Gerar a partir de descrição",
|
| 417 |
+
"generate_placeholder": "Descreva sua atividade, produtos ou serviços… A IA gerará automaticamente uma FAQ e a indexará na base de conhecimento.",
|
| 418 |
+
"generating_btn": "Gerando…",
|
| 419 |
+
"generate_btn": "Gerar",
|
| 420 |
+
"generate_success_count": "{{count}} Q&A geradas e indexadas",
|
| 421 |
+
"generate_result_summary": "{{count}} Q&A geradas · {{chunks}} chunks indexados"
|
| 422 |
},
|
| 423 |
"training": {
|
| 424 |
"title": "Training Lab",
|
| 425 |
+
"subtitle": "Teste e melhore a sua IA pedagógica",
|
| 426 |
+
"rules_injected": "Sucesso! {{count}} regras foram injetadas no dicionário.",
|
| 427 |
+
"inject_rules": "Injetar ({{count}}) Regras",
|
| 428 |
+
"ground_truth_label": "Verdade de Campo (Ground Truth)",
|
| 429 |
+
"training_saved": "Treino guardado!"
|
| 430 |
},
|
| 431 |
"ai_setup": {
|
| 432 |
"title": "Configuração do Agente IA",
|
|
|
|
| 483 |
"words_covered": "Palavras cobertas",
|
| 484 |
"bot_name_label": "Nome do agente",
|
| 485 |
"bot_name_placeholder": "Ex: Kora, Awa, SupportBot...",
|
| 486 |
+
"bot_name_hint": "O nome que seu agente usará para se apresentar no WhatsApp.",
|
| 487 |
+
"tone_professional": "Profissional",
|
| 488 |
+
"tone_friendly": "Amigável",
|
| 489 |
+
"tone_direct": "Direto",
|
| 490 |
+
"tone_pedagogical": "Pedagógico"
|
| 491 |
},
|
| 492 |
"tracks": {
|
| 493 |
"title": "Cursos",
|
|
|
|
| 497 |
"days": "dias",
|
| 498 |
"enrolled": "inscritos",
|
| 499 |
"days_label": "Dias",
|
| 500 |
+
"no_days": "Sem dias criados.",
|
| 501 |
+
"edit_day": "Editar Dia",
|
| 502 |
+
"new_day": "Novo dia",
|
| 503 |
+
"day_number": "Número do dia",
|
| 504 |
+
"day_title": "Título",
|
| 505 |
+
"lesson_text": "Texto da lição",
|
| 506 |
+
"lesson_placeholder": "Conteúdo pedagógico...",
|
| 507 |
+
"audio_url": "URL de Áudio (opcional)",
|
| 508 |
+
"exercise_type": "Tipo de exercício",
|
| 509 |
+
"exercise_type_text": "Texto livre",
|
| 510 |
+
"exercise_type_audio": "Áudio",
|
| 511 |
+
"exercise_type_button": "Botões",
|
| 512 |
+
"validation_keyword": "Palavra-chave de validação",
|
| 513 |
+
"exercise_prompt": "Prompt do exercício",
|
| 514 |
+
"exercise_prompt_placeholder": "Pergunta feita ao estudante...",
|
| 515 |
+
"no_lesson_text": "Sem texto",
|
| 516 |
+
"form_title_label": "Título",
|
| 517 |
+
"form_description": "Descrição",
|
| 518 |
+
"form_duration": "Duração (dias)",
|
| 519 |
+
"form_language": "Idioma",
|
| 520 |
+
"form_lang_fr": "Francês",
|
| 521 |
+
"form_lang_wolof": "Wolof",
|
| 522 |
+
"form_premium": "Formação Premium (paga)",
|
| 523 |
+
"form_price": "Preço (XOF)",
|
| 524 |
+
"ai_generate_btn": "Gerar com IA",
|
| 525 |
+
"ai_generate_first": "Gere seu primeiro programa com IA",
|
| 526 |
+
"ai_modal_badge": "Agente Criador de Conteúdo",
|
| 527 |
+
"ai_modal_title": "Gerar um programa",
|
| 528 |
+
"ai_modal_subtitle": "A IA cria todo o currículo em segundos",
|
| 529 |
+
"ai_description_label": "Descrição do programa",
|
| 530 |
+
"ai_description_placeholder": "Ex.: Formação de 5 dias sobre marketing digital para PMEs no Senegal...",
|
| 531 |
+
"ai_num_days": "Número de dias",
|
| 532 |
+
"ai_language": "Idioma",
|
| 533 |
+
"ai_lang_fr": "Francês",
|
| 534 |
+
"ai_lang_en": "Inglês",
|
| 535 |
+
"ai_lang_wol": "Wolof",
|
| 536 |
+
"ai_audience": "Público-alvo",
|
| 537 |
+
"ai_audience_optional": "opcional",
|
| 538 |
+
"ai_audience_placeholder": "Ex.: Empreendedores iniciantes, mulheres rurais, estudantes...",
|
| 539 |
+
"ai_generating": "Gerando... (15-30s)",
|
| 540 |
+
"ai_generate_submit": "Gerar programa",
|
| 541 |
+
"ai_error": "Falha na geração por IA"
|
| 542 |
},
|
| 543 |
"campaigns": {
|
| 544 |
"title": "Histórico de Campanhas",
|
|
|
|
| 846 |
"general_q2": "Qual é a diferença entre os modos?",
|
| 847 |
"general_q3": "Como funciona a faturação?",
|
| 848 |
"general_q4": "Onde posso ver as minhas estatísticas?"
|
| 849 |
+
},
|
| 850 |
+
"super_admin": {
|
| 851 |
+
"nav_dashboard": "Dashboard",
|
| 852 |
+
"nav_organizations": "Organizações",
|
| 853 |
+
"nav_users": "Utilizadores",
|
| 854 |
+
"nav_whatsapp": "Números WA",
|
| 855 |
+
"nav_templates": "Templates WA",
|
| 856 |
+
"nav_profiles": "Perfis WA",
|
| 857 |
+
"nav_monitoring": "Monitorização",
|
| 858 |
+
"nav_billing": "Faturação",
|
| 859 |
+
"nav_ai": "AI Insights",
|
| 860 |
+
"nav_audit_logs": "Audit Logs",
|
| 861 |
+
"exit_admin": "Sair do admin",
|
| 862 |
+
"logout": "Terminar sessão",
|
| 863 |
+
"system_active": "Sistema ativo",
|
| 864 |
+
"platform_admin": "Platform Admin",
|
| 865 |
+
"super_admin_label": "Super-admin",
|
| 866 |
+
|
| 867 |
+
"dashboard_title": "Platform Dashboard",
|
| 868 |
+
"dashboard_subtitle": "Vista global de todas as organizações",
|
| 869 |
+
"kpi_organizations": "Organizações",
|
| 870 |
+
"kpi_orgs_active": "{{count}} ativas",
|
| 871 |
+
"kpi_users": "Utilizadores",
|
| 872 |
+
"kpi_messages_24h": "Mensagens / 24h",
|
| 873 |
+
"kpi_queue_depth": "Fila de espera",
|
| 874 |
+
"kpi_queue_failed": "{{count}} falhados",
|
| 875 |
+
"kpi_revenue": "Receita / mês",
|
| 876 |
+
"kpi_alerts": "Alertas",
|
| 877 |
+
"system_health": "Saúde do sistema",
|
| 878 |
+
"health_db": "Base de dados",
|
| 879 |
+
"health_redis": "Redis",
|
| 880 |
+
"health_queue": "Fila",
|
| 881 |
+
"active_alerts": "Alertas ativos ({{count}})",
|
| 882 |
+
"alert_token": "Token a expirar — {{orgName}} ({{daysOld}}d)",
|
| 883 |
+
"alert_balance": "Saldo baixo — {{orgName}}: {{balance}} créditos",
|
| 884 |
+
"alert_queue_failed": "{{count}} jobs falhados na fila",
|
| 885 |
+
|
| 886 |
+
"orgs_title": "Organizações",
|
| 887 |
+
"orgs_total": "{{count}} organizações no total",
|
| 888 |
+
"org_new": "Nova organização",
|
| 889 |
+
"org_search_placeholder": "Pesquisar uma organização...",
|
| 890 |
+
"org_search_btn": "Pesquisar",
|
| 891 |
+
"org_loading": "A carregar...",
|
| 892 |
+
"org_empty": "Nenhuma organização encontrada",
|
| 893 |
+
"col_name": "Nome",
|
| 894 |
+
"col_plan": "Plano",
|
| 895 |
+
"col_status": "Estado",
|
| 896 |
+
"col_users": "Utilizadores",
|
| 897 |
+
"col_credits": "Créditos",
|
| 898 |
+
"status_suspended": "Suspenso",
|
| 899 |
+
"status_trial": "Trial",
|
| 900 |
+
"status_active": "Ativo",
|
| 901 |
+
"org_reactivated": "Organização reativada",
|
| 902 |
+
"org_suspended": "Organização suspensa",
|
| 903 |
+
"org_delete_confirm": "Eliminar definitivamente \"{{name}}\"? Esta ação não pode ser desfeita.",
|
| 904 |
+
"org_deleted": "\"{{name}}\" eliminada",
|
| 905 |
+
"org_updated": "Organização atualizada",
|
| 906 |
+
"org_created": "Organização criada",
|
| 907 |
+
"label_plan": "Plano",
|
| 908 |
+
"label_ai_credits": "Limite de créditos AI",
|
| 909 |
+
"label_crm_active": "CRM ativo",
|
| 910 |
+
"label_edtech_active": "EdTech ativo",
|
| 911 |
+
"saving": "A guardar...",
|
| 912 |
+
"save": "Guardar",
|
| 913 |
+
"modal_new_org": "Nova organização",
|
| 914 |
+
"org_name_placeholder": "Nome da organização",
|
| 915 |
+
"cancel": "Cancelar",
|
| 916 |
+
"creating": "A criar...",
|
| 917 |
+
"create": "Criar",
|
| 918 |
+
"err_load_orgs": "Erro ao carregar organizações",
|
| 919 |
+
"err_suspend": "Erro ao alterar estado",
|
| 920 |
+
"err_delete": "Erro ao eliminar",
|
| 921 |
+
"err_update": "Erro ao atualizar",
|
| 922 |
+
"err_create": "Erro ao criar",
|
| 923 |
+
"btn_edit": "Editar",
|
| 924 |
+
"btn_reactivate": "Reativar",
|
| 925 |
+
"btn_suspend": "Suspender",
|
| 926 |
+
"btn_delete_forever": "Eliminar definitivamente",
|
| 927 |
+
"pagination_info": "{{from}}–{{to}} de {{total}}",
|
| 928 |
+
"prev": "Anterior",
|
| 929 |
+
"next": "Seguinte",
|
| 930 |
+
|
| 931 |
+
"users_title": "Utilizadores",
|
| 932 |
+
"users_total": "{{count}} utilizadores no total",
|
| 933 |
+
"user_search_placeholder": "Pesquisar por nome ou email...",
|
| 934 |
+
"col_user": "Utilizador",
|
| 935 |
+
"col_organization": "Organização",
|
| 936 |
+
"col_role": "Função",
|
| 937 |
+
"col_created_at": "Criado em",
|
| 938 |
+
"user_empty": "Nenhum utilizador encontrado",
|
| 939 |
+
"role_updated": "Função atualizada",
|
| 940 |
+
"btn_reset_password": "Redefinir palavra-passe",
|
| 941 |
+
"reset_no_email": "Este utilizador não tem endereço de email",
|
| 942 |
+
"reset_confirm": "Enviar link de redefinição para {{email}}?",
|
| 943 |
+
"reset_sent": "Link enviado para {{email}}",
|
| 944 |
+
"err_load_users": "Erro ao carregar utilizadores",
|
| 945 |
+
"err_role_change": "Erro ao alterar função",
|
| 946 |
+
"err_reset_password": "Erro ao enviar email",
|
| 947 |
+
|
| 948 |
+
"wa_numbers_title": "Números WhatsApp",
|
| 949 |
+
"wa_numbers_total": "{{count}} números registados",
|
| 950 |
+
"wa_refresh": "Atualizar",
|
| 951 |
+
"wa_register_number": "Registar um número",
|
| 952 |
+
"wa_no_numbers": "Nenhum número WhatsApp registado",
|
| 953 |
+
"col_number": "Número",
|
| 954 |
+
"col_id": "ID",
|
| 955 |
+
"col_added_at": "Adicionado em",
|
| 956 |
+
"wa_register_title": "Registar um número WhatsApp",
|
| 957 |
+
"wa_step": "Passo {{step}}/2",
|
| 958 |
+
"label_org": "Organização",
|
| 959 |
+
"org_select_placeholder": "Selecionar uma organização...",
|
| 960 |
+
"label_phone_number_id": "Phone Number ID",
|
| 961 |
+
"phone_id_hint": "Encontre este ID no Meta Business Manager > WhatsApp Accounts",
|
| 962 |
+
"label_pin": "PIN de segurança",
|
| 963 |
+
"pin_hint": "PIN de 6 dígitos para proteger o número (deixe em branco = 000000)",
|
| 964 |
+
"wa_select_org_error": "Por favor selecione uma organização.",
|
| 965 |
+
"wa_phone_id_error": "O Phone Number ID deve ter entre 12 e 18 dígitos.",
|
| 966 |
+
"wa_pin_error": "O PIN deve ter exatamente 6 dígitos.",
|
| 967 |
+
"wa_sending": "A enviar...",
|
| 968 |
+
"wa_send_otp": "Enviar código OTP",
|
| 969 |
+
"wa_reg_error": "Erro durante o registo.",
|
| 970 |
+
"wa_net_error": "Erro de rede.",
|
| 971 |
+
"wa_otp_hint": "A Meta enviou-lhe um código OTP por SMS ou chamada de voz. Introduza-o abaixo.",
|
| 972 |
+
"label_otp": "Código OTP",
|
| 973 |
+
"wa_otp_error": "O código OTP deve ter entre 4 e 8 dígitos.",
|
| 974 |
+
"wa_verifying": "A verificar...",
|
| 975 |
+
"wa_verify": "Verificar",
|
| 976 |
+
"wa_otp_invalid": "Código OTP inválido ou expirado.",
|
| 977 |
+
"wa_number_registered": "Número registado com sucesso!",
|
| 978 |
+
"wa_back": "Voltar",
|
| 979 |
+
"err_load_numbers": "Erro ao carregar números",
|
| 980 |
+
|
| 981 |
+
"tpl_title": "WhatsApp Templates",
|
| 982 |
+
"tpl_orgs_count_one": "{{count}} organização com WhatsApp configurado",
|
| 983 |
+
"tpl_orgs_count_other": "{{count}} organizações com WhatsApp configurado",
|
| 984 |
+
"tpl_select_hint": "Selecione uma organização para gerir os seus modelos de mensagem WhatsApp.",
|
| 985 |
+
"tpl_search_placeholder": "Pesquisar uma organização ou WABA ID...",
|
| 986 |
+
"tpl_empty": "Nenhuma organização com WhatsApp configurado",
|
| 987 |
+
"col_waba_id": "WABA ID",
|
| 988 |
+
"col_actions": "Ações",
|
| 989 |
+
"tpl_view": "Ver templates",
|
| 990 |
+
"tpl_no_templates": "Nenhum modelo encontrado para esta organização.",
|
| 991 |
+
"col_category": "Categoria",
|
| 992 |
+
"col_language": "Língua",
|
| 993 |
+
"tpl_create_btn": "Criar template",
|
| 994 |
+
"tpl_create_title": "Novo template WhatsApp",
|
| 995 |
+
"label_template_name": "Nome do template",
|
| 996 |
+
"template_name_hint": "Minúsculas, dígitos, underscores apenas",
|
| 997 |
+
"template_name_error": "Minúsculas, dígitos, underscores apenas",
|
| 998 |
+
"label_category": "Categoria",
|
| 999 |
+
"label_language": "Língua",
|
| 1000 |
+
"label_header_optional": "Cabeçalho (opcional)",
|
| 1001 |
+
"label_body": "Corpo da mensagem",
|
| 1002 |
+
"label_footer_optional": "Rodapé (opcional)",
|
| 1003 |
+
"label_preview": "Pré-visualização",
|
| 1004 |
+
"body_placeholder": "Corpo da mensagem...",
|
| 1005 |
+
"tpl_select_org_placeholder": "Selecionar uma organização…",
|
| 1006 |
+
"tpl_submitted": "Template submetido à Meta para aprovação",
|
| 1007 |
+
"tpl_create_required": "Organização, nome e corpo da mensagem são obrigatórios.",
|
| 1008 |
+
"tpl_name_invalid": "O nome do template é inválido.",
|
| 1009 |
+
"tpl_creating": "A criar…",
|
| 1010 |
+
"tpl_create_submit": "Criar template",
|
| 1011 |
+
"tpl_create_error": "Erro ao criar o template.",
|
| 1012 |
+
|
| 1013 |
+
"profiles_title": "Perfis WhatsApp",
|
| 1014 |
+
"profiles_count_one": "{{count}} perfil",
|
| 1015 |
+
"profiles_count_other": "{{count}} perfis",
|
| 1016 |
+
"profile_empty": "Nenhum perfil WhatsApp encontrado",
|
| 1017 |
+
"label_org_name": "Nome da organização",
|
| 1018 |
+
"label_logo_url": "URL do logótipo",
|
| 1019 |
+
"label_primary_color": "Cor principal",
|
| 1020 |
+
"profile_updated": "Perfil atualizado",
|
| 1021 |
+
"err_load_profiles": "Erro ao carregar perfis",
|
| 1022 |
+
"err_save_profile": "Erro ao guardar",
|
| 1023 |
+
"btn_cancel_edit": "Cancelar",
|
| 1024 |
+
"btn_save": "Guardar",
|
| 1025 |
+
"saving_profile": "A guardar…",
|
| 1026 |
+
"btn_edit_profile": "Editar",
|
| 1027 |
+
|
| 1028 |
+
"monitoring_title": "Monitorização & Alertas",
|
| 1029 |
+
"monitoring_subtitle": "Estado do sistema em tempo real",
|
| 1030 |
+
"system_health_title": "Saúde do sistema",
|
| 1031 |
+
"health_redis_cache": "Redis / Cache",
|
| 1032 |
+
"health_queue_jobs": "Fila de jobs",
|
| 1033 |
+
"queue_failed_detail": "{{count}} falhados",
|
| 1034 |
+
"queue_waiting_detail": "{{count}} em espera",
|
| 1035 |
+
"token_expiry_title": "Tokens WhatsApp a expirar",
|
| 1036 |
+
"token_no_risk": "Nenhum token em risco de expiração",
|
| 1037 |
+
"col_org": "Organização",
|
| 1038 |
+
"col_issued_ago": "Emitido",
|
| 1039 |
+
"col_days": "{{count}} dias",
|
| 1040 |
+
"low_balance_title": "Saldos baixos (< 100 créditos)",
|
| 1041 |
+
"low_balance_none": "Nenhuma organização com saldo baixo",
|
| 1042 |
+
"credits_label": "{{count}} créditos",
|
| 1043 |
+
"err_load_monitoring": "Erro ao carregar monitorização",
|
| 1044 |
+
|
| 1045 |
+
"billing_title": "Faturação",
|
| 1046 |
+
"billing_transactions": "{{count}} transações",
|
| 1047 |
+
"billing_add_credits": "Adicionar créditos",
|
| 1048 |
+
"billing_no_transactions": "Nenhuma transação",
|
| 1049 |
+
"col_date": "Data",
|
| 1050 |
+
"col_type": "Tipo",
|
| 1051 |
+
"col_description": "Descrição",
|
| 1052 |
+
"col_amount": "Montante",
|
| 1053 |
+
"col_balance_after": "Saldo após",
|
| 1054 |
+
"credits_added": "{{amount}} créditos adicionados. Novo saldo: {{balance}}",
|
| 1055 |
+
"modal_add_credits": "Adicionar créditos",
|
| 1056 |
+
"label_org_id": "ID da organização",
|
| 1057 |
+
"org_id_placeholder": "org-uuid...",
|
| 1058 |
+
"label_credits_amount": "Montante (créditos)",
|
| 1059 |
+
"label_description_optional": "Descrição (opcional)",
|
| 1060 |
+
"credits_desc_placeholder": "Carregamento manual...",
|
| 1061 |
+
"adding": "A adicionar...",
|
| 1062 |
+
"add": "Adicionar",
|
| 1063 |
+
"err_load_transactions": "Erro ao carregar transações",
|
| 1064 |
+
"err_add_credits": "Erro ao adicionar créditos",
|
| 1065 |
+
|
| 1066 |
+
"audit_title": "Audit Logs",
|
| 1067 |
+
"audit_total": "{{count}} entradas no total",
|
| 1068 |
+
"col_datetime": "Data / Hora",
|
| 1069 |
+
"col_action": "Ação",
|
| 1070 |
+
"col_actor": "Ator",
|
| 1071 |
+
"col_resource": "Recurso",
|
| 1072 |
+
"col_details": "Detalhes",
|
| 1073 |
+
"col_from": "De",
|
| 1074 |
+
"col_to": "Até",
|
| 1075 |
+
"audit_search": "Pesquisar",
|
| 1076 |
+
"audit_empty": "Nenhum log para estes critérios",
|
| 1077 |
+
"audit_show": "Ver",
|
| 1078 |
+
"audit_hide": "Ocultar",
|
| 1079 |
+
|
| 1080 |
+
"ai_title": "AI Insights",
|
| 1081 |
+
"ai_subtitle": "Comandos em linguagem natural para gerir a plataforma",
|
| 1082 |
+
"ai_suggestion_1": "Mostra-me organizações com saldo baixo",
|
| 1083 |
+
"ai_suggestion_2": "Quais são as estatísticas da plataforma?",
|
| 1084 |
+
"ai_suggestion_3": "Lista os alertas ativos",
|
| 1085 |
+
"ai_suggestion_4": "Mostra as 10 últimas organizações",
|
| 1086 |
+
"ai_greeting": "Olá! Sou o seu assistente IA para a gestão da plataforma XAMLÉ. Faça-me uma pergunta ou dê-me uma instrução.",
|
| 1087 |
+
"ai_input_placeholder": "Digite um comando... ex: 'Adiciona 500 créditos à org XAMLÉ'",
|
| 1088 |
+
"ai_confirm": "Confirmar",
|
| 1089 |
+
"ai_cancelled": "Ação cancelada.",
|
| 1090 |
+
"ai_err": "Erro AI",
|
| 1091 |
+
"ai_err_exec": "Erro de execução",
|
| 1092 |
+
"ai_err_message": "Desculpe, ocorreu um erro.",
|
| 1093 |
+
"ai_err_exec_message": "Erro durante a execução.",
|
| 1094 |
+
"ai_done": "Ação concluída.",
|
| 1095 |
+
"ai_no_result": "Nenhum resultado encontrado."
|
| 1096 |
}
|
| 1097 |
}
|
apps/admin/src/pages/AIAgentSetup.tsx
CHANGED
|
@@ -25,6 +25,14 @@ const TONE_DESC_KEYS: Record<string, string> = {
|
|
| 25 |
Pédagogue: 'ai_setup.tone_desc_pedagogical',
|
| 26 |
};
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
interface KbStats {
|
| 29 |
chunkCount: number;
|
| 30 |
hasKnowledgeBase: boolean;
|
|
@@ -275,7 +283,7 @@ export default function AIAgentSetup() {
|
|
| 275 |
: 'border-slate-200 hover:bg-emerald-50 hover:border-emerald-200'
|
| 276 |
}`}
|
| 277 |
>
|
| 278 |
-
{tone}
|
| 279 |
{tone === recommendedTone && selectedTone !== tone && (
|
| 280 |
<span className="absolute -top-2 -right-1 text-[8px] bg-indigo-500 text-white px-1 rounded-full leading-4">✦</span>
|
| 281 |
)}
|
|
|
|
| 25 |
Pédagogue: 'ai_setup.tone_desc_pedagogical',
|
| 26 |
};
|
| 27 |
|
| 28 |
+
// Maps DB tone values to i18n keys for display labels
|
| 29 |
+
const TONE_LABELS: Record<string, string> = {
|
| 30 |
+
Professionnel: 'ai_setup.tone_professional',
|
| 31 |
+
Amical: 'ai_setup.tone_friendly',
|
| 32 |
+
Direct: 'ai_setup.tone_direct',
|
| 33 |
+
Pédagogue: 'ai_setup.tone_pedagogical',
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
interface KbStats {
|
| 37 |
chunkCount: number;
|
| 38 |
hasKnowledgeBase: boolean;
|
|
|
|
| 283 |
: 'border-slate-200 hover:bg-emerald-50 hover:border-emerald-200'
|
| 284 |
}`}
|
| 285 |
>
|
| 286 |
+
{t(TONE_LABELS[tone] ?? tone)}
|
| 287 |
{tone === recommendedTone && selectedTone !== tone && (
|
| 288 |
<span className="absolute -top-2 -right-1 text-[8px] bg-indigo-500 text-white px-1 rounded-full leading-4">✦</span>
|
| 289 |
)}
|
apps/admin/src/pages/AnalyticsPage.tsx
CHANGED
|
@@ -73,17 +73,17 @@ export default function AnalyticsPage() {
|
|
| 73 |
const data = await api.post('/v1/analytics/query', { question: sqlQuestion, language: 'FR' }, token, selectedOrgId);
|
| 74 |
setSqlResult(data);
|
| 75 |
} catch (err: any) {
|
| 76 |
-
setSqlError(err?.message ?? '
|
| 77 |
} finally {
|
| 78 |
setSqlLoading(false);
|
| 79 |
}
|
| 80 |
};
|
| 81 |
|
| 82 |
const EXAMPLE_QUESTIONS = [
|
| 83 |
-
'
|
| 84 |
-
'
|
| 85 |
-
'
|
| 86 |
-
'
|
| 87 |
];
|
| 88 |
|
| 89 |
const messageData = [
|
|
@@ -234,19 +234,19 @@ export default function AnalyticsPage() {
|
|
| 234 |
<BrainCircuit className="w-5 h-5 text-purple-600" />
|
| 235 |
</div>
|
| 236 |
<div>
|
| 237 |
-
<h2 className="font-bold text-slate-800">
|
| 238 |
-
<p className="text-xs text-slate-400">
|
| 239 |
</div>
|
| 240 |
</div>
|
| 241 |
<div className="overflow-x-auto">
|
| 242 |
<table className="w-full text-sm">
|
| 243 |
<thead>
|
| 244 |
<tr className="text-left text-xs text-slate-400 uppercase tracking-wider border-b border-slate-100">
|
| 245 |
-
<th className="pb-3 font-semibold">
|
| 246 |
-
<th className="pb-3 font-semibold text-right">
|
| 247 |
-
<th className="pb-3 font-semibold text-right">
|
| 248 |
-
<th className="pb-3 font-semibold text-right">
|
| 249 |
-
<th className="pb-3 font-semibold text-right">
|
| 250 |
</tr>
|
| 251 |
</thead>
|
| 252 |
<tbody className="divide-y divide-slate-50">
|
|
@@ -266,7 +266,7 @@ export default function AnalyticsPage() {
|
|
| 266 |
</tbody>
|
| 267 |
<tfoot className="border-t-2 border-slate-200">
|
| 268 |
<tr>
|
| 269 |
-
<td className="pt-3 font-bold text-slate-800" colSpan={4}>
|
| 270 |
<td className="pt-3 text-right font-bold text-purple-700">${(usage.costs.totalUsd as number).toFixed(4)}</td>
|
| 271 |
</tr>
|
| 272 |
</tfoot>
|
|
@@ -282,8 +282,8 @@ export default function AnalyticsPage() {
|
|
| 282 |
<Search className="w-5 h-5 text-violet-600" />
|
| 283 |
</div>
|
| 284 |
<div>
|
| 285 |
-
<h2 className="font-bold text-slate-800">
|
| 286 |
-
<p className="text-xs text-slate-400">
|
| 287 |
</div>
|
| 288 |
</div>
|
| 289 |
|
|
@@ -306,7 +306,7 @@ export default function AnalyticsPage() {
|
|
| 306 |
type="text"
|
| 307 |
value={sqlQuestion}
|
| 308 |
onChange={e => setSqlQuestion(e.target.value)}
|
| 309 |
-
placeholder=
|
| 310 |
className="flex-1 px-4 py-3 rounded-xl border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-violet-300 bg-slate-50"
|
| 311 |
/>
|
| 312 |
<button
|
|
@@ -315,7 +315,7 @@ export default function AnalyticsPage() {
|
|
| 315 |
className="px-5 py-3 bg-violet-600 hover:bg-violet-700 disabled:opacity-40 text-white text-sm font-bold rounded-xl flex items-center gap-2 transition"
|
| 316 |
>
|
| 317 |
{sqlLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
| 318 |
-
|
| 319 |
</button>
|
| 320 |
</form>
|
| 321 |
|
|
@@ -335,7 +335,7 @@ export default function AnalyticsPage() {
|
|
| 335 |
onClick={() => setSqlResult(r => r ? { ...r, _showSql: !((r as any)._showSql) } as any : r)}
|
| 336 |
className="flex items-center gap-1.5 text-[11px] text-slate-400 hover:text-slate-600 transition"
|
| 337 |
>
|
| 338 |
-
<Terminal className="w-3.5 h-3.5" />
|
| 339 |
</button>
|
| 340 |
</div>
|
| 341 |
|
|
@@ -369,7 +369,7 @@ export default function AnalyticsPage() {
|
|
| 369 |
</table>
|
| 370 |
</div>
|
| 371 |
) : (
|
| 372 |
-
<div className="text-center py-6 text-slate-400 text-sm">
|
| 373 |
)}
|
| 374 |
</div>
|
| 375 |
)}
|
|
|
|
| 73 |
const data = await api.post('/v1/analytics/query', { question: sqlQuestion, language: 'FR' }, token, selectedOrgId);
|
| 74 |
setSqlResult(data);
|
| 75 |
} catch (err: any) {
|
| 76 |
+
setSqlError(err?.message ?? t('analytics.sql_error'));
|
| 77 |
} finally {
|
| 78 |
setSqlLoading(false);
|
| 79 |
}
|
| 80 |
};
|
| 81 |
|
| 82 |
const EXAMPLE_QUESTIONS = [
|
| 83 |
+
t('analytics.sql_example_1'),
|
| 84 |
+
t('analytics.sql_example_2'),
|
| 85 |
+
t('analytics.sql_example_3'),
|
| 86 |
+
t('analytics.sql_example_4'),
|
| 87 |
];
|
| 88 |
|
| 89 |
const messageData = [
|
|
|
|
| 234 |
<BrainCircuit className="w-5 h-5 text-purple-600" />
|
| 235 |
</div>
|
| 236 |
<div>
|
| 237 |
+
<h2 className="font-bold text-slate-800">{t('analytics.ai_cost_title')}</h2>
|
| 238 |
+
<p className="text-xs text-slate-400">{t('analytics.ai_cost_subtitle')}</p>
|
| 239 |
</div>
|
| 240 |
</div>
|
| 241 |
<div className="overflow-x-auto">
|
| 242 |
<table className="w-full text-sm">
|
| 243 |
<thead>
|
| 244 |
<tr className="text-left text-xs text-slate-400 uppercase tracking-wider border-b border-slate-100">
|
| 245 |
+
<th className="pb-3 font-semibold">{t('analytics.col_feature')}</th>
|
| 246 |
+
<th className="pb-3 font-semibold text-right">{t('analytics.col_calls')}</th>
|
| 247 |
+
<th className="pb-3 font-semibold text-right">{t('analytics.col_tokens_in')}</th>
|
| 248 |
+
<th className="pb-3 font-semibold text-right">{t('analytics.col_tokens_out')}</th>
|
| 249 |
+
<th className="pb-3 font-semibold text-right">{t('analytics.col_cost')}</th>
|
| 250 |
</tr>
|
| 251 |
</thead>
|
| 252 |
<tbody className="divide-y divide-slate-50">
|
|
|
|
| 266 |
</tbody>
|
| 267 |
<tfoot className="border-t-2 border-slate-200">
|
| 268 |
<tr>
|
| 269 |
+
<td className="pt-3 font-bold text-slate-800" colSpan={4}>{t('analytics.total')}</td>
|
| 270 |
<td className="pt-3 text-right font-bold text-purple-700">${(usage.costs.totalUsd as number).toFixed(4)}</td>
|
| 271 |
</tr>
|
| 272 |
</tfoot>
|
|
|
|
| 282 |
<Search className="w-5 h-5 text-violet-600" />
|
| 283 |
</div>
|
| 284 |
<div>
|
| 285 |
+
<h2 className="font-bold text-slate-800">{t('analytics.nl_search_title')}</h2>
|
| 286 |
+
<p className="text-xs text-slate-400">{t('analytics.nl_search_subtitle')}</p>
|
| 287 |
</div>
|
| 288 |
</div>
|
| 289 |
|
|
|
|
| 306 |
type="text"
|
| 307 |
value={sqlQuestion}
|
| 308 |
onChange={e => setSqlQuestion(e.target.value)}
|
| 309 |
+
placeholder={t('analytics.nl_search_placeholder')}
|
| 310 |
className="flex-1 px-4 py-3 rounded-xl border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-violet-300 bg-slate-50"
|
| 311 |
/>
|
| 312 |
<button
|
|
|
|
| 315 |
className="px-5 py-3 bg-violet-600 hover:bg-violet-700 disabled:opacity-40 text-white text-sm font-bold rounded-xl flex items-center gap-2 transition"
|
| 316 |
>
|
| 317 |
{sqlLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
| 318 |
+
{t('analytics.search_btn')}
|
| 319 |
</button>
|
| 320 |
</form>
|
| 321 |
|
|
|
|
| 335 |
onClick={() => setSqlResult(r => r ? { ...r, _showSql: !((r as any)._showSql) } as any : r)}
|
| 336 |
className="flex items-center gap-1.5 text-[11px] text-slate-400 hover:text-slate-600 transition"
|
| 337 |
>
|
| 338 |
+
<Terminal className="w-3.5 h-3.5" /> {t('analytics.view_sql')}
|
| 339 |
</button>
|
| 340 |
</div>
|
| 341 |
|
|
|
|
| 369 |
</table>
|
| 370 |
</div>
|
| 371 |
) : (
|
| 372 |
+
<div className="text-center py-6 text-slate-400 text-sm">{t('analytics.no_results')}</div>
|
| 373 |
)}
|
| 374 |
</div>
|
| 375 |
)}
|
apps/admin/src/pages/ClientsManagementView.tsx
CHANGED
|
@@ -85,7 +85,7 @@ export default function ClientsManagementView() {
|
|
| 85 |
setMetaStatuses(prev => ({ ...prev, [orgId]: { ...status, loading: false } }));
|
| 86 |
} catch (err) {
|
| 87 |
logWarn('[Clients] fetchMetaStatus failed', err);
|
| 88 |
-
setMetaStatuses(prev => ({ ...prev, [orgId]: { configured: false, loading: false, error: '
|
| 89 |
}
|
| 90 |
};
|
| 91 |
|
|
|
|
| 85 |
setMetaStatuses(prev => ({ ...prev, [orgId]: { ...status, loading: false } }));
|
| 86 |
} catch (err) {
|
| 87 |
logWarn('[Clients] fetchMetaStatus failed', err);
|
| 88 |
+
setMetaStatuses(prev => ({ ...prev, [orgId]: { configured: false, loading: false, error: t('common.error') } }));
|
| 89 |
}
|
| 90 |
};
|
| 91 |
|
apps/admin/src/pages/ContactsPage.tsx
CHANGED
|
@@ -90,7 +90,7 @@ export default function ContactsPage() {
|
|
| 90 |
setContacts(prev => prev.map(c => c.id === contactId ? { ...c, tags: newTags } : c));
|
| 91 |
fetchAvailableTags();
|
| 92 |
} catch (err: any) {
|
| 93 |
-
toast.error(err?.message ?? '
|
| 94 |
} finally {
|
| 95 |
setSavingTags(prev => ({ ...prev, [contactId]: false }));
|
| 96 |
}
|
|
@@ -157,11 +157,11 @@ export default function ContactsPage() {
|
|
| 157 |
setImportStats(results);
|
| 158 |
fetchContacts();
|
| 159 |
if (results) {
|
| 160 |
-
toast.success(
|
| 161 |
}
|
| 162 |
} catch (error) {
|
| 163 |
logError("Import failed:", error);
|
| 164 |
-
toast.error(
|
| 165 |
} finally {
|
| 166 |
setUploading(false);
|
| 167 |
setUploadingFileName('');
|
|
@@ -184,7 +184,7 @@ export default function ContactsPage() {
|
|
| 184 |
setAiResult(data);
|
| 185 |
} catch (error) {
|
| 186 |
logError("AI Generation failed:", error);
|
| 187 |
-
toast.error(
|
| 188 |
} finally {
|
| 189 |
setGeneratingAI(false);
|
| 190 |
}
|
|
@@ -208,7 +208,7 @@ export default function ContactsPage() {
|
|
| 208 |
const data = await api.post('/v1/ai/crm/generate-campaign', { contact, objective: campaignObjective }, token, selectedOrgId);
|
| 209 |
results.push({ contact, ...data });
|
| 210 |
} catch (err) {
|
| 211 |
-
results.push({ contact, error: true, personalizedMessage:
|
| 212 |
}
|
| 213 |
}
|
| 214 |
|
|
@@ -232,7 +232,7 @@ export default function ContactsPage() {
|
|
| 232 |
|
| 233 |
const handleDeleteContact = async (id: string) => {
|
| 234 |
if (!token || !selectedOrgId) return;
|
| 235 |
-
if (!confirm(
|
| 236 |
|
| 237 |
try {
|
| 238 |
const res = await api.delete(`/v1/organizations/${selectedOrgId}/contacts/${id}`, token);
|
|
@@ -242,28 +242,28 @@ export default function ContactsPage() {
|
|
| 242 |
}
|
| 243 |
} catch (error) {
|
| 244 |
logError("Delete failed:", error);
|
| 245 |
-
toast.error(
|
| 246 |
}
|
| 247 |
};
|
| 248 |
|
| 249 |
const handleBulkDelete = async () => {
|
| 250 |
if (!token || !selectedOrgId || selectedContactIds.length === 0) return;
|
| 251 |
-
if (!confirm(
|
| 252 |
|
| 253 |
try {
|
| 254 |
await api.post(`/v1/organizations/${selectedOrgId}/contacts/bulk-delete`, { contactIds: selectedContactIds }, token, selectedOrgId);
|
| 255 |
setContacts(prev => prev.filter(c => !selectedContactIds.includes(c.id)));
|
| 256 |
setSelectedContactIds([]);
|
| 257 |
-
toast.success(
|
| 258 |
} catch (error) {
|
| 259 |
logError("Bulk delete failed:", error);
|
| 260 |
-
toast.error(
|
| 261 |
}
|
| 262 |
};
|
| 263 |
|
| 264 |
const handleExportCsv = () => {
|
| 265 |
if (filteredContacts.length === 0) return;
|
| 266 |
-
const headers = ['
|
| 267 |
const rows = filteredContacts.map(c => [
|
| 268 |
c.name ?? '',
|
| 269 |
c.phoneNumber,
|
|
@@ -695,7 +695,7 @@ export default function ContactsPage() {
|
|
| 695 |
onChange={(e) => setAiResult({...aiResult, personalizedMessage: e.target.value})}
|
| 696 |
/>
|
| 697 |
<button
|
| 698 |
-
onClick={() => { navigator.clipboard.writeText(aiResult.personalizedMessage); toast.success('
|
| 699 |
title="Copier le message"
|
| 700 |
className="absolute top-10 right-4 p-2 bg-white text-slate-400 rounded-lg hover:text-indigo-600 shadow-sm border border-slate-100 opacity-0 group-hover:opacity-100 transition"
|
| 701 |
>
|
|
@@ -846,7 +846,7 @@ export default function ContactsPage() {
|
|
| 846 |
<button
|
| 847 |
onClick={() => {
|
| 848 |
navigator.clipboard.writeText(result.personalizedMessage);
|
| 849 |
-
toast.success(
|
| 850 |
}}
|
| 851 |
className="p-3 bg-slate-100 text-slate-400 rounded-xl hover:bg-slate-200 hover:text-slate-900 transition"
|
| 852 |
title="Copier"
|
|
@@ -863,7 +863,7 @@ export default function ContactsPage() {
|
|
| 863 |
<div className="p-8 border-t border-slate-100 bg-slate-50/50 flex items-center justify-between">
|
| 864 |
<div className="flex items-center gap-2 text-emerald-600 font-bold">
|
| 865 |
<CheckCircle2 className="w-5 h-5" />
|
| 866 |
-
|
| 867 |
</div>
|
| 868 |
<div className="flex items-center gap-3">
|
| 869 |
<button
|
|
|
|
| 90 |
setContacts(prev => prev.map(c => c.id === contactId ? { ...c, tags: newTags } : c));
|
| 91 |
fetchAvailableTags();
|
| 92 |
} catch (err: any) {
|
| 93 |
+
toast.error(err?.message ?? t('contacts.tags_update_error'));
|
| 94 |
} finally {
|
| 95 |
setSavingTags(prev => ({ ...prev, [contactId]: false }));
|
| 96 |
}
|
|
|
|
| 157 |
setImportStats(results);
|
| 158 |
fetchContacts();
|
| 159 |
if (results) {
|
| 160 |
+
toast.success(t('contacts.import_success', { created: results.created ?? 0, updated: results.updated ?? 0, errors: results.errors ?? 0 }));
|
| 161 |
}
|
| 162 |
} catch (error) {
|
| 163 |
logError("Import failed:", error);
|
| 164 |
+
toast.error(t('contacts.upload_critical_error'));
|
| 165 |
} finally {
|
| 166 |
setUploading(false);
|
| 167 |
setUploadingFileName('');
|
|
|
|
| 184 |
setAiResult(data);
|
| 185 |
} catch (error) {
|
| 186 |
logError("AI Generation failed:", error);
|
| 187 |
+
toast.error(t('contacts.ai_generation_error'));
|
| 188 |
} finally {
|
| 189 |
setGeneratingAI(false);
|
| 190 |
}
|
|
|
|
| 208 |
const data = await api.post('/v1/ai/crm/generate-campaign', { contact, objective: campaignObjective }, token, selectedOrgId);
|
| 209 |
results.push({ contact, ...data });
|
| 210 |
} catch (err) {
|
| 211 |
+
results.push({ contact, error: true, personalizedMessage: t('contacts.generation_error_fallback') });
|
| 212 |
}
|
| 213 |
}
|
| 214 |
|
|
|
|
| 232 |
|
| 233 |
const handleDeleteContact = async (id: string) => {
|
| 234 |
if (!token || !selectedOrgId) return;
|
| 235 |
+
if (!confirm(t('contacts.confirm_delete_one'))) return;
|
| 236 |
|
| 237 |
try {
|
| 238 |
const res = await api.delete(`/v1/organizations/${selectedOrgId}/contacts/${id}`, token);
|
|
|
|
| 242 |
}
|
| 243 |
} catch (error) {
|
| 244 |
logError("Delete failed:", error);
|
| 245 |
+
toast.error(t('contacts.delete_error'));
|
| 246 |
}
|
| 247 |
};
|
| 248 |
|
| 249 |
const handleBulkDelete = async () => {
|
| 250 |
if (!token || !selectedOrgId || selectedContactIds.length === 0) return;
|
| 251 |
+
if (!confirm(t('contacts.confirm_delete_many', { count: selectedContactIds.length }))) return;
|
| 252 |
|
| 253 |
try {
|
| 254 |
await api.post(`/v1/organizations/${selectedOrgId}/contacts/bulk-delete`, { contactIds: selectedContactIds }, token, selectedOrgId);
|
| 255 |
setContacts(prev => prev.filter(c => !selectedContactIds.includes(c.id)));
|
| 256 |
setSelectedContactIds([]);
|
| 257 |
+
toast.success(t('contacts.bulk_delete_success'));
|
| 258 |
} catch (error) {
|
| 259 |
logError("Bulk delete failed:", error);
|
| 260 |
+
toast.error(t('contacts.bulk_delete_error'));
|
| 261 |
}
|
| 262 |
};
|
| 263 |
|
| 264 |
const handleExportCsv = () => {
|
| 265 |
if (filteredContacts.length === 0) return;
|
| 266 |
+
const headers = [t('contacts.csv_name'), t('contacts.csv_phone'), t('contacts.csv_created')];
|
| 267 |
const rows = filteredContacts.map(c => [
|
| 268 |
c.name ?? '',
|
| 269 |
c.phoneNumber,
|
|
|
|
| 695 |
onChange={(e) => setAiResult({...aiResult, personalizedMessage: e.target.value})}
|
| 696 |
/>
|
| 697 |
<button
|
| 698 |
+
onClick={() => { navigator.clipboard.writeText(aiResult.personalizedMessage); toast.success(t('contacts.message_copied')); }}
|
| 699 |
title="Copier le message"
|
| 700 |
className="absolute top-10 right-4 p-2 bg-white text-slate-400 rounded-lg hover:text-indigo-600 shadow-sm border border-slate-100 opacity-0 group-hover:opacity-100 transition"
|
| 701 |
>
|
|
|
|
| 846 |
<button
|
| 847 |
onClick={() => {
|
| 848 |
navigator.clipboard.writeText(result.personalizedMessage);
|
| 849 |
+
toast.success(t('contacts.copied'));
|
| 850 |
}}
|
| 851 |
className="p-3 bg-slate-100 text-slate-400 rounded-xl hover:bg-slate-200 hover:text-slate-900 transition"
|
| 852 |
title="Copier"
|
|
|
|
| 863 |
<div className="p-8 border-t border-slate-100 bg-slate-50/50 flex items-center justify-between">
|
| 864 |
<div className="flex items-center gap-2 text-emerald-600 font-bold">
|
| 865 |
<CheckCircle2 className="w-5 h-5" />
|
| 866 |
+
{t('contacts.generation_completed')}
|
| 867 |
</div>
|
| 868 |
<div className="flex items-center gap-3">
|
| 869 |
<button
|
apps/admin/src/pages/KnowledgeBasePage.tsx
CHANGED
|
@@ -79,14 +79,14 @@ export default function KnowledgeBasePage() {
|
|
| 79 |
setReindexing(true);
|
| 80 |
try {
|
| 81 |
await api.post(`/v1/organizations/${selectedOrgId}/index-kb`, {}, token);
|
| 82 |
-
toast.success('
|
| 83 |
} catch (err: any) {
|
| 84 |
const msg: string = err?.message ?? '';
|
| 85 |
if (msg.toLowerCase().includes('no kb url') || msg.toLowerCase().includes('not configured')) {
|
| 86 |
-
toast.error('
|
| 87 |
} else {
|
| 88 |
logError('[KB] Re-index failed:', err);
|
| 89 |
-
toast.error('
|
| 90 |
}
|
| 91 |
} finally {
|
| 92 |
setReindexing(false);
|
|
@@ -104,12 +104,12 @@ export default function KnowledgeBasePage() {
|
|
| 104 |
token
|
| 105 |
);
|
| 106 |
setGenResult(res);
|
| 107 |
-
toast.success(
|
| 108 |
await fetchEntries(1);
|
| 109 |
setPage(1);
|
| 110 |
} catch (err: any) {
|
| 111 |
logError('[KB] Generate failed:', err);
|
| 112 |
-
toast.error(err?.message ?? '
|
| 113 |
} finally {
|
| 114 |
setGenerating(false);
|
| 115 |
}
|
|
@@ -154,12 +154,12 @@ export default function KnowledgeBasePage() {
|
|
| 154 |
<div className="bg-white rounded-2xl border border-slate-100 p-5 mb-6">
|
| 155 |
<div className="flex items-center gap-2 mb-3">
|
| 156 |
<Wand2 className="w-4 h-4 text-violet-500" />
|
| 157 |
-
<h2 className="text-sm font-semibold text-slate-700">
|
| 158 |
</div>
|
| 159 |
<textarea
|
| 160 |
value={genDescription}
|
| 161 |
onChange={e => setGenDescription(e.target.value)}
|
| 162 |
-
placeholder=
|
| 163 |
rows={3}
|
| 164 |
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-violet-400 resize-none mb-3"
|
| 165 |
/>
|
|
@@ -169,14 +169,14 @@ export default function KnowledgeBasePage() {
|
|
| 169 |
className="flex items-center gap-2 px-4 py-2 bg-violet-600 text-white rounded-xl text-sm font-medium hover:bg-violet-700 transition disabled:opacity-50"
|
| 170 |
>
|
| 171 |
{generating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Wand2 className="w-4 h-4" />}
|
| 172 |
-
{generating ? '
|
| 173 |
</button>
|
| 174 |
|
| 175 |
{genResult && (
|
| 176 |
<div className="mt-4 pt-4 border-t border-slate-100">
|
| 177 |
<p className="text-sm font-medium text-slate-700 mb-3">
|
| 178 |
<CheckCircle2 className="inline w-4 h-4 text-green-500 mr-1" />
|
| 179 |
-
{
|
| 180 |
</p>
|
| 181 |
<div className="space-y-2">
|
| 182 |
{genResult.preview.slice(0, 3).map((qa, i) => (
|
|
|
|
| 79 |
setReindexing(true);
|
| 80 |
try {
|
| 81 |
await api.post(`/v1/organizations/${selectedOrgId}/index-kb`, {}, token);
|
| 82 |
+
toast.success(t('knowledge.reindex_success'));
|
| 83 |
} catch (err: any) {
|
| 84 |
const msg: string = err?.message ?? '';
|
| 85 |
if (msg.toLowerCase().includes('no kb url') || msg.toLowerCase().includes('not configured')) {
|
| 86 |
+
toast.error(t('knowledge.no_kb_url'));
|
| 87 |
} else {
|
| 88 |
logError('[KB] Re-index failed:', err);
|
| 89 |
+
toast.error(t('knowledge.reindex_error'));
|
| 90 |
}
|
| 91 |
} finally {
|
| 92 |
setReindexing(false);
|
|
|
|
| 104 |
token
|
| 105 |
);
|
| 106 |
setGenResult(res);
|
| 107 |
+
toast.success(t('knowledge.generate_success_count', { count: res.faqCount }));
|
| 108 |
await fetchEntries(1);
|
| 109 |
setPage(1);
|
| 110 |
} catch (err: any) {
|
| 111 |
logError('[KB] Generate failed:', err);
|
| 112 |
+
toast.error(err?.message ?? t('knowledge.generate_error'));
|
| 113 |
} finally {
|
| 114 |
setGenerating(false);
|
| 115 |
}
|
|
|
|
| 154 |
<div className="bg-white rounded-2xl border border-slate-100 p-5 mb-6">
|
| 155 |
<div className="flex items-center gap-2 mb-3">
|
| 156 |
<Wand2 className="w-4 h-4 text-violet-500" />
|
| 157 |
+
<h2 className="text-sm font-semibold text-slate-700">{t('knowledge.generate_from_desc')}</h2>
|
| 158 |
</div>
|
| 159 |
<textarea
|
| 160 |
value={genDescription}
|
| 161 |
onChange={e => setGenDescription(e.target.value)}
|
| 162 |
+
placeholder={t('knowledge.generate_placeholder')}
|
| 163 |
rows={3}
|
| 164 |
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-violet-400 resize-none mb-3"
|
| 165 |
/>
|
|
|
|
| 169 |
className="flex items-center gap-2 px-4 py-2 bg-violet-600 text-white rounded-xl text-sm font-medium hover:bg-violet-700 transition disabled:opacity-50"
|
| 170 |
>
|
| 171 |
{generating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Wand2 className="w-4 h-4" />}
|
| 172 |
+
{generating ? t('knowledge.generating_btn') : t('knowledge.generate_btn')}
|
| 173 |
</button>
|
| 174 |
|
| 175 |
{genResult && (
|
| 176 |
<div className="mt-4 pt-4 border-t border-slate-100">
|
| 177 |
<p className="text-sm font-medium text-slate-700 mb-3">
|
| 178 |
<CheckCircle2 className="inline w-4 h-4 text-green-500 mr-1" />
|
| 179 |
+
{t('knowledge.generate_result_summary', { count: genResult.faqCount, chunks: genResult.chunksIndexed })}
|
| 180 |
</p>
|
| 181 |
<div className="space-y-2">
|
| 182 |
{genResult.preview.slice(0, 3).map((qa, i) => (
|
apps/admin/src/pages/OnboardingWizard.tsx
CHANGED
|
@@ -14,27 +14,11 @@ import { logError, logWarn } from '../lib/logger';
|
|
| 14 |
|
| 15 |
// ─── Modes ───────────────────────────────────────────────────────────────────
|
| 16 |
|
| 17 |
-
const
|
| 18 |
-
{
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
},
|
| 23 |
-
{
|
| 24 |
-
value: 'CRM_MARKETING',
|
| 25 |
-
label: 'CRM & Campagnes',
|
| 26 |
-
desc: 'Gestion de contacts, campagnes broadcast, relances marketing',
|
| 27 |
-
},
|
| 28 |
-
{
|
| 29 |
-
value: 'AI_AGENT',
|
| 30 |
-
label: 'Agent IA',
|
| 31 |
-
desc: 'Bot conversationnel IA pour répondre en autonomie 24h/24',
|
| 32 |
-
},
|
| 33 |
-
{
|
| 34 |
-
value: 'CUSTOMER_SERVICE',
|
| 35 |
-
label: 'Support Client',
|
| 36 |
-
desc: 'Gestion des conversations entrantes et escalade vers un agent humain',
|
| 37 |
-
},
|
| 38 |
];
|
| 39 |
|
| 40 |
// ─── Slug helpers ─────────────────────────────────────────────────────────────
|
|
@@ -50,10 +34,10 @@ function toSlug(name: string): string {
|
|
| 50 |
|
| 51 |
// ─── Steps ───────────────────────────────────────────────────────────────────
|
| 52 |
|
| 53 |
-
const
|
| 54 |
-
{ id: 'org',
|
| 55 |
-
{ id: 'admin',
|
| 56 |
-
{ id: 'whatsapp',
|
| 57 |
];
|
| 58 |
|
| 59 |
// ─── Wizard ──────────────────────────────────────────────────────────────────
|
|
@@ -117,7 +101,7 @@ export default function OnboardingWizard() {
|
|
| 117 |
return true;
|
| 118 |
};
|
| 119 |
|
| 120 |
-
const next = () => setStep(s => Math.min(s + 1,
|
| 121 |
const back = () => setStep(s => Math.max(s - 1, 0));
|
| 122 |
|
| 123 |
// ── Submit ──────────────────────────────────────────────────────────────────
|
|
@@ -154,7 +138,7 @@ export default function OnboardingWizard() {
|
|
| 154 |
navigate('/clients');
|
| 155 |
} catch (err: any) {
|
| 156 |
logError(err);
|
| 157 |
-
toast.error(err.message || '
|
| 158 |
} finally {
|
| 159 |
setLoading(false);
|
| 160 |
}
|
|
@@ -173,7 +157,7 @@ export default function OnboardingWizard() {
|
|
| 173 |
}));
|
| 174 |
} catch (err) {
|
| 175 |
logWarn('[OnboardingWizard] Facebook login failed', err);
|
| 176 |
-
toast.error('
|
| 177 |
}
|
| 178 |
};
|
| 179 |
|
|
@@ -185,7 +169,7 @@ export default function OnboardingWizard() {
|
|
| 185 |
|
| 186 |
{/* Step bar */}
|
| 187 |
<div className="flex border-b border-gray-50">
|
| 188 |
-
{
|
| 189 |
const Icon = s.icon;
|
| 190 |
return (
|
| 191 |
<div key={s.id} className={`flex-1 flex items-center justify-center gap-2 py-5 border-b-2 transition-all ${
|
|
@@ -196,7 +180,7 @@ export default function OnboardingWizard() {
|
|
| 196 |
}`}>
|
| 197 |
{i < step ? <CheckCircle2 className="w-4 h-4" /> : <Icon className="w-4 h-4" />}
|
| 198 |
</div>
|
| 199 |
-
<span className="hidden md:block font-medium text-sm">{s.
|
| 200 |
</div>
|
| 201 |
);
|
| 202 |
})}
|
|
@@ -209,14 +193,14 @@ export default function OnboardingWizard() {
|
|
| 209 |
{step === 0 && (
|
| 210 |
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4">
|
| 211 |
<div>
|
| 212 |
-
<h2 className="text-2xl font-bold text-gray-900">
|
| 213 |
-
<p className="text-gray-400 text-sm mt-1">
|
| 214 |
</div>
|
| 215 |
|
| 216 |
<div className="space-y-4">
|
| 217 |
{/* Nom */}
|
| 218 |
<div>
|
| 219 |
-
<label className="block text-xs font-bold uppercase text-gray-400 mb-1.5">
|
| 220 |
<input
|
| 221 |
type="text"
|
| 222 |
value={org.name}
|
|
@@ -228,7 +212,7 @@ export default function OnboardingWizard() {
|
|
| 228 |
|
| 229 |
{/* Slug */}
|
| 230 |
<div>
|
| 231 |
-
<label className="block text-xs font-bold uppercase text-gray-400 mb-1.5">
|
| 232 |
<div className="flex items-center gap-0 border border-gray-200 rounded-xl overflow-hidden focus-within:ring-2 focus-within:ring-indigo-500/20 focus-within:border-indigo-500">
|
| 233 |
<span className="px-3 py-3 bg-gray-50 text-gray-400 text-sm font-mono border-r border-gray-200 whitespace-nowrap">xamle.studio/</span>
|
| 234 |
<input
|
|
@@ -239,14 +223,14 @@ export default function OnboardingWizard() {
|
|
| 239 |
className="flex-1 px-3 py-3 outline-none text-sm font-mono bg-white"
|
| 240 |
/>
|
| 241 |
</div>
|
| 242 |
-
<p className="text-[11px] text-gray-400 mt-1">
|
| 243 |
</div>
|
| 244 |
|
| 245 |
{/* Mode */}
|
| 246 |
<div>
|
| 247 |
-
<label className="block text-xs font-bold uppercase text-gray-400 mb-2">
|
| 248 |
<div className="grid grid-cols-2 gap-2">
|
| 249 |
-
{
|
| 250 |
<button
|
| 251 |
key={m.value}
|
| 252 |
type="button"
|
|
@@ -257,8 +241,8 @@ export default function OnboardingWizard() {
|
|
| 257 |
: 'border-gray-100 hover:border-gray-200 bg-white'
|
| 258 |
}`}
|
| 259 |
>
|
| 260 |
-
<p className="font-semibold text-sm text-gray-800">{m.
|
| 261 |
-
<p className="text-[11px] text-gray-400 mt-0.5 leading-snug">{m.
|
| 262 |
</button>
|
| 263 |
))}
|
| 264 |
</div>
|
|
@@ -271,13 +255,13 @@ export default function OnboardingWizard() {
|
|
| 271 |
{step === 1 && (
|
| 272 |
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4">
|
| 273 |
<div>
|
| 274 |
-
<h2 className="text-2xl font-bold text-gray-900">
|
| 275 |
-
<p className="text-gray-400 text-sm mt-1">
|
| 276 |
</div>
|
| 277 |
|
| 278 |
<div className="space-y-4">
|
| 279 |
<div>
|
| 280 |
-
<label className="block text-xs font-bold uppercase text-gray-400 mb-1.5">
|
| 281 |
<input
|
| 282 |
type="text"
|
| 283 |
value={admin.adminName}
|
|
@@ -288,7 +272,7 @@ export default function OnboardingWizard() {
|
|
| 288 |
</div>
|
| 289 |
|
| 290 |
<div>
|
| 291 |
-
<label className="block text-xs font-bold uppercase text-gray-400 mb-1.5">
|
| 292 |
<input
|
| 293 |
type="email"
|
| 294 |
value={admin.adminEmail}
|
|
@@ -300,14 +284,14 @@ export default function OnboardingWizard() {
|
|
| 300 |
|
| 301 |
<div>
|
| 302 |
<label className="block text-xs font-bold uppercase text-gray-400 mb-1.5">
|
| 303 |
-
|
| 304 |
</label>
|
| 305 |
<div className="relative">
|
| 306 |
<input
|
| 307 |
type={showPass ? 'text' : 'password'}
|
| 308 |
value={admin.password}
|
| 309 |
onChange={e => setAdmin(s => ({ ...s, password: e.target.value }))}
|
| 310 |
-
placeholder=
|
| 311 |
className="w-full px-4 py-3 pr-10 rounded-xl border border-gray-200 outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition"
|
| 312 |
/>
|
| 313 |
<button
|
|
@@ -318,7 +302,7 @@ export default function OnboardingWizard() {
|
|
| 318 |
{showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 319 |
</button>
|
| 320 |
</div>
|
| 321 |
-
<p className="text-[11px] text-gray-400 mt-1">
|
| 322 |
</div>
|
| 323 |
</div>
|
| 324 |
</div>
|
|
@@ -328,8 +312,8 @@ export default function OnboardingWizard() {
|
|
| 328 |
{step === 2 && (
|
| 329 |
<div className="space-y-5 animate-in fade-in slide-in-from-bottom-4">
|
| 330 |
<div>
|
| 331 |
-
<h2 className="text-2xl font-bold text-gray-900">
|
| 332 |
-
<p className="text-gray-400 text-sm mt-1">
|
| 333 |
</div>
|
| 334 |
|
| 335 |
{/* Contextual help block */}
|
|
@@ -501,13 +485,13 @@ export default function OnboardingWizard() {
|
|
| 501 |
</button>
|
| 502 |
|
| 503 |
<button
|
| 504 |
-
onClick={step ===
|
| 505 |
disabled={loading || !canNext()}
|
| 506 |
className="bg-indigo-600 hover:bg-indigo-700 text-white px-8 py-3 rounded-xl font-bold flex items-center gap-3 shadow-lg shadow-indigo-500/30 transition disabled:opacity-40"
|
| 507 |
>
|
| 508 |
{loading
|
| 509 |
? <><Loader2 className="w-4 h-4 animate-spin" /> {t('onboarding.creating')}</>
|
| 510 |
-
: step ===
|
| 511 |
? <><CheckCircle2 className="w-4 h-4" /> {t('onboarding.create_org')}</>
|
| 512 |
: <>{t('common.next')} <ArrowRight className="w-4 h-4" /></>
|
| 513 |
}
|
|
|
|
| 14 |
|
| 15 |
// ─── Modes ───────────────────────────────────────────────────────────────────
|
| 16 |
|
| 17 |
+
const MODE_KEYS = [
|
| 18 |
+
{ value: 'EDTECH', labelKey: 'onboarding.mode_edtech_label', descKey: 'onboarding.mode_edtech_desc' },
|
| 19 |
+
{ value: 'CRM_MARKETING', labelKey: 'onboarding.mode_crm_label', descKey: 'onboarding.mode_crm_desc' },
|
| 20 |
+
{ value: 'AI_AGENT', labelKey: 'onboarding.mode_ai_label', descKey: 'onboarding.mode_ai_desc' },
|
| 21 |
+
{ value: 'CUSTOMER_SERVICE', labelKey: 'onboarding.mode_customer_service_label', descKey: 'onboarding.mode_customer_service_desc' },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
];
|
| 23 |
|
| 24 |
// ─── Slug helpers ─────────────────────────────────────────────────────────────
|
|
|
|
| 34 |
|
| 35 |
// ─── Steps ───────────────────────────────────────────────────────────────────
|
| 36 |
|
| 37 |
+
const STEP_KEYS = [
|
| 38 |
+
{ id: 'org', titleKey: 'onboarding.step_org', icon: Building2 },
|
| 39 |
+
{ id: 'admin', titleKey: 'onboarding.step_admin', icon: UserCircle },
|
| 40 |
+
{ id: 'whatsapp', titleKey: 'onboarding.step_whatsapp', icon: Smartphone },
|
| 41 |
];
|
| 42 |
|
| 43 |
// ─── Wizard ──────────────────────────────────────────────────────────────────
|
|
|
|
| 101 |
return true;
|
| 102 |
};
|
| 103 |
|
| 104 |
+
const next = () => setStep(s => Math.min(s + 1, STEP_KEYS.length - 1));
|
| 105 |
const back = () => setStep(s => Math.max(s - 1, 0));
|
| 106 |
|
| 107 |
// ── Submit ──────────────────────────────────────────────────────────────────
|
|
|
|
| 138 |
navigate('/clients');
|
| 139 |
} catch (err: any) {
|
| 140 |
logError(err);
|
| 141 |
+
toast.error(err.message || t('onboarding.create_error'));
|
| 142 |
} finally {
|
| 143 |
setLoading(false);
|
| 144 |
}
|
|
|
|
| 157 |
}));
|
| 158 |
} catch (err) {
|
| 159 |
logWarn('[OnboardingWizard] Facebook login failed', err);
|
| 160 |
+
toast.error(t('onboarding.fb_error'));
|
| 161 |
}
|
| 162 |
};
|
| 163 |
|
|
|
|
| 169 |
|
| 170 |
{/* Step bar */}
|
| 171 |
<div className="flex border-b border-gray-50">
|
| 172 |
+
{STEP_KEYS.map((s, i) => {
|
| 173 |
const Icon = s.icon;
|
| 174 |
return (
|
| 175 |
<div key={s.id} className={`flex-1 flex items-center justify-center gap-2 py-5 border-b-2 transition-all ${
|
|
|
|
| 180 |
}`}>
|
| 181 |
{i < step ? <CheckCircle2 className="w-4 h-4" /> : <Icon className="w-4 h-4" />}
|
| 182 |
</div>
|
| 183 |
+
<span className="hidden md:block font-medium text-sm">{t(s.titleKey)}</span>
|
| 184 |
</div>
|
| 185 |
);
|
| 186 |
})}
|
|
|
|
| 193 |
{step === 0 && (
|
| 194 |
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4">
|
| 195 |
<div>
|
| 196 |
+
<h2 className="text-2xl font-bold text-gray-900">{t('onboarding.org_title')}</h2>
|
| 197 |
+
<p className="text-gray-400 text-sm mt-1">{t('onboarding.org_subtitle')}</p>
|
| 198 |
</div>
|
| 199 |
|
| 200 |
<div className="space-y-4">
|
| 201 |
{/* Nom */}
|
| 202 |
<div>
|
| 203 |
+
<label className="block text-xs font-bold uppercase text-gray-400 mb-1.5">{t('onboarding.org_name_label')}</label>
|
| 204 |
<input
|
| 205 |
type="text"
|
| 206 |
value={org.name}
|
|
|
|
| 212 |
|
| 213 |
{/* Slug */}
|
| 214 |
<div>
|
| 215 |
+
<label className="block text-xs font-bold uppercase text-gray-400 mb-1.5">{t('onboarding.slug_label')}</label>
|
| 216 |
<div className="flex items-center gap-0 border border-gray-200 rounded-xl overflow-hidden focus-within:ring-2 focus-within:ring-indigo-500/20 focus-within:border-indigo-500">
|
| 217 |
<span className="px-3 py-3 bg-gray-50 text-gray-400 text-sm font-mono border-r border-gray-200 whitespace-nowrap">xamle.studio/</span>
|
| 218 |
<input
|
|
|
|
| 223 |
className="flex-1 px-3 py-3 outline-none text-sm font-mono bg-white"
|
| 224 |
/>
|
| 225 |
</div>
|
| 226 |
+
<p className="text-[11px] text-gray-400 mt-1">{t('onboarding.slug_hint')}</p>
|
| 227 |
</div>
|
| 228 |
|
| 229 |
{/* Mode */}
|
| 230 |
<div>
|
| 231 |
+
<label className="block text-xs font-bold uppercase text-gray-400 mb-2">{t('onboarding.mode_label')}</label>
|
| 232 |
<div className="grid grid-cols-2 gap-2">
|
| 233 |
+
{MODE_KEYS.map(m => (
|
| 234 |
<button
|
| 235 |
key={m.value}
|
| 236 |
type="button"
|
|
|
|
| 241 |
: 'border-gray-100 hover:border-gray-200 bg-white'
|
| 242 |
}`}
|
| 243 |
>
|
| 244 |
+
<p className="font-semibold text-sm text-gray-800">{t(m.labelKey)}</p>
|
| 245 |
+
<p className="text-[11px] text-gray-400 mt-0.5 leading-snug">{t(m.descKey)}</p>
|
| 246 |
</button>
|
| 247 |
))}
|
| 248 |
</div>
|
|
|
|
| 255 |
{step === 1 && (
|
| 256 |
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4">
|
| 257 |
<div>
|
| 258 |
+
<h2 className="text-2xl font-bold text-gray-900">{t('onboarding.admin_title')}</h2>
|
| 259 |
+
<p className="text-gray-400 text-sm mt-1">{t('onboarding.admin_subtitle')}</p>
|
| 260 |
</div>
|
| 261 |
|
| 262 |
<div className="space-y-4">
|
| 263 |
<div>
|
| 264 |
+
<label className="block text-xs font-bold uppercase text-gray-400 mb-1.5">{t('onboarding.admin_name_label')}</label>
|
| 265 |
<input
|
| 266 |
type="text"
|
| 267 |
value={admin.adminName}
|
|
|
|
| 272 |
</div>
|
| 273 |
|
| 274 |
<div>
|
| 275 |
+
<label className="block text-xs font-bold uppercase text-gray-400 mb-1.5">{t('onboarding.admin_email_label')}</label>
|
| 276 |
<input
|
| 277 |
type="email"
|
| 278 |
value={admin.adminEmail}
|
|
|
|
| 284 |
|
| 285 |
<div>
|
| 286 |
<label className="block text-xs font-bold uppercase text-gray-400 mb-1.5">
|
| 287 |
+
{t('onboarding.admin_pass_label')} <span className="text-gray-300 font-normal normal-case">({t('onboarding.admin_pass_optional')})</span>
|
| 288 |
</label>
|
| 289 |
<div className="relative">
|
| 290 |
<input
|
| 291 |
type={showPass ? 'text' : 'password'}
|
| 292 |
value={admin.password}
|
| 293 |
onChange={e => setAdmin(s => ({ ...s, password: e.target.value }))}
|
| 294 |
+
placeholder={t('onboarding.admin_pass_placeholder')}
|
| 295 |
className="w-full px-4 py-3 pr-10 rounded-xl border border-gray-200 outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition"
|
| 296 |
/>
|
| 297 |
<button
|
|
|
|
| 302 |
{showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 303 |
</button>
|
| 304 |
</div>
|
| 305 |
+
<p className="text-[11px] text-gray-400 mt-1">{t('onboarding.admin_pass_hint')}</p>
|
| 306 |
</div>
|
| 307 |
</div>
|
| 308 |
</div>
|
|
|
|
| 312 |
{step === 2 && (
|
| 313 |
<div className="space-y-5 animate-in fade-in slide-in-from-bottom-4">
|
| 314 |
<div>
|
| 315 |
+
<h2 className="text-2xl font-bold text-gray-900">{t('onboarding.wa_title')}</h2>
|
| 316 |
+
<p className="text-gray-400 text-sm mt-1">{t('onboarding.wa_subtitle')}</p>
|
| 317 |
</div>
|
| 318 |
|
| 319 |
{/* Contextual help block */}
|
|
|
|
| 485 |
</button>
|
| 486 |
|
| 487 |
<button
|
| 488 |
+
onClick={step === STEP_KEYS.length - 1 ? handleSubmit : next}
|
| 489 |
disabled={loading || !canNext()}
|
| 490 |
className="bg-indigo-600 hover:bg-indigo-700 text-white px-8 py-3 rounded-xl font-bold flex items-center gap-3 shadow-lg shadow-indigo-500/30 transition disabled:opacity-40"
|
| 491 |
>
|
| 492 |
{loading
|
| 493 |
? <><Loader2 className="w-4 h-4 animate-spin" /> {t('onboarding.creating')}</>
|
| 494 |
+
: step === STEP_KEYS.length - 1
|
| 495 |
? <><CheckCircle2 className="w-4 h-4" /> {t('onboarding.create_org')}</>
|
| 496 |
: <>{t('common.next')} <ArrowRight className="w-4 h-4" /></>
|
| 497 |
}
|
apps/admin/src/pages/ResetPasswordPage.tsx
CHANGED
|
@@ -37,7 +37,7 @@ export default function ResetPasswordPage() {
|
|
| 37 |
setStep('sent');
|
| 38 |
} catch (err) {
|
| 39 |
logWarn('[ResetPassword] request failed', err);
|
| 40 |
-
setErrorMsg('
|
| 41 |
} finally {
|
| 42 |
setLoading(false);
|
| 43 |
}
|
|
@@ -45,15 +45,15 @@ export default function ResetPasswordPage() {
|
|
| 45 |
|
| 46 |
const handleReset = async (e: React.FormEvent) => {
|
| 47 |
e.preventDefault();
|
| 48 |
-
if (password !== confirm) { setErrorMsg('
|
| 49 |
-
if (password.length < 6) { setErrorMsg('
|
| 50 |
setLoading(true);
|
| 51 |
setErrorMsg('');
|
| 52 |
try {
|
| 53 |
await api.post('/v1/auth/reset-password', { token, password }, null);
|
| 54 |
setStep('done');
|
| 55 |
} catch (err: any) {
|
| 56 |
-
setErrorMsg(err.message || '
|
| 57 |
} finally {
|
| 58 |
setLoading(false);
|
| 59 |
}
|
|
|
|
| 37 |
setStep('sent');
|
| 38 |
} catch (err) {
|
| 39 |
logWarn('[ResetPassword] request failed', err);
|
| 40 |
+
setErrorMsg(t('auth.reset_network_error'));
|
| 41 |
} finally {
|
| 42 |
setLoading(false);
|
| 43 |
}
|
|
|
|
| 45 |
|
| 46 |
const handleReset = async (e: React.FormEvent) => {
|
| 47 |
e.preventDefault();
|
| 48 |
+
if (password !== confirm) { setErrorMsg(t('auth.reset_password_mismatch')); return; }
|
| 49 |
+
if (password.length < 6) { setErrorMsg(t('auth.reset_password_min_length')); return; }
|
| 50 |
setLoading(true);
|
| 51 |
setErrorMsg('');
|
| 52 |
try {
|
| 53 |
await api.post('/v1/auth/reset-password', { token, password }, null);
|
| 54 |
setStep('done');
|
| 55 |
} catch (err: any) {
|
| 56 |
+
setErrorMsg(err.message || t('auth.reset_token_expired'));
|
| 57 |
} finally {
|
| 58 |
setLoading(false);
|
| 59 |
}
|
apps/admin/src/pages/SettingsPage.tsx
CHANGED
|
@@ -72,12 +72,12 @@ export default function SettingsPage() {
|
|
| 72 |
phoneNumberId: waForm.phoneNumberId.trim() || undefined,
|
| 73 |
phoneNumber: waForm.phoneNumber.trim() || undefined,
|
| 74 |
}, token);
|
| 75 |
-
setMessage({ type: 'success', text: '
|
| 76 |
setWaFormOpen(false);
|
| 77 |
setWaForm({ wabaId: '', accessToken: '', phoneNumberId: '', phoneNumber: '' });
|
| 78 |
fetchOrg();
|
| 79 |
} catch {
|
| 80 |
-
setMessage({ type: 'error', text: '
|
| 81 |
} finally {
|
| 82 |
setWaSaving(false);
|
| 83 |
}
|
|
@@ -137,12 +137,12 @@ export default function SettingsPage() {
|
|
| 137 |
onChange={e => setOrg({...org, mode: e.target.value})}
|
| 138 |
className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none bg-white"
|
| 139 |
>
|
| 140 |
-
<option value="EDTECH">
|
| 141 |
-
<option value="WEBHOOK">
|
| 142 |
-
<option value="AI_AGENT">
|
| 143 |
-
<option value="PEDAGOGY">
|
| 144 |
-
<option value="CUSTOMER_SERVICE">
|
| 145 |
-
<option value="CRM_MARKETING">
|
| 146 |
</select>
|
| 147 |
</div>
|
| 148 |
</div>
|
|
@@ -243,7 +243,7 @@ export default function SettingsPage() {
|
|
| 243 |
onClick={() => setWaFormOpen(v => !v)}
|
| 244 |
className="text-xs px-3 py-1.5 bg-slate-700 hover:bg-slate-600 rounded-lg text-slate-300 transition-all"
|
| 245 |
>
|
| 246 |
-
{waFormOpen ? '
|
| 247 |
</button>
|
| 248 |
</div>
|
| 249 |
<div className="space-y-2 text-sm">
|
|
@@ -319,7 +319,7 @@ export default function SettingsPage() {
|
|
| 319 |
disabled={waSaving}
|
| 320 |
className="w-full py-2.5 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-all"
|
| 321 |
>
|
| 322 |
-
{waSaving ? '
|
| 323 |
</button>
|
| 324 |
</form>
|
| 325 |
)}
|
|
|
|
| 72 |
phoneNumberId: waForm.phoneNumberId.trim() || undefined,
|
| 73 |
phoneNumber: waForm.phoneNumber.trim() || undefined,
|
| 74 |
}, token);
|
| 75 |
+
setMessage({ type: 'success', text: t('settings.wa_connect_success') });
|
| 76 |
setWaFormOpen(false);
|
| 77 |
setWaForm({ wabaId: '', accessToken: '', phoneNumberId: '', phoneNumber: '' });
|
| 78 |
fetchOrg();
|
| 79 |
} catch {
|
| 80 |
+
setMessage({ type: 'error', text: t('settings.wa_connect_error') });
|
| 81 |
} finally {
|
| 82 |
setWaSaving(false);
|
| 83 |
}
|
|
|
|
| 137 |
onChange={e => setOrg({...org, mode: e.target.value})}
|
| 138 |
className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none bg-white"
|
| 139 |
>
|
| 140 |
+
<option value="EDTECH">{t('settings.mode_edtech')}</option>
|
| 141 |
+
<option value="WEBHOOK">{t('settings.mode_webhook')}</option>
|
| 142 |
+
<option value="AI_AGENT">{t('settings.mode_ai_agent')}</option>
|
| 143 |
+
<option value="PEDAGOGY">{t('settings.mode_pedagogy')}</option>
|
| 144 |
+
<option value="CUSTOMER_SERVICE">{t('settings.mode_customer_service')}</option>
|
| 145 |
+
<option value="CRM_MARKETING">{t('settings.mode_crm_marketing')}</option>
|
| 146 |
</select>
|
| 147 |
</div>
|
| 148 |
</div>
|
|
|
|
| 243 |
onClick={() => setWaFormOpen(v => !v)}
|
| 244 |
className="text-xs px-3 py-1.5 bg-slate-700 hover:bg-slate-600 rounded-lg text-slate-300 transition-all"
|
| 245 |
>
|
| 246 |
+
{waFormOpen ? t('settings.wa_cancel') : org.wabaId ? t('settings.wa_reconfigure') : t('settings.wa_connect_btn')}
|
| 247 |
</button>
|
| 248 |
</div>
|
| 249 |
<div className="space-y-2 text-sm">
|
|
|
|
| 319 |
disabled={waSaving}
|
| 320 |
className="w-full py-2.5 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-all"
|
| 321 |
>
|
| 322 |
+
{waSaving ? t('settings.wa_connecting') : t('settings.wa_connect_submit')}
|
| 323 |
</button>
|
| 324 |
</form>
|
| 325 |
)}
|
apps/admin/src/pages/TemplatesPage.tsx
CHANGED
|
@@ -212,11 +212,8 @@ export default function TemplatesPage() {
|
|
| 212 |
>
|
| 213 |
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
| 214 |
<div>
|
| 215 |
-
<p className="font-bold text-amber-800 text-sm">
|
| 216 |
-
<p className="text-amber-700 text-sm mt-1">
|
| 217 |
-
Cette organisation n'a pas encore de compte WhatsApp Business (WABA) associé.
|
| 218 |
-
Rendez-vous dans <strong>Paramètres → Intégration WhatsApp</strong> pour configurer votre numéro et votre WABA ID.
|
| 219 |
-
</p>
|
| 220 |
</div>
|
| 221 |
</motion.div>
|
| 222 |
)}
|
|
|
|
| 212 |
>
|
| 213 |
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
| 214 |
<div>
|
| 215 |
+
<p className="font-bold text-amber-800 text-sm">{t('whatsapp.templates.waba_not_configured')}</p>
|
| 216 |
+
<p className="text-amber-700 text-sm mt-1">{t('whatsapp.templates.waba_not_configured_desc')}</p>
|
|
|
|
|
|
|
|
|
|
| 217 |
</div>
|
| 218 |
</motion.div>
|
| 219 |
)}
|
apps/admin/src/pages/TrackDaysPage.tsx
CHANGED
|
@@ -82,30 +82,30 @@ export default function TrackDaysPage() {
|
|
| 82 |
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
| 83 |
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
| 84 |
<div className="flex items-center justify-between p-5 border-b">
|
| 85 |
-
<h2 className="font-bold text-slate-800">{editing.id ? `
|
| 86 |
<button onClick={() => setEditing(null)}><X className="w-5 h-5 text-slate-400" /></button>
|
| 87 |
</div>
|
| 88 |
<form onSubmit={saveDay} className="p-5 space-y-4">
|
| 89 |
<div className="grid grid-cols-2 gap-3">
|
| 90 |
-
<div><label className="text-xs font-medium text-slate-600 mb-1 block">
|
| 91 |
<input type="number" min={1} required className={inp} value={editing.dayNumber} onChange={e => setEditing((d: any) => ({ ...d, dayNumber: parseInt(e.target.value) }))} /></div>
|
| 92 |
-
<div><label className="text-xs font-medium text-slate-600 mb-1 block">
|
| 93 |
<input className={inp} value={editing.title || ''} onChange={e => setEditing((d: any) => ({ ...d, title: e.target.value }))} /></div>
|
| 94 |
</div>
|
| 95 |
-
<div><label className="text-xs font-medium text-slate-600 mb-1 block">
|
| 96 |
-
<textarea className={inp} rows={5} value={editing.lessonText || ''} onChange={e => setEditing((d: any) => ({ ...d, lessonText: e.target.value }))} placeholder=
|
| 97 |
-
<div><label className="text-xs font-medium text-slate-600 mb-1 block">
|
| 98 |
<input className={inp} value={editing.audioUrl || ''} onChange={e => setEditing((d: any) => ({ ...d, audioUrl: e.target.value }))} placeholder="https://..." /></div>
|
| 99 |
<div className="grid grid-cols-2 gap-3">
|
| 100 |
-
<div><label className="text-xs font-medium text-slate-600 mb-1 block">
|
| 101 |
<select className={inp} value={editing.exerciseType} onChange={e => setEditing((d: any) => ({ ...d, exerciseType: e.target.value }))}>
|
| 102 |
-
<option value="TEXT">
|
| 103 |
</select></div>
|
| 104 |
-
<div><label className="text-xs font-medium text-slate-600 mb-1 block">
|
| 105 |
<input className={inp} value={editing.validationKeyword || ''} onChange={e => setEditing((d: any) => ({ ...d, validationKeyword: e.target.value }))} /></div>
|
| 106 |
</div>
|
| 107 |
-
<div><label className="text-xs font-medium text-slate-600 mb-1 block">
|
| 108 |
-
<textarea className={inp} rows={2} value={editing.exercisePrompt || ''} onChange={e => setEditing((d: any) => ({ ...d, exercisePrompt: e.target.value }))} placeholder=
|
| 109 |
<div className="flex gap-3">
|
| 110 |
<button type="button" onClick={() => setEditing(null)} className="flex-1 border border-slate-200 text-slate-600 py-2.5 rounded-xl text-sm hover:bg-slate-50">{t('common.cancel')}</button>
|
| 111 |
<button type="submit" disabled={saving} className="flex-1 bg-slate-900 text-white py-2.5 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-slate-700 disabled:opacity-50">
|
|
@@ -122,8 +122,8 @@ export default function TrackDaysPage() {
|
|
| 122 |
<div className="flex gap-4">
|
| 123 |
<div className="bg-slate-900 text-white w-9 h-9 rounded-lg flex items-center justify-center text-sm font-bold shrink-0">{d.dayNumber}</div>
|
| 124 |
<div>
|
| 125 |
-
<p className="font-medium text-slate-800">{d.title || `
|
| 126 |
-
<p className="text-xs text-slate-500 mt-0.5 line-clamp-1">{d.lessonText?.substring(0, 100) || '
|
| 127 |
<div className="flex gap-2 mt-1.5">
|
| 128 |
<span className="text-xs bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full">{d.exerciseType}</span>
|
| 129 |
{d.audioUrl && <span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">🎵 Audio</span>}
|
|
|
|
| 82 |
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
| 83 |
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
| 84 |
<div className="flex items-center justify-between p-5 border-b">
|
| 85 |
+
<h2 className="font-bold text-slate-800">{editing.id ? `${t('tracks.edit_day')} ${editing.dayNumber}` : t('tracks.new_day')}</h2>
|
| 86 |
<button onClick={() => setEditing(null)}><X className="w-5 h-5 text-slate-400" /></button>
|
| 87 |
</div>
|
| 88 |
<form onSubmit={saveDay} className="p-5 space-y-4">
|
| 89 |
<div className="grid grid-cols-2 gap-3">
|
| 90 |
+
<div><label className="text-xs font-medium text-slate-600 mb-1 block">{t('tracks.day_number')}</label>
|
| 91 |
<input type="number" min={1} required className={inp} value={editing.dayNumber} onChange={e => setEditing((d: any) => ({ ...d, dayNumber: parseInt(e.target.value) }))} /></div>
|
| 92 |
+
<div><label className="text-xs font-medium text-slate-600 mb-1 block">{t('tracks.day_title')}</label>
|
| 93 |
<input className={inp} value={editing.title || ''} onChange={e => setEditing((d: any) => ({ ...d, title: e.target.value }))} /></div>
|
| 94 |
</div>
|
| 95 |
+
<div><label className="text-xs font-medium text-slate-600 mb-1 block">{t('tracks.lesson_text')}</label>
|
| 96 |
+
<textarea className={inp} rows={5} value={editing.lessonText || ''} onChange={e => setEditing((d: any) => ({ ...d, lessonText: e.target.value }))} placeholder={t('tracks.lesson_placeholder')} /></div>
|
| 97 |
+
<div><label className="text-xs font-medium text-slate-600 mb-1 block">{t('tracks.audio_url')}</label>
|
| 98 |
<input className={inp} value={editing.audioUrl || ''} onChange={e => setEditing((d: any) => ({ ...d, audioUrl: e.target.value }))} placeholder="https://..." /></div>
|
| 99 |
<div className="grid grid-cols-2 gap-3">
|
| 100 |
+
<div><label className="text-xs font-medium text-slate-600 mb-1 block">{t('tracks.exercise_type')}</label>
|
| 101 |
<select className={inp} value={editing.exerciseType} onChange={e => setEditing((d: any) => ({ ...d, exerciseType: e.target.value }))}>
|
| 102 |
+
<option value="TEXT">{t('tracks.exercise_type_text')}</option><option value="AUDIO">{t('tracks.exercise_type_audio')}</option><option value="BUTTON">{t('tracks.exercise_type_button')}</option>
|
| 103 |
</select></div>
|
| 104 |
+
<div><label className="text-xs font-medium text-slate-600 mb-1 block">{t('tracks.validation_keyword')}</label>
|
| 105 |
<input className={inp} value={editing.validationKeyword || ''} onChange={e => setEditing((d: any) => ({ ...d, validationKeyword: e.target.value }))} /></div>
|
| 106 |
</div>
|
| 107 |
+
<div><label className="text-xs font-medium text-slate-600 mb-1 block">{t('tracks.exercise_prompt')}</label>
|
| 108 |
+
<textarea className={inp} rows={2} value={editing.exercisePrompt || ''} onChange={e => setEditing((d: any) => ({ ...d, exercisePrompt: e.target.value }))} placeholder={t('tracks.exercise_prompt_placeholder')} /></div>
|
| 109 |
<div className="flex gap-3">
|
| 110 |
<button type="button" onClick={() => setEditing(null)} className="flex-1 border border-slate-200 text-slate-600 py-2.5 rounded-xl text-sm hover:bg-slate-50">{t('common.cancel')}</button>
|
| 111 |
<button type="submit" disabled={saving} className="flex-1 bg-slate-900 text-white py-2.5 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-slate-700 disabled:opacity-50">
|
|
|
|
| 122 |
<div className="flex gap-4">
|
| 123 |
<div className="bg-slate-900 text-white w-9 h-9 rounded-lg flex items-center justify-center text-sm font-bold shrink-0">{d.dayNumber}</div>
|
| 124 |
<div>
|
| 125 |
+
<p className="font-medium text-slate-800">{d.title || `${t('common.day')} ${d.dayNumber}`}</p>
|
| 126 |
+
<p className="text-xs text-slate-500 mt-0.5 line-clamp-1">{d.lessonText?.substring(0, 100) || t('tracks.no_lesson_text')}</p>
|
| 127 |
<div className="flex gap-2 mt-1.5">
|
| 128 |
<span className="text-xs bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full">{d.exerciseType}</span>
|
| 129 |
{d.audioUrl && <span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">🎵 Audio</span>}
|
apps/admin/src/pages/TrackFormPage.tsx
CHANGED
|
@@ -62,24 +62,24 @@ export default function TrackFormPage() {
|
|
| 62 |
<h1 className="text-2xl font-bold text-slate-800">{isNew ? t('tracks.new') : t('common.edit')}</h1>
|
| 63 |
</div>
|
| 64 |
<form onSubmit={handleSubmit} className="bg-white rounded-2xl border border-slate-100 p-6 space-y-4 shadow-sm">
|
| 65 |
-
<div><label className="text-sm font-medium text-slate-700 mb-1 block">
|
| 66 |
<input required className={inp} value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} /></div>
|
| 67 |
-
<div><label className="text-sm font-medium text-slate-700 mb-1 block">
|
| 68 |
<textarea className={inp} rows={3} value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} /></div>
|
| 69 |
<div className="grid grid-cols-2 gap-4">
|
| 70 |
-
<div><label className="text-sm font-medium text-slate-700 mb-1 block">
|
| 71 |
<input type="number" min={1} required className={inp} value={form.duration} onChange={e => setForm(f => ({ ...f, duration: parseInt(e.target.value) }))} /></div>
|
| 72 |
-
<div><label className="text-sm font-medium text-slate-700 mb-1 block">
|
| 73 |
<select className={inp} value={form.language} onChange={e => setForm(f => ({ ...f, language: e.target.value }))}>
|
| 74 |
-
<option value="FR">
|
| 75 |
</select></div>
|
| 76 |
</div>
|
| 77 |
<label className="flex items-center gap-3 p-3 bg-amber-50 rounded-xl cursor-pointer">
|
| 78 |
<input type="checkbox" checked={form.isPremium} onChange={e => setForm(f => ({ ...f, isPremium: e.target.checked }))} className="w-4 h-4" />
|
| 79 |
-
<span className="text-sm font-medium text-amber-800">
|
| 80 |
</label>
|
| 81 |
{form.isPremium && <div>
|
| 82 |
-
<label className="text-sm font-medium text-slate-700 mb-1 block">
|
| 83 |
<input type="number" className={inp} value={form.priceAmount} onChange={e => setForm(f => ({ ...f, priceAmount: parseInt(e.target.value) }))} />
|
| 84 |
</div>}
|
| 85 |
<div className="flex gap-3 pt-2">
|
|
|
|
| 62 |
<h1 className="text-2xl font-bold text-slate-800">{isNew ? t('tracks.new') : t('common.edit')}</h1>
|
| 63 |
</div>
|
| 64 |
<form onSubmit={handleSubmit} className="bg-white rounded-2xl border border-slate-100 p-6 space-y-4 shadow-sm">
|
| 65 |
+
<div><label className="text-sm font-medium text-slate-700 mb-1 block">{t('tracks.form_title_label')} *</label>
|
| 66 |
<input required className={inp} value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} /></div>
|
| 67 |
+
<div><label className="text-sm font-medium text-slate-700 mb-1 block">{t('tracks.form_description')}</label>
|
| 68 |
<textarea className={inp} rows={3} value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} /></div>
|
| 69 |
<div className="grid grid-cols-2 gap-4">
|
| 70 |
+
<div><label className="text-sm font-medium text-slate-700 mb-1 block">{t('tracks.form_duration')}</label>
|
| 71 |
<input type="number" min={1} required className={inp} value={form.duration} onChange={e => setForm(f => ({ ...f, duration: parseInt(e.target.value) }))} /></div>
|
| 72 |
+
<div><label className="text-sm font-medium text-slate-700 mb-1 block">{t('tracks.form_language')}</label>
|
| 73 |
<select className={inp} value={form.language} onChange={e => setForm(f => ({ ...f, language: e.target.value }))}>
|
| 74 |
+
<option value="FR">{t('tracks.form_lang_fr')}</option><option value="WOLOF">{t('tracks.form_lang_wolof')}</option>
|
| 75 |
</select></div>
|
| 76 |
</div>
|
| 77 |
<label className="flex items-center gap-3 p-3 bg-amber-50 rounded-xl cursor-pointer">
|
| 78 |
<input type="checkbox" checked={form.isPremium} onChange={e => setForm(f => ({ ...f, isPremium: e.target.checked }))} className="w-4 h-4" />
|
| 79 |
+
<span className="text-sm font-medium text-amber-800">{t('tracks.form_premium')}</span>
|
| 80 |
</label>
|
| 81 |
{form.isPremium && <div>
|
| 82 |
+
<label className="text-sm font-medium text-slate-700 mb-1 block">{t('tracks.form_price')}</label>
|
| 83 |
<input type="number" className={inp} value={form.priceAmount} onChange={e => setForm(f => ({ ...f, priceAmount: parseInt(e.target.value) }))} />
|
| 84 |
</div>}
|
| 85 |
<div className="flex gap-3 pt-2">
|
apps/admin/src/pages/TrackListPage.tsx
CHANGED
|
@@ -60,11 +60,11 @@ export default function TrackListPage() {
|
|
| 60 |
selectedOrgId
|
| 61 |
);
|
| 62 |
setAiModalOpen(false);
|
| 63 |
-
toast.success(`
|
| 64 |
load();
|
| 65 |
navigate(`/content/${res.track.id}/days`);
|
| 66 |
} catch (err: any) {
|
| 67 |
-
toast.error(err?.message ?? '
|
| 68 |
} finally {
|
| 69 |
setGenerating(false);
|
| 70 |
}
|
|
@@ -97,7 +97,7 @@ export default function TrackListPage() {
|
|
| 97 |
onClick={() => setAiModalOpen(true)}
|
| 98 |
className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-xl text-sm font-bold hover:bg-indigo-700 shadow-lg shadow-indigo-100"
|
| 99 |
>
|
| 100 |
-
<Sparkles className="w-4 h-4" />
|
| 101 |
</button>
|
| 102 |
<button onClick={() => navigate('/content/new')} className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-xl text-sm font-bold hover:bg-slate-700">
|
| 103 |
<Plus className="w-4 h-4" /> {t('tracks.new')}
|
|
@@ -134,7 +134,7 @@ export default function TrackListPage() {
|
|
| 134 |
onClick={() => setAiModalOpen(true)}
|
| 135 |
className="mt-4 inline-flex items-center gap-2 text-indigo-600 hover:text-indigo-700 font-semibold text-sm"
|
| 136 |
>
|
| 137 |
-
<Sparkles className="w-4 h-4" />
|
| 138 |
</button>
|
| 139 |
</div>
|
| 140 |
)}
|
|
@@ -149,10 +149,10 @@ export default function TrackListPage() {
|
|
| 149 |
<div className="flex justify-between items-start">
|
| 150 |
<div>
|
| 151 |
<div className="flex items-center gap-2 text-white/80 text-sm font-medium mb-1">
|
| 152 |
-
<Sparkles className="w-4 h-4" />
|
| 153 |
</div>
|
| 154 |
-
<h2 className="text-2xl font-bold text-white">
|
| 155 |
-
<p className="text-indigo-200 text-sm mt-1">
|
| 156 |
</div>
|
| 157 |
{!generating && (
|
| 158 |
<button onClick={() => setAiModalOpen(false)} className="text-white/60 hover:text-white">
|
|
@@ -164,21 +164,21 @@ export default function TrackListPage() {
|
|
| 164 |
|
| 165 |
<form onSubmit={handleAiGenerate} className="px-8 py-6 space-y-5">
|
| 166 |
<div>
|
| 167 |
-
<label className="block text-sm font-bold text-slate-700 mb-2">
|
| 168 |
<textarea
|
| 169 |
required
|
| 170 |
rows={3}
|
| 171 |
value={aiForm.description}
|
| 172 |
onChange={e => setAiForm(f => ({ ...f, description: e.target.value }))}
|
| 173 |
disabled={generating}
|
| 174 |
-
placeholder=
|
| 175 |
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all text-sm resize-none"
|
| 176 |
/>
|
| 177 |
</div>
|
| 178 |
|
| 179 |
<div className="grid grid-cols-2 gap-4">
|
| 180 |
<div>
|
| 181 |
-
<label className="block text-sm font-bold text-slate-700 mb-2">
|
| 182 |
<input
|
| 183 |
type="number"
|
| 184 |
min={1}
|
|
@@ -190,28 +190,28 @@ export default function TrackListPage() {
|
|
| 190 |
/>
|
| 191 |
</div>
|
| 192 |
<div>
|
| 193 |
-
<label className="block text-sm font-bold text-slate-700 mb-2">
|
| 194 |
<select
|
| 195 |
value={aiForm.language}
|
| 196 |
onChange={e => setAiForm(f => ({ ...f, language: e.target.value }))}
|
| 197 |
disabled={generating}
|
| 198 |
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all text-sm"
|
| 199 |
>
|
| 200 |
-
<option value="FR">
|
| 201 |
-
<option value="EN">
|
| 202 |
-
<option value="WOL">
|
| 203 |
</select>
|
| 204 |
</div>
|
| 205 |
</div>
|
| 206 |
|
| 207 |
<div>
|
| 208 |
-
<label className="block text-sm font-bold text-slate-700 mb-2">
|
| 209 |
<input
|
| 210 |
type="text"
|
| 211 |
value={aiForm.targetAudience}
|
| 212 |
onChange={e => setAiForm(f => ({ ...f, targetAudience: e.target.value }))}
|
| 213 |
disabled={generating}
|
| 214 |
-
placeholder=
|
| 215 |
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all text-sm"
|
| 216 |
/>
|
| 217 |
</div>
|
|
@@ -224,12 +224,12 @@ export default function TrackListPage() {
|
|
| 224 |
{generating ? (
|
| 225 |
<>
|
| 226 |
<Loader2 className="w-4 h-4 animate-spin" />
|
| 227 |
-
|
| 228 |
</>
|
| 229 |
) : (
|
| 230 |
<>
|
| 231 |
<Sparkles className="w-4 h-4" />
|
| 232 |
-
|
| 233 |
</>
|
| 234 |
)}
|
| 235 |
</button>
|
|
|
|
| 60 |
selectedOrgId
|
| 61 |
);
|
| 62 |
setAiModalOpen(false);
|
| 63 |
+
toast.success(`${res.track.title} — ${res.track.days.length} ${t('tracks.days')}`);
|
| 64 |
load();
|
| 65 |
navigate(`/content/${res.track.id}/days`);
|
| 66 |
} catch (err: any) {
|
| 67 |
+
toast.error(err?.message ?? t('tracks.ai_error'));
|
| 68 |
} finally {
|
| 69 |
setGenerating(false);
|
| 70 |
}
|
|
|
|
| 97 |
onClick={() => setAiModalOpen(true)}
|
| 98 |
className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-xl text-sm font-bold hover:bg-indigo-700 shadow-lg shadow-indigo-100"
|
| 99 |
>
|
| 100 |
+
<Sparkles className="w-4 h-4" /> {t('tracks.ai_generate_btn')}
|
| 101 |
</button>
|
| 102 |
<button onClick={() => navigate('/content/new')} className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-xl text-sm font-bold hover:bg-slate-700">
|
| 103 |
<Plus className="w-4 h-4" /> {t('tracks.new')}
|
|
|
|
| 134 |
onClick={() => setAiModalOpen(true)}
|
| 135 |
className="mt-4 inline-flex items-center gap-2 text-indigo-600 hover:text-indigo-700 font-semibold text-sm"
|
| 136 |
>
|
| 137 |
+
<Sparkles className="w-4 h-4" /> {t('tracks.ai_generate_first')}
|
| 138 |
</button>
|
| 139 |
</div>
|
| 140 |
)}
|
|
|
|
| 149 |
<div className="flex justify-between items-start">
|
| 150 |
<div>
|
| 151 |
<div className="flex items-center gap-2 text-white/80 text-sm font-medium mb-1">
|
| 152 |
+
<Sparkles className="w-4 h-4" /> {t('tracks.ai_modal_badge')}
|
| 153 |
</div>
|
| 154 |
+
<h2 className="text-2xl font-bold text-white">{t('tracks.ai_modal_title')}</h2>
|
| 155 |
+
<p className="text-indigo-200 text-sm mt-1">{t('tracks.ai_modal_subtitle')}</p>
|
| 156 |
</div>
|
| 157 |
{!generating && (
|
| 158 |
<button onClick={() => setAiModalOpen(false)} className="text-white/60 hover:text-white">
|
|
|
|
| 164 |
|
| 165 |
<form onSubmit={handleAiGenerate} className="px-8 py-6 space-y-5">
|
| 166 |
<div>
|
| 167 |
+
<label className="block text-sm font-bold text-slate-700 mb-2">{t('tracks.ai_description_label')} *</label>
|
| 168 |
<textarea
|
| 169 |
required
|
| 170 |
rows={3}
|
| 171 |
value={aiForm.description}
|
| 172 |
onChange={e => setAiForm(f => ({ ...f, description: e.target.value }))}
|
| 173 |
disabled={generating}
|
| 174 |
+
placeholder={t('tracks.ai_description_placeholder')}
|
| 175 |
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all text-sm resize-none"
|
| 176 |
/>
|
| 177 |
</div>
|
| 178 |
|
| 179 |
<div className="grid grid-cols-2 gap-4">
|
| 180 |
<div>
|
| 181 |
+
<label className="block text-sm font-bold text-slate-700 mb-2">{t('tracks.ai_num_days')}</label>
|
| 182 |
<input
|
| 183 |
type="number"
|
| 184 |
min={1}
|
|
|
|
| 190 |
/>
|
| 191 |
</div>
|
| 192 |
<div>
|
| 193 |
+
<label className="block text-sm font-bold text-slate-700 mb-2">{t('tracks.ai_language')}</label>
|
| 194 |
<select
|
| 195 |
value={aiForm.language}
|
| 196 |
onChange={e => setAiForm(f => ({ ...f, language: e.target.value }))}
|
| 197 |
disabled={generating}
|
| 198 |
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all text-sm"
|
| 199 |
>
|
| 200 |
+
<option value="FR">{t('tracks.ai_lang_fr')}</option>
|
| 201 |
+
<option value="EN">{t('tracks.ai_lang_en')}</option>
|
| 202 |
+
<option value="WOL">{t('tracks.ai_lang_wol')}</option>
|
| 203 |
</select>
|
| 204 |
</div>
|
| 205 |
</div>
|
| 206 |
|
| 207 |
<div>
|
| 208 |
+
<label className="block text-sm font-bold text-slate-700 mb-2">{t('tracks.ai_audience')} <span className="text-slate-400 font-normal">({t('tracks.ai_audience_optional')})</span></label>
|
| 209 |
<input
|
| 210 |
type="text"
|
| 211 |
value={aiForm.targetAudience}
|
| 212 |
onChange={e => setAiForm(f => ({ ...f, targetAudience: e.target.value }))}
|
| 213 |
disabled={generating}
|
| 214 |
+
placeholder={t('tracks.ai_audience_placeholder')}
|
| 215 |
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all text-sm"
|
| 216 |
/>
|
| 217 |
</div>
|
|
|
|
| 224 |
{generating ? (
|
| 225 |
<>
|
| 226 |
<Loader2 className="w-4 h-4 animate-spin" />
|
| 227 |
+
{t('tracks.ai_generating')}
|
| 228 |
</>
|
| 229 |
) : (
|
| 230 |
<>
|
| 231 |
<Sparkles className="w-4 h-4" />
|
| 232 |
+
{t('tracks.ai_generate_submit')}
|
| 233 |
</>
|
| 234 |
)}
|
| 235 |
</button>
|
apps/admin/src/pages/TrainingLab.tsx
CHANGED
|
@@ -76,7 +76,7 @@ export default function TrainingLab() {
|
|
| 76 |
setAudios(prev => prev.filter(a => a.id !== selectedAudio.id));
|
| 77 |
} catch (err: any) {
|
| 78 |
logError(err);
|
| 79 |
-
toast.error(err.message || '
|
| 80 |
} finally {
|
| 81 |
setSubmitting(false);
|
| 82 |
}
|
|
@@ -101,7 +101,7 @@ export default function TrainingLab() {
|
|
| 101 |
setSubmitting(true);
|
| 102 |
try {
|
| 103 |
const json = await api.post('/v1/admin/training/apply-suggestions', { suggestions: payload }, token, selectedOrgId);
|
| 104 |
-
toast.success(
|
| 105 |
fetchSuggestions();
|
| 106 |
} catch (err: any) {
|
| 107 |
logError(err);
|
|
@@ -177,7 +177,7 @@ export default function TrainingLab() {
|
|
| 177 |
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition disabled:opacity-50"
|
| 178 |
>
|
| 179 |
<Save className="w-4 h-4" />
|
| 180 |
-
|
| 181 |
</button>
|
| 182 |
</div>
|
| 183 |
</div>
|
|
@@ -309,7 +309,7 @@ export default function TrainingLab() {
|
|
| 309 |
|
| 310 |
<div className="mb-6">
|
| 311 |
<h3 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3 flex items-center justify-between">
|
| 312 |
-
|
| 313 |
<span className="text-xs font-normal text-indigo-500 bg-indigo-50 px-2 py-0.5 rounded-full">Wolof Standardisé</span>
|
| 314 |
</h3>
|
| 315 |
<textarea
|
|
@@ -341,7 +341,7 @@ export default function TrainingLab() {
|
|
| 341 |
|
| 342 |
<h3 className="text-lg font-bold mb-6 flex items-center gap-2">
|
| 343 |
<CheckCircle className="w-5 h-5 text-emerald-400" />
|
| 344 |
-
|
| 345 |
</h3>
|
| 346 |
|
| 347 |
<div className="grid grid-cols-2 gap-4 mb-6 relative z-10">
|
|
|
|
| 76 |
setAudios(prev => prev.filter(a => a.id !== selectedAudio.id));
|
| 77 |
} catch (err: any) {
|
| 78 |
logError(err);
|
| 79 |
+
toast.error(err.message || t('common.error'));
|
| 80 |
} finally {
|
| 81 |
setSubmitting(false);
|
| 82 |
}
|
|
|
|
| 101 |
setSubmitting(true);
|
| 102 |
try {
|
| 103 |
const json = await api.post('/v1/admin/training/apply-suggestions', { suggestions: payload }, token, selectedOrgId);
|
| 104 |
+
toast.success(t('training.rules_injected', { count: json.injectedCount }));
|
| 105 |
fetchSuggestions();
|
| 106 |
} catch (err: any) {
|
| 107 |
logError(err);
|
|
|
|
| 177 |
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition disabled:opacity-50"
|
| 178 |
>
|
| 179 |
<Save className="w-4 h-4" />
|
| 180 |
+
{t('training.inject_rules', { count: selectedSuggestions.size })}
|
| 181 |
</button>
|
| 182 |
</div>
|
| 183 |
</div>
|
|
|
|
| 309 |
|
| 310 |
<div className="mb-6">
|
| 311 |
<h3 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3 flex items-center justify-between">
|
| 312 |
+
{t('training.ground_truth_label')}
|
| 313 |
<span className="text-xs font-normal text-indigo-500 bg-indigo-50 px-2 py-0.5 rounded-full">Wolof Standardisé</span>
|
| 314 |
</h3>
|
| 315 |
<textarea
|
|
|
|
| 341 |
|
| 342 |
<h3 className="text-lg font-bold mb-6 flex items-center gap-2">
|
| 343 |
<CheckCircle className="w-5 h-5 text-emerald-400" />
|
| 344 |
+
{t('training.training_saved')}
|
| 345 |
</h3>
|
| 346 |
|
| 347 |
<div className="grid grid-cols-2 gap-4 mb-6 relative z-10">
|
apps/admin/src/pages/UserListPage.tsx
CHANGED
|
@@ -29,7 +29,7 @@ export default function UserListPage() {
|
|
| 29 |
setLoading(true);
|
| 30 |
api.get(`/v1/admin/users?page=${page}&limit=${LIMIT}`, token, selectedOrgId)
|
| 31 |
.then(d => { setUsers(d.users || d); setTotal(d.total || 0); setLoading(false); })
|
| 32 |
-
.catch((err) => { setLoading(false); toast.error(err?.message ?? '
|
| 33 |
};
|
| 34 |
|
| 35 |
useEffect(() => { loadUsers(); }, [token, selectedOrgId, page]);
|
|
@@ -54,15 +54,15 @@ export default function UserListPage() {
|
|
| 54 |
};
|
| 55 |
|
| 56 |
const handleDelete = async (userId: string) => {
|
| 57 |
-
if (!confirm('
|
| 58 |
setDeletingId(userId);
|
| 59 |
try {
|
| 60 |
await api.delete(`/v1/admin/users/${userId}`, token, selectedOrgId);
|
| 61 |
setUsers(prev => prev.filter(u => u.id !== userId));
|
| 62 |
setTotal(prev => prev - 1);
|
| 63 |
-
toast.success('
|
| 64 |
} catch (err: any) {
|
| 65 |
-
toast.error(err?.message ?? '
|
| 66 |
} finally {
|
| 67 |
setDeletingId(null);
|
| 68 |
}
|
|
@@ -73,9 +73,9 @@ export default function UserListPage() {
|
|
| 73 |
try {
|
| 74 |
const res = await api.delete(`/v1/admin/users/${userId}/handoff`, token, selectedOrgId);
|
| 75 |
setHandoffStatus(prev => ({ ...prev, [userId]: false }));
|
| 76 |
-
toast.success(res.wasActive ? '
|
| 77 |
} catch (err: any) {
|
| 78 |
-
toast.error(err?.message ?? '
|
| 79 |
} finally {
|
| 80 |
setReleasingId(null);
|
| 81 |
}
|
|
@@ -100,7 +100,7 @@ export default function UserListPage() {
|
|
| 100 |
}
|
| 101 |
|
| 102 |
const tableHeaders = [
|
| 103 |
-
t('common.phone'), t('common.name'), '
|
| 104 |
t('nav.organizations'), t('users.columns.status'), t('common.date'), t('common.actions')
|
| 105 |
];
|
| 106 |
|
|
@@ -130,13 +130,13 @@ export default function UserListPage() {
|
|
| 130 |
onClick={() => viewMessages(u.id)}
|
| 131 |
className="text-xs bg-slate-100 hover:bg-slate-200 text-slate-700 px-3 py-1.5 rounded-lg font-medium transition-colors"
|
| 132 |
>
|
| 133 |
-
|
| 134 |
</button>
|
| 135 |
<button
|
| 136 |
onClick={() => handleDelete(u.id)}
|
| 137 |
disabled={deletingId === u.id}
|
| 138 |
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-40"
|
| 139 |
-
title=
|
| 140 |
>
|
| 141 |
{deletingId === u.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
| 142 |
</button>
|
|
@@ -154,10 +154,10 @@ export default function UserListPage() {
|
|
| 154 |
{/* Pagination */}
|
| 155 |
{total > LIMIT && (
|
| 156 |
<div className="flex items-center justify-between text-sm text-slate-400 mt-4">
|
| 157 |
-
<span>{((page - 1) * LIMIT) + 1}–{Math.min(page * LIMIT, total)}
|
| 158 |
<div className="flex gap-2">
|
| 159 |
-
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="px-3 py-1.5 bg-white border border-slate-200 rounded-lg disabled:opacity-40 hover:bg-slate-50 transition-colors">
|
| 160 |
-
<button onClick={() => setPage(p => p + 1)} disabled={page * LIMIT >= total} className="px-3 py-1.5 bg-white border border-slate-200 rounded-lg disabled:opacity-40 hover:bg-slate-50 transition-colors">
|
| 161 |
</div>
|
| 162 |
</div>
|
| 163 |
)}
|
|
@@ -175,14 +175,14 @@ export default function UserListPage() {
|
|
| 175 |
{handoffStatus[selectedUser.id] && (
|
| 176 |
<div className="flex items-center gap-2 bg-amber-50 border border-amber-200 rounded-xl px-3 py-1.5">
|
| 177 |
<AlertTriangle className="w-3.5 h-3.5 text-amber-500" />
|
| 178 |
-
<span className="text-xs font-bold text-amber-700">
|
| 179 |
<button
|
| 180 |
onClick={() => handleReleaseHandoff(selectedUser.id)}
|
| 181 |
disabled={releasingId === selectedUser.id}
|
| 182 |
className="ml-1 flex items-center gap-1 text-xs bg-amber-500 hover:bg-amber-600 text-white px-2 py-0.5 rounded-lg font-bold transition-colors disabled:opacity-50"
|
| 183 |
>
|
| 184 |
{releasingId === selectedUser.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <UserCheck className="w-3 h-3" />}
|
| 185 |
-
|
| 186 |
</button>
|
| 187 |
</div>
|
| 188 |
)}
|
|
|
|
| 29 |
setLoading(true);
|
| 30 |
api.get(`/v1/admin/users?page=${page}&limit=${LIMIT}`, token, selectedOrgId)
|
| 31 |
.then(d => { setUsers(d.users || d); setTotal(d.total || 0); setLoading(false); })
|
| 32 |
+
.catch((err) => { setLoading(false); toast.error(err?.message ?? t('users.load_error')); });
|
| 33 |
};
|
| 34 |
|
| 35 |
useEffect(() => { loadUsers(); }, [token, selectedOrgId, page]);
|
|
|
|
| 54 |
};
|
| 55 |
|
| 56 |
const handleDelete = async (userId: string) => {
|
| 57 |
+
if (!confirm(t('users.confirm_delete'))) return;
|
| 58 |
setDeletingId(userId);
|
| 59 |
try {
|
| 60 |
await api.delete(`/v1/admin/users/${userId}`, token, selectedOrgId);
|
| 61 |
setUsers(prev => prev.filter(u => u.id !== userId));
|
| 62 |
setTotal(prev => prev - 1);
|
| 63 |
+
toast.success(t('users.delete_success'));
|
| 64 |
} catch (err: any) {
|
| 65 |
+
toast.error(err?.message ?? t('users.delete_error'));
|
| 66 |
} finally {
|
| 67 |
setDeletingId(null);
|
| 68 |
}
|
|
|
|
| 73 |
try {
|
| 74 |
const res = await api.delete(`/v1/admin/users/${userId}/handoff`, token, selectedOrgId);
|
| 75 |
setHandoffStatus(prev => ({ ...prev, [userId]: false }));
|
| 76 |
+
toast.success(res.wasActive ? t('users.handoff_released') : t('users.handoff_none'));
|
| 77 |
} catch (err: any) {
|
| 78 |
+
toast.error(err?.message ?? t('users.handoff_error'));
|
| 79 |
} finally {
|
| 80 |
setReleasingId(null);
|
| 81 |
}
|
|
|
|
| 100 |
}
|
| 101 |
|
| 102 |
const tableHeaders = [
|
| 103 |
+
t('common.phone'), t('common.name'), t('users.language_column'), t('users.sector_column'),
|
| 104 |
t('nav.organizations'), t('users.columns.status'), t('common.date'), t('common.actions')
|
| 105 |
];
|
| 106 |
|
|
|
|
| 130 |
onClick={() => viewMessages(u.id)}
|
| 131 |
className="text-xs bg-slate-100 hover:bg-slate-200 text-slate-700 px-3 py-1.5 rounded-lg font-medium transition-colors"
|
| 132 |
>
|
| 133 |
+
{t('users.conversation_btn')}
|
| 134 |
</button>
|
| 135 |
<button
|
| 136 |
onClick={() => handleDelete(u.id)}
|
| 137 |
disabled={deletingId === u.id}
|
| 138 |
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-40"
|
| 139 |
+
title={t('users.delete_title')}
|
| 140 |
>
|
| 141 |
{deletingId === u.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
| 142 |
</button>
|
|
|
|
| 154 |
{/* Pagination */}
|
| 155 |
{total > LIMIT && (
|
| 156 |
<div className="flex items-center justify-between text-sm text-slate-400 mt-4">
|
| 157 |
+
<span>{((page - 1) * LIMIT) + 1}–{Math.min(page * LIMIT, total)} {t('common.of')} {total}</span>
|
| 158 |
<div className="flex gap-2">
|
| 159 |
+
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="px-3 py-1.5 bg-white border border-slate-200 rounded-lg disabled:opacity-40 hover:bg-slate-50 transition-colors">{t('users.prev')}</button>
|
| 160 |
+
<button onClick={() => setPage(p => p + 1)} disabled={page * LIMIT >= total} className="px-3 py-1.5 bg-white border border-slate-200 rounded-lg disabled:opacity-40 hover:bg-slate-50 transition-colors">{t('common.next')}</button>
|
| 161 |
</div>
|
| 162 |
</div>
|
| 163 |
)}
|
|
|
|
| 175 |
{handoffStatus[selectedUser.id] && (
|
| 176 |
<div className="flex items-center gap-2 bg-amber-50 border border-amber-200 rounded-xl px-3 py-1.5">
|
| 177 |
<AlertTriangle className="w-3.5 h-3.5 text-amber-500" />
|
| 178 |
+
<span className="text-xs font-bold text-amber-700">{t('users.handoff_active')}</span>
|
| 179 |
<button
|
| 180 |
onClick={() => handleReleaseHandoff(selectedUser.id)}
|
| 181 |
disabled={releasingId === selectedUser.id}
|
| 182 |
className="ml-1 flex items-center gap-1 text-xs bg-amber-500 hover:bg-amber-600 text-white px-2 py-0.5 rounded-lg font-bold transition-colors disabled:opacity-50"
|
| 183 |
>
|
| 184 |
{releasingId === selectedUser.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <UserCheck className="w-3 h-3" />}
|
| 185 |
+
{t('users.release_ai')}
|
| 186 |
</button>
|
| 187 |
</div>
|
| 188 |
)}
|