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 +321 -229
- apps/api/src/index.ts +2 -0
- apps/api/src/routes/admin.ts +162 -27
- apps/api/src/routes/student.ts +71 -0
- apps/api/tsconfig.tsbuildinfo +1 -1
- apps/web/src/App.tsx +207 -148
- apps/whatsapp-worker/src/scheduler.ts +33 -8
- packages/database/seed.ts +101 -217
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 |
-
|
| 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 |
-
|
| 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
|
| 61 |
-
|
| 62 |
-
|
| 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
|
| 94 |
-
/>
|
| 95 |
{error && <p className="text-red-500 text-sm">{error}</p>}
|
| 96 |
-
<button
|
| 97 |
-
|
| 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 [
|
|
|
|
| 121 |
const [loading, setLoading] = useState(true);
|
| 122 |
-
|
| 123 |
useEffect(() => {
|
| 124 |
-
|
| 125 |
try {
|
| 126 |
-
const
|
| 127 |
-
const
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
fetch(`${API_URL}/v1/admin/stats`, { headers }),
|
| 131 |
-
fetch(`${API_URL}/v1/admin/enrollments`, { headers })
|
| 132 |
]);
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 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 (!
|
| 157 |
-
|
| 158 |
-
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
</div>
|
| 199 |
-
<
|
| 200 |
-
<
|
| 201 |
-
<
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
</div>
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
</div>
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
<div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
|
| 213 |
-
<
|
| 214 |
-
<
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 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 |
-
|
| 234 |
-
<
|
| 235 |
-
|
| 236 |
-
|
| 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-
|
| 272 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 282 |
-
|
| 283 |
-
<
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
|
|
|
|
|
|
| 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={<
|
| 300 |
-
<Route path="/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 7 |
fastify.get('/stats', async () => {
|
| 8 |
-
const totalUsers = await
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
});
|
| 29 |
|
| 30 |
-
//
|
| 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:
|
| 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 {
|
|
|
|
| 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">
|
| 16 |
</Link>
|
| 17 |
<div className="flex items-center space-x-4">
|
| 18 |
-
<Link to="/
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 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 |
-
|
| 40 |
-
<div className="absolute
|
| 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 |
-
|
| 51 |
</div>
|
| 52 |
-
|
| 53 |
<h1 className="font-heading font-extrabold text-5xl md:text-7xl text-secondary mb-6 leading-tight tracking-tight">
|
| 54 |
-
|
| 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 |
-
|
| 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 |
-
|
| 66 |
-
|
| 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 |
-
|
| 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">
|
| 91 |
-
<p className="text-lg text-gray-500 max-w-2xl mx-auto">
|
| 92 |
</div>
|
| 93 |
-
|
| 94 |
<div className="grid md:grid-cols-3 gap-12">
|
| 95 |
-
|
| 96 |
-
<div className="bg-
|
| 97 |
-
<
|
|
|
|
|
|
|
| 98 |
</div>
|
| 99 |
-
|
| 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 |
-
|
|
|
|
| 131 |
const navigate = useNavigate();
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
-
const
|
| 134 |
e.preventDefault();
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 147 |
-
</
|
| 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={
|
| 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 |
-
<
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
>
|
| 183 |
-
Send Magic Link
|
| 184 |
-
</button>
|
| 185 |
-
</div>
|
| 186 |
</form>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
</div>
|
| 198 |
-
|
| 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">
|
| 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=
|
| 223 |
-
<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
|
| 231 |
-
<li><a href="#" className="hover:text-white transition
|
| 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
|
| 237 |
-
|
| 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={<
|
| 260 |
-
<Route path="/
|
|
|
|
|
|
|
| 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
|
| 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 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}
|
| 42 |
}
|
| 43 |
} catch (error) {
|
| 44 |
-
console.error('[SCHEDULER] Error
|
| 45 |
}
|
| 46 |
});
|
| 47 |
|
| 48 |
-
console.log('Daily Content Scheduler initialized (cron
|
| 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 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 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 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 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(
|
|
|
|
| 219 |
}
|
| 220 |
|
| 221 |
main()
|
| 222 |
-
.
|
| 223 |
-
|
| 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());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|