CognxSafeTrack commited on
Commit
b150436
·
1 Parent(s): f1a06cd

feat(prod): implementation of missing features for production readiness

Browse files
apps/admin/src/App.tsx CHANGED
@@ -1,103 +1,52 @@
1
- import { BrowserRouter as Router, Routes, Route, Link, Navigate, useNavigate } from 'react-router-dom';
2
  import { useEffect, useState, createContext, useContext } from 'react';
 
3
 
4
- // ── Auth Context ────────────────────────────────────────────────────────────────
5
  const SESSION_KEY = 'edtech_admin_key';
6
-
7
- const AuthContext = createContext<{
8
- apiKey: string | null;
9
- login: (key: string) => void;
10
- logout: () => void;
11
- }>({
12
- apiKey: null,
13
- login: () => { },
14
- logout: () => { },
15
- });
16
-
17
  function AuthProvider({ children }: { children: React.ReactNode }) {
18
- const [apiKey, setApiKey] = useState<string | null>(
19
- () => sessionStorage.getItem(SESSION_KEY)
20
- );
21
-
22
- const login = (key: string) => {
23
- sessionStorage.setItem(SESSION_KEY, key);
24
- setApiKey(key);
25
- };
26
-
27
- const logout = () => {
28
- sessionStorage.removeItem(SESSION_KEY);
29
- setApiKey(null);
30
- };
31
-
32
  return <AuthContext.Provider value={{ apiKey, login, logout }}>{children}</AuthContext.Provider>;
33
  }
34
-
35
  const useAuth = () => useContext(AuthContext);
36
-
37
  function ProtectedRoute({ children }: { children: React.ReactNode }) {
38
  const { apiKey } = useAuth();
39
  if (!apiKey) return <Navigate to="/login" replace />;
40
  return <>{children}</>;
41
  }
42
 
43
- // ── Login Page ──────────────────────────────────────────────────────────────────
44
  function LoginPage() {
45
  const { login, apiKey } = useAuth();
46
  const navigate = useNavigate();
47
  const [key, setKey] = useState('');
48
  const [error, setError] = useState('');
49
  const [loading, setLoading] = useState(false);
50
-
51
- useEffect(() => {
52
- if (apiKey) navigate('/', { replace: true });
53
- }, [apiKey, navigate]);
54
-
55
  const handleSubmit = async (e: React.FormEvent) => {
56
- e.preventDefault();
57
- setError('');
58
- setLoading(true);
59
  try {
60
- const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
61
- const res = await fetch(`${API_URL}/v1/admin/stats`, {
62
- headers: { 'Authorization': `Bearer ${key}` }
63
- });
64
- if (res.ok) {
65
- login(key);
66
- navigate('/', { replace: true });
67
- } else {
68
- setError('Clé API invalide. Vérifie ton ADMIN_API_KEY.');
69
- }
70
- } catch {
71
- setError('Impossible de joindre le serveur.');
72
- } finally {
73
- setLoading(false);
74
- }
75
  };
76
-
77
  return (
78
  <div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
79
  <div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-sm">
80
- <div className="text-center mb-6">
81
- <div className="text-3xl mb-2">🔐</div>
82
  <h1 className="text-2xl font-bold text-slate-800">Admin Access</h1>
83
- <p className="text-sm text-slate-500 mt-1">Entrez votre ADMIN_API_KEY</p>
84
- </div>
85
  <form onSubmit={handleSubmit} className="space-y-4">
86
- <input
87
- id="apiKey"
88
- type="password"
89
- required
90
- placeholder="sk-admin-..."
91
- value={key}
92
  onChange={e => setKey(e.target.value)}
93
- className="w-full border border-slate-200 rounded-xl px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-slate-400 transition"
94
- />
95
  {error && <p className="text-red-500 text-sm">{error}</p>}
96
- <button
97
- type="submit"
98
- disabled={loading}
99
- className="w-full bg-slate-900 hover:bg-slate-700 text-white py-3 rounded-xl font-bold text-sm transition disabled:opacity-50"
100
- >
101
  {loading ? 'Vérification...' : 'Se connecter'}
102
  </button>
103
  </form>
@@ -105,161 +54,289 @@ function LoginPage() {
105
  </div>
106
  );
107
  }
108
- import { Users, PlayCircle, CheckCircle, Lightbulb, Download } from 'lucide-react'; interface DashboardData {
109
- stats: {
110
- totalUsers: number;
111
- activeEnrollments: number;
112
- completedEnrollments: number;
113
- totalTracks: number;
114
- } | null;
115
- enrollments: any[];
116
- }
117
 
118
  function Dashboard() {
119
  const { apiKey, logout } = useAuth();
120
- const [data, setData] = useState<DashboardData>({ stats: null, enrollments: [] });
 
121
  const [loading, setLoading] = useState(true);
122
-
123
  useEffect(() => {
124
- const fetchData = async () => {
125
  try {
126
- const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
127
- const headers = { 'Authorization': `Bearer ${apiKey}` };
128
-
129
- const [statsRes, enrollmentsRes] = await Promise.all([
130
- fetch(`${API_URL}/v1/admin/stats`, { headers }),
131
- fetch(`${API_URL}/v1/admin/enrollments`, { headers })
132
  ]);
133
-
134
- if (statsRes.status === 401 || enrollmentsRes.status === 401) {
135
- logout();
136
- return;
137
- }
138
-
139
- const stats = await statsRes.json();
140
- const enrollments = await enrollmentsRes.json();
141
-
142
- setData({ stats, enrollments });
143
- } catch (error) {
144
- console.error("Error fetching dashboard data", error);
145
- } finally {
146
- setLoading(false);
147
- }
148
- };
149
-
150
- fetchData();
151
  }, [apiKey, logout]);
152
-
153
- if (loading) return <div className="p-8 text-slate-500">Chargement du dashboard...</div>;
154
-
155
  const exportCSV = () => {
156
- if (!data.enrollments || data.enrollments.length === 0) {
157
- alert("No enrollments to export.");
158
- return;
159
- }
160
-
161
- const headers = ['ID', 'User Phone', 'Track Title', 'Status', 'Current Day', 'Started At'];
162
- const rows = data.enrollments.map(env => [
163
- env.id,
164
- env.user?.phone || 'Unknown',
165
- env.track?.title || 'Unknown',
166
- env.status,
167
- env.currentDay,
168
- env.startedAt
169
- ]);
170
-
171
- const csvContent = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
172
- const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
173
- const link = document.createElement('a');
174
- const url = URL.createObjectURL(blob);
175
- link.setAttribute('href', url);
176
- link.setAttribute('download', `enrollments_export_${new Date().toISOString().slice(0, 10)}.csv`);
177
- link.style.visibility = 'hidden';
178
- document.body.appendChild(link);
179
- link.click();
180
- document.body.removeChild(link);
181
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
 
 
 
 
 
 
 
183
  return (
184
  <div className="p-8">
185
- <h1 className="text-3xl font-bold mb-8 text-slate-800">Admin Dashboard</h1>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
- {/* Stats Overview */}
188
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
189
- <div className="bg-white p-6 rounded-xl shadow-sm border border-slate-100 flex flex-col justify-center items-center">
190
- <Users className="w-8 h-8 text-slate-400 mb-2" />
191
- <h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-1">Total Users</h2>
192
- <p className="text-4xl font-bold text-slate-900">{data.stats?.totalUsers || 0}</p>
193
- </div>
194
- <div className="bg-white p-6 rounded-xl shadow-sm border border-slate-100 flex flex-col justify-center items-center">
195
- <PlayCircle className="w-8 h-8 text-blue-400 mb-2" />
196
- <h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-1">Active Enrollments</h2>
197
- <p className="text-4xl font-bold text-blue-600">{data.stats?.activeEnrollments || 0}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  </div>
199
- <div className="bg-white p-6 rounded-xl shadow-sm border border-slate-100 flex flex-col justify-center items-center">
200
- <CheckCircle className="w-8 h-8 text-green-400 mb-2" />
201
- <h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-1">Completed</h2>
202
- <p className="text-4xl font-bold text-green-600">{data.stats?.completedEnrollments || 0}</p>
 
 
 
 
 
 
 
 
 
 
 
203
  </div>
204
- <div className="bg-white p-6 rounded-xl shadow-sm border border-slate-100 flex flex-col justify-center items-center">
205
- <Lightbulb className="w-8 h-8 text-purple-400 mb-2" />
206
- <h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-1">Tracks</h2>
207
- <p className="text-4xl font-bold text-purple-600">{data.stats?.totalTracks || 0}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  </div>
 
 
 
210
 
211
- {/* Recent Enrollments Table */}
 
 
 
 
 
 
 
212
  <div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
213
- <div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center">
214
- <h2 className="text-lg font-semibold text-slate-800">Recent Enrollments</h2>
215
- <button
216
- onClick={exportCSV}
217
- className="flex items-center space-x-2 bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
218
- >
219
- <Download className="w-4 h-4" />
220
- <span>Export CSV</span>
221
- </button>
222
- </div>
223
- <div className="overflow-x-auto">
224
- <table className="w-full text-sm text-left">
225
- <thead className="text-xs text-slate-500 bg-slate-50 uppercase">
226
- <tr>
227
- <th className="px-6 py-3">User (WhatsApp)</th>
228
- <th className="px-6 py-3">Track</th>
229
- <th className="px-6 py-3">Status</th>
230
- <th className="px-6 py-3">Progress</th>
231
- <th className="px-6 py-3">Date</th>
232
  </tr>
233
- </thead>
234
- <tbody>
235
- {data.enrollments.map((env: any) => (
236
- <tr key={env.id} className="border-b border-slate-50 hover:bg-slate-50/50">
237
- <td className="px-6 py-4 font-medium text-slate-900">{env.user?.phone || 'Unknown'}</td>
238
- <td className="px-6 py-4">{env.track?.title || 'Unknown Track'}</td>
239
- <td className="px-6 py-4">
240
- <span className={`px-2.5 py-1 py-0.5 rounded-full text-xs font-medium
241
- ${env.status === 'ACTIVE' ? 'bg-blue-100 text-blue-800' :
242
- env.status === 'COMPLETED' ? 'bg-green-100 text-green-800' :
243
- 'bg-slate-100 text-slate-800'}`}>
244
- {env.status}
245
- </span>
246
- </td>
247
- <td className="px-6 py-4">Day {env.currentDay}</td>
248
- <td className="px-6 py-4 text-slate-500">
249
- {new Date(env.startedAt).toLocaleDateString()}
250
- </td>
251
- </tr>
252
- ))}
253
- {data.enrollments.length === 0 && (
254
- <tr>
255
- <td colSpan={5} className="px-6 py-8 text-center text-slate-500">
256
- No enrollments found.
257
- </td>
258
- </tr>
259
- )}
260
- </tbody>
261
- </table>
262
- </div>
263
  </div>
264
  </div>
265
  );
@@ -267,37 +344,53 @@ function Dashboard() {
267
 
268
  function Settings() {
269
  return (
270
- <div className="p-8">
271
- <h1 className="text-3xl font-bold mb-4">Settings</h1>
272
- <p>System configuration</p>
 
 
 
 
 
 
 
 
 
 
273
  </div>
274
  );
275
  }
276
 
277
  function AppShell() {
278
  const { logout } = useAuth();
 
 
 
 
 
 
279
  return (
280
  <div className="min-h-screen bg-gray-50 flex">
281
- {/* Sidebar */}
282
- <aside className="w-64 bg-slate-900 text-white p-6 flex flex-col">
283
- <div className="text-xl font-bold mb-8">EdTech Admin</div>
284
- <nav className="space-y-4 flex-1">
285
- <Link to="/" className="block hover:text-gray-300">Dashboard</Link>
286
- <Link to="/settings" className="block hover:text-gray-300">Settings</Link>
 
 
287
  </nav>
288
- <button
289
- onClick={logout}
290
- className="text-sm text-slate-400 hover:text-white transition mt-4 text-left"
291
- >
292
- 🔓 Se déconnecter
293
- </button>
294
  </aside>
295
-
296
- {/* Main Content */}
297
- <main className="flex-1">
298
  <Routes>
299
- <Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
300
- <Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
 
 
 
 
 
301
  </Routes>
302
  </main>
303
  </div>
@@ -309,12 +402,11 @@ function App() {
309
  <AuthProvider>
310
  <Router>
311
  <Routes>
312
- <Route path="/login" element={<LoginPage />} />
313
- <Route path="/*" element={<AppShell />} />
314
  </Routes>
315
  </Router>
316
  </AuthProvider>
317
  );
318
  }
319
-
320
- export default App
 
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();
18
  if (!apiKey) return <Navigate to="/login" replace />;
19
  return <>{children}</>;
20
  }
21
 
 
22
  function LoginPage() {
23
  const { login, apiKey } = useAuth();
24
  const navigate = useNavigate();
25
  const [key, setKey] = useState('');
26
  const [error, setError] = useState('');
27
  const [loading, setLoading] = useState(false);
28
+ useEffect(() => { if (apiKey) navigate('/', { replace: true }); }, [apiKey, navigate]);
 
 
 
 
29
  const handleSubmit = async (e: React.FormEvent) => {
30
+ e.preventDefault(); setError(''); setLoading(true);
 
 
31
  try {
32
+ const res = await fetch(`${API_URL}/v1/admin/stats`, { headers: { 'Authorization': `Bearer ${key}` } });
33
+ if (res.ok) { login(key); navigate('/', { replace: true }); }
34
+ else setError('Clé API invalide.');
35
+ } catch { setError('Impossible de joindre le serveur.'); } finally { setLoading(false); }
 
 
 
 
 
 
 
 
 
 
 
36
  };
 
37
  return (
38
  <div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
39
  <div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-sm">
40
+ <div className="text-center mb-6"><div className="text-3xl mb-2">🔐</div>
 
41
  <h1 className="text-2xl font-bold text-slate-800">Admin Access</h1>
42
+ <p className="text-sm text-slate-500 mt-1">Entrez votre ADMIN_API_KEY</p></div>
 
43
  <form onSubmit={handleSubmit} className="space-y-4">
44
+ <input id="apiKey" type="password" required placeholder="sk-admin-..." value={key}
 
 
 
 
 
45
  onChange={e => setKey(e.target.value)}
46
+ className="w-full border border-slate-200 rounded-xl px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-slate-400" />
 
47
  {error && <p className="text-red-500 text-sm">{error}</p>}
48
+ <button type="submit" disabled={loading}
49
+ className="w-full bg-slate-900 hover:bg-slate-700 text-white py-3 rounded-xl font-bold text-sm transition disabled:opacity-50">
 
 
 
50
  {loading ? 'Vérification...' : 'Se connecter'}
51
  </button>
52
  </form>
 
54
  </div>
55
  );
56
  }
 
 
 
 
 
 
 
 
 
57
 
58
  function Dashboard() {
59
  const { apiKey, logout } = useAuth();
60
+ const [stats, setStats] = useState<any>(null);
61
+ const [enrollments, setEnrollments] = useState<any[]>([]);
62
  const [loading, setLoading] = useState(true);
 
63
  useEffect(() => {
64
+ (async () => {
65
  try {
66
+ const h = { 'Authorization': `Bearer ${apiKey}` };
67
+ const [sRes, eRes] = await Promise.all([
68
+ fetch(`${API_URL}/v1/admin/stats`, { headers: h }),
69
+ fetch(`${API_URL}/v1/admin/enrollments`, { headers: h })
 
 
70
  ]);
71
+ if (sRes.status === 401) { logout(); return; }
72
+ setStats(await sRes.json());
73
+ setEnrollments(await eRes.json());
74
+ } finally { setLoading(false); }
75
+ })();
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  }, [apiKey, logout]);
 
 
 
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 = [
86
+ { icon: <Users className="w-6 h-6 text-slate-400" />, label: 'Utilisateurs', value: stats?.totalUsers || 0, color: 'text-slate-900' },
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>
100
+ </div>
101
+ ))}
102
+ </div>
103
+ <div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
104
+ <div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center">
105
+ <h2 className="text-lg font-semibold text-slate-800">Inscriptions récentes</h2>
106
+ <button onClick={exportCSV} className="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 rounded-lg text-sm font-medium">
107
+ <Download className="w-4 h-4" /><span>Export CSV</span>
108
+ </button>
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>
128
+ </div>
129
+ );
130
+ }
131
 
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>
221
+ </div>
222
+ );
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>
282
+ </div>
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
+ );
310
+ }
311
 
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>
341
  </div>
342
  );
 
344
 
345
  function Settings() {
346
  return (
347
+ <div className="p-8 max-w-xl">
348
+ <h1 className="text-3xl font-bold mb-6 text-slate-800">Configuration</h1>
349
+ <div className="bg-white rounded-2xl border border-slate-100 p-6 shadow-sm space-y-3">
350
+ <div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl">
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>
358
+ ))}
359
+ </div>
360
  </div>
361
  );
362
  }
363
 
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>
381
+ ))}
382
  </nav>
383
+ <button onClick={logout} className="text-xs text-slate-500 hover:text-white transition px-3 py-2 text-left">🔓 Se déconnecter</button>
 
 
 
 
 
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
  <AuthProvider>
403
  <Router>
404
  <Routes>
405
+ <Route path="/login" element={<LoginPage/>}/>
406
+ <Route path="/*" element={<ProtectedRoute><AppShell/></ProtectedRoute>}/>
407
  </Routes>
408
  </Router>
409
  </AuthProvider>
410
  );
411
  }
412
+ export default App;
 
apps/api/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import { adminRoutes } from './routes/admin';
8
  import { aiRoutes } from './routes/ai';
9
  import { paymentRoutes, stripeWebhookRoute } from './routes/payments';
10
  import { internalRoutes } from './routes/internal';
 
11
 
12
  // ── Fail-fast: vérifier les secrets critiques au démarrage ─────────────────────
13
  // Only WHATSAPP_VERIFY_TOKEN is strictly needed at startup (for Meta webhook validation).
@@ -52,6 +53,7 @@ async function setupRateLimit() {
52
 
53
  // ── Public Routes (no auth) ────────────────────────────────────────────────────
54
  server.register(whatsappRoutes, { prefix: '/v1/whatsapp' });
 
55
 
56
  // ── Stripe Webhook (public — Stripe can't send API Key, verified by signature) ─
57
  server.register(stripeWebhookRoute, { prefix: '/v1/payments' });
 
8
  import { aiRoutes } from './routes/ai';
9
  import { paymentRoutes, stripeWebhookRoute } from './routes/payments';
10
  import { internalRoutes } from './routes/internal';
11
+ import { studentRoutes } from './routes/student';
12
 
13
  // ── Fail-fast: vérifier les secrets critiques au démarrage ─────────────────────
14
  // Only WHATSAPP_VERIFY_TOKEN is strictly needed at startup (for Meta webhook validation).
 
53
 
54
  // ── Public Routes (no auth) ────────────────────────────────────────────────────
55
  server.register(whatsappRoutes, { prefix: '/v1/whatsapp' });
56
+ server.register(studentRoutes, { prefix: '/v1/student' });
57
 
58
  // ── Stripe Webhook (public — Stripe can't send API Key, verified by signature) ─
59
  server.register(stripeWebhookRoute, { prefix: '/v1/payments' });
apps/api/src/routes/admin.ts CHANGED
@@ -1,42 +1,177 @@
1
  import { FastifyInstance } from 'fastify';
2
  import { prisma } from '../services/prisma';
 
3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  export async function adminRoutes(fastify: FastifyInstance) {
6
- // 1. Get Dashboard Stats
 
7
  fastify.get('/stats', async () => {
8
- const totalUsers = await prisma.user.count();
9
- const activeEnrollments = await prisma.enrollment.count({ where: { status: 'ACTIVE' } });
10
- const completedEnrollments = await prisma.enrollment.count({ where: { status: 'COMPLETED' } });
11
- const totalTracks = await prisma.track.count();
12
-
13
- return {
14
- totalUsers,
15
- activeEnrollments,
16
- completedEnrollments,
17
- totalTracks
18
- };
19
- });
20
-
21
- // 2. Get Users List
22
- fastify.get('/users', async () => {
23
- const users = await prisma.user.findMany({
24
- orderBy: { createdAt: 'desc' },
25
- take: 50,
26
- });
27
- return users;
 
 
 
 
 
 
 
 
 
28
  });
29
 
30
- // 3. Get Recent Enrollments
31
  fastify.get('/enrollments', async () => {
32
  const enrollments = await prisma.enrollment.findMany({
33
- include: {
34
- user: true,
35
- track: true,
36
- },
37
  orderBy: { startedAt: 'desc' },
38
- take: 50,
39
  });
40
  return enrollments;
41
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  }
 
1
  import { FastifyInstance } from 'fastify';
2
  import { prisma } from '../services/prisma';
3
+ import { z } from 'zod';
4
 
5
+ // ─── Zod Schemas ───────────────────────────────────────────────────────────────
6
+ const TrackSchema = z.object({
7
+ title: z.string().min(1),
8
+ description: z.string().optional(),
9
+ duration: z.number().int().positive(),
10
+ language: z.enum(['FR', 'WOLOF']).default('FR'),
11
+ isPremium: z.boolean().default(false),
12
+ priceAmount: z.number().int().optional(),
13
+ stripePriceId: z.string().optional(),
14
+ });
15
+
16
+ const TrackDaySchema = z.object({
17
+ dayNumber: z.number().int().positive(),
18
+ title: z.string().optional(),
19
+ lessonText: z.string().optional(),
20
+ audioUrl: z.string().url().optional().or(z.literal('')),
21
+ exerciseType: z.enum(['TEXT', 'AUDIO', 'BUTTON']).default('TEXT'),
22
+ exercisePrompt: z.string().optional(),
23
+ validationKeyword: z.string().optional(),
24
+ buttonsJson: z.array(z.object({ id: z.string(), title: z.string() })).optional(),
25
+ unlockCondition: z.string().optional(),
26
+ });
27
 
28
  export async function adminRoutes(fastify: FastifyInstance) {
29
+
30
+ // ── Dashboard Stats ────────────────────────────────────────────────────────
31
  fastify.get('/stats', async () => {
32
+ const [totalUsers, activeEnrollments, completedEnrollments, totalTracks, totalRevenue] = await Promise.all([
33
+ prisma.user.count(),
34
+ prisma.enrollment.count({ where: { status: 'ACTIVE' } }),
35
+ prisma.enrollment.count({ where: { status: 'COMPLETED' } }),
36
+ prisma.track.count(),
37
+ prisma.payment.aggregate({ where: { status: 'COMPLETED' }, _sum: { amount: true } }),
38
+ ]);
39
+ return { totalUsers, activeEnrollments, completedEnrollments, totalTracks, totalRevenue: totalRevenue._sum.amount || 0 };
40
+ });
41
+
42
+ // ── Users ──────────────────────────────────────────────────────────────────
43
+ fastify.get('/users', async (req) => {
44
+ const query = req.query as { page?: string; limit?: string };
45
+ const page = Math.max(1, parseInt(query.page || '1'));
46
+ const limit = Math.min(100, parseInt(query.limit || '50'));
47
+
48
+ const [users, total] = await Promise.all([
49
+ prisma.user.findMany({
50
+ orderBy: { createdAt: 'desc' },
51
+ skip: (page - 1) * limit,
52
+ take: limit,
53
+ include: {
54
+ enrollments: { include: { track: true }, orderBy: { startedAt: 'desc' }, take: 1 },
55
+ _count: { select: { enrollments: true, responses: true } }
56
+ }
57
+ }),
58
+ prisma.user.count()
59
+ ]);
60
+ return { users, total, page, limit };
61
  });
62
 
63
+ // ── Enrollments ────────────────────────────────────────────────────────────
64
  fastify.get('/enrollments', async () => {
65
  const enrollments = await prisma.enrollment.findMany({
66
+ include: { user: true, track: true },
 
 
 
67
  orderBy: { startedAt: 'desc' },
68
+ take: 100,
69
  });
70
  return enrollments;
71
  });
72
+
73
+ // ══════════════════════════════════════════════════════════════════════════
74
+ // TRACKS CRUD
75
+ // ══════════════════════════════════════════════════════════════════════════
76
+
77
+ // List tracks
78
+ fastify.get('/tracks', async () => {
79
+ return prisma.track.findMany({
80
+ include: { _count: { select: { days: true, enrollments: true } } },
81
+ orderBy: { createdAt: 'desc' }
82
+ });
83
+ });
84
+
85
+ // Get single track with all days
86
+ fastify.get<{ Params: { id: string } }>('/tracks/:id', async (req, reply) => {
87
+ const track = await prisma.track.findUnique({
88
+ where: { id: req.params.id },
89
+ include: { days: { orderBy: { dayNumber: 'asc' } } }
90
+ });
91
+ if (!track) return reply.code(404).send({ error: 'Track not found' });
92
+ return track;
93
+ });
94
+
95
+ // Create track
96
+ fastify.post('/tracks', async (req, reply) => {
97
+ const body = TrackSchema.safeParse(req.body);
98
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
99
+ const track = await prisma.track.create({ data: body.data });
100
+ return reply.code(201).send(track);
101
+ });
102
+
103
+ // Update track
104
+ fastify.put<{ Params: { id: string } }>('/tracks/:id', async (req, reply) => {
105
+ const body = TrackSchema.partial().safeParse(req.body);
106
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
107
+ try {
108
+ const track = await prisma.track.update({ where: { id: req.params.id }, data: body.data });
109
+ return track;
110
+ } catch {
111
+ return reply.code(404).send({ error: 'Track not found' });
112
+ }
113
+ });
114
+
115
+ // Delete track
116
+ fastify.delete<{ Params: { id: string } }>('/tracks/:id', async (req, reply) => {
117
+ try {
118
+ await prisma.trackDay.deleteMany({ where: { trackId: req.params.id } });
119
+ await prisma.track.delete({ where: { id: req.params.id } });
120
+ return { ok: true };
121
+ } catch {
122
+ return reply.code(404).send({ error: 'Track not found' });
123
+ }
124
+ });
125
+
126
+ // ══════════════════════════════════════════════════════════════════════════
127
+ // TRACK DAYS CRUD
128
+ // ══════════════════════════════════════════════════════════════════════════
129
+
130
+ // List days for a track
131
+ fastify.get<{ Params: { trackId: string } }>('/tracks/:trackId/days', async (req) => {
132
+ return prisma.trackDay.findMany({
133
+ where: { trackId: req.params.trackId },
134
+ orderBy: { dayNumber: 'asc' }
135
+ });
136
+ });
137
+
138
+ // Create day
139
+ fastify.post<{ Params: { trackId: string } }>('/tracks/:trackId/days', async (req, reply) => {
140
+ const body = TrackDaySchema.safeParse(req.body);
141
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
142
+ const day = await prisma.trackDay.create({
143
+ data: {
144
+ ...body.data,
145
+ trackId: req.params.trackId,
146
+ audioUrl: body.data.audioUrl || null,
147
+ buttonsJson: body.data.buttonsJson ? body.data.buttonsJson : undefined
148
+ }
149
+ });
150
+ return reply.code(201).send(day);
151
+ });
152
+
153
+ // Update day
154
+ fastify.put<{ Params: { trackId: string; dayId: string } }>('/tracks/:trackId/days/:dayId', async (req, reply) => {
155
+ const body = TrackDaySchema.partial().safeParse(req.body);
156
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
157
+ try {
158
+ const day = await prisma.trackDay.update({
159
+ where: { id: req.params.dayId },
160
+ data: { ...body.data, audioUrl: body.data.audioUrl === '' ? null : body.data.audioUrl }
161
+ });
162
+ return day;
163
+ } catch {
164
+ return reply.code(404).send({ error: 'Day not found' });
165
+ }
166
+ });
167
+
168
+ // Delete day
169
+ fastify.delete<{ Params: { trackId: string; dayId: string } }>('/tracks/:trackId/days/:dayId', async (req, reply) => {
170
+ try {
171
+ await prisma.trackDay.delete({ where: { id: req.params.dayId } });
172
+ return { ok: true };
173
+ } catch {
174
+ return reply.code(404).send({ error: 'Day not found' });
175
+ }
176
+ });
177
  }
apps/api/src/routes/student.ts ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FastifyInstance } from 'fastify';
2
+ import { prisma } from '../services/prisma';
3
+ import { z } from 'zod';
4
+
5
+ /**
6
+ * Student-facing public routes (query by phone number).
7
+ * Protected by ADMIN_API_KEY in the guarded scope.
8
+ */
9
+ export async function studentRoutes(fastify: FastifyInstance) {
10
+
11
+ // GET /v1/student/me?phone=221771234567
12
+ // Returns user profile + enrollments + generated documents
13
+ fastify.get('/me', async (req, reply) => {
14
+ const query = req.query as { phone?: string };
15
+
16
+ const phoneSchema = z.string().min(7);
17
+ const phoneResult = phoneSchema.safeParse(query.phone);
18
+ if (!phoneResult.success) {
19
+ return reply.code(400).send({ error: 'phone query param is required' });
20
+ }
21
+
22
+ const phone = phoneResult.data.replace(/\s+/g, '').replace(/^\+/, '');
23
+
24
+ const user = await prisma.user.findUnique({
25
+ where: { phone },
26
+ include: {
27
+ enrollments: {
28
+ include: {
29
+ track: {
30
+ include: { days: { orderBy: { dayNumber: 'asc' }, take: 1 } }
31
+ }
32
+ },
33
+ orderBy: { startedAt: 'desc' }
34
+ },
35
+ payments: {
36
+ where: { status: 'COMPLETED' },
37
+ orderBy: { createdAt: 'desc' }
38
+ },
39
+ progress: {
40
+ orderBy: { lastInteraction: 'desc' }
41
+ }
42
+ }
43
+ });
44
+
45
+ if (!user) {
46
+ return reply.code(404).send({ error: 'User not found. Send INSCRIPTION on WhatsApp to register.' });
47
+ }
48
+
49
+ return {
50
+ id: user.id,
51
+ phone: user.phone,
52
+ name: user.name,
53
+ language: user.language,
54
+ activity: user.activity,
55
+ createdAt: user.createdAt,
56
+ enrollments: user.enrollments.map(e => ({
57
+ id: e.id,
58
+ trackId: e.trackId,
59
+ trackTitle: e.track.title,
60
+ status: e.status,
61
+ currentDay: e.currentDay,
62
+ totalDays: e.track.duration,
63
+ progressPercent: Math.round((e.currentDay / e.track.duration) * 100),
64
+ startedAt: e.startedAt,
65
+ completedAt: e.completedAt,
66
+ })),
67
+ payments: user.payments,
68
+ // R2 document URLs are stored as Payment metadata (future enhancement)
69
+ };
70
+ });
71
+ }
apps/api/tsconfig.tsbuildinfo CHANGED
@@ -1 +1 @@
1
- {"root":["./src/index.ts","./src/plugins/auth.ts","./src/routes/admin.ts","./src/routes/ai.ts","./src/routes/internal.ts","./src/routes/payments.ts","./src/routes/whatsapp.ts","./src/services/prisma.ts","./src/services/queue.ts","./src/services/storage.ts","./src/services/stripe.ts","./src/services/whatsapp.ts","./src/services/ai/index.ts","./src/services/ai/mock-provider.ts","./src/services/ai/openai-provider.ts","./src/services/ai/types.ts","./src/services/renderers/pdf-renderer.ts","./src/services/renderers/pptx-renderer.ts","./src/services/renderers/types.ts"],"version":"5.9.3"}
 
1
+ {"root":["./src/index.ts","./src/plugins/auth.ts","./src/routes/admin.ts","./src/routes/ai.ts","./src/routes/internal.ts","./src/routes/payments.ts","./src/routes/student.ts","./src/routes/whatsapp.ts","./src/services/prisma.ts","./src/services/queue.ts","./src/services/storage.ts","./src/services/stripe.ts","./src/services/whatsapp.ts","./src/services/ai/index.ts","./src/services/ai/mock-provider.ts","./src/services/ai/openai-provider.ts","./src/services/ai/types.ts","./src/services/renderers/pdf-renderer.ts","./src/services/renderers/pptx-renderer.ts","./src/services/renderers/types.ts"],"version":"5.9.3"}
apps/web/src/App.tsx CHANGED
@@ -1,7 +1,9 @@
1
- import { BrowserRouter as Router, Routes, Route, Link, useNavigate } from 'react-router-dom';
2
- import { BookOpen, FileText, Smartphone, ArrowRight, Phone } from 'lucide-react';
 
3
 
4
  const WA_NUMBER = import.meta.env.VITE_WHATSAPP_NUMBER || '221771234567';
 
5
 
6
  function Navbar() {
7
  return (
@@ -12,19 +14,13 @@ function Navbar() {
12
  <div className="bg-primary/10 p-2 rounded-xl group-hover:bg-primary/20 transition-colors">
13
  <BookOpen className="h-6 w-6 text-primary" />
14
  </div>
15
- <span className="font-heading font-bold text-2xl text-secondary tracking-tight">SafeTrack<span className="text-primary">.edu</span></span>
16
  </Link>
17
  <div className="flex items-center space-x-4">
18
- <Link to="/login" className="text-secondary font-medium hover:text-primary transition-colors">
19
- Student Login
20
- </Link>
21
- <a
22
- href={`https://wa.me/${WA_NUMBER}?text=Hello! I want to enroll.`}
23
- target="_blank"
24
- rel="noreferrer"
25
- className="bg-primary text-white px-5 py-2.5 rounded-full font-medium hover:bg-emerald-700 hover:shadow-lg hover:shadow-primary/30 transition-all active:scale-95 hidden sm:inline-flex items-center"
26
- >
27
- Start Learning <ArrowRight className="ml-2 w-4 h-4" />
28
  </a>
29
  </div>
30
  </div>
@@ -36,45 +32,26 @@ function Navbar() {
36
  function Hero() {
37
  return (
38
  <div className="relative overflow-hidden bg-slate-50 pt-24 pb-32">
39
- {/* Background Decorations */}
40
- <div className="absolute top-0 right-0 -translate-y-12 translate-x-1/3">
41
- <div className="w-96 h-96 bg-accent/20 rounded-full blur-3xl"></div>
42
- </div>
43
- <div className="absolute bottom-0 left-0 translate-y-1/3 -translate-x-1/3">
44
- <div className="w-[500px] h-[500px] bg-primary/10 rounded-full blur-3xl"></div>
45
- </div>
46
-
47
  <div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
48
  <div className="inline-flex items-center px-4 py-2 rounded-full bg-blue-50 text-secondary font-medium text-sm mb-8 border border-blue-100 shadow-sm">
49
  <span className="flex h-2 w-2 rounded-full bg-blue-600 mr-2 animate-pulse"></span>
50
- Learn directly on WhatsApp
51
  </div>
52
-
53
  <h1 className="font-heading font-extrabold text-5xl md:text-7xl text-secondary mb-6 leading-tight tracking-tight">
54
- Master Business Skills, <br className="hidden md:block" />
55
- One Message at a Time.
56
  </h1>
57
-
58
  <p className="max-w-2xl mx-auto text-xl text-gray-600 mb-10 font-sans">
59
- Join our interactive audio and text courses directly via WhatsApp.
60
- Build your startup pitch and get AI-generated slide decks automatically.
61
  </p>
62
-
63
  <div className="flex flex-col sm:flex-row justify-center items-center gap-4">
64
- <a
65
- href={`https://wa.me/${WA_NUMBER}?text=Start`}
66
- target="_blank"
67
- rel="noreferrer"
68
- className="w-full sm:w-auto bg-primary text-white text-lg px-8 py-4 rounded-full font-bold shadow-xl shadow-primary/30 hover:bg-emerald-700 hover:scale-105 transition-all flex items-center justify-center group"
69
- >
70
- <Phone className="mr-2 w-5 h-5 group-hover:rotate-12 transition-transform" />
71
- Enroll via WhatsApp
72
  </a>
73
- <Link
74
- to="/login"
75
- className="w-full sm:w-auto bg-white text-secondary border-2 border-gray-200 text-lg px-8 py-4 rounded-full font-bold hover:border-secondary hover:bg-gray-50 transition-all flex items-center justify-center"
76
- >
77
- Student Portal
78
  </Link>
79
  </div>
80
  </div>
@@ -83,121 +60,217 @@ function Hero() {
83
  }
84
 
85
  function Features() {
 
 
 
 
 
86
  return (
87
  <div className="py-24 bg-white">
88
  <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
89
  <div className="text-center mb-16">
90
- <h2 className="font-heading font-bold text-3xl md:text-4xl text-secondary mb-4">How It Works</h2>
91
- <p className="text-lg text-gray-500 max-w-2xl mx-auto">An effortless learning experience designed for mobile-first entrepreneurs.</p>
92
  </div>
93
-
94
  <div className="grid md:grid-cols-3 gap-12">
95
- <div className="group bg-slate-50 p-8 rounded-3xl border border-slate-100 hover:border-primary/30 hover:shadow-xl hover:shadow-primary/5 transition-all">
96
- <div className="bg-white w-14 h-14 rounded-2xl flex items-center justify-center shadow-sm mb-6 group-hover:scale-110 transition-transform">
97
- <Smartphone className="w-7 h-7 text-primary" />
 
 
98
  </div>
99
- <h3 className="font-heading font-bold text-xl text-secondary mb-3">WhatsApp Native</h3>
100
- <p className="text-gray-600 leading-relaxed text-sm">
101
- No apps to install. Receive daily interactive micro-lessons right where you already chat. Listen to audio clips and submit assignments easily.
102
- </p>
103
- </div>
104
-
105
- <div className="group bg-slate-50 p-8 rounded-3xl border border-slate-100 hover:border-accent/30 hover:shadow-xl hover:shadow-accent/5 transition-all">
106
- <div className="bg-white w-14 h-14 rounded-2xl flex items-center justify-center shadow-sm mb-6 group-hover:scale-110 transition-transform">
107
- <BookOpen className="w-7 h-7 text-accent" />
108
- </div>
109
- <h3 className="font-heading font-bold text-xl text-secondary mb-3">Structured Tracks</h3>
110
- <p className="text-gray-600 leading-relaxed text-sm">
111
- Follow expertly designed multi-day tracks. Learn how to structure your business, define your target audience, and build your value proposition.
112
- </p>
113
- </div>
114
-
115
- <div className="group bg-slate-50 p-8 rounded-3xl border border-slate-100 hover:border-blue-900/30 hover:shadow-xl hover:shadow-blue-900/5 transition-all">
116
- <div className="bg-white w-14 h-14 rounded-2xl flex items-center justify-center shadow-sm mb-6 group-hover:scale-110 transition-transform">
117
- <FileText className="w-7 h-7 text-secondary" />
118
- </div>
119
- <h3 className="font-heading font-bold text-xl text-secondary mb-3">AI Document Generation</h3>
120
- <p className="text-gray-600 leading-relaxed text-sm">
121
- Finish a track and our AI automatically organizes your answers into a beautiful PDF One-Pager and a PPTX Pitch Deck.
122
- </p>
123
- </div>
124
  </div>
125
  </div>
126
  </div>
127
  );
128
  }
129
 
130
- function Login() {
 
131
  const navigate = useNavigate();
 
 
 
132
 
133
- const handleLogin = (e: React.FormEvent) => {
134
  e.preventDefault();
135
- // Mock login - in a real app, this would hit the API
136
- setTimeout(() => {
137
- alert("Magic link sent to your WhatsApp!");
138
- navigate('/');
139
- }, 500);
 
 
 
 
 
 
 
 
 
140
  };
141
 
142
  return (
143
  <div className="min-h-[80vh] flex flex-col justify-center py-12 sm:px-6 lg:px-8 bg-slate-50">
144
  <div className="sm:mx-auto sm:w-full sm:max-w-md text-center">
145
- <div className="inline-flex bg-primary/10 p-4 rounded-full mb-4">
146
- <BookOpen className="w-10 h-10 text-primary" />
147
- </div>
148
- <h2 className="text-center text-3xl font-heading font-extrabold text-secondary">
149
- Welcome Back
150
- </h2>
151
- <p className="mt-2 text-center text-sm text-gray-600">
152
- Access your learning materials and AI documents.
153
- </p>
154
  </div>
155
-
156
  <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
157
  <div className="bg-white py-8 px-4 shadow sm:rounded-2xl sm:px-10 border border-gray-100">
158
- <form className="space-y-6" onSubmit={handleLogin}>
159
  <div>
160
- <label htmlFor="phone" className="block text-sm font-medium text-gray-700">
161
- WhatsApp Phone Number
162
- </label>
163
  <div className="mt-1 relative rounded-md shadow-sm">
164
  <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
165
  <Phone className="h-5 w-5 text-gray-400" />
166
  </div>
167
- <input
168
- id="phone"
169
- name="phone"
170
- type="tel"
171
- required
172
  className="focus:ring-primary focus:border-primary block w-full pl-10 sm:text-sm border-gray-300 rounded-xl py-3 border outline-none transition-colors"
173
- placeholder="+221 77 123 45 67"
174
- />
175
  </div>
176
  </div>
177
-
178
- <div>
179
- <button
180
- type="submit"
181
- className="w-full flex justify-center py-3 px-4 border border-transparent rounded-xl shadow-sm text-sm font-bold text-white bg-secondary hover:bg-blue-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary transition-all active:scale-95"
182
- >
183
- Send Magic Link
184
- </button>
185
- </div>
186
  </form>
 
 
 
 
 
 
 
 
187
 
188
- <div className="mt-6">
189
- <div className="relative">
190
- <div className="absolute inset-0 flex items-center">
191
- <div className="w-full border-t border-gray-200" />
192
- </div>
193
- <div className="relative flex justify-center text-sm">
194
- <span className="px-2 bg-white text-gray-500">
195
- Format: +[Country Code][Number]
196
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  </div>
198
- </div>
199
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  </div>
202
  </div>
203
  );
@@ -210,45 +283,30 @@ function Footer() {
210
  <div className="col-span-2">
211
  <Link to="/" className="flex items-center space-x-2 mb-4">
212
  <BookOpen className="h-6 w-6 text-primary" />
213
- <span className="font-heading font-bold text-xl tracking-tight">SafeTrack<span className="text-primary">.edu</span></span>
214
  </Link>
215
- <p className="text-gray-400 text-sm max-w-sm">
216
- Empowering entrepreneurs across Africa through accessible, mobile-first education powered by Artificial Intelligence.
217
- </p>
218
  </div>
219
- <div>
220
- <h4 className="font-bold mb-4 font-heading">Platform</h4>
221
  <ul className="space-y-2 text-sm text-gray-400">
222
- <li><a href="#" className="hover:text-white transition-colors">Courses</a></li>
223
- <li><a href="#" className="hover:text-white transition-colors">AI Generator</a></li>
224
- <li><a href="#" className="hover:text-white transition-colors">Pricing</a></li>
225
  </ul>
226
  </div>
227
- <div>
228
- <h4 className="font-bold mb-4 font-heading">Company</h4>
229
  <ul className="space-y-2 text-sm text-gray-400">
230
- <li><a href="#" className="hover:text-white transition-colors">About</a></li>
231
- <li><a href="#" className="hover:text-white transition-colors">Contact</a></li>
232
- <li><a href="#" className="hover:text-white transition-colors">Privacy Policy</a></li>
233
  </ul>
234
  </div>
235
  </div>
236
- <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-12 pt-8 border-t border-white/10 text-center text-sm text-gray-400">
237
- &copy; {new Date().getFullYear()} SafeTrack. All rights reserved.
238
  </div>
239
  </footer>
240
  );
241
  }
242
 
243
- function Home() {
244
- return (
245
- <div className="min-h-screen bg-white">
246
- <Hero />
247
- <Features />
248
- </div>
249
- );
250
- }
251
-
252
  function App() {
253
  return (
254
  <Router>
@@ -256,8 +314,10 @@ function App() {
256
  <Navbar />
257
  <main className="flex-grow">
258
  <Routes>
259
- <Route path="/" element={<Home />} />
260
- <Route path="/login" element={<Login />} />
 
 
261
  </Routes>
262
  </main>
263
  <Footer />
@@ -265,5 +325,4 @@ function App() {
265
  </Router>
266
  );
267
  }
268
-
269
  export default App;
 
1
+ import { BrowserRouter as Router, Routes, Route, Link, useNavigate, useSearchParams, useParams } from 'react-router-dom';
2
+ import { useEffect, useState } from 'react';
3
+ import { BookOpen, FileText, Smartphone, ArrowRight, Phone, CheckCircle, Download, AlertCircle } from 'lucide-react';
4
 
5
  const WA_NUMBER = import.meta.env.VITE_WHATSAPP_NUMBER || '221771234567';
6
+ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
7
 
8
  function Navbar() {
9
  return (
 
14
  <div className="bg-primary/10 p-2 rounded-xl group-hover:bg-primary/20 transition-colors">
15
  <BookOpen className="h-6 w-6 text-primary" />
16
  </div>
17
+ <span className="font-heading font-bold text-2xl text-secondary tracking-tight">EdTech<span className="text-primary">.sn</span></span>
18
  </Link>
19
  <div className="flex items-center space-x-4">
20
+ <Link to="/student" className="text-secondary font-medium hover:text-primary transition-colors">Mon espace</Link>
21
+ <a href={`https://wa.me/${WA_NUMBER}?text=INSCRIPTION`} target="_blank" rel="noreferrer"
22
+ className="bg-primary text-white px-5 py-2.5 rounded-full font-medium hover:bg-emerald-700 hover:shadow-lg hover:shadow-primary/30 transition-all active:scale-95 hidden sm:inline-flex items-center">
23
+ Commencer <ArrowRight className="ml-2 w-4 h-4" />
 
 
 
 
 
 
24
  </a>
25
  </div>
26
  </div>
 
32
  function Hero() {
33
  return (
34
  <div className="relative overflow-hidden bg-slate-50 pt-24 pb-32">
35
+ <div className="absolute top-0 right-0 -translate-y-12 translate-x-1/3"><div className="w-96 h-96 bg-accent/20 rounded-full blur-3xl"></div></div>
36
+ <div className="absolute bottom-0 left-0 translate-y-1/3 -translate-x-1/3"><div className="w-[500px] h-[500px] bg-primary/10 rounded-full blur-3xl"></div></div>
 
 
 
 
 
 
37
  <div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
38
  <div className="inline-flex items-center px-4 py-2 rounded-full bg-blue-50 text-secondary font-medium text-sm mb-8 border border-blue-100 shadow-sm">
39
  <span className="flex h-2 w-2 rounded-full bg-blue-600 mr-2 animate-pulse"></span>
40
+ Formation directement sur WhatsApp
41
  </div>
 
42
  <h1 className="font-heading font-extrabold text-5xl md:text-7xl text-secondary mb-6 leading-tight tracking-tight">
43
+ Maîtrise ton business,<br className="hidden md:block" /> un message à la fois.
 
44
  </h1>
 
45
  <p className="max-w-2xl mx-auto text-xl text-gray-600 mb-10 font-sans">
46
+ Des formations audio interactives sur WhatsApp pour les entrepreneurs du Sénégal. Reçois ta leçon chaque matin et génère ton dossier business en IA.
 
47
  </p>
 
48
  <div className="flex flex-col sm:flex-row justify-center items-center gap-4">
49
+ <a href={`https://wa.me/${WA_NUMBER}?text=INSCRIPTION`} target="_blank" rel="noreferrer"
50
+ className="w-full sm:w-auto bg-primary text-white text-lg px-8 py-4 rounded-full font-bold shadow-xl shadow-primary/30 hover:bg-emerald-700 hover:scale-105 transition-all flex items-center justify-center group">
51
+ <Phone className="mr-2 w-5 h-5 group-hover:rotate-12 transition-transform" />S'inscrire sur WhatsApp
 
 
 
 
 
52
  </a>
53
+ <Link to="/student" className="w-full sm:w-auto bg-white text-secondary border-2 border-gray-200 text-lg px-8 py-4 rounded-full font-bold hover:border-secondary hover:bg-gray-50 transition-all flex items-center justify-center">
54
+ Mon espace étudiant
 
 
 
55
  </Link>
56
  </div>
57
  </div>
 
60
  }
61
 
62
  function Features() {
63
+ const features = [
64
+ { icon: <Smartphone className="w-7 h-7 text-primary" />, title: 'WhatsApp natif', desc: 'Pas d\'application à installer. Reçois tes leçons audio et exercices directement dans WhatsApp.' },
65
+ { icon: <BookOpen className="w-7 h-7 text-accent" />, title: 'Parcours structurés', desc: 'Des formations multi-jours conçues pour les entrepreneurs de l\'informel au Sénégal.' },
66
+ { icon: <FileText className="w-7 h-7 text-secondary" />, title: 'Dossier IA', desc: 'À la fin de ta formation, l\'IA génère automatiquement ton One-Pager PDF et ton Pitch Deck.' },
67
+ ];
68
  return (
69
  <div className="py-24 bg-white">
70
  <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
71
  <div className="text-center mb-16">
72
+ <h2 className="font-heading font-bold text-3xl md:text-4xl text-secondary mb-4">Comment ça marche</h2>
73
+ <p className="text-lg text-gray-500 max-w-2xl mx-auto">Une expérience d'apprentissage conçue pour les entrepreneurs mobiles.</p>
74
  </div>
 
75
  <div className="grid md:grid-cols-3 gap-12">
76
+ {features.map((f, i) => (
77
+ <div key={i} className="group bg-slate-50 p-8 rounded-3xl border border-slate-100 hover:border-primary/30 hover:shadow-xl hover:shadow-primary/5 transition-all">
78
+ <div className="bg-white w-14 h-14 rounded-2xl flex items-center justify-center shadow-sm mb-6 group-hover:scale-110 transition-transform">{f.icon}</div>
79
+ <h3 className="font-heading font-bold text-xl text-secondary mb-3">{f.title}</h3>
80
+ <p className="text-gray-600 leading-relaxed text-sm">{f.desc}</p>
81
  </div>
82
+ ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  </div>
84
  </div>
85
  </div>
86
  );
87
  }
88
 
89
+ // ── Student Portal ────────────────────────────────────────────────────────────────
90
+ function StudentPortal() {
91
  const navigate = useNavigate();
92
+ const [phone, setPhone] = useState('');
93
+ const [loading, setLoading] = useState(false);
94
+ const [error, setError] = useState('');
95
 
96
+ const handleSubmit = async (e: React.FormEvent) => {
97
  e.preventDefault();
98
+ setError(''); setLoading(true);
99
+ const cleaned = phone.replace(/\s+/g, '').replace(/^\+/, '');
100
+ try {
101
+ const res = await fetch(`${API_URL}/v1/student/me?phone=${cleaned}`);
102
+ if (res.ok) {
103
+ navigate(`/student/${cleaned}`);
104
+ } else if (res.status === 404) {
105
+ setError('Numéro non trouvé. Envoie INSCRIPTION sur WhatsApp pour t\'inscrire.');
106
+ } else {
107
+ setError('Erreur serveur. Réessaie dans un moment.');
108
+ }
109
+ } catch {
110
+ setError('Impossible de joindre le serveur.');
111
+ } finally { setLoading(false); }
112
  };
113
 
114
  return (
115
  <div className="min-h-[80vh] flex flex-col justify-center py-12 sm:px-6 lg:px-8 bg-slate-50">
116
  <div className="sm:mx-auto sm:w-full sm:max-w-md text-center">
117
+ <div className="inline-flex bg-primary/10 p-4 rounded-full mb-4"><BookOpen className="w-10 h-10 text-primary" /></div>
118
+ <h2 className="text-center text-3xl font-heading font-extrabold text-secondary">Mon espace étudiant</h2>
119
+ <p className="mt-2 text-center text-sm text-gray-600">Entre ton numéro WhatsApp pour voir ta progression.</p>
 
 
 
 
 
 
120
  </div>
 
121
  <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
122
  <div className="bg-white py-8 px-4 shadow sm:rounded-2xl sm:px-10 border border-gray-100">
123
+ <form className="space-y-6" onSubmit={handleSubmit}>
124
  <div>
125
+ <label htmlFor="phone" className="block text-sm font-medium text-gray-700">Numéro WhatsApp</label>
 
 
126
  <div className="mt-1 relative rounded-md shadow-sm">
127
  <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
128
  <Phone className="h-5 w-5 text-gray-400" />
129
  </div>
130
+ <input id="phone" name="phone" type="tel" required value={phone} onChange={e => setPhone(e.target.value)}
 
 
 
 
131
  className="focus:ring-primary focus:border-primary block w-full pl-10 sm:text-sm border-gray-300 rounded-xl py-3 border outline-none transition-colors"
132
+ placeholder="+221 77 123 45 67" />
 
133
  </div>
134
  </div>
135
+ {error && <div className="flex items-start gap-2 p-3 bg-red-50 rounded-xl text-sm text-red-700"><AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />{error}</div>}
136
+ <button type="submit" disabled={loading}
137
+ className="w-full flex justify-center py-3 px-4 border border-transparent rounded-xl shadow-sm text-sm font-bold text-white bg-secondary hover:bg-blue-900 focus:outline-none transition-all active:scale-95 disabled:opacity-50">
138
+ {loading ? 'Recherche...' : 'Voir ma progression'}
139
+ </button>
 
 
 
 
140
  </form>
141
+ <p className="mt-6 text-center text-xs text-gray-400">
142
+ Pas encore inscrit ? <a href={`https://wa.me/${WA_NUMBER}?text=INSCRIPTION`} className="text-primary font-medium">Envoie INSCRIPTION sur WhatsApp</a>
143
+ </p>
144
+ </div>
145
+ </div>
146
+ </div>
147
+ );
148
+ }
149
 
150
+ // ── Student Dashboard ─────────────────────────────────────────────────────────────
151
+ function StudentDashboard() {
152
+ const { phone } = useParams<{ phone: string }>();
153
+ const [data, setData] = useState<any>(null);
154
+ const [loading, setLoading] = useState(true);
155
+ const [error, setError] = useState('');
156
+
157
+ useEffect(() => {
158
+ fetch(`${API_URL}/v1/student/me?phone=${phone}`)
159
+ .then(r => { if (!r.ok) throw new Error('not found'); return r.json(); })
160
+ .then(setData)
161
+ .catch(() => setError('Impossible de charger tes données.'))
162
+ .finally(() => setLoading(false));
163
+ }, [phone]);
164
+
165
+ if (loading) return <div className="min-h-screen flex items-center justify-center text-slate-400">Chargement...</div>;
166
+ if (error) return (
167
+ <div className="min-h-screen flex flex-col items-center justify-center gap-4">
168
+ <AlertCircle className="w-10 h-10 text-red-400" />
169
+ <p className="text-slate-600">{error}</p>
170
+ <Link to="/student" className="text-primary font-medium">← Retour</Link>
171
+ </div>
172
+ );
173
+
174
+ return (
175
+ <div className="min-h-screen bg-slate-50 py-12">
176
+ <div className="max-w-3xl mx-auto px-4">
177
+ <div className="flex items-center justify-between mb-8">
178
+ <div>
179
+ <h1 className="text-2xl font-bold text-slate-800">{data.name || 'Mon espace'}</h1>
180
+ <p className="text-sm text-slate-500">{data.phone} · {data.language} · {data.activity || 'Secteur non défini'}</p>
181
+ </div>
182
+ <Link to="/student" className="text-sm text-slate-400 hover:text-slate-600">← Changer de compte</Link>
183
+ </div>
184
+
185
+ {/* Enrollments */}
186
+ <h2 className="text-lg font-bold text-slate-700 mb-4">Mes formations</h2>
187
+ {data.enrollments.length === 0 ? (
188
+ <div className="bg-white rounded-2xl border border-slate-100 p-8 text-center text-slate-400 mb-8">
189
+ <BookOpen className="w-10 h-10 mx-auto mb-3 opacity-30" />
190
+ <p>Tu n'es inscrit à aucune formation.</p>
191
+ <a href={`https://wa.me/${WA_NUMBER}?text=FORMATION`} target="_blank" rel="noreferrer"
192
+ className="mt-4 inline-block bg-primary text-white px-5 py-2 rounded-full text-sm font-medium hover:bg-emerald-700 transition">
193
+ Voir les formations disponibles
194
+ </a>
195
+ </div>
196
+ ) : (
197
+ <div className="grid gap-4 mb-8">
198
+ {data.enrollments.map((e: any) => (
199
+ <div key={e.id} className="bg-white rounded-2xl border border-slate-100 p-6 shadow-sm">
200
+ <div className="flex items-start justify-between mb-4">
201
+ <div>
202
+ <h3 className="font-bold text-slate-800">{e.trackTitle}</h3>
203
+ <p className="text-sm text-slate-500 mt-1">Jour {e.currentDay} sur {e.totalDays}</p>
204
+ </div>
205
+ <span className={`text-xs font-medium px-2.5 py-1 rounded-full ${e.status === 'ACTIVE' ? 'bg-blue-100 text-blue-700' : e.status === 'COMPLETED' ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-600'}`}>
206
+ {e.status === 'ACTIVE' ? '🟢 En cours' : e.status === 'COMPLETED' ? '✅ Terminé' : e.status}
207
+ </span>
208
+ </div>
209
+ {/* Progress bar */}
210
+ <div className="w-full bg-slate-100 rounded-full h-2 mb-2">
211
+ <div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${e.progressPercent}%` }}></div>
212
+ </div>
213
+ <p className="text-xs text-slate-400 text-right">{e.progressPercent}% complété</p>
214
+ {e.status === 'ACTIVE' && (
215
+ <a href={`https://wa.me/${WA_NUMBER}?text=SUITE`} target="_blank" rel="noreferrer"
216
+ className="mt-4 flex items-center justify-center gap-2 bg-primary/10 text-primary font-medium text-sm py-2.5 rounded-xl hover:bg-primary/20 transition">
217
+ <Phone className="w-4 h-4" /> Continuer sur WhatsApp
218
+ </a>
219
+ )}
220
  </div>
221
+ ))}
222
  </div>
223
+ )}
224
+
225
+ {/* Payments */}
226
+ {data.payments.length > 0 && (
227
+ <>
228
+ <h2 className="text-lg font-bold text-slate-700 mb-4">Mes paiements</h2>
229
+ <div className="bg-white rounded-2xl border border-slate-100 p-5 shadow-sm">
230
+ {data.payments.map((p: any) => (
231
+ <div key={p.id} className="flex items-center justify-between py-3 border-b border-slate-50 last:border-0">
232
+ <div className="flex items-center gap-3">
233
+ <CheckCircle className="w-5 h-5 text-green-500" />
234
+ <div>
235
+ <p className="text-sm font-medium text-slate-800">{p.amount.toLocaleString()} {p.currency}</p>
236
+ <p className="text-xs text-slate-400">{new Date(p.createdAt).toLocaleDateString('fr-FR')}</p>
237
+ </div>
238
+ </div>
239
+ <span className="text-xs text-green-600 font-medium bg-green-50 px-2 py-1 rounded-full">Payé</span>
240
+ </div>
241
+ ))}
242
+ </div>
243
+ </>
244
+ )}
245
+ </div>
246
+ </div>
247
+ );
248
+ }
249
+ // ── Payment Success ───────────────────────────────────────────────────────────────
250
+ function PaymentSuccess() {
251
+ const [searchParams] = useSearchParams();
252
+ const phone = searchParams.get('phone') || '';
253
+ return (
254
+ <div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
255
+ <div className="bg-white rounded-3xl shadow-xl p-10 max-w-md w-full text-center">
256
+ <div className="inline-flex bg-green-100 p-5 rounded-full mb-6">
257
+ <CheckCircle className="w-12 h-12 text-green-500" />
258
  </div>
259
+ <h1 className="text-3xl font-heading font-bold text-slate-800 mb-3">Paiement réussi !</h1>
260
+ <p className="text-gray-500 mb-8">Ta formation a été débloquée. Tu vas recevoir ta première leçon sur WhatsApp dans quelques minutes.</p>
261
+ <div className="bg-slate-50 rounded-2xl p-5 mb-8">
262
+ <p className="text-sm text-slate-500 mb-4">Continue ton apprentissage :</p>
263
+ <a href={`https://wa.me/${WA_NUMBER}?text=SUITE`} target="_blank" rel="noreferrer"
264
+ className="flex items-center justify-center gap-2 bg-primary text-white font-bold py-3.5 px-6 rounded-xl hover:bg-emerald-700 transition shadow-lg shadow-primary/30">
265
+ <Phone className="w-5 h-5" /> Ouvrir WhatsApp
266
+ </a>
267
+ </div>
268
+ {phone && (
269
+ <Link to={`/student/${phone.replace(/^\+/, '')}`} className="text-sm text-primary font-medium hover:underline flex items-center justify-center gap-1">
270
+ <Download className="w-4 h-4" /> Voir mon espace étudiant
271
+ </Link>
272
+ )}
273
+ <Link to="/" className="block mt-4 text-sm text-slate-400 hover:text-slate-600">← Retour à l'accueil</Link>
274
  </div>
275
  </div>
276
  );
 
283
  <div className="col-span-2">
284
  <Link to="/" className="flex items-center space-x-2 mb-4">
285
  <BookOpen className="h-6 w-6 text-primary" />
286
+ <span className="font-heading font-bold text-xl tracking-tight">EdTech<span className="text-primary">.sn</span></span>
287
  </Link>
288
+ <p className="text-gray-400 text-sm max-w-sm">Former les entrepreneurs d'Afrique grâce à une éducation accessible, mobile-first et propulsée par l'IA.</p>
 
 
289
  </div>
290
+ <div><h4 className="font-bold mb-4 font-heading">Plateforme</h4>
 
291
  <ul className="space-y-2 text-sm text-gray-400">
292
+ <li><a href={`https://wa.me/${WA_NUMBER}?text=INSCRIPTION`} className="hover:text-white transition">S'inscrire</a></li>
293
+ <li><Link to="/student" className="hover:text-white transition">Mon espace</Link></li>
 
294
  </ul>
295
  </div>
296
+ <div><h4 className="font-bold mb-4 font-heading">Entreprise</h4>
 
297
  <ul className="space-y-2 text-sm text-gray-400">
298
+ <li><a href="#" className="hover:text-white transition">À propos</a></li>
299
+ <li><a href="#" className="hover:text-white transition">Contact</a></li>
 
300
  </ul>
301
  </div>
302
  </div>
303
+ <div className="max-w-7xl mx-auto px-4 mt-12 pt-8 border-t border-white/10 text-center text-sm text-gray-400">
304
+ © {new Date().getFullYear()} EdTech.sn. Tous droits réservés.
305
  </div>
306
  </footer>
307
  );
308
  }
309
 
 
 
 
 
 
 
 
 
 
310
  function App() {
311
  return (
312
  <Router>
 
314
  <Navbar />
315
  <main className="flex-grow">
316
  <Routes>
317
+ <Route path="/" element={<><Hero /><Features /></>} />
318
+ <Route path="/student" element={<StudentPortal />} />
319
+ <Route path="/student/:phone" element={<StudentDashboard />} />
320
+ <Route path="/payment/success" element={<PaymentSuccess />} />
321
  </Routes>
322
  </main>
323
  <Footer />
 
325
  </Router>
326
  );
327
  }
 
328
  export default App;
apps/whatsapp-worker/src/scheduler.ts CHANGED
@@ -1,9 +1,9 @@
1
  import cron from 'node-cron';
2
  import { Queue } from 'bullmq';
3
  import { PrismaClient } from '@prisma/client';
 
4
 
5
  const prisma = new PrismaClient();
6
- import Redis from 'ioredis';
7
 
8
  const connection = process.env.REDIS_URL
9
  ? new Redis(process.env.REDIS_URL, { maxRetriesPerRequest: null })
@@ -19,31 +19,56 @@ const connection = process.env.REDIS_URL
19
  const whatsappQueue = new Queue('whatsapp-queue', { connection: connection as any });
20
 
21
  export function startDailyScheduler() {
22
- // Runs at 08:00 AM every day
23
  cron.schedule('0 8 * * *', async () => {
24
- console.log('[SCHEDULER] Running daily content check for active enrollments...');
25
 
26
  try {
27
  const activeEnrollments = await prisma.enrollment.findMany({
28
- where: { status: 'ACTIVE' }
29
  });
30
 
31
  for (const enrollment of activeEnrollments) {
 
 
 
 
 
 
 
 
 
 
32
  const nextDay = enrollment.currentDay + 1;
33
 
34
- // Queue the next day's content for sending
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  await whatsappQueue.add('send-content', {
36
  userId: enrollment.userId,
37
  trackId: enrollment.trackId,
38
  dayNumber: nextDay
39
  });
40
 
41
- console.log(`[SCHEDULER] Queued Day ${nextDay} content for User ${enrollment.userId} (Track: ${enrollment.trackId})`);
42
  }
43
  } catch (error) {
44
- console.error('[SCHEDULER] Error running daily scheduler:', error);
45
  }
46
  });
47
 
48
- console.log('Daily Content Scheduler initialized (cron schedule: 0 8 * * *).');
49
  }
 
1
  import cron from 'node-cron';
2
  import { Queue } from 'bullmq';
3
  import { PrismaClient } from '@prisma/client';
4
+ import Redis from 'ioredis';
5
 
6
  const prisma = new PrismaClient();
 
7
 
8
  const connection = process.env.REDIS_URL
9
  ? new Redis(process.env.REDIS_URL, { maxRetriesPerRequest: null })
 
19
  const whatsappQueue = new Queue('whatsapp-queue', { connection: connection as any });
20
 
21
  export function startDailyScheduler() {
22
+ // Runs at 08:00 AM every day (Dakar time = UTC+0 in winter, so 8 UTC = 8 Dakar)
23
  cron.schedule('0 8 * * *', async () => {
24
+ console.log('[SCHEDULER] Running daily content check...');
25
 
26
  try {
27
  const activeEnrollments = await prisma.enrollment.findMany({
28
+ where: { status: 'ACTIVE' },
29
  });
30
 
31
  for (const enrollment of activeEnrollments) {
32
+ // ── Skip if exercise still PENDING (user hasn't responded yet) ───
33
+ const progress = await prisma.userProgress.findUnique({
34
+ where: { userId_trackId: { userId: enrollment.userId, trackId: enrollment.trackId } }
35
+ });
36
+
37
+ if (progress?.exerciseStatus === 'PENDING') {
38
+ console.log(`[SCHEDULER] Skip User ${enrollment.userId} — Day ${enrollment.currentDay} still PENDING`);
39
+ continue;
40
+ }
41
+
42
  const nextDay = enrollment.currentDay + 1;
43
 
44
+ // ── Check the next day content exists ──────────────────────────
45
+ const nextDayContent = await prisma.trackDay.findFirst({
46
+ where: { trackId: enrollment.trackId, dayNumber: nextDay }
47
+ });
48
+
49
+ if (!nextDayContent) {
50
+ // No more content → mark enrollment COMPLETED
51
+ console.log(`[SCHEDULER] No Day ${nextDay} for Track ${enrollment.trackId} — marking COMPLETED`);
52
+ await prisma.enrollment.update({
53
+ where: { id: enrollment.id },
54
+ data: { status: 'COMPLETED', completedAt: new Date() }
55
+ });
56
+ continue;
57
+ }
58
+
59
+ // ── Queue the next lesson ─────────────────────────────────────
60
  await whatsappQueue.add('send-content', {
61
  userId: enrollment.userId,
62
  trackId: enrollment.trackId,
63
  dayNumber: nextDay
64
  });
65
 
66
+ console.log(`[SCHEDULER] Queued Day ${nextDay} for User ${enrollment.userId}`);
67
  }
68
  } catch (error) {
69
+ console.error('[SCHEDULER] Error:', error);
70
  }
71
  });
72
 
73
+ console.log('Daily Content Scheduler initialized (cron: 0 8 * * *).');
74
  }
packages/database/seed.ts CHANGED
@@ -1,229 +1,113 @@
 
 
 
 
 
 
1
  import { PrismaClient } from '@prisma/client';
2
-
3
  const prisma = new PrismaClient();
4
 
5
  async function main() {
6
- // 1. Module 1: Comprendre Son Business (FR)
7
- const comprendreBusinessFR = await prisma.track.create({
8
- data: {
9
- title: "Comprendre Son Business (FR)",
10
- description: "Apprenez à définir, tester et vendre votre projet en 12 leçons.",
11
- duration: 12,
12
- language: "FR",
13
- days: {
14
- create: [
15
- {
16
- dayNumber: 1,
17
- exerciseType: "AUDIO",
18
- lessonText: "Aujourd’hui, on commence simple. Beaucoup de personnes disent : je fais le commerce. Mais ça ne veut rien dire. Dis-moi clairement : Tu aides QUI, à faire QUOI, et comment tu gagnes de l’argent. Exemple : Je vends du jus bissap aux étudiants devant l’université. Maintenant, c’est à toi. Dis ta phrase en 15 secondes.",
19
- exercisePrompt: "Envoie-moi un court message vocal (ou texte) avec ta phrase d'activité :"
20
- },
21
- {
22
- dayNumber: 2,
23
- exerciseType: "AUDIO",
24
- lessonText: "Le client n’achète pas ton produit. Il achète un résultat. Il n’achète pas du savon. Il achète la propreté. Va demander à 2 clients : Pourquoi tu achètes ça ? Écoute bien leurs mots.",
25
- exercisePrompt: "Envoie un audio résumant les 2 réponses de tes clients."
26
- },
27
- {
28
- dayNumber: 3,
29
- exerciseType: "BUTTON",
30
- lessonText: "Si tu vends à tout le monde, tu ne vends à personne. Choisis un seul client principal. Qui est le plus intéressé par ton produit ?",
31
- exercisePrompt: "Sélectionne ton client principal ci-dessous :",
32
- buttonsJson: [
33
- { id: "jeunes", title: "Jeunes" },
34
- { id: "femmes", title: "Femmes" },
35
- { id: "commercants", title: "Commerçants" }
36
- ]
37
- },
38
- {
39
- dayNumber: 4,
40
- exerciseType: "TEXT",
41
- lessonText: "Ton client a un problème. Quel est son plus grand problème ? Parle à 3 personnes aujourd’hui. Pose cette question. Écoute sans expliquer ton produit.",
42
- exercisePrompt: "Quel est le problème N°1 que tes clients t'ont partagé ?"
43
- },
44
- {
45
- dayNumber: 5,
46
- exerciseType: "BUTTON",
47
- lessonText: "À quel moment ton client a ce problème ?",
48
- exercisePrompt: "Choisis le moment d'apparition du problème :",
49
- buttonsJson: [
50
- { id: "matin_midi", title: "Matin ou Midi" },
51
- { id: "soir", title: "Le Soir" },
52
- { id: "tout_le_temps", title: "Tout le temps" }
53
- ]
54
- },
55
- {
56
- dayNumber: 6,
57
- exerciseType: "TEXT",
58
- lessonText: "Avant toi, il faisait comment ?",
59
- exercisePrompt: "Donne-moi 2 solutions que ton client utilisait avant de te connaître :"
60
- },
61
- {
62
- dayNumber: 7,
63
- exerciseType: "TEXT",
64
- lessonText: "Explique ta solution en mots simples. Pas compliqué.",
65
- exercisePrompt: "Décris-moi ton offre très simplement en une phrase :"
66
- },
67
- {
68
- dayNumber: 8,
69
- exerciseType: "BUTTON",
70
- lessonText: "Tu ne peux pas promettre tout. Choisis une seule force.",
71
- exercisePrompt: "Quelle est ta promesse principale ?",
72
- buttonsJson: [
73
- { id: "rapide", title: "Rapide" },
74
- { id: "moins_cher", title: "Moins cher" },
75
- { id: "fiable_proche", title: "Fiable / Proche" }
76
- ]
77
- },
78
- {
79
- dayNumber: 9,
80
- exerciseType: "TEXT",
81
- lessonText: "Parle à 5 personnes. Dis ta phrase. Combien disent OUI ?",
82
- exercisePrompt: "Combien t'ont dit OUI ? (Envoie juste un chiffre)"
83
- },
84
- {
85
- dayNumber: 10,
86
- exerciseType: "TEXT",
87
- lessonText: "Ton prix doit couvrir tes coûts. Note 2 dépenses importantes.",
88
- exercisePrompt: "Quelles sont tes 2 plus grosses dépenses pour ce projet ?"
89
- },
90
- {
91
- dayNumber: 11,
92
- exerciseType: "BUTTON",
93
- lessonText: "Pourquoi toi et pas un autre ?",
94
- exercisePrompt: "Quel est ton vrai avantage concurrentiel ?",
95
- buttonsJson: [
96
- { id: "qualite", title: "Qualité" },
97
- { id: "rapidite", title: "Rapidité" },
98
- { id: "confiance", title: "Confiance" }
99
- ]
100
- },
101
- {
102
- dayNumber: 12,
103
- exerciseType: "AUDIO",
104
- lessonText: "Maintenant tu es prêt. Dis en 30 secondes : Je suis... J’aide... Parce que... Je vends... À...",
105
- exercisePrompt: "C'est l'heure du test ! Envoie-moi un audio avec ton Mini Pitch de 30 secondes :"
106
- }
107
- ]
108
- }
109
  }
110
  });
111
 
112
- // 2. Module 1: Comprendre Son Business (WOLOF)
113
- const comprendreBusinessWO = await prisma.track.create({
114
- data: {
115
- title: "Comprendre Son Business (WOLOF)",
116
- description: "Apprenez à définir, tester et vendre votre projet en 12 leçons.",
117
- duration: 12,
118
- language: "WOLOF",
119
- days: {
120
- create: [
121
- {
122
- dayNumber: 1,
123
- exerciseType: "AUDIO",
124
- lessonText: "Tey, danuy tàmbalee ak lu yomb. Nit ñu bari dañuy wax : dama def commerce. Waaye loolu amul solo. Wax ma leer : Yaay jàppalé KAN, mu def LAN, te naka nga amee xaalis. Misaal : Damaa jaay jus bissap ci taalibe yu université. Léegi sa waxtu la. Wax sa activité ci 15 seconde.",
125
- exercisePrompt: "Yónnee ma ab kàddu (audio) walla message bu gatt ngir wax sa mbir :"
126
- },
127
- {
128
- dayNumber: 2,
129
- exerciseType: "AUDIO",
130
- lessonText: "Kiliifa bi du jënd sa produit rek. Mu jënd ab résultat. Du jënd savon rek. Mu jënd set. Dem laaj 2 kiliifa : Lu tax nga jënd lii ? Déggal bu baax li ñuy wax.",
131
- exercisePrompt: "Yónnee ma audio ngir tënk ñaari tontu ya."
132
- },
133
- {
134
- dayNumber: 3,
135
- exerciseType: "BUTTON",
136
- lessonText: "Su nga jaay ci ñépp, doo jaay ci kenn. Tànnal benn kiliifa bu mag. Kan moo gën a soxla sa produit ?",
137
- exercisePrompt: "Tànnal sa kiliifa bu mag ci suuf :",
138
- buttonsJson: [
139
- { id: "ndaw_nyi", title: "Ndaw ñi / Jeunes" },
140
- { id: "jigeen_nyi", title: "Jigeen ñi / Femmes" },
141
- { id: "jaaykat_yi", title: "Jaaykat yi / Comms" }
142
- ]
143
- },
144
- {
145
- dayNumber: 4,
146
- exerciseType: "TEXT",
147
- lessonText: "Sa kiliifa am na jafe jafe. Lan mooy jafe jafe bu gën a rëy ? Dem waxtaan ak 3 nit. Laaj leen. Bul def publicité.",
148
- exercisePrompt: "Lan mooy jafe jafe bu gën a mag bi sa kiliifa yi am ?"
149
- },
150
- {
151
- dayNumber: 5,
152
- exerciseType: "BUTTON",
153
- lessonText: "Kañ la jafe jafe bi di ñëw ?",
154
- exercisePrompt: "Tànnal jamono ji jafe jafe bi di faral di am :",
155
- buttonsJson: [
156
- { id: "suba_bëccëg", title: "Suba walla Bëccëg" },
157
- { id: "ngoon", title: "Ngoon / Guddi" },
158
- { id: "saa_su_ne", title: "Saa su nekk" }
159
- ]
160
- },
161
- {
162
- dayNumber: 6,
163
- exerciseType: "TEXT",
164
- lessonText: "Balaa yaw, naka la daan def ?",
165
- exercisePrompt: "Wax ma ñaari pexe yi kiliifa bi daan jëfandikoo balaa xam sa produit :"
166
- },
167
- {
168
- dayNumber: 7,
169
- exerciseType: "TEXT",
170
- lessonText: "Wax sa solution ci wax yu yomb.",
171
- exercisePrompt: "Tënkal sa solution ci benn phrase bu yomb :"
172
- },
173
- {
174
- dayNumber: 8,
175
- exerciseType: "BUTTON",
176
- lessonText: "Bul promettre lépp. Tànnal benn doole.",
177
- exercisePrompt: "Lan mooy sa dige bu mag ?",
178
- buttonsJson: [
179
- { id: "gaaw", title: "Dafa gaaw" },
180
- { id: "yomb", title: "Dafa yomb / Prix" },
181
- { id: "woor", title: "Dafa woor" }
182
- ]
183
- },
184
- {
185
- dayNumber: 9,
186
- exerciseType: "TEXT",
187
- lessonText: "Dem waxtaan ak 5 nit. Ñaata ñu wax WAAN ?",
188
- exercisePrompt: "Ñaata nit ñoo wax WAAN ? (Bind ma chiffre bi rek)"
189
- },
190
- {
191
- dayNumber: 10,
192
- exerciseType: "TEXT",
193
- lessonText: "Sa priix war na japp sa dépense. Bind ñaari dépense.",
194
- exercisePrompt: "Bind ma ñaari dépense yu gën a rëy ci sa mbir :"
195
- },
196
- {
197
- dayNumber: 11,
198
- exerciseType: "BUTTON",
199
- lessonText: "Lu tax yaw te du keneen ?",
200
- exercisePrompt: "Lan nga gën a mën ci ñeneen ñi ?",
201
- buttonsJson: [
202
- { id: "baax", title: "Dafa baax" },
203
- { id: "gaaw", title: "Dafa gaaw" },
204
- { id: "koolute", title: "Kooluté / Confiance" }
205
- ]
206
- },
207
- {
208
- dayNumber: 12,
209
- exerciseType: "AUDIO",
210
- lessonText: "Léegi nga hazır. Wax ci 30 seconde : Man ma... Damaa jàppalé... Ndax... Damaa jaay... Ci... Yónnee sa audio.",
211
- exercisePrompt: "Yónnee ma sa Pitch bu gatt ci 30 seconde :"
212
- }
213
- ]
214
- }
215
  }
216
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
- console.log({ comprendreBusinessFR, comprendreBusinessWO });
 
219
  }
220
 
221
  main()
222
- .then(async () => {
223
- await prisma.$disconnect()
224
- })
225
- .catch(async (e) => {
226
- console.error(e)
227
- await prisma.$disconnect()
228
- process.exit(1)
229
- })
 
1
+ /**
2
+ * Seed Script — Module 1 : Eco-Moto Business
3
+ * Inserts a realistic Track + 3 TrackDays for testing.
4
+ * Run: npx ts-node scripts/seed.ts
5
+ * Or: add SEED_DATA to whatsapp trigger (handled in whatsapp.ts)
6
+ */
7
  import { PrismaClient } from '@prisma/client';
 
8
  const prisma = new PrismaClient();
9
 
10
  async function main() {
11
+ console.log('🌱 Seeding database...');
12
+
13
+ // Upsert Track
14
+ const track = await prisma.track.upsert({
15
+ where: { id: 'seed-module-1-ecomoto' },
16
+ update: {},
17
+ create: {
18
+ id: 'seed-module-1-ecomoto',
19
+ title: 'Lancer son Business – Module 1',
20
+ description: 'Formation de 3 jours pour structurer son idée de business et rédiger son pitch.',
21
+ duration: 3,
22
+ language: 'FR',
23
+ isPremium: false,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  }
25
  });
26
 
27
+ console.log(`✅ Track: ${track.title}`);
28
+
29
+ const days = [
30
+ {
31
+ dayNumber: 1,
32
+ title: 'Jour 1 – Ton idée business',
33
+ lessonText: `Bienvenue dans ta formation ! 🎉
34
+
35
+ Aujourd'hui, on parle de TON idée.
36
+
37
+ Une bonne idée business répond à un problème réel que les gens autour de toi ont vraiment.
38
+
39
+ 👉 EXEMPLE : La réparation de motos à domicile. Le problème ? Les gens perdent du temps à aller chez le mécanicien. La solution ? Le mécanicien vient à eux.
40
+
41
+ Pour TON business, réponds à ces 3 questions :
42
+ 1️⃣ Quel problème tu résous ?
43
+ 2️⃣ Pour qui ? (femmes, jeunes, commerçants...)
44
+ 3️⃣ Pourquoi toi et pas un autre ?`,
45
+ exerciseType: 'TEXT' as const,
46
+ exercisePrompt: '📝 Décris ton idée business en 2-3 phrases. Quel problème tu résous ? Pour qui ?',
47
+ validationKeyword: null,
48
+ },
49
+ {
50
+ dayNumber: 2,
51
+ title: 'Jour 2 Tes clients cibles',
52
+ lessonText: `Félicitations pour hier ! 🌟
53
+
54
+ Aujourd'hui : TES CLIENTS.
55
+
56
+ Beaucoup d'entrepreneurs échouent parce qu'ils veulent vendre à "tout le monde". ❌
57
+
58
+ La règle d'or : commence par un groupe précis.
59
+
60
+ 👤 EXEMPLE Eco-Moto :
61
+ Qui ? Artisans et commerçants de Dakar
62
+ Âge ? 25-45 ans
63
+ Problème spécifique ? Leur moto tombe en panne et ils perdent des clients
64
+
65
+ Plus tu connais ton client, plus tu peux lui parler directement.`,
66
+ exerciseType: 'TEXT' as const,
67
+ exercisePrompt: '📝 Décris ton client cible idéal : Qui est-il ? Quel âge ? Où vit-il ? Quel est son problème principal ?',
68
+ validationKeyword: null,
69
+ },
70
+ {
71
+ dayNumber: 3,
72
+ title: 'Jour 3 Ton pitch en 1 minute',
73
+ lessonText: `Tu es presque un entrepreneur ! 🚀
74
+
75
+ Aujourd'hui : TON PITCH.
76
+
77
+ Un pitch c'est une présentation rapide de ton business. En 60 secondes, tu dois convaincre quelqu'un d'investir ou d'acheter.
78
+
79
+ 📣 STRUCTURE :
80
+ 1. Le problème (10 sec)
81
+ 2. Ta solution (10 sec)
82
+ 3. Tes clients (10 sec)
83
+ 4. Pourquoi toi (10 sec)
84
+ 5. Ce que tu demandes (20 sec)
85
+
86
+ EXEMPLE :
87
+ "En Afrique, 60% des motos tombent en panne sans accès rapide à un mécanicien. Eco-Moto envoie un mécanicien qualifié chez vous en moins de 30 minutes. On cible les artisans et commerçants de Dakar qui dépendent de leur moto. On a déjà 50 clients satisfaits. On cherche 500 000 XOF pour acheter nos premiers équipements."`,
88
+ exerciseType: 'TEXT' as const,
89
+ exercisePrompt: '🎤 Écris ton pitch en utilisant la structure ci-dessus. Sois précis et concis !',
90
+ validationKeyword: null,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  }
92
+ ];
93
+
94
+ for (const day of days) {
95
+ const existing = await prisma.trackDay.findFirst({
96
+ where: { trackId: track.id, dayNumber: day.dayNumber }
97
+ });
98
+ if (existing) {
99
+ await prisma.trackDay.update({ where: { id: existing.id }, data: day });
100
+ console.log(`✅ Updated Day ${day.dayNumber}: ${day.title}`);
101
+ } else {
102
+ await prisma.trackDay.create({ data: { ...day, trackId: track.id } });
103
+ console.log(`✅ Created Day ${day.dayNumber}: ${day.title}`);
104
+ }
105
+ }
106
 
107
+ console.log('\n🎉 Seed complete! Track ID:', track.id);
108
+ console.log('Send INSCRIPTION + FORMATION on WhatsApp to test.');
109
  }
110
 
111
  main()
112
+ .catch(console.error)
113
+ .finally(() => prisma.$disconnect());