1. Système d'Analytics (Nouveauté Majeure)

Modèle de Données : Ajout de la table AnalyticsEvent dans prisma/schema.prisma pour suivre les vues de pages, les clics et le temps de rétention (DWELL_TIME).
API Backend : Création de la route app/api/analytics/route.ts pour enregistrer les événements en base de données.
Tracking Client : Création du composant AnalyticsTracker.tsx (intégré dans le layout principal) qui capture automatiquement :
Le changement de page (Page Views).
Les clics sur les boutons et liens importants.
Le temps passé sur chaque page.
2. Intégration Base de Données (PostgreSQL/Prisma)
Fiches Entreprises : Mise à jour de app/directory/[id]/page.tsx pour récupérer les données réelles depuis PostgreSQL via Prisma au lieu d'utiliser les données de test (mockData).
Navigation Dashboard : Correction du bouton "Voir ma fiche" dans le tableau de bord pour qu'il redirige correctement vers le profil public de l'entrepreneur.
3. Administration & Metrics
Nouvelle Application Admin : Initialisation d'un dossier admin/ (application Next.js séparée) destinée à la gestion de la plateforme et à la visualisation des statistiques (Metrics & Analytics).
4. Authentification & Inscription
Nouvelle Page : Création de app/register/page.tsx pour permettre l'inscription des nouveaux utilisateurs.
Gestion de Session : Amélioration de UserProvider.tsx pour une meilleure gestion de l'état utilisateur à travers l'application.
5. Maintenance & Types
Mise à jour des types globaux dans types.ts et rafraîchissement du package-lock.json suite à l'ajout de dépendances.
This commit is contained in:
2026-04-11 22:20:41 +02:00
parent d88ba7c53c
commit b227657f19
100 changed files with 14142 additions and 157 deletions

2
.env
View File

@@ -1,5 +1,5 @@
# Connexion locale via le Docker Compose fourni :
DATABASE_URL=postgresql://admin:adminpassword@plumedbkaelstudio.tech:5432/afropreunariat_dev?schema=public
DATABASE_URL="postgresql://admin:adminpassword@192.168.1.25:5432/afropreunariat_dev?schema=public"
# Exemple avec Supabase ou Neon (si base distante)
# DATABASE_URL="postgresql://postgres:[MOT_DE_PASSE]@db.[ID_PROJET].supabase.co:5432/postgres"

3
.gitignore vendored
View File

@@ -6,7 +6,8 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.next
.next/*
node_modules
dist
dist-ssr

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,8 @@
"dynamicRoutes": {},
"notFoundRoutes": [],
"preview": {
"previewModeId": "36346de21023de4aacdc241201ed203e",
"previewModeSigningKey": "96be46da0bc424e421106ea753fd19fd68fb09c0846ae91c38451b20c6e16300",
"previewModeEncryptionKey": "55ee3316e83beac98a679eccb547ff11393487f7fbdaef6e378e1d4e642b423c"
"previewModeId": "549bb4c6520c4055391738eb3af7c117",
"previewModeSigningKey": "50be5dfec6eae3bf6960e9caf454be821cc00c686ff415fb845771a700e10c41",
"previewModeEncryptionKey": "2d171c6cc9411b6e306ffde24c967f9ae230daccd3927ceeaae19e3bbffa4f70"
}
}

View File

@@ -1,8 +1,8 @@
{
"/afrolife/page": "app/afrolife/page.js",
"/api/analytics/route": "app/api/analytics/route.js",
"/api/businesses/route": "app/api/businesses/route.js",
"/blog/page": "app/blog/page.js",
"/dashboard/page": "app/dashboard/page.js",
"/directory/page": "app/directory/page.js",
"/login/page": "app/login/page.js",
"/page": "app/page.js"
}

View File

@@ -4,7 +4,7 @@ R.c("server/chunks/ssr/[root-of-the-server]__a9977dab._.js")
R.c("server/chunks/ssr/a30ac_next_dist_2984d70a._.js")
R.c("server/chunks/ssr/[externals]__7f148858._.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_global-error_c1b18c9b.js")
R.c("server/chunks/ssr/Documents_00 - projet_afropreunariat_3412ba8c._.js")
R.c("server/chunks/ssr/Documents_00 - projet_afropreunariat_4bc040cc._.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_a7eb5008._.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_forbidden_24897189.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_unauthorized_d9e897c8.js")

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
var R=require("../../../chunks/[turbopack]_runtime.js")("server/app/api/businesses/route.js")
R.c("server/chunks/[root-of-the-server]__70022515._.js")
R.c("server/chunks/[root-of-the-server]__e552af3e._.js")
R.c("server/chunks/[root-of-the-server]__2914bac6._.js")
R.c("server/chunks/b79dd_afropreunariat__next-internal_server_app_api_businesses_route_actions_cb075aef.js")
R.m("[project]/Documents/00 - projet/afropreunariat/node_modules/next/dist/esm/build/templates/app-route.js { INNER_APP_ROUTE => \"[project]/Documents/00 - projet/afropreunariat/app/api/businesses/route.ts [app-route] (ecmascript)\" } [app-route] (ecmascript)")
module.exports=R.m("[project]/Documents/00 - projet/afropreunariat/node_modules/next/dist/esm/build/templates/app-route.js { INNER_APP_ROUTE => \"[project]/Documents/00 - projet/afropreunariat/app/api/businesses/route.ts [app-route] (ecmascript)\" } [app-route] (ecmascript)").exports

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@ R.c("server/chunks/ssr/[root-of-the-server]__3e36205a._.js")
R.c("server/chunks/ssr/a30ac_next_dist_2984d70a._.js")
R.c("server/chunks/ssr/[externals]__7f148858._.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_global-error_c1b18c9b.js")
R.c("server/chunks/ssr/Documents_00 - projet_afropreunariat_3412ba8c._.js")
R.c("server/chunks/ssr/Documents_00 - projet_afropreunariat_4bc040cc._.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_a7eb5008._.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_forbidden_24897189.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_unauthorized_d9e897c8.js")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@ R.c("server/chunks/ssr/[root-of-the-server]__927faa22._.js")
R.c("server/chunks/ssr/a30ac_next_dist_2984d70a._.js")
R.c("server/chunks/ssr/[externals]__7f148858._.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_global-error_c1b18c9b.js")
R.c("server/chunks/ssr/Documents_00 - projet_afropreunariat_3412ba8c._.js")
R.c("server/chunks/ssr/Documents_00 - projet_afropreunariat_4bc040cc._.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_a7eb5008._.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_forbidden_24897189.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_unauthorized_d9e897c8.js")

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@ R.c("server/chunks/ssr/[root-of-the-server]__f8962b83._.js")
R.c("server/chunks/ssr/a30ac_next_dist_2984d70a._.js")
R.c("server/chunks/ssr/[externals]__7f148858._.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_global-error_c1b18c9b.js")
R.c("server/chunks/ssr/Documents_00 - projet_afropreunariat_3412ba8c._.js")
R.c("server/chunks/ssr/Documents_00 - projet_afropreunariat_4bc040cc._.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_a7eb5008._.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_forbidden_24897189.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_unauthorized_d9e897c8.js")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@ R.c("server/chunks/ssr/[root-of-the-server]__9a704c34._.js")
R.c("server/chunks/ssr/a30ac_next_dist_2984d70a._.js")
R.c("server/chunks/ssr/[externals]__7f148858._.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_global-error_c1b18c9b.js")
R.c("server/chunks/ssr/Documents_00 - projet_afropreunariat_3412ba8c._.js")
R.c("server/chunks/ssr/Documents_00 - projet_afropreunariat_4bc040cc._.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_a7eb5008._.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_forbidden_24897189.js")
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_unauthorized_d9e897c8.js")

File diff suppressed because one or more lines are too long

View File

@@ -9,22 +9,48 @@ __turbopack_context__.s([
var __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$ssr$2f$react$2d$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[project]/Documents/00 - projet/afropreunariat/node_modules/next/dist/server/route-modules/app-page/vendored/ssr/react-jsx-dev-runtime.js [app-ssr] (ecmascript)");
var __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$ssr$2f$react$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[project]/Documents/00 - projet/afropreunariat/node_modules/next/dist/server/route-modules/app-page/vendored/ssr/react.js [app-ssr] (ecmascript)");
var __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$navigation$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[project]/Documents/00 - projet/afropreunariat/node_modules/next/navigation.js [app-ssr] (ecmascript)");
var __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$client$2f$app$2d$dir$2f$link$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[project]/Documents/00 - projet/afropreunariat/node_modules/next/dist/client/app-dir/link.js [app-ssr] (ecmascript)");
var __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$components$2f$UserProvider$2e$tsx__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[project]/Documents/00 - projet/afropreunariat/components/UserProvider.tsx [app-ssr] (ecmascript)");
"use client";
;
;
;
;
;
const LoginPage = ()=>{
const { login } = (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$components$2f$UserProvider$2e$tsx__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["useUser"])();
const [email, setEmail] = (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$ssr$2f$react$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["useState"])('');
const [password, setPassword] = (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$ssr$2f$react$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["useState"])('');
const [error, setError] = (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$ssr$2f$react$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["useState"])('');
const [loading, setLoading] = (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$ssr$2f$react$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["useState"])(false);
const router = (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$navigation$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["useRouter"])();
const handleSubmit = (e)=>{
const handleSubmit = async (e)=>{
e.preventDefault();
// Mock login
login();
setLoading(true);
setError('');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email,
password
})
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Erreur lors de la connexion');
}
// Real login with user data from DB
login(data.user);
router.push('/dashboard');
} catch (err) {
setError(err.message);
} finally{
setLoading(false);
}
};
return /*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$ssr$2f$react$2d$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["jsxDEV"])("div", {
className: "min-h-[80vh] flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8",
@@ -39,7 +65,7 @@ const LoginPage = ()=>{
children: "A"
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 25,
lineNumber: 49,
columnNumber: 21
}, ("TURBOPACK compile-time value", void 0)),
/*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$ssr$2f$react$2d$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["jsxDEV"])("h2", {
@@ -47,38 +73,46 @@ const LoginPage = ()=>{
children: "Connexion à votre espace"
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 26,
lineNumber: 50,
columnNumber: 21
}, ("TURBOPACK compile-time value", void 0)),
/*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$ssr$2f$react$2d$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["jsxDEV"])("p", {
className: "mt-2 text-sm text-gray-600",
children: [
"Ou ",
/*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$ssr$2f$react$2d$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["jsxDEV"])("a", {
href: "#",
/*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$ssr$2f$react$2d$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["jsxDEV"])(__TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$client$2f$app$2d$dir$2f$link$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["default"], {
href: "/register",
className: "font-medium text-brand-600 hover:text-brand-500",
children: "créez votre compte entreprise pour 1€"
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 28,
lineNumber: 52,
columnNumber: 28
}, ("TURBOPACK compile-time value", void 0))
]
}, void 0, true, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 27,
lineNumber: 51,
columnNumber: 21
}, ("TURBOPACK compile-time value", void 0))
]
}, void 0, true, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 24,
lineNumber: 48,
columnNumber: 17
}, ("TURBOPACK compile-time value", void 0)),
/*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$ssr$2f$react$2d$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["jsxDEV"])("form", {
className: "mt-8 space-y-6",
onSubmit: handleSubmit,
children: [
error && /*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$ssr$2f$react$2d$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["jsxDEV"])("div", {
className: "bg-red-50 border-l-4 border-red-500 p-4 rounded text-red-700 text-sm",
children: error
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 57,
columnNumber: 25
}, ("TURBOPACK compile-time value", void 0)),
/*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$ssr$2f$react$2d$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["jsxDEV"])("div", {
className: "rounded-md shadow-sm -space-y-px",
children: [
@@ -92,12 +126,12 @@ const LoginPage = ()=>{
onChange: (e)=>setEmail(e.target.value)
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 34,
lineNumber: 63,
columnNumber: 29
}, ("TURBOPACK compile-time value", void 0))
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 33,
lineNumber: 62,
columnNumber: 25
}, ("TURBOPACK compile-time value", void 0)),
/*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$ssr$2f$react$2d$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["jsxDEV"])("div", {
@@ -110,58 +144,51 @@ const LoginPage = ()=>{
onChange: (e)=>setPassword(e.target.value)
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 44,
lineNumber: 73,
columnNumber: 29
}, ("TURBOPACK compile-time value", void 0))
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 43,
lineNumber: 72,
columnNumber: 25
}, ("TURBOPACK compile-time value", void 0))
]
}, void 0, true, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 32,
lineNumber: 61,
columnNumber: 21
}, ("TURBOPACK compile-time value", void 0)),
/*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$ssr$2f$react$2d$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["jsxDEV"])("div", {
children: /*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$ssr$2f$react$2d$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["jsxDEV"])("button", {
type: "submit",
className: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500",
children: "Se connecter"
disabled: loading,
className: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 disabled:opacity-70",
children: loading ? 'Connexion...' : 'Se connecter'
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 55,
lineNumber: 84,
columnNumber: 25
}, ("TURBOPACK compile-time value", void 0))
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 54,
columnNumber: 21
}, ("TURBOPACK compile-time value", void 0)),
/*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$server$2f$route$2d$modules$2f$app$2d$page$2f$vendored$2f$ssr$2f$react$2d$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$ssr$5d$__$28$ecmascript$29$__["jsxDEV"])("div", {
className: "text-sm text-center text-gray-500",
children: "(Compte démo : n'importe quel email/mdp fonctionne)"
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 59,
lineNumber: 83,
columnNumber: 21
}, ("TURBOPACK compile-time value", void 0))
]
}, void 0, true, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 31,
lineNumber: 55,
columnNumber: 17
}, ("TURBOPACK compile-time value", void 0))
]
}, void 0, true, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 23,
lineNumber: 47,
columnNumber: 13
}, ("TURBOPACK compile-time value", void 0))
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 22,
lineNumber: 46,
columnNumber: 9
}, ("TURBOPACK compile-time value", void 0));
};

View File

@@ -1,7 +1,7 @@
(globalThis.TURBOPACK_CHUNK_LISTS || (globalThis.TURBOPACK_CHUNK_LISTS = [])).push({
script: typeof document === "object" ? document.currentScript : undefined,
chunks: [
"static/chunks/Documents_00 - projet_afropreunariat_71913515._.js",
"static/chunks/Documents_00 - projet_afropreunariat_19ee5171._.js",
"static/chunks/Documents_00 - projet_afropreunariat_app_globals_062f25c8.css"
],
source: "dynamic"

View File

@@ -9,6 +9,7 @@ __turbopack_context__.s([
var __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2f$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[project]/Documents/00 - projet/afropreunariat/node_modules/next/dist/compiled/react/jsx-dev-runtime.js [app-client] (ecmascript)");
var __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2f$index$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[project]/Documents/00 - projet/afropreunariat/node_modules/next/dist/compiled/react/index.js [app-client] (ecmascript)");
var __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$navigation$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[project]/Documents/00 - projet/afropreunariat/node_modules/next/navigation.js [app-client] (ecmascript)");
var __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$client$2f$app$2d$dir$2f$link$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[project]/Documents/00 - projet/afropreunariat/node_modules/next/dist/client/app-dir/link.js [app-client] (ecmascript)");
var __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$components$2f$UserProvider$2e$tsx__$5b$app$2d$client$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[project]/Documents/00 - projet/afropreunariat/components/UserProvider.tsx [app-client] (ecmascript)");
;
var _s = __turbopack_context__.k.signature();
@@ -16,17 +17,42 @@ var _s = __turbopack_context__.k.signature();
;
;
;
;
const LoginPage = ()=>{
_s();
const { login } = (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$components$2f$UserProvider$2e$tsx__$5b$app$2d$client$5d$__$28$ecmascript$29$__["useUser"])();
const [email, setEmail] = (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2f$index$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["useState"])('');
const [password, setPassword] = (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2f$index$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["useState"])('');
const [error, setError] = (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2f$index$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["useState"])('');
const [loading, setLoading] = (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2f$index$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["useState"])(false);
const router = (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$navigation$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["useRouter"])();
const handleSubmit = (e)=>{
const handleSubmit = async (e)=>{
e.preventDefault();
// Mock login
login();
setLoading(true);
setError('');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email,
password
})
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Erreur lors de la connexion');
}
// Real login with user data from DB
login(data.user);
router.push('/dashboard');
} catch (err) {
setError(err.message);
} finally{
setLoading(false);
}
};
return /*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2f$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["jsxDEV"])("div", {
className: "min-h-[80vh] flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8",
@@ -41,7 +67,7 @@ const LoginPage = ()=>{
children: "A"
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 25,
lineNumber: 49,
columnNumber: 21
}, ("TURBOPACK compile-time value", void 0)),
/*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2f$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["jsxDEV"])("h2", {
@@ -49,38 +75,46 @@ const LoginPage = ()=>{
children: "Connexion à votre espace"
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 26,
lineNumber: 50,
columnNumber: 21
}, ("TURBOPACK compile-time value", void 0)),
/*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2f$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["jsxDEV"])("p", {
className: "mt-2 text-sm text-gray-600",
children: [
"Ou ",
/*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2f$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["jsxDEV"])("a", {
href: "#",
/*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2f$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["jsxDEV"])(__TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$client$2f$app$2d$dir$2f$link$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["default"], {
href: "/register",
className: "font-medium text-brand-600 hover:text-brand-500",
children: "créez votre compte entreprise pour 1€"
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 28,
lineNumber: 52,
columnNumber: 28
}, ("TURBOPACK compile-time value", void 0))
]
}, void 0, true, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 27,
lineNumber: 51,
columnNumber: 21
}, ("TURBOPACK compile-time value", void 0))
]
}, void 0, true, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 24,
lineNumber: 48,
columnNumber: 17
}, ("TURBOPACK compile-time value", void 0)),
/*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2f$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["jsxDEV"])("form", {
className: "mt-8 space-y-6",
onSubmit: handleSubmit,
children: [
error && /*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2f$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["jsxDEV"])("div", {
className: "bg-red-50 border-l-4 border-red-500 p-4 rounded text-red-700 text-sm",
children: error
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 57,
columnNumber: 25
}, ("TURBOPACK compile-time value", void 0)),
/*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2f$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["jsxDEV"])("div", {
className: "rounded-md shadow-sm -space-y-px",
children: [
@@ -94,12 +128,12 @@ const LoginPage = ()=>{
onChange: (e)=>setEmail(e.target.value)
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 34,
lineNumber: 63,
columnNumber: 29
}, ("TURBOPACK compile-time value", void 0))
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 33,
lineNumber: 62,
columnNumber: 25
}, ("TURBOPACK compile-time value", void 0)),
/*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2f$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["jsxDEV"])("div", {
@@ -112,62 +146,55 @@ const LoginPage = ()=>{
onChange: (e)=>setPassword(e.target.value)
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 44,
lineNumber: 73,
columnNumber: 29
}, ("TURBOPACK compile-time value", void 0))
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 43,
lineNumber: 72,
columnNumber: 25
}, ("TURBOPACK compile-time value", void 0))
]
}, void 0, true, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 32,
lineNumber: 61,
columnNumber: 21
}, ("TURBOPACK compile-time value", void 0)),
/*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2f$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["jsxDEV"])("div", {
children: /*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2f$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["jsxDEV"])("button", {
type: "submit",
className: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500",
children: "Se connecter"
disabled: loading,
className: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 disabled:opacity-70",
children: loading ? 'Connexion...' : 'Se connecter'
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 55,
lineNumber: 84,
columnNumber: 25
}, ("TURBOPACK compile-time value", void 0))
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 54,
columnNumber: 21
}, ("TURBOPACK compile-time value", void 0)),
/*#__PURE__*/ (0, __TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$dist$2f$compiled$2f$react$2f$jsx$2d$dev$2d$runtime$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["jsxDEV"])("div", {
className: "text-sm text-center text-gray-500",
children: "(Compte démo : n'importe quel email/mdp fonctionne)"
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 59,
lineNumber: 83,
columnNumber: 21
}, ("TURBOPACK compile-time value", void 0))
]
}, void 0, true, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 31,
lineNumber: 55,
columnNumber: 17
}, ("TURBOPACK compile-time value", void 0))
]
}, void 0, true, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 23,
lineNumber: 47,
columnNumber: 13
}, ("TURBOPACK compile-time value", void 0))
}, void 0, false, {
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
lineNumber: 22,
lineNumber: 46,
columnNumber: 9
}, ("TURBOPACK compile-time value", void 0));
};
_s(LoginPage, "qGJtimaX2V4ZJh9/GR5AzR7oWcs=", false, function() {
_s(LoginPage, "nG8y4kb2uOxnA1iKpg1TA+sZcpM=", false, function() {
return [
__TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$components$2f$UserProvider$2e$tsx__$5b$app$2d$client$5d$__$28$ecmascript$29$__["useUser"],
__TURBOPACK__imported__module__$5b$project$5d2f$Documents$2f$00__$2d$__projet$2f$afropreunariat$2f$node_modules$2f$next$2f$navigation$2e$js__$5b$app$2d$client$5d$__$28$ecmascript$29$__["useRouter"]

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,8 @@
// This file is generated automatically by Next.js
// Do not edit this file manually
type AppRoutes = "/" | "/afrolife" | "/afrolife/[id]" | "/blog" | "/blog/[id]" | "/dashboard" | "/directory" | "/directory/[id]" | "/login" | "/subscription"
type AppRouteHandlerRoutes = "/api/blog" | "/api/blog/[id]" | "/api/businesses" | "/api/businesses/[id]" | "/api/gemini" | "/api/interviews" | "/api/interviews/[id]" | "/api/offers" | "/api/offers/[id]" | "/api/users"
type AppRoutes = "/" | "/afrolife" | "/afrolife/[id]" | "/blog" | "/blog/[id]" | "/dashboard" | "/directory" | "/directory/[id]" | "/login" | "/register" | "/subscription"
type AppRouteHandlerRoutes = "/api/analytics" | "/api/auth/login" | "/api/auth/register" | "/api/blog" | "/api/blog/[id]" | "/api/businesses" | "/api/businesses/[id]" | "/api/businesses/[id]/view" | "/api/businesses/me" | "/api/gemini" | "/api/interviews" | "/api/interviews/[id]" | "/api/offers" | "/api/offers/[id]" | "/api/users"
type PageRoutes = never
type LayoutRoutes = "/"
type RedirectRoutes = never
@@ -14,10 +14,15 @@ interface ParamMap {
"/": {}
"/afrolife": {}
"/afrolife/[id]": { "id": string; }
"/api/analytics": {}
"/api/auth/login": {}
"/api/auth/register": {}
"/api/blog": {}
"/api/blog/[id]": { "id": string; }
"/api/businesses": {}
"/api/businesses/[id]": { "id": string; }
"/api/businesses/[id]/view": { "id": string; }
"/api/businesses/me": {}
"/api/gemini": {}
"/api/interviews": {}
"/api/interviews/[id]": { "id": string; }
@@ -30,6 +35,7 @@ interface ParamMap {
"/directory": {}
"/directory/[id]": { "id": string; }
"/login": {}
"/register": {}
"/subscription": {}
}

View File

@@ -128,6 +128,15 @@ type RouteHandlerConfig<Route extends AppRouteHandlerRoutes = AppRouteHandlerRou
type __Unused = __Check
}
// Validate ../../../app/register/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/register">> = Specific
const handler = {} as typeof import("../../../app/register/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../../app/subscription/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/subscription">> = Specific
@@ -137,6 +146,33 @@ type RouteHandlerConfig<Route extends AppRouteHandlerRoutes = AppRouteHandlerRou
type __Unused = __Check
}
// Validate ../../../app/api/analytics/route.ts
{
type __IsExpected<Specific extends RouteHandlerConfig<"/api/analytics">> = Specific
const handler = {} as typeof import("../../../app/api/analytics/route.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../../app/api/auth/login/route.ts
{
type __IsExpected<Specific extends RouteHandlerConfig<"/api/auth/login">> = Specific
const handler = {} as typeof import("../../../app/api/auth/login/route.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../../app/api/auth/register/route.ts
{
type __IsExpected<Specific extends RouteHandlerConfig<"/api/auth/register">> = Specific
const handler = {} as typeof import("../../../app/api/auth/register/route.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../../app/api/blog/[id]/route.ts
{
type __IsExpected<Specific extends RouteHandlerConfig<"/api/blog/[id]">> = Specific
@@ -164,6 +200,24 @@ type RouteHandlerConfig<Route extends AppRouteHandlerRoutes = AppRouteHandlerRou
type __Unused = __Check
}
// Validate ../../../app/api/businesses/[id]/view/route.ts
{
type __IsExpected<Specific extends RouteHandlerConfig<"/api/businesses/[id]/view">> = Specific
const handler = {} as typeof import("../../../app/api/businesses/[id]/view/route.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../../app/api/businesses/me/route.ts
{
type __IsExpected<Specific extends RouteHandlerConfig<"/api/businesses/me">> = Specific
const handler = {} as typeof import("../../../app/api/businesses/me/route.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../../app/api/businesses/route.ts
{
type __IsExpected<Specific extends RouteHandlerConfig<"/api/businesses">> = Specific

41
admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
admin/AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
admin/CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

36
admin/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

18
admin/eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
admin/next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

7845
admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
admin/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@prisma/adapter-pg": "^7.7.0",
"@prisma/client": "^7.7.0",
"@prisma/config": "^7.7.0",
"@types/bcryptjs": "^2.4.6",
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.1",
"lucide-react": "^1.8.0",
"next": "16.2.3",
"pg": "^8.20.0",
"prisma": "^7.7.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv-cli": "^11.0.0",
"eslint": "^9",
"eslint-config-next": "16.2.3",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
admin/postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

13
admin/prisma.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import path from 'node:path'
import { config as loadEnv } from 'dotenv'
import { defineConfig, env } from '@prisma/config'
// Load .env
loadEnv({ path: '.env' })
export default defineConfig({
schema: path.join('prisma', 'schema.prisma'),
datasource: {
url: env('DATABASE_URL'),
},
})

147
admin/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,147 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
enum UserRole {
VISITOR
ENTREPRENEUR
ADMIN
}
model User {
id String @id @default(uuid())
name String
email String @unique
password String
role UserRole @default(VISITOR)
avatar String?
businesses Business[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Business {
id String @id @default(uuid())
name String
category String
location String
description String @db.Text
logoUrl String
videoUrl String?
// Contact & Social
contactEmail String
contactPhone String?
// We use JSON to store social links (facebook, linkedin, instagram, website)
socialLinks Json?
// Stats
verified Boolean @default(false)
viewCount Int @default(0)
rating Float @default(0.0)
tags String[]
// Entrepreneur of the Month fields
isFeatured Boolean @default(false)
founderName String?
founderImageUrl String?
keyMetric String?
// Relations
ownerId String @unique
owner User @relation(fields: [ownerId], references: [id])
offers Offer[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum OfferType {
PRODUCT
SERVICE
}
model Offer {
id String @id @default(uuid())
title String
type OfferType
price Float
currency String @default("XOF") // EUR or XOF
description String? @db.Text
imageUrl String
active Boolean @default(true)
// Relations
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model BlogPost {
id String @id @default(uuid())
title String
excerpt String @db.Text
content String @db.Text
author String
date DateTime @default(now())
imageUrl String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum InterviewType {
VIDEO
ARTICLE
}
model Interview {
id String @id @default(uuid())
title String
guestName String
companyName String
role String
type InterviewType
thumbnailUrl String
videoUrl String?
content String? @db.Text
excerpt String @db.Text
date DateTime @default(now())
duration String? // "15 min" ou "5 min de lecture"
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Comment {
id String @id @default(uuid())
content String @db.Text
// Relations
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AnalyticsEvent {
id String @id @default(uuid())
type String // PAGE_VIEW, CLICK, DWELL_TIME
path String
label String? // For clicks (button text, link name)
value Float? // For dwell time (seconds)
metadata Json? // Browser, OS, etc.
createdAt DateTime @default(now())
}

1
admin/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
admin/public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
admin/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
admin/public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
admin/public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,58 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function createBlogPost(data: {
title: string;
excerpt: string;
content: string;
author: string;
imageUrl: string;
}) {
try {
const post = await prisma.blogPost.create({
data: {
...data,
},
});
revalidatePath("/blog");
return { success: true, data: post };
} catch (error) {
console.error("Failed to create blog post:", error);
return { success: false, error: "Erreur lors de la création de l'article" };
}
}
export async function updateBlogPost(id: string, data: {
title: string;
excerpt: string;
content: string;
author: string;
imageUrl: string;
}) {
try {
const post = await prisma.blogPost.update({
where: { id },
data,
});
revalidatePath("/blog");
return { success: true, data: post };
} catch (error) {
console.error("Failed to update blog post:", error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}
export async function deleteBlogPost(id: string) {
try {
await prisma.blogPost.delete({
where: { id },
});
revalidatePath("/blog");
return { success: true };
} catch (error) {
console.error("Failed to delete blog post:", error);
return { success: false, error: "Erreur lors de la suppression" };
}
}

View File

@@ -0,0 +1,63 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function toggleVerification(id: string, currentStatus: boolean) {
try {
await prisma.business.update({
where: { id },
data: { verified: !currentStatus },
});
revalidatePath("/entrepreneurs");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Failed to toggle verification:", error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}
export async function setFeaturedBusiness(id: string, data: {
founderName: string;
founderImageUrl: string;
keyMetric: string;
}) {
try {
// 1. Reset all others (we only want one entrepreneur of the month)
await prisma.business.updateMany({
where: { isFeatured: true },
data: { isFeatured: false },
});
// 2. Set the new one
await prisma.business.update({
where: { id },
data: {
isFeatured: true,
...data,
},
});
revalidatePath("/entrepreneurs");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Failed to set featured business:", error);
return { success: false, error: "Erreur lors de la mise à jour de l'entrepreneur du mois" };
}
}
export async function removeFeaturedBusiness(id: string) {
try {
await prisma.business.update({
where: { id },
data: { isFeatured: false },
});
revalidatePath("/entrepreneurs");
return { success: true };
} catch (error) {
console.error("Failed to remove featured status:", error);
return { success: false, error: "Erreur lors de la suppression" };
}
}

View File

@@ -0,0 +1,18 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function deleteComment(id: string) {
try {
await prisma.comment.delete({
where: { id },
});
revalidatePath("/comments");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Failed to delete comment:", error);
return { success: false, error: "Erreur lors de la suppression" };
}
}

View File

@@ -0,0 +1,67 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { InterviewType } from "@prisma/client";
export async function createInterview(data: {
title: string;
guestName: string;
companyName: string;
role: string;
type: InterviewType;
thumbnailUrl: string;
videoUrl?: string;
content?: string;
excerpt: string;
duration?: string;
}) {
try {
const interview = await prisma.interview.create({
data,
});
revalidatePath("/blog");
return { success: true, data: interview };
} catch (error) {
console.error("Failed to create interview:", error);
return { success: false, error: "Erreur lors de la création de l'interview" };
}
}
export async function updateInterview(id: string, data: {
title: string;
guestName: string;
companyName: string;
role: string;
type: InterviewType;
thumbnailUrl: string;
videoUrl?: string;
content?: string;
excerpt: string;
duration?: string;
}) {
try {
const interview = await prisma.interview.update({
where: { id },
data,
});
revalidatePath("/blog");
return { success: true, data: interview };
} catch (error) {
console.error("Failed to update interview:", error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}
export async function deleteInterview(id: string) {
try {
await prisma.interview.delete({
where: { id },
});
revalidatePath("/blog");
return { success: true };
} catch (error) {
console.error("Failed to delete interview:", error);
return { success: false, error: "Erreur lors de la suppression" };
}
}

View File

@@ -0,0 +1,16 @@
import { prisma } from "@/lib/prisma";
import BlogForm from "@/components/BlogForm";
import { notFound } from "next/navigation";
export default async function EditBlogPage({ params }: { params: { id: string } }) {
const { id } = params;
const post = await prisma.blogPost.findUnique({
where: { id },
});
if (!post) {
notFound();
}
return <BlogForm initialData={post} />;
}

View File

@@ -0,0 +1,5 @@
import BlogForm from "@/components/BlogForm";
export default function NewBlogPage() {
return <BlogForm />;
}

125
admin/src/app/blog/page.tsx Normal file
View File

@@ -0,0 +1,125 @@
import { prisma } from '@/lib/prisma';
import Link from 'next/link';
import { Plus, BookOpen, Mic2, Trash2, Edit } from 'lucide-react';
import DeleteBlogButton from '@/components/DeleteBlogButton';
import DeleteInterviewButton from '@/components/DeleteInterviewButton';
async function getData() {
const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } });
const interviews = await prisma.interview.findMany({ orderBy: { createdAt: 'desc' } });
return { posts, interviews };
}
export default async function BlogCMSPage() {
const { posts, interviews } = await getData();
return (
<div>
<div className="flex justify-between items-end mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">CMS Blog & Interviews</h1>
<p className="text-slate-400">Gérez le contenu éditorial de la plateforme.</p>
</div>
<div className="flex gap-4">
<Link href="/blog/new" className="btn-verify flex items-center gap-2">
<Plus className="w-4 h-4" />
Nouvel Article
</Link>
<Link href="/interview/new" className="btn-verify bg-indigo-600 flex items-center gap-2">
<Plus className="w-4 h-4" />
Nouvelle Interview
</Link>
</div>
</div>
<div className="grid grid-cols-1 gap-8">
{/* Blog Posts Section */}
<div className="card">
<div className="flex items-center gap-2 mb-6">
<BookOpen className="text-indigo-400 w-5 h-5" />
<h2 className="text-xl font-bold text-white">Articles de Blog ({posts.length})</h2>
</div>
<div className="overflow-x-auto">
<table className="data-table">
<thead>
<tr>
<th>Titre</th>
<th>Auteur</th>
<th>Date</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody>
{posts.map((post) => (
<tr key={post.id}>
<td>
<div className="font-medium text-white">{post.title}</div>
<div className="text-xs text-slate-500 truncate max-w-xs">{post.excerpt}</div>
</td>
<td className="text-sm text-slate-300">{post.author}</td>
<td className="text-sm text-slate-500">{new Date(post.createdAt).toLocaleDateString('fr-FR')}</td>
<td className="text-right">
<div className="flex justify-end gap-2">
<Link href={`/blog/edit/${post.id}`} className="p-2 text-slate-400 hover:text-indigo-400">
<Edit className="w-5 h-5" />
</Link>
<DeleteBlogButton id={post.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Interviews Section */}
<div className="card">
<div className="flex items-center gap-2 mb-6">
<Mic2 className="text-emerald-400 w-5 h-5" />
<h2 className="text-xl font-bold text-white">Interviews ({interviews.length})</h2>
</div>
<div className="overflow-x-auto">
<table className="data-table">
<thead>
<tr>
<th>Interviewé</th>
<th>Entreprise / Rôle</th>
<th>Type</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody>
{interviews.map((interview) => (
<tr key={interview.id}>
<td>
<div className="font-medium text-white">{interview.guestName}</div>
<div className="text-xs text-slate-500 truncate max-w-xs">{interview.title}</div>
</td>
<td>
<div className="text-sm text-slate-300">{interview.companyName}</div>
<div className="text-xs text-slate-500">{interview.role}</div>
</td>
<td>
<span className={`badge ${interview.type === 'VIDEO' ? 'badge-verified' : 'badge-pending'}`}>
{interview.type}
</span>
</td>
<td className="text-right">
<div className="flex justify-end gap-2">
<Link href={`/interview/edit/${interview.id}`} className="p-2 text-slate-400 hover:text-emerald-400">
<Edit className="w-5 h-5" />
</Link>
<DeleteInterviewButton id={interview.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { prisma } from '@/lib/prisma';
import DeleteCommentButton from '@/components/DeleteCommentButton';
async function getComments() {
return await prisma.comment.findMany({
include: {
author: true,
business: true,
},
orderBy: {
createdAt: 'desc',
},
});
}
export default async function CommentsPage() {
const comments = await getComments();
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Modération des Commentaires</h1>
<p className="text-slate-400">Surveiller et supprimer les commentaires inappropriés.</p>
</div>
<div className="card overflow-hidden p-0">
<table className="data-table">
<thead>
<tr>
<th>Auteur</th>
<th>Entreprise</th>
<th>Contenu</th>
<th>Date</th>
<th className="text-right">Action</th>
</tr>
</thead>
<tbody>
{comments.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-10 text-slate-500">
Aucun commentaire trouvé.
</td>
</tr>
) : (
comments.map((comment) => (
<tr key={comment.id}>
<td>
<div className="font-medium text-white">{comment.author.name}</div>
<div className="text-xs text-slate-500">{comment.author.email}</div>
</td>
<td>
<div className="text-sm text-indigo-400">{comment.business.name}</div>
</td>
<td className="max-w-md">
<p className="text-sm text-slate-300 truncate" title={comment.content}>
{comment.content}
</p>
</td>
<td>
<div className="text-sm text-slate-500">
{new Date(comment.createdAt).toLocaleDateString('fr-FR')}
</div>
</td>
<td className="text-right">
<DeleteCommentButton id={comment.id} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { prisma } from '@/lib/prisma';
import {
Users,
Store,
Clock,
MessageCircle,
Eye
} from 'lucide-react';
async function getStats() {
const usersCount = await prisma.user.count();
const businessCount = await prisma.business.count();
const pendingCount = await prisma.business.count({ where: { verified: false } });
const commentsCount = await prisma.comment.count();
// Aggregate views
const aggregateResult = await prisma.business.aggregate({
_sum: {
viewCount: true
}
});
const totalViews = aggregateResult._sum.viewCount || 0;
return { usersCount, businessCount, pendingCount, commentsCount, totalViews };
}
export default async function DashboardPage() {
const stats = await getStats();
const statCards = [
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
{ label: 'Entrepreneurs', value: stats.businessCount, icon: Store, color: 'text-emerald-400' },
{ label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' },
{ label: 'Commentaires', value: stats.commentsCount, icon: MessageCircle, color: 'text-purple-400' },
{ label: 'Total Vues', value: stats.totalViews, icon: Eye, color: 'text-brand-400' },
];
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Tableau de bord</h1>
<p className="text-slate-400">Vue d'ensemble de l'activité sur la plateforme.</p>
</div>
<div className="stats-grid">
{statCards.map((card) => (
<div key={card.label} className="card">
<div className="flex justify-between items-start mb-4">
<div className={`p-2 rounded-lg bg-slate-800 ${card.color}`}>
<card.icon className="w-6 h-6" />
</div>
<span className="text-2xl font-bold text-white">{card.value}</span>
</div>
<h3 className="text-slate-400 font-medium">{card.label}</h3>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="card">
<h2 className="text-xl font-bold text-white mb-4">Derniers entrepreneurs</h2>
<div className="space-y-4">
<p className="text-slate-500 italic">Bientôt disponible...</p>
</div>
</div>
<div className="card">
<h2 className="text-xl font-bold text-white mb-4">Activité récente</h2>
<div className="space-y-4">
<p className="text-slate-500 italic">Bientôt disponible...</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { prisma } from '@/lib/prisma';
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
import FeaturedModal from '@/components/FeaturedModal';
async function getBusinesses() {
return await prisma.business.findMany({
include: {
owner: true,
},
orderBy: {
createdAt: 'desc',
},
});
}
export default async function EntrepreneursPage() {
const businesses = await getBusinesses();
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Entrepreneurs</h1>
<p className="text-slate-400">Valider ou révoquer les comptes des entreprises.</p>
</div>
<div className="card overflow-hidden p-0">
<table className="data-table">
<thead>
<tr>
<th>Entreprise</th>
<th>Catégorie</th>
<th>Propriétaire</th>
<th>Statut</th>
<th className="text-center">Vues</th>
<th className="text-center">Mise en avant</th>
<th className="text-right">Action</th>
</tr>
</thead>
<tbody>
{businesses.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-10 text-slate-500">
Aucun entrepreneur trouvé.
</td>
</tr>
) : (
businesses.map((business) => (
<tr key={business.id}>
<td>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center overflow-hidden">
{business.logoUrl ? (
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover" />
) : (
<span className="text-xs text-slate-500">LOGO</span>
)}
</div>
<div>
<div className="font-semibold text-white">{business.name}</div>
<div className="text-xs text-slate-500">{business.location}</div>
</div>
</div>
</td>
<td>
<span className="text-sm text-slate-300">{business.category}</span>
</td>
<td>
<div className="text-sm text-white">{business.owner.name}</div>
<div className="text-xs text-slate-500">{business.owner.email}</div>
</td>
<td>
{business.verified ? (
<span className="badge badge-verified">Vérifié</span>
) : (
<span className="badge badge-pending">En attente</span>
)}
</td>
<td className="text-center font-medium text-slate-300">
{business.viewCount.toLocaleString()}
</td>
<td className="text-center">
<FeaturedModal business={business} />
</td>
<td className="text-right">
<ToggleVerifyButton id={business.id} verified={business.verified} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

BIN
admin/src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

195
admin/src/app/globals.css Normal file
View File

@@ -0,0 +1,195 @@
@import "tailwindcss";
:root {
--background: #0f172a;
--foreground: #f8fafc;
--primary: #6366f1;
--secondary: #1e293b;
--accent: #10b981;
--muted: #64748b;
--border: #334155;
--card: #1e293b;
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: 'Inter', sans-serif;
margin: 0;
padding: 0;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--background);
}
::-webkit-scrollbar-thumb {
background: var(--secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border);
}
.admin-sidebar {
width: 260px;
background-color: var(--card);
border-right: 1px solid var(--border);
height: 100vh;
position: fixed;
left: 0;
top: 0;
}
.admin-content {
margin-left: 260px;
padding: 2rem;
min-height: 100vh;
}
.nav-link {
display: flex;
align-items: center;
padding: 0.75rem 1.5rem;
color: var(--muted);
text-decoration: none;
transition: all 0.2s;
border-radius: 0.5rem;
margin: 0.25rem 1rem;
}
.nav-link:hover, .nav-link.active {
background-color: var(--secondary);
color: var(--foreground);
}
.nav-link.active {
color: var(--primary);
background-color: rgba(99, 102, 241, 0.1);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.card {
background-color: var(--card);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 1.5rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
}
.data-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.data-table th {
text-align: left;
padding: 1rem;
border-bottom: 1px solid var(--border);
color: var(--muted);
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
}
.data-table td {
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.btn-verify {
background-color: var(--accent);
color: white;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: opacity 0.2s;
}
.btn-verify:hover {
opacity: 0.9;
}
.btn-unverify {
background-color: #ef4444;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: opacity 0.2s;
}
.btn-unverify:hover {
opacity: 0.9;
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-verified {
background-color: rgba(16, 185, 129, 0.1);
color: var(--accent);
}
.badge-pending {
background-color: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
/* Modals */
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: 1rem;
}
.modal-content {
background-color: var(--card);
border: 1px solid var(--border);
border-radius: 1rem;
width: 100%;
max-width: 500px;
padding: 2rem;
position: relative;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
}
.btn-star {
color: var(--muted);
transition: color 0.2s;
}
.btn-star:hover, .btn-star.active {
color: #f59e0b;
}

View File

@@ -0,0 +1,16 @@
import { prisma } from "@/lib/prisma";
import InterviewForm from "@/components/InterviewForm";
import { notFound } from "next/navigation";
export default async function EditInterviewPage({ params }: { params: { id: string } }) {
const { id } = params;
const interview = await prisma.interview.findUnique({
where: { id },
});
if (!interview) {
notFound();
}
return <InterviewForm initialData={interview} />;
}

View File

@@ -0,0 +1,5 @@
import InterviewForm from "@/components/InterviewForm";
export default function NewInterviewPage() {
return <InterviewForm />;
}

30
admin/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Sidebar from "@/components/Sidebar";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "AfroAdmin - Administration Afropreunariat",
description: "Panneau d'administration pour la plateforme Afropreunariat",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr">
<body className={inter.className}>
<div className="flex min-h-screen">
<Sidebar />
<main className="admin-content flex-1">
{children}
</main>
</div>
</body>
</html>
);
}

View File

@@ -0,0 +1,205 @@
import { prisma } from '@/lib/prisma';
import {
BarChart3,
MousePointer2,
Clock,
Eye,
ArrowUpRight,
TrendingUp
} from 'lucide-react';
async function getMetrics() {
// 1. Top Pages
const topPages = await prisma.analyticsEvent.groupBy({
by: ['path'],
where: { type: 'PAGE_VIEW' },
_count: {
id: true
},
orderBy: {
_count: {
id: 'desc'
}
},
take: 10
});
// 2. Top Clicks
const topClicks = await prisma.analyticsEvent.groupBy({
by: ['label'],
where: { type: 'CLICK' },
_count: {
id: true
},
orderBy: {
_count: {
id: 'desc'
}
},
take: 10
});
// 3. Average Dwell Time
const dwellTime = await prisma.analyticsEvent.groupBy({
by: ['path'],
where: { type: 'DWELL_TIME' },
_avg: {
value: true
},
_count: {
id: true
},
orderBy: {
_avg: {
value: 'desc'
}
},
take: 10
});
// 4. Global Stats
const totalEvents = await prisma.analyticsEvent.count();
const uniquePaths = (await prisma.analyticsEvent.groupBy({ by: ['path'] })).length;
return { topPages, topClicks, dwellTime, totalEvents, uniquePaths };
}
export default async function MetricsPage() {
const { topPages, topClicks, dwellTime, totalEvents, uniquePaths } = await getMetrics();
return (
<div className="space-y-8">
<div className="flex justify-between items-end">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Metrics & Analytics</h1>
<p className="text-slate-400">Comportement des utilisateurs et engagement en temps réel.</p>
</div>
<div className="bg-slate-800/50 border border-slate-700 px-4 py-2 rounded-lg text-xs font-mono text-slate-400">
Capture : <span className="text-emerald-400">{totalEvents.toLocaleString()}</span> événements total
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="card">
<div className="flex justify-between items-start mb-4">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-400">
<Eye className="w-6 h-6" />
</div>
<span className="text-2xl font-bold text-white">{uniquePaths}</span>
</div>
<h3 className="text-slate-400 font-medium">Pages uniques explorées</h3>
</div>
<div className="card">
<div className="flex justify-between items-start mb-4">
<div className="p-2 rounded-lg bg-pink-500/10 text-pink-400">
<MousePointer2 className="w-6 h-6" />
</div>
<span className="text-2xl font-bold text-white">
{topClicks.reduce((acc, curr) => acc + curr._count.id, 0)}
</span>
</div>
<h3 className="text-slate-400 font-medium">Clicks enregistrés (Top 10)</h3>
</div>
<div className="card">
<div className="flex justify-between items-start mb-4">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-400">
<Clock className="w-6 h-6" />
</div>
<span className="text-2xl font-bold text-white">
{Math.round(dwellTime.reduce((acc, curr) => acc + (curr._avg.value || 0), 0) / (dwellTime.length || 1))}s
</span>
</div>
<h3 className="text-slate-400 font-medium">Temps moyen / page</h3>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Top Pages Table */}
<div className="card p-0 overflow-hidden">
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-emerald-400" /> Pages les plus visitées
</h2>
</div>
<table className="w-full text-left">
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<tr>
<th className="px-6 py-4">URL / Chemin</th>
<th className="px-6 py-4 text-right">Vues</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{topPages.map((page) => (
<tr key={page.path} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4 text-sm font-mono text-slate-300">{page.path}</td>
<td className="px-6 py-4 text-right text-sm font-bold text-white">
{page._count.id}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Top Clicks Table */}
<div className="card p-0 overflow-hidden">
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<MousePointer2 className="w-5 h-5 text-indigo-400" /> Actions & Clicks
</h2>
</div>
<table className="w-full text-left">
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<tr>
<th className="px-6 py-4">Élément / Texte</th>
<th className="px-6 py-4 text-right">Interactions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{topClicks.map((click) => (
<tr key={click.label} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4 text-sm text-slate-300 truncate max-w-xs">{click.label || 'Sans label'}</td>
<td className="px-6 py-4 text-right text-sm font-bold text-white">
{click._count.id}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Dwell Time Table */}
<div className="card p-0 overflow-hidden lg:col-span-2">
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Clock className="w-5 h-5 text-amber-400" /> Temps d'attention par page
</h2>
</div>
<table className="w-full text-left">
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<tr>
<th className="px-6 py-4">Page</th>
<th className="px-6 py-4">Échantillon</th>
<th className="px-6 py-4 text-right">Moyenne de rétention</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{dwellTime.map((time) => (
<tr key={time.path} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4 text-sm font-mono text-slate-300">{time.path}</td>
<td className="px-6 py-4 text-xs text-slate-500">{time._count.id} sessions</td>
<td className="px-6 py-4 text-right">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20">
{Math.round(time._avg.value || 0)} secondes
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

5
admin/src/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/dashboard");
}

View File

@@ -0,0 +1,138 @@
"use client";
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { createBlogPost, updateBlogPost } from '@/app/actions/blog';
import { Loader2, ArrowLeft, Save } from 'lucide-react';
import Link from 'next/link';
interface Props {
initialData?: {
id: string;
title: string;
excerpt: string;
content: string;
author: string;
imageUrl: string;
};
}
export default function BlogForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const data = {
title: formData.get('title') as string,
excerpt: formData.get('excerpt') as string,
content: formData.get('content') as string,
author: formData.get('author') as string,
imageUrl: formData.get('imageUrl') as string,
};
startTransition(async () => {
const result = initialData
? await updateBlogPost(initialData.id, data)
: await createBlogPost(data);
if (result.success) {
router.push('/blog');
router.refresh();
} else {
alert(result.error);
}
});
};
return (
<div className="max-w-4xl mx-auto">
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
<h1 className="text-3xl font-bold text-white">
{initialData ? 'Modifier l\'article' : 'Nouvel Article'}
</h1>
</div>
</div>
<form onSubmit={handleSubmit} className="card space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Titre</label>
<input
name="title"
defaultValue={initialData?.title}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: L'essor de la Tech en Afrique"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Auteur</label>
<input
name="author"
defaultValue={initialData?.author}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Jean Dupont"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL de l'image</label>
<input
name="imageUrl"
defaultValue={initialData?.imageUrl}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="https://images.unsplash.com/..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Extrait (Excerpt)</label>
<textarea
name="excerpt"
defaultValue={initialData?.excerpt}
required
rows={2}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Un court résumé de l'article..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Contenu de l'article</label>
<textarea
name="content"
defaultValue={initialData?.content}
required
rows={12}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500 font-serif leading-relaxed"
placeholder="Rédigez votre article ici..."
/>
</div>
<div className="pt-4">
<button
type="submit"
disabled={isPending}
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg"
>
{isPending ? (
<Loader2 className="w-6 h-6 animate-spin" />
) : (
<Save className="w-6 h-6" />
)}
{initialData ? 'Enregistrer les modifications' : 'Publier l\'article'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { useTransition } from 'react';
import { deleteBlogPost } from '@/app/actions/blog';
import { Trash2, Loader2 } from 'lucide-react';
export default function DeleteBlogButton({ id }: { id: string }) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (confirm("Supprimer cet article ?")) {
startTransition(async () => {
const result = await deleteBlogPost(id);
if (!result.success) alert(result.error);
});
}
};
return (
<button onClick={handleDelete} disabled={isPending} className="p-2 text-slate-500 hover:text-red-400">
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
</button>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import { useTransition } from 'react';
import { deleteComment } from '@/app/actions/comment';
import { Trash2, Loader2 } from 'lucide-react';
interface Props {
id: string;
}
export default function DeleteCommentButton({ id }: Props) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (confirm("Êtes-vous sûr de vouloir supprimer ce commentaire ?")) {
startTransition(async () => {
const result = await deleteComment(id);
if (!result.success) {
alert(result.error);
}
});
}
};
return (
<button
onClick={handleDelete}
disabled={isPending}
className="p-2 text-slate-500 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-colors"
title="Supprimer"
>
{isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Trash2 className="w-5 h-5" />
)}
</button>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { useTransition } from 'react';
import { deleteInterview } from '@/app/actions/interview';
import { Trash2, Loader2 } from 'lucide-react';
export default function DeleteInterviewButton({ id }: { id: string }) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (confirm("Supprimer cette interview ?")) {
startTransition(async () => {
const result = await deleteInterview(id);
if (!result.success) alert(result.error);
});
}
};
return (
<button onClick={handleDelete} disabled={isPending} className="p-2 text-slate-500 hover:text-red-400">
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
</button>
);
}

View File

@@ -0,0 +1,140 @@
"use client";
import { useState, useTransition } from 'react';
import { setFeaturedBusiness, removeFeaturedBusiness } from '@/app/actions/business';
import { Star, X, Loader2, Save } from 'lucide-react';
interface Props {
business: {
id: string;
name: string;
isFeatured: boolean;
founderName?: string | null;
founderImageUrl?: string | null;
keyMetric?: string | null;
};
}
export default function FeaturedModal({ business }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const handleToggleFeature = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const data = {
founderName: formData.get('founderName') as string,
founderImageUrl: formData.get('founderImageUrl') as string,
keyMetric: formData.get('keyMetric') as string,
};
startTransition(async () => {
const result = await setFeaturedBusiness(business.id, data);
if (result.success) {
setIsOpen(false);
} else {
alert(result.error);
}
});
};
const handleRemove = () => {
if (confirm("Retirer cet entrepreneur du titre 'Entrepreneur du mois' ?")) {
startTransition(async () => {
const result = await removeFeaturedBusiness(business.id);
if (result.success) setIsOpen(false);
});
}
};
return (
<>
<button
onClick={() => setIsOpen(true)}
className={`px-3 py-1.5 rounded-lg border text-sm font-medium transition-all flex items-center gap-2 mx-auto ${
business.isFeatured
? 'bg-amber-500/10 border-amber-500/50 text-amber-500 hover:bg-amber-500/20'
: 'bg-slate-800 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-white'
}`}
>
<Star className={`w-4 h-4 ${business.isFeatured ? 'fill-amber-500' : ''}`} />
{business.isFeatured ? 'Vedette Active' : 'Mettre en avant'}
</button>
{isOpen && (
<div className="modal-overlay">
<div className="modal-content">
<button
onClick={() => setIsOpen(false)}
className="absolute top-4 right-4 text-slate-500 hover:text-white"
>
<X className="w-6 h-6" />
</button>
<div className="mb-6">
<h2 className="text-2xl font-bold text-white mb-2">💰 Entrepreneur du Mois</h2>
<p className="text-slate-400">Configurez les détails pour {business.name}.</p>
</div>
<form onSubmit={handleToggleFeature} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nom du fondateur</label>
<input
name="founderName"
defaultValue={business.founderName || ''}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
placeholder="Ex: Aliko Dangote"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL Photo du fondateur</label>
<input
name="founderImageUrl"
defaultValue={business.founderImageUrl || ''}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Métrique Clé (ex: Chiffre d'affaires, Impact)</label>
<input
name="keyMetric"
defaultValue={business.keyMetric || ''}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
placeholder="Ex: +500 emplois créés"
/>
</div>
<div className="pt-4 flex flex-col gap-3">
<button
type="submit"
disabled={isPending}
className="w-full bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold py-3 rounded-lg flex items-center justify-center gap-2 transition-colors"
>
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
Définir comme Entrepreneur du Mois
</button>
{business.isFeatured && (
<button
type="button"
onClick={handleRemove}
disabled={isPending}
className="w-full bg-slate-800 hover:bg-red-900/30 text-red-400 font-semibold py-3 rounded-lg transition-colors border border-red-900/50"
>
Révoquer le titre
</button>
)}
</div>
</form>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,187 @@
"use client";
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { createInterview, updateInterview } from '@/app/actions/interview';
import { Loader2, ArrowLeft, Save, Video, FileText } from 'lucide-react';
import Link from 'next/link';
import { InterviewType } from '@prisma/client';
interface Props {
initialData?: any;
}
export default function InterviewForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const data: any = {
title: formData.get('title') as string,
guestName: formData.get('guestName') as string,
companyName: formData.get('companyName') as string,
role: formData.get('role') as string,
type: formData.get('type') as InterviewType,
thumbnailUrl: formData.get('thumbnailUrl') as string,
videoUrl: formData.get('videoUrl') as string || null,
excerpt: formData.get('excerpt') as string,
content: formData.get('content') as string || null,
duration: formData.get('duration') as string || null,
};
startTransition(async () => {
const result = initialData
? await updateInterview(initialData.id, data)
: await createInterview(data);
if (result.success) {
router.push('/blog');
router.refresh();
} else {
alert(result.error);
}
});
};
return (
<div className="max-w-4xl mx-auto">
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
<h1 className="text-3xl font-bold text-white">
{initialData ? 'Modifier l\'interview' : 'Nouvelle Interview'}
</h1>
</div>
</div>
<form onSubmit={handleSubmit} className="card space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Titre de l'interview</label>
<input
name="title"
defaultValue={initialData?.title}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Entreprise X : Une success story..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nom de l'invité</label>
<input
name="guestName"
defaultValue={initialData?.guestName}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Sarah Koné"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Entreprise</label>
<input
name="companyName"
defaultValue={initialData?.companyName}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: TechAfrica"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Rôle / Poste</label>
<input
name="role"
defaultValue={initialData?.role}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Fondatrice & CEO"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Type d'interview</label>
<select
name="type"
defaultValue={initialData?.type || 'VIDEO'}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
>
<option value="VIDEO">Vidéo</option>
<option value="ARTICLE">Article (Texte)</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label>
<input
name="duration"
defaultValue={initialData?.duration}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: 12 min"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL de la miniature (Thumbnail)</label>
<input
name="thumbnailUrl"
defaultValue={initialData?.thumbnailUrl}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL Vidéo (Si type Vidéo)</label>
<input
name="videoUrl"
defaultValue={initialData?.videoUrl}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="https://youtube.com/..."
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Extrait (Court résumé)</label>
<textarea
name="excerpt"
defaultValue={initialData?.excerpt}
required
rows={2}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Contenu / Transcription (Optionnel)</label>
<textarea
name="content"
defaultValue={initialData?.content}
rows={8}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="pt-4">
<button
type="submit"
disabled={isPending}
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg bg-emerald-600"
>
{isPending ? (
<Loader2 className="w-6 h-6 animate-spin" />
) : (
<Save className="w-6 h-6" />
)}
{initialData ? 'Enregistrer les modifications' : 'Créer l\'interview'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
LayoutDashboard,
Users,
MessageSquare,
BookOpen,
LogOut,
ShieldCheck,
BarChart3
} from 'lucide-react';
const menuItems = [
{ name: 'Dashboard', icon: LayoutDashboard, href: '/dashboard' },
{ name: 'Entrepreneurs', icon: ShieldCheck, href: '/entrepreneurs' },
{ name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' },
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' },
];
export default function Sidebar() {
const pathname = usePathname();
return (
<div className="admin-sidebar p-6 flex flex-col">
<div className="flex items-center gap-3 mb-10 px-2">
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center">
<ShieldCheck className="text-white" />
</div>
<h1 className="text-xl font-bold tracking-tight text-white">AfroAdmin</h1>
</div>
<nav className="flex-1 space-y-1">
{menuItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={`nav-link ${isActive ? 'active' : ''}`}
>
<item.icon className="w-5 h-5 mr-3" />
<span>{item.name}</span>
</Link>
);
})}
</nav>
<div className="mt-auto pt-6 border-t border-slate-700">
<button className="nav-link w-full text-left text-red-400 hover:bg-red-950/30">
<LogOut className="w-5 h-5 mr-3" />
<span>Déconnexion</span>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
"use client";
import { useTransition } from 'react';
import { toggleVerification } from '@/app/actions/business';
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
interface Props {
id: string;
verified: boolean;
}
export default function ToggleVerifyButton({ id, verified }: Props) {
const [isPending, startTransition] = useTransition();
const handleToggle = () => {
startTransition(async () => {
const result = await toggleVerification(id, verified);
if (!result.success) {
alert(result.error);
}
});
};
return (
<button
onClick={handleToggle}
disabled={isPending}
className={verified ? "btn-unverify" : "btn-verify"}
>
<div className="flex items-center gap-2">
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : verified ? (
<XCircle className="w-4 h-4" />
) : (
<CheckCircle className="w-4 h-4" />
)}
{verified ? "Révoquer" : "Valider"}
</div>
</button>
);
}

20
admin/src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,20 @@
import { PrismaClient } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
// Updated: 2026-04-10
import pg from 'pg'
const connectionString = process.env.DATABASE_URL!
function makePrisma() {
const pool = new pg.Pool({ connectionString })
const adapter = new PrismaPg(pool)
return new PrismaClient({ adapter })
}
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma = globalForPrisma.prisma || makePrisma()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
export default prisma

26
admin/test-db.js Normal file
View File

@@ -0,0 +1,26 @@
const { Pool } = require('pg');
require('dotenv').config();
async function test() {
const connectionString = process.env.DATABASE_URL;
console.log('Testing connection to:', connectionString ? 'URL present' : 'URL MISSING');
const pool = new Pool({
connectionString,
connectionTimeoutMillis: 5000,
});
try {
const client = await pool.connect();
console.log('Successfully connected to DB!');
const res = await client.query('SELECT NOW()');
console.log('Query result:', res.rows[0]);
client.release();
} catch (err) {
console.error('Connection failed:', err.message);
} finally {
await pool.end();
}
}
test();

34
admin/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../lib/prisma';
export async function POST(request: NextRequest) {
console.log('>>> ANALYTICS API HIT');
try {
const body = await request.json();
console.log('>>> PAYLOAD:', JSON.stringify(body));
const { type, path, label, value, metadata } = body;
if (!type || !path) {
return NextResponse.json({ error: 'Type et Path requis' }, { status: 400 });
}
const event = await prisma.analyticsEvent.create({
data: {
type,
path,
label: label || null,
value: value || null,
metadata: metadata || {},
},
});
return NextResponse.json({ success: true, id: event.id });
} catch (error) {
console.error('Error recording analytics event:', error);
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
}
}

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../lib/prisma';
import bcrypt from 'bcryptjs';
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
if (!email || !password) {
return NextResponse.json(
{ error: 'Email et mot de passe requis' },
{ status: 400 }
);
}
// Find user
const user = await prisma.user.findUnique({
where: { email }
});
if (!user) {
return NextResponse.json(
{ error: 'Identifiants invalides' },
{ status: 401 }
);
}
// Check password
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return NextResponse.json(
{ error: 'Identifiants invalides' },
{ status: 401 }
);
}
// Omit password from response
const { password: _, ...userWithoutPassword } = user;
return NextResponse.json(
{ message: 'Connexion réussie', user: userWithoutPassword },
{ status: 200 }
);
} catch (error) {
console.error('Login error:', error);
return NextResponse.json(
{ error: 'Une erreur est survenue lors de la connexion' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../lib/prisma';
import bcrypt from 'bcryptjs';
export async function POST(request: NextRequest) {
try {
const { name, email, password } = await request.json();
// Basic validation
if (!name || !email || !password) {
return NextResponse.json(
{ error: 'Tous les champs sont obligatoires' },
{ status: 400 }
);
}
if (password.length < 6) {
return NextResponse.json(
{ error: 'Le mot de passe doit contenir au moins 6 caractères' },
{ status: 400 }
);
}
// Check if user already exists
const existingUser = await prisma.user.findUnique({
where: { email }
});
if (existingUser) {
return NextResponse.json(
{ error: 'Un utilisateur avec cet email existe déjà' },
{ status: 400 }
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
const user = await prisma.user.create({
data: {
name,
email,
password: hashedPassword,
role: 'VISITOR' // Default role
}
});
// Omit password from response
const { password: _, ...userWithoutPassword } = user;
return NextResponse.json(
{ message: 'Compte créé avec succès', user: userWithoutPassword },
{ status: 201 }
);
} catch (error) {
console.error('Registration error:', error);
return NextResponse.json(
{ error: 'Une erreur est survenue lors de l\'inscription' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../../lib/prisma';
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
if (!id) {
return NextResponse.json({ error: 'ID requis' }, { status: 400 });
}
// Attempt to increment in DB
// Note: We don't care about mock businesses here as they are static
const business = await prisma.business.update({
where: { id },
data: {
viewCount: {
increment: 1,
},
},
});
return NextResponse.json({ success: true, viewCount: business.viewCount });
} catch (error) {
// If business not found in DB (might be mock), just return success but no update
console.error('Error incrementing view count:', error);
return NextResponse.json({ success: false, error: 'Business not found or error' }, { status: 404 });
}
}

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
// GET /api/businesses/me — Fetch business for the current user
export async function GET(request: NextRequest) {
try {
const userId = request.headers.get('x-user-id')
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
const business = await prisma.business.findFirst({
where: { ownerId: userId },
include: { offers: true }
})
if (!business) {
return NextResponse.json(null) // Return null if no business yet
}
return NextResponse.json(business)
} catch (error) {
console.error('GET /api/businesses/me error:', error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}

View File

@@ -1,49 +1,128 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../lib/prisma'
import { MOCK_BUSINESSES } from '../../../lib/mockData'
// GET /api/businesses — List all businesses
// GET /api/businesses — List all businesses (Mock + DB)
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const category = searchParams.get('category')
const q = searchParams.get('q')
const q = searchParams.get('q')?.toLowerCase()
const featured = searchParams.get('featured')
const where: Record<string, unknown> = {}
// 1. Fetch from Database
const where: any = {}
if (category && category !== 'All') where.category = category
if (featured === 'true') where.isFeatured = true
if (q) {
where.OR = [
{ name: { contains: q, mode: 'insensitive' } },
{ description: { contains: q, mode: 'insensitive' } },
{ tags: { hasSome: [q] } },
// Simplified tags check
{ tags: { has: q } }
]
}
const businesses = await prisma.business.findMany({
const dbBusinesses = await prisma.business.findMany({
where,
include: { owner: true, offers: true },
orderBy: { createdAt: 'desc' },
})
return NextResponse.json(businesses)
// 2. Filter Mock Businesses
const filteredMocks = MOCK_BUSINESSES.filter(biz => {
// Category filter
if (category && category !== 'All' && biz.category !== category) return false;
// Featured filter
if (featured === 'true' && !biz.isFeatured) return false;
// Search query filter
if (q) {
const matches =
biz.name.toLowerCase().includes(q) ||
biz.description.toLowerCase().includes(q) ||
biz.tags.some(t => t.toLowerCase().includes(q));
if (!matches) return false;
}
return true;
});
// 3. Combine and return
// Prioritize DB entries over mocks to show user-created data first
const combined = [...dbBusinesses, ...filteredMocks];
return NextResponse.json(combined)
} catch (error) {
console.error('GET /api/businesses error:', error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
// POST /api/businesses — Create a business
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const business = await prisma.business.create({
data: body,
include: { owner: true },
})
return NextResponse.json(business, { status: 201 })
const headerUserId = request.headers.get('x-user-id')
const { ownerId: bodyOwnerId, ...data } = body
const ownerId = bodyOwnerId || headerUserId
if (!ownerId) {
return NextResponse.json({ error: 'ownerId est requis (via body ou x-user-id header)' }, { status: 400 })
}
// 1. Sanitize data - ONLY pass fields that exist in the Prisma Business model
// We must exclude 'id', 'offers', 'owner', 'createdAt', 'updatedAt' from the data object
const cleanData: any = {
name: data.name || "Nouvelle Entreprise",
category: data.category || "Autre",
location: data.location || "Ma Ville",
description: data.description || "",
logoUrl: data.logoUrl || "https://picsum.photos/200/200?random=default",
videoUrl: data.videoUrl || null,
contactEmail: data.contactEmail || "",
contactPhone: data.contactPhone || null,
socialLinks: data.socialLinks || {},
verified: data.verified ?? false,
viewCount: data.viewCount ?? 0,
rating: data.rating ?? 0,
tags: Array.isArray(data.tags) ? data.tags : [],
isFeatured: data.isFeatured ?? false,
founderName: data.founderName || null,
founderImageUrl: data.founderImageUrl || null,
keyMetric: data.keyMetric || null,
}
let business;
const existing = await prisma.business.findFirst({
where: { ownerId: ownerId }
});
console.log('Upserting business for ownerId:', ownerId, 'Existing:', !!existing);
if (existing) {
// When updating, we use the sanitized cleanData and the existing record's id
business = await prisma.business.update({
where: { id: existing.id },
data: cleanData,
include: { owner: true }
});
} else {
// When creating, we add the ownerId to the sanitized cleanData
business = await prisma.business.create({
data: {
...cleanData,
ownerId: ownerId
},
include: { owner: true }
});
}
console.log('Business saved successfully:', business.id);
return NextResponse.json(business, { status: 200 })
} catch (error) {
console.error('POST /api/businesses error:', error)
return NextResponse.json({ error: 'Erreur lors de la création' }, { status: 500 })
return NextResponse.json({ error: 'Erreur lors de lenregistrement' }, { status: 500 })
}
}

View File

@@ -1,7 +1,7 @@
"use client";
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { LayoutDashboard, Edit3, ShoppingBag, CreditCard, LogOut, Eye } from 'lucide-react';
import { useRouter } from 'next/navigation';
@@ -17,13 +17,73 @@ const DashboardPage = () => {
const { user, logout } = useUser();
const router = useRouter();
const [currentView, setCurrentView] = useState<'overview' | 'profile' | 'offers' | 'subscription'>('overview');
const [business, setBusiness] = useState(MOCK_BUSINESSES[0]); // In real app, fetch by user.id
const [business, setBusiness] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
// Fetch user's business
useEffect(() => {
if (!user || !isMounted) return;
const fetchBusiness = async () => {
try {
const res = await fetch('/api/businesses/me', {
headers: { 'x-user-id': user.id }
});
const data = await res.json();
if (data && !data.error) {
setBusiness(data);
}
} catch (error) {
console.error('Error fetching dashboard business:', error);
} finally {
setLoading(false);
}
};
fetchBusiness();
}, [user, isMounted]);
if (!isMounted) return null;
if (!user) {
if (typeof window !== 'undefined') router.push('/login');
return null;
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="flex flex-col items-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-600"></div>
<p className="mt-4 text-gray-600 font-medium">Chargement de votre espace...</p>
</div>
</div>
);
}
// Default business structure for new users
const displayBusiness = business || {
id: 'new',
name: "Ma Boutique",
category: "Technologie & IT",
location: "Ma Ville",
description: "Décrivez votre entreprise ici...",
logoUrl: "https://picsum.photos/200/200?random=default",
contactEmail: user?.email || "",
contactPhone: "",
tags: [],
socialLinks: {},
verified: false,
viewCount: 0,
rating: 0,
offers: []
};
return (
<div className="min-h-screen bg-gray-100 flex">
{/* 1. Sidebar */}
@@ -88,7 +148,7 @@ const DashboardPage = () => {
{currentView === 'subscription' && 'Mon Abonnement'}
</h1>
<div className="flex items-center space-x-4">
<a href={`/directory`} target="_blank" rel="noopener noreferrer" className="hidden sm:inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none">
<a href={displayBusiness.id === 'new' ? '/directory' : `/directory/${displayBusiness.id}`} target="_blank" rel="noopener noreferrer" className="hidden sm:inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none">
<Eye className="-ml-1 mr-2 h-4 w-4 text-gray-500" />
Voir ma fiche
</a>
@@ -97,8 +157,8 @@ const DashboardPage = () => {
<main className="flex-1 overflow-y-auto p-6">
<div className="max-w-7xl mx-auto">
{currentView === 'overview' && <DashboardOverview business={business} />}
{currentView === 'profile' && <DashboardProfile business={business} setBusiness={setBusiness} />}
{currentView === 'overview' && <DashboardOverview business={displayBusiness} />}
{currentView === 'profile' && <DashboardProfile business={displayBusiness} setBusiness={setBusiness} />}
{currentView === 'offers' && <DashboardOffers />}
{currentView === 'subscription' && (
<div className="space-y-6">

View File

@@ -6,11 +6,12 @@ import Link from 'next/link';
import { useParams } from 'next/navigation';
import { ArrowLeft, MapPin, Globe, Mail, Phone, CheckCircle, Star, Facebook, Linkedin, Instagram, Play, Share2, Send, Award, Quote } from 'lucide-react';
import { MOCK_BUSINESSES, MOCK_OFFERS } from '../../../lib/mockData';
import { OfferType } from '../../../types';
import { Business, OfferType } from '../../../types';
const BusinessDetailPage = () => {
const { id } = useParams();
const business = MOCK_BUSINESSES.find(b => b.id === id);
const [business, setBusiness] = useState<Business | null>(null);
const [loading, setLoading] = useState(true);
const [contactForm, setContactForm] = useState({
name: '',
@@ -20,13 +21,59 @@ const BusinessDetailPage = () => {
const [formStatus, setFormStatus] = useState<'idle' | 'sending' | 'success'>('idle');
const businessOffers = useMemo(() => {
if (!business) return [];
// If the business has its own offers (from DB), use them
if (business.offers && business.offers.length > 0) return business.offers;
// Otherwise fallback to mock offers for mock businesses
return MOCK_OFFERS.filter(o => o.businessId === id && o.active);
}, [id]);
}, [business, id]);
useEffect(() => {
window.scrollTo(0, 0);
const loadBusiness = async () => {
setLoading(true);
// 1. Try Mock Data First (Instant)
const mock = MOCK_BUSINESSES.find(b => b.id === id);
if (mock) {
setBusiness(mock);
setLoading(false);
return;
}
// 2. Try API (Database)
try {
const res = await fetch(`/api/businesses/${id}`);
if (res.ok) {
const data = await res.json();
setBusiness(data);
// 3. Increment views silently
fetch(`/api/businesses/${id}/view`, { method: 'POST' }).catch(console.error);
} else {
setBusiness(null);
}
} catch (err) {
console.error("Error fetching business:", err);
setBusiness(null);
} finally {
setLoading(false);
}
};
if (id) loadBusiness();
}, [id]);
if (loading) {
return (
<div className="min-h-[60vh] flex flex-col items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-brand-600 mb-4"></div>
<p className="text-gray-500">Chargement du profil...</p>
</div>
);
}
if (!business) {
return (
<div className="min-h-[60vh] flex flex-col items-center justify-center">

View File

@@ -3,39 +3,70 @@
import React, { useState, useEffect, useMemo, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { Search } from 'lucide-react';
import { MOCK_BUSINESSES } from '../../lib/mockData';
import { CATEGORIES } from '../../types';
import { Search, Loader2 } from 'lucide-react';
import { CATEGORIES, Business } from '../../types';
import BusinessCard from '../../components/BusinessCard';
import DirectoryHero from '../../components/DirectoryHero';
const DirectoryPageContent = () => {
const [businesses, setBusinesses] = useState<Business[]>([]);
const [featuredBusiness, setFeaturedBusiness] = useState<Business | null>(null);
const [loading, setLoading] = useState(true);
const [filterCategory, setFilterCategory] = useState('All');
const [searchQuery, setSearchQuery] = useState('');
const searchParams = useSearchParams();
// 1. Fetch Featured Headliner once
useEffect(() => {
const fetchHeadliner = async () => {
try {
const res = await fetch('/api/businesses?featured=true');
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
setFeaturedBusiness(data[0]);
}
} catch (error) {
console.error('Error fetching headliner:', error);
}
};
fetchHeadliner();
}, []);
// 2. Fetch businesses from API
useEffect(() => {
const fetchBusinesses = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filterCategory !== 'All') params.append('category', filterCategory);
if (searchQuery) params.append('q', searchQuery);
const res = await fetch(`/api/businesses?${params.toString()}`);
const data = await res.json();
if (Array.isArray(data)) {
setBusinesses(data);
}
} catch (error) {
console.error('Error fetching businesses:', error);
} finally {
setLoading(false);
}
};
const timeoutId = setTimeout(fetchBusinesses, 300); // Debounce
return () => clearTimeout(timeoutId);
}, [filterCategory, searchQuery]);
useEffect(() => {
const q = searchParams.get('q');
if (q) setSearchQuery(q);
}, [searchParams]);
const featuredBusiness = useMemo(() => {
return MOCK_BUSINESSES.find(b => b.isFeatured);
}, []);
const filteredBusinesses = useMemo(() => {
return MOCK_BUSINESSES.filter(b => {
const matchesCategory = filterCategory === 'All' || b.category === filterCategory;
const matchesSearch = b.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
b.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
b.tags.some(t => t.toLowerCase().includes(searchQuery.toLowerCase()));
return matchesCategory && matchesSearch;
});
}, [filterCategory, searchQuery]);
const filteredBusinesses = businesses;
return (
<div>
{/* New Hero Section */}
{/* Tête d'affiche (Headliner) - Always visible regardless of filters */}
{featuredBusiness && <DirectoryHero featuredBusiness={featuredBusiness} />}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
@@ -95,7 +126,12 @@ const DirectoryPageContent = () => {
<span className="text-sm text-gray-500">{filteredBusinesses.length} résultats</span>
</div>
{filteredBusinesses.length > 0 ? (
{loading ? (
<div className="flex flex-col items-center justify-center py-20 bg-white rounded-xl border border-gray-100 shadow-sm">
<Loader2 className="w-12 h-12 text-brand-600 animate-spin mb-4" />
<p className="text-gray-500 font-medium">Chargement des entreprises...</p>
</div>
) : filteredBusinesses.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredBusinesses.map(biz => (
<BusinessCard key={biz.id} business={biz} />

View File

@@ -3,6 +3,7 @@ import Script from 'next/script';
import Navbar from '../components/Navbar';
import Footer from '../components/Footer';
import { UserProvider } from '../components/UserProvider';
import AnalyticsTracker from '../components/AnalyticsTracker';
import { Metadata } from 'next';
import './globals.css';
@@ -20,6 +21,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</head>
<body className="bg-gray-50 text-gray-900 antialiased min-h-screen flex flex-col font-sans">
<UserProvider>
<AnalyticsTracker />
<Navbar />
<main className="flex-grow">
{children}

View File

@@ -3,19 +3,43 @@
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useUser } from '../../components/UserProvider';
const LoginPage = () => {
const { login } = useUser();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Mock login
login();
setLoading(true);
setError('');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Erreur lors de la connexion');
}
// Real login with user data from DB
login(data.user);
router.push('/dashboard');
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
@@ -25,10 +49,15 @@ const LoginPage = () => {
<div className="mx-auto h-12 w-12 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold text-2xl">A</div>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 font-serif">Connexion à votre espace</h2>
<p className="mt-2 text-sm text-gray-600">
Ou <a href="#" className="font-medium text-brand-600 hover:text-brand-500">créez votre compte entreprise pour 1</a>
Ou <Link href="/register" className="font-medium text-brand-600 hover:text-brand-500">créez votre compte entreprise pour 1</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded text-red-700 text-sm">
{error}
</div>
)}
<div className="rounded-md shadow-sm -space-y-px">
<div>
<input
@@ -52,13 +81,14 @@ const LoginPage = () => {
</div>
</div>
<div>
<button type="submit" className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500">
Se connecter
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 disabled:opacity-70"
>
{loading ? 'Connexion...' : 'Se connecter'}
</button>
</div>
<div className="text-sm text-center text-gray-500">
(Compte démo : n'importe quel email/mdp fonctionne)
</div>
</form>
</div>
</div>

View File

@@ -1,17 +1,34 @@
"use client";
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Search, MapPin, Briefcase, TrendingUp } from 'lucide-react';
import { CATEGORIES } from '../types';
import { MOCK_BUSINESSES } from '../lib/mockData';
import { Search, MapPin, Briefcase, TrendingUp, Loader2 } from 'lucide-react';
import { CATEGORIES, Business } from '../types';
import BusinessCard from '../components/BusinessCard';
const HomePage = () => {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState('');
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchFeatured = async () => {
try {
const res = await fetch('/api/businesses?featured=true');
const data = await res.json();
if (Array.isArray(data)) {
setFeaturedBusinesses(data.slice(0, 4)); // Show top 4
}
} catch (error) {
console.error('Error fetching featured businesses:', error);
} finally {
setLoading(false);
}
};
fetchFeatured();
}, []);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
@@ -23,7 +40,6 @@ const HomePage = () => {
{/* Hero Section */}
<div className="relative bg-dark-900 overflow-hidden">
<div className="absolute inset-0 opacity-40">
{/* UPDATED IMAGE: Group of diverse/African professionals */}
<img src="https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1740&q=80" className="w-full h-full object-cover" alt="African entrepreneurs team" />
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32">
@@ -84,11 +100,18 @@ const HomePage = () => {
Voir tout l'annuaire <TrendingUp className="w-4 h-4 ml-1" />
</Link>
</div>
{loading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-10 h-10 text-brand-600 animate-spin" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{MOCK_BUSINESSES.map(biz => (
{featuredBusinesses.map(biz => (
<BusinessCard key={biz.id} business={biz} />
))}
</div>
)}
</div>
</div>
</div>

203
app/register/page.tsx Normal file
View File

@@ -0,0 +1,203 @@
"use client";
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { User, Mail, Lock, ArrowRight, CheckCircle, AlertCircle } from 'lucide-react';
const RegisterPage = () => {
const router = useRouter();
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: ''
});
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
setError('');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
if (formData.password !== formData.confirmPassword) {
setError('Les mots de passe ne correspondent pas');
setLoading(false);
return;
}
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name,
email: formData.email,
password: formData.password
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Une erreur est survenue");
}
setSuccess(true);
setTimeout(() => {
router.push('/login');
}, 2000);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8 bg-[radial-gradient(circle_at_top_right,_var(--color-brand-100),_transparent_40%),_radial-gradient(circle_at_bottom_left,_var(--color-brand-50),_transparent_40%)]">
<div className="max-w-md w-full">
<div className="text-center mb-10">
<Link href="/" className="inline-flex items-center justify-center h-16 w-16 bg-brand-600 rounded-2xl shadow-lg shadow-brand-200 text-white font-bold text-3xl mb-6 hover:scale-105 transition-transform">
A
</Link>
<h2 className="text-4xl font-extrabold text-dark-900 font-serif mb-2">Rejoignez l'aventure</h2>
<p className="text-gray-600">Créez votre compte pour propulser votre entreprise</p>
</div>
<div className="bg-white/80 backdrop-blur-xl p-8 rounded-3xl shadow-2xl border border-white shadow-brand-100/50">
{success ? (
<div className="text-center py-10 animate-in fade-in zoom-in duration-500">
<div className="inline-flex items-center justify-center h-20 w-20 bg-green-100 text-green-600 rounded-full mb-6">
<CheckCircle size={40} />
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-2">Compte créé !</h3>
<p className="text-gray-600">Redirection vers la page de connexion...</p>
</div>
) : (
<form className="space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded-r-lg flex items-center gap-3 text-red-700 animate-in slide-in-from-top-2 duration-300">
<AlertCircle size={20} className="shrink-0" />
<p className="text-sm font-medium">{error}</p>
</div>
)}
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-semibold text-gray-700 ml-1">Nom complet</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-brand-600 transition-colors">
<User size={18} />
</div>
<input
name="name"
type="text"
required
className="block w-full pl-10 pr-3 py-3 border border-gray-200 rounded-xl bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 transition-all text-gray-900 placeholder-gray-400"
placeholder="Ex: Jean Dupont"
value={formData.name}
onChange={handleChange}
/>
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-semibold text-gray-700 ml-1">Adresse email</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-brand-600 transition-colors">
<Mail size={18} />
</div>
<input
name="email"
type="email"
required
className="block w-full pl-10 pr-3 py-3 border border-gray-200 rounded-xl bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 transition-all text-gray-900 placeholder-gray-400"
placeholder="votre@email.com"
value={formData.email}
onChange={handleChange}
/>
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-semibold text-gray-700 ml-1">Mot de passe</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-brand-600 transition-colors">
<Lock size={18} />
</div>
<input
name="password"
type="password"
required
className="block w-full pl-10 pr-3 py-3 border border-gray-200 rounded-xl bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 transition-all text-gray-900 placeholder-gray-400"
placeholder="••••••••"
value={formData.password}
onChange={handleChange}
/>
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-semibold text-gray-700 ml-1">Confirmer le mot de passe</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-brand-600 transition-colors">
<Lock size={18} />
</div>
<input
name="confirmPassword"
type="password"
required
className="block w-full pl-10 pr-3 py-3 border border-gray-200 rounded-xl bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 transition-all text-gray-900 placeholder-gray-400"
placeholder="••••••••"
value={formData.confirmPassword}
onChange={handleChange}
/>
</div>
</div>
</div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center items-center py-3.5 px-4 border border-transparent text-sm font-bold rounded-xl text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 transition-all shadow-lg shadow-brand-200 disabled:opacity-70 disabled:cursor-not-allowed overflow-hidden"
>
{loading ? (
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
S'inscrire
<ArrowRight className="ml-2 group-hover:translate-x-1 transition-transform" size={18} />
</>
)}
</button>
<div className="text-center mt-6">
<p className="text-gray-600 text-sm">
Déjà un compte ?{' '}
<Link href="/login" className="font-bold text-brand-600 hover:text-brand-700 transition-colors underline-offset-4 hover:underline">
Se connecter
</Link>
</p>
</div>
</form>
)}
</div>
<p className="mt-8 text-center text-xs text-gray-400 uppercase tracking-widest font-semibold">
Afropreunariat &copy; 2026
</p>
</div>
</div>
);
};
export default RegisterPage;

View File

@@ -0,0 +1,90 @@
"use client";
import { useEffect, useRef } from 'react';
import { usePathname } from 'next/navigation';
export default function AnalyticsTracker() {
const pathname = usePathname();
const startTimeRef = useRef<number>(Date.now());
// Helper to send events
const sendEvent = async (data: {
type: string;
path: string;
label?: string;
value?: number;
metadata?: any;
}) => {
try {
// Use standard fetch for development reliability
await fetch('/api/analytics', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
} catch (err) {
console.error('[Analytics] Error:', err);
}
};
// 1. Track Page Views
useEffect(() => {
sendEvent({
type: 'PAGE_VIEW',
path: pathname,
metadata: {
userAgent: navigator.userAgent,
screen: `${window.innerWidth}x${window.innerHeight}`,
}
});
// Reset start time for dwell time calculation
startTimeRef.current = Date.now();
// 2. Track Dwell Time on cleanup (when leaving page)
return () => {
const dwellTime = Math.round((Date.now() - startTimeRef.current) / 1000);
if (dwellTime > 0) {
sendEvent({
type: 'DWELL_TIME',
path: pathname,
value: dwellTime,
});
}
};
}, [pathname]);
// 3. Track Global Clicks
useEffect(() => {
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// Look for clickable elements (buttons, links)
const clickable = target.closest('button, a, [role="button"]');
if (clickable) {
const label = clickable.textContent?.trim().slice(0, 50) ||
(clickable as HTMLAnchorElement).href ||
clickable.getAttribute('aria-label') ||
'Unnamed Action';
sendEvent({
type: 'CLICK',
path: window.location.pathname,
label,
metadata: {
tagName: clickable.tagName,
id: clickable.id,
className: clickable.className,
}
});
}
};
window.addEventListener('click', handleClick);
return () => window.removeEventListener('click', handleClick);
}, []);
return null; // Invisible component
}

View File

@@ -6,7 +6,7 @@ import { MOCK_USER } from '../lib/mockData';
interface UserContextType {
user: User | null;
login: () => void;
login: (user: User) => void;
logout: () => void;
}
@@ -15,12 +15,21 @@ const UserContext = createContext<UserContextType | undefined>(undefined);
export function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = () => {
setUser(MOCK_USER);
React.useEffect(() => {
const savedUser = localStorage.getItem('afro_user');
if (savedUser) {
setUser(JSON.parse(savedUser));
}
}, []);
const login = (userData: User) => {
setUser(userData);
localStorage.setItem('afro_user', JSON.stringify(userData));
};
const logout = () => {
setUser(null);
localStorage.removeItem('afro_user');
};
return (

View File

@@ -18,6 +18,18 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
const [videoInput, setVideoInput] = useState(business.videoUrl || '');
const [videoPreviewId, setVideoPreviewId] = useState<string | null>(getYouTubeId(business.videoUrl || ''));
const [isGenerating, setIsGenerating] = useState(false);
const [isMounted, setIsMounted] = useState(false);
// Sync formData when business prop changes (initial fetch)
React.useEffect(() => {
setFormData(business);
setVideoInput(business.videoUrl || '');
setVideoPreviewId(getYouTubeId(business.videoUrl || ''));
}, [business]);
React.useEffect(() => {
setIsMounted(true);
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
@@ -44,9 +56,33 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
setIsGenerating(false);
};
const handleSave = () => {
setBusiness(formData);
const handleSave = async () => {
try {
const res = await fetch('/api/businesses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': business?.ownerId || (typeof window !== 'undefined' ? JSON.parse(localStorage.getItem('afro_user') || '{}').id : '')
},
body: JSON.stringify({
...formData,
ownerId: business?.ownerId || (typeof window !== 'undefined' ? JSON.parse(localStorage.getItem('afro_user') || '{}').id : null)
})
});
const data = await res.json();
if (data.error) {
alert('Erreur: ' + data.error);
} else {
setBusiness(data);
alert('Profil mis à jour avec succès !');
// Optional: refresh page to show in directory
}
} catch (error) {
console.error('Save error:', error);
alert('Erreur lors de la sauvegarde');
}
};
return (

View File

@@ -51,6 +51,7 @@ export const MOCK_BUSINESSES: Business[] = [
viewCount: 850,
rating: 4.9,
tags: ['Traiteur', 'Bio', 'Événementiel', 'Gastronomie'],
isFeatured: true,
founderName: 'Aïssa Maïga',
socialLinks: { instagram: 'https://instagram.com' }
},
@@ -83,6 +84,7 @@ export const MOCK_BUSINESSES: Business[] = [
viewCount: 560,
rating: 4.7,
tags: ['Énergie', 'Solaire', 'Durable', 'Tech'],
isFeatured: true,
founderName: 'Ousmane Diop'
},
{
@@ -152,6 +154,7 @@ export const MOCK_BUSINESSES: Business[] = [
viewCount: 670,
rating: 4.8,
tags: ['Architecture', 'Écologie', 'BTC', 'Durable'],
isFeatured: true,
founderName: 'Fatimata Sylla',
founderImageUrl: 'https://images.unsplash.com/photo-1542596594-649edbc13630?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80'
}

561
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@prisma/adapter-pg": "^7.4.1",
"@prisma/client": "^7.4.1",
"@types/pg": "^8.16.0",
"bcryptjs": "^3.0.3",
"lucide-react": "^0.554.0",
"next": "^16.1.6",
"pg": "^8.19.0",
@@ -21,12 +22,14 @@
"devDependencies": {
"@prisma/config": "^7.4.1",
"@tailwindcss/postcss": "^4.2.1",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.27",
"dotenv": "^17.3.1",
"postcss": "^8.5.6",
"prisma": "^7.4.1",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"typescript": "~5.8.2"
}
},
@@ -120,6 +123,448 @@
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@google/genai": {
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.42.0.tgz",
@@ -1382,6 +1827,13 @@
"tailwindcss": "4.2.1"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
@@ -1540,6 +1992,15 @@
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
@@ -1913,6 +2374,48 @@
"node": ">=10.13.0"
}
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -2024,6 +2527,21 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/gaxios": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
@@ -2070,6 +2588,19 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/get-tsconfig": {
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/giget": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
@@ -3296,6 +3827,16 @@
"url": "https://github.com/sponsors/remeda"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@@ -3641,6 +4182,26 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",

View File

@@ -14,6 +14,7 @@
"@prisma/adapter-pg": "^7.4.1",
"@prisma/client": "^7.4.1",
"@types/pg": "^8.16.0",
"bcryptjs": "^3.0.3",
"lucide-react": "^0.554.0",
"next": "^16.1.6",
"pg": "^8.19.0",
@@ -23,12 +24,17 @@
"devDependencies": {
"@prisma/config": "^7.4.1",
"@tailwindcss/postcss": "^4.2.1",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.27",
"dotenv": "^17.3.1",
"postcss": "^8.5.6",
"prisma": "^7.4.1",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"typescript": "~5.8.2"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}

View File

@@ -16,10 +16,12 @@ model User {
id String @id @default(uuid())
name String
email String @unique
password String
role UserRole @default(VISITOR)
avatar String?
businesses Business[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -52,9 +54,10 @@ model Business {
keyMetric String?
// Relations
ownerId String
ownerId String @unique
owner User @relation(fields: [ownerId], references: [id])
offers Offer[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -118,3 +121,27 @@ model Interview {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Comment {
id String @id @default(uuid())
content String @db.Text
// Relations
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AnalyticsEvent {
id String @id @default(uuid())
type String // PAGE_VIEW, CLICK, DWELL_TIME
path String
label String? // For clicks (button text, link name)
value Float? // For dwell time (seconds)
metadata Json? // Browser, OS, etc.
createdAt DateTime @default(now())
}

155
prisma/seed.ts Normal file
View File

@@ -0,0 +1,155 @@
import { PrismaClient, UserRole, InterviewType, OfferType } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import pg from 'pg';
import bcrypt from 'bcryptjs';
import { MOCK_BUSINESSES, MOCK_BLOG_POSTS, MOCK_INTERVIEWS, MOCK_OFFERS } from '../lib/mockData.js';
const connectionString = process.env.DATABASE_URL!;
const pool = new pg.Pool({ connectionString });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
async function main() {
console.log('🌱 Starting database seeding...');
const hashedPassword = await bcrypt.hash('password123', 10);
// 1. Clean existing data
console.log('🧹 Cleaning existing data...');
await prisma.comment.deleteMany({});
await prisma.offer.deleteMany({});
await prisma.business.deleteMany({});
await prisma.blogPost.deleteMany({});
await prisma.interview.deleteMany({});
await prisma.user.deleteMany({});
// 2. Create Mock Users
console.log('👤 Creating users...');
// Create Main Admin
const admin = await prisma.user.create({
data: {
name: 'Admin Afropreunariat',
email: 'admin@afropreunariat.com',
password: hashedPassword,
role: UserRole.ADMIN,
},
});
// Create Users from mockup (we'll use their ownerId as IDs to match relationships)
const usersToCreate = [
{ id: 'u1', name: 'Jean-Marc Kouassi', email: 'jm.kouassi@example.com' },
{ id: 'u2', name: 'Aïssa Maïga', email: 'aissa@example.com' },
{ id: 'u3', name: 'Chidinma Okeke', email: 'chidinma@example.com' },
{ id: 'u4', name: 'Ousmane Diop', email: 'ousmane@example.com' },
{ id: 'u5', name: 'Marie-Claire Etoa', email: 'marie@example.com' },
{ id: 'u6', name: 'David Ochieng', email: 'david@example.com' },
{ id: 'u7', name: 'Serge Mbemba', email: 'serge@example.com' },
{ id: 'u8', name: 'Fatimata Sylla', email: 'fatimata@example.com' },
];
for (const u of usersToCreate) {
await prisma.user.create({
data: {
id: u.id,
name: u.name,
email: u.email,
password: hashedPassword,
role: UserRole.ENTREPRENEUR,
},
});
}
// 3. Create Businesses
console.log('🏢 Creating businesses...');
for (const b of MOCK_BUSINESSES) {
await prisma.business.create({
data: {
id: b.id,
name: b.name,
category: b.category,
location: b.location,
description: b.description,
logoUrl: b.logoUrl,
videoUrl: b.videoUrl,
socialLinks: b.socialLinks as any,
contactEmail: b.contactEmail,
contactPhone: b.contactPhone,
verified: b.verified,
viewCount: b.viewCount,
rating: b.rating,
tags: b.tags,
isFeatured: b.isFeatured || false,
founderName: b.founderName,
founderImageUrl: b.founderImageUrl,
keyMetric: b.keyMetric,
ownerId: b.ownerId, // This matches the User IDs we just created
},
});
}
// 4. Create Offers
console.log('🏷️ Creating offers...');
for (const o of MOCK_OFFERS) {
await prisma.offer.create({
data: {
id: o.id,
businessId: o.businessId,
title: o.title,
type: o.type as OfferType,
price: o.price,
currency: o.currency,
description: o.description,
imageUrl: o.imageUrl,
active: o.active,
},
});
}
// 5. Create Blog Posts
console.log('✍️ Creating blog posts...');
for (const p of MOCK_BLOG_POSTS) {
await prisma.blogPost.create({
data: {
id: p.id,
title: p.title,
excerpt: p.excerpt,
content: p.content,
author: p.author,
imageUrl: p.imageUrl,
},
});
}
// 6. Create Interviews
console.log('🎙️ Creating interviews...');
for (const i of MOCK_INTERVIEWS) {
await prisma.interview.create({
data: {
id: i.id,
title: i.title,
guestName: i.guestName,
companyName: i.companyName,
role: i.role,
type: i.type as InterviewType,
thumbnailUrl: i.thumbnailUrl,
videoUrl: i.videoUrl,
content: i.content,
excerpt: i.excerpt,
duration: i.duration,
},
});
}
console.log('✅ Seeding completed successfully!');
}
main()
.catch((e) => {
console.error('❌ Seeding failed:');
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

24
test-analytics.js Normal file
View File

@@ -0,0 +1,24 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function test() {
console.log('Attempting to create an AnalyticsEvent...');
try {
const event = await prisma.analyticsEvent.create({
data: {
type: 'TEST_MANUAL',
path: '/test-script',
label: 'Script Test',
metadata: { source: 'script' }
}
});
console.log('SUCCESS! Event created:', event.id);
} catch (error) {
console.error('FAILURE! Prisma Error:');
console.error(error);
} finally {
await prisma.$disconnect();
}
}
test();

25
test-analytics.mjs Normal file
View File

@@ -0,0 +1,25 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function test() {
console.log('Attempting to create an AnalyticsEvent (ESM)...');
try {
const event = await prisma.analyticsEvent.create({
data: {
type: 'TEST_ESM',
path: '/test-esm',
label: 'ESM Test',
metadata: { source: 'esm-script' }
}
});
console.log('SUCCESS! Event created:', event.id);
} catch (error) {
console.error('FAILURE! Prisma Error:');
console.error(error.message);
if (error.code) console.log('Prisma Code:', error.code);
} finally {
await prisma.$disconnect();
}
}
test();

41
tmp/test-db-business.ts Normal file
View File

@@ -0,0 +1,41 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
// Find the user we created earlier
const user = await prisma.user.findUnique({
where: { email: 'real@example.com' }
})
if (!user) {
console.error('User not found. Please register first.')
return
}
const business = await prisma.business.create({
data: {
name: 'Real DB Business',
category: 'Technologie & IT',
location: 'Dakar, Sénégal',
description: 'This is a real business stored in the database, merged with mock data.',
logoUrl: 'https://picsum.photos/200/200?random=100',
contactEmail: 'contact@realdb.com',
verified: true,
isFeatured: true,
ownerId: user.id,
tags: ['Real', 'Database', 'Test']
}
})
console.log('Created business:', business)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long

View File

@@ -42,6 +42,7 @@ export interface Business {
founderName?: string;
founderImageUrl?: string;
keyMetric?: string; // "3000 clients delivered"
offers?: Offer[]; // Joined from database or from mocks
}
export enum OfferType {