Compare commits

...

2 Commits

27 changed files with 1457 additions and 229 deletions

View File

@@ -35,11 +35,15 @@ model Business {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
category String category String
suggestedCategory String?
location String // Legacy location string location String // Legacy location string
countryId String? countryId String?
city String? city String?
description String description String
logoUrl String logoUrl String
coverUrl String?
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
videoUrl String? videoUrl String?
contactEmail String contactEmail String
contactPhone String? contactPhone String?
@@ -71,6 +75,19 @@ model Business {
offers Offer[] offers Offer[]
ratings Rating[] ratings Rating[]
country Country? @relation(fields: [countryId], references: [id]) country Country? @relation(fields: [countryId], references: [id])
categoryRef BusinessCategory? @relation(fields: [categoryId], references: [id])
categoryId String?
}
model BusinessCategory {
id String @id @default(uuid())
name String @unique
slug String @unique
icon String? // Lucide icon name
isActive Boolean @default(true)
businesses Business[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model Country { model Country {
@@ -85,18 +102,14 @@ model Country {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
enum Plan {
STARTER
BOOSTER
EMPIRE
}
model PricingPlan { model PricingPlan {
id String @id @default(uuid()) id String @id @default(uuid())
tier Plan @unique tier Plan @unique
name String name String
priceXOF String priceXOF String
yearlyPriceXOF String?
priceEUR String priceEUR String
yearlyPriceEUR String?
description String description String
features String[] features String[]
offerLimit Int @default(1) offerLimit Int @default(1)
@@ -265,6 +278,12 @@ enum UserRole {
ADMIN ADMIN
} }
enum Plan {
STARTER
BOOSTER
EMPIRE
}
enum OfferType { enum OfferType {
PRODUCT PRODUCT
SERVICE SERVICE
@@ -287,8 +306,17 @@ model SiteSetting {
instagramUrl String? instagramUrl String?
linkedinUrl String? linkedinUrl String?
footerText String? @default("© 2025 Afrohub. Tous droits réservés.") footerText String? @default("© 2025 Afrohub. Tous droits réservés.")
homeBlogShow Boolean @default(true)
homeBlogTitle String @default("Derniers Articles")
homeBlogSubtitle String @default("Toutes les actualités de l'entrepreneuriat africain.")
homeBlogCount Int @default(3)
homeBlogSelection String @default("latest")
homeBlogIds String[] @default([])
homeBlogCategories String[] @default([])
homeCategories String[] @default(["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"])
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model LegalDocument { model LegalDocument {
id String @id @default(uuid()) id String @id @default(uuid())
type String @unique // 'CGU' or 'CGV' type String @unique // 'CGU' or 'CGV'

View File

@@ -75,3 +75,14 @@ export async function deleteBlogPost(id: string) {
return { success: false, error: "Erreur lors de la suppression" }; return { success: false, error: "Erreur lors de la suppression" };
} }
} }
export async function getBlogPosts() {
try {
return await prisma.blogPost.findMany({
orderBy: { date: 'desc' }
});
} catch (error) {
console.error("Failed to fetch blog posts:", error);
return [];
}
}

View File

@@ -0,0 +1,111 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function getCategories() {
try {
return await prisma.businessCategory.findMany({
orderBy: { name: 'asc' }
});
} catch (error) {
console.error("Failed to fetch categories:", error);
return [];
}
}
export async function createCategory(data: { name: string; slug: string; icon?: string; suggestionId?: string }) {
try {
const { suggestionId, ...categoryData } = data;
const category = await prisma.businessCategory.create({
data: categoryData
});
// Si c'est une conversion de suggestion, on lie automatiquement l'entreprise concernée
if (suggestionId) {
await prisma.business.update({
where: { id: suggestionId },
data: {
category: category.name,
categoryId: category.id,
suggestedCategory: null // On nettoie le champ tampon car la suggestion est validée
}
});
}
revalidatePath("/settings");
revalidatePath("/categories");
revalidatePath("/entrepreneurs");
return { success: true, data: category };
} catch (error) {
console.error("Failed to create category:", error);
return { success: false, error: "Erreur lors de la création de la catégorie" };
}
}
export async function updateCategory(id: string, data: { name: string; slug: string; icon?: string; isActive: boolean }) {
try {
// 1. Récupérer l'ancien nom pour la migration
const oldCategory = await prisma.businessCategory.findUnique({
where: { id },
select: { name: true }
});
// 2. Mettre à jour la catégorie
const category = await prisma.businessCategory.update({
where: { id },
data
});
// 3. Si le nom a changé, mettre à jour tous les entrepreneurs qui l'utilisaient
if (oldCategory && oldCategory.name !== data.name) {
await prisma.business.updateMany({
where: { category: oldCategory.name },
data: { category: data.name }
});
}
revalidatePath("/settings");
revalidatePath("/categories");
return { success: true, data: category };
} catch (error) {
console.error("Failed to update category:", error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}
export async function deleteCategory(id: string) {
try {
await prisma.businessCategory.delete({
where: { id }
});
revalidatePath("/settings");
revalidatePath("/categories");
return { success: true };
} catch (error) {
console.error("Failed to delete category:", error);
return { success: false, error: "Erreur lors de la suppression" };
}
}
export async function getSuggestedCategories() {
try {
return await prisma.business.findMany({
where: {
suggestedCategory: { not: null },
category: 'Autre'
},
select: {
id: true,
name: true,
suggestedCategory: true,
createdAt: true
},
orderBy: { createdAt: 'desc' }
});
} catch (error) {
console.error("Failed to fetch suggested categories:", error);
return [];
}
}

View File

@@ -21,7 +21,9 @@ export async function updatePricingPlanDetail(id: string, data: any) {
data: { data: {
name: data.name, name: data.name,
priceXOF: data.priceXOF, priceXOF: data.priceXOF,
yearlyPriceXOF: data.yearlyPriceXOF || null,
priceEUR: data.priceEUR, priceEUR: data.priceEUR,
yearlyPriceEUR: data.yearlyPriceEUR || null,
description: data.description, description: data.description,
features: data.features, // Expected to be string[] features: data.features, // Expected to be string[]
offerLimit: parseInt(data.offerLimit), offerLimit: parseInt(data.offerLimit),

View File

@@ -20,7 +20,15 @@ export async function getSiteSettings() {
twitterUrl: "", twitterUrl: "",
instagramUrl: "", instagramUrl: "",
linkedinUrl: "", linkedinUrl: "",
footerText: "© 2025 Afrohub. Tous droits réservés." footerText: "© 2025 Afrohub. Tous droits réservés.",
homeBlogShow: true,
homeBlogTitle: "Derniers Articles",
homeBlogSubtitle: "Toutes les actualités de l'entrepreneuriat africain.",
homeBlogCount: 3,
homeBlogSelection: "latest",
homeBlogIds: [],
homeBlogCategories: [],
homeCategories: ["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"]
}; };
if (!settings) return defaultSettings; if (!settings) return defaultSettings;
@@ -43,7 +51,7 @@ export async function updateSiteSettings(data: any) {
}); });
revalidatePath('/settings'); revalidatePath('/settings');
revalidatePath('/', 'layout'); // Revalidate all public pages because footer/header might use it revalidatePath('/', 'layout');
return { success: true, settings }; return { success: true, settings };
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,404 @@
"use client";
import React, { useState, useEffect, useTransition } from 'react';
import {
Plus,
Edit2,
Trash2,
Check,
X,
Loader2,
Briefcase,
AlertCircle,
Clock,
Sparkles,
Cpu,
Sprout,
Shirt,
Utensils,
HardHat,
Stethoscope,
GraduationCap,
Palette,
Plane,
Truck,
Wallet,
Zap,
Leaf,
Camera,
Music,
ShoppingBag,
Heart,
Home
} from 'lucide-react';
import { getCategories, createCategory, updateCategory, deleteCategory, getSuggestedCategories } from '@/app/actions/categories';
import { toast } from 'react-hot-toast';
const ICON_LIST = [
{ name: 'Briefcase', icon: Briefcase },
{ name: 'Cpu', icon: Cpu },
{ name: 'Sprout', icon: Sprout },
{ name: 'Shirt', icon: Shirt },
{ name: 'Sparkles', icon: Sparkles },
{ name: 'Utensils', icon: Utensils },
{ name: 'HardHat', icon: HardHat },
{ name: 'Stethoscope', icon: Stethoscope },
{ name: 'GraduationCap', icon: GraduationCap },
{ name: 'Palette', icon: Palette },
{ name: 'Plane', icon: Plane },
{ name: 'Truck', icon: Truck },
{ name: 'Wallet', icon: Wallet },
{ name: 'Zap', icon: Zap },
{ name: 'Leaf', icon: Leaf },
{ name: 'Camera', icon: Camera },
{ name: 'Music', icon: Music },
{ name: 'ShoppingBag', icon: ShoppingBag },
{ name: 'Heart', icon: Heart },
{ name: 'Home', icon: Home },
];
export default function CategoriesPage() {
const [categories, setCategories] = useState<any[]>([]);
const [suggestions, setSuggestions] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [isPending, startTransition] = useTransition();
const [showAddModal, setShowAddModal] = useState(false);
const [editingCategory, setEditingCategory] = useState<any>(null);
const [selectedSuggestion, setSelectedSuggestion] = useState<any>(null);
const renderIcon = (iconName: string) => {
const found = ICON_LIST.find(i => i.name === iconName);
if (found) {
const IconComp = found.icon;
return <IconComp className="w-4 h-4 text-indigo-400" />;
}
return <Briefcase className="w-4 h-4 text-indigo-400" />;
};
const [formData, setFormData] = useState({
name: '',
slug: '',
icon: 'Briefcase',
isActive: true
});
useEffect(() => {
loadCategories();
}, []);
async function loadCategories() {
setLoading(true);
try {
const [catData, suggData] = await Promise.all([
getCategories(),
getSuggestedCategories()
]);
setCategories(catData);
setSuggestions(suggData);
} catch (e) {
toast.error("Erreur de chargement");
} finally {
setLoading(false);
}
}
const handleAddCategory = async (e: React.FormEvent) => {
e.preventDefault();
startTransition(async () => {
const result = await createCategory({ ...formData, suggestionId: selectedSuggestion?.id });
if (result.success) {
toast.success("Catégorie créée !");
setShowAddModal(false);
setSelectedSuggestion(null);
setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true });
loadCategories();
} else {
toast.error(result.error || "Erreur");
}
});
};
const handleUpdateCategory = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingCategory) return;
startTransition(async () => {
const result = await updateCategory(editingCategory.id, formData);
if (result.success) {
toast.success("Catégorie mise à jour !");
setEditingCategory(null);
loadCategories();
} else {
toast.error(result.error || "Erreur");
}
});
};
const handleDelete = async (id: string) => {
if (!confirm("Supprimer cette catégorie ?")) return;
const result = await deleteCategory(id);
if (result.success) {
toast.success("Supprimée !");
loadCategories();
} else {
toast.error(result.error || "Erreur");
}
};
const openEdit = (cat: any) => {
setEditingCategory(cat);
setFormData({
name: cat.name,
slug: cat.slug,
icon: cat.icon || 'Briefcase',
isActive: cat.isActive
});
};
if (loading) {
return (
<div className="flex items-center justify-center p-20">
<Loader2 className="w-8 h-8 animate-spin text-indigo-500" />
</div>
);
}
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">Secteurs & Catégories</h1>
<p className="text-slate-400">Gérez les secteurs d'activité disponibles pour les entrepreneurs.</p>
</div>
<button
onClick={() => { setShowAddModal(true); setEditingCategory(null); setSelectedSuggestion(null); setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true }); }}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-xl flex items-center gap-2 transition-all shadow-lg shadow-indigo-600/20"
>
<Plus className="w-4 h-4" /> Nouvelle Catégorie
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center justify-between">
<h2 className="font-semibold text-white">Catégories Officielles</h2>
<span className="text-xs text-slate-400">{categories.length} secteurs</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-slate-700">
<th className="p-4 text-xs font-semibold text-slate-400 uppercase tracking-wider">Nom</th>
<th className="p-4 text-xs font-semibold text-slate-400 uppercase tracking-wider">Statut</th>
<th className="p-4 text-xs font-semibold text-slate-400 uppercase tracking-wider text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{categories.map((cat) => (
<tr key={cat.id} className="hover:bg-slate-800/30 transition-colors">
<td className="p-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-slate-800 rounded-lg flex items-center justify-center">
{renderIcon(cat.icon)}
</div>
<div>
<p className="font-medium text-white">{cat.name}</p>
<p className="text-[10px] text-slate-500 font-mono">{cat.slug}</p>
</div>
</div>
</td>
<td className="p-4">
{cat.isActive ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-500">
<Check className="w-3 h-3" /> Actif
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-500/10 text-slate-500">
<X className="w-3 h-3" /> Inactif
</span>
)}
</td>
<td className="p-4 text-right">
<div className="flex justify-end gap-2">
<button
onClick={() => openEdit(cat)}
className="p-2 text-slate-400 hover:text-indigo-400 hover:bg-indigo-400/10 rounded-lg transition-all"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(cat.id)}
className="p-2 text-slate-400 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<div className="space-y-6">
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
<Clock className="w-5 h-5 text-yellow-400" />
<h2 className="font-semibold text-white">Suggestions</h2>
</div>
<div className="p-4 space-y-4">
{suggestions.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-3">
<Check className="w-6 h-6 text-slate-600" />
</div>
<p className="text-sm text-slate-500">Aucune suggestion en attente.</p>
</div>
) : (
suggestions.map((sugg) => (
<div key={sugg.id} className="p-4 bg-slate-950 rounded-xl border border-slate-800 space-y-2 hover:border-indigo-500/30 transition-colors group">
<div className="flex justify-between items-start">
<span className="text-[10px] font-bold text-indigo-400 uppercase tracking-wider bg-indigo-400/10 px-2 py-0.5 rounded">Proposé</span>
<span className="text-[10px] text-slate-500">{new Date(sugg.createdAt).toLocaleDateString()}</span>
</div>
<p className="text-white font-bold text-lg">{sugg.suggestedCategory}</p>
<div className="flex items-center gap-2 text-xs text-slate-400">
<Briefcase className="w-3 h-3" />
<span>Par : <span className="text-slate-300 font-medium">{sugg.name}</span></span>
</div>
<button
onClick={() => {
setSelectedSuggestion(sugg);
setFormData({
name: sugg.suggestedCategory,
slug: sugg.suggestedCategory.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/ /g, '-').replace(/[^\w-]/g, ''),
icon: 'Briefcase',
isActive: true
});
setShowAddModal(true);
}}
className="w-full mt-3 text-xs bg-indigo-600/10 text-indigo-400 hover:bg-indigo-600 hover:text-white font-bold py-2.5 rounded-lg transition-all border border-indigo-600/20"
>
Convertir en catégorie officielle
</button>
</div>
))
)}
</div>
</div>
</div>
</div>
{(showAddModal || editingCategory) && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-2xl overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
<div className="p-6 border-b border-slate-700 flex justify-between items-center bg-slate-800/30">
<div className="flex items-center gap-3">
{selectedSuggestion && <Sparkles className="w-5 h-5 text-yellow-400" />}
<h3 className="text-xl font-bold text-white">
{editingCategory ? 'Modifier la catégorie' : selectedSuggestion ? 'Valider la suggestion' : 'Nouvelle catégorie'}
</h3>
</div>
<button
onClick={() => { setShowAddModal(false); setEditingCategory(null); setSelectedSuggestion(null); }}
className="text-slate-400 hover:text-white transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={editingCategory ? handleUpdateCategory : handleAddCategory} className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nom de la catégorie</label>
<input
required
autoFocus
value={formData.name}
onChange={(e) => {
const name = e.target.value;
const slug = name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/ /g, '-').replace(/[^\w-]/g, '');
setFormData({...formData, name, slug});
}}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Slug (URL)</label>
<input
required
value={formData.slug}
onChange={(e) => setFormData({...formData, slug: e.target.value})}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 font-mono text-sm"
/>
</div>
<div className="flex items-center gap-3 p-3 bg-slate-950 rounded-xl border border-slate-800">
<input
type="checkbox"
id="isActive"
checked={formData.isActive}
onChange={(e) => setFormData({...formData, isActive: e.target.checked})}
className="w-5 h-5 rounded border-slate-700 bg-slate-800 text-indigo-600 focus:ring-indigo-500"
/>
<label htmlFor="isActive" className="text-sm font-medium text-slate-300">Rendre active immédiatement</label>
</div>
{selectedSuggestion && (
<div className="p-4 bg-indigo-500/10 border border-indigo-500/20 rounded-xl text-xs text-indigo-300">
<p>✨ **Note :** En créant cette catégorie, l'entreprise **{selectedSuggestion.name}** y sera automatiquement rattachée.</p>
</div>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 block mb-2">Choisir une icône</label>
<div className="grid grid-cols-5 gap-2 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
{ICON_LIST.map((item) => (
<button
key={item.name}
type="button"
onClick={() => setFormData({...formData, icon: item.name})}
className={`p-3 rounded-xl border flex items-center justify-center transition-all ${
formData.icon === item.name
? 'bg-indigo-600 border-indigo-500 text-white shadow-lg shadow-indigo-600/30 scale-105'
: 'bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-600'
}`}
title={item.name}
>
{typeof item.icon === 'function' ? item.icon() : <item.icon className="w-5 h-5" />}
</button>
))}
</div>
</div>
</div>
<div className="pt-4 flex gap-3">
<button
type="button"
onClick={() => { setShowAddModal(false); setEditingCategory(null); setSelectedSuggestion(null); }}
className="flex-1 bg-slate-800 hover:bg-slate-700 text-white font-bold py-3 rounded-xl transition-all border border-slate-700"
>
Annuler
</button>
<button
type="submit"
disabled={isPending}
className="flex-1 bg-indigo-600 hover:bg-indigo-700 disabled:bg-slate-700 text-white font-bold py-3 rounded-xl transition-all flex items-center justify-center gap-2 shadow-lg shadow-indigo-600/20"
>
{isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{editingCategory ? 'Sauvegarder les modifications' : selectedSuggestion ? 'Valider et Créer' : 'Créer la catégorie'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -9,6 +9,7 @@ export default function PlansPage() {
const [plans, setPlans] = useState<any[]>([]); const [plans, setPlans] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editingPlan, setEditingPlan] = useState<any>(null); const [editingPlan, setEditingPlan] = useState<any>(null);
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
useEffect(() => { useEffect(() => {
async function loadPlans() { async function loadPlans() {
@@ -29,9 +30,27 @@ export default function PlansPage() {
return ( return (
<div className="p-8"> <div className="p-8">
<div className="mb-8"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Tarifs & Forfaits</h1> <div>
<p className="text-slate-400">Modifiez les noms, prix, limites d'offres et avantages affichés sur le site principal.</p> <h1 className="text-3xl font-bold text-white mb-2 font-serif">Gestion des Tarifs & Forfaits</h1>
<p className="text-slate-400">Modifiez les noms, prix et limites d'offres affichés sur le site.</p>
</div>
{/* Toggle Billing */}
<div className="flex bg-slate-900/50 p-1 rounded-xl border border-slate-800">
<button
onClick={() => setBillingCycle('monthly')}
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all ${billingCycle === 'monthly' ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-600/20' : 'text-slate-400 hover:text-white'}`}
>
Mensuel
</button>
<button
onClick={() => setBillingCycle('yearly')}
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all ${billingCycle === 'yearly' ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-600/20' : 'text-slate-400 hover:text-white'}`}
>
Annuel
</button>
</div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
@@ -55,14 +74,25 @@ export default function PlansPage() {
</span> </span>
)} )}
</div> </div>
<h3 className="text-xl font-bold text-white mb-1 uppercase tracking-wider">{plan.name}</h3> <h3 className="text-xl font-bold text-white mb-1 uppercase tracking-wider font-serif">{plan.name}</h3>
<p className="text-sm text-slate-400 line-clamp-1">{plan.description}</p> <p className="text-sm text-slate-400 line-clamp-1">{plan.description}</p>
</div> </div>
{/* Price & Limit */} {/* Price & Limit */}
<div className="p-6 bg-slate-950/30 flex-1"> <div className="p-6 bg-slate-950/30 flex-1">
<div className="mb-6"> <div className="mb-6">
<div className="text-3xl font-bold text-white mb-1">{plan.priceXOF}</div> <div className="text-3xl font-bold text-white mb-1">
{billingCycle === 'yearly'
? (plan.yearlyPriceXOF || plan.priceXOF)
: plan.priceXOF
}
<span className="text-xs text-slate-500 font-normal ml-2">
/ {billingCycle === 'yearly' ? 'an' : 'mois'}
</span>
</div>
{billingCycle === 'yearly' && plan.yearlyPriceXOF && (
<div className="text-xs text-emerald-500 font-bold mb-2">PRIX ANNUEL ACTIF</div>
)}
<div className="text-xs text-slate-500 uppercase font-bold tracking-tighter">Limite : {plan.offerLimit} Offre(s)</div> <div className="text-xs text-slate-500 uppercase font-bold tracking-tighter">Limite : {plan.offerLimit} Offre(s)</div>
</div> </div>

View File

@@ -1,19 +1,58 @@
"use client"; "use client";
import React, { useState, useEffect, useTransition } from 'react'; import React, { useState, useEffect, useTransition } from 'react';
import { Save, Globe, Mail, Phone, MapPin, Share2, Link as LinkIcon, Loader2 } from 'lucide-react'; import { Save, Globe, Mail, Phone, MapPin, Share2, Link as LinkIcon, Loader2, Newspaper, Briefcase } from 'lucide-react';
import { getSiteSettings, updateSiteSettings } from '@/app/actions/settings'; import { getSiteSettings, updateSiteSettings } from '@/app/actions/settings';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { getBlogPosts } from '@/app/actions/blog';
const BUSINESS_CATEGORIES = [
"Technologie & IT",
"Agriculture & Agrobusiness",
"Mode & Textile",
"Cosmétique & Beauté",
"Services aux entreprises",
"Restauration & Alimentation",
"Construction & BTP",
"Éducation & Formation",
"Santé & Bien-être",
"Artisanat & Déco",
"Tourisme & Loisirs",
"Finance & Assurance",
"Immobilier",
"Transport & Logistique",
"Média & Communication",
"Autre"
];
const BLOG_CATEGORIES = [
"Actualité",
"Conseils",
"Success Story",
"Événement",
"Formation"
];
export default function SettingsPage() { export default function SettingsPage() {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [settings, setSettings] = useState<any>(null); const [settings, setSettings] = useState<any>(null);
const [allPosts, setAllPosts] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
const [selectedHomeCategories, setSelectedHomeCategories] = useState<string[]>([]);
const [selectedBlogCategories, setSelectedBlogCategories] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
async function loadData() { async function loadData() {
const data = await getSiteSettings(); const [settingsData, postsData] = await Promise.all([
setSettings(data); getSiteSettings(),
getBlogPosts()
]);
setSettings(settingsData);
setAllPosts(postsData);
setSelectedPostIds(settingsData?.homeBlogIds || []);
setSelectedHomeCategories(settingsData?.homeCategories || []);
setSelectedBlogCategories(settingsData?.homeBlogCategories || []);
setLoading(false); setLoading(false);
} }
loadData(); loadData();
@@ -33,6 +72,14 @@ export default function SettingsPage() {
instagramUrl: formData.get('instagramUrl'), instagramUrl: formData.get('instagramUrl'),
linkedinUrl: formData.get('linkedinUrl'), linkedinUrl: formData.get('linkedinUrl'),
footerText: formData.get('footerText'), footerText: formData.get('footerText'),
homeBlogShow: formData.get('homeBlogShow') === 'on',
homeBlogTitle: formData.get('homeBlogTitle'),
homeBlogSubtitle: formData.get('homeBlogSubtitle'),
homeBlogCount: parseInt(formData.get('homeBlogCount') as string || '3'),
homeBlogSelection: formData.get('homeBlogSelection'),
homeBlogIds: selectedPostIds,
homeBlogCategories: selectedBlogCategories,
homeCategories: selectedHomeCategories,
}; };
startTransition(async () => { startTransition(async () => {
@@ -45,6 +92,24 @@ export default function SettingsPage() {
}); });
}; };
const togglePostId = (id: string) => {
setSelectedPostIds(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
};
const toggleHomeCategory = (cat: string) => {
setSelectedHomeCategories(prev =>
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
);
};
const toggleBlogCategory = (cat: string) => {
setSelectedBlogCategories(prev =>
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
);
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center p-20"> <div className="flex items-center justify-center p-20">
@@ -97,6 +162,149 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{/* Section Page d'Accueil - Secteurs */}
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
<Briefcase className="w-5 h-5 text-yellow-400" />
<h2 className="font-semibold text-white">Secteurs en Vedette (Home)</h2>
</div>
<div className="p-6">
<p className="text-sm text-slate-400 mb-4">Sélectionnez les secteurs d'activité à afficher sur la page d'accueil.</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{BUSINESS_CATEGORIES.map(cat => (
<button
key={cat}
type="button"
onClick={() => toggleHomeCategory(cat)}
className={`text-left p-2 rounded-lg border text-xs transition-all ${
selectedHomeCategories.includes(cat)
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
}`}
>
{cat}
</button>
))}
</div>
</div>
</div>
{/* Section Page d'Accueil - Blog */}
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
<Newspaper className="w-5 h-5 text-purple-400" />
<h2 className="font-semibold text-white">Section Blog sur l'Accueil</h2>
</div>
<div className="p-6 space-y-6">
<div className="flex items-center gap-3 p-3 bg-indigo-600/10 border border-indigo-500/20 rounded-xl">
<input
type="checkbox"
name="homeBlogShow"
id="homeBlogShow"
defaultChecked={settings?.homeBlogShow}
className="w-5 h-5 rounded border-slate-700 bg-slate-950 text-indigo-600 focus:ring-indigo-500"
/>
<label htmlFor="homeBlogShow" className="text-sm font-medium text-slate-300 font-bold uppercase tracking-wider">Activer cette section</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Titre de la section</label>
<input
name="homeBlogTitle"
defaultValue={settings?.homeBlogTitle}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Sous-titre</label>
<input
name="homeBlogSubtitle"
defaultValue={settings?.homeBlogSubtitle}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
<div className="border-t border-slate-700 pt-6">
<label className="text-sm font-medium text-slate-400 block mb-3">Mode de sélection des articles</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<label className={`flex flex-col p-4 rounded-xl border cursor-pointer transition-all ${settings?.homeBlogSelection === 'latest' ? 'bg-indigo-600/10 border-indigo-500' : 'bg-slate-950 border-slate-700 hover:border-slate-500'}`}>
<input type="radio" name="homeBlogSelection" value="latest" defaultChecked={settings?.homeBlogSelection === 'latest'} className="sr-only" />
<span className="text-white font-bold text-sm">Les plus récents</span>
<span className="text-xs text-slate-500 mt-1">Affiche automatiquement les X derniers articles.</span>
</label>
<label className={`flex flex-col p-4 rounded-xl border cursor-pointer transition-all ${settings?.homeBlogSelection === 'manual' ? 'bg-indigo-600/10 border-indigo-500' : 'bg-slate-950 border-slate-700 hover:border-slate-500'}`}>
<input type="radio" name="homeBlogSelection" value="manual" defaultChecked={settings?.homeBlogSelection === 'manual'} className="sr-only" />
<span className="text-white font-bold text-sm">Sélection manuelle</span>
<span className="text-xs text-slate-500 mt-1">Choisissez précisément les articles à mettre en avant.</span>
</label>
<label className={`flex flex-col p-4 rounded-xl border cursor-pointer transition-all ${settings?.homeBlogSelection === 'category' ? 'bg-indigo-600/10 border-indigo-500' : 'bg-slate-950 border-slate-700 hover:border-slate-500'}`}>
<input type="radio" name="homeBlogSelection" value="category" defaultChecked={settings?.homeBlogSelection === 'category'} className="sr-only" />
<span className="text-white font-bold text-sm">Par catégorie</span>
<span className="text-xs text-slate-500 mt-1">Affiche les articles appartenant à certaines catégories.</span>
</label>
</div>
</div>
<div className="grid grid-cols-1 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nombre maximum d'articles</label>
<input
type="number"
name="homeBlogCount"
defaultValue={settings?.homeBlogCount}
min="1" max="12"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
{/* Manual Selection UI */}
<div className="space-y-3">
<label className="text-sm font-medium text-slate-400">Articles sélectionnés (si mode manuel)</label>
<div className="grid grid-cols-1 gap-2 max-h-60 overflow-y-auto p-2 bg-slate-950 rounded-xl border border-slate-700">
{allPosts.map(post => (
<button
key={post.id}
type="button"
onClick={() => togglePostId(post.id)}
className={`flex items-center justify-between p-3 rounded-lg text-sm transition-all ${
selectedPostIds.includes(post.id)
? 'bg-indigo-600/20 border-indigo-500/50 text-white'
: 'text-slate-400 hover:bg-slate-900'
}`}
>
<span>{post.title}</span>
{selectedPostIds.includes(post.id) && <span className="text-indigo-400 font-bold text-xs">SÉLECTIONNÉ</span>}
</button>
))}
</div>
</div>
{/* Category Selection UI */}
<div className="space-y-3">
<label className="text-sm font-medium text-slate-400">Catégories d'articles (si mode catégorie)</label>
<div className="flex flex-wrap gap-2">
{BLOG_CATEGORIES.map(cat => (
<button
key={cat}
type="button"
onClick={() => toggleBlogCategory(cat)}
className={`px-4 py-2 rounded-full border text-xs transition-all ${
selectedBlogCategories.includes(cat)
? 'bg-purple-600 border-purple-500 text-white'
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
}`}
>
{cat}
</button>
))}
</div>
</div>
</div>
</div>
</div>
{/* Section Contact */} {/* Section Contact */}
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden"> <div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2"> <div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">

View File

@@ -16,7 +16,9 @@ export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalPr
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: plan.name, name: plan.name,
priceXOF: plan.priceXOF, priceXOF: plan.priceXOF,
yearlyPriceXOF: plan.yearlyPriceXOF || '',
priceEUR: plan.priceEUR, priceEUR: plan.priceEUR,
yearlyPriceEUR: plan.yearlyPriceEUR || '',
description: plan.description, description: plan.description,
offerLimit: plan.offerLimit.toString(), offerLimit: plan.offerLimit.toString(),
recommended: plan.recommended, recommended: plan.recommended,
@@ -100,7 +102,7 @@ export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalPr
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix XOF (Texte)</label> <label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix XOF (Mensuel)</label>
<input <input
value={formData.priceXOF} value={formData.priceXOF}
onChange={(e) => setFormData({...formData, priceXOF: e.target.value})} onChange={(e) => setFormData({...formData, priceXOF: e.target.value})}
@@ -108,13 +110,31 @@ export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalPr
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix EUR (Texte)</label> <label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix XOF (Annuel)</label>
<input
value={formData.yearlyPriceXOF}
onChange={(e) => setFormData({...formData, yearlyPriceXOF: e.target.value})}
placeholder="ex: 45 000 FCFA"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix EUR (Mensuel)</label>
<input <input
value={formData.priceEUR} value={formData.priceEUR}
onChange={(e) => setFormData({...formData, priceEUR: e.target.value})} onChange={(e) => setFormData({...formData, priceEUR: e.target.value})}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
/> />
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix EUR (Annuel)</label>
<input
value={formData.yearlyPriceEUR}
onChange={(e) => setFormData({...formData, yearlyPriceEUR: e.target.value})}
placeholder="ex: 69 €"
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -14,7 +14,8 @@ import {
CreditCard, CreditCard,
Settings, Settings,
Globe, Globe,
FileText FileText,
Briefcase
} from 'lucide-react'; } from 'lucide-react';
const menuItems = [ const menuItems = [
@@ -26,6 +27,7 @@ const menuItems = [
{ name: 'Modération', icon: Flag, href: '/moderation' }, { name: 'Modération', icon: Flag, href: '/moderation' },
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' }, { name: 'Blog & Interviews', icon: BookOpen, href: '/blog' },
{ name: 'Tarifs & Plans', icon: CreditCard, href: '/plans' }, { name: 'Tarifs & Plans', icon: CreditCard, href: '/plans' },
{ name: 'Secteurs & Catégories', icon: Briefcase, href: '/categories' },
{ name: 'Pays', icon: Globe, href: '/countries' }, { name: 'Pays', icon: Globe, href: '/countries' },
{ name: 'Documents Légaux', icon: FileText, href: '/legal' }, { name: 'Documents Légaux', icon: FileText, href: '/legal' },
{ name: 'Configuration', icon: Settings, href: '/settings' }, { name: 'Configuration', icon: Settings, href: '/settings' },

View File

@@ -119,8 +119,11 @@ export default async function AfroLifePage({ searchParams }: Props) {
<EventTimeline events={items} /> <EventTimeline events={items} />
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{items.map(item => ( {items.map(item => {
<Link href={`/afrolife/${(item as any).slug || item.id}`} key={item.id} className="group block h-full"> const isPastEvent = item.type === 'EVENT' && new Date(item.date) < new Date();
return (
<Link href={`/afrolife/${(item as any).slug || item.id}`} key={item.id} className={`group block h-full transition-all ${isPastEvent ? 'opacity-60 grayscale-[0.5]' : ''}`}>
<div className="relative h-64 rounded-xl overflow-hidden shadow-sm group-hover:shadow-xl transition-all duration-300"> <div className="relative h-64 rounded-xl overflow-hidden shadow-sm group-hover:shadow-xl transition-all duration-300">
<img <img
src={item.thumbnailUrl} src={item.thumbnailUrl}
@@ -132,13 +135,14 @@ export default async function AfroLifePage({ searchParams }: Props) {
{/* Type Badge */} {/* Type Badge */}
<div className="absolute top-4 left-4"> <div className="absolute top-4 left-4">
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide text-white backdrop-blur-md ${ <span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide text-white backdrop-blur-md ${
isPastEvent ? 'bg-gray-600/90' :
item.type === 'VIDEO' ? 'bg-red-600/90' : item.type === 'VIDEO' ? 'bg-red-600/90' :
item.type === 'EVENT' ? 'bg-amber-600/90' : 'bg-blue-600/90' item.type === 'EVENT' ? 'bg-amber-600/90' : 'bg-blue-600/90'
}`}> }`}>
{item.type === 'VIDEO' ? <Play className="w-3 h-3 mr-1 fill-current" /> : {item.type === 'VIDEO' ? <Play className="w-3 h-3 mr-1 fill-current" /> :
item.type === 'EVENT' ? <Calendar className="w-3 h-3 mr-1" /> : <Mic className="w-3 h-3 mr-1" />} item.type === 'EVENT' ? <Calendar className="w-3 h-3 mr-1" /> : <Mic className="w-3 h-3 mr-1" />}
{item.type === 'VIDEO' ? 'Vidéo' : {item.type === 'VIDEO' ? 'Vidéo' :
item.type === 'EVENT' ? 'Événement' : 'Interview'} item.type === 'EVENT' ? (isPastEvent ? 'Événement Passé' : 'Événement') : 'Interview'}
</span> </span>
</div> </div>
@@ -154,19 +158,20 @@ export default async function AfroLifePage({ searchParams }: Props) {
<div className="pt-6 px-2"> <div className="pt-6 px-2">
<div className="flex items-center text-xs text-gray-500 mb-3 space-x-3"> <div className="flex items-center text-xs text-gray-500 mb-3 space-x-3">
<span className="font-semibold text-brand-600 uppercase">{item.guestName}</span> <span className={`font-semibold uppercase ${isPastEvent ? 'text-gray-400' : 'text-brand-600'}`}>{item.guestName}</span>
<span className="w-1 h-1 bg-gray-300 rounded-full"></span> <span className="w-1 h-1 bg-gray-300 rounded-full"></span>
<span className="truncate max-w-[100px]">{item.companyName}</span> <span className="truncate max-w-[100px]">{item.companyName}</span>
<span className="w-1 h-1 bg-gray-300 rounded-full"></span> <span className="w-1 h-1 bg-gray-300 rounded-full"></span>
<span className="flex items-center flex-nowrap"><Clock className="w-3 h-3 mr-1 shrink-0"/> {item.duration || 'N/A'}</span> <span className="flex items-center flex-nowrap"><Clock className="w-3 h-3 mr-1 shrink-0"/> {item.duration || 'N/A'}</span>
</div> </div>
<h3 className="text-xl font-serif font-bold text-gray-900 mb-2 group-hover:text-brand-600 transition-colors leading-tight line-clamp-2"> <h3 className={`text-xl font-serif font-bold mb-2 transition-colors leading-tight line-clamp-2 ${isPastEvent ? 'text-gray-500' : 'text-gray-900 group-hover:text-brand-600'}`}>
{item.title} {item.title}
</h3> </h3>
<div className="text-gray-600 text-sm line-clamp-2 prose-sm" dangerouslySetInnerHTML={{ __html: item.excerpt }} /> <div className="text-gray-600 text-sm line-clamp-2 prose-sm" dangerouslySetInnerHTML={{ __html: item.excerpt }} />
</div> </div>
</Link> </Link>
))} );
})}
</div> </div>
)} )}
</div> </div>

View File

@@ -4,7 +4,7 @@
import React, { useState, useEffect, useMemo, Suspense } from 'react'; import React, { useState, useEffect, useMemo, Suspense } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { Search, Loader2 } from 'lucide-react'; import { Search, Loader2 } from 'lucide-react';
import { CATEGORIES, Business, Country } from '../../types'; import { Business, Country } from '../../types';
import BusinessCard from '../../components/BusinessCard'; import BusinessCard from '../../components/BusinessCard';
import AnnuaireHero from '../../components/AnnuaireHero'; import AnnuaireHero from '../../components/AnnuaireHero';
@@ -12,22 +12,28 @@ const AnnuairePageContent = () => {
const [businesses, setBusinesses] = useState<Business[]>([]); const [businesses, setBusinesses] = useState<Business[]>([]);
const [featuredBusiness, setFeaturedBusiness] = useState<Business | null>(null); const [featuredBusiness, setFeaturedBusiness] = useState<Business | null>(null);
const [countries, setCountries] = useState<Country[]>([]); const [countries, setCountries] = useState<Country[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [filterCategory, setFilterCategory] = useState('All'); const [filterCategory, setFilterCategory] = useState('All');
const [filterCountry, setFilterCountry] = useState('All'); const [filterCountry, setFilterCountry] = useState('All');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const searchParams = useSearchParams(); const searchParams = useSearchParams();
// 1. Fetch Countries // 1. Fetch Metadata (Countries & Categories)
useEffect(() => { useEffect(() => {
const fetchCountries = async () => { const fetchMetadata = async () => {
try { try {
const res = await fetch('/api/countries'); const [cRes, catRes] = await Promise.all([
const data = await res.json(); fetch('/api/countries'),
if (Array.isArray(data)) setCountries(data); fetch('/api/categories')
]);
const cData = await cRes.json();
const catData = await catRes.json();
if (Array.isArray(cData)) setCountries(cData);
if (Array.isArray(catData)) setCategories(catData);
} catch (e) {} } catch (e) {}
}; };
fetchCountries(); fetchMetadata();
}, []); }, []);
// 2. Fetch Featured Headliner once // 2. Fetch Featured Headliner once
@@ -139,17 +145,17 @@ const AnnuairePageContent = () => {
/> />
<label htmlFor="cat-all" className="ml-3 text-sm text-gray-600">Toutes</label> <label htmlFor="cat-all" className="ml-3 text-sm text-gray-600">Toutes</label>
</div> </div>
{CATEGORIES.map(cat => ( {categories.map(cat => (
<div key={cat} className="flex items-center"> <div key={cat.id} className="flex items-center">
<input <input
id={`cat-${cat}`} id={`cat-${cat.id}`}
name="category" name="category"
type="radio" type="radio"
checked={filterCategory === cat} checked={filterCategory === cat.id || filterCategory === cat.name}
onChange={() => setFilterCategory(cat)} onChange={() => setFilterCategory(cat.id)}
className="focus:ring-brand-500 h-4 w-4 text-brand-600 border-gray-300" className="focus:ring-brand-500 h-4 w-4 text-brand-600 border-gray-300"
/> />
<label htmlFor={`cat-${cat}`} className="ml-3 text-sm text-gray-600 truncate" title={cat}>{cat}</label> <label htmlFor={`cat-${cat.id}`} className="ml-3 text-sm text-gray-600 truncate" title={cat.name}>{cat.name}</label>
</div> </div>
))} ))}
</div> </div>

View File

@@ -18,7 +18,17 @@ export async function GET(request: NextRequest) {
isSuspended: false isSuspended: false
} }
} }
if (category && category !== 'All') where.category = category if (category && category !== 'All') {
// Robust check: IDs are usually alphanumeric and long, without spaces or special chars
// Names like "Agriculture & Agrobusiness" have spaces or special chars.
const isId = /^[a-z0-9-]+$/.test(category) && category.length > 20;
if (isId) {
where.categoryId = category
} else {
where.category = category
}
}
if (featured === 'true') where.isFeatured = true if (featured === 'true') where.isFeatured = true
if (countryId) where.countryId = countryId if (countryId) where.countryId = countryId
if (q) { if (q) {
@@ -61,6 +71,8 @@ export async function POST(request: NextRequest) {
name: data.name || "", name: data.name || "",
slug: data.slug || null, slug: data.slug || null,
category: data.category || "Autre", category: data.category || "Autre",
categoryId: (data.categoryId && data.categoryId !== 'Autre') ? data.categoryId : null,
suggestedCategory: data.suggestedCategory || null,
location: data.location || "", location: data.location || "",
countryId: data.countryId || null, countryId: data.countryId || null,
city: data.city || "", city: data.city || "",

View File

@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
const categories = await prisma.businessCategory.findMany({
where: { isActive: true },
orderBy: { name: 'asc' }
});
return NextResponse.json(categories);
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch categories' }, { status: 500 });
}
}

View File

@@ -3,15 +3,20 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Search, MapPin, Briefcase, TrendingUp, Loader2 } from 'lucide-react'; import { Search, MapPin, Briefcase, TrendingUp, Loader2, ArrowRight } from 'lucide-react';
import { CATEGORIES, Business } from '../types'; import { Business, BlogPost } from '../types';
import BusinessCard from '../components/BusinessCard'; import BusinessCard from '../components/BusinessCard';
import { useUser } from '../components/UserProvider';
const HomePage = () => { const HomePage = () => {
const router = useRouter(); const router = useRouter();
const { settings } = useUser();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>([]); const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>([]);
const [posts, setPosts] = useState<BlogPost[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingPosts, setLoadingPosts] = useState(true);
useEffect(() => { useEffect(() => {
const fetchFeatured = async () => { const fetchFeatured = async () => {
@@ -28,6 +33,29 @@ const HomePage = () => {
} }
}; };
fetchFeatured(); fetchFeatured();
const fetchPosts = async () => {
try {
const res = await fetch('/api/blog');
const data = await res.json();
if (Array.isArray(data)) {
setPosts(data);
}
} catch (error) {
console.error('Error fetching blog posts:', error);
} finally {
setLoadingPosts(false);
}
};
const fetchCategories = async () => {
try {
const res = await fetch('/api/categories');
const data = await res.json();
if (Array.isArray(data)) setCategories(data);
} catch (e) {}
};
fetchPosts();
fetchCategories();
}, []); }, []);
const handleSearch = (e: React.FormEvent) => { const handleSearch = (e: React.FormEvent) => {
@@ -77,7 +105,7 @@ const HomePage = () => {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16"> <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-8 font-serif">Secteurs en vedette</h2> <h2 className="text-3xl font-bold text-gray-900 mb-8 font-serif">Secteurs en vedette</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{CATEGORIES.slice(0, 4).map((cat, idx) => ( {(settings?.homeCategories || categories.slice(0, 4).map(c => c.name)).map((cat: string, idx: number) => (
<Link <Link
key={idx} key={idx}
href={`/annuaire?category=${encodeURIComponent(cat)}`} href={`/annuaire?category=${encodeURIComponent(cat)}`}
@@ -118,6 +146,58 @@ const HomePage = () => {
)} )}
</div> </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 || 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" />
</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> </div>
); );
}; };

View File

@@ -32,15 +32,16 @@ const EventTimeline = ({ events }: EventTimelineProps) => {
{sortedEvents.map((event, index) => { {sortedEvents.map((event, index) => {
const eventDate = new Date(event.date); const eventDate = new Date(event.date);
const isEven = index % 2 === 0; const isEven = index % 2 === 0;
const isPast = eventDate < new Date();
return ( return (
<div key={event.id} className={`relative flex flex-col md:flex-row items-center ${isEven ? 'md:flex-row-reverse' : ''}`}> <div key={event.id} className={`relative flex flex-col md:flex-row items-center ${isEven ? 'md:flex-row-reverse' : ''} ${isPast ? 'opacity-60' : ''}`}>
{/* Marker */} {/* Marker */}
<div className="absolute left-4 md:left-1/2 w-4 h-4 bg-white border-4 border-brand-600 rounded-full transform md:-translate-x-1/2 z-10 shadow-[0_0_15px_rgba(234,88,12,0.5)]"></div> <div className={`absolute left-4 md:left-1/2 w-4 h-4 bg-white border-4 rounded-full transform md:-translate-x-1/2 z-10 ${isPast ? 'border-gray-400 shadow-none' : 'border-brand-600 shadow-[0_0_15px_rgba(234,88,12,0.5)]'}`}></div>
{/* Content Card */} {/* Content Card */}
<div className={`w-full md:w-[45%] ml-12 md:ml-0 group`}> <div className={`w-full md:w-[45%] ml-12 md:ml-0 group`}>
<div className="relative bg-white/80 backdrop-blur-xl p-6 rounded-3xl border border-white shadow-[0_8px_30px_rgb(0,0,0,0.04)] hover:shadow-[0_8px_30px_rgb(0,0,0,0.12)] transition-all duration-500 group-hover:-translate-y-1"> <div className={`relative bg-white/80 backdrop-blur-xl p-6 rounded-3xl border border-white shadow-[0_8px_30px_rgb(0,0,0,0.04)] hover:shadow-[0_8px_30px_rgb(0,0,0,0.12)] transition-all duration-500 group-hover:-translate-y-1 ${isPast ? 'grayscale-[0.8]' : ''}`}>
{/* Image with overlay gradient */} {/* Image with overlay gradient */}
<div className="relative h-48 mb-6 rounded-2xl overflow-hidden"> <div className="relative h-48 mb-6 rounded-2xl overflow-hidden">
<img <img
@@ -50,7 +51,7 @@ const EventTimeline = ({ events }: EventTimelineProps) => {
/> />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div> <div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
<div className="absolute bottom-4 left-4 right-4 flex justify-between items-end"> <div className="absolute bottom-4 left-4 right-4 flex justify-between items-end">
<span className="bg-brand-600/90 backdrop-blur-md text-white px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider flex items-center"> <span className={`${isPast ? 'bg-gray-600/90' : 'bg-brand-600/90'} backdrop-blur-md text-white px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider flex items-center`}>
<Calendar className="w-3 h-3 mr-1" /> <Calendar className="w-3 h-3 mr-1" />
{eventDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })} {eventDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}
</span> </span>
@@ -59,11 +60,12 @@ const EventTimeline = ({ events }: EventTimelineProps) => {
{/* Event Details */} {/* Event Details */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center text-brand-600 text-xs font-bold uppercase tracking-widest"> <div className={`flex items-center text-xs font-bold uppercase tracking-widest ${isPast ? 'text-gray-500' : 'text-brand-600'}`}>
<Clock className="w-3 h-3 mr-1" /> <Clock className="w-3 h-3 mr-1" />
{eventDate.toLocaleDateString('fr-FR', { year: 'numeric' })} {eventDate.toLocaleDateString('fr-FR', { year: 'numeric' })}
{isPast && <span className="ml-2 px-2 py-0.5 bg-gray-100 text-gray-500 rounded text-[10px]">PASSÉ</span>}
</div> </div>
<h3 className="text-2xl font-serif font-bold text-gray-900 group-hover:text-brand-600 transition-colors leading-tight"> <h3 className={`text-2xl font-serif font-bold transition-colors leading-tight ${isPast ? 'text-gray-500' : 'text-gray-900 group-hover:text-brand-600'}`}>
{event.title} {event.title}
</h3> </h3>
<div className="flex items-center text-sm text-gray-500"> <div className="flex items-center text-sm text-gray-500">
@@ -78,7 +80,7 @@ const EventTimeline = ({ events }: EventTimelineProps) => {
<div className="pt-4 flex items-center justify-between"> <div className="pt-4 flex items-center justify-between">
<Link <Link
href={`/afrolife/${event.slug || event.id}`} href={`/afrolife/${event.slug || event.id}`}
className="inline-flex items-center text-brand-600 text-sm font-bold hover:underline group/link" className={`inline-flex items-center text-sm font-bold hover:underline group/link ${isPast ? 'text-gray-400' : 'text-brand-600'}`}
> >
Voir les détails Voir les détails
<ArrowRight className="w-4 h-4 ml-1 transform group-hover/link:translate-x-1 transition-transform" /> <ArrowRight className="w-4 h-4 ml-1 transform group-hover/link:translate-x-1 transition-transform" />
@@ -89,14 +91,16 @@ const EventTimeline = ({ events }: EventTimelineProps) => {
</div> </div>
{/* Hover glow effect */} {/* Hover glow effect */}
{!isPast && (
<div className="absolute -inset-px bg-gradient-to-br from-brand-500/10 to-amber-500/10 rounded-3xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div> <div className="absolute -inset-px bg-gradient-to-br from-brand-500/10 to-amber-500/10 rounded-3xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
)}
</div> </div>
</div> </div>
{/* Date Column (Desktop Only) */} {/* Date Column (Desktop Only) */}
<div className="hidden md:flex w-[45%] justify-center items-center px-8"> <div className="hidden md:flex w-[45%] justify-center items-center px-8">
<div className={`text-center ${isEven ? 'text-right' : 'text-left'}`}> <div className={`text-center ${isEven ? 'text-right' : 'text-left'}`}>
<span className="block text-4xl font-serif font-bold text-gray-200 group-hover:text-brand-500/20 transition-colors"> <span className={`block text-4xl font-serif font-bold transition-colors ${isPast ? 'text-gray-100' : 'text-gray-200 group-hover:text-brand-500/20'}`}>
{eventDate.toLocaleDateString('fr-FR', { month: 'long' })} {eventDate.toLocaleDateString('fr-FR', { month: 'long' })}
</span> </span>
<span className="block text-xl font-bold text-gray-400"> <span className="block text-xl font-bold text-gray-400">

View File

@@ -9,7 +9,9 @@ interface Plan {
tier: string; tier: string;
name: string; name: string;
priceXOF: string; priceXOF: string;
yearlyPriceXOF?: string;
priceEUR: string; priceEUR: string;
yearlyPriceEUR?: string;
description: string; description: string;
features: string[]; features: string[];
recommended: boolean; recommended: boolean;
@@ -130,12 +132,24 @@ const PricingSection = () => {
<p className="mt-4 text-sm text-gray-500">{plan.description}</p> <p className="mt-4 text-sm text-gray-500">{plan.description}</p>
<div className="mt-8 flex items-baseline"> <div className="mt-8 flex items-baseline">
<span className="text-4xl font-extrabold text-gray-900 tracking-tight"> <span className="text-4xl font-extrabold text-gray-900 tracking-tight">
{plan.priceXOF === 'Gratuit' ? 'Gratuit' : (billingCycle === 'yearly' && plan.tier !== 'STARTER' ? 'Sur Devis' : plan.priceXOF)} {plan.priceXOF === 'Gratuit'
? 'Gratuit'
: (billingCycle === 'yearly'
? (plan.yearlyPriceXOF || plan.priceXOF)
: plan.priceXOF
)
}
</span> </span>
{plan.priceXOF !== 'Gratuit' && <span className="ml-1 text-xl font-medium text-gray-500">/mois</span>} {plan.priceXOF !== 'Gratuit' && (
<span className="ml-1 text-xl font-medium text-gray-500">
{billingCycle === 'yearly' ? '/an' : '/mois'}
</span>
)}
</div> </div>
{plan.priceXOF !== 'Gratuit' && ( {plan.priceXOF !== 'Gratuit' && (
<p className="text-xs text-gray-400 mt-1">soit env. {plan.priceEUR} /mois</p> <p className="text-xs text-gray-400 mt-1">
soit env. {billingCycle === 'yearly' ? (plan.yearlyPriceEUR || plan.priceEUR) : plan.priceEUR} {billingCycle === 'yearly' ? '/an' : '/mois'}
</p>
)} )}
<ul className="mt-8 space-y-4"> <ul className="mt-8 space-y-4">
@@ -193,8 +207,10 @@ const PricingSection = () => {
{paymentStep === 'method' && ( {paymentStep === 'method' && (
<> <>
<div className="bg-brand-50 p-4 rounded-md mb-6 border border-brand-100"> <div className="bg-brand-50 p-4 rounded-md mb-6 border border-brand-100">
<p className="text-sm text-brand-800 font-medium">Vous avez choisi le plan <span className="font-bold">{selectedPlan.name}</span></p> <p className="text-sm text-brand-800 font-medium">Vous avez choisi le plan <span className="font-bold">{selectedPlan.name}</span> ({billingCycle === 'yearly' ? 'Annuel' : 'Mensuel'})</p>
<p className="text-2xl font-bold text-brand-900 mt-1">{selectedPlan.priceXOF}</p> <p className="text-2xl font-bold text-brand-900 mt-1">
{billingCycle === 'yearly' ? (selectedPlan.yearlyPriceXOF || selectedPlan.priceXOF) : selectedPlan.priceXOF}
</p>
</div> </div>
<p className="text-sm font-medium text-gray-700 mb-3">Moyen de paiement</p> <p className="text-sm font-medium text-gray-700 mb-3">Moyen de paiement</p>
@@ -292,7 +308,7 @@ const PricingSection = () => {
onClick={handlePayment} onClick={handlePayment}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-brand-600 text-base font-medium text-white hover:bg-brand-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed sm:ml-3 sm:w-auto sm:text-sm" className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-brand-600 text-base font-medium text-white hover:bg-brand-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed sm:ml-3 sm:w-auto sm:text-sm"
> >
Payer {selectedPlan.priceXOF} Payer {billingCycle === 'yearly' ? (selectedPlan.yearlyPriceXOF || selectedPlan.priceXOF) : selectedPlan.priceXOF}
</button> </button>
<button <button
type="button" type="button"

View File

@@ -3,7 +3,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram, CheckCircle, AlertCircle } from 'lucide-react'; import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram, CheckCircle, AlertCircle } from 'lucide-react';
import { Business, CATEGORIES, Country } from '../../types'; import { Business, Country } from '../../types';
import { generateBusinessDescription } from '../../lib/geminiService'; import { generateBusinessDescription } from '../../lib/geminiService';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { useUser } from '../UserProvider'; import { useUser } from '../UserProvider';
@@ -18,7 +18,9 @@ const getYouTubeId = (url: string) => {
const normalizeBusinessData = (b: Business): Business => ({ const normalizeBusinessData = (b: Business): Business => ({
...b, ...b,
name: b.name || 'Nouvelle Entreprise', name: b.name || 'Nouvelle Entreprise',
category: b.category || CATEGORIES[0], category: b.category || 'Autre',
categoryId: b.categoryId || '',
suggestedCategory: b.suggestedCategory || '',
description: b.description || '', description: b.description || '',
location: b.location || '', location: b.location || '',
countryId: b.countryId || '', countryId: b.countryId || '',
@@ -46,23 +48,29 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
const { login } = useUser(); const { login } = useUser();
const [formData, setFormData] = useState<Business>(normalizeBusinessData(business)); const [formData, setFormData] = useState<Business>(normalizeBusinessData(business));
const [countries, setCountries] = useState<Country[]>([]); const [countries, setCountries] = useState<Country[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [videoInput, setVideoInput] = useState(business.videoUrl || ''); const [videoInput, setVideoInput] = useState(business.videoUrl || '');
const [videoPreviewId, setVideoPreviewId] = useState<string | null>(getYouTubeId(business.videoUrl || '')); const [videoPreviewId, setVideoPreviewId] = useState<string | null>(getYouTubeId(business.videoUrl || ''));
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
// Fetch countries // Fetch countries and categories
React.useEffect(() => { React.useEffect(() => {
const fetchCountries = async () => { const fetchData = async () => {
try { try {
const res = await fetch('/api/countries'); const [cRes, catRes] = await Promise.all([
const data = await res.json(); fetch('/api/countries'),
if (!data.error) setCountries(data); fetch('/api/categories')
]);
const cData = await cRes.json();
const catData = await catRes.json();
if (!cData.error) setCountries(cData);
if (!catData.error) setCategories(catData);
} catch (error) { } catch (error) {
console.error('Error fetching countries:', error); console.error('Error fetching metadata:', error);
} }
}; };
fetchCountries(); fetchData();
}, []); }, []);
// Sync formData when business prop changes (initial fetch) // Sync formData when business prop changes (initial fetch)
@@ -333,10 +341,37 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
</div> </div>
<div className="sm:col-span-3"> <div className="sm:col-span-3">
<label className="block text-sm font-medium text-gray-700">Secteur d'activité</label> <label className="block text-sm font-medium text-gray-700">Secteur d'activité</label>
<select name="category" value={formData.category} onChange={handleInputChange} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm"> <select
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)} name="categoryId"
value={formData.categoryId || ''}
onChange={(e) => {
const catId = e.target.value;
const catName = categories.find(c => c.id === catId)?.name || 'Autre';
setFormData({ ...formData, categoryId: catId, category: catName });
}}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm"
>
<option value="">Sélectionner un secteur</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
<option value="Autre">Autre (proposer...)</option>
</select> </select>
</div> </div>
{formData.category === 'Autre' && (
<div className="sm:col-span-3 animate-in fade-in slide-in-from-top-2 duration-300">
<label className="block text-sm font-medium text-brand-600 flex items-center gap-1">
<Sparkles className="w-3 h-3" /> Nom de la nouvelle catégorie
</label>
<input
type="text"
name="suggestedCategory"
value={formData.suggestedCategory || ''}
onChange={handleInputChange}
placeholder="ex: Intelligence Artificielle"
className="mt-1 block w-full border border-brand-300 bg-brand-50 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-brand-500">Elle sera vérifiée par nos administrateurs avant d'être ajoutée officiellement.</p>
</div>
)}
<div className="sm:col-span-6"> <div className="sm:col-span-6">
<div className="flex justify-between mb-1"> <div className="flex justify-between mb-1">
<label className="block text-sm font-medium text-gray-700">Description</label> <label className="block text-sm font-medium text-gray-700">Description</label>

View File

@@ -11,7 +11,15 @@ export async function getSiteSettings() {
twitterUrl: "#", twitterUrl: "#",
instagramUrl: "#", instagramUrl: "#",
linkedinUrl: "#", linkedinUrl: "#",
footerText: "© 2025 Afrohub. Tous droits réservés." footerText: "© 2025 Afrohub. Tous droits réservés.",
homeBlogShow: true,
homeBlogTitle: "Derniers Articles",
homeBlogSubtitle: "Toutes les actualités de l'entrepreneuriat africain.",
homeBlogCount: 3,
homeBlogSelection: "latest",
homeBlogIds: [],
homeBlogCategories: [],
homeCategories: ["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"]
}; };
try { try {

90
package-lock.json generated
View File

@@ -10,7 +10,7 @@
"dependencies": { "dependencies": {
"@google/genai": "^1.30.0", "@google/genai": "^1.30.0",
"@prisma/adapter-pg": "^7.6.0", "@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0", "@prisma/client": "^7.7.0",
"@prisma/config": "^7.6.0", "@prisma/config": "^7.6.0",
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
@@ -24,7 +24,7 @@
"next": "^16.1.6", "next": "^16.1.6",
"pg": "^8.19.0", "pg": "^8.19.0",
"postcss": "^8.5.10", "postcss": "^8.5.10",
"prisma": "^7.6.0", "prisma": "^7.7.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
@@ -1231,12 +1231,12 @@
} }
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "7.6.0", "version": "7.7.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.6.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.7.0.tgz",
"integrity": "sha512-7Pe/1ayh3GgWPEg4mmT4ax77LJ1wC+XlnIFvQ94bLP2DsUnOpnruQQR3Jw7r+Frthk94QqDNxo3FjSg8h9PXeQ==", "integrity": "sha512-5Ar4OsZpJ54s21sy5oDNNW9gQtd4NuxCaiM7+JDTOU07D6VvlpLjYzAVCMB1+JzokN+08dAVomlx+b7bhJd3ww==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/client-runtime-utils": "7.6.0" "@prisma/client-runtime-utils": "7.7.0"
}, },
"engines": { "engines": {
"node": "^20.19 || ^22.12 || >=24.0" "node": "^20.19 || ^22.12 || >=24.0"
@@ -1255,9 +1255,9 @@
} }
}, },
"node_modules/@prisma/client-runtime-utils": { "node_modules/@prisma/client-runtime-utils": {
"version": "7.6.0", "version": "7.7.0",
"resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.6.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.7.0.tgz",
"integrity": "sha512-fD7jlqubsZvVODKvsp9lOpXVecx2aWGxC2l35Ioz2t+teUJ5CfR0SAMsi7UkU1VvaZmmm+DS6BdujF622nY7tQ==", "integrity": "sha512-BLyd0UpFYOtyJFTHm7jS9vesHW7P83abibodQMiIofqjBKzDHQ1VAsQkdfvXyYDkPlONPfOTz7/rv3x/+CQqvQ==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/config": { "node_modules/@prisma/config": {
@@ -1313,16 +1313,16 @@
} }
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "7.6.0", "version": "7.7.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.6.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.7.0.tgz",
"integrity": "sha512-Sn5edRzhHqgRV2M+A0eIbY442B4mReWWf3pKs/LKreYgW7oa/up8JtK/s4iv/EQA097cyboZ08mmkpbLp+tZ3w==", "integrity": "sha512-7fmcbT7HHXBq/b+3h/dO1JI3fd8l8q7erf7xP7pRprh58hmSSnG8mg9K3yjW3h9WaHWUwngVFpSxxxivaitQ2w==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "7.6.0", "@prisma/debug": "7.7.0",
"@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", "@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711",
"@prisma/fetch-engine": "7.6.0", "@prisma/fetch-engine": "7.7.0",
"@prisma/get-platform": "7.6.0" "@prisma/get-platform": "7.7.0"
} }
}, },
"node_modules/@prisma/engines-version": { "node_modules/@prisma/engines-version": {
@@ -1331,45 +1331,33 @@
"integrity": "sha512-r51DLcJ8bDRSrBEJF3J4cinoWyGA7rfP2mG6lD90VqIbGNOkbfcLcXalSVjq5Y6brQS3vcjrq4GbyUb1Cb7vkw==", "integrity": "sha512-r51DLcJ8bDRSrBEJF3J4cinoWyGA7rfP2mG6lD90VqIbGNOkbfcLcXalSVjq5Y6brQS3vcjrq4GbyUb1Cb7vkw==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/engines/node_modules/@prisma/debug": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.6.0.tgz",
"integrity": "sha512-LpHr3qos4lQZ6sxwjStf59YBht7m9/QF7NSQsMH6qGENWZu2w3UkQUGn1h5iRkDjnWRj3VHykOu9qFhps4ADvA==",
"license": "Apache-2.0"
},
"node_modules/@prisma/engines/node_modules/@prisma/get-platform": { "node_modules/@prisma/engines/node_modules/@prisma/get-platform": {
"version": "7.6.0", "version": "7.7.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.6.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.7.0.tgz",
"integrity": "sha512-ohZDwXvtmnbzOcutR2D13lDWpZP1wQjmPyztmt0AwXLzQI7q95EE7NYCvS+M6N6SivT+BM0NOqLmTH3wms4L3A==", "integrity": "sha512-MEUNzvKxvYnJ7kgvd6oNRnMmmiGNS9TYLB2weMeIXplnHdL/UWEGnvavYGnN7KLJ2n0iI4dDAyzSkHI3c7AscQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "7.6.0" "@prisma/debug": "7.7.0"
} }
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "7.6.0", "version": "7.7.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.6.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.7.0.tgz",
"integrity": "sha512-N575Ni95c3FkduWY/eKTHqNYgNbceZ1tQaSknVtJjpKmiiBXmniESn/GTxsDvICC4ZeiNrXxioGInzQrCdx16w==", "integrity": "sha512-TfyzveBQoK4xALzsTpVhB/0KG1N8zOK0ap+RnBMkzGUu3f98fnQ4QtXa2wlKPhsO2X8a3N5ugFQgcKNoHGmDfw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "7.6.0", "@prisma/debug": "7.7.0",
"@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", "@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711",
"@prisma/get-platform": "7.6.0" "@prisma/get-platform": "7.7.0"
} }
}, },
"node_modules/@prisma/fetch-engine/node_modules/@prisma/debug": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.6.0.tgz",
"integrity": "sha512-LpHr3qos4lQZ6sxwjStf59YBht7m9/QF7NSQsMH6qGENWZu2w3UkQUGn1h5iRkDjnWRj3VHykOu9qFhps4ADvA==",
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": {
"version": "7.6.0", "version": "7.7.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.6.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.7.0.tgz",
"integrity": "sha512-ohZDwXvtmnbzOcutR2D13lDWpZP1wQjmPyztmt0AwXLzQI7q95EE7NYCvS+M6N6SivT+BM0NOqLmTH3wms4L3A==", "integrity": "sha512-MEUNzvKxvYnJ7kgvd6oNRnMmmiGNS9TYLB2weMeIXplnHdL/UWEGnvavYGnN7KLJ2n0iI4dDAyzSkHI3c7AscQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "7.6.0" "@prisma/debug": "7.7.0"
} }
}, },
"node_modules/@prisma/get-platform": { "node_modules/@prisma/get-platform": {
@@ -4223,15 +4211,15 @@
} }
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "7.6.0", "version": "7.7.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.6.0.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.7.0.tgz",
"integrity": "sha512-OKJIPT81K3+F+AayIkY/Y3mkF2NWoFh7lZApaaqPYy7EHILKdO0VsmGkP+hDKYTySHsFSyLWXm/JgcR1B8fY1Q==", "integrity": "sha512-HlgwRBt1uEFB9LStHL4HLYDvoi4BNu1rYA0hPG0zCAEyK9SaZBqp7E5Rjpc3Qh8Lex/ye/svoHZ0OWoFNhWxuQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/config": "7.6.0", "@prisma/config": "7.7.0",
"@prisma/dev": "0.24.3", "@prisma/dev": "0.24.3",
"@prisma/engines": "7.6.0", "@prisma/engines": "7.7.0",
"@prisma/studio-core": "0.27.3", "@prisma/studio-core": "0.27.3",
"mysql2": "3.15.3", "mysql2": "3.15.3",
"postgres": "3.4.7" "postgres": "3.4.7"
@@ -4255,18 +4243,6 @@
} }
} }
}, },
"node_modules/prisma/node_modules/@prisma/config": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.6.0.tgz",
"integrity": "sha512-MuAz1MK4PeG5/03YzfzX3CnFVHQ6qePGwUpQRzPzX5tT0ffJ3Tzi9zJZbBc+VzEGFCM8ghW/gTVDR85Syjt+Yw==",
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
"deepmerge-ts": "7.1.5",
"effect": "3.20.0",
"empathic": "2.0.0"
}
},
"node_modules/proper-lockfile": { "node_modules/proper-lockfile": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",

View File

@@ -20,7 +20,7 @@
"dependencies": { "dependencies": {
"@google/genai": "^1.30.0", "@google/genai": "^1.30.0",
"@prisma/adapter-pg": "^7.6.0", "@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0", "@prisma/client": "^7.7.0",
"@prisma/config": "^7.6.0", "@prisma/config": "^7.6.0",
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
@@ -34,7 +34,7 @@
"next": "^16.1.6", "next": "^16.1.6",
"pg": "^8.19.0", "pg": "^8.19.0",
"postcss": "^8.5.10", "postcss": "^8.5.10",
"prisma": "^7.6.0", "prisma": "^7.7.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",

View File

@@ -18,10 +18,10 @@ model User {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
bio String? bio String?
location String? location String?
countryId String?
phone String? phone String?
isSuspended Boolean @default(false) isSuspended Boolean @default(false)
suspensionReason String? suspensionReason String?
countryId String?
businesses Business? businesses Business?
comments Comment[] comments Comment[]
conversations ConversationParticipant[] conversations ConversationParticipant[]
@@ -35,7 +35,10 @@ model Business {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
category String category String
suggestedCategory String?
location String location String
countryId String?
city String?
description String description String
logoUrl String logoUrl String
coverUrl String? coverUrl String?
@@ -66,26 +69,37 @@ model Business {
isSuspended Boolean @default(false) isSuspended Boolean @default(false)
suspensionReason String? suspensionReason String?
plan Plan @default(STARTER) plan Plan @default(STARTER)
city String?
countryId String?
country Country? @relation(fields: [countryId], references: [id])
owner User @relation(fields: [ownerId], references: [id]) owner User @relation(fields: [ownerId], references: [id])
comments Comment[] comments Comment[]
conversations Conversation[] conversations Conversation[]
offers Offer[] offers Offer[]
ratings Rating[] ratings Rating[]
country Country? @relation(fields: [countryId], references: [id])
categoryRef BusinessCategory? @relation(fields: [categoryId], references: [id])
categoryId String?
}
model BusinessCategory {
id String @id @default(uuid())
name String @unique
slug String @unique
icon String? // Lucide icon name
isActive Boolean @default(true)
businesses Business[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model Country { model Country {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique name String @unique
code String @unique code String @unique // ISO alpha-2
flag String? flag String? // Emoji or URL
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
businesses Business[] businesses Business[]
users User[] users User[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model PricingPlan { model PricingPlan {
@@ -93,7 +107,9 @@ model PricingPlan {
tier Plan @unique tier Plan @unique
name String name String
priceXOF String priceXOF String
yearlyPriceXOF String?
priceEUR String priceEUR String
yearlyPriceEUR String?
description String description String
features String[] features String[]
offerLimit Int @default(1) offerLimit Int @default(1)
@@ -172,34 +188,40 @@ model AnalyticsEvent {
label String? label String?
value Float? value Float?
metadata Json? metadata Json?
createdAt DateTime @default(now())
browser String?
city String?
country String?
device String?
ip String? ip String?
country String?
city String?
browser String?
os String? os String?
device String?
referrer String? referrer String?
userId String? userId String?
createdAt DateTime @default(now())
} }
model Rating { model Rating {
id String @id @default(uuid()) id String @id @default(uuid())
value Int value Int
userId String
businessId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
comment String? comment String?
reply String? reply String?
replyAt DateTime? replyAt DateTime?
status RatingStatus @default(PENDING) status RatingStatus @default(PENDING)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) userId String
businessId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
@@unique([userId, businessId]) @@unique([userId, businessId])
} }
enum RatingStatus {
PENDING
APPROVED
REJECTED
}
model Conversation { model Conversation {
id String @id @default(uuid()) id String @id @default(uuid())
businessId String businessId String
@@ -244,33 +266,6 @@ model MessageReport {
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade) reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
} }
model SiteSetting {
id String @id @default("singleton")
siteName String @default("Afrohub")
siteSlogan String @default("La plateforme de référence pour l'entrepreneuriat africain.")
contactEmail String @default("support@afrohub.com")
contactPhone String? @default("+225 00 00 00 00 00")
address String? @default("Abidjan, Côte d'Ivoire")
facebookUrl String?
twitterUrl String?
instagramUrl String?
linkedinUrl String?
footerText String? @default("© 2025 Afrohub. Tous droits réservés.")
updatedAt DateTime @updatedAt
}
enum Plan {
STARTER
BOOSTER
EMPIRE
}
enum RatingStatus {
PENDING
APPROVED
REJECTED
}
enum ReportStatus { enum ReportStatus {
PENDING PENDING
RESOLVED RESOLVED
@@ -283,6 +278,12 @@ enum UserRole {
ADMIN ADMIN
} }
enum Plan {
STARTER
BOOSTER
EMPIRE
}
enum OfferType { enum OfferType {
PRODUCT PRODUCT
SERVICE SERVICE
@@ -292,6 +293,30 @@ enum InterviewType {
VIDEO VIDEO
ARTICLE ARTICLE
} }
model SiteSetting {
id String @id @default("singleton")
siteName String @default("Afrohub")
siteSlogan String @default("La plateforme de référence pour l'entrepreneuriat africain.")
contactEmail String @default("support@afrohub.com")
contactPhone String? @default("+225 00 00 00 00 00")
address String? @default("Abidjan, Côte d'Ivoire")
facebookUrl String?
twitterUrl String?
instagramUrl String?
linkedinUrl String?
footerText String? @default("© 2025 Afrohub. Tous droits réservés.")
homeBlogShow Boolean @default(true)
homeBlogTitle String @default("Derniers Articles")
homeBlogSubtitle String @default("Toutes les actualités de l'entrepreneuriat africain.")
homeBlogCount Int @default(3)
homeBlogSelection String @default("latest")
homeBlogIds String[] @default([])
homeBlogCategories String[] @default([])
homeCategories String[] @default(["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"])
updatedAt DateTime @updatedAt
}
model LegalDocument { model LegalDocument {
id String @id @default(uuid()) id String @id @default(uuid())
type String @unique // 'CGU' or 'CGV' type String @unique // 'CGU' or 'CGV'

52
prisma/seed-categories.ts Normal file
View File

@@ -0,0 +1,52 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import pg from 'pg';
import { config } from 'dotenv';
config();
config({ path: '.env.local', override: true });
const connectionString = process.env.DATABASE_URL!;
const pool = new pg.Pool({ connectionString });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
const CATEGORIES = [
{ name: "Technologie & IT", slug: "technologie-it" },
{ name: "Agriculture & Agrobusiness", slug: "agriculture-agrobusiness" },
{ name: "Mode & Textile", slug: "mode-textile" },
{ name: "Cosmétique & Beauté", slug: "cosmetique-beaute" },
{ name: "Services aux entreprises", slug: "services-entreprises" },
{ name: "Restauration & Alimentation", slug: "restauration-alimentation" },
{ name: "Construction & BTP", slug: "construction-btp" },
{ name: "Éducation & Formation", slug: "education-formation" },
{ name: "Santé & Bien-être", slug: "sante-bien-etre" },
{ name: "Artisanat & Déco", slug: "artisanat-deco" },
{ name: "Tourisme & Loisirs", slug: "tourisme-loisirs" },
{ name: "Finance & Assurance", slug: "finance-assurance" }
];
async function main() {
console.log('Seeding categories...');
for (const cat of CATEGORIES) {
await prisma.businessCategory.upsert({
where: { name: cat.name },
update: {},
create: {
name: cat.name,
slug: cat.slug,
isActive: true
},
});
}
console.log('Categories seeded!');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

126
prisma/seed-events.ts Normal file
View File

@@ -0,0 +1,126 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import pg from 'pg';
import { config } from 'dotenv';
config();
config({ path: '.env.local', override: true });
const connectionString = process.env.DATABASE_URL!;
const pool = new pg.Pool({ connectionString });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
const EVENTS = [
{
title: "Forum de l'Entrepreneuriat Africain 2026",
slug: "forum-entrepreneuriat-africain-2026",
description: "Le plus grand rassemblement d'entrepreneurs africains pour discuter des opportunités de croissance et de financement.",
date: new Date("2026-02-15T09:00:00Z"),
location: "Abidjan, Côte d'Ivoire",
thumbnailUrl: "https://images.unsplash.com/photo-1540575861501-7ad05823c28b?auto=format&fit=crop&q=80&w=1200",
tags: ["Entrepreneuriat", "Networking", "Abidjan"],
},
{
title: "Tech Summit Dakar 2026",
slug: "tech-summit-dakar-2026",
description: "Découvrez les dernières innovations technologiques et rencontrez les startups les plus prometteuses du Sénégal.",
date: new Date("2026-03-10T10:00:00Z"),
location: "Dakar, Sénégal",
thumbnailUrl: "https://images.unsplash.com/photo-1505373877841-8d25f7d46678?auto=format&fit=crop&q=80&w=1200",
tags: ["Tech", "Innovation", "Dakar"],
},
{
title: "Salon de l'Agrobusiness Africain",
slug: "salon-agrobusiness-africain-2026",
description: "Exploration des nouvelles techniques agricoles et des opportunités de transformation locale.",
date: new Date("2026-05-20T08:30:00Z"),
location: "Nairobi, Kenya",
thumbnailUrl: "https://images.unsplash.com/photo-1523348837708-15d4a09cfac2?auto=format&fit=crop&q=80&w=1200",
tags: ["Agriculture", "Agrobusiness", "Nairobi"],
},
{
title: "Conférence sur l'Économie Numérique",
slug: "conference-economie-numerique-2026",
description: "Vers une accélération de la digitalisation des services en Afrique centrale.",
date: new Date("2026-06-15T09:00:00Z"),
location: "Douala, Cameroun",
thumbnailUrl: "https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&q=80&w=1200",
tags: ["Digital", "Économie", "Douala"],
},
{
title: "Afro-Invest 2026",
slug: "afro-invest-2026",
description: "Rencontre entre investisseurs internationaux et porteurs de projets africains.",
date: new Date("2026-08-05T14:00:00Z"),
location: "Lagos, Nigeria",
thumbnailUrl: "https://images.unsplash.com/photo-1559136555-9303baea8ebd?auto=format&fit=crop&q=80&w=1200",
tags: ["Investissement", "Finance", "Lagos"],
},
{
title: "Sommet du Tourisme Durable",
slug: "sommet-tourisme-durable-2026",
description: "Promotion des destinations africaines à travers un tourisme respectueux de l'environnement.",
date: new Date("2026-09-22T09:00:00Z"),
location: "Cape Town, Afrique du Sud",
thumbnailUrl: "https://images.unsplash.com/photo-1469474968028-56623f02e42e?auto=format&fit=crop&q=80&w=1200",
tags: ["Tourisme", "Durable", "Cape Town"],
},
{
title: "Salon de la Mode et du Design Africain",
slug: "salon-mode-design-africain-2026",
description: "Célébration des créateurs locaux et de l'artisanat de luxe.",
date: new Date("2026-10-12T11:00:00Z"),
location: "Accra, Ghana",
thumbnailUrl: "https://images.unsplash.com/photo-1445205170230-053b83016050?auto=format&fit=crop&q=80&w=1200",
tags: ["Mode", "Design", "Accra"],
},
{
title: "Forum de l'Énergie Renouvelable",
slug: "forum-energie-renouvelable-2026",
description: "Solutions énergétiques pour l'Afrique de demain.",
date: new Date("2026-11-08T09:30:00Z"),
location: "Casablanca, Maroc",
thumbnailUrl: "https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?auto=format&fit=crop&q=80&w=1200",
tags: ["Énergie", "Solaire", "Casablanca"],
},
{
title: "Afro-Education Summit",
slug: "afro-education-summit-2026",
description: "L'avenir de l'éducation et de la formation professionnelle en Afrique.",
date: new Date("2026-12-05T09:00:00Z"),
location: "Kigali, Rwanda",
thumbnailUrl: "https://images.unsplash.com/photo-1524178232363-1fb2b075b655?auto=format&fit=crop&q=80&w=1200",
tags: ["Éducation", "Kigali"],
},
{
title: "Gala de fin d'année des Entrepreneurs",
slug: "gala-entrepreneurs-2026",
description: "Soirée de prestige pour célébrer les réussites entrepreneuriales de l'année.",
date: new Date("2026-12-20T20:00:00Z"),
location: "Abidjan, Côte d'Ivoire",
thumbnailUrl: "https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&q=80&w=1200",
tags: ["Gala", "Célébration", "Abidjan"],
},
];
async function main() {
console.log('Seeding events...');
for (const event of EVENTS) {
await prisma.event.upsert({
where: { slug: event.slug },
update: event,
create: event,
});
}
console.log('Events seeded!');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

15
scratch/check-cats.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function check() {
try {
const count = await prisma.businessCategory.count();
console.log('Category count:', count);
const cats = await prisma.businessCategory.findMany({take: 5});
console.log('Sample:', cats);
} catch (e: any) {
console.error('Error:', e.message);
} finally {
await prisma.$disconnect();
}
}
check();

View File

@@ -0,0 +1,38 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import pg from 'pg';
import { config } from 'dotenv';
config();
config({ path: '.env.local', override: true });
const connectionString = process.env.DATABASE_URL!;
const pool = new pg.Pool({ connectionString });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
async function migrate() {
console.log('Starting migration...');
const categories = await prisma.businessCategory.findMany();
const categoryMap = new Map(categories.map(c => [c.name, c.id]));
const businesses = await prisma.business.findMany();
let updatedCount = 0;
for (const biz of businesses) {
const catId = categoryMap.get(biz.category);
if (catId) {
await prisma.business.update({
where: { id: biz.id },
data: { categoryId: catId }
});
updatedCount++;
}
}
console.log(`Migration finished. Updated ${updatedCount} businesses.`);
}
migrate()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@@ -41,6 +41,8 @@ export interface Business {
name: string; name: string;
slug?: string; slug?: string;
category: string; category: string;
categoryId?: string;
suggestedCategory?: string;
location: string; // Legacy: City, Country location: string; // Legacy: City, Country
countryId?: string; countryId?: string;
country?: Country; country?: Country;
@@ -133,13 +135,3 @@ export interface Rating {
createdAt: string; createdAt: string;
} }
export const CATEGORIES = [
"Technologie & IT",
"Agriculture & Agrobusiness",
"Mode & Textile",
"Cosmétique & Beauté",
"Services aux entreprises",
"Restauration & Alimentation",
"Construction & BTP",
"Éducation & Formation"
];