CognxSafeTrack Claude Sonnet 4.6 commited on
Commit
ab43d7b
·
1 Parent(s): 4e2a593

feat: wire i18n to dashboard, settings, users — complete locale files

Browse files

DashboardPage, SettingsPage, UserListPage now use useTranslation().
fr/es/pt locale files expanded with dashboard, analytics, users,
contacts, settings, crm, knowledge sections.
Sidebar was already translated; main content pages now follow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

apps/admin/src/locales/es.json CHANGED
@@ -10,20 +10,205 @@
10
  "cancel": "Cancelar",
11
  "loading": "Cargando...",
12
  "error": "Ocurrió un error",
13
- "success": "Éxito"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  },
15
  "onboarding": {
16
  "title": "Bienvenido a Xamlé.Studio",
17
  "subtitle": "Configuremos tu escuela en unos segundos.",
 
 
 
 
18
  "connect_fb": "Conectar con Facebook",
19
  "fb_connected": "¡Cuenta de Facebook conectada!",
20
- "setup_waba": "Configurando tu cuenta de WhatsApp Business..."
 
 
21
  },
22
- "nav": {
23
- "home": "Inicio",
24
- "inbox": "Bandeja de entrada",
25
- "campaigns": "Campañas",
26
- "templates": "Plantillas",
27
- "organizations": "Organizaciones"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  }
29
  }
 
10
  "cancel": "Cancelar",
11
  "loading": "Cargando...",
12
  "error": "Ocurrió un error",
13
+ "success": "Éxito",
14
+ "create": "Crear",
15
+ "delete": "Eliminar",
16
+ "edit": "Editar",
17
+ "search": "Buscar...",
18
+ "actions": "Acciones",
19
+ "export": "Exportar",
20
+ "sync": "Sincronizar",
21
+ "send": "Enviar",
22
+ "close": "Cerrar",
23
+ "confirm": "Confirmar",
24
+ "back": "Atrás",
25
+ "next": "Siguiente",
26
+ "yes": "Sí",
27
+ "no": "No",
28
+ "status": "Estado",
29
+ "date": "Fecha",
30
+ "name": "Nombre",
31
+ "email": "Correo",
32
+ "phone": "Teléfono",
33
+ "day": "Día",
34
+ "none": "Ninguno"
35
+ },
36
+ "nav": {
37
+ "home": "Inicio",
38
+ "inbox": "Bandeja de entrada",
39
+ "campaigns": "Campañas",
40
+ "templates": "Plantillas",
41
+ "organizations": "Organizaciones",
42
+ "users": "Usuarios",
43
+ "training": "Training Lab",
44
+ "moderation": "Moderación",
45
+ "b2b": "Clientes B2B"
46
+ },
47
+ "dashboard": {
48
+ "title": "Tablero",
49
+ "subtitle": "Estado de tu plataforma en tiempo real",
50
+ "select_org": "Bienvenido a EdTech Admin",
51
+ "select_org_hint": "Para empezar, selecciona una organización en el menú de la izquierda.",
52
+ "isolation_note": "El aislamiento de datos garantiza que solo ves las estadísticas de la organización activa.",
53
+ "loading": "Analizando datos...",
54
+ "no_enrollments": "Sin inscripciones",
55
+ "recent_enrollments": "Inscripciones recientes",
56
+ "export_csv": "Exportar CSV",
57
+ "stats": {
58
+ "users": "Usuarios",
59
+ "active": "Activos",
60
+ "completed": "Completados",
61
+ "tracks": "Cursos",
62
+ "revenue": "Ingresos",
63
+ "total_messages": "Mensajes Totales",
64
+ "active_users": "Usuarios Activos",
65
+ "completion_rate": "Tasa de Finalización",
66
+ "ai_cost": "Costo IA estimado"
67
+ },
68
+ "table": {
69
+ "phone": "Teléfono",
70
+ "track": "Curso",
71
+ "status": "Estado",
72
+ "day": "Día",
73
+ "date": "Fecha"
74
+ }
75
+ },
76
+ "analytics": {
77
+ "title": "Análisis y Rendimiento",
78
+ "export": "Exportar informe",
79
+ "explain": "Explicar",
80
+ "messages": {
81
+ "title": "Volumen de Mensajes",
82
+ "inbound": "Entrantes",
83
+ "outbound": "Salientes"
84
+ },
85
+ "completion": {
86
+ "title": "Tasa de Éxito",
87
+ "completed": "Completados",
88
+ "in_progress": "En progreso"
89
+ },
90
+ "performance": {
91
+ "title": "Rendimiento",
92
+ "avg_score": "Puntuación media de ejercicios"
93
+ },
94
+ "engagement": {
95
+ "title": "Compromiso",
96
+ "avg_days": "Días de formación en promedio"
97
+ }
98
+ },
99
+ "users": {
100
+ "title": "Gestión de Usuarios",
101
+ "subtitle": "Todos los estudiantes inscritos",
102
+ "no_users": "No se encontraron usuarios",
103
+ "invite": "Invitar",
104
+ "columns": {
105
+ "name": "Nombre",
106
+ "phone": "Teléfono",
107
+ "track": "Curso activo",
108
+ "day": "Día",
109
+ "status": "Estado",
110
+ "joined": "Inscripción"
111
+ }
112
+ },
113
+ "contacts": {
114
+ "title": "Contactos",
115
+ "subtitle": "Gestión de tu base de contactos CRM",
116
+ "add": "Agregar contacto",
117
+ "import": "Importar",
118
+ "no_contacts": "Sin contactos",
119
+ "search_placeholder": "Buscar un contacto..."
120
+ },
121
+ "settings": {
122
+ "title": "Configuración",
123
+ "profile": "Perfil de la organización",
124
+ "branding": "Marca y Colores",
125
+ "ai_config": "Configuración IA",
126
+ "whatsapp_config": "Configuración WhatsApp",
127
+ "billing": "Facturación",
128
+ "org_name": "Nombre de la organización",
129
+ "primary_color": "Color principal",
130
+ "logo_url": "URL del logo",
131
+ "save_success": "Configuración guardada con éxito.",
132
+ "save_error": "No se pudo guardar la configuración.",
133
+ "no_org_selected": "Por favor selecciona una organización."
134
  },
135
  "onboarding": {
136
  "title": "Bienvenido a Xamlé.Studio",
137
  "subtitle": "Configuremos tu escuela en unos segundos.",
138
+ "step_welcome": "Bienvenida",
139
+ "step_legal": "Contrato",
140
+ "step_whatsapp": "WhatsApp",
141
+ "step_ai": "IA",
142
  "connect_fb": "Conectar con Facebook",
143
  "fb_connected": "¡Cuenta de Facebook conectada!",
144
+ "setup_waba": "Configurando tu cuenta de WhatsApp Business...",
145
+ "cta_launch": "Lanzar mi plataforma",
146
+ "legal_text": "Al aceptar, aceptas nuestros términos de socio y las políticas comerciales de Meta."
147
  },
148
+ "crm": {
149
+ "stats": {
150
+ "total_contacts": "Contactos Totales",
151
+ "messages_sent": "Mensajes Enviados",
152
+ "open_rate": "Tasa de apertura",
153
+ "conversion": "Conversión"
154
+ },
155
+ "inbox": {
156
+ "title": "Conversaciones",
157
+ "no_messages": "No se encontraron conversaciones.",
158
+ "reply_placeholder": "Tu mensaje...",
159
+ "send": "Enviar"
160
+ },
161
+ "campaigns": {
162
+ "title": "Historial de Campañas",
163
+ "subtitle": "Sigue todas tus transmisiones y su rendimiento.",
164
+ "new_campaign": "Nueva Campaña",
165
+ "select_template": "Plantilla WhatsApp (Opcional)",
166
+ "choose_approved": "Selecciona una plantilla aprobada...",
167
+ "no_approved_templates": "No hay plantillas aprobadas. Sincroniza primero.",
168
+ "use_ai_text": "Usar texto generado por IA",
169
+ "status_sent": "Enviado",
170
+ "status_delivered": "Entregado",
171
+ "status_read": "Leído",
172
+ "status_failed": "Fallido"
173
+ }
174
+ },
175
+ "whatsapp": {
176
+ "templates": {
177
+ "title": "Plantillas de Mensajes WhatsApp",
178
+ "subtitle": "Gestiona y sincroniza tus plantillas aprobadas por Meta.",
179
+ "sync_button": "Sincronizar con Meta",
180
+ "create_button": "Crear plantilla",
181
+ "no_templates": "No hay plantillas. Sincroniza o crea una.",
182
+ "table": {
183
+ "name": "Nombre de plantilla",
184
+ "category": "Categoría",
185
+ "language": "Idioma",
186
+ "status": "Estado"
187
+ },
188
+ "create_modal": {
189
+ "title": "Crear nueva plantilla",
190
+ "name_label": "Nombre (minúsculas, sin espacios)",
191
+ "category_label": "Categoría",
192
+ "language_label": "Idioma",
193
+ "body_label": "Texto del cuerpo",
194
+ "submit": "Enviar para aprobación",
195
+ "success": "¡Plantilla enviada con éxito!",
196
+ "error": "Error al enviar la plantilla."
197
+ }
198
+ }
199
+ },
200
+ "knowledge": {
201
+ "title": "Base de Conocimiento",
202
+ "subtitle": "Gestiona los documentos de tu IA",
203
+ "upload": "Agregar documento",
204
+ "no_documents": "No hay documentos indexados."
205
+ },
206
+ "training": {
207
+ "title": "Training Lab",
208
+ "subtitle": "Prueba y mejora tu IA pedagógica"
209
+ },
210
+ "ai_setup": {
211
+ "title": "Configuración del Agente IA",
212
+ "save": "Guardar configuración"
213
  }
214
  }
apps/admin/src/locales/fr.json CHANGED
@@ -10,20 +10,205 @@
10
  "cancel": "Annuler",
11
  "loading": "Chargement...",
12
  "error": "Une erreur est survenue",
13
- "success": "Succès"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  },
15
  "onboarding": {
16
  "title": "Bienvenue sur Xamlé.Studio",
17
  "subtitle": "Configurons votre école en quelques secondes.",
 
 
 
 
18
  "connect_fb": "Se connecter avec Facebook",
19
  "fb_connected": "Compte Facebook connecté !",
20
- "setup_waba": "Configuration de votre compte WhatsApp Business..."
 
 
21
  },
22
- "nav": {
23
- "home": "Accueil",
24
- "inbox": "Messagerie",
25
- "campaigns": "Campagnes",
26
- "templates": "Modèles",
27
- "organizations": "Organisations"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  }
29
  }
 
10
  "cancel": "Annuler",
11
  "loading": "Chargement...",
12
  "error": "Une erreur est survenue",
13
+ "success": "Succès",
14
+ "create": "Créer",
15
+ "delete": "Supprimer",
16
+ "edit": "Modifier",
17
+ "search": "Rechercher...",
18
+ "actions": "Actions",
19
+ "export": "Exporter",
20
+ "sync": "Synchroniser",
21
+ "send": "Envoyer",
22
+ "close": "Fermer",
23
+ "confirm": "Confirmer",
24
+ "back": "Retour",
25
+ "next": "Suivant",
26
+ "yes": "Oui",
27
+ "no": "Non",
28
+ "status": "Statut",
29
+ "date": "Date",
30
+ "name": "Nom",
31
+ "email": "Email",
32
+ "phone": "Téléphone",
33
+ "day": "Jour",
34
+ "none": "Aucun"
35
+ },
36
+ "nav": {
37
+ "home": "Accueil",
38
+ "inbox": "Messagerie",
39
+ "campaigns": "Campagnes",
40
+ "templates": "Modèles",
41
+ "organizations": "Organisations",
42
+ "users": "Utilisateurs",
43
+ "training": "Training Lab",
44
+ "moderation": "Modération",
45
+ "b2b": "Clients B2B"
46
+ },
47
+ "dashboard": {
48
+ "title": "Tableau de Bord",
49
+ "subtitle": "Statut de votre plateforme en temps réel",
50
+ "select_org": "Bienvenue sur EdTech Admin",
51
+ "select_org_hint": "Pour commencer, veuillez sélectionner une organisation dans le menu déroulant à gauche.",
52
+ "isolation_note": "L'isolation des données garantit que vous ne voyez que les statistiques de l'organisation active.",
53
+ "loading": "Analyse des données en cours...",
54
+ "no_enrollments": "Aucune inscription",
55
+ "recent_enrollments": "Inscriptions récentes",
56
+ "export_csv": "Exporter CSV",
57
+ "stats": {
58
+ "users": "Utilisateurs",
59
+ "active": "Actifs",
60
+ "completed": "Complétés",
61
+ "tracks": "Parcours",
62
+ "revenue": "Revenus",
63
+ "total_messages": "Messages Totaux",
64
+ "active_users": "Utilisateurs Actifs",
65
+ "completion_rate": "Taux de Complétion",
66
+ "ai_cost": "Estimation Coût IA"
67
+ },
68
+ "table": {
69
+ "phone": "Téléphone",
70
+ "track": "Parcours",
71
+ "status": "Statut",
72
+ "day": "Jour",
73
+ "date": "Date"
74
+ }
75
+ },
76
+ "analytics": {
77
+ "title": "Analyses & Performance",
78
+ "export": "Exporter le rapport",
79
+ "explain": "Expliquer",
80
+ "messages": {
81
+ "title": "Volume des Messages",
82
+ "inbound": "Entrants",
83
+ "outbound": "Sortants"
84
+ },
85
+ "completion": {
86
+ "title": "Taux de Réussite",
87
+ "completed": "Complétés",
88
+ "in_progress": "En cours"
89
+ },
90
+ "performance": {
91
+ "title": "Performance",
92
+ "avg_score": "Score moyen des exercices"
93
+ },
94
+ "engagement": {
95
+ "title": "Engagement",
96
+ "avg_days": "Jours de formation en moyenne"
97
+ }
98
+ },
99
+ "users": {
100
+ "title": "Gestion des Utilisateurs",
101
+ "subtitle": "Tous les apprenants inscrits",
102
+ "no_users": "Aucun utilisateur trouvé",
103
+ "invite": "Inviter",
104
+ "columns": {
105
+ "name": "Nom",
106
+ "phone": "Téléphone",
107
+ "track": "Parcours actif",
108
+ "day": "Jour",
109
+ "status": "Statut",
110
+ "joined": "Inscription"
111
+ }
112
+ },
113
+ "contacts": {
114
+ "title": "Contacts",
115
+ "subtitle": "Gestion de votre base de contacts CRM",
116
+ "add": "Ajouter un contact",
117
+ "import": "Importer",
118
+ "no_contacts": "Aucun contact",
119
+ "search_placeholder": "Rechercher un contact..."
120
+ },
121
+ "settings": {
122
+ "title": "Paramètres",
123
+ "profile": "Profil de l'organisation",
124
+ "branding": "Marque & Couleurs",
125
+ "ai_config": "Configuration IA",
126
+ "whatsapp_config": "Configuration WhatsApp",
127
+ "billing": "Facturation",
128
+ "org_name": "Nom de l'organisation",
129
+ "primary_color": "Couleur principale",
130
+ "logo_url": "URL du logo",
131
+ "save_success": "Paramètres enregistrés avec succès.",
132
+ "save_error": "Impossible d'enregistrer les paramètres.",
133
+ "no_org_selected": "Veuillez sélectionner une organisation."
134
  },
135
  "onboarding": {
136
  "title": "Bienvenue sur Xamlé.Studio",
137
  "subtitle": "Configurons votre école en quelques secondes.",
138
+ "step_welcome": "Bienvenue",
139
+ "step_legal": "Contrat",
140
+ "step_whatsapp": "WhatsApp",
141
+ "step_ai": "IA",
142
  "connect_fb": "Se connecter avec Facebook",
143
  "fb_connected": "Compte Facebook connecté !",
144
+ "setup_waba": "Configuration de votre compte WhatsApp Business...",
145
+ "cta_launch": "Lancer ma plateforme",
146
+ "legal_text": "En acceptant, vous acceptez nos conditions de partenaire et les politiques commerciales de Meta."
147
  },
148
+ "crm": {
149
+ "stats": {
150
+ "total_contacts": "Contacts Totaux",
151
+ "messages_sent": "Messages Envoyés",
152
+ "open_rate": "Taux d'ouverture",
153
+ "conversion": "Conversion"
154
+ },
155
+ "inbox": {
156
+ "title": "Conversations",
157
+ "no_messages": "Aucune conversation trouvée.",
158
+ "reply_placeholder": "Votre message...",
159
+ "send": "Envoyer"
160
+ },
161
+ "campaigns": {
162
+ "title": "Historique des Campagnes",
163
+ "subtitle": "Suivez toutes vos diffusions et leurs performances.",
164
+ "new_campaign": "Nouvelle Campagne",
165
+ "select_template": "Modèle WhatsApp (Optionnel)",
166
+ "choose_approved": "Sélectionner un modèle approuvé...",
167
+ "no_approved_templates": "Aucun modèle approuvé. Synchronisez d'abord.",
168
+ "use_ai_text": "Utiliser le texte généré par IA",
169
+ "status_sent": "Envoyé",
170
+ "status_delivered": "Livré",
171
+ "status_read": "Lu",
172
+ "status_failed": "Échec"
173
+ }
174
+ },
175
+ "whatsapp": {
176
+ "templates": {
177
+ "title": "Modèles de Messages WhatsApp",
178
+ "subtitle": "Gérez et synchronisez vos modèles approuvés par Meta.",
179
+ "sync_button": "Synchroniser avec Meta",
180
+ "create_button": "Créer un modèle",
181
+ "no_templates": "Aucun modèle. Synchronisez ou créez-en un.",
182
+ "table": {
183
+ "name": "Nom du modèle",
184
+ "category": "Catégorie",
185
+ "language": "Langue",
186
+ "status": "Statut"
187
+ },
188
+ "create_modal": {
189
+ "title": "Créer un nouveau modèle",
190
+ "name_label": "Nom du modèle (minuscules, sans espaces)",
191
+ "category_label": "Catégorie",
192
+ "language_label": "Langue",
193
+ "body_label": "Texte du corps",
194
+ "submit": "Soumettre pour approbation",
195
+ "success": "Modèle soumis avec succès !",
196
+ "error": "Échec de la soumission."
197
+ }
198
+ }
199
+ },
200
+ "knowledge": {
201
+ "title": "Base de Connaissances",
202
+ "subtitle": "Gérez les documents de votre IA",
203
+ "upload": "Ajouter un document",
204
+ "no_documents": "Aucun document indexé."
205
+ },
206
+ "training": {
207
+ "title": "Training Lab",
208
+ "subtitle": "Testez et affinez votre IA pédagogique"
209
+ },
210
+ "ai_setup": {
211
+ "title": "Configuration de l'Agent IA",
212
+ "save": "Enregistrer la configuration"
213
  }
214
  }
apps/admin/src/locales/pt.json CHANGED
@@ -6,24 +6,209 @@
6
  "analytics": "Análises",
7
  "settings": "Configurações",
8
  "logout": "Sair",
9
- "save": "Salvar",
10
  "cancel": "Cancelar",
11
  "loading": "Carregando...",
12
  "error": "Ocorreu um erro",
13
- "success": "Sucesso"
14
- },
15
- "onboarding": {
16
- "title": "Bem-vindo ao Xamlé.Studio",
17
- "subtitle": "Vamos configurar sua escola em alguns segundos.",
18
- "connect_fb": "Conectar com Facebook",
19
- "fb_connected": "Conta do Facebook conectada!",
20
- "setup_waba": "Configurando sua conta do WhatsApp Business..."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  },
22
  "nav": {
23
  "home": "Início",
24
  "inbox": "Caixa de entrada",
25
  "campaigns": "Campanhas",
26
  "templates": "Modelos",
27
- "organizations": "Organizações"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  }
29
  }
 
6
  "analytics": "Análises",
7
  "settings": "Configurações",
8
  "logout": "Sair",
9
+ "save": "Guardar",
10
  "cancel": "Cancelar",
11
  "loading": "Carregando...",
12
  "error": "Ocorreu um erro",
13
+ "success": "Sucesso",
14
+ "create": "Criar",
15
+ "delete": "Excluir",
16
+ "edit": "Editar",
17
+ "search": "Pesquisar...",
18
+ "actions": "Ações",
19
+ "export": "Exportar",
20
+ "sync": "Sincronizar",
21
+ "send": "Enviar",
22
+ "close": "Fechar",
23
+ "confirm": "Confirmar",
24
+ "back": "Voltar",
25
+ "next": "Próximo",
26
+ "yes": "Sim",
27
+ "no": "Não",
28
+ "status": "Estado",
29
+ "date": "Data",
30
+ "name": "Nome",
31
+ "email": "Email",
32
+ "phone": "Telefone",
33
+ "day": "Dia",
34
+ "none": "Nenhum"
35
  },
36
  "nav": {
37
  "home": "Início",
38
  "inbox": "Caixa de entrada",
39
  "campaigns": "Campanhas",
40
  "templates": "Modelos",
41
+ "organizations": "Organizações",
42
+ "users": "Utilizadores",
43
+ "training": "Training Lab",
44
+ "moderation": "Moderação",
45
+ "b2b": "Clientes B2B"
46
+ },
47
+ "dashboard": {
48
+ "title": "Painel",
49
+ "subtitle": "Estado da sua plataforma em tempo real",
50
+ "select_org": "Bem-vindo ao EdTech Admin",
51
+ "select_org_hint": "Para começar, selecione uma organização no menu à esquerda.",
52
+ "isolation_note": "O isolamento de dados garante que você veja apenas as estatísticas da organização ativa.",
53
+ "loading": "Analisando dados...",
54
+ "no_enrollments": "Sem inscrições",
55
+ "recent_enrollments": "Inscrições recentes",
56
+ "export_csv": "Exportar CSV",
57
+ "stats": {
58
+ "users": "Utilizadores",
59
+ "active": "Ativos",
60
+ "completed": "Concluídos",
61
+ "tracks": "Cursos",
62
+ "revenue": "Receita",
63
+ "total_messages": "Mensagens Totais",
64
+ "active_users": "Utilizadores Ativos",
65
+ "completion_rate": "Taxa de Conclusão",
66
+ "ai_cost": "Custo IA estimado"
67
+ },
68
+ "table": {
69
+ "phone": "Telefone",
70
+ "track": "Curso",
71
+ "status": "Estado",
72
+ "day": "Dia",
73
+ "date": "Data"
74
+ }
75
+ },
76
+ "analytics": {
77
+ "title": "Análises e Desempenho",
78
+ "export": "Exportar relatório",
79
+ "explain": "Explicar",
80
+ "messages": {
81
+ "title": "Volume de Mensagens",
82
+ "inbound": "Recebidas",
83
+ "outbound": "Enviadas"
84
+ },
85
+ "completion": {
86
+ "title": "Taxa de Sucesso",
87
+ "completed": "Concluídos",
88
+ "in_progress": "Em progresso"
89
+ },
90
+ "performance": {
91
+ "title": "Desempenho",
92
+ "avg_score": "Pontuação média dos exercícios"
93
+ },
94
+ "engagement": {
95
+ "title": "Envolvimento",
96
+ "avg_days": "Dias de formação em média"
97
+ }
98
+ },
99
+ "users": {
100
+ "title": "Gestão de Utilizadores",
101
+ "subtitle": "Todos os alunos inscritos",
102
+ "no_users": "Nenhum utilizador encontrado",
103
+ "invite": "Convidar",
104
+ "columns": {
105
+ "name": "Nome",
106
+ "phone": "Telefone",
107
+ "track": "Curso ativo",
108
+ "day": "Dia",
109
+ "status": "Estado",
110
+ "joined": "Inscrição"
111
+ }
112
+ },
113
+ "contacts": {
114
+ "title": "Contactos",
115
+ "subtitle": "Gestão da sua base de contactos CRM",
116
+ "add": "Adicionar contacto",
117
+ "import": "Importar",
118
+ "no_contacts": "Sem contactos",
119
+ "search_placeholder": "Procurar um contacto..."
120
+ },
121
+ "settings": {
122
+ "title": "Configurações",
123
+ "profile": "Perfil da organização",
124
+ "branding": "Marca e Cores",
125
+ "ai_config": "Configuração IA",
126
+ "whatsapp_config": "Configuração WhatsApp",
127
+ "billing": "Faturação",
128
+ "org_name": "Nome da organização",
129
+ "primary_color": "Cor principal",
130
+ "logo_url": "URL do logótipo",
131
+ "save_success": "Configurações guardadas com sucesso.",
132
+ "save_error": "Não foi possível guardar as configurações.",
133
+ "no_org_selected": "Por favor selecione uma organização."
134
+ },
135
+ "onboarding": {
136
+ "title": "Bem-vindo ao Xamlé.Studio",
137
+ "subtitle": "Vamos configurar a sua escola em segundos.",
138
+ "step_welcome": "Boas-vindas",
139
+ "step_legal": "Contrato",
140
+ "step_whatsapp": "WhatsApp",
141
+ "step_ai": "IA",
142
+ "connect_fb": "Ligar ao Facebook",
143
+ "fb_connected": "Conta Facebook ligada!",
144
+ "setup_waba": "A configurar a sua conta WhatsApp Business...",
145
+ "cta_launch": "Lançar a minha plataforma",
146
+ "legal_text": "Ao aceitar, concorda com os nossos termos de parceiro e as políticas comerciais da Meta."
147
+ },
148
+ "crm": {
149
+ "stats": {
150
+ "total_contacts": "Total de Contactos",
151
+ "messages_sent": "Mensagens Enviadas",
152
+ "open_rate": "Taxa de abertura",
153
+ "conversion": "Conversão"
154
+ },
155
+ "inbox": {
156
+ "title": "Conversas",
157
+ "no_messages": "Nenhuma conversa encontrada.",
158
+ "reply_placeholder": "A sua mensagem...",
159
+ "send": "Enviar"
160
+ },
161
+ "campaigns": {
162
+ "title": "Histórico de Campanhas",
163
+ "subtitle": "Acompanhe todas as suas transmissões e o seu desempenho.",
164
+ "new_campaign": "Nova Campanha",
165
+ "select_template": "Modelo WhatsApp (Opcional)",
166
+ "choose_approved": "Selecione um modelo aprovado...",
167
+ "no_approved_templates": "Sem modelos aprovados. Sincronize primeiro.",
168
+ "use_ai_text": "Usar texto gerado por IA",
169
+ "status_sent": "Enviado",
170
+ "status_delivered": "Entregue",
171
+ "status_read": "Lido",
172
+ "status_failed": "Falhou"
173
+ }
174
+ },
175
+ "whatsapp": {
176
+ "templates": {
177
+ "title": "Modelos de Mensagens WhatsApp",
178
+ "subtitle": "Gerencie e sincronize os seus modelos aprovados pela Meta.",
179
+ "sync_button": "Sincronizar com Meta",
180
+ "create_button": "Criar modelo",
181
+ "no_templates": "Sem modelos. Sincronize ou crie um.",
182
+ "table": {
183
+ "name": "Nome do modelo",
184
+ "category": "Categoria",
185
+ "language": "Idioma",
186
+ "status": "Estado"
187
+ },
188
+ "create_modal": {
189
+ "title": "Criar novo modelo",
190
+ "name_label": "Nome (minúsculas, sem espaços)",
191
+ "category_label": "Categoria",
192
+ "language_label": "Idioma",
193
+ "body_label": "Texto do corpo",
194
+ "submit": "Submeter para aprovação",
195
+ "success": "Modelo submetido com sucesso!",
196
+ "error": "Falha ao submeter o modelo."
197
+ }
198
+ }
199
+ },
200
+ "knowledge": {
201
+ "title": "Base de Conhecimento",
202
+ "subtitle": "Gerencie os documentos da sua IA",
203
+ "upload": "Adicionar documento",
204
+ "no_documents": "Nenhum documento indexado."
205
+ },
206
+ "training": {
207
+ "title": "Training Lab",
208
+ "subtitle": "Teste e melhore a sua IA pedagógica"
209
+ },
210
+ "ai_setup": {
211
+ "title": "Configuração do Agente IA",
212
+ "save": "Guardar configuração"
213
  }
214
  }
apps/admin/src/pages/DashboardPage.tsx CHANGED
@@ -1,10 +1,12 @@
1
  import { useEffect, useState } from 'react';
 
2
  import { Users, PlayCircle, CheckCircle, Lightbulb, DollarSign, Download, Building2, Loader2 } from 'lucide-react';
3
  import { useAuth } from '@/lib/auth';
4
  import { useTenant } from '@/lib/tenant';
5
  import { api } from '@/lib/api';
6
 
7
  export default function DashboardPage() {
 
8
  const { token } = useAuth();
9
  const { selectedOrgId } = useTenant();
10
  const [stats, setStats] = useState<any>(null);
@@ -37,12 +39,12 @@ export default function DashboardPage() {
37
  }, [token, selectedOrgId]);
38
 
39
  const exportCSV = () => {
40
- if (!enrollments.length) return alert('Aucune inscription.');
41
  const rows = enrollments.map((e: any) => [e.id, e.user?.phone, e.track?.title, e.status, e.currentDay, e.startedAt]);
42
  const csv = [['ID', 'Phone', 'Track', 'Status', 'Day', 'Started'].join(','), ...rows.map(r => r.join(','))].join('\n');
43
- const a = document.createElement('a');
44
  a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
45
- a.download = `enrollments_${new Date().toISOString().slice(0, 10)}.csv`;
46
  a.click();
47
  };
48
 
@@ -50,12 +52,10 @@ export default function DashboardPage() {
50
  return (
51
  <div className="flex flex-col items-center justify-center min-h-[80vh] text-slate-400">
52
  <Building2 className="w-16 h-16 mb-6 opacity-20" />
53
- <h2 className="text-2xl font-bold text-slate-900">Bienvenue sur EdTech Admin</h2>
54
- <p className="max-w-md text-center mt-3 text-lg">
55
- Pour commencer, veuillez sélectionner une **organisation** dans le menu déroulant à gauche.
56
- </p>
57
  <div className="mt-8 p-4 bg-blue-50 text-blue-700 rounded-2xl text-sm font-medium border border-blue-100">
58
- 💡 L'isolation des données garantit que vous ne voyez que les statistiques de l'organisation active.
59
  </div>
60
  </div>
61
  );
@@ -65,40 +65,49 @@ export default function DashboardPage() {
65
  return (
66
  <div className="flex flex-col items-center justify-center min-h-[80vh] text-slate-400">
67
  <Loader2 className="w-10 h-10 animate-spin mb-4 text-slate-900" />
68
- <p className="text-lg font-medium">Analyse des données en cours...</p>
69
  </div>
70
  );
71
  }
72
 
73
  const statCards = [
74
- { icon: <Users className="w-6 h-6 text-slate-400" />, label: 'Utilisateurs', value: stats?.totalUsers || 0, color: 'text-slate-900' },
75
- { icon: <PlayCircle className="w-6 h-6 text-blue-400" />, label: 'Actifs', value: stats?.activeEnrollments || 0, color: 'text-blue-600' },
76
- { icon: <CheckCircle className="w-6 h-6 text-green-400" />, label: 'Complétés', value: stats?.completedEnrollments || 0, color: 'text-green-600' },
77
- { icon: <Lightbulb className="w-6 h-6 text-purple-400" />, label: 'Parcours', value: stats?.totalTracks || 0, color: 'text-purple-600' },
78
- { icon: <DollarSign className="w-6 h-6 text-emerald-400" />, label: 'Revenus', value: `${(stats?.totalRevenue || 0).toLocaleString()} XOF`, color: 'text-emerald-600' },
 
 
 
 
 
 
 
 
79
  ];
80
 
81
  return (
82
  <div className="p-8">
83
- <h1 className="text-3xl font-bold mb-8 text-slate-800">Dashboard</h1>
84
  <div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
85
  {statCards.map((s, i) => (
86
  <div key={i} className="bg-white p-5 rounded-xl shadow-sm border border-slate-100 flex flex-col items-center gap-2">
87
- {s.icon}<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">{s.label}</p>
 
88
  <p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
89
  </div>
90
  ))}
91
  </div>
92
  <div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
93
  <div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center">
94
- <h2 className="text-lg font-semibold text-slate-800">Inscriptions récentes</h2>
95
  <button onClick={exportCSV} className="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 rounded-lg text-sm font-medium">
96
- <Download className="w-4 h-4" /><span>Export CSV</span>
97
  </button>
98
  </div>
99
  <table className="w-full text-sm">
100
  <thead className="bg-slate-50 text-xs text-slate-500 uppercase">
101
- <tr>{['Téléphone', 'Parcours', 'Statut', 'Jour', 'Date'].map(h => <th key={h} className="px-6 py-3 text-left">{h}</th>)}</tr>
102
  </thead>
103
  <tbody>
104
  {enrollments.map((e: any) => (
@@ -110,11 +119,13 @@ export default function DashboardPage() {
110
  {e.status}
111
  </span>
112
  </td>
113
- <td className="px-6 py-4">Jour {e.currentDay}</td>
114
- <td className="px-6 py-4 text-slate-500">{new Date(e.startedAt).toLocaleDateString('fr-FR')}</td>
115
  </tr>
116
  ))}
117
- {!enrollments.length && <tr><td colSpan={5} className="px-6 py-8 text-center text-slate-400">Aucune inscription</td></tr>}
 
 
118
  </tbody>
119
  </table>
120
  </div>
 
1
  import { useEffect, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
  import { Users, PlayCircle, CheckCircle, Lightbulb, DollarSign, Download, Building2, Loader2 } from 'lucide-react';
4
  import { useAuth } from '@/lib/auth';
5
  import { useTenant } from '@/lib/tenant';
6
  import { api } from '@/lib/api';
7
 
8
  export default function DashboardPage() {
9
+ const { t } = useTranslation();
10
  const { token } = useAuth();
11
  const { selectedOrgId } = useTenant();
12
  const [stats, setStats] = useState<any>(null);
 
39
  }, [token, selectedOrgId]);
40
 
41
  const exportCSV = () => {
42
+ if (!enrollments.length) return alert(t('dashboard.no_enrollments'));
43
  const rows = enrollments.map((e: any) => [e.id, e.user?.phone, e.track?.title, e.status, e.currentDay, e.startedAt]);
44
  const csv = [['ID', 'Phone', 'Track', 'Status', 'Day', 'Started'].join(','), ...rows.map(r => r.join(','))].join('\n');
45
+ const a = document.createElement('a');
46
  a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
47
+ a.download = `enrollments_${new Date().toISOString().slice(0, 10)}.csv`;
48
  a.click();
49
  };
50
 
 
52
  return (
53
  <div className="flex flex-col items-center justify-center min-h-[80vh] text-slate-400">
54
  <Building2 className="w-16 h-16 mb-6 opacity-20" />
55
+ <h2 className="text-2xl font-bold text-slate-900">{t('dashboard.select_org')}</h2>
56
+ <p className="max-w-md text-center mt-3 text-lg">{t('dashboard.select_org_hint')}</p>
 
 
57
  <div className="mt-8 p-4 bg-blue-50 text-blue-700 rounded-2xl text-sm font-medium border border-blue-100">
58
+ 💡 {t('dashboard.isolation_note')}
59
  </div>
60
  </div>
61
  );
 
65
  return (
66
  <div className="flex flex-col items-center justify-center min-h-[80vh] text-slate-400">
67
  <Loader2 className="w-10 h-10 animate-spin mb-4 text-slate-900" />
68
+ <p className="text-lg font-medium">{t('dashboard.loading')}</p>
69
  </div>
70
  );
71
  }
72
 
73
  const statCards = [
74
+ { icon: <Users className="w-6 h-6 text-slate-400" />, label: t('dashboard.stats.users'), value: stats?.totalUsers || 0, color: 'text-slate-900' },
75
+ { icon: <PlayCircle className="w-6 h-6 text-blue-400" />, label: t('dashboard.stats.active'), value: stats?.activeEnrollments || 0, color: 'text-blue-600' },
76
+ { icon: <CheckCircle className="w-6 h-6 text-green-400" />, label: t('dashboard.stats.completed'), value: stats?.completedEnrollments || 0, color: 'text-green-600' },
77
+ { icon: <Lightbulb className="w-6 h-6 text-purple-400" />, label: t('dashboard.stats.tracks'), value: stats?.totalTracks || 0, color: 'text-purple-600' },
78
+ { icon: <DollarSign className="w-6 h-6 text-emerald-400" />, label: t('dashboard.stats.revenue'), value: `${(stats?.totalRevenue || 0).toLocaleString()} XOF`, color: 'text-emerald-600' },
79
+ ];
80
+
81
+ const tableHeaders = [
82
+ t('dashboard.table.phone'),
83
+ t('dashboard.table.track'),
84
+ t('dashboard.table.status'),
85
+ t('dashboard.table.day'),
86
+ t('dashboard.table.date'),
87
  ];
88
 
89
  return (
90
  <div className="p-8">
91
+ <h1 className="text-3xl font-bold mb-8 text-slate-800">{t('dashboard.title')}</h1>
92
  <div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
93
  {statCards.map((s, i) => (
94
  <div key={i} className="bg-white p-5 rounded-xl shadow-sm border border-slate-100 flex flex-col items-center gap-2">
95
+ {s.icon}
96
+ <p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">{s.label}</p>
97
  <p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
98
  </div>
99
  ))}
100
  </div>
101
  <div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
102
  <div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center">
103
+ <h2 className="text-lg font-semibold text-slate-800">{t('dashboard.recent_enrollments')}</h2>
104
  <button onClick={exportCSV} className="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 rounded-lg text-sm font-medium">
105
+ <Download className="w-4 h-4" /><span>{t('dashboard.export_csv')}</span>
106
  </button>
107
  </div>
108
  <table className="w-full text-sm">
109
  <thead className="bg-slate-50 text-xs text-slate-500 uppercase">
110
+ <tr>{tableHeaders.map(h => <th key={h} className="px-6 py-3 text-left">{h}</th>)}</tr>
111
  </thead>
112
  <tbody>
113
  {enrollments.map((e: any) => (
 
119
  {e.status}
120
  </span>
121
  </td>
122
+ <td className="px-6 py-4">{t('common.day')} {e.currentDay}</td>
123
+ <td className="px-6 py-4 text-slate-500">{new Date(e.startedAt).toLocaleDateString()}</td>
124
  </tr>
125
  ))}
126
+ {!enrollments.length && (
127
+ <tr><td colSpan={5} className="px-6 py-8 text-center text-slate-400">{t('dashboard.no_enrollments')}</td></tr>
128
+ )}
129
  </tbody>
130
  </table>
131
  </div>
apps/admin/src/pages/SettingsPage.tsx CHANGED
@@ -1,10 +1,12 @@
1
 
2
  import React, { useState, useEffect } from 'react';
 
3
  import { api } from '../lib/api';
4
  import { useTenant } from '../lib/tenant';
5
  import { useAuth } from '../lib/auth';
6
 
7
  export default function SettingsPage() {
 
8
  const { token } = useAuth();
9
  const { selectedOrgId } = useTenant();
10
  const [org, setOrg] = useState<any>(null);
@@ -12,7 +14,7 @@ export default function SettingsPage() {
12
  const [saving, setSaving] = useState(false);
13
  const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
14
 
15
- const isValidId = selectedOrgId && selectedOrgId !== 'default-org-id';
16
 
17
  useEffect(() => {
18
  if (isValidId && token) {
@@ -41,9 +43,9 @@ export default function SettingsPage() {
41
  setMessage(null);
42
  try {
43
  await api.put(`/v1/organizations/${selectedOrgId}`, org, token);
44
- setMessage({ type: 'success', text: 'Configuration enregistrée !' });
45
  } catch (err) {
46
- setMessage({ type: 'error', text: 'Erreur lors de l\'enregistrement.' });
47
  } finally {
48
  setSaving(false);
49
  }
@@ -52,24 +54,24 @@ export default function SettingsPage() {
52
  if (!isValidId) {
53
  return (
54
  <div className="p-12 text-center text-slate-400">
55
- <p className="text-lg font-medium">Veuillez sélectionner une organisation pour modifier ses paramètres.</p>
56
  </div>
57
  );
58
  }
59
 
60
- if (loading) return <div className="p-12 text-slate-500 animate-pulse">Chargement des paramètres...</div>;
61
- if (!org) return <div className="p-12 text-red-500">Organisation non trouvée.</div>;
62
 
63
  return (
64
  <div className="p-8 max-w-4xl mx-auto">
65
  <div className="flex justify-between items-center mb-8">
66
- <h1 className="text-3xl font-bold text-slate-800">Configuration de l'Organisation</h1>
67
- <button
68
  onClick={handleSave}
69
  disabled={saving}
70
  className="px-6 py-2 bg-emerald-600 text-white rounded-xl font-medium hover:bg-emerald-700 disabled:opacity-50 transition-all shadow-lg shadow-emerald-200"
71
  >
72
- {saving ? 'Enregistrement...' : 'Enregistrer'}
73
  </button>
74
  </div>
75
 
@@ -81,32 +83,31 @@ export default function SettingsPage() {
81
  )}
82
 
83
  <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
84
- {/* General Settings */}
85
  <div className="space-y-6">
86
  <section className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm">
87
  <h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
88
- <span>🏢</span> Profil Général
89
  </h2>
90
  <div className="space-y-4">
91
  <div>
92
- <label className="block text-sm font-medium text-slate-600 mb-1">Nom de l'organisation</label>
93
- <input
94
- type="text"
95
- value={org.name || ''}
96
  onChange={e => setOrg({...org, name: e.target.value})}
97
  className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
98
  />
99
  </div>
100
  <div>
101
- <label className="block text-sm font-medium text-slate-600 mb-1">Mode de fonctionnement</label>
102
- <select
103
  value={org.mode || 'EDTECH'}
104
  onChange={e => setOrg({...org, mode: e.target.value})}
105
  className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none bg-white"
106
  >
107
- <option value="EDTECH">EdTech (Apprentissage & Leçons)</option>
108
- <option value="WEBHOOK">Webhook (Routage externe)</option>
109
- <option value="AI_AGENT">Agent IA (Conseiller autonome)</option>
110
  </select>
111
  </div>
112
  </div>
@@ -115,25 +116,25 @@ export default function SettingsPage() {
115
  {org.mode === 'WEBHOOK' && (
116
  <section className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm animate-in fade-in slide-in-from-top-4">
117
  <h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
118
- <span>🔗</span> Configuration Webhook
119
  </h2>
120
  <div className="space-y-4">
121
  <div>
122
- <label className="block text-sm font-medium text-slate-600 mb-1">URL de destination</label>
123
- <input
124
- type="url"
125
  placeholder="https://votre-api.com/webhook"
126
- value={org.webhookUrl || ''}
127
  onChange={e => setOrg({...org, webhookUrl: e.target.value})}
128
  className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
129
  />
130
  </div>
131
  <div>
132
- <label className="block text-sm font-medium text-slate-600 mb-1">Secret (X-Hub-Signature)</label>
133
- <input
134
- type="password"
135
  placeholder="••••••••"
136
- value={org.webhookSecret || ''}
137
  onChange={e => setOrg({...org, webhookSecret: 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"
139
  />
@@ -145,25 +146,25 @@ export default function SettingsPage() {
145
  {org.mode === 'AI_AGENT' && (
146
  <section className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm animate-in fade-in slide-in-from-top-4">
147
  <h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
148
- <span>🤖</span> Paramètres Agent IA
149
  </h2>
150
  <div className="space-y-4">
151
  <div>
152
- <label className="block text-sm font-medium text-slate-600 mb-1">Base de connaissances (URL PDF)</label>
153
- <input
154
- type="url"
155
  placeholder="https://storage.com/catalogue.pdf"
156
- value={org.knowledgeBaseUrl || ''}
157
  onChange={e => setOrg({...org, knowledgeBaseUrl: e.target.value})}
158
  className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
159
  />
160
  </div>
161
  <div>
162
- <label className="block text-sm font-medium text-slate-600 mb-1">Prompt Système (Instructions)</label>
163
- <textarea
164
  rows={4}
165
  placeholder="Tu es un conseiller expert en..."
166
- value={org.customPrompt || ''}
167
  onChange={e => setOrg({...org, customPrompt: e.target.value})}
168
  className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none resize-none"
169
  />
@@ -173,32 +174,22 @@ export default function SettingsPage() {
173
  )}
174
  </div>
175
 
176
- {/* EdTech / Flow Config */}
177
  <div className="space-y-6">
178
  <section className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm">
179
  <h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
180
- <span>⚙️</span> Configuration des Flux (JSON)
181
  </h2>
182
- <p className="text-xs text-slate-500 mb-4">
183
- Définissez ici vos secteurs et vos parcours par défaut.
184
- </p>
185
- <textarea
186
  rows={12}
187
- value={typeof org.flowConfig === 'string' ? org.flowConfig : JSON.stringify(org.flowConfig, null, 2)}
188
- onChange={e => {
189
- try {
190
- // We keep it as string while editing
191
- setOrg({...org, flowConfig: e.target.value});
192
- } catch(e) {}
193
- }}
194
  onBlur={() => {
195
  try {
196
- // Parse back to object on blur to validate
197
  if (typeof org.flowConfig === 'string') {
198
  setOrg({...org, flowConfig: JSON.parse(org.flowConfig)});
199
  }
200
- } catch(e) {
201
- setMessage({ type: 'error', text: 'Format JSON invalide dans Flow Config.' });
202
  }
203
  }}
204
  className="w-full px-4 py-2 font-mono text-xs border border-slate-200 rounded-xl focus:ring-2 focus:ring-slate-500 outline-none bg-slate-50"
@@ -206,23 +197,23 @@ export default function SettingsPage() {
206
  </section>
207
 
208
  <section className="bg-slate-800 p-6 rounded-2xl text-white">
209
- <h2 className="text-lg font-semibold mb-2">État Technique</h2>
210
  <div className="space-y-2 opacity-80 text-sm">
211
- <p>WABA ID: <span className="font-mono text-emerald-400">{org.wabaId || 'Non lié'}</span></p>
212
- <p>Système: <span className="font-mono text-emerald-400">{org.systemUserToken ? 'Connecté ✅' : 'Déconnecté ❌'}</span></p>
213
  </div>
214
  </section>
215
 
216
  <section className="bg-white p-6 rounded-2xl border border-indigo-100 shadow-sm shadow-indigo-50">
217
  <h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
218
- <span>💳</span> Facturation & Abonnement
219
  </h2>
220
  <div className="p-4 bg-indigo-50 rounded-xl mb-4 border border-indigo-100">
221
- <div className="text-xs font-bold text-indigo-600 uppercase mb-1">Statut Actuel</div>
222
  <div className="text-lg font-bold text-indigo-900">{org.subscriptionStatus || 'INACTIF'}</div>
223
  </div>
224
  <p className="text-xs text-slate-500">
225
- Paiement via Orange Money et Wave — portail de gestion disponible prochainement.
226
  </p>
227
  </section>
228
  </div>
 
1
 
2
  import React, { useState, useEffect } from 'react';
3
+ import { useTranslation } from 'react-i18next';
4
  import { api } from '../lib/api';
5
  import { useTenant } from '../lib/tenant';
6
  import { useAuth } from '../lib/auth';
7
 
8
  export default function SettingsPage() {
9
+ const { t } = useTranslation();
10
  const { token } = useAuth();
11
  const { selectedOrgId } = useTenant();
12
  const [org, setOrg] = useState<any>(null);
 
14
  const [saving, setSaving] = useState(false);
15
  const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
16
 
17
+ const isValidId = !!selectedOrgId;
18
 
19
  useEffect(() => {
20
  if (isValidId && token) {
 
43
  setMessage(null);
44
  try {
45
  await api.put(`/v1/organizations/${selectedOrgId}`, org, token);
46
+ setMessage({ type: 'success', text: t('settings.save_success') });
47
  } catch (err) {
48
+ setMessage({ type: 'error', text: t('settings.save_error') });
49
  } finally {
50
  setSaving(false);
51
  }
 
54
  if (!isValidId) {
55
  return (
56
  <div className="p-12 text-center text-slate-400">
57
+ <p className="text-lg font-medium">{t('settings.no_org_selected')}</p>
58
  </div>
59
  );
60
  }
61
 
62
+ if (loading) return <div className="p-12 text-slate-500 animate-pulse">{t('common.loading')}</div>;
63
+ if (!org) return <div className="p-12 text-red-500">{t('common.error')}</div>;
64
 
65
  return (
66
  <div className="p-8 max-w-4xl mx-auto">
67
  <div className="flex justify-between items-center mb-8">
68
+ <h1 className="text-3xl font-bold text-slate-800">{t('settings.title')}</h1>
69
+ <button
70
  onClick={handleSave}
71
  disabled={saving}
72
  className="px-6 py-2 bg-emerald-600 text-white rounded-xl font-medium hover:bg-emerald-700 disabled:opacity-50 transition-all shadow-lg shadow-emerald-200"
73
  >
74
+ {saving ? t('common.loading') : t('common.save')}
75
  </button>
76
  </div>
77
 
 
83
  )}
84
 
85
  <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
 
86
  <div className="space-y-6">
87
  <section className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm">
88
  <h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
89
+ <span>🏢</span> {t('settings.profile')}
90
  </h2>
91
  <div className="space-y-4">
92
  <div>
93
+ <label className="block text-sm font-medium text-slate-600 mb-1">{t('settings.org_name')}</label>
94
+ <input
95
+ type="text"
96
+ value={org.name || ''}
97
  onChange={e => setOrg({...org, name: e.target.value})}
98
  className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
99
  />
100
  </div>
101
  <div>
102
+ <label className="block text-sm font-medium text-slate-600 mb-1">{t('settings.ai_config')}</label>
103
+ <select
104
  value={org.mode || 'EDTECH'}
105
  onChange={e => setOrg({...org, mode: e.target.value})}
106
  className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none bg-white"
107
  >
108
+ <option value="EDTECH">EdTech</option>
109
+ <option value="WEBHOOK">Webhook</option>
110
+ <option value="AI_AGENT">Agent IA</option>
111
  </select>
112
  </div>
113
  </div>
 
116
  {org.mode === 'WEBHOOK' && (
117
  <section className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm animate-in fade-in slide-in-from-top-4">
118
  <h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
119
+ <span>🔗</span> {t('settings.whatsapp_config')}
120
  </h2>
121
  <div className="space-y-4">
122
  <div>
123
+ <label className="block text-sm font-medium text-slate-600 mb-1">URL</label>
124
+ <input
125
+ type="url"
126
  placeholder="https://votre-api.com/webhook"
127
+ value={org.webhookUrl || ''}
128
  onChange={e => setOrg({...org, webhookUrl: e.target.value})}
129
  className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
130
  />
131
  </div>
132
  <div>
133
+ <label className="block text-sm font-medium text-slate-600 mb-1">Secret</label>
134
+ <input
135
+ type="password"
136
  placeholder="••••••••"
137
+ value={org.webhookSecret || ''}
138
  onChange={e => setOrg({...org, webhookSecret: e.target.value})}
139
  className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
140
  />
 
146
  {org.mode === 'AI_AGENT' && (
147
  <section className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm animate-in fade-in slide-in-from-top-4">
148
  <h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
149
+ <span>🤖</span> {t('ai_setup.title')}
150
  </h2>
151
  <div className="space-y-4">
152
  <div>
153
+ <label className="block text-sm font-medium text-slate-600 mb-1">{t('knowledge.title')} (URL PDF)</label>
154
+ <input
155
+ type="url"
156
  placeholder="https://storage.com/catalogue.pdf"
157
+ value={org.knowledgeBaseUrl || ''}
158
  onChange={e => setOrg({...org, knowledgeBaseUrl: e.target.value})}
159
  className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
160
  />
161
  </div>
162
  <div>
163
+ <label className="block text-sm font-medium text-slate-600 mb-1">Prompt</label>
164
+ <textarea
165
  rows={4}
166
  placeholder="Tu es un conseiller expert en..."
167
+ value={org.customPrompt || ''}
168
  onChange={e => setOrg({...org, customPrompt: e.target.value})}
169
  className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none resize-none"
170
  />
 
174
  )}
175
  </div>
176
 
 
177
  <div className="space-y-6">
178
  <section className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm">
179
  <h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
180
+ <span>⚙️</span> Flow Config (JSON)
181
  </h2>
182
+ <textarea
 
 
 
183
  rows={12}
184
+ value={typeof org.flowConfig === 'string' ? org.flowConfig : JSON.stringify(org.flowConfig, null, 2)}
185
+ onChange={e => setOrg({...org, flowConfig: e.target.value})}
 
 
 
 
 
186
  onBlur={() => {
187
  try {
 
188
  if (typeof org.flowConfig === 'string') {
189
  setOrg({...org, flowConfig: JSON.parse(org.flowConfig)});
190
  }
191
+ } catch {
192
+ setMessage({ type: 'error', text: 'Format JSON invalide.' });
193
  }
194
  }}
195
  className="w-full px-4 py-2 font-mono text-xs border border-slate-200 rounded-xl focus:ring-2 focus:ring-slate-500 outline-none bg-slate-50"
 
197
  </section>
198
 
199
  <section className="bg-slate-800 p-6 rounded-2xl text-white">
200
+ <h2 className="text-lg font-semibold mb-2">{t('settings.whatsapp_config')}</h2>
201
  <div className="space-y-2 opacity-80 text-sm">
202
+ <p>WABA ID: <span className="font-mono text-emerald-400">{org.wabaId || ''}</span></p>
203
+ <p>Token: <span className="font-mono text-emerald-400">{org.systemUserToken ? '✅' : '❌'}</span></p>
204
  </div>
205
  </section>
206
 
207
  <section className="bg-white p-6 rounded-2xl border border-indigo-100 shadow-sm shadow-indigo-50">
208
  <h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
209
+ <span>💳</span> {t('settings.billing')}
210
  </h2>
211
  <div className="p-4 bg-indigo-50 rounded-xl mb-4 border border-indigo-100">
212
+ <div className="text-xs font-bold text-indigo-600 uppercase mb-1">{t('common.status')}</div>
213
  <div className="text-lg font-bold text-indigo-900">{org.subscriptionStatus || 'INACTIF'}</div>
214
  </div>
215
  <p className="text-xs text-slate-500">
216
+ Orange Money / Wave — {t('common.loading').toLowerCase()}
217
  </p>
218
  </section>
219
  </div>
apps/admin/src/pages/UserListPage.tsx CHANGED
@@ -1,46 +1,39 @@
1
  import { useEffect, useState } from 'react';
 
2
  import { X, Building2, Loader2 } from 'lucide-react';
3
  import { useAuth } from '../lib/auth';
4
  import { useTenant } from '../lib/tenant';
5
  import { API_URL, ah } from '../lib/api';
6
 
7
  export default function UserListPage() {
 
8
  const { token } = useAuth();
9
  const { selectedOrgId } = useTenant();
10
- const [users, setUsers] = useState<any[]>([]);
11
- const [total, setTotal] = useState(0);
12
  const [loading, setLoading] = useState(true);
13
- const [selectedUser, setSelectedUser] = useState<any>(null);
14
- const [messages, setMessages] = useState<any[]>([]);
15
  const [loadingMsg, setLoadingMsg] = useState(false);
16
 
17
-
18
-
19
- useEffect(() => {
20
- if (!selectedOrgId) {
21
- setLoading(false);
22
- return;
23
- }
24
  setLoading(true);
25
  fetch(`${API_URL}/v1/admin/users`, { headers: ah(token!, selectedOrgId) })
26
  .then(r => r.json())
27
- .then(d => {
28
- setUsers(d.users || d);
29
- setTotal(d.total || 0);
30
- setLoading(false);
31
- });
32
  }, [token, selectedOrgId]);
33
 
34
  const viewMessages = async (userId: string) => {
35
- setLoadingMsg(true);
36
  setSelectedUser({ id: userId });
37
  try {
38
  const res = await fetch(`${API_URL}/v1/admin/users/${userId}/messages`, { headers: ah(token!, selectedOrgId) });
39
  const data = await res.json();
40
  setSelectedUser(data.user);
41
  setMessages(data.messages || []);
42
- } catch (e) {
43
- alert("Erreur lors du chargement des messages.");
44
  } finally {
45
  setLoadingMsg(false);
46
  }
@@ -50,8 +43,7 @@ export default function UserListPage() {
50
  return (
51
  <div className="flex flex-col items-center justify-center min-h-[60vh] text-slate-400">
52
  <Building2 className="w-12 h-12 mb-4 opacity-20" />
53
- <h3 className="text-lg font-bold text-slate-900">Aucune organisation sélectionnée</h3>
54
- <p className="max-w-xs text-center mt-2">Sélectionnez une organisation pour gérer ses utilisateurs.</p>
55
  </div>
56
  );
57
  }
@@ -60,18 +52,25 @@ export default function UserListPage() {
60
  return (
61
  <div className="flex flex-col items-center justify-center min-h-[60vh] text-slate-400">
62
  <Loader2 className="w-8 h-8 animate-spin mb-4 text-slate-900" />
63
- <p>Chargement des utilisateurs...</p>
64
  </div>
65
  );
66
  }
67
 
 
 
 
 
 
68
  return (
69
  <div className="p-8">
70
- <h1 className="text-3xl font-bold mb-6 text-slate-800">Utilisateurs <span className="text-lg font-normal text-slate-400">({total})</span></h1>
 
 
71
  <div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
72
  <table className="w-full text-sm">
73
  <thead className="bg-slate-50 text-xs text-slate-500 uppercase">
74
- <tr>{['Téléphone', 'Nom', 'Langue', 'Secteur', 'Inscrip.', 'Réponses', 'Date', 'Actions'].map(h => <th key={h} className="px-5 py-3 text-left">{h}</th>)}</tr>
75
  </thead>
76
  <tbody>
77
  {users.map((u: any) => (
@@ -82,13 +81,17 @@ export default function UserListPage() {
82
  <td className="px-5 py-3 text-slate-500 text-xs">{u.activity || '—'}</td>
83
  <td className="px-5 py-3 text-center">{u._count?.enrollments || 0}</td>
84
  <td className="px-5 py-3 text-center">{u._count?.responses || 0}</td>
85
- <td className="px-5 py-3 text-slate-400 text-xs">{new Date(u.createdAt).toLocaleDateString('fr-FR')}</td>
86
  <td className="px-5 py-3 text-right">
87
- <button onClick={() => viewMessages(u.id)} className="text-xs bg-slate-100 hover:bg-slate-200 text-slate-700 px-3 py-1.5 rounded-lg font-medium transition-colors">Conversation</button>
 
 
88
  </td>
89
  </tr>
90
  ))}
91
- {!users.length && <tr><td colSpan={8} className="px-5 py-8 text-center text-slate-400">Aucun utilisateur</td></tr>}
 
 
92
  </tbody>
93
  </table>
94
  </div>
@@ -98,16 +101,18 @@ export default function UserListPage() {
98
  <div className="bg-slate-50 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
99
  <div className="bg-white px-6 py-4 flex items-center justify-between border-b border-slate-200">
100
  <div>
101
- <h3 className="font-bold text-slate-800">{selectedUser.name || 'Chat Utilisateur'}</h3>
102
  <p className="text-xs text-slate-500">{selectedUser.phone}</p>
103
  </div>
104
- <button onClick={() => { setSelectedUser(null); setMessages([]); }} className="p-2 hover:bg-slate-100 rounded-full"><X className="w-5 h-5 text-slate-500" /></button>
 
 
105
  </div>
106
  <div className="flex-1 overflow-y-auto p-6 space-y-4 bg-[#e5ddd5]">
107
  {loadingMsg ? (
108
- <div className="text-center text-slate-500 py-10">Chargement de l'historique...</div>
109
  ) : messages.length === 0 ? (
110
- <div className="text-center text-slate-500 py-10 bg-white/50 rounded-xl">Aucun message pour cet utilisateur.</div>
111
  ) : (
112
  messages.map((m: any) => {
113
  const isBot = m.direction === 'OUTBOUND';
@@ -116,15 +121,15 @@ export default function UserListPage() {
116
  <div className={`max-w-[80%] rounded-2xl px-4 py-2.5 shadow-sm text-sm ${isBot ? 'bg-white text-slate-800 rounded-tl-none' : 'bg-[#dcf8c6] text-slate-900 rounded-tr-none'}`}>
117
  {m.mediaUrl && (
118
  <div className="mb-2">
119
- {m.mediaUrl.endsWith('.mp3') || m.mediaUrl.endsWith('.ogg') || m.mediaUrl.endsWith('.webm') ?
120
- <audio src={m.mediaUrl} controls className="h-10 max-w-full" /> :
121
- <a href={m.mediaUrl} target="_blank" rel="noreferrer" className="text-blue-600 underline">Voir Media</a>
122
  }
123
  </div>
124
  )}
125
  {m.content && <p className="whitespace-pre-wrap">{m.content}</p>}
126
  <p className={`text-[10px] mt-1 text-right ${isBot ? 'text-slate-400' : 'text-slate-500'}`}>
127
- {new Date(m.createdAt).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
128
  </p>
129
  </div>
130
  </div>
 
1
  import { useEffect, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
  import { X, Building2, Loader2 } from 'lucide-react';
4
  import { useAuth } from '../lib/auth';
5
  import { useTenant } from '../lib/tenant';
6
  import { API_URL, ah } from '../lib/api';
7
 
8
  export default function UserListPage() {
9
+ const { t } = useTranslation();
10
  const { token } = useAuth();
11
  const { selectedOrgId } = useTenant();
12
+ const [users, setUsers] = useState<any[]>([]);
13
+ const [total, setTotal] = useState(0);
14
  const [loading, setLoading] = useState(true);
15
+ const [selectedUser, setSelectedUser] = useState<any>(null);
16
+ const [messages, setMessages] = useState<any[]>([]);
17
  const [loadingMsg, setLoadingMsg] = useState(false);
18
 
19
+ useEffect(() => {
20
+ if (!selectedOrgId) { setLoading(false); return; }
 
 
 
 
 
21
  setLoading(true);
22
  fetch(`${API_URL}/v1/admin/users`, { headers: ah(token!, selectedOrgId) })
23
  .then(r => r.json())
24
+ .then(d => { setUsers(d.users || d); setTotal(d.total || 0); setLoading(false); });
 
 
 
 
25
  }, [token, selectedOrgId]);
26
 
27
  const viewMessages = async (userId: string) => {
28
+ setLoadingMsg(true);
29
  setSelectedUser({ id: userId });
30
  try {
31
  const res = await fetch(`${API_URL}/v1/admin/users/${userId}/messages`, { headers: ah(token!, selectedOrgId) });
32
  const data = await res.json();
33
  setSelectedUser(data.user);
34
  setMessages(data.messages || []);
35
+ } catch {
36
+ alert(t('common.error'));
37
  } finally {
38
  setLoadingMsg(false);
39
  }
 
43
  return (
44
  <div className="flex flex-col items-center justify-center min-h-[60vh] text-slate-400">
45
  <Building2 className="w-12 h-12 mb-4 opacity-20" />
46
+ <h3 className="text-lg font-bold text-slate-900">{t('settings.no_org_selected')}</h3>
 
47
  </div>
48
  );
49
  }
 
52
  return (
53
  <div className="flex flex-col items-center justify-center min-h-[60vh] text-slate-400">
54
  <Loader2 className="w-8 h-8 animate-spin mb-4 text-slate-900" />
55
+ <p>{t('common.loading')}</p>
56
  </div>
57
  );
58
  }
59
 
60
+ const tableHeaders = [
61
+ t('common.phone'), t('common.name'), 'Langue', 'Secteur',
62
+ t('nav.organizations'), t('users.columns.status'), t('common.date'), t('common.actions')
63
+ ];
64
+
65
  return (
66
  <div className="p-8">
67
+ <h1 className="text-3xl font-bold mb-6 text-slate-800">
68
+ {t('users.title')} <span className="text-lg font-normal text-slate-400">({total})</span>
69
+ </h1>
70
  <div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
71
  <table className="w-full text-sm">
72
  <thead className="bg-slate-50 text-xs text-slate-500 uppercase">
73
+ <tr>{tableHeaders.map(h => <th key={h} className="px-5 py-3 text-left">{h}</th>)}</tr>
74
  </thead>
75
  <tbody>
76
  {users.map((u: any) => (
 
81
  <td className="px-5 py-3 text-slate-500 text-xs">{u.activity || '—'}</td>
82
  <td className="px-5 py-3 text-center">{u._count?.enrollments || 0}</td>
83
  <td className="px-5 py-3 text-center">{u._count?.responses || 0}</td>
84
+ <td className="px-5 py-3 text-slate-400 text-xs">{new Date(u.createdAt).toLocaleDateString()}</td>
85
  <td className="px-5 py-3 text-right">
86
+ <button onClick={() => viewMessages(u.id)} className="text-xs bg-slate-100 hover:bg-slate-200 text-slate-700 px-3 py-1.5 rounded-lg font-medium transition-colors">
87
+ Conversation
88
+ </button>
89
  </td>
90
  </tr>
91
  ))}
92
+ {!users.length && (
93
+ <tr><td colSpan={8} className="px-5 py-8 text-center text-slate-400">{t('users.no_users')}</td></tr>
94
+ )}
95
  </tbody>
96
  </table>
97
  </div>
 
101
  <div className="bg-slate-50 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
102
  <div className="bg-white px-6 py-4 flex items-center justify-between border-b border-slate-200">
103
  <div>
104
+ <h3 className="font-bold text-slate-800">{selectedUser.name || 'Chat'}</h3>
105
  <p className="text-xs text-slate-500">{selectedUser.phone}</p>
106
  </div>
107
+ <button onClick={() => { setSelectedUser(null); setMessages([]); }} className="p-2 hover:bg-slate-100 rounded-full">
108
+ <X className="w-5 h-5 text-slate-500" />
109
+ </button>
110
  </div>
111
  <div className="flex-1 overflow-y-auto p-6 space-y-4 bg-[#e5ddd5]">
112
  {loadingMsg ? (
113
+ <div className="text-center text-slate-500 py-10">{t('common.loading')}</div>
114
  ) : messages.length === 0 ? (
115
+ <div className="text-center text-slate-500 py-10 bg-white/50 rounded-xl">{t('crm.inbox.no_messages')}</div>
116
  ) : (
117
  messages.map((m: any) => {
118
  const isBot = m.direction === 'OUTBOUND';
 
121
  <div className={`max-w-[80%] rounded-2xl px-4 py-2.5 shadow-sm text-sm ${isBot ? 'bg-white text-slate-800 rounded-tl-none' : 'bg-[#dcf8c6] text-slate-900 rounded-tr-none'}`}>
122
  {m.mediaUrl && (
123
  <div className="mb-2">
124
+ {m.mediaUrl.endsWith('.mp3') || m.mediaUrl.endsWith('.ogg') || m.mediaUrl.endsWith('.webm')
125
+ ? <audio src={m.mediaUrl} controls className="h-10 max-w-full" />
126
+ : <a href={m.mediaUrl} target="_blank" rel="noreferrer" className="text-blue-600 underline">Media</a>
127
  }
128
  </div>
129
  )}
130
  {m.content && <p className="whitespace-pre-wrap">{m.content}</p>}
131
  <p className={`text-[10px] mt-1 text-right ${isBot ? 'text-slate-400' : 'text-slate-500'}`}>
132
+ {new Date(m.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
133
  </p>
134
  </div>
135
  </div>