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 +110 -103
- apps/admin/src/pages/LiveFeed.tsx +376 -0
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)=>
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
| 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)=>
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
| 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:'/
|
| 370 |
-
{ to:'/
|
|
|
|
| 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="/
|
| 393 |
-
<Route path="/
|
|
|
|
| 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 |
+
}
|