CognxSafeTrack commited on
Commit
d80fec4
·
1 Parent(s): 4f90920

feat(i18n): complete admin app internationalization across all pages

Browse files

Replace 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 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 ?? 'Erreur lors de l\'exécution de la requête');
77
  } finally {
78
  setSqlLoading(false);
79
  }
80
  };
81
 
82
  const EXAMPLE_QUESTIONS = [
83
- 'Combien d\'utilisateurs actifs cette semaine ?',
84
- 'Quels sont les 5 utilisateurs avec le plus de messages ?',
85
- 'Quel est le taux de complétion moyen ?',
86
- 'Combien de crédits IA ont été consommés ce mois ?',
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">Coût IA par fonctionnalité</h2>
238
- <p className="text-xs text-slate-400">Données réelles — source : UsageEvent</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">Fonctionnalité</th>
246
- <th className="pb-3 font-semibold text-right">Appels</th>
247
- <th className="pb-3 font-semibold text-right">Tokens in</th>
248
- <th className="pb-3 font-semibold text-right">Tokens out</th>
249
- <th className="pb-3 font-semibold text-right">Coût (USD)</th>
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}>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,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">Recherche en langage naturel</h2>
286
- <p className="text-xs text-slate-400">Posez une question sur vos données — l'IA génère la requête SQL</p>
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="Ex : Quels sont les utilisateurs inactifs depuis 7 jours ?"
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
- Rechercher
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" /> Voir SQL
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">Aucun résultat</div>
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: 'Erreur réseau' } }));
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 ?? 'Échec de la mise à jour des tags');
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(`Import réussi : ${results.created ?? 0} ajoutés, ${results.updated ?? 0} mis à jour, ${results.errors ?? 0} erreurs.`);
161
  }
162
  } catch (error) {
163
  logError("Import failed:", error);
164
- toast.error("Une erreur critique est survenue lors de l'upload.");
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("Échec de la génération IA.");
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: "Erreur de génération" });
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("Voulez-vous vraiment supprimer ce contact ?")) return;
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("Erreur lors de la suppression.");
246
  }
247
  };
248
 
249
  const handleBulkDelete = async () => {
250
  if (!token || !selectedOrgId || selectedContactIds.length === 0) return;
251
- if (!confirm(`Voulez-vous vraiment supprimer ${selectedContactIds.length} contacts ?`)) 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("Contacts supprimés avec succès.");
258
  } catch (error) {
259
  logError("Bulk delete failed:", error);
260
- toast.error("Erreur lors de la suppression groupée.");
261
  }
262
  };
263
 
264
  const handleExportCsv = () => {
265
  if (filteredContacts.length === 0) return;
266
- const headers = ['Nom', 'Téléphone', 'Créé le'];
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('Message copié !'); }}
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("Copié !");
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
- Génération terminée avec succès
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('Réindexation lancée avec succès');
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('Aucune URL de base de connaissances configurée. Ajoutez une URL dans Paramètres.');
87
  } else {
88
  logError('[KB] Re-index failed:', err);
89
- toast.error('Échec de la réindexation');
90
  }
91
  } finally {
92
  setReindexing(false);
@@ -104,12 +104,12 @@ export default function KnowledgeBasePage() {
104
  token
105
  );
106
  setGenResult(res);
107
- toast.success(`${res.faqCount} Q&A générées et indexées`);
108
  await fetchEntries(1);
109
  setPage(1);
110
  } catch (err: any) {
111
  logError('[KB] Generate failed:', err);
112
- toast.error(err?.message ?? 'Échec de la génération');
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">Générer depuis une description</h2>
158
  </div>
159
  <textarea
160
  value={genDescription}
161
  onChange={e => setGenDescription(e.target.value)}
162
- 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."
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 ? 'Génération en cours…' : 'Générer'}
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
- {genResult.faqCount} Q&R générées · {genResult.chunksIndexed} chunks indexés
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 MODES = [
18
- {
19
- value: 'EDTECH',
20
- label: 'Formation & EdTech',
21
- desc: 'Parcours éducatifs, exercices, suivi des apprenants via WhatsApp',
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 STEPS = [
54
- { id: 'org', title: 'Organisation', icon: Building2 },
55
- { id: 'admin', title: 'Administrateur', icon: UserCircle },
56
- { id: 'whatsapp', title: 'WhatsApp', icon: Smartphone },
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, STEPS.length - 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 || 'Création impossible');
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('La connexion Facebook a été annulée ou a échoué.');
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
- {STEPS.map((s, i) => {
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.title}</span>
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">L'organisation</h2>
213
- <p className="text-gray-400 text-sm mt-1">Nom, identifiant URL et type d'usage.</p>
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">Nom de l'organisation</label>
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">Identifiant URL (slug)</label>
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">Auto-généré depuis le nom. Modifiable — lettres minuscules, chiffres et tirets uniquement.</p>
243
  </div>
244
 
245
  {/* Mode */}
246
  <div>
247
- <label className="block text-xs font-bold uppercase text-gray-400 mb-2">Type d'usage</label>
248
  <div className="grid grid-cols-2 gap-2">
249
- {MODES.map(m => (
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.label}</p>
261
- <p className="text-[11px] text-gray-400 mt-0.5 leading-snug">{m.desc}</p>
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">L'administrateur</h2>
275
- <p className="text-gray-400 text-sm mt-1">Le premier compte admin de cette organisation.</p>
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">Nom complet</label>
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">Email</label>
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
- Mot de passe initial <span className="text-gray-300 font-normal normal-case">(optionnel — généré automatiquement si vide)</span>
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="Laissez vide pour auto-générer"
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">Un email avec le mot de passe temporaire est envoyé à l'admin après création.</p>
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">Connexion WhatsApp</h2>
332
- <p className="text-gray-400 text-sm mt-1">Optionnel — peut être configuré plus tard depuis la fiche organisation.</p>
333
  </div>
334
 
335
  {/* Contextual help block */}
@@ -501,13 +485,13 @@ export default function OnboardingWizard() {
501
  </button>
502
 
503
  <button
504
- onClick={step === STEPS.length - 1 ? handleSubmit : next}
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 === STEPS.length - 1
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('Erreur réseau. Veuillez réessayer.');
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('Les mots de passe ne correspondent pas.'); return; }
49
- if (password.length < 6) { setErrorMsg('Le mot de passe doit contenir au moins 6 caractères.'); 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 || 'Token invalide ou expiré.');
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: 'WhatsApp connecté avec succès ✅' });
76
  setWaFormOpen(false);
77
  setWaForm({ wabaId: '', accessToken: '', phoneNumberId: '', phoneNumber: '' });
78
  fetchOrg();
79
  } catch {
80
- setMessage({ type: 'error', text: 'Échec de la connexion WhatsApp. Vérifie le token et le WABA ID.' });
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">EdTech</option>
141
- <option value="WEBHOOK">Webhook</option>
142
- <option value="AI_AGENT">Agent IA</option>
143
- <option value="PEDAGOGY">Pédagogie</option>
144
- <option value="CUSTOMER_SERVICE">Support Client</option>
145
- <option value="CRM_MARKETING">CRM & Campagnes</option>
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 ? 'Annuler' : org.wabaId ? '🔄 Reconfigurer' : '🔗 Connecter'}
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 ? 'Connexion...' : 'Connecter WhatsApp'}
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">WhatsApp Business non configuré</p>
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 ? `Modifier Jour ${editing.dayNumber}` : 'Nouveau jour'}</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">Numéro du jour</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">Titre</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">Texte de la leçon</label>
96
- <textarea className={inp} rows={5} value={editing.lessonText || ''} onChange={e => setEditing((d: any) => ({ ...d, lessonText: e.target.value }))} placeholder="Contenu pédagogique..." /></div>
97
- <div><label className="text-xs font-medium text-slate-600 mb-1 block">URL Audio (optionnel)</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">Type exercice</label>
101
  <select className={inp} value={editing.exerciseType} onChange={e => setEditing((d: any) => ({ ...d, exerciseType: e.target.value }))}>
102
- <option value="TEXT">Texte libre</option><option value="AUDIO">Audio</option><option value="BUTTON">Boutons</option>
103
  </select></div>
104
- <div><label className="text-xs font-medium text-slate-600 mb-1 block">Mot-clé validation</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">Prompt exercice</label>
108
- <textarea className={inp} rows={2} value={editing.exercisePrompt || ''} onChange={e => setEditing((d: any) => ({ ...d, exercisePrompt: e.target.value }))} placeholder="Question posée à l'étudiant..." /></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,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 || `Jour ${d.dayNumber}`}</p>
126
- <p className="text-xs text-slate-500 mt-0.5 line-clamp-1">{d.lessonText?.substring(0, 100) || 'Pas de texte'}</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>}
 
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">Titre *</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">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">Durée (jours)</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">Langue</label>
73
  <select className={inp} value={form.language} onChange={e => setForm(f => ({ ...f, language: e.target.value }))}>
74
- <option value="FR">Français</option><option value="WOLOF">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">Formation Premium (payante)</span>
80
  </label>
81
  {form.isPremium && <div>
82
- <label className="text-sm font-medium text-slate-700 mb-1 block">Prix (XOF)</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">
 
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(`Programme "${res.track.title}" généré avec ${res.track.days.length} jours !`);
64
  load();
65
  navigate(`/content/${res.track.id}/days`);
66
  } catch (err: any) {
67
- toast.error(err?.message ?? 'Échec de la génération IA');
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" /> Générer avec IA
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" /> Générer votre premier programme avec IA
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" /> Agent Créateur de Contenu
153
  </div>
154
- <h2 className="text-2xl font-bold text-white">Générer un programme</h2>
155
- <p className="text-indigo-200 text-sm mt-1">L'IA crée tout le curriculum en quelques secondes</p>
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">Description du programme *</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="Ex: Formation de 5 jours sur les bases du marketing digital pour les PME au Sénégal. Objectif : maîtriser les réseaux sociaux et créer une présence en ligne..."
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">Nombre de jours</label>
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">Langue</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">Français</option>
201
- <option value="EN">Anglais</option>
202
- <option value="WOL">Wolof</option>
203
  </select>
204
  </div>
205
  </div>
206
 
207
  <div>
208
- <label className="block text-sm font-bold text-slate-700 mb-2">Public cible <span className="text-slate-400 font-normal">(optionnel)</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="Ex: Entrepreneurs débutants, femmes rurales, lycéens..."
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
- Génération en cours... (15-30s)
228
  </>
229
  ) : (
230
  <>
231
  <Sparkles className="w-4 h-4" />
232
- Générer le programme
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 || 'Erreur serveur.');
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(`Succès ! ${json.injectedCount} règles ont été injectées dans le dictionnaire.`);
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
- Injecter ({selectedSuggestions.size}) Règles
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
- Vérité Terrain (Ground Truth)
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
- Entraînement enregistré !
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 ?? 'Erreur chargement utilisateurs'); });
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('Supprimer cet utilisateur ? Cette action est réversible côté base de données.')) 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('Utilisateur supprimé');
64
  } catch (err: any) {
65
- toast.error(err?.message ?? 'Échec de la suppression');
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 ? 'Handoff libéré — l\'IA reprend la conversation' : 'Aucun handoff actif pour cet utilisateur');
77
  } catch (err: any) {
78
- toast.error(err?.message ?? 'Échec');
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'), 'Langue', 'Secteur',
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
- Conversation
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="Supprimer l'utilisateur"
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)} sur {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">Précédent</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">Suivant</button>
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">Handoff actif</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
- Libérer l'IA
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
  )}