diff --git a/admin/prisma/migrations/0_init/migration.sql b/admin/prisma/migrations/0_init/migration.sql
new file mode 100644
index 0000000..d14cf34
Binary files /dev/null and b/admin/prisma/migrations/0_init/migration.sql differ
diff --git a/admin/prisma/migrations/20260427214502_init/migration.sql b/admin/prisma/migrations/20260427214502_init/migration.sql
new file mode 100644
index 0000000..220c22c
--- /dev/null
+++ b/admin/prisma/migrations/20260427214502_init/migration.sql
@@ -0,0 +1,459 @@
+-- CreateEnum
+CREATE TYPE "RatingStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
+
+-- CreateEnum
+CREATE TYPE "ReportStatus" AS ENUM ('PENDING', 'RESOLVED', 'DISMISSED');
+
+-- CreateEnum
+CREATE TYPE "UserRole" AS ENUM ('VISITOR', 'ENTREPRENEUR', 'ADMIN');
+
+-- CreateEnum
+CREATE TYPE "Plan" AS ENUM ('STARTER', 'BOOSTER', 'EMPIRE');
+
+-- CreateEnum
+CREATE TYPE "OfferType" AS ENUM ('PRODUCT', 'SERVICE');
+
+-- CreateEnum
+CREATE TYPE "InterviewType" AS ENUM ('VIDEO', 'ARTICLE');
+
+-- CreateTable
+CREATE TABLE "User" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "email" TEXT NOT NULL,
+ "password" TEXT NOT NULL,
+ "role" "UserRole" NOT NULL DEFAULT 'VISITOR',
+ "avatar" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "bio" TEXT,
+ "location" TEXT,
+ "phone" TEXT,
+ "isSuspended" BOOLEAN NOT NULL DEFAULT false,
+ "suspensionReason" TEXT,
+ "countryId" TEXT,
+ "resetToken" TEXT,
+ "resetTokenExpiry" TIMESTAMP(3),
+ "emailVerified" BOOLEAN NOT NULL DEFAULT false,
+ "verificationToken" TEXT,
+
+ CONSTRAINT "User_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Business" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "category" TEXT NOT NULL,
+ "location" TEXT NOT NULL,
+ "description" TEXT NOT NULL,
+ "logoUrl" TEXT NOT NULL,
+ "videoUrl" TEXT,
+ "contactEmail" TEXT NOT NULL,
+ "contactPhone" TEXT,
+ "socialLinks" JSONB,
+ "verified" BOOLEAN NOT NULL DEFAULT false,
+ "viewCount" INTEGER NOT NULL DEFAULT 0,
+ "rating" DOUBLE PRECISION NOT NULL DEFAULT 0.0,
+ "ratingCount" INTEGER NOT NULL DEFAULT 0,
+ "tags" TEXT[],
+ "isFeatured" BOOLEAN NOT NULL DEFAULT false,
+ "founderName" TEXT,
+ "founderImageUrl" TEXT,
+ "keyMetric" TEXT,
+ "ownerId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "slug" TEXT,
+ "isActive" BOOLEAN NOT NULL DEFAULT false,
+ "showEmail" BOOLEAN NOT NULL DEFAULT true,
+ "showPhone" BOOLEAN NOT NULL DEFAULT true,
+ "showSocials" BOOLEAN NOT NULL DEFAULT true,
+ "websiteUrl" TEXT,
+ "isSuspended" BOOLEAN NOT NULL DEFAULT false,
+ "suspensionReason" TEXT,
+ "plan" "Plan" NOT NULL DEFAULT 'STARTER',
+ "city" TEXT,
+ "countryId" TEXT,
+ "coverUrl" TEXT,
+ "coverPosition" TEXT DEFAULT '50% 50%',
+ "coverZoom" DOUBLE PRECISION DEFAULT 1.0,
+ "suggestedCategory" TEXT,
+ "categoryId" TEXT,
+
+ CONSTRAINT "Business_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "BusinessCategory" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "slug" TEXT NOT NULL,
+ "icon" TEXT,
+ "isActive" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "BusinessCategory_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Country" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "code" TEXT NOT NULL,
+ "flag" TEXT,
+ "isActive" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "description" TEXT,
+
+ CONSTRAINT "Country_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "PricingPlan" (
+ "id" TEXT NOT NULL,
+ "tier" "Plan" NOT NULL,
+ "name" TEXT NOT NULL,
+ "priceXOF" TEXT NOT NULL,
+ "priceEUR" TEXT NOT NULL,
+ "description" TEXT NOT NULL,
+ "features" TEXT[],
+ "offerLimit" INTEGER NOT NULL DEFAULT 1,
+ "recommended" BOOLEAN NOT NULL DEFAULT false,
+ "color" TEXT NOT NULL DEFAULT 'gray',
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "yearlyPriceEUR" TEXT,
+ "yearlyPriceXOF" TEXT,
+
+ CONSTRAINT "PricingPlan_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Offer" (
+ "id" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "type" "OfferType" NOT NULL,
+ "price" DOUBLE PRECISION NOT NULL,
+ "currency" TEXT NOT NULL DEFAULT 'XOF',
+ "description" TEXT,
+ "imageUrl" TEXT NOT NULL,
+ "active" BOOLEAN NOT NULL DEFAULT true,
+ "businessId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Offer_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "BlogPost" (
+ "id" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "excerpt" TEXT NOT NULL,
+ "content" TEXT NOT NULL,
+ "author" TEXT NOT NULL,
+ "date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "imageUrl" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "metaDescription" TEXT,
+ "metaTitle" TEXT,
+ "slug" TEXT,
+ "tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
+
+ CONSTRAINT "BlogPost_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Interview" (
+ "id" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "guestName" TEXT NOT NULL,
+ "companyName" TEXT NOT NULL,
+ "role" TEXT NOT NULL,
+ "type" "InterviewType" NOT NULL,
+ "thumbnailUrl" TEXT NOT NULL,
+ "videoUrl" TEXT,
+ "content" TEXT,
+ "excerpt" TEXT NOT NULL,
+ "date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "duration" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "metaDescription" TEXT,
+ "metaTitle" TEXT,
+ "slug" TEXT,
+ "tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
+
+ CONSTRAINT "Interview_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Comment" (
+ "id" TEXT NOT NULL,
+ "content" TEXT NOT NULL,
+ "authorId" TEXT NOT NULL,
+ "businessId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "AnalyticsEvent" (
+ "id" TEXT NOT NULL,
+ "type" TEXT NOT NULL,
+ "path" TEXT NOT NULL,
+ "label" TEXT,
+ "value" DOUBLE PRECISION,
+ "metadata" JSONB,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "browser" TEXT,
+ "city" TEXT,
+ "country" TEXT,
+ "device" TEXT,
+ "ip" TEXT,
+ "os" TEXT,
+ "referrer" TEXT,
+ "userId" TEXT,
+
+ CONSTRAINT "AnalyticsEvent_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Rating" (
+ "id" TEXT NOT NULL,
+ "value" INTEGER NOT NULL,
+ "userId" TEXT NOT NULL,
+ "businessId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "comment" TEXT,
+ "reply" TEXT,
+ "replyAt" TIMESTAMP(3),
+ "status" "RatingStatus" NOT NULL DEFAULT 'PENDING',
+
+ CONSTRAINT "Rating_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Conversation" (
+ "id" TEXT NOT NULL,
+ "businessId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Conversation_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "ConversationParticipant" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "conversationId" TEXT NOT NULL,
+ "isArchived" BOOLEAN NOT NULL DEFAULT false,
+
+ CONSTRAINT "ConversationParticipant_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Message" (
+ "id" TEXT NOT NULL,
+ "content" TEXT NOT NULL,
+ "senderId" TEXT NOT NULL,
+ "conversationId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "read" BOOLEAN NOT NULL DEFAULT false,
+
+ CONSTRAINT "Message_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "MessageReport" (
+ "id" TEXT NOT NULL,
+ "reason" TEXT,
+ "messageId" TEXT NOT NULL,
+ "reporterId" TEXT NOT NULL,
+ "status" "ReportStatus" NOT NULL DEFAULT 'PENDING',
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "MessageReport_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "SiteSetting" (
+ "id" TEXT NOT NULL DEFAULT 'singleton',
+ "siteName" TEXT NOT NULL DEFAULT 'Afrohub',
+ "siteSlogan" TEXT NOT NULL DEFAULT 'La plateforme de référence pour l''entrepreneuriat africain.',
+ "contactEmail" TEXT NOT NULL DEFAULT 'support@afrohub.com',
+ "contactPhone" TEXT DEFAULT '+225 00 00 00 00 00',
+ "address" TEXT DEFAULT 'Abidjan, Côte d''Ivoire',
+ "facebookUrl" TEXT,
+ "twitterUrl" TEXT,
+ "instagramUrl" TEXT,
+ "linkedinUrl" TEXT,
+ "footerText" TEXT DEFAULT '© 2025 Afrohub. Tous droits réservés.',
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "homeBlogCount" INTEGER NOT NULL DEFAULT 3,
+ "homeBlogShow" BOOLEAN NOT NULL DEFAULT true,
+ "homeBlogSubtitle" TEXT NOT NULL DEFAULT 'Toutes les actualités de l''entrepreneuriat africain.',
+ "homeBlogTitle" TEXT NOT NULL DEFAULT 'Derniers Articles',
+ "homeBlogCategories" TEXT[] DEFAULT ARRAY[]::TEXT[],
+ "homeBlogIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
+ "homeBlogSelection" TEXT NOT NULL DEFAULT 'latest',
+ "homeCategories" TEXT[] DEFAULT ARRAY['Technologie & IT', 'Agriculture & Agrobusiness', 'Mode & Textile', 'Cosmétique & Beauté']::TEXT[],
+ "resetPasswordSubject" TEXT NOT NULL DEFAULT 'Réinitialisation de votre mot de passe - Afrohub',
+ "resetPasswordTemplate" TEXT NOT NULL DEFAULT '
{siteName} Bonjour {name},
Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :
Ce lien expirera dans 1 heure. Si vous n''avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.
© 2025 {siteName}. Tous droits réservés.
',
+ "verifyEmailSubject" TEXT NOT NULL DEFAULT 'Vérification de votre compte - Afrohub',
+ "verifyEmailTemplate" TEXT NOT NULL DEFAULT '{siteName} Bonjour {name},
Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :
Si vous n''avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.
© 2025 {siteName}. Tous droits réservés.
',
+
+ CONSTRAINT "SiteSetting_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "LegalDocument" (
+ "id" TEXT NOT NULL,
+ "type" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "content" TEXT NOT NULL,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "LegalDocument_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Event" (
+ "id" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "description" TEXT NOT NULL,
+ "date" TIMESTAMP(3) NOT NULL,
+ "location" TEXT NOT NULL,
+ "thumbnailUrl" TEXT NOT NULL,
+ "link" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "metaDescription" TEXT,
+ "metaTitle" TEXT,
+ "slug" TEXT,
+ "tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
+
+ CONSTRAINT "Event_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Favorite" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "businessId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "Favorite_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_resetToken_key" ON "User"("resetToken");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_verificationToken_key" ON "User"("verificationToken");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Business_ownerId_key" ON "Business"("ownerId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Business_slug_key" ON "Business"("slug");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "BusinessCategory_name_key" ON "BusinessCategory"("name");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "BusinessCategory_slug_key" ON "BusinessCategory"("slug");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Country_name_key" ON "Country"("name");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Country_code_key" ON "Country"("code");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "PricingPlan_tier_key" ON "PricingPlan"("tier");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "BlogPost_slug_key" ON "BlogPost"("slug");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Interview_slug_key" ON "Interview"("slug");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Rating_userId_businessId_key" ON "Rating"("userId", "businessId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ConversationParticipant_userId_conversationId_key" ON "ConversationParticipant"("userId", "conversationId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "LegalDocument_type_key" ON "LegalDocument"("type");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Event_slug_key" ON "Event"("slug");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Favorite_userId_businessId_key" ON "Favorite"("userId", "businessId");
+
+-- AddForeignKey
+ALTER TABLE "User" ADD CONSTRAINT "User_countryId_fkey" FOREIGN KEY ("countryId") REFERENCES "Country"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Business" ADD CONSTRAINT "Business_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "BusinessCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Business" ADD CONSTRAINT "Business_countryId_fkey" FOREIGN KEY ("countryId") REFERENCES "Country"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Business" ADD CONSTRAINT "Business_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Offer" ADD CONSTRAINT "Offer_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Comment" ADD CONSTRAINT "Comment_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Rating" ADD CONSTRAINT "Rating_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Rating" ADD CONSTRAINT "Rating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Conversation" ADD CONSTRAINT "Conversation_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ConversationParticipant" ADD CONSTRAINT "ConversationParticipant_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ConversationParticipant" ADD CONSTRAINT "ConversationParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Message" ADD CONSTRAINT "Message_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Message" ADD CONSTRAINT "Message_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "MessageReport" ADD CONSTRAINT "MessageReport_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "MessageReport" ADD CONSTRAINT "MessageReport_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Favorite" ADD CONSTRAINT "Favorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Favorite" ADD CONSTRAINT "Favorite_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/admin/prisma/migrations/20260503171918_add_published_at/migration.sql b/admin/prisma/migrations/20260503171918_add_published_at/migration.sql
new file mode 100644
index 0000000..5c2a930
--- /dev/null
+++ b/admin/prisma/migrations/20260503171918_add_published_at/migration.sql
@@ -0,0 +1,8 @@
+-- AlterTable
+ALTER TABLE "BlogPost" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
+
+-- AlterTable
+ALTER TABLE "Event" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
+
+-- AlterTable
+ALTER TABLE "Interview" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
diff --git a/admin/prisma/migrations/20260503172958_add_content_status/migration.sql b/admin/prisma/migrations/20260503172958_add_content_status/migration.sql
new file mode 100644
index 0000000..f70ae9d
--- /dev/null
+++ b/admin/prisma/migrations/20260503172958_add_content_status/migration.sql
@@ -0,0 +1,11 @@
+-- CreateEnum
+CREATE TYPE "ContentStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
+
+-- AlterTable
+ALTER TABLE "BlogPost" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
+
+-- AlterTable
+ALTER TABLE "Event" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
+
+-- AlterTable
+ALTER TABLE "Interview" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
diff --git a/admin/prisma/migrations/migration_lock.toml b/admin/prisma/migrations/migration_lock.toml
new file mode 100644
index 0000000..044d57c
--- /dev/null
+++ b/admin/prisma/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (e.g., Git)
+provider = "postgresql"
diff --git a/admin/prisma/schema.prisma b/admin/prisma/schema.prisma
index ca723e3..ce93b4f 100644
--- a/admin/prisma/schema.prisma
+++ b/admin/prisma/schema.prisma
@@ -68,6 +68,8 @@ model Business {
websiteUrl String?
isSuspended Boolean @default(false)
suspensionReason String?
+ metaTitle String?
+ metaDescription String?
plan Plan @default(STARTER)
city String?
countryId String?
diff --git a/admin/src/app/actions/business.ts b/admin/src/app/actions/business.ts
index 3ff72d6..7469b6d 100644
--- a/admin/src/app/actions/business.ts
+++ b/admin/src/app/actions/business.ts
@@ -79,3 +79,23 @@ export async function getPendingVerificationCount() {
return 0;
}
}
+export async function updateBusinessSeo(id: string, data: {
+ metaTitle: string;
+ metaDescription: string;
+}) {
+ try {
+ await prisma.business.update({
+ where: { id },
+ data: {
+ metaTitle: data.metaTitle,
+ metaDescription: data.metaDescription,
+ },
+ });
+ revalidatePath("/users");
+ revalidatePath(`/annuaire/${id}`);
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to update business SEO:", error);
+ return { success: false, error: "Erreur lors de la mise à jour SEO" };
+ }
+}
diff --git a/admin/src/app/users/page.tsx b/admin/src/app/users/page.tsx
index ad40879..5830950 100644
--- a/admin/src/app/users/page.tsx
+++ b/admin/src/app/users/page.tsx
@@ -180,6 +180,8 @@ export default async function UsersPage({
userName={business.owner.name}
isBusinessSuspended={!!business.isSuspended}
isUserSuspended={!!business.owner?.isSuspended}
+ metaTitle={business.metaTitle}
+ metaDescription={business.metaDescription}
/>
diff --git a/admin/src/components/BusinessSeoModal.tsx b/admin/src/components/BusinessSeoModal.tsx
new file mode 100644
index 0000000..6dcde76
--- /dev/null
+++ b/admin/src/components/BusinessSeoModal.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import React, { useState, useTransition } from 'react';
+import { X, Save, Loader2, Globe } from 'lucide-react';
+import { updateBusinessSeo } from '@/app/actions/business';
+import { toast } from 'react-hot-toast';
+
+interface BusinessSeoModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ business: {
+ id: string;
+ name: string;
+ metaTitle?: string | null;
+ metaDescription?: string | null;
+ };
+}
+
+export default function BusinessSeoModal({ isOpen, onClose, business }: BusinessSeoModalProps) {
+ const [isPending, startTransition] = useTransition();
+ const [metaTitle, setMetaTitle] = useState(business.metaTitle || '');
+ const [metaDescription, setMetaDescription] = useState(business.metaDescription || '');
+
+ if (!isOpen) return null;
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ startTransition(async () => {
+ const result = await updateBusinessSeo(business.id, {
+ metaTitle,
+ metaDescription
+ });
+ if (result.success) {
+ toast.success("Configuration SEO mise à jour");
+ onClose();
+ } else {
+ toast.error(result.error || "Erreur lors de la mise à jour");
+ }
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
SEO & Partage
+
Personnaliser l'aperçu WhatsApp pour {business.name}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/admin/src/components/EntrepreneurActions.tsx b/admin/src/components/EntrepreneurActions.tsx
index f68f0c1..98ed46b 100644
--- a/admin/src/components/EntrepreneurActions.tsx
+++ b/admin/src/components/EntrepreneurActions.tsx
@@ -1,10 +1,11 @@
"use client";
import React, { useState } from 'react';
-import { ShieldAlert, ShieldCheck, ShoppingBag, User } from 'lucide-react';
+import { ShieldAlert, ShieldCheck, ShoppingBag, User, Globe } from 'lucide-react';
import { suspendUser, unsuspendUser, suspendBusiness, unsuspendBusiness } from '@/app/actions/suspension';
import { toast } from 'react-hot-toast';
import SuspensionModal from './SuspensionModal';
+import BusinessSeoModal from './BusinessSeoModal';
interface EntrepreneurActionsProps {
businessId: string;
@@ -13,6 +14,8 @@ interface EntrepreneurActionsProps {
userName: string;
isBusinessSuspended: boolean;
isUserSuspended: boolean;
+ metaTitle?: string | null;
+ metaDescription?: string | null;
}
export default function EntrepreneurActions({
@@ -21,9 +24,12 @@ export default function EntrepreneurActions({
userId,
userName,
isBusinessSuspended,
- isUserSuspended
+ isUserSuspended,
+ metaTitle,
+ metaDescription
}: EntrepreneurActionsProps) {
const [modalType, setModalType] = useState<'USER' | 'BUSINESS' | null>(null);
+ const [seoModalOpen, setSeoModalOpen] = useState(false);
const handleConfirm = async (reason: string) => {
if (modalType === 'USER') {
@@ -41,6 +47,16 @@ export default function EntrepreneurActions({
return (
+ {/* SEO Button */}
+ setSeoModalOpen(true)}
+ className="btn-action bg-indigo-500/10 text-indigo-500 hover:bg-indigo-500 hover:text-white"
+ title="Modifier le SEO / Partage WhatsApp"
+ >
+
+ SEO
+
+
{/* User Suspension */}
{isUserSuspended ? (
+
+ setSeoModalOpen(false)}
+ business={{
+ id: businessId,
+ name: businessName,
+ metaTitle,
+ metaDescription
+ }}
+ />
);
}
diff --git a/app/HomeClient.tsx b/app/HomeClient.tsx
new file mode 100644
index 0000000..a967cbb
--- /dev/null
+++ b/app/HomeClient.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import React, { useState, useEffect } from 'react';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { Search, MapPin, Briefcase, TrendingUp, Loader2, ArrowRight } from 'lucide-react';
+import { generateSlug } from '@/lib/utils';
+import { Business, BlogPost } from '@/types';
+import BusinessCard from '@/components/BusinessCard';
+import { useUser } from '@/components/UserProvider';
+
+interface Props {
+ initialFeatured: Business[];
+ initialPosts: BlogPost[];
+ initialCategories: any[];
+}
+
+const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props) => {
+ const router = useRouter();
+ const { user, settings } = useUser();
+ const [searchTerm, setSearchTerm] = useState('');
+ const [featuredBusinesses, setFeaturedBusinesses] = useState
(initialFeatured);
+ const [posts, setPosts] = useState(initialPosts);
+ const [categories, setCategories] = useState(initialCategories);
+ const [loading, setLoading] = useState(false);
+ const [loadingPosts, setLoadingPosts] = useState(false);
+
+ const handleSearch = (e: React.FormEvent) => {
+ e.preventDefault();
+ router.push(`/annuaire?q=${searchTerm}`);
+ };
+
+ return (
+
+ {/* Hero Section */}
+
+
+
+
+
+
+ Boostez votre visibilité dans
+ l'écosystème africain
+
+
+ L'annuaire 2.0 qui connecte les talents, les entrepreneurs et les entreprises de la diaspora et du continent.
+
+
+
+
+
+ setSearchTerm(e.target.value)}
+ />
+
+
+
+
+
+
+ Rechercher
+
+
+
+
+
+ {/* Featured Categories */}
+
+
Secteurs en vedette
+
+ {(settings?.homeCategories || categories.slice(0, 4).map(c => c.name)).map((cat: string, idx: number) => (
+
+
+
+
+
{cat}
+
+ ))}
+
+
+
+ {/* Featured Businesses */}
+
+
+
+
+
Entreprises à la une
+
Découvrez les pépites de notre communauté.
+
+
+ Voir tout l'annuaire
+
+
+
+
+ {featuredBusinesses.map(biz => (
+
+ ))}
+
+
+
+
+ {/* Blog Section */}
+ {settings?.homeBlogShow !== false && (
+
+
+
+
+
{settings?.homeBlogTitle || "Derniers Articles"}
+
{settings?.homeBlogSubtitle || "Toutes les actualités de l'entrepreneuriat africain."}
+
+
+ Voir tout le blog
+
+
+
+
+ {posts
+ .filter(post => {
+ if (settings?.homeBlogSelection === 'manual') {
+ return settings.homeBlogIds?.includes(post.id);
+ }
+ if (settings?.homeBlogSelection === 'category') {
+ return post.tags?.some((tag: string) => settings.homeBlogCategories?.includes(tag));
+ }
+ return true;
+ })
+ .slice(0, settings?.homeBlogCount || 3)
+ .map(post => (
+
+
+
+
+
+
{post.title}
+
{post.excerpt}
+
+ {new Date(post.date).toLocaleDateString('fr-FR')}
+ Lire la suite →
+
+
+
+ ))}
+
+
+
+ )}
+
+ );
+};
+
+export default HomeClient;
diff --git a/app/annuaire/[id]/BusinessDetailClient.tsx b/app/annuaire/[id]/BusinessDetailClient.tsx
new file mode 100644
index 0000000..a00366b
--- /dev/null
+++ b/app/annuaire/[id]/BusinessDetailClient.tsx
@@ -0,0 +1,469 @@
+"use client";
+
+import React, { useEffect, useMemo, useState } from 'react';
+import Link from 'next/link';
+import { useParams, useRouter } from 'next/navigation';
+import { ArrowLeft, MapPin, Globe, Mail, Phone, CheckCircle, Star, Facebook, Linkedin, Instagram, Twitter, Play, Share2, Send, Award, Quote, MessageCircle, Lock, Loader2, Link2, ShoppingBag, Heart } from 'lucide-react';
+import { Business, OfferType, Rating } from '@/types';
+import { toast } from 'react-hot-toast';
+import { useUser } from '@/components/UserProvider';
+
+interface Props {
+ initialBusiness: Business;
+}
+
+const BusinessDetailClient = ({ initialBusiness }: Props) => {
+ const { id } = useParams();
+ const router = useRouter();
+ const { user } = useUser();
+ const [business, setBusiness] = useState(initialBusiness);
+ const [loading, setLoading] = useState(false);
+ const [existingConversationId, setExistingConversationId] = useState(null);
+ const [isShareOpen, setIsShareOpen] = useState(false);
+ const [selectedOffer, setSelectedOffer] = useState(null);
+ const [userRating, setUserRating] = useState(null);
+ const [hoverRating, setHoverRating] = useState(null);
+ const [isRating, setIsRating] = useState(false);
+ const [ratings, setRatings] = useState([]);
+ const [reviewComment, setReviewComment] = useState('');
+ const [userRatingStatus, setUserRatingStatus] = useState<'PENDING' | 'APPROVED' | 'REJECTED' | null>(null);
+ const [loadingRatings, setLoadingRatings] = useState(false);
+ const [replyingTo, setReplyingTo] = useState(null);
+ const [replyText, setReplyText] = useState('');
+ const [isSubmittingReply, setIsSubmittingReply] = useState(false);
+ const [isFavorited, setIsFavorited] = useState(false);
+ const [isTogglingFavorite, setIsTogglingFavorite] = useState(false);
+
+ const isOwner = user && business && user.id === business.ownerId;
+ const contactRef = React.useRef(null);
+
+ const [contactForm, setContactForm] = useState({
+ name: '',
+ email: '',
+ message: ''
+ });
+ const [formStatus, setFormStatus] = useState<'idle' | 'sending' | 'success'>('idle');
+
+ const handleOrder = (offer: any) => {
+ setContactForm(prev => ({
+ ...prev,
+ message: `Bonjour, je souhaiterais commander ou avoir plus d'informations sur votre offre : "${offer.title}". Pouvez-vous me recontacter ?`
+ }));
+ setSelectedOffer(null);
+ contactRef.current?.scrollIntoView({ behavior: 'smooth' });
+ setTimeout(() => {
+ const textarea = document.getElementById('contact-message');
+ if (textarea) textarea.focus();
+ }, 800);
+ toast.success("Message préparé ! Vous pouvez l'envoyer ci-dessous.");
+ };
+
+ const businessOffers = useMemo(() => {
+ if (!business) return [];
+ if (business.offers && business.offers.length > 0) return business.offers;
+ return [];
+ }, [business]);
+
+ useEffect(() => {
+ const loadExtras = async () => {
+ // Track view event if not owner
+ if (!user || user.id !== business.ownerId) {
+ fetch('/api/analytics', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ type: 'BUSINESS_VIEW',
+ path: window.location.pathname,
+ label: business.name,
+ metadata: {
+ businessId: business.id,
+ businessName: business.name,
+ userId: user?.id || 'anonymous'
+ }
+ })
+ }).catch(console.error);
+
+ fetch(`/api/businesses/${business.id}/view`, {
+ method: 'POST',
+ headers: user ? { 'x-user-id': user.id } : {}
+ }).catch(console.error);
+ }
+
+ // Fetch user rating if logged in
+ if (user) {
+ try {
+ const ratingRes = await fetch(`/api/businesses/${business.id}/rating/me`, {
+ headers: { 'x-user-id': user.id }
+ });
+ const ratingData = await ratingRes.json();
+ if (ratingData.rating) {
+ setUserRating(ratingData.rating);
+ setReviewComment(ratingData.comment || '');
+ setUserRatingStatus(ratingData.status);
+ }
+ } catch (e) { console.error(e); }
+
+ // Fetch favorite status
+ try {
+ const favRes = await fetch('/api/favorites', {
+ headers: { 'x-user-id': user.id }
+ });
+ const favData = await favRes.json();
+ if (Array.isArray(favData)) {
+ setIsFavorited(favData.some((f: any) => f.businessId === business.id));
+ }
+ } catch (e) { console.error(e); }
+ }
+
+ // Fetch all ratings
+ try {
+ setLoadingRatings(true);
+ const ratingsRes = await fetch(`/api/businesses/${business.id}/ratings`, {
+ headers: user ? { 'x-user-id': user.id } : {}
+ });
+ if (ratingsRes.ok) {
+ const ratingsData = await ratingsRes.json();
+ setRatings(ratingsData);
+ }
+ } catch (e) { console.error(e); } finally { setLoadingRatings(false); }
+ };
+
+ loadExtras();
+ }, [business.id, user]);
+
+ useEffect(() => {
+ const checkExistingConversation = async () => {
+ if (!user || user.id === 'undefined' || !business?.id) return;
+ try {
+ const res = await fetch(`/api/conversations?businessId=${business.id}`, {
+ headers: { 'x-user-id': user.id }
+ });
+ const data = await res.json();
+ if (Array.isArray(data) && data.length > 0) {
+ setExistingConversationId(data[0].id);
+ }
+ } catch (error) { console.error(error); }
+ };
+ if (user && business) checkExistingConversation();
+ }, [user, business]);
+
+ const getEmbedUrl = (url: string) => {
+ const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
+ const match = url.match(regExp);
+ return (match && match[2].length === 11) ? `https://www.youtube.com/embed/${match[2]}` : null;
+ };
+
+ const videoEmbedUrl = business.videoUrl ? getEmbedUrl(business.videoUrl) : null;
+
+ const handleTrackEvent = async (type: string, label?: string) => {
+ if (!business?.id || (user && user.id === business.ownerId)) return;
+ try {
+ fetch('/api/analytics', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ type,
+ path: window.location.pathname,
+ label: label || business.name,
+ metadata: {
+ businessId: business.id,
+ businessName: business.name,
+ userId: user?.id || 'anonymous'
+ }
+ })
+ });
+ } catch (e) { console.error(e); }
+ };
+
+ const handleContactSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!user) {
+ toast.error('Veuillez vous connecter pour envoyer un message');
+ router.push('/login');
+ return;
+ }
+ setFormStatus('sending');
+ try {
+ const res = await fetch('/api/messages', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-user-id': user.id
+ },
+ body: JSON.stringify({
+ businessId: business.id,
+ content: contactForm.message
+ })
+ });
+ const data = await res.json();
+ if (!data.error) {
+ handleTrackEvent('BUSINESS_CONTACT_CLICK', 'Message Sent');
+ setFormStatus('success');
+ toast.success('Message envoyé ! Redirection vers votre messagerie...');
+ setContactForm({ ...contactForm, message: '' });
+ setTimeout(() => router.push('/dashboard?view=messages'), 2000);
+ } else {
+ toast.error(data.error);
+ setFormStatus('idle');
+ }
+ } catch (error) {
+ toast.error("Erreur lors de l'envoi");
+ setFormStatus('idle');
+ }
+ };
+
+ const handleShare = (platform: string) => {
+ const url = encodeURIComponent(window.location.href);
+ const text = encodeURIComponent(`Découvrez ${business.name} sur Afrohub`);
+ let shareLink = '';
+ switch(platform) {
+ case 'facebook': shareLink = `https://www.facebook.com/sharer/sharer.php?u=${url}`; break;
+ case 'twitter': shareLink = `https://twitter.com/intent/tweet?url=${url}&text=${text}`; break;
+ case 'linkedin': shareLink = `https://www.linkedin.com/sharing/share-offsite/?url=${url}`; break;
+ case 'copy':
+ navigator.clipboard.writeText(window.location.href);
+ toast.success("Lien copié !");
+ setIsShareOpen(false);
+ return;
+ }
+ if (shareLink) window.open(shareLink, '_blank', 'width=600,height=400');
+ setIsShareOpen(false);
+ };
+
+ const handleRate = async (value: number) => {
+ if (!user) { toast.error('Connectez-vous pour voter'); router.push('/login'); return; }
+ if (isOwner) { toast.error("Vous ne pouvez pas noter votre propre entreprise"); return; }
+ if (isRating) return;
+ setIsRating(true);
+ try {
+ const res = await fetch(`/api/businesses/${business.id}/rate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'x-user-id': user.id },
+ body: JSON.stringify({ value, comment: reviewComment })
+ });
+ const data = await res.json();
+ if (!data.error) {
+ setUserRating(value);
+ setBusiness(prev => ({ ...prev, rating: data.rating, ratingCount: data.ratingCount }));
+ toast.success('Merci pour votre vote !');
+ handleTrackEvent('BUSINESS_RATING_CAST', `Stars: ${value}`);
+ const rRes = await fetch(`/api/businesses/${business.id}/ratings`);
+ if (rRes.ok) setRatings(await rRes.json());
+ } else toast.error(data.error);
+ } catch (error) { toast.error("Erreur vote"); } finally { setIsRating(false); }
+ };
+
+ const handleReply = async (ratingId: string) => {
+ if (!user || !isOwner || !replyText.trim()) return;
+ setIsSubmittingReply(true);
+ try {
+ const res = await fetch(`/api/ratings/${ratingId}/reply`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'x-user-id': user.id },
+ body: JSON.stringify({ reply: replyText })
+ });
+ if (res.ok) {
+ toast.success("Réponse publiée !");
+ setReplyingTo(null);
+ setReplyText('');
+ const rRes = await fetch(`/api/businesses/${business.id}/ratings`);
+ if (rRes.ok) setRatings(await rRes.json());
+ } else toast.error("Erreur réponse");
+ } catch (e) { toast.error("Erreur connexion"); } finally { setIsSubmittingReply(false); }
+ };
+
+ const toggleFavorite = async () => {
+ if (!user) { toast.error("Connectez-vous"); router.push('/login'); return; }
+ setIsTogglingFavorite(true);
+ try {
+ const res = await fetch('/api/favorites', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'x-user-id': user.id },
+ body: JSON.stringify({ businessId: business.id })
+ });
+ const data = await res.json();
+ if (!data.error) {
+ setIsFavorited(data.favorited);
+ toast.success(data.favorited ? 'Ajouté aux favoris' : 'Retiré des favoris');
+ }
+ } catch (error) { toast.error('Erreur'); } finally { setIsTogglingFavorite(false); }
+ };
+
+ return (
+
+ {/* Header Banner */}
+
+ {business.coverUrl ? (
+
+ ) : (
+
+ )}
+
+
router.back()}
+ className="inline-flex items-center text-white/80 hover:text-white transition-colors w-fit group active:scale-95"
+ >
+ Retour
+
+
+
+
+
+
+
+ {/* Profile Header */}
+
+
+
+ {business.isFeatured && (
+
+ )}
+
+
+
+
+
+
+
+ {business.name}
+
+ {business.verified && }
+
+
{business.category}
+
+
+
+
setIsShareOpen(!isShareOpen)}
+ className="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 transition-all active:scale-95"
+ >
+ Partager
+
+
+ {isShareOpen && (
+
+ handleShare('facebook')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-blue-600">
+ Facebook
+
+ handleShare('twitter')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-black">
+ X (Twitter)
+
+ handleShare('copy')} className="w-full text-left px-4 py-2.5 text-sm font-medium text-brand-600 hover:bg-brand-50 flex items-center">
+ Copier le lien
+
+
+ )}
+
+
+
+
+ {isFavorited ? 'Favori' : 'Mettre en favori'}
+
+
+
+ Contacter
+
+
+
+
+
+
+
+ {business.location}
+
+
+
+ {[1, 2, 3, 4, 5].map((star) => (
+
+ ))}
+
+
{business.rating.toFixed(1)}
+
({business.ratingCount} votes • {business.viewCount} vues)
+
+
+
+
+
+
+
+
+
+
+
À propos
+
{business.description}
+
+
+ {videoEmbedUrl && (
+
+
+ Présentation Vidéo
+
+
+
+ )}
+
+ {businessOffers.length > 0 && (
+
+
Produits & Services
+
+ {businessOffers.map(offer => (
+
handleOrder(offer)}>
+
+
+
+
+
{offer.title}
+
{new Intl.NumberFormat('fr-FR').format(offer.price)} {offer.currency}
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
Contacter l'entreprise
+
+ setContactForm({...contactForm, message: e.target.value})}
+ placeholder="Votre message..."
+ className="w-full bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm focus:ring-2 focus:ring-brand-500 outline-none h-32"
+ required
+ />
+
+ {formStatus === 'sending' ? : }
+ Envoyer le message
+
+
+
+
+
+
+
+ );
+};
+
+export default BusinessDetailClient;
diff --git a/app/annuaire/[id]/page.tsx b/app/annuaire/[id]/page.tsx
index e1de6ff..8d5219b 100644
--- a/app/annuaire/[id]/page.tsx
+++ b/app/annuaire/[id]/page.tsx
@@ -1,1158 +1,77 @@
-"use client";
+import React from 'react';
+import { notFound } from 'next/navigation';
+import { prisma } from '@/lib/prisma';
+import BusinessDetailClient from './BusinessDetailClient';
+import { Metadata } from 'next';
+interface Props {
+ params: Promise<{ id: string }>;
+}
-import React, { useEffect, useMemo, useState } from 'react';
-import Link from 'next/link';
-import { useParams, useRouter } from 'next/navigation';
-import { ArrowLeft, MapPin, Globe, Mail, Phone, CheckCircle, Star, Facebook, Linkedin, Instagram, Twitter, Play, Share2, Send, Award, Quote, MessageCircle, Lock, Loader2, Link2, ShoppingBag, Heart } from 'lucide-react';
-import { Business, OfferType, Rating } from '../../../types';
-import { toast } from 'react-hot-toast';
-import { useUser } from '../../../components/UserProvider';
-
-const BusinessDetailPage = () => {
- const { id } = useParams();
- const router = useRouter();
- const { user } = useUser();
- const [business, setBusiness] = useState(null);
- const [loading, setLoading] = useState(true);
- const [existingConversationId, setExistingConversationId] = useState(null);
- const [isShareOpen, setIsShareOpen] = useState(false);
- const [selectedOffer, setSelectedOffer] = useState(null);
- const [userRating, setUserRating] = useState(null);
- const [hoverRating, setHoverRating] = useState(null);
- const [isRating, setIsRating] = useState(false);
- const [ratings, setRatings] = useState([]);
- const [reviewComment, setReviewComment] = useState('');
- const [userRatingStatus, setUserRatingStatus] = useState<'PENDING' | 'APPROVED' | 'REJECTED' | null>(null);
- const [loadingRatings, setLoadingRatings] = useState(false);
- const [replyingTo, setReplyingTo] = useState(null);
- const [replyText, setReplyText] = useState('');
- const [isSubmittingReply, setIsSubmittingReply] = useState(false);
- const [isFavorited, setIsFavorited] = useState(false);
- const [isTogglingFavorite, setIsTogglingFavorite] = useState(false);
-
- const isOwner = user && business && user.id === business.ownerId;
-
- const contactRef = React.useRef(null);
-
- const [contactForm, setContactForm] = useState({
- name: '',
- email: '',
- message: ''
- });
- const [formStatus, setFormStatus] = useState<'idle' | 'sending' | 'success'>('idle');
-
- const handleOrder = (offer: any) => {
- setContactForm(prev => ({
- ...prev,
- message: `Bonjour, je souhaiterais commander ou avoir plus d'informations sur votre offre : "${offer.title}". Pouvez-vous me recontacter ?`
- }));
- setSelectedOffer(null);
-
- // Scroll to contact form
- contactRef.current?.scrollIntoView({ behavior: 'smooth' });
-
- // Focus textarea after scroll
- setTimeout(() => {
- const textarea = document.getElementById('contact-message');
- if (textarea) textarea.focus();
- }, 800);
-
- toast.success("Message préparé ! Vous pouvez l'envoyer ci-dessous.");
- };
-
- const businessOffers = useMemo(() => {
- if (!business) return [];
- if (business.offers && business.offers.length > 0) return business.offers;
- return [];
- }, [business]);
-
- useEffect(() => {
- window.scrollTo(0, 0);
-
- const loadBusiness = async () => {
- setLoading(true);
-
- // Sync with DB directly
-
- // 2. Try API (Database)
- try {
- const res = await fetch(`/api/businesses/${id}`, {
- headers: user ? { 'x-user-id': user.id } : {}
- });
- if (res.ok) {
- const data = await res.json();
- setBusiness(data);
-
- // 3. Track view event
- // Only track if not owner
- const currentUserStr = localStorage.getItem('afro_user');
- const currentUser = currentUserStr ? JSON.parse(currentUserStr) : null;
- if (!currentUser || currentUser.id !== data.ownerId) {
- fetch('/api/analytics', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- type: 'BUSINESS_VIEW',
- path: window.location.pathname,
- label: data.name,
- metadata: {
- businessId: data.id,
- businessName: data.name,
- userId: currentUser?.id || 'anonymous'
- }
- })
- }).catch(console.error);
- }
-
- // Also increment views count in DB (Legacy support)
- fetch(`/api/businesses/${id}/view`, {
- method: 'POST',
- headers: user ? { 'x-user-id': user.id } : {}
- }).catch(console.error);
- } else {
- setBusiness(null);
- }
- } catch (err) {
- console.error("Error fetching business:", err);
- setBusiness(null);
- } finally {
- setLoading(false);
- }
-
- // 4. Fetch user rating if logged in
- if (user) {
- try {
- const ratingRes = await fetch(`/api/businesses/${id}/rating/me`, {
- headers: { 'x-user-id': user.id }
- });
- const ratingData = await ratingRes.json();
- if (ratingData.rating) {
- setUserRating(ratingData.rating);
- setReviewComment(ratingData.comment || '');
- setUserRatingStatus(ratingData.status);
- }
- } catch (e) {
- console.error('Error fetching user rating:', e);
- }
- }
-
- // 5. Fetch all ratings
- try {
- setLoadingRatings(true);
- const ratingsRes = await fetch(`/api/businesses/${id}/ratings`, {
- headers: user ? { 'x-user-id': user.id } : {}
- });
- if (ratingsRes.ok) {
- const ratingsData = await ratingsRes.json();
- setRatings(ratingsData);
- }
- } catch (e) {
- console.error('Error fetching ratings list:', e);
- } finally {
- setLoadingRatings(false);
- }
-
- // 6. Fetch favorite status
- if (user) {
- try {
- const favRes = await fetch('/api/favorites', {
- headers: { 'x-user-id': user.id }
- });
- const favData = await favRes.json();
- if (Array.isArray(favData)) {
- setIsFavorited(favData.some((f: any) => f.businessId === id));
- }
- } catch (e) {
- console.error('Error fetching favorite status:', e);
- }
- }
- };
-
- if (id) loadBusiness();
- }, [id]);
-
- useEffect(() => {
- const checkExistingConversation = async () => {
- if (!user || user.id === 'undefined' || !business?.id) return;
-
- try {
- const res = await fetch(`/api/conversations?businessId=${business.id}`, {
- headers: { 'x-user-id': user.id }
- });
- const data = await res.json();
- if (Array.isArray(data) && data.length > 0) {
- setExistingConversationId(data[0].id);
- }
- } catch (error) {
- console.error('Error checking existing conversation:', error);
- }
- };
-
- if (user && business) checkExistingConversation();
- }, [user, business]);
-
- if (loading) {
- return (
-
-
-
Chargement du profil...
-
- );
+export async function generateMetadata({ params }: Props): Promise {
+ const { id } = await params;
+
+ const business = await prisma.business.findFirst({
+ where: {
+ OR: [
+ { id: id },
+ { slug: id }
+ ],
+ isActive: true,
+ isSuspended: false
}
+ });
- if (!business) {
- return (
-
-
Entreprise introuvable
-
-
Retour à l'annuaire
-
-
- );
+ if (!business) return { title: 'Entreprise non trouvée' };
+
+ const title = business.metaTitle || `${business.name} - Afroprenariat`;
+ const description = business.metaDescription || (business.description.length > 160
+ ? business.description.substring(0, 157) + '...'
+ : business.description);
+
+ return {
+ title,
+ description,
+ openGraph: {
+ title,
+ description,
+ images: [business.logoUrl],
+ type: 'profile',
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title,
+ description,
+ images: [business.logoUrl],
}
+ };
+}
- // Helper to get YouTube embed URL
- const getEmbedUrl = (url: string) => {
- const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
- const match = url.match(regExp);
- return (match && match[2].length === 11) ? `https://www.youtube.com/embed/${match[2]}` : null;
- };
+export default async function BusinessDetailPage({ params }: Props) {
+ const { id } = await params;
+
+ const business = await prisma.business.findFirst({
+ where: {
+ OR: [
+ { id: id },
+ { slug: id }
+ ],
+ isActive: true,
+ isSuspended: false
+ },
+ include: {
+ offers: true,
+ country: true,
+ categoryRef: true
+ }
+ });
- const videoEmbedUrl = business.videoUrl ? getEmbedUrl(business.videoUrl) : null;
+ if (!business) {
+ notFound();
+ }
- const handleTrackEvent = async (type: string, label?: string) => {
- if (!business?.id) return;
-
- // Don't track if the current user is the owner
- if (user && user.id === business.ownerId) return;
+ // Normalize data for client component (converting Date/Json to plain objects if needed)
+ const normalizedBusiness = JSON.parse(JSON.stringify(business));
- try {
- fetch('/api/analytics', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- type,
- path: window.location.pathname,
- label: label || business.name,
- metadata: {
- businessId: business.id,
- businessName: business.name,
- userId: user?.id || 'anonymous'
- }
- })
- });
- } catch (e) {
- console.error('Analytics error:', e);
- }
- };
-
- const handleContactSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
-
- if (!user) {
- toast.error('Veuillez vous connecter pour envoyer un message');
- router.push('/login');
- return;
- }
-
- setFormStatus('sending');
-
- try {
- const res = await fetch('/api/messages', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'x-user-id': user.id
- },
- body: JSON.stringify({
- businessId: business.id,
- content: contactForm.message
- })
- });
-
- const data = await res.json();
-
- if (!data.error) {
- // Track contact event
- handleTrackEvent('BUSINESS_CONTACT_CLICK', 'Message Sent');
-
- setFormStatus('success');
- toast.success('Message envoyé ! Redirection vers votre messagerie...');
- setContactForm({ ...contactForm, message: '' });
-
- setTimeout(() => {
- router.push('/dashboard?view=messages'); // Hypothetical view param handle later
- }, 2000);
- } else {
- toast.error(data.error);
- setFormStatus('idle');
- }
- } catch (error) {
- toast.error("Erreur lors de l'envoi");
- setFormStatus('idle');
- }
- };
-
- const handleShare = (platform: string) => {
- if (!business) return;
- const url = encodeURIComponent(window.location.href);
- const text = encodeURIComponent(`Découvrez ${business.name} sur Afrohub`);
-
- let shareLink = '';
- switch(platform) {
- case 'facebook':
- shareLink = `https://www.facebook.com/sharer/sharer.php?u=${url}`;
- break;
- case 'twitter':
- shareLink = `https://twitter.com/intent/tweet?url=${url}&text=${text}`;
- break;
- case 'linkedin':
- shareLink = `https://www.linkedin.com/sharing/share-offsite/?url=${url}`;
- break;
- case 'instagram':
- toast("Pour partager sur Instagram, copiez le lien ou faites une capture d'écran.", { icon: '📸' });
- setIsShareOpen(false);
- return;
- case 'copy':
- navigator.clipboard.writeText(window.location.href);
- toast.success("Lien copié dans le presse-papier !");
- setIsShareOpen(false);
- return;
- }
-
- if (shareLink) {
- window.open(shareLink, '_blank', 'width=600,height=400');
- }
- setIsShareOpen(false);
- };
-
- const handleRate = async (value: number) => {
- if (!user) {
- toast.error('Connectez-vous pour voter');
- router.push('/login');
- return;
- }
-
- if (isOwner) {
- toast.error("Vous ne pouvez pas noter votre propre entreprise");
- return;
- }
-
- if (isRating) return;
- setIsRating(true);
-
- try {
- const res = await fetch(`/api/businesses/${id}/rate`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'x-user-id': user.id
- },
- body: JSON.stringify({ value, comment: reviewComment })
- });
-
- const data = await res.json();
-
- if (!data.error) {
- setUserRating(value);
- setBusiness(prev => prev ? {
- ...prev,
- rating: data.rating,
- ratingCount: data.ratingCount
- } : null);
-
- toast.success('Merci pour votre vote !');
- handleTrackEvent('BUSINESS_RATING_CAST', `Stars: ${value}`);
-
- // Refresh ratings list
- const ratingsRes = await fetch(`/api/businesses/${id}/ratings`);
- if (ratingsRes.ok) {
- const ratingsData = await ratingsRes.json();
- setRatings(ratingsData);
- }
- } else {
- toast.error(data.error);
- }
- } catch (error) {
- toast.error("Erreur lors de l'enregistrement du vote");
- } finally {
- setIsRating(false);
- }
- };
-
- const handleReply = async (ratingId: string) => {
- if (!user || !isOwner) return;
- if (!replyText.trim()) {
- toast.error("La réponse ne peut pas être vide");
- return;
- }
-
- setIsSubmittingReply(true);
- try {
- const res = await fetch(`/api/ratings/${ratingId}/reply`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'x-user-id': user.id
- },
- body: JSON.stringify({ reply: replyText })
- });
-
- if (res.ok) {
- toast.success("Réponse publiée !");
- setReplyingTo(null);
- setReplyText('');
-
- // Refresh ratings
- const ratingsRes = await fetch(`/api/businesses/${id}/ratings`);
- if (ratingsRes.ok) {
- const ratingsData = await ratingsRes.json();
- setRatings(ratingsData);
- }
- } else {
- const data = await res.json();
- toast.error(data.error || "Erreur lors de la réponse");
- }
- } catch (e) {
- toast.error("Erreur de connexion");
- } finally {
- setIsSubmittingReply(false);
- }
- };
-
- const toggleFavorite = async () => {
- if (!user) {
- toast.error("Connectez-vous pour ajouter des favoris");
- router.push('/login');
- return;
- }
-
- setIsTogglingFavorite(true);
- try {
- const res = await fetch('/api/favorites', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'x-user-id': user.id
- },
- body: JSON.stringify({ businessId: business?.id || id })
- });
- const data = await res.json();
- if (!data.error) {
- setIsFavorited(data.favorited);
- toast.success(data.favorited ? 'Ajouté aux favoris' : 'Retiré des favoris');
- }
- } catch (error) {
- toast.error('Erreur réseau');
- } finally {
- setIsTogglingFavorite(false);
- }
- };
-
- return (
-
- {/* Header Banner */}
-
- {business.coverUrl ? (
-
- ) : (
-
- )}
-
-
{
- if (window.history.length > 1) {
- router.back();
- } else {
- router.push('/annuaire');
- }
- }}
- className="inline-flex items-center text-white/80 hover:text-white transition-colors w-fit group active:scale-95"
- >
- Retour
-
-
-
-
-
-
-
- {/* Profile Header */}
-
-
-
- {business.isFeatured && (
-
- )}
-
-
-
-
-
-
-
- {business.name}
-
- {business.verified && }
-
-
{business.category}
- {business.isFeatured && (
-
- )}
-
-
- {/* Actions block refreshed by AI coding assistant */}
-
-
setIsShareOpen(!isShareOpen)}
- className="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 transition-all active:scale-95"
- >
- Partager
-
-
- {isShareOpen && (
-
setIsShareOpen(false)}
- >
-
Partager la fiche
-
-
handleShare('facebook')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-blue-600 transition-colors">
-
-
-
- Facebook
-
-
-
handleShare('twitter')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-black transition-colors">
-
-
-
- X (Twitter)
-
-
-
handleShare('linkedin')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-blue-700 transition-colors">
-
-
-
- LinkedIn
-
-
-
handleShare('instagram')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-pink-600 transition-colors text-gray-400">
-
-
-
- Instagram
-
-
-
-
-
handleShare('copy')} className="w-full text-left px-4 py-2.5 text-sm font-medium text-brand-600 hover:bg-brand-50 flex items-center transition-colors">
-
-
-
- Copier le lien
-
-
- )}
-
-
-
-
- {isFavorited ? 'Favori' : 'Mettre en favori'}
-
-
-
- Contacter
-
-
-
-
-
-
-
- {business.location}
-
-
-
- {[1, 2, 3, 4, 5].map((star) => (
- setHoverRating(star)}
- onMouseLeave={() => setHoverRating(null)}
- onClick={() => handleRate(star)}
- className={`p-0.5 transition-all duration-200 ${!user ? 'cursor-default' : 'hover:scale-125 cursor-pointer active:scale-95'}`}
- title={user ? `Voter ${star} étoiles` : "Connectez-vous pour voter"}
- >
-
-
- ))}
-
-
- {business.rating.toFixed(1)}
-
- ({(business as any).ratingCount || 0} votes • {business.viewCount} vues)
-
-
-
- {business.tags.map(tag => (
-
#{tag}
- ))}
-
-
-
-
-
-
-
- {/* Left Column: Main Content */}
-
-
- {/* Presentation */}
-
-
À propos
-
- {business.description}
-
-
-
- {/* Founder Section (Specifically for Featured or if data exists) */}
- {(business.founderName || business.founderImageUrl) && (
-
-
-
-
-
Le Fondateur
-
- {business.founderImageUrl && (
-
- )}
-
-
{business.founderName}
-
CEO & Fondateur
-
- "Notre mission est d'apporter des solutions concrètes et adaptées aux réalités locales, tout en visant l'excellence internationale."
-
- {business.keyMetric && (
-
- 🚀 {business.keyMetric}
-
- )}
-
-
-
- )}
-
- {/* Video */}
- {videoEmbedUrl && (
-
-
- Présentation Vidéo
-
-
-
-
-
- )}
-
- {/* Offers */}
- {businessOffers.length > 0 && (
-
-
Produits & Services
-
- {businessOffers.map(offer => (
-
setSelectedOffer(offer)}
- className="border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition-all cursor-pointer group hover:scale-[1.01]"
- >
-
-
-
- {offer.type === OfferType.PRODUCT ? 'Produit' : 'Service'}
-
-
-
-
{offer.title}
-
- {new Intl.NumberFormat('fr-FR').format(offer.price)} {offer.currency}
-
-
- Détails & Commander
-
-
-
- ))}
-
-
- )}
-
- {/* Reviews Section */}
-
-
-
-
- Avis & Témoignages
-
- ({ratings.length} avis)
-
-
-
-
-
- {[1, 2, 3, 4, 5].map((s) => (
-
- ))}
-
-
{business.rating.toFixed(1)}
-
-
-
- {/* Leave a Review Form */}
-
-
- {isOwner ? "Votre Fiche" : (userRating ? "Modifier votre avis" : "Laisser un avis")}
-
-
- {isOwner ? (
-
- Vous consultez votre propre fiche. Vous pouvez répondre aux avis des clients ci-dessous.
-
- ) : (
-
-
-
Votre Note
-
- {[1, 2, 3, 4, 5].map((star) => (
- setHoverRating(star)}
- onMouseLeave={() => setHoverRating(null)}
- onClick={() => handleRate(star)}
- className="transition-all duration-200 hover:scale-125 focus:outline-none"
- >
-
-
- ))}
- {userRating && Vous avez mis {userRating} étoiles }
-
- {userRatingStatus === 'PENDING' && (
-
-
-
- Votre avis est reçu ! Il est actuellement en cours de modération par notre équipe pour assurer la qualité des retours.
-
-
- )}
- {userRating && userRatingStatus !== 'PENDING' &&
Conformément à nos règles, la note peut uniquement être réévaluée à la hausse.
}
-
-
-
-
Votre Commentaire
-
setReviewComment(e.target.value)}
- placeholder={ratings.some(r => r.userId === user?.id && r.comment) ? "Le commentaire ne peut plus être modifié une fois posté." : "Partagez votre expérience avec cet entrepreneur..."}
- className={`w-full px-4 py-3 bg-white border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-brand-500 outline-none transition-all resize-none shadow-sm ${ratings.some(r => r.userId === user?.id && r.comment) ? 'opacity-60 cursor-not-allowed bg-gray-50' : ''}`}
- rows={3}
- disabled={!user || isRating || ratings.some(r => r.userId === user?.id && r.comment)}
- />
- {ratings.some(r => r.userId === user?.id && r.comment) && (
- Votre avis est désormais immuable. Seul le score peut changer.
- )}
-
-
-
- {!user && (
-
- Connectez-vous pour laisser un commentaire
-
- )}
- {user && (
-
userRating ? handleRate(userRating) : toast.error("Veuillez d'abord sélectionner une note")}
- disabled={isRating}
- className="ml-auto bg-brand-600 text-white px-6 py-2 rounded-lg text-sm font-bold shadow-md hover:bg-brand-700 transition-all disabled:opacity-50 flex items-center gap-2"
- >
- {isRating ? : }
- {userRating ? "Mettre à jour la note" : "Publier l'avis"}
-
- )}
-
-
- )}
-
-
- {/* Reviews List */}
-
- {loadingRatings ? (
-
-
-
Chargement des avis...
-
- ) : ratings.length > 0 ? (
- ratings.map((r: any) => (
-
-
-
- {r.user?.avatar ? (
-
- ) : (
- r.user?.name?.charAt(0) || '?'
- )}
-
-
-
-
{r.user?.name}
-
- {new Date(r.createdAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })}
-
-
-
- {[1, 2, 3, 4, 5].map((s) => (
-
- ))}
-
- {r.comment ? (
-
- {r.comment}
-
- ) : (
-
Note attribuée sans commentaire.
- )}
-
- {/* Entrepreneur Reply */}
- {r.reply ? (
-
-
- Réponse de l'entrepreneur
-
- {new Date(r.replyAt!).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}
-
-
-
"{r.reply}"
-
- ) : isOwner && (
-
- {replyingTo === r.id ? (
-
-
setReplyText(e.target.value)}
- placeholder="Votre réponse..."
- className="w-full px-3 py-2 bg-white border border-brand-200 rounded-lg text-sm focus:ring-1 focus:ring-brand-500 outline-none transition-all resize-none shadow-sm"
- rows={2}
- />
-
- setReplyingTo(null)}
- className="text-xs text-gray-500 hover:text-gray-700"
- >
- Annuler
-
- handleReply(r.id)}
- disabled={isSubmittingReply || !replyText.trim()}
- className="bg-brand-600 text-white px-3 py-1 rounded text-xs font-bold hover:bg-brand-700 transition-all disabled:opacity-50"
- >
- {isSubmittingReply ? "Envoi..." : "Répondre"}
-
-
-
- ) : (
-
setReplyingTo(r.id)}
- className="text-xs font-bold text-brand-600 hover:text-brand-700 flex items-center gap-1 group/btn"
- >
-
- Répondre à cet avis
-
- )}
-
- )}
-
-
-
- ))
- ) : (
-
-
-
Aucun avis pour le moment.
-
Soyez le premier à partager votre expérience !
-
- )}
-
-
-
-
- {/* Right Column: Sidebar */}
-
- {/* Contact Card */}
-
-
- {/* Similar Businesses (Mock) */}
- {/* Similar Businesses (Disabled - Mock data removed) */}
- {false && (
-
-
Dans la même catégorie
-
-
-
- )}
-
-
-
-
- {/* Offer Detail Modal */}
- {selectedOffer && (
-
-
setSelectedOffer(null)}
- >
-
-
- {/* Close button */}
-
setSelectedOffer(null)}
- className="absolute top-4 right-4 p-2 bg-white/80 backdrop-blur rounded-full text-gray-500 hover:text-gray-900 shadow-md transition-all z-20"
- >
-
-
-
-
-
-
-
-
-
-
- {selectedOffer.type === OfferType.PRODUCT ? 'Produit' : 'Service'}
-
-
-
-
-
-
{selectedOffer.title}
-
- {new Intl.NumberFormat('fr-FR').format(selectedOffer.price)} {selectedOffer.currency}
-
-
-
-
Description
-
- {selectedOffer.description || "Aucune description détaillée n'est disponible pour ce produit/service."}
-
-
-
-
- handleOrder(selectedOffer)}
- className="w-full bg-brand-600 text-white font-bold py-3 px-6 rounded-xl hover:bg-brand-700 shadow-lg shadow-brand-100 transition-all flex items-center justify-center gap-3 active:scale-95"
- >
-
- Commander maintenant
-
- setSelectedOffer(null)}
- className="w-full bg-gray-50 text-gray-500 font-medium py-2 px-6 rounded-lg hover:bg-gray-100 transition-colors text-sm"
- >
- Fermer
-
-
-
-
-
-
- )}
-
- );
-};
-
-export default BusinessDetailPage;
+ return ;
+}
diff --git a/app/page.tsx b/app/page.tsx
index ebbb2a5..b692934 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,208 +1,76 @@
-"use client";
+import React from 'react';
+import { prisma } from '@/lib/prisma';
+import HomeClient from './HomeClient';
+import { Metadata } from 'next';
-import React, { useState, useEffect } from 'react';
-import Link from 'next/link';
-import { useRouter } from 'next/navigation';
-import { Search, MapPin, Briefcase, TrendingUp, Loader2, ArrowRight } from 'lucide-react';
-import { generateSlug } from '../lib/utils';
-import { Business, BlogPost } from '../types';
-import BusinessCard from '../components/BusinessCard';
-import { useUser } from '../components/UserProvider';
+export async function generateMetadata(): Promise {
+ const settings = await prisma.siteSetting.findUnique({
+ where: { id: 'singleton' }
+ });
-const HomePage = () => {
- const router = useRouter();
- const { user, settings } = useUser();
- const [searchTerm, setSearchTerm] = useState('');
- const [featuredBusinesses, setFeaturedBusinesses] = useState([]);
- const [posts, setPosts] = useState([]);
- const [categories, setCategories] = useState([]);
- const [loading, setLoading] = useState(true);
- const [loadingPosts, setLoadingPosts] = useState(true);
+ const title = settings?.siteName || 'Afroprenariat';
+ const description = settings?.siteSlogan || "La plateforme de référence pour l'entrepreneuriat africain.";
- useEffect(() => {
- const fetchFeatured = async () => {
- try {
- const res = await fetch('/api/businesses?featured=true', {
- headers: user ? { 'x-user-id': user.id } : {}
- });
- 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 fetchPosts = async () => {
- try {
- const res = await fetch('/api/blog');
- const data = await res.json();
- if (Array.isArray(data)) {
- setPosts(data);
- }
- } catch (error) {
- console.error('Error fetching blog posts:', error);
- } finally {
- setLoadingPosts(false);
- }
- };
- const fetchCategories = async () => {
- try {
- const res = await fetch('/api/categories');
- const data = await res.json();
- if (Array.isArray(data)) setCategories(data);
- } catch (e) {}
- };
- fetchPosts();
- fetchCategories();
- }, []);
-
- const handleSearch = (e: React.FormEvent) => {
- e.preventDefault();
- router.push(`/annuaire?q=${searchTerm}`);
+ return {
+ title: {
+ default: title,
+ template: `%s | ${title}`
+ },
+ description: description,
+ openGraph: {
+ title: title,
+ description: description,
+ url: 'https://afroprenariat.com',
+ siteName: title,
+ images: [
+ {
+ url: 'https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1200&h=630&q=80',
+ width: 1200,
+ height: 630,
+ alt: title,
+ },
+ ],
+ locale: 'fr_FR',
+ type: 'website',
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title: title,
+ description: description,
+ images: ['https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1200&h=630&q=80'],
+ },
};
+}
+
+export default async function HomePage() {
+ // Fetch initial data for faster loading and SEO
+ const [featuredBusinesses, posts, categories] = await Promise.all([
+ prisma.business.findMany({
+ where: { isFeatured: true, isActive: true, isSuspended: false },
+ take: 4,
+ include: { categoryRef: true }
+ }),
+ prisma.blogPost.findMany({
+ where: { status: 'PUBLISHED', publishedAt: { lte: new Date() } },
+ orderBy: { date: 'desc' },
+ take: 6
+ }),
+ prisma.businessCategory.findMany({
+ where: { isActive: true },
+ take: 8
+ })
+ ]);
+
+ // Convert to plain objects for client component
+ const initialFeatured = JSON.parse(JSON.stringify(featuredBusinesses));
+ const initialPosts = JSON.parse(JSON.stringify(posts));
+ const initialCategories = JSON.parse(JSON.stringify(categories));
return (
-
- {/* Hero Section */}
-
-
-
-
-
-
- Boostez votre visibilité dans
- l'écosystème africain
-
-
- L'annuaire 2.0 qui connecte les talents, les entrepreneurs et les entreprises de la diaspora et du continent.
-
-
-
-
-
- setSearchTerm(e.target.value)}
- />
-
-
-
-
-
-
- Rechercher
-
-
-
-
-
- {/* Featured Categories */}
-
-
Secteurs en vedette
-
- {(settings?.homeCategories || categories.slice(0, 4).map(c => c.name)).map((cat: string, idx: number) => (
-
-
-
-
-
{cat}
-
- ))}
-
-
-
- {/* Featured Businesses */}
-
-
-
-
-
Entreprises à la une
-
Découvrez les pépites de notre communauté.
-
-
- Voir tout l'annuaire
-
-
-
- {loading ? (
-
-
-
- ) : (
-
- {featuredBusinesses.map(biz => (
-
- ))}
-
- )}
-
-
-
- {/* Blog Section */}
- {settings?.homeBlogShow !== false && (
-
-
-
-
-
{settings?.homeBlogTitle || "Derniers Articles"}
-
{settings?.homeBlogSubtitle || "Toutes les actualités de l'entrepreneuriat africain."}
-
-
- Voir tout le blog
-
-
-
- {loadingPosts ? (
-
-
-
- ) : (
-
- {posts
- .filter(post => {
- if (settings?.homeBlogSelection === 'manual') {
- return settings.homeBlogIds?.includes(post.id);
- }
- if (settings?.homeBlogSelection === 'category') {
- return post.tags?.some((tag: string) => settings.homeBlogCategories?.includes(tag));
- }
- return true;
- })
- .slice(0, settings?.homeBlogCount || 3)
- .map(post => (
-
-
-
-
-
-
{post.title}
-
{post.excerpt}
-
- {new Date(post.date).toLocaleDateString('fr-FR')}
- Lire la suite →
-
-
-
- ))}
-
- )}
-
-
- )}
-
+
);
-};
-
-export default HomePage;
+}
diff --git a/components/UserProvider.tsx b/components/UserProvider.tsx
index 43fb13e..5462838 100644
--- a/components/UserProvider.tsx
+++ b/components/UserProvider.tsx
@@ -1,12 +1,12 @@
"use client";
import React, { createContext, useContext, useState, ReactNode } from 'react';
-import { User } from '../types';
+import { User, SiteSetting } from '../types';
import { useRouter, usePathname } from 'next/navigation';
interface UserContextType {
user: User | null;
- settings: any | null;
+ settings: SiteSetting | null;
login: (user: User) => void;
logout: () => void;
}
@@ -15,7 +15,7 @@ const UserContext = createContext(undefined);
export function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState(null);
- const [settings, setSettings] = useState(null);
+ const [settings, setSettings] = useState(null);
const router = useRouter();
const pathname = usePathname();
diff --git a/lib/mockData.ts b/lib/mockData.ts
index a819cae..4f19b1f 100644
--- a/lib/mockData.ts
+++ b/lib/mockData.ts
@@ -33,7 +33,10 @@ export const MOCK_BUSINESSES: Business[] = [
verified: true,
viewCount: 1240,
rating: 4.8,
+ ratingCount: 45,
tags: ['Dev', 'SaaS', 'Mobile', 'ERP'],
+ metaTitle: null,
+ metaDescription: null,
// Featured Data
isFeatured: true,
founderName: 'Jean-Marc Kouassi',
@@ -57,7 +60,10 @@ export const MOCK_BUSINESSES: Business[] = [
verified: true,
viewCount: 850,
rating: 4.9,
+ ratingCount: 12,
tags: ['Traiteur', 'Bio', 'Événementiel', 'Gastronomie'],
+ metaTitle: null,
+ metaDescription: null,
isFeatured: true,
founderName: 'Aïssa Maïga',
socialLinks: { instagram: 'https://instagram.com' }
@@ -78,7 +84,10 @@ export const MOCK_BUSINESSES: Business[] = [
verified: false,
viewCount: 320,
rating: 4.5,
+ ratingCount: 8,
tags: ['Mode', 'Ankara', 'Luxe', 'Ethique'],
+ metaTitle: null,
+ metaDescription: null,
founderName: 'Chidinma Okeke'
},
{
@@ -98,7 +107,10 @@ export const MOCK_BUSINESSES: Business[] = [
verified: true,
viewCount: 560,
rating: 4.7,
+ ratingCount: 24,
tags: ['Énergie', 'Solaire', 'Durable', 'Tech'],
+ metaTitle: null,
+ metaDescription: null,
isFeatured: true,
founderName: 'Ousmane Diop'
},
@@ -120,7 +132,10 @@ export const MOCK_BUSINESSES: Business[] = [
verified: true,
viewCount: 410,
rating: 4.6,
+ ratingCount: 15,
tags: ['Bio', 'Skincare', 'Karité', 'Naturel'],
+ metaTitle: null,
+ metaDescription: null,
founderName: 'Marie-Claire Etoa',
founderImageUrl: 'https://images.unsplash.com/photo-1589156191108-c762ff4b96ab?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80'
},
@@ -143,7 +158,10 @@ export const MOCK_BUSINESSES: Business[] = [
verified: true,
viewCount: 980,
rating: 5.0,
+ ratingCount: 112,
tags: ['Formation', 'Code', 'Bootcamp', 'Emploi'],
+ metaTitle: null,
+ metaDescription: null,
founderName: 'David Ochieng',
keyMetric: '90% d\'embauche'
},
@@ -163,7 +181,10 @@ export const MOCK_BUSINESSES: Business[] = [
verified: false,
viewCount: 230,
rating: 4.2,
+ ratingCount: 4,
tags: ['FinTech', 'Paiement', 'Mobile Money', 'API'],
+ metaTitle: null,
+ metaDescription: null,
founderName: 'Serge Mbemba'
},
{
@@ -184,7 +205,10 @@ export const MOCK_BUSINESSES: Business[] = [
verified: true,
viewCount: 670,
rating: 4.8,
+ ratingCount: 32,
tags: ['Architecture', 'Écologie', 'BTC', 'Durable'],
+ metaTitle: null,
+ metaDescription: null,
isFeatured: true,
founderName: 'Fatimata Sylla',
founderImageUrl: 'https://images.unsplash.com/photo-1542596594-649edbc13630?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80'
diff --git a/prisma/migrations/0_init/migration.sql b/prisma/migrations/0_init/migration.sql
new file mode 100644
index 0000000..d14cf34
Binary files /dev/null and b/prisma/migrations/0_init/migration.sql differ
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index ca723e3..ce93b4f 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -68,6 +68,8 @@ model Business {
websiteUrl String?
isSuspended Boolean @default(false)
suspensionReason String?
+ metaTitle String?
+ metaDescription String?
plan Plan @default(STARTER)
city String?
countryId String?
diff --git a/types.ts b/types.ts
index fdbc9db..5751929 100644
--- a/types.ts
+++ b/types.ts
@@ -64,9 +64,12 @@ export interface Business {
verified: boolean;
viewCount: number;
rating: number;
+ ratingCount: number;
tags: string[];
isSuspended?: boolean;
suspensionReason?: string;
+ metaTitle?: string | null;
+ metaDescription?: string | null;
// New fields for "Entrepreneur of the Month"
isFeatured?: boolean;
@@ -147,3 +150,33 @@ export interface Event {
link?: string;
tags?: string[];
}
+
+export interface BusinessCategory {
+ id: string;
+ name: string;
+ slug: string;
+ icon?: string;
+ isActive: boolean;
+}
+
+export interface SiteSetting {
+ id: string;
+ siteName: string;
+ siteSlogan: string;
+ contactEmail: string;
+ contactPhone?: string;
+ address?: string;
+ facebookUrl?: string;
+ twitterUrl?: string;
+ instagramUrl?: string;
+ linkedinUrl?: string;
+ footerText?: string;
+ homeBlogCount: number;
+ homeBlogShow: boolean;
+ homeBlogSubtitle: string;
+ homeBlogTitle: string;
+ homeBlogCategories: string[];
+ homeBlogIds: string[];
+ homeBlogSelection: string;
+ homeCategories: string[];
+}