feat: implement home page, business directory, and administrative management features with updated schema and API routes
Some checks failed
Build and Push App / build (push) Failing after 51s

This commit is contained in:
2026-04-23 14:40:50 +02:00
parent 88e4c13b9f
commit e6310f30de
23 changed files with 1124 additions and 222 deletions

View File

@@ -35,6 +35,7 @@ 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?
@@ -74,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 {
@@ -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)

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

@@ -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,16 +74,27 @@ 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">
<div className="text-xs text-slate-500 uppercase font-bold tracking-tighter">Limite : {plan.offerLimit} Offre(s)</div> {billingCycle === 'yearly'
</div> ? (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>
<div className="space-y-3 mb-8"> <div className="space-y-3 mb-8">
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2">Avantages inclus :</div> <div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2">Avantages inclus :</div>

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,54 +119,59 @@ 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();
<div className="relative h-64 rounded-xl overflow-hidden shadow-sm group-hover:shadow-xl transition-all duration-300">
<img
src={item.thumbnailUrl}
alt={item.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
/>
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div>
{/* Type Badge */} return (
<div className="absolute top-4 left-4"> <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]' : ''}`}>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide text-white backdrop-blur-md ${ <div className="relative h-64 rounded-xl overflow-hidden shadow-sm group-hover:shadow-xl transition-all duration-300">
item.type === 'VIDEO' ? 'bg-red-600/90' : <img
item.type === 'EVENT' ? 'bg-amber-600/90' : 'bg-blue-600/90' src={item.thumbnailUrl}
}`}> alt={item.title}
{item.type === 'VIDEO' ? <Play className="w-3 h-3 mr-1 fill-current" /> : className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
item.type === 'EVENT' ? <Calendar className="w-3 h-3 mr-1" /> : <Mic className="w-3 h-3 mr-1" />} />
{item.type === 'VIDEO' ? 'Vidéo' : <div className="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div>
item.type === 'EVENT' ? 'Événement' : 'Interview'}
</span>
</div>
{/* Play Icon Overlay for Videos */} {/* Type Badge */}
{item.type === 'VIDEO' && ( <div className="absolute top-4 left-4">
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300"> <span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide text-white backdrop-blur-md ${
<div className="w-16 h-16 bg-white/30 backdrop-blur-sm rounded-full flex items-center justify-center border border-white/50"> isPastEvent ? 'bg-gray-600/90' :
<Play className="w-8 h-8 text-white fill-current ml-1" /> item.type === 'VIDEO' ? 'bg-red-600/90' :
</div> 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 === 'EVENT' ? <Calendar className="w-3 h-3 mr-1" /> : <Mic className="w-3 h-3 mr-1" />}
{item.type === 'VIDEO' ? 'Vidéo' :
item.type === 'EVENT' ? (isPastEvent ? 'Événement Passé' : 'Événement') : 'Interview'}
</span>
</div> </div>
)}
</div>
<div className="pt-6 px-2"> {/* Play Icon Overlay for Videos */}
<div className="flex items-center text-xs text-gray-500 mb-3 space-x-3"> {item.type === 'VIDEO' && (
<span className="font-semibold text-brand-600 uppercase">{item.guestName}</span> <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<span className="w-1 h-1 bg-gray-300 rounded-full"></span> <div className="w-16 h-16 bg-white/30 backdrop-blur-sm rounded-full flex items-center justify-center border border-white/50">
<span className="truncate max-w-[100px]">{item.companyName}</span> <Play className="w-8 h-8 text-white fill-current ml-1" />
<span className="w-1 h-1 bg-gray-300 rounded-full"></span> </div>
<span className="flex items-center flex-nowrap"><Clock className="w-3 h-3 mr-1 shrink-0"/> {item.duration || 'N/A'}</span> </div>
)}
</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">
{item.title} <div className="pt-6 px-2">
</h3> <div className="flex items-center text-xs text-gray-500 mb-3 space-x-3">
<div className="text-gray-600 text-sm line-clamp-2 prose-sm" dangerouslySetInnerHTML={{ __html: item.excerpt }} /> <span className={`font-semibold uppercase ${isPastEvent ? 'text-gray-400' : 'text-brand-600'}`}>{item.guestName}</span>
</div> <span className="w-1 h-1 bg-gray-300 rounded-full"></span>
</Link> <span className="truncate max-w-[100px]">{item.companyName}</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>
</div>
<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}
</h3>
<div className="text-gray-600 text-sm line-clamp-2 prose-sm" dangerouslySetInnerHTML={{ __html: item.excerpt }} />
</div>
</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

@@ -4,7 +4,7 @@ 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, ArrowRight } from 'lucide-react'; import { Search, MapPin, Briefcase, TrendingUp, Loader2, ArrowRight } from 'lucide-react';
import { CATEGORIES, Business, BlogPost } from '../types'; import { Business, BlogPost } from '../types';
import BusinessCard from '../components/BusinessCard'; import BusinessCard from '../components/BusinessCard';
import { useUser } from '../components/UserProvider'; import { useUser } from '../components/UserProvider';
@@ -14,6 +14,7 @@ const HomePage = () => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>([]); const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>([]);
const [posts, setPosts] = useState<BlogPost[]>([]); 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); const [loadingPosts, setLoadingPosts] = useState(true);
@@ -46,7 +47,15 @@ const HomePage = () => {
setLoadingPosts(false); 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(); fetchPosts();
fetchCategories();
}, []); }, []);
const handleSearch = (e: React.FormEvent) => { const handleSearch = (e: React.FormEvent) => {
@@ -96,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">
{(settings?.homeCategories || CATEGORIES.slice(0, 4)).map((cat: string, idx: number) => ( {(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)}`}

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 */}
<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> {!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> </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>

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
user User @relation(fields: [userId], references: [id], onDelete: Cascade) businessId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
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,6 +266,34 @@ model MessageReport {
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade) reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
} }
enum ReportStatus {
PENDING
RESOLVED
DISMISSED
}
enum UserRole {
VISITOR
ENTREPRENEUR
ADMIN
}
enum Plan {
STARTER
BOOSTER
EMPIRE
}
enum OfferType {
PRODUCT
SERVICE
}
enum InterviewType {
VIDEO
ARTICLE
}
model SiteSetting { model SiteSetting {
id String @id @default("singleton") id String @id @default("singleton")
siteName String @default("Afrohub") siteName String @default("Afrohub")
@@ -267,39 +317,6 @@ model SiteSetting {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
enum Plan {
STARTER
BOOSTER
EMPIRE
}
enum RatingStatus {
PENDING
APPROVED
REJECTED
}
enum ReportStatus {
PENDING
RESOLVED
DISMISSED
}
enum UserRole {
VISITOR
ENTREPRENEUR
ADMIN
}
enum OfferType {
PRODUCT
SERVICE
}
enum InterviewType {
VIDEO
ARTICLE
}
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"
];