CognxSafeTrack commited on
Commit
a343cb3
·
1 Parent(s): 7c7162a

feat(admin): integrate LiveFeed moderation component into the dashboard

Browse files
apps/admin/src/App.tsx CHANGED
@@ -1,17 +1,18 @@
1
  import { BrowserRouter as Router, Routes, Route, Link, Navigate, useNavigate, useParams } from 'react-router-dom';
2
  import { useEffect, useState, createContext, useContext } from 'react';
3
- import { Users, PlayCircle, CheckCircle, Lightbulb, Download, BookOpen, Plus, Edit2, Trash2, ChevronRight, X, Save, BarChart2, DollarSign, ArrowLeft } from 'lucide-react';
 
4
 
5
  const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
6
  const SESSION_KEY = 'edtech_admin_key';
7
- const AuthContext = createContext<{ apiKey: string | null; login: (k: string) => void; logout: () => void; }>({ apiKey: null, login: () => {}, logout: () => {} });
8
  function AuthProvider({ children }: { children: React.ReactNode }) {
9
  const [apiKey, setApiKey] = useState<string | null>(() => sessionStorage.getItem(SESSION_KEY));
10
  const login = (k: string) => { sessionStorage.setItem(SESSION_KEY, k); setApiKey(k); };
11
  const logout = () => { sessionStorage.removeItem(SESSION_KEY); setApiKey(null); };
12
  return <AuthContext.Provider value={{ apiKey, login, logout }}>{children}</AuthContext.Provider>;
13
  }
14
- const useAuth = () => useContext(AuthContext);
15
  const ah = (k: string) => ({ 'Authorization': `Bearer ${k}`, 'Content-Type': 'application/json' });
16
  function ProtectedRoute({ children }: { children: React.ReactNode }) {
17
  const { apiKey } = useAuth();
@@ -77,9 +78,9 @@ function Dashboard() {
77
  const exportCSV = () => {
78
  if (!enrollments.length) return alert('Aucune inscription.');
79
  const rows = enrollments.map((e: any) => [e.id, e.user?.phone, e.track?.title, e.status, e.currentDay, e.startedAt]);
80
- const csv = [['ID','Phone','Track','Status','Day','Started'].join(','), ...rows.map(r => r.join(','))].join('\n');
81
  const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
82
- a.download = `enrollments_${new Date().toISOString().slice(0,10)}.csv`; a.click();
83
  };
84
  if (loading) return <div className="p-8 text-slate-400">Chargement...</div>;
85
  const statCards = [
@@ -87,13 +88,13 @@ function Dashboard() {
87
  { icon: <PlayCircle className="w-6 h-6 text-blue-400" />, label: 'Actifs', value: stats?.activeEnrollments || 0, color: 'text-blue-600' },
88
  { icon: <CheckCircle className="w-6 h-6 text-green-400" />, label: 'Complétés', value: stats?.completedEnrollments || 0, color: 'text-green-600' },
89
  { icon: <Lightbulb className="w-6 h-6 text-purple-400" />, label: 'Parcours', value: stats?.totalTracks || 0, color: 'text-purple-600' },
90
- { icon: <DollarSign className="w-6 h-6 text-emerald-400" />, label: 'Revenus', value: `${(stats?.totalRevenue||0).toLocaleString()} XOF`, color: 'text-emerald-600' },
91
  ];
92
  return (
93
  <div className="p-8">
94
  <h1 className="text-3xl font-bold mb-8 text-slate-800">Dashboard</h1>
95
  <div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
96
- {statCards.map((s,i) => (
97
  <div key={i} className="bg-white p-5 rounded-xl shadow-sm border border-slate-100 flex flex-col items-center gap-2">
98
  {s.icon}<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">{s.label}</p>
99
  <p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
@@ -109,19 +110,19 @@ function Dashboard() {
109
  </div>
110
  <table className="w-full text-sm">
111
  <thead className="bg-slate-50 text-xs text-slate-500 uppercase">
112
- <tr>{['Téléphone','Parcours','Statut','Jour','Date'].map(h=><th key={h} className="px-6 py-3 text-left">{h}</th>)}</tr>
113
  </thead>
114
  <tbody>
115
- {enrollments.map((e:any)=>(
116
  <tr key={e.id} className="border-t border-slate-50 hover:bg-slate-50/50">
117
- <td className="px-6 py-4 font-medium text-slate-900">{e.user?.phone||'—'}</td>
118
- <td className="px-6 py-4">{e.track?.title||'—'}</td>
119
- <td className="px-6 py-4"><span className={`px-2 py-1 rounded-full text-xs font-medium ${e.status==='ACTIVE'?'bg-blue-100 text-blue-800':e.status==='COMPLETED'?'bg-green-100 text-green-800':'bg-slate-100 text-slate-800'}`}>{e.status}</span></td>
120
  <td className="px-6 py-4">Jour {e.currentDay}</td>
121
  <td className="px-6 py-4 text-slate-500">{new Date(e.startedAt).toLocaleDateString('fr-FR')}</td>
122
  </tr>
123
  ))}
124
- {!enrollments.length&&<tr><td colSpan={5} className="px-6 py-8 text-center text-slate-400">Aucune inscription</td></tr>}
125
  </tbody>
126
  </table>
127
  </div>
@@ -132,89 +133,91 @@ function Dashboard() {
132
  function TrackList() {
133
  const { apiKey } = useAuth(); const navigate = useNavigate();
134
  const [tracks, setTracks] = useState<any[]>([]); const [loading, setLoading] = useState(true);
135
- const load = async () => { const r = await fetch(`${API_URL}/v1/admin/tracks`,{headers:ah(apiKey!)}); setTracks(await r.json()); setLoading(false); };
136
- useEffect(()=>{ load(); },[]);
137
- const del = async(id:string)=>{ if(!confirm('Supprimer ce parcours ?')) return; await fetch(`${API_URL}/v1/admin/tracks/${id}`,{method:'DELETE',headers:ah(apiKey!)}); load(); };
138
- if(loading) return <div className="p-8 text-slate-400">Chargement...</div>;
139
  return (
140
  <div className="p-8">
141
  <div className="flex justify-between items-center mb-6">
142
  <h1 className="text-3xl font-bold text-slate-800">Parcours</h1>
143
- <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">
144
- <Plus className="w-4 h-4"/> Nouveau parcours
145
  </button>
146
  </div>
147
  <div className="grid gap-4">
148
- {tracks.map((t:any)=>(
149
  <div key={t.id} className="bg-white rounded-xl border border-slate-100 p-5 flex items-center justify-between shadow-sm hover:shadow-md transition">
150
  <div className="flex items-center gap-4">
151
- <div className="bg-purple-100 p-3 rounded-xl"><BookOpen className="w-5 h-5 text-purple-600"/></div>
152
  <div>
153
  <div className="flex items-center gap-2">
154
  <h3 className="font-bold text-slate-800">{t.title}</h3>
155
- {t.isPremium&&<span className="bg-amber-100 text-amber-700 text-xs px-2 py-0.5 rounded-full font-medium">Premium</span>}
156
  <span className="bg-slate-100 text-slate-600 text-xs px-2 py-0.5 rounded-full">{t.language}</span>
157
  </div>
158
- <p className="text-sm text-slate-500 mt-0.5">{t._count?.days||0} jours · {t._count?.enrollments||0} inscrits · {t.duration}j</p>
159
  </div>
160
  </div>
161
  <div className="flex items-center gap-2">
162
- <button onClick={()=>navigate(`/content/${t.id}`)} className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg"><Edit2 className="w-4 h-4"/></button>
163
- <button onClick={()=>navigate(`/content/${t.id}/days`)} className="flex items-center gap-1 text-sm text-slate-600 hover:text-slate-900 px-3 py-2 rounded-lg hover:bg-slate-50">Jours <ChevronRight className="w-4 h-4"/></button>
164
- <button onClick={()=>del(t.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg"><Trash2 className="w-4 h-4"/></button>
165
  </div>
166
  </div>
167
  ))}
168
- {!tracks.length&&<div className="text-center py-16 text-slate-400"><BookOpen className="w-12 h-12 mx-auto mb-3 opacity-30"/><p>Aucun parcours. Créez-en un !</p></div>}
169
  </div>
170
  </div>
171
  );
172
  }
173
 
174
  function TrackForm() {
175
- const { apiKey } = useAuth(); const { id } = useParams<{id:string}>(); const navigate = useNavigate();
176
- const isNew = id==='new';
177
- const [form, setForm] = useState({title:'',description:'',duration:7,language:'FR',isPremium:false,priceAmount:0,stripePriceId:''});
178
  const [saving, setSaving] = useState(false);
179
- useEffect(()=>{ if(!isNew) fetch(`${API_URL}/v1/admin/tracks/${id}`,{headers:ah(apiKey!)}).then(r=>r.json()).then(t=>setForm({title:t.title,description:t.description||'',duration:t.duration,language:t.language,isPremium:t.isPremium,priceAmount:t.priceAmount||0,stripePriceId:t.stripePriceId||''})); },[id]);
180
  const inp = "w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-slate-300";
181
- const handleSubmit = async(e:React.FormEvent)=>{ e.preventDefault(); setSaving(true);
182
- const url = isNew?`${API_URL}/v1/admin/tracks`:`${API_URL}/v1/admin/tracks/${id}`;
183
- await fetch(url,{method:isNew?'POST':'PUT',headers:ah(apiKey!),body:JSON.stringify({...form,priceAmount:form.priceAmount||undefined,stripePriceId:form.stripePriceId||undefined})});
184
- navigate('/content'); };
 
 
185
  return (
186
  <div className="p-8 max-w-xl">
187
  <div className="flex items-center gap-3 mb-6">
188
- <button onClick={()=>navigate('/content')} className="p-2 hover:bg-slate-100 rounded-lg"><ArrowLeft className="w-4 h-4"/></button>
189
- <h1 className="text-2xl font-bold text-slate-800">{isNew?'Nouveau parcours':'Modifier le parcours'}</h1>
190
  </div>
191
  <form onSubmit={handleSubmit} className="bg-white rounded-2xl border border-slate-100 p-6 space-y-4 shadow-sm">
192
  <div><label className="text-sm font-medium text-slate-700 mb-1 block">Titre *</label>
193
- <input required className={inp} value={form.title} onChange={e=>setForm(f=>({...f,title:e.target.value}))}/></div>
194
  <div><label className="text-sm font-medium text-slate-700 mb-1 block">Description</label>
195
- <textarea className={inp} rows={3} value={form.description} onChange={e=>setForm(f=>({...f,description:e.target.value}))}/></div>
196
  <div className="grid grid-cols-2 gap-4">
197
  <div><label className="text-sm font-medium text-slate-700 mb-1 block">Durée (jours)</label>
198
- <input type="number" min={1} required className={inp} value={form.duration} onChange={e=>setForm(f=>({...f,duration:parseInt(e.target.value)}))}/></div>
199
  <div><label className="text-sm font-medium text-slate-700 mb-1 block">Langue</label>
200
- <select className={inp} value={form.language} onChange={e=>setForm(f=>({...f,language:e.target.value}))}>
201
  <option value="FR">Français</option><option value="WOLOF">Wolof</option>
202
  </select></div>
203
  </div>
204
  <label className="flex items-center gap-3 p-3 bg-amber-50 rounded-xl cursor-pointer">
205
- <input type="checkbox" checked={form.isPremium} onChange={e=>setForm(f=>({...f,isPremium:e.target.checked}))} className="w-4 h-4"/>
206
  <span className="text-sm font-medium text-amber-800">Formation Premium (payante)</span>
207
  </label>
208
- {form.isPremium&&<div className="grid grid-cols-2 gap-4">
209
  <div><label className="text-sm font-medium text-slate-700 mb-1 block">Prix (XOF)</label>
210
- <input type="number" className={inp} value={form.priceAmount} onChange={e=>setForm(f=>({...f,priceAmount:parseInt(e.target.value)}))}/></div>
211
  <div><label className="text-sm font-medium text-slate-700 mb-1 block">Stripe Price ID</label>
212
- <input className={inp} value={form.stripePriceId} onChange={e=>setForm(f=>({...f,stripePriceId:e.target.value}))}/></div>
213
  </div>}
214
  <div className="flex gap-3 pt-2">
215
- <button type="button" onClick={()=>navigate('/content')} className="flex-1 border border-slate-200 text-slate-600 py-2.5 rounded-xl text-sm hover:bg-slate-50">Annuler</button>
216
  <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">
217
- <Save className="w-4 h-4"/>{saving?'Enregistrement...':'Enregistrer'}
218
  </button>
219
  </div>
220
  </form>
@@ -223,59 +226,61 @@ function TrackForm() {
223
  }
224
 
225
  function TrackDays() {
226
- const { apiKey } = useAuth(); const { trackId } = useParams<{trackId:string}>(); const navigate = useNavigate();
227
  const [days, setDays] = useState<any[]>([]); const [track, setTrack] = useState<any>(null); const [editing, setEditing] = useState<any>(null); const [saving, setSaving] = useState(false);
228
- const load = async()=>{ const[tR,dR]=await Promise.all([fetch(`${API_URL}/v1/admin/tracks/${trackId}`,{headers:ah(apiKey!)}),fetch(`${API_URL}/v1/admin/tracks/${trackId}/days`,{headers:ah(apiKey!)})]); setTrack(await tR.json()); setDays(await dR.json()); };
229
- useEffect(()=>{ load(); },[]);
230
- const emptyDay = {dayNumber:(days.length||0)+1,title:'',lessonText:'',audioUrl:'',exerciseType:'TEXT',exercisePrompt:'',validationKeyword:''};
231
- const saveDay = async(e:React.FormEvent)=>{ e.preventDefault(); setSaving(true);
232
- const url = editing.id?`${API_URL}/v1/admin/tracks/${trackId}/days/${editing.id}`:`${API_URL}/v1/admin/tracks/${trackId}/days`;
233
- await fetch(url,{method:editing.id?'PUT':'POST',headers:ah(apiKey!),body:JSON.stringify(editing)});
234
- setEditing(null); load(); setSaving(false); };
235
- const del = async(dayId:string)=>{ if(!confirm('Supprimer ce jour?')) return; await fetch(`${API_URL}/v1/admin/tracks/${trackId}/days/${dayId}`,{method:'DELETE',headers:ah(apiKey!)}); load(); };
 
 
236
  const inp = "w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-slate-300";
237
  return (
238
  <div className="p-8">
239
  <div className="flex items-center gap-3 mb-6">
240
- <button onClick={()=>navigate('/content')} className="p-2 hover:bg-slate-100 rounded-lg"><ArrowLeft className="w-4 h-4"/></button>
241
  <div><h1 className="text-2xl font-bold text-slate-800">{track?.title}</h1>
242
  <p className="text-sm text-slate-500">{days.length} jours configurés</p></div>
243
- <button onClick={()=>setEditing(emptyDay)} className="ml-auto flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-xl text-sm font-bold hover:bg-slate-700">
244
- <Plus className="w-4 h-4"/> Ajouter un jour
245
  </button>
246
  </div>
247
- {editing&&(
248
  <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
249
  <div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
250
  <div className="flex items-center justify-between p-5 border-b">
251
- <h2 className="font-bold text-slate-800">{editing.id?`Modifier Jour ${editing.dayNumber}`:'Nouveau jour'}</h2>
252
- <button onClick={()=>setEditing(null)}><X className="w-5 h-5 text-slate-400"/></button>
253
  </div>
254
  <form onSubmit={saveDay} className="p-5 space-y-4">
255
  <div className="grid grid-cols-2 gap-3">
256
  <div><label className="text-xs font-medium text-slate-600 mb-1 block">Numéro du jour</label>
257
- <input type="number" min={1} required className={inp} value={editing.dayNumber} onChange={e=>setEditing((d:any)=>({...d,dayNumber:parseInt(e.target.value)}))}/></div>
258
  <div><label className="text-xs font-medium text-slate-600 mb-1 block">Titre</label>
259
- <input className={inp} value={editing.title||''} onChange={e=>setEditing((d:any)=>({...d,title:e.target.value}))}/></div>
260
  </div>
261
  <div><label className="text-xs font-medium text-slate-600 mb-1 block">Texte de la leçon</label>
262
- <textarea className={inp} rows={5} value={editing.lessonText||''} onChange={e=>setEditing((d:any)=>({...d,lessonText:e.target.value}))} placeholder="Contenu pédagogique..."/></div>
263
  <div><label className="text-xs font-medium text-slate-600 mb-1 block">URL Audio (optionnel)</label>
264
- <input className={inp} value={editing.audioUrl||''} onChange={e=>setEditing((d:any)=>({...d,audioUrl:e.target.value}))} placeholder="https://..."/></div>
265
  <div className="grid grid-cols-2 gap-3">
266
  <div><label className="text-xs font-medium text-slate-600 mb-1 block">Type exercice</label>
267
- <select className={inp} value={editing.exerciseType} onChange={e=>setEditing((d:any)=>({...d,exerciseType:e.target.value}))}>
268
  <option value="TEXT">Texte libre</option><option value="AUDIO">Audio</option><option value="BUTTON">Boutons</option>
269
  </select></div>
270
  <div><label className="text-xs font-medium text-slate-600 mb-1 block">Mot-clé validation</label>
271
- <input className={inp} value={editing.validationKeyword||''} onChange={e=>setEditing((d:any)=>({...d,validationKeyword:e.target.value}))}/></div>
272
  </div>
273
  <div><label className="text-xs font-medium text-slate-600 mb-1 block">Prompt exercice</label>
274
- <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>
275
  <div className="flex gap-3">
276
- <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">Annuler</button>
277
  <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">
278
- <Save className="w-4 h-4"/>{saving?'Enregistrement...':'Enregistrer'}
279
  </button>
280
  </div>
281
  </form>
@@ -283,27 +288,27 @@ function TrackDays() {
283
  </div>
284
  )}
285
  <div className="grid gap-3">
286
- {days.map((d:any)=>(
287
  <div key={d.id} className="bg-white rounded-xl border border-slate-100 p-4 flex items-start justify-between shadow-sm">
288
  <div className="flex gap-4">
289
  <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>
290
  <div>
291
- <p className="font-medium text-slate-800">{d.title||`Jour ${d.dayNumber}`}</p>
292
- <p className="text-xs text-slate-500 mt-0.5 line-clamp-1">{d.lessonText?.substring(0,100)||'Pas de texte'}</p>
293
  <div className="flex gap-2 mt-1.5">
294
  <span className="text-xs bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full">{d.exerciseType}</span>
295
- {d.audioUrl&&<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">🎵 Audio</span>}
296
- {d.exercisePrompt&&<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">🎯 Exercice</span>}
297
  </div>
298
  </div>
299
  </div>
300
  <div className="flex gap-1 shrink-0 ml-4">
301
- <button onClick={()=>setEditing(d)} className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg"><Edit2 className="w-4 h-4"/></button>
302
- <button onClick={()=>del(d.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg"><Trash2 className="w-4 h-4"/></button>
303
  </div>
304
  </div>
305
  ))}
306
- {!days.length&&<div className="text-center py-12 text-slate-400 bg-white rounded-xl border border-dashed border-slate-200"><p>Aucun jour. Ajoutez le contenu pédagogique !</p></div>}
307
  </div>
308
  </div>
309
  );
@@ -312,29 +317,29 @@ function TrackDays() {
312
  function UserList() {
313
  const { apiKey } = useAuth();
314
  const [users, setUsers] = useState<any[]>([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true);
315
- useEffect(()=>{ fetch(`${API_URL}/v1/admin/users`,{headers:ah(apiKey!)}).then(r=>r.json()).then(d=>{ setUsers(d.users||d); setTotal(d.total||0); setLoading(false); }); },[]);
316
- if(loading) return <div className="p-8 text-slate-400">Chargement...</div>;
317
  return (
318
  <div className="p-8">
319
  <h1 className="text-3xl font-bold mb-6 text-slate-800">Utilisateurs <span className="text-lg font-normal text-slate-400">({total})</span></h1>
320
  <div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
321
  <table className="w-full text-sm">
322
  <thead className="bg-slate-50 text-xs text-slate-500 uppercase">
323
- <tr>{['Téléphone','Nom','Langue','Secteur','Inscrip.','Réponses','Date'].map(h=><th key={h} className="px-5 py-3 text-left">{h}</th>)}</tr>
324
  </thead>
325
  <tbody>
326
- {users.map((u:any)=>(
327
  <tr key={u.id} className="border-t border-slate-50 hover:bg-slate-50/50">
328
  <td className="px-5 py-3 font-medium">{u.phone}</td>
329
- <td className="px-5 py-3 text-slate-600">{u.name||'—'}</td>
330
  <td className="px-5 py-3"><span className="bg-blue-100 text-blue-700 text-xs px-2 py-0.5 rounded-full">{u.language}</span></td>
331
- <td className="px-5 py-3 text-slate-500 text-xs">{u.activity||'—'}</td>
332
- <td className="px-5 py-3 text-center">{u._count?.enrollments||0}</td>
333
- <td className="px-5 py-3 text-center">{u._count?.responses||0}</td>
334
  <td className="px-5 py-3 text-slate-400 text-xs">{new Date(u.createdAt).toLocaleDateString('fr-FR')}</td>
335
  </tr>
336
  ))}
337
- {!users.length&&<tr><td colSpan={7} className="px-5 py-8 text-center text-slate-400">Aucun utilisateur</td></tr>}
338
  </tbody>
339
  </table>
340
  </div>
@@ -351,7 +356,7 @@ function Settings() {
351
  <div><p className="font-medium text-slate-800">API URL</p><p className="text-sm text-slate-500 font-mono">{API_URL}</p></div>
352
  </div>
353
  <p className="text-sm font-medium text-slate-600">Variables Railway requises :</p>
354
- {['WHATSAPP_VERIFY_TOKEN','WHATSAPP_APP_SECRET','WHATSAPP_ACCESS_TOKEN','OPENAI_API_KEY','DATABASE_URL','REDIS_URL','API_URL','ADMIN_API_KEY'].map(v=>(
355
  <div key={v} className="flex items-center gap-3 p-3 border border-slate-100 rounded-xl">
356
  <span className="font-mono text-xs text-slate-700">{v}</span>
357
  </div>
@@ -364,17 +369,18 @@ function Settings() {
364
  function AppShell() {
365
  const { logout } = useAuth();
366
  const navItems = [
367
- { to:'/', label:'Dashboard', icon:<BarChart2 className="w-4 h-4"/> },
368
- { to:'/content', label:'Parcours', icon:<BookOpen className="w-4 h-4"/> },
369
- { to:'/users', label:'Utilisateurs', icon:<Users className="w-4 h-4"/> },
370
- { to:'/settings', label:'Paramètres', icon:<Lightbulb className="w-4 h-4"/> },
 
371
  ];
372
  return (
373
  <div className="min-h-screen bg-gray-50 flex">
374
  <aside className="w-56 bg-slate-900 text-white p-5 flex flex-col shrink-0">
375
  <div className="text-lg font-bold mb-8 flex items-center gap-2"><span className="text-2xl">🎓</span>EdTech Admin</div>
376
  <nav className="space-y-1 flex-1">
377
- {navItems.map(n=>(
378
  <Link key={n.to} to={n.to} className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-800 transition">
379
  {n.icon}{n.label}
380
  </Link>
@@ -384,13 +390,14 @@ function AppShell() {
384
  </aside>
385
  <main className="flex-1 overflow-auto">
386
  <Routes>
387
- <Route path="/" element={<Dashboard/>}/>
388
- <Route path="/content" element={<TrackList/>}/>
389
- <Route path="/content/new" element={<TrackForm/>}/>
390
- <Route path="/content/:id" element={<TrackForm/>}/>
391
- <Route path="/content/:trackId/days" element={<TrackDays/>}/>
392
- <Route path="/users" element={<UserList/>}/>
393
- <Route path="/settings" element={<Settings/>}/>
 
394
  </Routes>
395
  </main>
396
  </div>
@@ -402,8 +409,8 @@ function App() {
402
  <AuthProvider>
403
  <Router>
404
  <Routes>
405
- <Route path="/login" element={<LoginPage/>}/>
406
- <Route path="/*" element={<ProtectedRoute><AppShell/></ProtectedRoute>}/>
407
  </Routes>
408
  </Router>
409
  </AuthProvider>
 
1
  import { BrowserRouter as Router, Routes, Route, Link, Navigate, useNavigate, useParams } from 'react-router-dom';
2
  import { useEffect, useState, createContext, useContext } from 'react';
3
+ import { Users, PlayCircle, CheckCircle, Lightbulb, Download, BookOpen, Plus, Edit2, Trash2, ChevronRight, X, Save, BarChart2, DollarSign, ArrowLeft, Mic } from 'lucide-react';
4
+ import LiveFeed from './pages/LiveFeed';
5
 
6
  const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
7
  const SESSION_KEY = 'edtech_admin_key';
8
+ export const AuthContext = createContext<{ apiKey: string | null; login: (k: string) => void; logout: () => void; }>({ apiKey: null, login: () => { }, logout: () => { } });
9
  function AuthProvider({ children }: { children: React.ReactNode }) {
10
  const [apiKey, setApiKey] = useState<string | null>(() => sessionStorage.getItem(SESSION_KEY));
11
  const login = (k: string) => { sessionStorage.setItem(SESSION_KEY, k); setApiKey(k); };
12
  const logout = () => { sessionStorage.removeItem(SESSION_KEY); setApiKey(null); };
13
  return <AuthContext.Provider value={{ apiKey, login, logout }}>{children}</AuthContext.Provider>;
14
  }
15
+ export const useAuth = () => useContext(AuthContext);
16
  const ah = (k: string) => ({ 'Authorization': `Bearer ${k}`, 'Content-Type': 'application/json' });
17
  function ProtectedRoute({ children }: { children: React.ReactNode }) {
18
  const { apiKey } = useAuth();
 
78
  const exportCSV = () => {
79
  if (!enrollments.length) return alert('Aucune inscription.');
80
  const rows = enrollments.map((e: any) => [e.id, e.user?.phone, e.track?.title, e.status, e.currentDay, e.startedAt]);
81
+ const csv = [['ID', 'Phone', 'Track', 'Status', 'Day', 'Started'].join(','), ...rows.map(r => r.join(','))].join('\n');
82
  const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
83
+ a.download = `enrollments_${new Date().toISOString().slice(0, 10)}.csv`; a.click();
84
  };
85
  if (loading) return <div className="p-8 text-slate-400">Chargement...</div>;
86
  const statCards = [
 
88
  { icon: <PlayCircle className="w-6 h-6 text-blue-400" />, label: 'Actifs', value: stats?.activeEnrollments || 0, color: 'text-blue-600' },
89
  { icon: <CheckCircle className="w-6 h-6 text-green-400" />, label: 'Complétés', value: stats?.completedEnrollments || 0, color: 'text-green-600' },
90
  { icon: <Lightbulb className="w-6 h-6 text-purple-400" />, label: 'Parcours', value: stats?.totalTracks || 0, color: 'text-purple-600' },
91
+ { icon: <DollarSign className="w-6 h-6 text-emerald-400" />, label: 'Revenus', value: `${(stats?.totalRevenue || 0).toLocaleString()} XOF`, color: 'text-emerald-600' },
92
  ];
93
  return (
94
  <div className="p-8">
95
  <h1 className="text-3xl font-bold mb-8 text-slate-800">Dashboard</h1>
96
  <div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
97
+ {statCards.map((s, i) => (
98
  <div key={i} className="bg-white p-5 rounded-xl shadow-sm border border-slate-100 flex flex-col items-center gap-2">
99
  {s.icon}<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">{s.label}</p>
100
  <p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
 
110
  </div>
111
  <table className="w-full text-sm">
112
  <thead className="bg-slate-50 text-xs text-slate-500 uppercase">
113
+ <tr>{['Téléphone', 'Parcours', 'Statut', 'Jour', 'Date'].map(h => <th key={h} className="px-6 py-3 text-left">{h}</th>)}</tr>
114
  </thead>
115
  <tbody>
116
+ {enrollments.map((e: any) => (
117
  <tr key={e.id} className="border-t border-slate-50 hover:bg-slate-50/50">
118
+ <td className="px-6 py-4 font-medium text-slate-900">{e.user?.phone || '—'}</td>
119
+ <td className="px-6 py-4">{e.track?.title || '—'}</td>
120
+ <td className="px-6 py-4"><span className={`px-2 py-1 rounded-full text-xs font-medium ${e.status === 'ACTIVE' ? 'bg-blue-100 text-blue-800' : e.status === 'COMPLETED' ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-800'}`}>{e.status}</span></td>
121
  <td className="px-6 py-4">Jour {e.currentDay}</td>
122
  <td className="px-6 py-4 text-slate-500">{new Date(e.startedAt).toLocaleDateString('fr-FR')}</td>
123
  </tr>
124
  ))}
125
+ {!enrollments.length && <tr><td colSpan={5} className="px-6 py-8 text-center text-slate-400">Aucune inscription</td></tr>}
126
  </tbody>
127
  </table>
128
  </div>
 
133
  function TrackList() {
134
  const { apiKey } = useAuth(); const navigate = useNavigate();
135
  const [tracks, setTracks] = useState<any[]>([]); const [loading, setLoading] = useState(true);
136
+ const load = async () => { const r = await fetch(`${API_URL}/v1/admin/tracks`, { headers: ah(apiKey!) }); setTracks(await r.json()); setLoading(false); };
137
+ useEffect(() => { load(); }, []);
138
+ const del = async (id: string) => { if (!confirm('Supprimer ce parcours ?')) return; await fetch(`${API_URL}/v1/admin/tracks/${id}`, { method: 'DELETE', headers: ah(apiKey!) }); load(); };
139
+ if (loading) return <div className="p-8 text-slate-400">Chargement...</div>;
140
  return (
141
  <div className="p-8">
142
  <div className="flex justify-between items-center mb-6">
143
  <h1 className="text-3xl font-bold text-slate-800">Parcours</h1>
144
+ <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">
145
+ <Plus className="w-4 h-4" /> Nouveau parcours
146
  </button>
147
  </div>
148
  <div className="grid gap-4">
149
+ {tracks.map((t: any) => (
150
  <div key={t.id} className="bg-white rounded-xl border border-slate-100 p-5 flex items-center justify-between shadow-sm hover:shadow-md transition">
151
  <div className="flex items-center gap-4">
152
+ <div className="bg-purple-100 p-3 rounded-xl"><BookOpen className="w-5 h-5 text-purple-600" /></div>
153
  <div>
154
  <div className="flex items-center gap-2">
155
  <h3 className="font-bold text-slate-800">{t.title}</h3>
156
+ {t.isPremium && <span className="bg-amber-100 text-amber-700 text-xs px-2 py-0.5 rounded-full font-medium">Premium</span>}
157
  <span className="bg-slate-100 text-slate-600 text-xs px-2 py-0.5 rounded-full">{t.language}</span>
158
  </div>
159
+ <p className="text-sm text-slate-500 mt-0.5">{t._count?.days || 0} jours · {t._count?.enrollments || 0} inscrits · {t.duration}j</p>
160
  </div>
161
  </div>
162
  <div className="flex items-center gap-2">
163
+ <button onClick={() => navigate(`/content/${t.id}`)} className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg"><Edit2 className="w-4 h-4" /></button>
164
+ <button onClick={() => navigate(`/content/${t.id}/days`)} className="flex items-center gap-1 text-sm text-slate-600 hover:text-slate-900 px-3 py-2 rounded-lg hover:bg-slate-50">Jours <ChevronRight className="w-4 h-4" /></button>
165
+ <button onClick={() => del(t.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg"><Trash2 className="w-4 h-4" /></button>
166
  </div>
167
  </div>
168
  ))}
169
+ {!tracks.length && <div className="text-center py-16 text-slate-400"><BookOpen className="w-12 h-12 mx-auto mb-3 opacity-30" /><p>Aucun parcours. Créez-en un !</p></div>}
170
  </div>
171
  </div>
172
  );
173
  }
174
 
175
  function TrackForm() {
176
+ const { apiKey } = useAuth(); const { id } = useParams<{ id: string }>(); const navigate = useNavigate();
177
+ const isNew = id === 'new';
178
+ const [form, setForm] = useState({ title: '', description: '', duration: 7, language: 'FR', isPremium: false, priceAmount: 0, stripePriceId: '' });
179
  const [saving, setSaving] = useState(false);
180
+ useEffect(() => { if (!isNew) fetch(`${API_URL}/v1/admin/tracks/${id}`, { headers: ah(apiKey!) }).then(r => r.json()).then(t => setForm({ title: t.title, description: t.description || '', duration: t.duration, language: t.language, isPremium: t.isPremium, priceAmount: t.priceAmount || 0, stripePriceId: t.stripePriceId || '' })); }, [id]);
181
  const inp = "w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-slate-300";
182
+ const handleSubmit = async (e: React.FormEvent) => {
183
+ e.preventDefault(); setSaving(true);
184
+ const url = isNew ? `${API_URL}/v1/admin/tracks` : `${API_URL}/v1/admin/tracks/${id}`;
185
+ await fetch(url, { method: isNew ? 'POST' : 'PUT', headers: ah(apiKey!), body: JSON.stringify({ ...form, priceAmount: form.priceAmount || undefined, stripePriceId: form.stripePriceId || undefined }) });
186
+ navigate('/content');
187
+ };
188
  return (
189
  <div className="p-8 max-w-xl">
190
  <div className="flex items-center gap-3 mb-6">
191
+ <button onClick={() => navigate('/content')} className="p-2 hover:bg-slate-100 rounded-lg"><ArrowLeft className="w-4 h-4" /></button>
192
+ <h1 className="text-2xl font-bold text-slate-800">{isNew ? 'Nouveau parcours' : 'Modifier le parcours'}</h1>
193
  </div>
194
  <form onSubmit={handleSubmit} className="bg-white rounded-2xl border border-slate-100 p-6 space-y-4 shadow-sm">
195
  <div><label className="text-sm font-medium text-slate-700 mb-1 block">Titre *</label>
196
+ <input required className={inp} value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} /></div>
197
  <div><label className="text-sm font-medium text-slate-700 mb-1 block">Description</label>
198
+ <textarea className={inp} rows={3} value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} /></div>
199
  <div className="grid grid-cols-2 gap-4">
200
  <div><label className="text-sm font-medium text-slate-700 mb-1 block">Durée (jours)</label>
201
+ <input type="number" min={1} required className={inp} value={form.duration} onChange={e => setForm(f => ({ ...f, duration: parseInt(e.target.value) }))} /></div>
202
  <div><label className="text-sm font-medium text-slate-700 mb-1 block">Langue</label>
203
+ <select className={inp} value={form.language} onChange={e => setForm(f => ({ ...f, language: e.target.value }))}>
204
  <option value="FR">Français</option><option value="WOLOF">Wolof</option>
205
  </select></div>
206
  </div>
207
  <label className="flex items-center gap-3 p-3 bg-amber-50 rounded-xl cursor-pointer">
208
+ <input type="checkbox" checked={form.isPremium} onChange={e => setForm(f => ({ ...f, isPremium: e.target.checked }))} className="w-4 h-4" />
209
  <span className="text-sm font-medium text-amber-800">Formation Premium (payante)</span>
210
  </label>
211
+ {form.isPremium && <div className="grid grid-cols-2 gap-4">
212
  <div><label className="text-sm font-medium text-slate-700 mb-1 block">Prix (XOF)</label>
213
+ <input type="number" className={inp} value={form.priceAmount} onChange={e => setForm(f => ({ ...f, priceAmount: parseInt(e.target.value) }))} /></div>
214
  <div><label className="text-sm font-medium text-slate-700 mb-1 block">Stripe Price ID</label>
215
+ <input className={inp} value={form.stripePriceId} onChange={e => setForm(f => ({ ...f, stripePriceId: e.target.value }))} /></div>
216
  </div>}
217
  <div className="flex gap-3 pt-2">
218
+ <button type="button" onClick={() => navigate('/content')} className="flex-1 border border-slate-200 text-slate-600 py-2.5 rounded-xl text-sm hover:bg-slate-50">Annuler</button>
219
  <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">
220
+ <Save className="w-4 h-4" />{saving ? 'Enregistrement...' : 'Enregistrer'}
221
  </button>
222
  </div>
223
  </form>
 
226
  }
227
 
228
  function TrackDays() {
229
+ const { apiKey } = useAuth(); const { trackId } = useParams<{ trackId: string }>(); const navigate = useNavigate();
230
  const [days, setDays] = useState<any[]>([]); const [track, setTrack] = useState<any>(null); const [editing, setEditing] = useState<any>(null); const [saving, setSaving] = useState(false);
231
+ const load = async () => { const [tR, dR] = await Promise.all([fetch(`${API_URL}/v1/admin/tracks/${trackId}`, { headers: ah(apiKey!) }), fetch(`${API_URL}/v1/admin/tracks/${trackId}/days`, { headers: ah(apiKey!) })]); setTrack(await tR.json()); setDays(await dR.json()); };
232
+ useEffect(() => { load(); }, []);
233
+ const emptyDay = { dayNumber: (days.length || 0) + 1, title: '', lessonText: '', audioUrl: '', exerciseType: 'TEXT', exercisePrompt: '', validationKeyword: '' };
234
+ const saveDay = async (e: React.FormEvent) => {
235
+ e.preventDefault(); setSaving(true);
236
+ const url = editing.id ? `${API_URL}/v1/admin/tracks/${trackId}/days/${editing.id}` : `${API_URL}/v1/admin/tracks/${trackId}/days`;
237
+ await fetch(url, { method: editing.id ? 'PUT' : 'POST', headers: ah(apiKey!), body: JSON.stringify(editing) });
238
+ setEditing(null); load(); setSaving(false);
239
+ };
240
+ const del = async (dayId: string) => { if (!confirm('Supprimer ce jour?')) return; await fetch(`${API_URL}/v1/admin/tracks/${trackId}/days/${dayId}`, { method: 'DELETE', headers: ah(apiKey!) }); load(); };
241
  const inp = "w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-slate-300";
242
  return (
243
  <div className="p-8">
244
  <div className="flex items-center gap-3 mb-6">
245
+ <button onClick={() => navigate('/content')} className="p-2 hover:bg-slate-100 rounded-lg"><ArrowLeft className="w-4 h-4" /></button>
246
  <div><h1 className="text-2xl font-bold text-slate-800">{track?.title}</h1>
247
  <p className="text-sm text-slate-500">{days.length} jours configurés</p></div>
248
+ <button onClick={() => setEditing(emptyDay)} className="ml-auto flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-xl text-sm font-bold hover:bg-slate-700">
249
+ <Plus className="w-4 h-4" /> Ajouter un jour
250
  </button>
251
  </div>
252
+ {editing && (
253
  <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
254
  <div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
255
  <div className="flex items-center justify-between p-5 border-b">
256
+ <h2 className="font-bold text-slate-800">{editing.id ? `Modifier Jour ${editing.dayNumber}` : 'Nouveau jour'}</h2>
257
+ <button onClick={() => setEditing(null)}><X className="w-5 h-5 text-slate-400" /></button>
258
  </div>
259
  <form onSubmit={saveDay} className="p-5 space-y-4">
260
  <div className="grid grid-cols-2 gap-3">
261
  <div><label className="text-xs font-medium text-slate-600 mb-1 block">Numéro du jour</label>
262
+ <input type="number" min={1} required className={inp} value={editing.dayNumber} onChange={e => setEditing((d: any) => ({ ...d, dayNumber: parseInt(e.target.value) }))} /></div>
263
  <div><label className="text-xs font-medium text-slate-600 mb-1 block">Titre</label>
264
+ <input className={inp} value={editing.title || ''} onChange={e => setEditing((d: any) => ({ ...d, title: e.target.value }))} /></div>
265
  </div>
266
  <div><label className="text-xs font-medium text-slate-600 mb-1 block">Texte de la leçon</label>
267
+ <textarea className={inp} rows={5} value={editing.lessonText || ''} onChange={e => setEditing((d: any) => ({ ...d, lessonText: e.target.value }))} placeholder="Contenu pédagogique..." /></div>
268
  <div><label className="text-xs font-medium text-slate-600 mb-1 block">URL Audio (optionnel)</label>
269
+ <input className={inp} value={editing.audioUrl || ''} onChange={e => setEditing((d: any) => ({ ...d, audioUrl: e.target.value }))} placeholder="https://..." /></div>
270
  <div className="grid grid-cols-2 gap-3">
271
  <div><label className="text-xs font-medium text-slate-600 mb-1 block">Type exercice</label>
272
+ <select className={inp} value={editing.exerciseType} onChange={e => setEditing((d: any) => ({ ...d, exerciseType: e.target.value }))}>
273
  <option value="TEXT">Texte libre</option><option value="AUDIO">Audio</option><option value="BUTTON">Boutons</option>
274
  </select></div>
275
  <div><label className="text-xs font-medium text-slate-600 mb-1 block">Mot-clé validation</label>
276
+ <input className={inp} value={editing.validationKeyword || ''} onChange={e => setEditing((d: any) => ({ ...d, validationKeyword: e.target.value }))} /></div>
277
  </div>
278
  <div><label className="text-xs font-medium text-slate-600 mb-1 block">Prompt exercice</label>
279
+ <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>
280
  <div className="flex gap-3">
281
+ <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">Annuler</button>
282
  <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">
283
+ <Save className="w-4 h-4" />{saving ? 'Enregistrement...' : 'Enregistrer'}
284
  </button>
285
  </div>
286
  </form>
 
288
  </div>
289
  )}
290
  <div className="grid gap-3">
291
+ {days.map((d: any) => (
292
  <div key={d.id} className="bg-white rounded-xl border border-slate-100 p-4 flex items-start justify-between shadow-sm">
293
  <div className="flex gap-4">
294
  <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>
295
  <div>
296
+ <p className="font-medium text-slate-800">{d.title || `Jour ${d.dayNumber}`}</p>
297
+ <p className="text-xs text-slate-500 mt-0.5 line-clamp-1">{d.lessonText?.substring(0, 100) || 'Pas de texte'}</p>
298
  <div className="flex gap-2 mt-1.5">
299
  <span className="text-xs bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full">{d.exerciseType}</span>
300
+ {d.audioUrl && <span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">🎵 Audio</span>}
301
+ {d.exercisePrompt && <span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">🎯 Exercice</span>}
302
  </div>
303
  </div>
304
  </div>
305
  <div className="flex gap-1 shrink-0 ml-4">
306
+ <button onClick={() => setEditing(d)} className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg"><Edit2 className="w-4 h-4" /></button>
307
+ <button onClick={() => del(d.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg"><Trash2 className="w-4 h-4" /></button>
308
  </div>
309
  </div>
310
  ))}
311
+ {!days.length && <div className="text-center py-12 text-slate-400 bg-white rounded-xl border border-dashed border-slate-200"><p>Aucun jour. Ajoutez le contenu pédagogique !</p></div>}
312
  </div>
313
  </div>
314
  );
 
317
  function UserList() {
318
  const { apiKey } = useAuth();
319
  const [users, setUsers] = useState<any[]>([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true);
320
+ useEffect(() => { fetch(`${API_URL}/v1/admin/users`, { headers: ah(apiKey!) }).then(r => r.json()).then(d => { setUsers(d.users || d); setTotal(d.total || 0); setLoading(false); }); }, []);
321
+ if (loading) return <div className="p-8 text-slate-400">Chargement...</div>;
322
  return (
323
  <div className="p-8">
324
  <h1 className="text-3xl font-bold mb-6 text-slate-800">Utilisateurs <span className="text-lg font-normal text-slate-400">({total})</span></h1>
325
  <div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
326
  <table className="w-full text-sm">
327
  <thead className="bg-slate-50 text-xs text-slate-500 uppercase">
328
+ <tr>{['Téléphone', 'Nom', 'Langue', 'Secteur', 'Inscrip.', 'Réponses', 'Date'].map(h => <th key={h} className="px-5 py-3 text-left">{h}</th>)}</tr>
329
  </thead>
330
  <tbody>
331
+ {users.map((u: any) => (
332
  <tr key={u.id} className="border-t border-slate-50 hover:bg-slate-50/50">
333
  <td className="px-5 py-3 font-medium">{u.phone}</td>
334
+ <td className="px-5 py-3 text-slate-600">{u.name || '—'}</td>
335
  <td className="px-5 py-3"><span className="bg-blue-100 text-blue-700 text-xs px-2 py-0.5 rounded-full">{u.language}</span></td>
336
+ <td className="px-5 py-3 text-slate-500 text-xs">{u.activity || '—'}</td>
337
+ <td className="px-5 py-3 text-center">{u._count?.enrollments || 0}</td>
338
+ <td className="px-5 py-3 text-center">{u._count?.responses || 0}</td>
339
  <td className="px-5 py-3 text-slate-400 text-xs">{new Date(u.createdAt).toLocaleDateString('fr-FR')}</td>
340
  </tr>
341
  ))}
342
+ {!users.length && <tr><td colSpan={7} className="px-5 py-8 text-center text-slate-400">Aucun utilisateur</td></tr>}
343
  </tbody>
344
  </table>
345
  </div>
 
356
  <div><p className="font-medium text-slate-800">API URL</p><p className="text-sm text-slate-500 font-mono">{API_URL}</p></div>
357
  </div>
358
  <p className="text-sm font-medium text-slate-600">Variables Railway requises :</p>
359
+ {['WHATSAPP_VERIFY_TOKEN', 'WHATSAPP_APP_SECRET', 'WHATSAPP_ACCESS_TOKEN', 'OPENAI_API_KEY', 'DATABASE_URL', 'REDIS_URL', 'API_URL', 'ADMIN_API_KEY'].map(v => (
360
  <div key={v} className="flex items-center gap-3 p-3 border border-slate-100 rounded-xl">
361
  <span className="font-mono text-xs text-slate-700">{v}</span>
362
  </div>
 
369
  function AppShell() {
370
  const { logout } = useAuth();
371
  const navItems = [
372
+ { to: '/', label: 'Dashboard', icon: <BarChart2 className="w-4 h-4" /> },
373
+ { to: '/content', label: 'Parcours', icon: <BookOpen className="w-4 h-4" /> },
374
+ { to: '/live-feed', label: 'Modération', icon: <Mic className="w-4 h-4 text-emerald-500" /> },
375
+ { to: '/users', label: 'Utilisateurs', icon: <Users className="w-4 h-4" /> },
376
+ { to: '/settings', label: 'Paramètres', icon: <Lightbulb className="w-4 h-4" /> },
377
  ];
378
  return (
379
  <div className="min-h-screen bg-gray-50 flex">
380
  <aside className="w-56 bg-slate-900 text-white p-5 flex flex-col shrink-0">
381
  <div className="text-lg font-bold mb-8 flex items-center gap-2"><span className="text-2xl">🎓</span>EdTech Admin</div>
382
  <nav className="space-y-1 flex-1">
383
+ {navItems.map(n => (
384
  <Link key={n.to} to={n.to} className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-800 transition">
385
  {n.icon}{n.label}
386
  </Link>
 
390
  </aside>
391
  <main className="flex-1 overflow-auto">
392
  <Routes>
393
+ <Route path="/" element={<Dashboard />} />
394
+ <Route path="/content" element={<TrackList />} />
395
+ <Route path="/content/new" element={<TrackForm />} />
396
+ <Route path="/content/:id" element={<TrackForm />} />
397
+ <Route path="/content/:trackId/days" element={<TrackDays />} />
398
+ <Route path="/live-feed" element={<LiveFeed />} />
399
+ <Route path="/users" element={<UserList />} />
400
+ <Route path="/settings" element={<Settings />} />
401
  </Routes>
402
  </main>
403
  </div>
 
409
  <AuthProvider>
410
  <Router>
411
  <Routes>
412
+ <Route path="/login" element={<LoginPage />} />
413
+ <Route path="/*" element={<ProtectedRoute><AppShell /></ProtectedRoute>} />
414
  </Routes>
415
  </Router>
416
  </AuthProvider>
apps/admin/src/pages/LiveFeed.tsx ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { Play, Square, Mic, Send, AlertCircle, CheckCircle2, Loader2, User, Briefcase } from 'lucide-react';
3
+ import { useAuth } from '../App';
4
+
5
+ interface PendingReview {
6
+ id: string;
7
+ userId: string;
8
+ trackId: string;
9
+ user: {
10
+ id: string;
11
+ name: string | null;
12
+ phone: string;
13
+ activity: string | null;
14
+ language: string;
15
+ };
16
+ track: {
17
+ id: string;
18
+ title: string;
19
+ };
20
+ audioUrl: string | null;
21
+ dayNumber: number;
22
+ }
23
+
24
+ export default function LiveFeed() {
25
+ const [reviews, setReviews] = useState<PendingReview[]>([]);
26
+ const [loading, setLoading] = useState(true);
27
+ const [error, setError] = useState<string | null>(null);
28
+ const [successMsg, setSuccessMsg] = useState<string | null>(null);
29
+
30
+ const { apiKey } = useAuth();
31
+ // MOCK ADMIN ID (Replace with actual auth context)
32
+ const ADMIN_ID = "admin_01";
33
+
34
+ // Gestion propre de l'URL de l'API (Support pour Vite, Next.js ou relative)
35
+ const getApiUrl = (endpoint: string) => {
36
+ const rawBaseUrl = import.meta.env?.VITE_API_URL || 'http://localhost:3001';
37
+ const baseUrl = rawBaseUrl.replace(/\/$/, '');
38
+ return `${baseUrl}/v1/admin${endpoint}`;
39
+ };
40
+
41
+ const fetchLiveFeed = async () => {
42
+ try {
43
+ const res = await fetch(getApiUrl('/live-feed'), {
44
+ headers: {
45
+ 'Authorization': `Bearer ${apiKey}`
46
+ }
47
+ });
48
+ if (!res.ok) throw new Error('Erreur réseau');
49
+ const data = await res.json();
50
+ setReviews(data);
51
+ } catch (err: any) {
52
+ setError(err.message);
53
+ } finally {
54
+ setLoading(false);
55
+ }
56
+ };
57
+
58
+ useEffect(() => {
59
+ fetchLiveFeed();
60
+ // Polling every 30 seconds
61
+ const interval = setInterval(fetchLiveFeed, 30000);
62
+ return () => clearInterval(interval);
63
+ }, []);
64
+
65
+ const handleOverrideSuccess = (userId: string, isSkip: boolean = false) => {
66
+ setReviews((prev: PendingReview[]) => prev.filter((r: PendingReview) => r.userId !== userId));
67
+ if (!isSkip) {
68
+ setSuccessMsg("Intervention vocale envoyée avec succès !");
69
+ setTimeout(() => setSuccessMsg(null), 3000);
70
+ }
71
+ };
72
+
73
+ if (loading) {
74
+ return (
75
+ <div className="flex items-center justify-center p-12">
76
+ <Loader2 className="w-8 h-8 animate-spin text-emerald-600" />
77
+ </div>
78
+ );
79
+ }
80
+
81
+ return (
82
+ <div className="p-8 max-w-5xl mx-auto">
83
+ <div className="flex items-center justify-between mb-8">
84
+ <div>
85
+ <h1 className="text-3xl font-bold tracking-tight text-slate-900">Live Feed Modération</h1>
86
+ <p className="text-slate-500 mt-2">Étudiants Wolof en attente d'intervention manuelle.</p>
87
+ </div>
88
+ <div className="bg-emerald-50 text-emerald-700 px-4 py-2 rounded-full font-medium flex items-center gap-2">
89
+ <AlertCircle className="w-5 h-5" />
90
+ {reviews.length} En attente
91
+ </div>
92
+ </div>
93
+
94
+ {error && (
95
+ <div className="bg-red-50 text-red-600 p-4 rounded-lg mb-8 flex items-center gap-2">
96
+ <AlertCircle className="w-5 h-5" />
97
+ <p>Erreur lors du chargement: {error}</p>
98
+ </div>
99
+ )}
100
+
101
+ {successMsg && (
102
+ <div className="bg-emerald-50 text-emerald-700 p-4 rounded-lg mb-8 flex items-center gap-2 transition-all">
103
+ <CheckCircle2 className="w-5 h-5" />
104
+ <p>{successMsg}</p>
105
+ </div>
106
+ )}
107
+
108
+ <div className="grid gap-6">
109
+ {reviews.length === 0 ? (
110
+ <div className="text-center py-24 bg-slate-50 rounded-2xl border border-dashed border-slate-200">
111
+ <CheckCircle2 className="w-12 h-12 text-slate-300 mx-auto mb-4" />
112
+ <p className="text-slate-500 text-lg">Aucune intervention requise. Excellent travail !</p>
113
+ </div>
114
+ ) : (
115
+ reviews.map(review => (
116
+ <ModerationCard
117
+ key={review.id}
118
+ review={review}
119
+ adminId={ADMIN_ID}
120
+ onSuccess={() => handleOverrideSuccess(review.userId, false)}
121
+ onSkip={() => handleOverrideSuccess(review.userId, true)} // Temporarily hide it the same way
122
+ getApiUrl={getApiUrl}
123
+ apiKey={apiKey || ''}
124
+ />
125
+ ))
126
+ )}
127
+ </div>
128
+ </div>
129
+ );
130
+ }
131
+
132
+ // ----------------------------------------------------------------------
133
+ // MODERATION CARD COMPONENT
134
+ // ----------------------------------------------------------------------
135
+
136
+ function ModerationCard({ review, adminId, onSuccess, onSkip, getApiUrl, apiKey }: { key?: string | number, review: PendingReview, adminId: string, onSuccess: () => void, onSkip: () => void, getApiUrl: (path: string) => string, apiKey: string }) {
137
+ const [transcription, setTranscription] = useState('');
138
+ const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
139
+ const [isSubmitting, setIsSubmitting] = useState(false);
140
+ const audioRef = useRef<HTMLAudioElement>(null);
141
+ const [playbackRate, setPlaybackRate] = useState(1);
142
+
143
+ const changeSpeed = () => {
144
+ const nextRates = { 1: 1.5, 1.5: 2, 2: 1 } as Record<number, number>;
145
+ const newRate = nextRates[playbackRate] || 1;
146
+ setPlaybackRate(newRate);
147
+ if (audioRef.current) {
148
+ audioRef.current.playbackRate = newRate;
149
+ }
150
+ };
151
+
152
+ const handleSubmit = async () => {
153
+ if (!transcription || !audioBlob) {
154
+ alert("Veuillez remplir la transcription ET enregistrer une réponse audio.");
155
+ return;
156
+ }
157
+
158
+ setIsSubmitting(true);
159
+ let overrideAudioUrl = "";
160
+
161
+ try {
162
+ // 1. Upload the AudioBlob to S3/CloudFlare via an upload route
163
+ const formData = new FormData();
164
+ formData.append("file", audioBlob, `override-${review.userId}.webm`);
165
+
166
+ const uploadRes = await fetch(getApiUrl('/upload'), {
167
+ method: 'POST',
168
+ headers: {
169
+ 'Authorization': `Bearer ${apiKey}`
170
+ },
171
+ body: formData
172
+ });
173
+
174
+ if (!uploadRes.ok) throw new Error("Erreur lors de l'upload audio");
175
+ const uploadData = await uploadRes.json();
176
+ overrideAudioUrl = uploadData.url;
177
+
178
+ // 2. Submit the Override Feedback
179
+ const feedbackRes = await fetch(getApiUrl('/override-feedback'), {
180
+ method: 'POST',
181
+ headers: {
182
+ 'Content-Type': 'application/json',
183
+ 'Authorization': `Bearer ${apiKey}`
184
+ },
185
+ body: JSON.stringify({
186
+ userId: review.userId,
187
+ trackId: review.trackId,
188
+ transcription: transcription,
189
+ overrideAudioUrl: overrideAudioUrl,
190
+ adminId: adminId
191
+ })
192
+ });
193
+
194
+ if (!feedbackRes.ok) throw new Error("Erreur lors de la validation");
195
+ onSuccess();
196
+
197
+ } catch (err: any) {
198
+ alert(err.message);
199
+ } finally {
200
+ setIsSubmitting(false);
201
+ }
202
+ };
203
+
204
+ return (
205
+ <div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 flex flex-col gap-6">
206
+
207
+ {/* Header: User Info */}
208
+ <div className="flex items-start justify-between">
209
+ <div>
210
+ <div className="flex items-center gap-3">
211
+ <div className="w-10 h-10 bg-indigo-50 rounded-full flex items-center justify-center">
212
+ <User className="w-5 h-5 text-indigo-600" />
213
+ </div>
214
+ <div>
215
+ <h3 className="font-semibold text-slate-900">{review.user.name || review.user.phone}</h3>
216
+ <p className="text-sm text-slate-500">Jour {review.dayNumber} • {review.track.title}</p>
217
+ </div>
218
+ </div>
219
+ </div>
220
+ <div className="flex items-center gap-2 bg-slate-50 px-3 py-1.5 rounded-md border border-slate-100">
221
+ <Briefcase className="w-4 h-4 text-slate-400" />
222
+ <span className="text-sm font-medium text-slate-700">{review.user.activity || "Activité inconnue"}</span>
223
+ </div>
224
+ </div>
225
+
226
+ {/* Body: Audio & Transcription */}
227
+ <div className="grid md:grid-cols-2 gap-6">
228
+
229
+ {/* Left Column: Écoute */}
230
+ <div className="bg-slate-50 p-5 rounded-xl border border-slate-100">
231
+ <h4 className="text-sm font-semibold text-slate-700 mb-3 uppercase tracking-wider flex justify-between items-center">
232
+ Audio Étudiant (WOLOF)
233
+ {review.audioUrl && (
234
+ <button onClick={changeSpeed} className="text-xs bg-indigo-100 text-indigo-700 px-2 py-1 rounded-md font-medium hover:bg-indigo-200 transition-colors">
235
+ Vitesse: {playbackRate}x
236
+ </button>
237
+ )}
238
+ </h4>
239
+ {review.audioUrl ? (
240
+ <audio ref={audioRef} controls src={review.audioUrl} className="w-full h-10" />
241
+ ) : (
242
+ <p className="text-sm text-slate-400 italic">Aucun fichier audio trouvé.</p>
243
+ )}
244
+ </div>
245
+
246
+ {/* Right Column: Saisie manuelle */}
247
+ <div>
248
+ <label className="text-sm font-semibold text-slate-700 mb-2 block uppercase tracking-wider">
249
+ Transcription Française <span className="text-red-500">*</span>
250
+ </label>
251
+ <textarea
252
+ className="w-full text-sm p-3 rounded-lg border border-slate-200 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-shadow resize-none h-24"
253
+ placeholder="Écrivez ce que l'étudiant a dit en le traduisant correctement en français..."
254
+ value={transcription}
255
+ onChange={(e: any) => setTranscription(e.target.value)}
256
+ />
257
+ </div>
258
+ </div>
259
+
260
+ <hr className="border-slate-100" />
261
+
262
+ {/* Footer: Admin Voice Overdrive & Action */}
263
+ <div className="flex items-end justify-between gap-4">
264
+ <div className="flex-1">
265
+ <label className="text-sm font-semibold text-slate-700 mb-2 block uppercase tracking-wider">
266
+ Votre Réponse Audio (WOLOF) <span className="text-red-500">*</span>
267
+ </label>
268
+ <AudioRecorder onRecordComplete={setAudioBlob} />
269
+ </div>
270
+
271
+ <div className="flex gap-2">
272
+ <button
273
+ onClick={onSkip}
274
+ type="button"
275
+ className="bg-slate-100 hover:bg-slate-200 text-slate-600 px-6 py-3 rounded-xl font-medium shadow-sm transition-colors flex items-center justify-center h-[52px]"
276
+ >
277
+ Passer
278
+ </button>
279
+ <button
280
+ onClick={handleSubmit}
281
+ disabled={isSubmitting || !transcription || !audioBlob}
282
+ className="bg-emerald-600 hover:bg-emerald-700 disabled:bg-slate-300 disabled:cursor-not-allowed text-white px-6 py-3 rounded-xl font-medium shadow-sm transition-colors flex items-center gap-2 h-[52px]"
283
+ >
284
+ {isSubmitting ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
285
+ Envoyer Overdrive
286
+ </button>
287
+ </div>
288
+ </div>
289
+
290
+ </div>
291
+ );
292
+ }
293
+
294
+ // ----------------------------------------------------------------------
295
+ // AUDIO RECORDER COMPONENT (Web MediaRecorder API)
296
+ // ----------------------------------------------------------------------
297
+
298
+ function AudioRecorder({ onRecordComplete }: { onRecordComplete: (blob: Blob | null) => void }) {
299
+ const [isRecording, setIsRecording] = useState(false);
300
+ const [audioUrl, setAudioUrl] = useState<string | null>(null);
301
+
302
+ const mediaRecorder = useRef<MediaRecorder | null>(null);
303
+ const audioChunks = useRef<BlobPart[]>([]);
304
+
305
+ const startRecording = async () => {
306
+ try {
307
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
308
+ mediaRecorder.current = new MediaRecorder(stream);
309
+ audioChunks.current = [];
310
+
311
+ mediaRecorder.current.ondataavailable = (event: any) => {
312
+ audioChunks.current.push(event.data);
313
+ };
314
+
315
+ mediaRecorder.current.onstop = () => {
316
+ const audioBlob = new Blob(audioChunks.current, { type: 'audio/webm' });
317
+ const url = URL.createObjectURL(audioBlob);
318
+ setAudioUrl(url);
319
+ onRecordComplete(audioBlob);
320
+ };
321
+
322
+ mediaRecorder.current.start();
323
+ setIsRecording(true);
324
+ setAudioUrl(null);
325
+ onRecordComplete(null);
326
+ } catch (err) {
327
+ alert("Accès au microphone refusé.");
328
+ }
329
+ };
330
+
331
+ const stopRecording = () => {
332
+ if (mediaRecorder.current && isRecording) {
333
+ mediaRecorder.current.stop();
334
+ setIsRecording(false);
335
+ // Stop all tracks to release mic
336
+ mediaRecorder.current.stream.getTracks().forEach((track: any) => track.stop());
337
+ }
338
+ };
339
+
340
+ const clearRecording = () => {
341
+ setAudioUrl(null);
342
+ onRecordComplete(null);
343
+ };
344
+
345
+ return (
346
+ <div className="flex items-center gap-3">
347
+ {isRecording ? (
348
+ <button
349
+ onClick={stopRecording}
350
+ className="flex items-center justify-center gap-2 bg-red-100 text-red-600 px-4 h-10 rounded-lg font-medium animate-pulse"
351
+ >
352
+ <Square className="w-4 h-4 fill-current" /> Arrêter l'enregistrement
353
+ </button>
354
+ ) : (
355
+ <button
356
+ onClick={startRecording}
357
+ className="flex items-center justify-center gap-2 bg-slate-100 hover:bg-slate-200 text-slate-700 px-4 h-10 rounded-lg font-medium transition-colors"
358
+ >
359
+ <Mic className="w-4 h-4" /> Commencer l'enregistrement
360
+ </button>
361
+ )}
362
+
363
+ {audioUrl && !isRecording && (
364
+ <div className="flex items-center gap-2 flex-1">
365
+ <audio src={audioUrl} controls className="h-10 w-full max-w-[250px]" />
366
+ <button
367
+ onClick={clearRecording}
368
+ className="text-xs text-slate-500 hover:text-red-500 underline"
369
+ >
370
+ Recommencer
371
+ </button>
372
+ </div>
373
+ )}
374
+ </div>
375
+ );
376
+ }