migration correction et maj
Some checks failed
Build and Push App / build (push) Failing after 11m56s

This commit is contained in:
2026-05-03 23:15:14 +02:00
parent 5f421b418e
commit 16d66c0456
19 changed files with 1482 additions and 1355 deletions

Binary file not shown.

View File

@@ -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 '<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;"><h2 style="color: #0d9488; text-align: center;">{siteName}</h2><p>Bonjour {name},</p><p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p><div style="text-align: center; margin: 30px 0;"><a href="{resetUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Réinitialiser mon mot de passe</a></div><p>Ce lien expirera dans 1 heure. Si vous n''avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p><hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;"><p style="font-size: 12px; color: #6b7280; text-align: center;">&copy; 2025 {siteName}. Tous droits réservés.</p></div>',
"verifyEmailSubject" TEXT NOT NULL DEFAULT 'Vérification de votre compte - Afrohub',
"verifyEmailTemplate" TEXT NOT NULL DEFAULT '<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;"><h2 style="color: #0d9488; text-align: center;">{siteName}</h2><p>Bonjour {name},</p><p>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 :</p><div style="text-align: center; margin: 30px 0;"><a href="{verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a></div><p>Si vous n''avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p><hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;"><p style="font-size: 12px; color: #6b7280; text-align: center;">&copy; 2025 {siteName}. Tous droits réservés.</p></div>',
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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -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"

View File

@@ -68,6 +68,8 @@ model Business {
websiteUrl String? websiteUrl String?
isSuspended Boolean @default(false) isSuspended Boolean @default(false)
suspensionReason String? suspensionReason String?
metaTitle String?
metaDescription String?
plan Plan @default(STARTER) plan Plan @default(STARTER)
city String? city String?
countryId String? countryId String?

View File

@@ -79,3 +79,23 @@ export async function getPendingVerificationCount() {
return 0; 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" };
}
}

View File

@@ -180,6 +180,8 @@ export default async function UsersPage({
userName={business.owner.name} userName={business.owner.name}
isBusinessSuspended={!!business.isSuspended} isBusinessSuspended={!!business.isSuspended}
isUserSuspended={!!business.owner?.isSuspended} isUserSuspended={!!business.owner?.isSuspended}
metaTitle={business.metaTitle}
metaDescription={business.metaDescription}
/> />
</div> </div>
</td> </td>

View File

@@ -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 (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl animate-in zoom-in duration-200">
<div className="p-6 border-b border-slate-800 flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-500">
<Globe className="w-6 h-6" />
</div>
<div>
<h3 className="text-xl font-bold text-white">SEO & Partage</h3>
<p className="text-xs text-slate-400">Personnaliser l'aperçu WhatsApp pour {business.name}</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Meta Titre (WhatsApp/Google)</label>
<input
value={metaTitle}
onChange={(e) => setMetaTitle(e.target.value)}
placeholder={`Ex: ${business.name} - Expert en ...`}
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-all"
/>
<p className="text-[10px] text-slate-500 italic">Recommandé : Moins de 60 caractères.</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Meta Description</label>
<textarea
value={metaDescription}
onChange={(e) => setMetaDescription(e.target.value)}
rows={4}
placeholder="Décrivez l'entreprise en quelques mots pour attirer les clics..."
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-all resize-none"
/>
<p className="text-[10px] text-slate-500 italic">Recommandé : Entre 140 et 160 caractères.</p>
</div>
<div className="bg-indigo-500/5 border border-indigo-500/10 rounded-xl p-4">
<h4 className="text-[10px] font-bold text-indigo-400 uppercase tracking-widest mb-2">Aperçu du partage</h4>
<div className="bg-slate-950 rounded-lg p-3 border border-slate-800">
<div className="text-sm font-bold text-indigo-400 line-clamp-1">{metaTitle || business.name}</div>
<div className="text-xs text-slate-500 line-clamp-2 mt-1">{metaDescription || "Découvrez cette entreprise sur Afroprenariat..."}</div>
<div className="text-[10px] text-slate-600 mt-2">afroprenariat.com</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 py-3 rounded-xl border border-slate-800 text-slate-400 font-bold hover:bg-slate-800 transition-all"
>
Annuler
</button>
<button
type="submit"
disabled={isPending}
className="flex-[2] py-3 rounded-xl bg-indigo-600 text-white font-bold hover:bg-indigo-500 transition-all flex items-center justify-center gap-2 shadow-lg shadow-indigo-600/20"
>
{isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Save className="w-5 h-5" />
)}
Enregistrer
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,10 +1,11 @@
"use client"; "use client";
import React, { useState } from 'react'; 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 { suspendUser, unsuspendUser, suspendBusiness, unsuspendBusiness } from '@/app/actions/suspension';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import SuspensionModal from './SuspensionModal'; import SuspensionModal from './SuspensionModal';
import BusinessSeoModal from './BusinessSeoModal';
interface EntrepreneurActionsProps { interface EntrepreneurActionsProps {
businessId: string; businessId: string;
@@ -13,6 +14,8 @@ interface EntrepreneurActionsProps {
userName: string; userName: string;
isBusinessSuspended: boolean; isBusinessSuspended: boolean;
isUserSuspended: boolean; isUserSuspended: boolean;
metaTitle?: string | null;
metaDescription?: string | null;
} }
export default function EntrepreneurActions({ export default function EntrepreneurActions({
@@ -21,9 +24,12 @@ export default function EntrepreneurActions({
userId, userId,
userName, userName,
isBusinessSuspended, isBusinessSuspended,
isUserSuspended isUserSuspended,
metaTitle,
metaDescription
}: EntrepreneurActionsProps) { }: EntrepreneurActionsProps) {
const [modalType, setModalType] = useState<'USER' | 'BUSINESS' | null>(null); const [modalType, setModalType] = useState<'USER' | 'BUSINESS' | null>(null);
const [seoModalOpen, setSeoModalOpen] = useState(false);
const handleConfirm = async (reason: string) => { const handleConfirm = async (reason: string) => {
if (modalType === 'USER') { if (modalType === 'USER') {
@@ -41,6 +47,16 @@ export default function EntrepreneurActions({
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
{/* SEO Button */}
<button
onClick={() => 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"
>
<Globe className="w-4 h-4" />
<span className="text-[10px] uppercase font-bold">SEO</span>
</button>
{/* User Suspension */} {/* User Suspension */}
{isUserSuspended ? ( {isUserSuspended ? (
<button <button
@@ -93,6 +109,17 @@ export default function EntrepreneurActions({
? `Voulez-vous suspendre TOUT le compte de ${userName} ? Sa boutique sera également masquée.` ? `Voulez-vous suspendre TOUT le compte de ${userName} ? Sa boutique sera également masquée.`
: `Voulez-vous masquer la boutique "${businessName}" ? L'entrepreneur gardera l'accès à son compte.`} : `Voulez-vous masquer la boutique "${businessName}" ? L'entrepreneur gardera l'accès à son compte.`}
/> />
<BusinessSeoModal
isOpen={seoModalOpen}
onClose={() => setSeoModalOpen(false)}
business={{
id: businessId,
name: businessName,
metaTitle,
metaDescription
}}
/>
</div> </div>
); );
} }

160
app/HomeClient.tsx Normal file
View File

@@ -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<Business[]>(initialFeatured);
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
const [categories, setCategories] = useState<any[]>(initialCategories);
const [loading, setLoading] = useState(false);
const [loadingPosts, setLoadingPosts] = useState(false);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
router.push(`/annuaire?q=${searchTerm}`);
};
return (
<div>
{/* Hero Section */}
<div className="relative bg-dark-900 overflow-hidden">
<div className="absolute inset-0 opacity-40">
<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" width={1740} height={1160} />
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32">
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight">
Boostez votre visibilité dans <br />
<span className="text-brand-500">l'écosystème africain</span>
</h1>
<p className="text-xl text-gray-300 mb-8 max-w-2xl">
L'annuaire 2.0 qui connecte les talents, les entrepreneurs et les entreprises de la diaspora et du continent.
</p>
<form onSubmit={handleSearch} className="max-w-3xl bg-white p-2 rounded-lg shadow-xl flex flex-col md:flex-row gap-2">
<div className="flex-1 relative">
<Search className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Que recherchez-vous ? (ex: Développeur, Traiteur...)"
className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="md:w-1/3 relative border-t md:border-t-0 md:border-l border-gray-200">
<MapPin className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
<input type="text" placeholder="Localisation (ex: Abidjan)" className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900" />
</div>
<button type="submit" className="bg-brand-600 text-white px-8 py-3 rounded-md font-semibold hover:bg-brand-700 transition-colors">
Rechercher
</button>
</form>
</div>
</div>
{/* Featured Categories */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<h2 className="text-3xl font-bold text-gray-900 mb-10 font-serif text-center">Secteurs en vedette</h2>
<div className="flex flex-wrap justify-center gap-4 sm:gap-6">
{(settings?.homeCategories || categories.slice(0, 4).map(c => c.name)).map((cat: string, idx: number) => (
<Link
key={idx}
href={`/annuaire?category=${encodeURIComponent(cat)}`}
className="group cursor-pointer bg-white p-6 rounded-xl border border-gray-100 shadow-sm hover:shadow-md hover:border-brand-200 transition-all text-center w-[calc(50%-0.5rem)] sm:w-48 lg:w-56 flex flex-col items-center"
>
<div className="w-12 h-12 bg-brand-50 text-brand-600 rounded-full flex items-center justify-center mb-4 group-hover:bg-brand-600 group-hover:text-white transition-colors">
<Briefcase className="w-6 h-6" />
</div>
<h3 className="font-semibold text-gray-900 text-sm sm:text-base line-clamp-2">{cat}</h3>
</Link>
))}
</div>
</div>
{/* Featured Businesses */}
<div className="bg-gray-50 py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-end mb-8">
<div>
<h2 className="text-3xl font-bold text-gray-900 font-serif">Entreprises à la une</h2>
<p className="text-gray-500 mt-2">Découvrez les pépites de notre communauté.</p>
</div>
<Link href="/annuaire" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
Voir tout l'annuaire <TrendingUp className="w-4 h-4 ml-1" />
</Link>
</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>
{/* Blog Section */}
{settings?.homeBlogShow !== false && (
<div className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-end mb-8">
<div>
<h2 className="text-3xl font-bold text-gray-900 font-serif">{settings?.homeBlogTitle || "Derniers Articles"}</h2>
<p className="text-gray-500 mt-2">{settings?.homeBlogSubtitle || "Toutes les actualités de l'entrepreneuriat africain."}</p>
</div>
<Link href="/blog" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
Voir tout le blog <ArrowRight className="w-4 h-4 ml-1" />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{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 => (
<Link key={post.id} href={`/blog/${post.slug ? generateSlug(post.slug) : post.id}`} className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-all">
<div className="h-48 overflow-hidden">
<img src={post.imageUrl} alt={post.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" width={400} height={300} loading="lazy" />
</div>
<div className="p-6">
<h3 className="text-xl font-bold text-gray-900 mb-2 line-clamp-2 group-hover:text-brand-600 transition-colors">{post.title}</h3>
<p className="text-gray-500 text-sm line-clamp-3 mb-4">{post.excerpt}</p>
<div className="flex items-center justify-between mt-auto pt-4 border-t border-gray-50">
<span className="text-xs font-medium text-gray-400">{new Date(post.date).toLocaleDateString('fr-FR')}</span>
<span className="text-brand-600 font-bold text-xs">Lire la suite </span>
</div>
</div>
</Link>
))}
</div>
</div>
</div>
)}
</div>
);
};
export default HomeClient;

View File

@@ -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<Business>(initialBusiness);
const [loading, setLoading] = useState(false);
const [existingConversationId, setExistingConversationId] = useState<string | null>(null);
const [isShareOpen, setIsShareOpen] = useState(false);
const [selectedOffer, setSelectedOffer] = useState<any | null>(null);
const [userRating, setUserRating] = useState<number | null>(null);
const [hoverRating, setHoverRating] = useState<number | null>(null);
const [isRating, setIsRating] = useState(false);
const [ratings, setRatings] = useState<Rating[]>([]);
const [reviewComment, setReviewComment] = useState('');
const [userRatingStatus, setUserRatingStatus] = useState<'PENDING' | 'APPROVED' | 'REJECTED' | null>(null);
const [loadingRatings, setLoadingRatings] = useState(false);
const [replyingTo, setReplyingTo] = useState<string | null>(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<HTMLDivElement>(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 (
<div className="bg-gray-50 min-h-screen pb-12">
{/* Header Banner */}
<div className="h-48 md:h-64 bg-gray-900 relative overflow-hidden">
{business.coverUrl ? (
<img
src={business.coverUrl}
alt="Couverture"
className="absolute inset-0 w-full h-full object-cover opacity-60"
style={{
objectPosition: business.coverPosition || '50% 50%',
transform: `scale(${business.coverZoom || 1})`
}}
/>
) : (
<div className="absolute inset-0 bg-gradient-to-r from-gray-900 to-gray-800 opacity-100">
<div className="absolute inset-0 opacity-20 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] pointer-events-none"></div>
</div>
)}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full flex flex-col justify-between py-6 relative z-10">
<button
onClick={() => router.back()}
className="inline-flex items-center text-white/80 hover:text-white transition-colors w-fit group active:scale-95"
>
<ArrowLeft className="w-4 h-4 mr-2 transition-transform group-hover:-translate-x-1" /> Retour
</button>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-20 relative z-10">
<div className="bg-white rounded-xl shadow-lg border border-gray-100">
<div className="p-6 md:p-8">
{/* Profile Header */}
<div className="flex flex-col md:flex-row gap-6 items-start">
<div className="w-32 h-32 md:w-40 md:h-40 rounded-xl bg-white p-1 shadow-md -mt-16 md:-mt-24 border border-gray-100 flex-shrink-0 relative z-20">
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover rounded-lg bg-gray-50" />
{business.isFeatured && (
<div className="absolute -top-3 -right-3 bg-yellow-400 text-yellow-900 rounded-full p-2 shadow-md" title="Afroshine">
<Award className="w-6 h-6" />
</div>
)}
</div>
<div className="flex-1 w-full">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<div className="flex items-center gap-2">
<h1 className="text-3xl font-serif font-bold text-gray-900 flex items-center gap-2">
{business.name}
</h1>
{business.verified && <span title="Entreprise Vérifiée"><CheckCircle className="w-6 h-6 text-blue-500" /></span>}
</div>
<div className="text-brand-600 font-medium mt-1 uppercase tracking-wide text-sm">{business.category}</div>
</div>
<div className="flex gap-3 relative">
<div className="relative">
<button
onClick={() => 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"
>
<Share2 className="w-4 h-4 mr-2" /> Partager
</button>
{isShareOpen && (
<div className="absolute right-0 mt-2 w-56 bg-white rounded-xl shadow-2xl py-3 border border-gray-100 z-50 animate-in fade-in zoom-in duration-200">
<button onClick={() => 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 className="w-4 h-4 mr-3" /> Facebook
</button>
<button onClick={() => 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">
<Twitter className="w-4 h-4 mr-3" /> X (Twitter)
</button>
<button onClick={() => 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">
<Link2 className="w-4 h-4 mr-3" /> Copier le lien
</button>
</div>
)}
</div>
<button
onClick={toggleFavorite}
disabled={isTogglingFavorite}
className={`inline-flex items-center px-4 py-2 border shadow-sm text-sm font-medium rounded-md transition-all active:scale-95 ${isFavorited ? 'bg-red-500 border-red-500 text-white' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
<Heart className={`w-4 h-4 mr-2 ${isFavorited ? 'fill-current' : ''}`} />
{isFavorited ? 'Favori' : 'Mettre en favori'}
</button>
<a href="#contact-form" className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700">
Contacter
</a>
</div>
</div>
<div className="flex flex-wrap gap-4 mt-6 text-sm text-gray-500 border-t border-gray-100 pt-4">
<div className="flex items-center">
<MapPin className="w-4 h-4 mr-1 text-gray-400" />
{business.location}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-5 h-5 ${star <= Math.round(business.rating) ? 'text-yellow-400 fill-current' : 'text-gray-300'}`}
/>
))}
</div>
<span className="font-bold text-gray-900">{business.rating.toFixed(1)}</span>
<span className="text-gray-400">({business.ratingCount} votes {business.viewCount} vues)</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-8">
<div className="lg:col-span-2 space-y-8">
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<h2 className="text-xl font-bold font-serif text-gray-900 mb-4">À propos</h2>
<p className="text-gray-600 leading-relaxed whitespace-pre-line">{business.description}</p>
</div>
{videoEmbedUrl && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<h2 className="text-xl font-bold font-serif text-gray-900 mb-4 flex items-center">
<Play className="w-5 h-5 mr-2 text-brand-600" /> Présentation Vidéo
</h2>
<iframe src={videoEmbedUrl} className="w-full h-64 md:h-96 rounded-lg" allowFullScreen></iframe>
</div>
)}
{businessOffers.length > 0 && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Produits & Services</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{businessOffers.map(offer => (
<div key={offer.id} className="border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition-all cursor-pointer group" onClick={() => handleOrder(offer)}>
<div className="h-40 bg-gray-100 relative">
<img src={offer.imageUrl} alt={offer.title} className="w-full h-full object-cover" />
</div>
<div className="p-4">
<h3 className="font-bold text-gray-900">{offer.title}</h3>
<p className="text-brand-600 font-bold mt-1">{new Intl.NumberFormat('fr-FR').format(offer.price)} {offer.currency}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
<div className="space-y-8">
<div id="contact-form" ref={contactRef} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Contacter l'entreprise</h2>
<form onSubmit={handleContactSubmit} className="space-y-4">
<textarea
id="contact-message"
value={contactForm.message}
onChange={(e) => 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
/>
<button type="submit" disabled={formStatus === 'sending'} className="w-full bg-brand-600 text-white py-3 rounded-lg font-bold hover:bg-brand-700 transition-colors flex items-center justify-center gap-2">
{formStatus === 'sending' ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
Envoyer le message
</button>
</form>
</div>
</div>
</div>
</div>
</div>
);
};
export default BusinessDetailClient;

File diff suppressed because it is too large Load Diff

View File

@@ -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'; export async function generateMetadata(): Promise<Metadata> {
import Link from 'next/link'; const settings = await prisma.siteSetting.findUnique({
import { useRouter } from 'next/navigation'; where: { id: 'singleton' }
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';
const HomePage = () => { const title = settings?.siteName || 'Afroprenariat';
const router = useRouter(); const description = settings?.siteSlogan || "La plateforme de référence pour l'entrepreneuriat africain.";
const { user, settings } = useUser();
const [searchTerm, setSearchTerm] = useState('');
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>([]);
const [posts, setPosts] = useState<BlogPost[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [loadingPosts, setLoadingPosts] = useState(true);
useEffect(() => { return {
const fetchFeatured = async () => { title: {
try { default: title,
const res = await fetch('/api/businesses?featured=true', { template: `%s | ${title}`
headers: user ? { 'x-user-id': user.id } : {} },
}); description: description,
const data = await res.json(); openGraph: {
if (Array.isArray(data)) { title: title,
setFeaturedBusinesses(data.slice(0, 4)); // Show top 4 description: description,
} url: 'https://afroprenariat.com',
} catch (error) { siteName: title,
console.error('Error fetching featured businesses:', error); images: [
} finally { {
setLoading(false); 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,
fetchFeatured(); alt: title,
},
const fetchPosts = async () => { ],
try { locale: 'fr_FR',
const res = await fetch('/api/blog'); type: 'website',
const data = await res.json(); },
if (Array.isArray(data)) { twitter: {
setPosts(data); card: 'summary_large_image',
} title: title,
} catch (error) { description: description,
console.error('Error fetching blog posts:', error); images: ['https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1200&h=630&q=80'],
} 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}`);
}; };
}
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 ( return (
<div> <HomeClient
{/* Hero Section */} initialFeatured={initialFeatured}
<div className="relative bg-dark-900 overflow-hidden"> initialPosts={initialPosts}
<div className="absolute inset-0 opacity-40"> initialCategories={initialCategories}
<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" width={1740} height={1160} /> />
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32">
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight">
Boostez votre visibilité dans <br />
<span className="text-brand-500">l'écosystème africain</span>
</h1>
<p className="text-xl text-gray-300 mb-8 max-w-2xl">
L'annuaire 2.0 qui connecte les talents, les entrepreneurs et les entreprises de la diaspora et du continent.
</p>
<form onSubmit={handleSearch} className="max-w-3xl bg-white p-2 rounded-lg shadow-xl flex flex-col md:flex-row gap-2">
<div className="flex-1 relative">
<Search className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Que recherchez-vous ? (ex: Développeur, Traiteur...)"
className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="md:w-1/3 relative border-t md:border-t-0 md:border-l border-gray-200">
<MapPin className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
<input type="text" placeholder="Localisation (ex: Abidjan)" className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900" />
</div>
<button type="submit" className="bg-brand-600 text-white px-8 py-3 rounded-md font-semibold hover:bg-brand-700 transition-colors">
Rechercher
</button>
</form>
</div>
</div>
{/* Featured Categories */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<h2 className="text-3xl font-bold text-gray-900 mb-10 font-serif text-center">Secteurs en vedette</h2>
<div className="flex flex-wrap justify-center gap-4 sm:gap-6">
{(settings?.homeCategories || categories.slice(0, 4).map(c => c.name)).map((cat: string, idx: number) => (
<Link
key={idx}
href={`/annuaire?category=${encodeURIComponent(cat)}`}
className="group cursor-pointer bg-white p-6 rounded-xl border border-gray-100 shadow-sm hover:shadow-md hover:border-brand-200 transition-all text-center w-[calc(50%-0.5rem)] sm:w-48 lg:w-56 flex flex-col items-center"
>
<div className="w-12 h-12 bg-brand-50 text-brand-600 rounded-full flex items-center justify-center mb-4 group-hover:bg-brand-600 group-hover:text-white transition-colors">
<Briefcase className="w-6 h-6" />
</div>
<h3 className="font-semibold text-gray-900 text-sm sm:text-base line-clamp-2">{cat}</h3>
</Link>
))}
</div>
</div>
{/* Featured Businesses */}
<div className="bg-gray-50 py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-end mb-8">
<div>
<h2 className="text-3xl font-bold text-gray-900 font-serif">Entreprises à la une</h2>
<p className="text-gray-500 mt-2">Découvrez les pépites de notre communauté.</p>
</div>
<Link href="/annuaire" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
Voir tout l'annuaire <TrendingUp className="w-4 h-4 ml-1" />
</Link>
</div>
{loading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-10 h-10 text-brand-600 animate-spin" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{featuredBusinesses.map(biz => (
<BusinessCard key={biz.id} business={biz} />
))}
</div>
)}
</div>
</div>
{/* Blog Section */}
{settings?.homeBlogShow !== false && (
<div className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-end mb-8">
<div>
<h2 className="text-3xl font-bold text-gray-900 font-serif">{settings?.homeBlogTitle || "Derniers Articles"}</h2>
<p className="text-gray-500 mt-2">{settings?.homeBlogSubtitle || "Toutes les actualités de l'entrepreneuriat africain."}</p>
</div>
<Link href="/blog" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
Voir tout le blog <ArrowRight className="w-4 h-4 ml-1" />
</Link>
</div>
{loadingPosts ? (
<div className="flex justify-center py-12">
<Loader2 className="w-10 h-10 text-brand-600 animate-spin" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{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 => (
<Link key={post.id} href={`/blog/${post.slug ? generateSlug(post.slug) : post.id}`} className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-all">
<div className="h-48 overflow-hidden">
<img src={post.imageUrl} alt={post.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" width={400} height={300} loading="lazy" />
</div>
<div className="p-6">
<h3 className="text-xl font-bold text-gray-900 mb-2 line-clamp-2 group-hover:text-brand-600 transition-colors">{post.title}</h3>
<p className="text-gray-500 text-sm line-clamp-3 mb-4">{post.excerpt}</p>
<div className="flex items-center justify-between mt-auto pt-4 border-t border-gray-50">
<span className="text-xs font-medium text-gray-400">{new Date(post.date).toLocaleDateString('fr-FR')}</span>
<span className="text-brand-600 font-bold text-xs">Lire la suite </span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
</div>
)}
</div>
); );
}; }
export default HomePage;

View File

@@ -1,12 +1,12 @@
"use client"; "use client";
import React, { createContext, useContext, useState, ReactNode } from 'react'; import React, { createContext, useContext, useState, ReactNode } from 'react';
import { User } from '../types'; import { User, SiteSetting } from '../types';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
interface UserContextType { interface UserContextType {
user: User | null; user: User | null;
settings: any | null; settings: SiteSetting | null;
login: (user: User) => void; login: (user: User) => void;
logout: () => void; logout: () => void;
} }
@@ -15,7 +15,7 @@ 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 [settings, setSettings] = useState<any>(null); const [settings, setSettings] = useState<SiteSetting | null>(null);
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();

View File

@@ -33,7 +33,10 @@ export const MOCK_BUSINESSES: Business[] = [
verified: true, verified: true,
viewCount: 1240, viewCount: 1240,
rating: 4.8, rating: 4.8,
ratingCount: 45,
tags: ['Dev', 'SaaS', 'Mobile', 'ERP'], tags: ['Dev', 'SaaS', 'Mobile', 'ERP'],
metaTitle: null,
metaDescription: null,
// Featured Data // Featured Data
isFeatured: true, isFeatured: true,
founderName: 'Jean-Marc Kouassi', founderName: 'Jean-Marc Kouassi',
@@ -57,7 +60,10 @@ export const MOCK_BUSINESSES: Business[] = [
verified: true, verified: true,
viewCount: 850, viewCount: 850,
rating: 4.9, rating: 4.9,
ratingCount: 12,
tags: ['Traiteur', 'Bio', 'Événementiel', 'Gastronomie'], tags: ['Traiteur', 'Bio', 'Événementiel', 'Gastronomie'],
metaTitle: null,
metaDescription: null,
isFeatured: true, isFeatured: true,
founderName: 'Aïssa Maïga', founderName: 'Aïssa Maïga',
socialLinks: { instagram: 'https://instagram.com' } socialLinks: { instagram: 'https://instagram.com' }
@@ -78,7 +84,10 @@ export const MOCK_BUSINESSES: Business[] = [
verified: false, verified: false,
viewCount: 320, viewCount: 320,
rating: 4.5, rating: 4.5,
ratingCount: 8,
tags: ['Mode', 'Ankara', 'Luxe', 'Ethique'], tags: ['Mode', 'Ankara', 'Luxe', 'Ethique'],
metaTitle: null,
metaDescription: null,
founderName: 'Chidinma Okeke' founderName: 'Chidinma Okeke'
}, },
{ {
@@ -98,7 +107,10 @@ export const MOCK_BUSINESSES: Business[] = [
verified: true, verified: true,
viewCount: 560, viewCount: 560,
rating: 4.7, rating: 4.7,
ratingCount: 24,
tags: ['Énergie', 'Solaire', 'Durable', 'Tech'], tags: ['Énergie', 'Solaire', 'Durable', 'Tech'],
metaTitle: null,
metaDescription: null,
isFeatured: true, isFeatured: true,
founderName: 'Ousmane Diop' founderName: 'Ousmane Diop'
}, },
@@ -120,7 +132,10 @@ export const MOCK_BUSINESSES: Business[] = [
verified: true, verified: true,
viewCount: 410, viewCount: 410,
rating: 4.6, rating: 4.6,
ratingCount: 15,
tags: ['Bio', 'Skincare', 'Karité', 'Naturel'], tags: ['Bio', 'Skincare', 'Karité', 'Naturel'],
metaTitle: null,
metaDescription: null,
founderName: 'Marie-Claire Etoa', 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' 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, verified: true,
viewCount: 980, viewCount: 980,
rating: 5.0, rating: 5.0,
ratingCount: 112,
tags: ['Formation', 'Code', 'Bootcamp', 'Emploi'], tags: ['Formation', 'Code', 'Bootcamp', 'Emploi'],
metaTitle: null,
metaDescription: null,
founderName: 'David Ochieng', founderName: 'David Ochieng',
keyMetric: '90% d\'embauche' keyMetric: '90% d\'embauche'
}, },
@@ -163,7 +181,10 @@ export const MOCK_BUSINESSES: Business[] = [
verified: false, verified: false,
viewCount: 230, viewCount: 230,
rating: 4.2, rating: 4.2,
ratingCount: 4,
tags: ['FinTech', 'Paiement', 'Mobile Money', 'API'], tags: ['FinTech', 'Paiement', 'Mobile Money', 'API'],
metaTitle: null,
metaDescription: null,
founderName: 'Serge Mbemba' founderName: 'Serge Mbemba'
}, },
{ {
@@ -184,7 +205,10 @@ export const MOCK_BUSINESSES: Business[] = [
verified: true, verified: true,
viewCount: 670, viewCount: 670,
rating: 4.8, rating: 4.8,
ratingCount: 32,
tags: ['Architecture', 'Écologie', 'BTC', 'Durable'], tags: ['Architecture', 'Écologie', 'BTC', 'Durable'],
metaTitle: null,
metaDescription: null,
isFeatured: true, 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'

Binary file not shown.

View File

@@ -68,6 +68,8 @@ model Business {
websiteUrl String? websiteUrl String?
isSuspended Boolean @default(false) isSuspended Boolean @default(false)
suspensionReason String? suspensionReason String?
metaTitle String?
metaDescription String?
plan Plan @default(STARTER) plan Plan @default(STARTER)
city String? city String?
countryId String? countryId String?

View File

@@ -64,9 +64,12 @@ export interface Business {
verified: boolean; verified: boolean;
viewCount: number; viewCount: number;
rating: number; rating: number;
ratingCount: number;
tags: string[]; tags: string[];
isSuspended?: boolean; isSuspended?: boolean;
suspensionReason?: string; suspensionReason?: string;
metaTitle?: string | null;
metaDescription?: string | null;
// New fields for "Entrepreneur of the Month" // New fields for "Entrepreneur of the Month"
isFeatured?: boolean; isFeatured?: boolean;
@@ -147,3 +150,33 @@ export interface Event {
link?: string; link?: string;
tags?: 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[];
}