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:
2
.env
2
.env
@@ -1,5 +1,5 @@
|
|||||||
# Connexion locale via le Docker Compose fourni :
|
# 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)
|
# Exemple avec Supabase ou Neon (si base distante)
|
||||||
# DATABASE_URL="postgresql://postgres:[MOT_DE_PASSE]@db.[ID_PROJET].supabase.co:5432/postgres"
|
# DATABASE_URL="postgresql://postgres:[MOT_DE_PASSE]@db.[ID_PROJET].supabase.co:5432/postgres"
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,7 +6,8 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
.next
|
||||||
|
.next/*
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
|||||||
BIN
.next/dev/cache/turbopack/23c46498/CURRENT
vendored
BIN
.next/dev/cache/turbopack/23c46498/CURRENT
vendored
Binary file not shown.
1602
.next/dev/cache/turbopack/23c46498/LOG
vendored
1602
.next/dev/cache/turbopack/23c46498/LOG
vendored
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,8 @@
|
|||||||
"dynamicRoutes": {},
|
"dynamicRoutes": {},
|
||||||
"notFoundRoutes": [],
|
"notFoundRoutes": [],
|
||||||
"preview": {
|
"preview": {
|
||||||
"previewModeId": "36346de21023de4aacdc241201ed203e",
|
"previewModeId": "549bb4c6520c4055391738eb3af7c117",
|
||||||
"previewModeSigningKey": "96be46da0bc424e421106ea753fd19fd68fb09c0846ae91c38451b20c6e16300",
|
"previewModeSigningKey": "50be5dfec6eae3bf6960e9caf454be821cc00c686ff415fb845771a700e10c41",
|
||||||
"previewModeEncryptionKey": "55ee3316e83beac98a679eccb547ff11393487f7fbdaef6e378e1d4e642b423c"
|
"previewModeEncryptionKey": "2d171c6cc9411b6e306ffde24c967f9ae230daccd3927ceeaae19e3bbffa4f70"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"/afrolife/page": "app/afrolife/page.js",
|
"/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",
|
"/blog/page": "app/blog/page.js",
|
||||||
"/dashboard/page": "app/dashboard/page.js",
|
|
||||||
"/directory/page": "app/directory/page.js",
|
"/directory/page": "app/directory/page.js",
|
||||||
"/login/page": "app/login/page.js",
|
|
||||||
"/page": "app/page.js"
|
"/page": "app/page.js"
|
||||||
}
|
}
|
||||||
@@ -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/a30ac_next_dist_2984d70a._.js")
|
||||||
R.c("server/chunks/ssr/[externals]__7f148858._.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/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_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_forbidden_24897189.js")
|
||||||
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_unauthorized_d9e897c8.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
@@ -1,6 +1,6 @@
|
|||||||
var R=require("../../../chunks/[turbopack]_runtime.js")("server/app/api/businesses/route.js")
|
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]__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.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)")
|
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
|
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
@@ -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/a30ac_next_dist_2984d70a._.js")
|
||||||
R.c("server/chunks/ssr/[externals]__7f148858._.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/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_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_forbidden_24897189.js")
|
||||||
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_unauthorized_d9e897c8.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
@@ -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/a30ac_next_dist_2984d70a._.js")
|
||||||
R.c("server/chunks/ssr/[externals]__7f148858._.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/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_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_forbidden_24897189.js")
|
||||||
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_unauthorized_d9e897c8.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
@@ -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/a30ac_next_dist_2984d70a._.js")
|
||||||
R.c("server/chunks/ssr/[externals]__7f148858._.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/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_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_forbidden_24897189.js")
|
||||||
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_unauthorized_d9e897c8.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
@@ -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/a30ac_next_dist_2984d70a._.js")
|
||||||
R.c("server/chunks/ssr/[externals]__7f148858._.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/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_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_forbidden_24897189.js")
|
||||||
R.c("server/chunks/ssr/a30ac_next_dist_client_components_builtin_unauthorized_d9e897c8.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
@@ -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$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$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$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)");
|
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";
|
"use client";
|
||||||
;
|
;
|
||||||
;
|
;
|
||||||
;
|
;
|
||||||
;
|
;
|
||||||
|
;
|
||||||
const LoginPage = ()=>{
|
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 { 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 [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 [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 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();
|
e.preventDefault();
|
||||||
// Mock login
|
setLoading(true);
|
||||||
login();
|
setError('');
|
||||||
router.push('/dashboard');
|
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", {
|
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",
|
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"
|
children: "A"
|
||||||
}, void 0, false, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 25,
|
lineNumber: 49,
|
||||||
columnNumber: 21
|
columnNumber: 21
|
||||||
}, ("TURBOPACK compile-time value", void 0)),
|
}, ("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", {
|
/*#__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"
|
children: "Connexion à votre espace"
|
||||||
}, void 0, false, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 26,
|
lineNumber: 50,
|
||||||
columnNumber: 21
|
columnNumber: 21
|
||||||
}, ("TURBOPACK compile-time value", void 0)),
|
}, ("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", {
|
/*#__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",
|
className: "mt-2 text-sm text-gray-600",
|
||||||
children: [
|
children: [
|
||||||
"Ou ",
|
"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", {
|
/*#__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: "#",
|
href: "/register",
|
||||||
className: "font-medium text-brand-600 hover:text-brand-500",
|
className: "font-medium text-brand-600 hover:text-brand-500",
|
||||||
children: "créez votre compte entreprise pour 1€"
|
children: "créez votre compte entreprise pour 1€"
|
||||||
}, void 0, false, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 28,
|
lineNumber: 52,
|
||||||
columnNumber: 28
|
columnNumber: 28
|
||||||
}, ("TURBOPACK compile-time value", void 0))
|
}, ("TURBOPACK compile-time value", void 0))
|
||||||
]
|
]
|
||||||
}, void 0, true, {
|
}, void 0, true, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 27,
|
lineNumber: 51,
|
||||||
columnNumber: 21
|
columnNumber: 21
|
||||||
}, ("TURBOPACK compile-time value", void 0))
|
}, ("TURBOPACK compile-time value", void 0))
|
||||||
]
|
]
|
||||||
}, void 0, true, {
|
}, void 0, true, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 24,
|
lineNumber: 48,
|
||||||
columnNumber: 17
|
columnNumber: 17
|
||||||
}, ("TURBOPACK compile-time value", void 0)),
|
}, ("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", {
|
/*#__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",
|
className: "mt-8 space-y-6",
|
||||||
onSubmit: handleSubmit,
|
onSubmit: handleSubmit,
|
||||||
children: [
|
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", {
|
/*#__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",
|
className: "rounded-md shadow-sm -space-y-px",
|
||||||
children: [
|
children: [
|
||||||
@@ -92,12 +126,12 @@ const LoginPage = ()=>{
|
|||||||
onChange: (e)=>setEmail(e.target.value)
|
onChange: (e)=>setEmail(e.target.value)
|
||||||
}, void 0, false, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 34,
|
lineNumber: 63,
|
||||||
columnNumber: 29
|
columnNumber: 29
|
||||||
}, ("TURBOPACK compile-time value", void 0))
|
}, ("TURBOPACK compile-time value", void 0))
|
||||||
}, void 0, false, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 33,
|
lineNumber: 62,
|
||||||
columnNumber: 25
|
columnNumber: 25
|
||||||
}, ("TURBOPACK compile-time value", void 0)),
|
}, ("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", {
|
/*#__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)
|
onChange: (e)=>setPassword(e.target.value)
|
||||||
}, void 0, false, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 44,
|
lineNumber: 73,
|
||||||
columnNumber: 29
|
columnNumber: 29
|
||||||
}, ("TURBOPACK compile-time value", void 0))
|
}, ("TURBOPACK compile-time value", void 0))
|
||||||
}, void 0, false, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 43,
|
lineNumber: 72,
|
||||||
columnNumber: 25
|
columnNumber: 25
|
||||||
}, ("TURBOPACK compile-time value", void 0))
|
}, ("TURBOPACK compile-time value", void 0))
|
||||||
]
|
]
|
||||||
}, void 0, true, {
|
}, void 0, true, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 32,
|
lineNumber: 61,
|
||||||
columnNumber: 21
|
columnNumber: 21
|
||||||
}, ("TURBOPACK compile-time value", void 0)),
|
}, ("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", {
|
/*#__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", {
|
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",
|
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",
|
disabled: loading,
|
||||||
children: "Se connecter"
|
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, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 55,
|
lineNumber: 84,
|
||||||
columnNumber: 25
|
columnNumber: 25
|
||||||
}, ("TURBOPACK compile-time value", void 0))
|
}, ("TURBOPACK compile-time value", void 0))
|
||||||
}, void 0, false, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 54,
|
lineNumber: 83,
|
||||||
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,
|
|
||||||
columnNumber: 21
|
columnNumber: 21
|
||||||
}, ("TURBOPACK compile-time value", void 0))
|
}, ("TURBOPACK compile-time value", void 0))
|
||||||
]
|
]
|
||||||
}, void 0, true, {
|
}, void 0, true, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 31,
|
lineNumber: 55,
|
||||||
columnNumber: 17
|
columnNumber: 17
|
||||||
}, ("TURBOPACK compile-time value", void 0))
|
}, ("TURBOPACK compile-time value", void 0))
|
||||||
]
|
]
|
||||||
}, void 0, true, {
|
}, void 0, true, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 23,
|
lineNumber: 47,
|
||||||
columnNumber: 13
|
columnNumber: 13
|
||||||
}, ("TURBOPACK compile-time value", void 0))
|
}, ("TURBOPACK compile-time value", void 0))
|
||||||
}, void 0, false, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 22,
|
lineNumber: 46,
|
||||||
columnNumber: 9
|
columnNumber: 9
|
||||||
}, ("TURBOPACK compile-time value", void 0));
|
}, ("TURBOPACK compile-time value", void 0));
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
|||||||
(globalThis.TURBOPACK_CHUNK_LISTS || (globalThis.TURBOPACK_CHUNK_LISTS = [])).push({
|
(globalThis.TURBOPACK_CHUNK_LISTS || (globalThis.TURBOPACK_CHUNK_LISTS = [])).push({
|
||||||
script: typeof document === "object" ? document.currentScript : undefined,
|
script: typeof document === "object" ? document.currentScript : undefined,
|
||||||
chunks: [
|
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"
|
"static/chunks/Documents_00 - projet_afropreunariat_app_globals_062f25c8.css"
|
||||||
],
|
],
|
||||||
source: "dynamic"
|
source: "dynamic"
|
||||||
|
|||||||
@@ -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$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$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$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 __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();
|
var _s = __turbopack_context__.k.signature();
|
||||||
@@ -16,17 +17,42 @@ var _s = __turbopack_context__.k.signature();
|
|||||||
;
|
;
|
||||||
;
|
;
|
||||||
;
|
;
|
||||||
|
;
|
||||||
const LoginPage = ()=>{
|
const LoginPage = ()=>{
|
||||||
_s();
|
_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 { 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 [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 [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 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();
|
e.preventDefault();
|
||||||
// Mock login
|
setLoading(true);
|
||||||
login();
|
setError('');
|
||||||
router.push('/dashboard');
|
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", {
|
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",
|
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"
|
children: "A"
|
||||||
}, void 0, false, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 25,
|
lineNumber: 49,
|
||||||
columnNumber: 21
|
columnNumber: 21
|
||||||
}, ("TURBOPACK compile-time value", void 0)),
|
}, ("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", {
|
/*#__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"
|
children: "Connexion à votre espace"
|
||||||
}, void 0, false, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 26,
|
lineNumber: 50,
|
||||||
columnNumber: 21
|
columnNumber: 21
|
||||||
}, ("TURBOPACK compile-time value", void 0)),
|
}, ("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", {
|
/*#__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",
|
className: "mt-2 text-sm text-gray-600",
|
||||||
children: [
|
children: [
|
||||||
"Ou ",
|
"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", {
|
/*#__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: "#",
|
href: "/register",
|
||||||
className: "font-medium text-brand-600 hover:text-brand-500",
|
className: "font-medium text-brand-600 hover:text-brand-500",
|
||||||
children: "créez votre compte entreprise pour 1€"
|
children: "créez votre compte entreprise pour 1€"
|
||||||
}, void 0, false, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 28,
|
lineNumber: 52,
|
||||||
columnNumber: 28
|
columnNumber: 28
|
||||||
}, ("TURBOPACK compile-time value", void 0))
|
}, ("TURBOPACK compile-time value", void 0))
|
||||||
]
|
]
|
||||||
}, void 0, true, {
|
}, void 0, true, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 27,
|
lineNumber: 51,
|
||||||
columnNumber: 21
|
columnNumber: 21
|
||||||
}, ("TURBOPACK compile-time value", void 0))
|
}, ("TURBOPACK compile-time value", void 0))
|
||||||
]
|
]
|
||||||
}, void 0, true, {
|
}, void 0, true, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 24,
|
lineNumber: 48,
|
||||||
columnNumber: 17
|
columnNumber: 17
|
||||||
}, ("TURBOPACK compile-time value", void 0)),
|
}, ("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", {
|
/*#__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",
|
className: "mt-8 space-y-6",
|
||||||
onSubmit: handleSubmit,
|
onSubmit: handleSubmit,
|
||||||
children: [
|
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", {
|
/*#__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",
|
className: "rounded-md shadow-sm -space-y-px",
|
||||||
children: [
|
children: [
|
||||||
@@ -94,12 +128,12 @@ const LoginPage = ()=>{
|
|||||||
onChange: (e)=>setEmail(e.target.value)
|
onChange: (e)=>setEmail(e.target.value)
|
||||||
}, void 0, false, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 34,
|
lineNumber: 63,
|
||||||
columnNumber: 29
|
columnNumber: 29
|
||||||
}, ("TURBOPACK compile-time value", void 0))
|
}, ("TURBOPACK compile-time value", void 0))
|
||||||
}, void 0, false, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 33,
|
lineNumber: 62,
|
||||||
columnNumber: 25
|
columnNumber: 25
|
||||||
}, ("TURBOPACK compile-time value", void 0)),
|
}, ("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", {
|
/*#__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)
|
onChange: (e)=>setPassword(e.target.value)
|
||||||
}, void 0, false, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 44,
|
lineNumber: 73,
|
||||||
columnNumber: 29
|
columnNumber: 29
|
||||||
}, ("TURBOPACK compile-time value", void 0))
|
}, ("TURBOPACK compile-time value", void 0))
|
||||||
}, void 0, false, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 43,
|
lineNumber: 72,
|
||||||
columnNumber: 25
|
columnNumber: 25
|
||||||
}, ("TURBOPACK compile-time value", void 0))
|
}, ("TURBOPACK compile-time value", void 0))
|
||||||
]
|
]
|
||||||
}, void 0, true, {
|
}, void 0, true, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 32,
|
lineNumber: 61,
|
||||||
columnNumber: 21
|
columnNumber: 21
|
||||||
}, ("TURBOPACK compile-time value", void 0)),
|
}, ("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", {
|
/*#__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", {
|
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",
|
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",
|
disabled: loading,
|
||||||
children: "Se connecter"
|
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, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 55,
|
lineNumber: 84,
|
||||||
columnNumber: 25
|
columnNumber: 25
|
||||||
}, ("TURBOPACK compile-time value", void 0))
|
}, ("TURBOPACK compile-time value", void 0))
|
||||||
}, void 0, false, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 54,
|
lineNumber: 83,
|
||||||
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,
|
|
||||||
columnNumber: 21
|
columnNumber: 21
|
||||||
}, ("TURBOPACK compile-time value", void 0))
|
}, ("TURBOPACK compile-time value", void 0))
|
||||||
]
|
]
|
||||||
}, void 0, true, {
|
}, void 0, true, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 31,
|
lineNumber: 55,
|
||||||
columnNumber: 17
|
columnNumber: 17
|
||||||
}, ("TURBOPACK compile-time value", void 0))
|
}, ("TURBOPACK compile-time value", void 0))
|
||||||
]
|
]
|
||||||
}, void 0, true, {
|
}, void 0, true, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 23,
|
lineNumber: 47,
|
||||||
columnNumber: 13
|
columnNumber: 13
|
||||||
}, ("TURBOPACK compile-time value", void 0))
|
}, ("TURBOPACK compile-time value", void 0))
|
||||||
}, void 0, false, {
|
}, void 0, false, {
|
||||||
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
fileName: "[project]/Documents/00 - projet/afropreunariat/app/login/page.tsx",
|
||||||
lineNumber: 22,
|
lineNumber: 46,
|
||||||
columnNumber: 9
|
columnNumber: 9
|
||||||
}, ("TURBOPACK compile-time value", void 0));
|
}, ("TURBOPACK compile-time value", void 0));
|
||||||
};
|
};
|
||||||
_s(LoginPage, "qGJtimaX2V4ZJh9/GR5AzR7oWcs=", false, function() {
|
_s(LoginPage, "nG8y4kb2uOxnA1iKpg1TA+sZcpM=", false, function() {
|
||||||
return [
|
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$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"]
|
__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
File diff suppressed because one or more lines are too long
10
.next/dev/types/routes.d.ts
vendored
10
.next/dev/types/routes.d.ts
vendored
@@ -1,8 +1,8 @@
|
|||||||
// This file is generated automatically by Next.js
|
// This file is generated automatically by Next.js
|
||||||
// Do not edit this file manually
|
// Do not edit this file manually
|
||||||
|
|
||||||
type AppRoutes = "/" | "/afrolife" | "/afrolife/[id]" | "/blog" | "/blog/[id]" | "/dashboard" | "/directory" | "/directory/[id]" | "/login" | "/subscription"
|
type AppRoutes = "/" | "/afrolife" | "/afrolife/[id]" | "/blog" | "/blog/[id]" | "/dashboard" | "/directory" | "/directory/[id]" | "/login" | "/register" | "/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 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 PageRoutes = never
|
||||||
type LayoutRoutes = "/"
|
type LayoutRoutes = "/"
|
||||||
type RedirectRoutes = never
|
type RedirectRoutes = never
|
||||||
@@ -14,10 +14,15 @@ interface ParamMap {
|
|||||||
"/": {}
|
"/": {}
|
||||||
"/afrolife": {}
|
"/afrolife": {}
|
||||||
"/afrolife/[id]": { "id": string; }
|
"/afrolife/[id]": { "id": string; }
|
||||||
|
"/api/analytics": {}
|
||||||
|
"/api/auth/login": {}
|
||||||
|
"/api/auth/register": {}
|
||||||
"/api/blog": {}
|
"/api/blog": {}
|
||||||
"/api/blog/[id]": { "id": string; }
|
"/api/blog/[id]": { "id": string; }
|
||||||
"/api/businesses": {}
|
"/api/businesses": {}
|
||||||
"/api/businesses/[id]": { "id": string; }
|
"/api/businesses/[id]": { "id": string; }
|
||||||
|
"/api/businesses/[id]/view": { "id": string; }
|
||||||
|
"/api/businesses/me": {}
|
||||||
"/api/gemini": {}
|
"/api/gemini": {}
|
||||||
"/api/interviews": {}
|
"/api/interviews": {}
|
||||||
"/api/interviews/[id]": { "id": string; }
|
"/api/interviews/[id]": { "id": string; }
|
||||||
@@ -30,6 +35,7 @@ interface ParamMap {
|
|||||||
"/directory": {}
|
"/directory": {}
|
||||||
"/directory/[id]": { "id": string; }
|
"/directory/[id]": { "id": string; }
|
||||||
"/login": {}
|
"/login": {}
|
||||||
|
"/register": {}
|
||||||
"/subscription": {}
|
"/subscription": {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,15 @@ type RouteHandlerConfig<Route extends AppRouteHandlerRoutes = AppRouteHandlerRou
|
|||||||
type __Unused = __Check
|
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
|
// Validate ../../../app/subscription/page.tsx
|
||||||
{
|
{
|
||||||
type __IsExpected<Specific extends AppPageConfig<"/subscription">> = Specific
|
type __IsExpected<Specific extends AppPageConfig<"/subscription">> = Specific
|
||||||
@@ -137,6 +146,33 @@ type RouteHandlerConfig<Route extends AppRouteHandlerRoutes = AppRouteHandlerRou
|
|||||||
type __Unused = __Check
|
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
|
// Validate ../../../app/api/blog/[id]/route.ts
|
||||||
{
|
{
|
||||||
type __IsExpected<Specific extends RouteHandlerConfig<"/api/blog/[id]">> = Specific
|
type __IsExpected<Specific extends RouteHandlerConfig<"/api/blog/[id]">> = Specific
|
||||||
@@ -164,6 +200,24 @@ type RouteHandlerConfig<Route extends AppRouteHandlerRoutes = AppRouteHandlerRou
|
|||||||
type __Unused = __Check
|
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
|
// Validate ../../../app/api/businesses/route.ts
|
||||||
{
|
{
|
||||||
type __IsExpected<Specific extends RouteHandlerConfig<"/api/businesses">> = Specific
|
type __IsExpected<Specific extends RouteHandlerConfig<"/api/businesses">> = Specific
|
||||||
|
|||||||
41
admin/.gitignore
vendored
Normal file
41
admin/.gitignore
vendored
Normal 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
5
admin/AGENTS.md
Normal 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
1
admin/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
36
admin/README.md
Normal file
36
admin/README.md
Normal 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
18
admin/eslint.config.mjs
Normal 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
7
admin/next.config.ts
Normal 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
7845
admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
admin/package.json
Normal file
37
admin/package.json
Normal 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
7
admin/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
13
admin/prisma.config.ts
Normal file
13
admin/prisma.config.ts
Normal 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
147
admin/prisma/schema.prisma
Normal 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
1
admin/public/file.svg
Normal 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
1
admin/public/globe.svg
Normal 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
1
admin/public/next.svg
Normal 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
1
admin/public/vercel.svg
Normal 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
1
admin/public/window.svg
Normal 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 |
58
admin/src/app/actions/blog.ts
Normal file
58
admin/src/app/actions/blog.ts
Normal 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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
63
admin/src/app/actions/business.ts
Normal file
63
admin/src/app/actions/business.ts
Normal 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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
18
admin/src/app/actions/comment.ts
Normal file
18
admin/src/app/actions/comment.ts
Normal 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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
67
admin/src/app/actions/interview.ts
Normal file
67
admin/src/app/actions/interview.ts
Normal 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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
16
admin/src/app/blog/edit/[id]/page.tsx
Normal file
16
admin/src/app/blog/edit/[id]/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
5
admin/src/app/blog/new/page.tsx
Normal file
5
admin/src/app/blog/new/page.tsx
Normal 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
125
admin/src/app/blog/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
admin/src/app/comments/page.tsx
Normal file
75
admin/src/app/comments/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
admin/src/app/dashboard/page.tsx
Normal file
75
admin/src/app/dashboard/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
admin/src/app/entrepreneurs/page.tsx
Normal file
95
admin/src/app/entrepreneurs/page.tsx
Normal 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
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
195
admin/src/app/globals.css
Normal 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;
|
||||||
|
}
|
||||||
16
admin/src/app/interview/edit/[id]/page.tsx
Normal file
16
admin/src/app/interview/edit/[id]/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
5
admin/src/app/interview/new/page.tsx
Normal file
5
admin/src/app/interview/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import InterviewForm from "@/components/InterviewForm";
|
||||||
|
|
||||||
|
export default function NewInterviewPage() {
|
||||||
|
return <InterviewForm />;
|
||||||
|
}
|
||||||
30
admin/src/app/layout.tsx
Normal file
30
admin/src/app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
admin/src/app/metrics/page.tsx
Normal file
205
admin/src/app/metrics/page.tsx
Normal 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
5
admin/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
138
admin/src/components/BlogForm.tsx
Normal file
138
admin/src/components/BlogForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
admin/src/components/DeleteBlogButton.tsx
Normal file
24
admin/src/components/DeleteBlogButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
admin/src/components/DeleteCommentButton.tsx
Normal file
39
admin/src/components/DeleteCommentButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
admin/src/components/DeleteInterviewButton.tsx
Normal file
24
admin/src/components/DeleteInterviewButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
admin/src/components/FeaturedModal.tsx
Normal file
140
admin/src/components/FeaturedModal.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
admin/src/components/InterviewForm.tsx
Normal file
187
admin/src/components/InterviewForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
admin/src/components/Sidebar.tsx
Normal file
59
admin/src/components/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
admin/src/components/ToggleVerifyButton.tsx
Normal file
42
admin/src/components/ToggleVerifyButton.tsx
Normal 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
20
admin/src/lib/prisma.ts
Normal 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
26
admin/test-db.js
Normal 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
34
admin/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
30
app/api/analytics/route.ts
Normal file
30
app/api/analytics/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/api/auth/login/route.ts
Normal file
53
app/api/auth/login/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/api/auth/register/route.ts
Normal file
64
app/api/auth/register/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/api/businesses/[id]/view/route.ts
Normal file
32
app/api/businesses/[id]/view/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/api/businesses/me/route.ts
Normal file
27
app/api/businesses/me/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +1,128 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../lib/prisma'
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const category = searchParams.get('category')
|
const category = searchParams.get('category')
|
||||||
const q = searchParams.get('q')
|
const q = searchParams.get('q')?.toLowerCase()
|
||||||
const featured = searchParams.get('featured')
|
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 (category && category !== 'All') where.category = category
|
||||||
if (featured === 'true') where.isFeatured = true
|
if (featured === 'true') where.isFeatured = true
|
||||||
if (q) {
|
if (q) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ name: { contains: q, mode: 'insensitive' } },
|
{ name: { contains: q, mode: 'insensitive' } },
|
||||||
{ description: { 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,
|
where,
|
||||||
include: { owner: true, offers: true },
|
include: { owner: true, offers: true },
|
||||||
orderBy: { createdAt: 'desc' },
|
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) {
|
} catch (error) {
|
||||||
console.error('GET /api/businesses error:', error)
|
console.error('GET /api/businesses error:', error)
|
||||||
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
|
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/businesses — Create a business
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const business = await prisma.business.create({
|
const headerUserId = request.headers.get('x-user-id')
|
||||||
data: body,
|
const { ownerId: bodyOwnerId, ...data } = body
|
||||||
include: { owner: true },
|
|
||||||
})
|
const ownerId = bodyOwnerId || headerUserId
|
||||||
return NextResponse.json(business, { status: 201 })
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('POST /api/businesses error:', 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 l’enregistrement' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { LayoutDashboard, Edit3, ShoppingBag, CreditCard, LogOut, Eye } from 'lucide-react';
|
import { LayoutDashboard, Edit3, ShoppingBag, CreditCard, LogOut, Eye } from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
@@ -17,13 +17,73 @@ const DashboardPage = () => {
|
|||||||
const { user, logout } = useUser();
|
const { user, logout } = useUser();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [currentView, setCurrentView] = useState<'overview' | 'profile' | 'offers' | 'subscription'>('overview');
|
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 (!user) {
|
||||||
if (typeof window !== 'undefined') router.push('/login');
|
if (typeof window !== 'undefined') router.push('/login');
|
||||||
return null;
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100 flex">
|
<div className="min-h-screen bg-gray-100 flex">
|
||||||
{/* 1. Sidebar */}
|
{/* 1. Sidebar */}
|
||||||
@@ -88,7 +148,7 @@ const DashboardPage = () => {
|
|||||||
{currentView === 'subscription' && 'Mon Abonnement'}
|
{currentView === 'subscription' && 'Mon Abonnement'}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center space-x-4">
|
<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" />
|
<Eye className="-ml-1 mr-2 h-4 w-4 text-gray-500" />
|
||||||
Voir ma fiche
|
Voir ma fiche
|
||||||
</a>
|
</a>
|
||||||
@@ -97,8 +157,8 @@ const DashboardPage = () => {
|
|||||||
|
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{currentView === 'overview' && <DashboardOverview business={business} />}
|
{currentView === 'overview' && <DashboardOverview business={displayBusiness} />}
|
||||||
{currentView === 'profile' && <DashboardProfile business={business} setBusiness={setBusiness} />}
|
{currentView === 'profile' && <DashboardProfile business={displayBusiness} setBusiness={setBusiness} />}
|
||||||
{currentView === 'offers' && <DashboardOffers />}
|
{currentView === 'offers' && <DashboardOffers />}
|
||||||
{currentView === 'subscription' && (
|
{currentView === 'subscription' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import Link from 'next/link';
|
|||||||
import { useParams } from 'next/navigation';
|
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 { 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 { MOCK_BUSINESSES, MOCK_OFFERS } from '../../../lib/mockData';
|
||||||
import { OfferType } from '../../../types';
|
import { Business, OfferType } from '../../../types';
|
||||||
|
|
||||||
const BusinessDetailPage = () => {
|
const BusinessDetailPage = () => {
|
||||||
const { id } = useParams();
|
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({
|
const [contactForm, setContactForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -20,13 +21,59 @@ const BusinessDetailPage = () => {
|
|||||||
const [formStatus, setFormStatus] = useState<'idle' | 'sending' | 'success'>('idle');
|
const [formStatus, setFormStatus] = useState<'idle' | 'sending' | 'success'>('idle');
|
||||||
|
|
||||||
const businessOffers = useMemo(() => {
|
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);
|
return MOCK_OFFERS.filter(o => o.businessId === id && o.active);
|
||||||
}, [id]);
|
}, [business, id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.scrollTo(0, 0);
|
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]);
|
}, [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) {
|
if (!business) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[60vh] flex flex-col items-center justify-center">
|
<div className="min-h-[60vh] flex flex-col items-center justify-center">
|
||||||
|
|||||||
@@ -3,39 +3,70 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useMemo, Suspense } from 'react';
|
import React, { useState, useEffect, useMemo, Suspense } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { Search } from 'lucide-react';
|
import { Search, Loader2 } from 'lucide-react';
|
||||||
import { MOCK_BUSINESSES } from '../../lib/mockData';
|
import { CATEGORIES, Business } from '../../types';
|
||||||
import { CATEGORIES } from '../../types';
|
|
||||||
import BusinessCard from '../../components/BusinessCard';
|
import BusinessCard from '../../components/BusinessCard';
|
||||||
import DirectoryHero from '../../components/DirectoryHero';
|
import DirectoryHero from '../../components/DirectoryHero';
|
||||||
|
|
||||||
const DirectoryPageContent = () => {
|
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 [filterCategory, setFilterCategory] = useState('All');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const searchParams = useSearchParams();
|
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(() => {
|
useEffect(() => {
|
||||||
const q = searchParams.get('q');
|
const q = searchParams.get('q');
|
||||||
if (q) setSearchQuery(q);
|
if (q) setSearchQuery(q);
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const featuredBusiness = useMemo(() => {
|
const filteredBusinesses = businesses;
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* New Hero Section */}
|
{/* Tête d'affiche (Headliner) - Always visible regardless of filters */}
|
||||||
{featuredBusiness && <DirectoryHero featuredBusiness={featuredBusiness} />}
|
{featuredBusiness && <DirectoryHero featuredBusiness={featuredBusiness} />}
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<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>
|
<span className="text-sm text-gray-500">{filteredBusinesses.length} résultats</span>
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{filteredBusinesses.map(biz => (
|
{filteredBusinesses.map(biz => (
|
||||||
<BusinessCard key={biz.id} business={biz} />
|
<BusinessCard key={biz.id} business={biz} />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Script from 'next/script';
|
|||||||
import Navbar from '../components/Navbar';
|
import Navbar from '../components/Navbar';
|
||||||
import Footer from '../components/Footer';
|
import Footer from '../components/Footer';
|
||||||
import { UserProvider } from '../components/UserProvider';
|
import { UserProvider } from '../components/UserProvider';
|
||||||
|
import AnalyticsTracker from '../components/AnalyticsTracker';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
</head>
|
</head>
|
||||||
<body className="bg-gray-50 text-gray-900 antialiased min-h-screen flex flex-col font-sans">
|
<body className="bg-gray-50 text-gray-900 antialiased min-h-screen flex flex-col font-sans">
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
|
<AnalyticsTracker />
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main className="flex-grow">
|
<main className="flex-grow">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -3,19 +3,43 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
import { useUser } from '../../components/UserProvider';
|
import { useUser } from '../../components/UserProvider';
|
||||||
|
|
||||||
const LoginPage = () => {
|
const LoginPage = () => {
|
||||||
const { login } = useUser();
|
const { login } = useUser();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Mock login
|
setLoading(true);
|
||||||
login();
|
setError('');
|
||||||
router.push('/dashboard');
|
|
||||||
|
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 (
|
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>
|
<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>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
<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 className="rounded-md shadow-sm -space-y-px">
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
@@ -52,13 +81,14 @@ const LoginPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<button
|
||||||
Se connecter
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-center text-gray-500">
|
|
||||||
(Compte démo : n'importe quel email/mdp fonctionne)
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
45
app/page.tsx
45
app/page.tsx
@@ -1,17 +1,34 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
import React, { useState } from 'react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Search, MapPin, Briefcase, TrendingUp } from 'lucide-react';
|
import { Search, MapPin, Briefcase, TrendingUp, Loader2 } from 'lucide-react';
|
||||||
import { CATEGORIES } from '../types';
|
import { CATEGORIES, Business } from '../types';
|
||||||
import { MOCK_BUSINESSES } from '../lib/mockData';
|
|
||||||
import BusinessCard from '../components/BusinessCard';
|
import BusinessCard from '../components/BusinessCard';
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
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) => {
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -23,7 +40,6 @@ const HomePage = () => {
|
|||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<div className="relative bg-dark-900 overflow-hidden">
|
<div className="relative bg-dark-900 overflow-hidden">
|
||||||
<div className="absolute inset-0 opacity-40">
|
<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" />
|
<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>
|
||||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32">
|
<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" />
|
Voir tout l'annuaire <TrendingUp className="w-4 h-4 ml-1" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
{MOCK_BUSINESSES.map(biz => (
|
{loading ? (
|
||||||
<BusinessCard key={biz.id} business={biz} />
|
<div className="flex justify-center py-12">
|
||||||
))}
|
<Loader2 className="w-10 h-10 text-brand-600 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{featuredBusinesses.map(biz => (
|
||||||
|
<BusinessCard key={biz.id} business={biz} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
203
app/register/page.tsx
Normal file
203
app/register/page.tsx
Normal 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 © 2026
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RegisterPage;
|
||||||
90
components/AnalyticsTracker.tsx
Normal file
90
components/AnalyticsTracker.tsx
Normal 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
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { MOCK_USER } from '../lib/mockData';
|
|||||||
|
|
||||||
interface UserContextType {
|
interface UserContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
login: () => void;
|
login: (user: User) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,12 +15,21 @@ const UserContext = createContext<UserContextType | undefined>(undefined);
|
|||||||
export function UserProvider({ children }: { children: ReactNode }) {
|
export function UserProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
|
||||||
const login = () => {
|
React.useEffect(() => {
|
||||||
setUser(MOCK_USER);
|
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 = () => {
|
const logout = () => {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
localStorage.removeItem('afro_user');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
|||||||
const [videoInput, setVideoInput] = useState(business.videoUrl || '');
|
const [videoInput, setVideoInput] = useState(business.videoUrl || '');
|
||||||
const [videoPreviewId, setVideoPreviewId] = useState<string | null>(getYouTubeId(business.videoUrl || ''));
|
const [videoPreviewId, setVideoPreviewId] = useState<string | null>(getYouTubeId(business.videoUrl || ''));
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
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>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||||
@@ -44,9 +56,33 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
|||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
setBusiness(formData);
|
try {
|
||||||
alert('Profil mis à jour avec succès !');
|
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 (
|
return (
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const MOCK_BUSINESSES: Business[] = [
|
|||||||
viewCount: 850,
|
viewCount: 850,
|
||||||
rating: 4.9,
|
rating: 4.9,
|
||||||
tags: ['Traiteur', 'Bio', 'Événementiel', 'Gastronomie'],
|
tags: ['Traiteur', 'Bio', 'Événementiel', 'Gastronomie'],
|
||||||
|
isFeatured: true,
|
||||||
founderName: 'Aïssa Maïga',
|
founderName: 'Aïssa Maïga',
|
||||||
socialLinks: { instagram: 'https://instagram.com' }
|
socialLinks: { instagram: 'https://instagram.com' }
|
||||||
},
|
},
|
||||||
@@ -83,6 +84,7 @@ export const MOCK_BUSINESSES: Business[] = [
|
|||||||
viewCount: 560,
|
viewCount: 560,
|
||||||
rating: 4.7,
|
rating: 4.7,
|
||||||
tags: ['Énergie', 'Solaire', 'Durable', 'Tech'],
|
tags: ['Énergie', 'Solaire', 'Durable', 'Tech'],
|
||||||
|
isFeatured: true,
|
||||||
founderName: 'Ousmane Diop'
|
founderName: 'Ousmane Diop'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -152,6 +154,7 @@ export const MOCK_BUSINESSES: Business[] = [
|
|||||||
viewCount: 670,
|
viewCount: 670,
|
||||||
rating: 4.8,
|
rating: 4.8,
|
||||||
tags: ['Architecture', 'Écologie', 'BTC', 'Durable'],
|
tags: ['Architecture', 'Écologie', 'BTC', 'Durable'],
|
||||||
|
isFeatured: true,
|
||||||
founderName: 'Fatimata Sylla',
|
founderName: 'Fatimata Sylla',
|
||||||
founderImageUrl: 'https://images.unsplash.com/photo-1542596594-649edbc13630?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80'
|
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
561
package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@prisma/adapter-pg": "^7.4.1",
|
"@prisma/adapter-pg": "^7.4.1",
|
||||||
"@prisma/client": "^7.4.1",
|
"@prisma/client": "^7.4.1",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"pg": "^8.19.0",
|
"pg": "^8.19.0",
|
||||||
@@ -21,12 +22,14 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@prisma/config": "^7.4.1",
|
"@prisma/config": "^7.4.1",
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prisma": "^7.4.1",
|
"prisma": "^7.4.1",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "~5.8.2"
|
"typescript": "~5.8.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -120,6 +123,448 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@google/genai": {
|
||||||
"version": "1.42.0",
|
"version": "1.42.0",
|
||||||
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.42.0.tgz",
|
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.42.0.tgz",
|
||||||
@@ -1382,6 +1827,13 @@
|
|||||||
"tailwindcss": "4.2.1"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.11",
|
"version": "22.19.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
||||||
@@ -1540,6 +1992,15 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/bignumber.js": {
|
||||||
"version": "9.3.1",
|
"version": "9.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||||
@@ -1913,6 +2374,48 @@
|
|||||||
"node": ">=10.13.0"
|
"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": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -2024,6 +2527,21 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"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": {
|
"node_modules/gaxios": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
|
||||||
@@ -2070,6 +2588,19 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/giget": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
||||||
@@ -3296,6 +3827,16 @@
|
|||||||
"url": "https://github.com/sponsors/remeda"
|
"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": {
|
"node_modules/retry": {
|
||||||
"version": "0.13.1",
|
"version": "0.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||||
@@ -3641,6 +4182,26 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.8.3",
|
"version": "5.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@prisma/adapter-pg": "^7.4.1",
|
"@prisma/adapter-pg": "^7.4.1",
|
||||||
"@prisma/client": "^7.4.1",
|
"@prisma/client": "^7.4.1",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"pg": "^8.19.0",
|
"pg": "^8.19.0",
|
||||||
@@ -23,12 +24,17 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@prisma/config": "^7.4.1",
|
"@prisma/config": "^7.4.1",
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prisma": "^7.4.1",
|
"prisma": "^7.4.1",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "~5.8.2"
|
"typescript": "~5.8.2"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ model User {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
email String @unique
|
email String @unique
|
||||||
|
password String
|
||||||
role UserRole @default(VISITOR)
|
role UserRole @default(VISITOR)
|
||||||
avatar String?
|
avatar String?
|
||||||
|
|
||||||
businesses Business[]
|
businesses Business[]
|
||||||
|
comments Comment[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
@@ -52,9 +54,10 @@ model Business {
|
|||||||
keyMetric String?
|
keyMetric String?
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
ownerId String
|
ownerId String @unique
|
||||||
owner User @relation(fields: [ownerId], references: [id])
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
offers Offer[]
|
offers Offer[]
|
||||||
|
comments Comment[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -118,3 +121,27 @@ model Interview {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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
155
prisma/seed.ts
Normal 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
24
test-analytics.js
Normal 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
25
test-analytics.mjs
Normal 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
41
tmp/test-db-business.ts
Normal 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
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
1
types.ts
1
types.ts
@@ -42,6 +42,7 @@ export interface Business {
|
|||||||
founderName?: string;
|
founderName?: string;
|
||||||
founderImageUrl?: string;
|
founderImageUrl?: string;
|
||||||
keyMetric?: string; // "3000 clients delivered"
|
keyMetric?: string; // "3000 clients delivered"
|
||||||
|
offers?: Offer[]; // Joined from database or from mocks
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum OfferType {
|
export enum OfferType {
|
||||||
|
|||||||
Reference in New Issue
Block a user