migration correction et maj
Some checks failed
Build and Push App / build (push) Failing after 11m56s
Some checks failed
Build and Push App / build (push) Failing after 11m56s
This commit is contained in:
BIN
admin/prisma/migrations/0_init/migration.sql
Normal file
BIN
admin/prisma/migrations/0_init/migration.sql
Normal file
Binary file not shown.
459
admin/prisma/migrations/20260427214502_init/migration.sql
Normal file
459
admin/prisma/migrations/20260427214502_init/migration.sql
Normal 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;">© 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;">© 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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
3
admin/prisma/migrations/migration_lock.toml
Normal file
3
admin/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
@@ -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?
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
120
admin/src/components/BusinessSeoModal.tsx
Normal file
120
admin/src/components/BusinessSeoModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<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 */}
|
||||
{isUserSuspended ? (
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
160
app/HomeClient.tsx
Normal file
160
app/HomeClient.tsx
Normal 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;
|
||||
469
app/annuaire/[id]/BusinessDetailClient.tsx
Normal file
469
app/annuaire/[id]/BusinessDetailClient.tsx
Normal 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
266
app/page.tsx
266
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';
|
||||
|
||||
const HomePage = () => {
|
||||
const router = useRouter();
|
||||
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(() => {
|
||||
const fetchFeatured = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/businesses?featured=true', {
|
||||
headers: user ? { 'x-user-id': user.id } : {}
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const settings = await prisma.siteSetting.findUnique({
|
||||
where: { id: 'singleton' }
|
||||
});
|
||||
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 title = settings?.siteName || 'Afroprenariat';
|
||||
const description = settings?.siteSlogan || "La plateforme de référence pour l'entrepreneuriat africain.";
|
||||
|
||||
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 (
|
||||
<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)}
|
||||
<HomeClient
|
||||
initialFeatured={initialFeatured}
|
||||
initialPosts={initialPosts}
|
||||
initialCategories={initialCategories}
|
||||
/>
|
||||
</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;
|
||||
}
|
||||
|
||||
@@ -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<UserContextType | undefined>(undefined);
|
||||
|
||||
export function UserProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [settings, setSettings] = useState<any>(null);
|
||||
const [settings, setSettings] = useState<SiteSetting | null>(null);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
BIN
prisma/migrations/0_init/migration.sql
Normal file
BIN
prisma/migrations/0_init/migration.sql
Normal file
Binary file not shown.
@@ -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?
|
||||
|
||||
33
types.ts
33
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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user