Compare commits
2 Commits
33ec11d6db
...
e6310f30de
| Author | SHA1 | Date | |
|---|---|---|---|
| e6310f30de | |||
| 88e4c13b9f |
@@ -35,11 +35,15 @@ model Business {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
category String
|
||||
suggestedCategory String?
|
||||
location String // Legacy location string
|
||||
countryId String?
|
||||
city String?
|
||||
description String
|
||||
logoUrl String
|
||||
coverUrl String?
|
||||
coverPosition String? @default("50% 50%")
|
||||
coverZoom Float? @default(1.0)
|
||||
videoUrl String?
|
||||
contactEmail String
|
||||
contactPhone String?
|
||||
@@ -71,6 +75,19 @@ model Business {
|
||||
offers Offer[]
|
||||
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 {
|
||||
@@ -85,18 +102,14 @@ model Country {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
enum Plan {
|
||||
STARTER
|
||||
BOOSTER
|
||||
EMPIRE
|
||||
}
|
||||
|
||||
model PricingPlan {
|
||||
id String @id @default(uuid())
|
||||
tier Plan @unique
|
||||
name String
|
||||
priceXOF String
|
||||
yearlyPriceXOF String?
|
||||
priceEUR String
|
||||
yearlyPriceEUR String?
|
||||
description String
|
||||
features String[]
|
||||
offerLimit Int @default(1)
|
||||
@@ -265,6 +278,12 @@ enum UserRole {
|
||||
ADMIN
|
||||
}
|
||||
|
||||
enum Plan {
|
||||
STARTER
|
||||
BOOSTER
|
||||
EMPIRE
|
||||
}
|
||||
|
||||
enum OfferType {
|
||||
PRODUCT
|
||||
SERVICE
|
||||
@@ -287,8 +306,17 @@ model SiteSetting {
|
||||
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 {
|
||||
id String @id @default(uuid())
|
||||
type String @unique // 'CGU' or 'CGV'
|
||||
|
||||
@@ -75,3 +75,14 @@ export async function deleteBlogPost(id: string) {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
111
admin/src/app/actions/categories.ts
Normal file
111
admin/src/app/actions/categories.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,9 @@ export async function updatePricingPlanDetail(id: string, data: any) {
|
||||
data: {
|
||||
name: data.name,
|
||||
priceXOF: data.priceXOF,
|
||||
yearlyPriceXOF: data.yearlyPriceXOF || null,
|
||||
priceEUR: data.priceEUR,
|
||||
yearlyPriceEUR: data.yearlyPriceEUR || null,
|
||||
description: data.description,
|
||||
features: data.features, // Expected to be string[]
|
||||
offerLimit: parseInt(data.offerLimit),
|
||||
|
||||
@@ -20,7 +20,15 @@ export async function getSiteSettings() {
|
||||
twitterUrl: "",
|
||||
instagramUrl: "",
|
||||
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;
|
||||
@@ -43,7 +51,7 @@ export async function updateSiteSettings(data: any) {
|
||||
});
|
||||
|
||||
revalidatePath('/settings');
|
||||
revalidatePath('/', 'layout'); // Revalidate all public pages because footer/header might use it
|
||||
revalidatePath('/', 'layout');
|
||||
|
||||
return { success: true, settings };
|
||||
} catch (error) {
|
||||
|
||||
404
admin/src/app/categories/page.tsx
Normal file
404
admin/src/app/categories/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export default function PlansPage() {
|
||||
const [plans, setPlans] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingPlan, setEditingPlan] = useState<any>(null);
|
||||
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
||||
|
||||
useEffect(() => {
|
||||
async function loadPlans() {
|
||||
@@ -29,9 +30,27 @@ export default function PlansPage() {
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Tarifs & Forfaits</h1>
|
||||
<p className="text-slate-400">Modifiez les noms, prix, limites d'offres et avantages affichés sur le site principal.</p>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
|
||||
<div>
|
||||
<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 className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
@@ -55,16 +74,27 @@ export default function PlansPage() {
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Price & Limit */}
|
||||
<div className="p-6 bg-slate-950/30 flex-1">
|
||||
<div className="mb-6">
|
||||
<div className="text-3xl font-bold text-white mb-1">{plan.priceXOF}</div>
|
||||
<div className="text-xs text-slate-500 uppercase font-bold tracking-tighter">Limite : {plan.offerLimit} Offre(s)</div>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<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>
|
||||
|
||||
<div className="space-y-3 mb-8">
|
||||
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2">Avantages inclus :</div>
|
||||
|
||||
@@ -1,19 +1,58 @@
|
||||
"use client";
|
||||
|
||||
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 { 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() {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [settings, setSettings] = useState<any>(null);
|
||||
const [allPosts, setAllPosts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
|
||||
const [selectedHomeCategories, setSelectedHomeCategories] = useState<string[]>([]);
|
||||
const [selectedBlogCategories, setSelectedBlogCategories] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
const data = await getSiteSettings();
|
||||
setSettings(data);
|
||||
const [settingsData, postsData] = await Promise.all([
|
||||
getSiteSettings(),
|
||||
getBlogPosts()
|
||||
]);
|
||||
setSettings(settingsData);
|
||||
setAllPosts(postsData);
|
||||
setSelectedPostIds(settingsData?.homeBlogIds || []);
|
||||
setSelectedHomeCategories(settingsData?.homeCategories || []);
|
||||
setSelectedBlogCategories(settingsData?.homeBlogCategories || []);
|
||||
setLoading(false);
|
||||
}
|
||||
loadData();
|
||||
@@ -33,6 +72,14 @@ export default function SettingsPage() {
|
||||
instagramUrl: formData.get('instagramUrl'),
|
||||
linkedinUrl: formData.get('linkedinUrl'),
|
||||
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 () => {
|
||||
@@ -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) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-20">
|
||||
@@ -97,6 +162,149 @@ export default function SettingsPage() {
|
||||
</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 */}
|
||||
<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">
|
||||
|
||||
@@ -16,7 +16,9 @@ export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalPr
|
||||
const [formData, setFormData] = useState({
|
||||
name: plan.name,
|
||||
priceXOF: plan.priceXOF,
|
||||
yearlyPriceXOF: plan.yearlyPriceXOF || '',
|
||||
priceEUR: plan.priceEUR,
|
||||
yearlyPriceEUR: plan.yearlyPriceEUR || '',
|
||||
description: plan.description,
|
||||
offerLimit: plan.offerLimit.toString(),
|
||||
recommended: plan.recommended,
|
||||
@@ -100,7 +102,7 @@ export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalPr
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
value={formData.priceXOF}
|
||||
onChange={(e) => setFormData({...formData, priceXOF: e.target.value})}
|
||||
@@ -108,13 +110,31 @@ export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalPr
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
value={formData.priceEUR}
|
||||
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"
|
||||
/>
|
||||
</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 className="space-y-2">
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
CreditCard,
|
||||
Settings,
|
||||
Globe,
|
||||
FileText
|
||||
FileText,
|
||||
Briefcase
|
||||
} from 'lucide-react';
|
||||
|
||||
const menuItems = [
|
||||
@@ -26,6 +27,7 @@ const menuItems = [
|
||||
{ name: 'Modération', icon: Flag, href: '/moderation' },
|
||||
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' },
|
||||
{ name: 'Tarifs & Plans', icon: CreditCard, href: '/plans' },
|
||||
{ name: 'Secteurs & Catégories', icon: Briefcase, href: '/categories' },
|
||||
{ name: 'Pays', icon: Globe, href: '/countries' },
|
||||
{ name: 'Documents Légaux', icon: FileText, href: '/legal' },
|
||||
{ name: 'Configuration', icon: Settings, href: '/settings' },
|
||||
|
||||
@@ -119,54 +119,59 @@ export default async function AfroLifePage({ searchParams }: Props) {
|
||||
<EventTimeline events={items} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{items.map(item => (
|
||||
<Link href={`/afrolife/${(item as any).slug || item.id}`} key={item.id} className="group block h-full">
|
||||
<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 */}
|
||||
<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 ${
|
||||
item.type === 'VIDEO' ? 'bg-red-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 === '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' ? 'Événement' : 'Interview'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Play Icon Overlay for Videos */}
|
||||
{item.type === 'VIDEO' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div className="w-16 h-16 bg-white/30 backdrop-blur-sm rounded-full flex items-center justify-center border border-white/50">
|
||||
<Play className="w-8 h-8 text-white fill-current ml-1" />
|
||||
</div>
|
||||
{items.map(item => {
|
||||
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">
|
||||
<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 */}
|
||||
<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 ${
|
||||
isPastEvent ? 'bg-gray-600/90' :
|
||||
item.type === 'VIDEO' ? 'bg-red-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 === '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 className="pt-6 px-2">
|
||||
<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="w-1 h-1 bg-gray-300 rounded-full"></span>
|
||||
<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>
|
||||
|
||||
{/* Play Icon Overlay for Videos */}
|
||||
{item.type === 'VIDEO' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div className="w-16 h-16 bg-white/30 backdrop-blur-sm rounded-full flex items-center justify-center border border-white/50">
|
||||
<Play className="w-8 h-8 text-white fill-current ml-1" />
|
||||
</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}
|
||||
</h3>
|
||||
<div className="text-gray-600 text-sm line-clamp-2 prose-sm" dangerouslySetInnerHTML={{ __html: item.excerpt }} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<div className="pt-6 px-2">
|
||||
<div className="flex items-center text-xs text-gray-500 mb-3 space-x-3">
|
||||
<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="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>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import React, { useState, useEffect, useMemo, Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Search, Loader2 } from 'lucide-react';
|
||||
import { CATEGORIES, Business, Country } from '../../types';
|
||||
import { Business, Country } from '../../types';
|
||||
import BusinessCard from '../../components/BusinessCard';
|
||||
import AnnuaireHero from '../../components/AnnuaireHero';
|
||||
|
||||
@@ -12,22 +12,28 @@ const AnnuairePageContent = () => {
|
||||
const [businesses, setBusinesses] = useState<Business[]>([]);
|
||||
const [featuredBusiness, setFeaturedBusiness] = useState<Business | null>(null);
|
||||
const [countries, setCountries] = useState<Country[]>([]);
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filterCategory, setFilterCategory] = useState('All');
|
||||
const [filterCountry, setFilterCountry] = useState('All');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// 1. Fetch Countries
|
||||
// 1. Fetch Metadata (Countries & Categories)
|
||||
useEffect(() => {
|
||||
const fetchCountries = async () => {
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/countries');
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) setCountries(data);
|
||||
const [cRes, catRes] = await Promise.all([
|
||||
fetch('/api/countries'),
|
||||
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) {}
|
||||
};
|
||||
fetchCountries();
|
||||
fetchMetadata();
|
||||
}, []);
|
||||
|
||||
// 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>
|
||||
</div>
|
||||
{CATEGORIES.map(cat => (
|
||||
<div key={cat} className="flex items-center">
|
||||
{categories.map(cat => (
|
||||
<div key={cat.id} className="flex items-center">
|
||||
<input
|
||||
id={`cat-${cat}`}
|
||||
id={`cat-${cat.id}`}
|
||||
name="category"
|
||||
type="radio"
|
||||
checked={filterCategory === cat}
|
||||
onChange={() => setFilterCategory(cat)}
|
||||
checked={filterCategory === cat.id || filterCategory === cat.name}
|
||||
onChange={() => setFilterCategory(cat.id)}
|
||||
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>
|
||||
|
||||
@@ -18,7 +18,17 @@ export async function GET(request: NextRequest) {
|
||||
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 (countryId) where.countryId = countryId
|
||||
if (q) {
|
||||
@@ -61,6 +71,8 @@ export async function POST(request: NextRequest) {
|
||||
name: data.name || "",
|
||||
slug: data.slug || null,
|
||||
category: data.category || "Autre",
|
||||
categoryId: (data.categoryId && data.categoryId !== 'Autre') ? data.categoryId : null,
|
||||
suggestedCategory: data.suggestedCategory || null,
|
||||
location: data.location || "",
|
||||
countryId: data.countryId || null,
|
||||
city: data.city || "",
|
||||
|
||||
14
app/api/categories/route.ts
Normal file
14
app/api/categories/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
86
app/page.tsx
86
app/page.tsx
@@ -3,15 +3,20 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Search, MapPin, Briefcase, TrendingUp, Loader2 } from 'lucide-react';
|
||||
import { CATEGORIES, Business } from '../types';
|
||||
import { Search, MapPin, Briefcase, TrendingUp, Loader2, ArrowRight } from 'lucide-react';
|
||||
import { Business, BlogPost } from '../types';
|
||||
import BusinessCard from '../components/BusinessCard';
|
||||
import { useUser } from '../components/UserProvider';
|
||||
|
||||
const HomePage = () => {
|
||||
const router = useRouter();
|
||||
const { settings } = useUser();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>([]);
|
||||
const [posts, setPosts] = useState<BlogPost[]>([]);
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingPosts, setLoadingPosts] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFeatured = async () => {
|
||||
@@ -28,6 +33,29 @@ const HomePage = () => {
|
||||
}
|
||||
};
|
||||
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) => {
|
||||
@@ -77,7 +105,7 @@ const HomePage = () => {
|
||||
<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>
|
||||
<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
|
||||
key={idx}
|
||||
href={`/annuaire?category=${encodeURIComponent(cat)}`}
|
||||
@@ -118,6 +146,58 @@ const HomePage = () => {
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -32,15 +32,16 @@ const EventTimeline = ({ events }: EventTimelineProps) => {
|
||||
{sortedEvents.map((event, index) => {
|
||||
const eventDate = new Date(event.date);
|
||||
const isEven = index % 2 === 0;
|
||||
const isPast = eventDate < new Date();
|
||||
|
||||
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 */}
|
||||
<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 */}
|
||||
<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 */}
|
||||
<div className="relative h-48 mb-6 rounded-2xl overflow-hidden">
|
||||
<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 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" />
|
||||
{eventDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}
|
||||
</span>
|
||||
@@ -59,11 +60,12 @@ const EventTimeline = ({ events }: EventTimelineProps) => {
|
||||
|
||||
{/* Event Details */}
|
||||
<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" />
|
||||
{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>
|
||||
<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}
|
||||
</h3>
|
||||
<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">
|
||||
<Link
|
||||
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
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Date Column (Desktop Only) */}
|
||||
<div className="hidden md:flex w-[45%] justify-center items-center px-8">
|
||||
<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' })}
|
||||
</span>
|
||||
<span className="block text-xl font-bold text-gray-400">
|
||||
|
||||
@@ -9,7 +9,9 @@ interface Plan {
|
||||
tier: string;
|
||||
name: string;
|
||||
priceXOF: string;
|
||||
yearlyPriceXOF?: string;
|
||||
priceEUR: string;
|
||||
yearlyPriceEUR?: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
recommended: boolean;
|
||||
@@ -130,12 +132,24 @@ const PricingSection = () => {
|
||||
<p className="mt-4 text-sm text-gray-500">{plan.description}</p>
|
||||
<div className="mt-8 flex items-baseline">
|
||||
<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>
|
||||
{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>
|
||||
{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">
|
||||
@@ -193,8 +207,10 @@ const PricingSection = () => {
|
||||
{paymentStep === 'method' && (
|
||||
<>
|
||||
<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-2xl font-bold text-brand-900 mt-1">{selectedPlan.priceXOF}</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">
|
||||
{billingCycle === 'yearly' ? (selectedPlan.yearlyPriceXOF || selectedPlan.priceXOF) : selectedPlan.priceXOF}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-medium text-gray-700 mb-3">Moyen de paiement</p>
|
||||
@@ -292,7 +308,7 @@ const PricingSection = () => {
|
||||
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"
|
||||
>
|
||||
Payer {selectedPlan.priceXOF}
|
||||
Payer {billingCycle === 'yearly' ? (selectedPlan.yearlyPriceXOF || selectedPlan.priceXOF) : selectedPlan.priceXOF}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import React, { useState } from '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 { toast } from 'react-hot-toast';
|
||||
import { useUser } from '../UserProvider';
|
||||
@@ -18,7 +18,9 @@ const getYouTubeId = (url: string) => {
|
||||
const normalizeBusinessData = (b: Business): Business => ({
|
||||
...b,
|
||||
name: b.name || 'Nouvelle Entreprise',
|
||||
category: b.category || CATEGORIES[0],
|
||||
category: b.category || 'Autre',
|
||||
categoryId: b.categoryId || '',
|
||||
suggestedCategory: b.suggestedCategory || '',
|
||||
description: b.description || '',
|
||||
location: b.location || '',
|
||||
countryId: b.countryId || '',
|
||||
@@ -46,23 +48,29 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
||||
const { login } = useUser();
|
||||
const [formData, setFormData] = useState<Business>(normalizeBusinessData(business));
|
||||
const [countries, setCountries] = useState<Country[]>([]);
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
const [videoInput, setVideoInput] = useState(business.videoUrl || '');
|
||||
const [videoPreviewId, setVideoPreviewId] = useState<string | null>(getYouTubeId(business.videoUrl || ''));
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
// Fetch countries
|
||||
// Fetch countries and categories
|
||||
React.useEffect(() => {
|
||||
const fetchCountries = async () => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/countries');
|
||||
const data = await res.json();
|
||||
if (!data.error) setCountries(data);
|
||||
const [cRes, catRes] = await Promise.all([
|
||||
fetch('/api/countries'),
|
||||
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) {
|
||||
console.error('Error fetching countries:', error);
|
||||
console.error('Error fetching metadata:', error);
|
||||
}
|
||||
};
|
||||
fetchCountries();
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Sync formData when business prop changes (initial fetch)
|
||||
@@ -333,10 +341,37 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<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">
|
||||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
<select
|
||||
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>
|
||||
</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="flex justify-between mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">Description</label>
|
||||
|
||||
@@ -11,7 +11,15 @@ export async function getSiteSettings() {
|
||||
twitterUrl: "#",
|
||||
instagramUrl: "#",
|
||||
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 {
|
||||
|
||||
90
package-lock.json
generated
90
package-lock.json
generated
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.30.0",
|
||||
"@prisma/adapter-pg": "^7.6.0",
|
||||
"@prisma/client": "^7.6.0",
|
||||
"@prisma/client": "^7.7.0",
|
||||
"@prisma/config": "^7.6.0",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
@@ -24,7 +24,7 @@
|
||||
"next": "^16.1.6",
|
||||
"pg": "^8.19.0",
|
||||
"postcss": "^8.5.10",
|
||||
"prisma": "^7.6.0",
|
||||
"prisma": "^7.7.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
@@ -1231,12 +1231,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.6.0.tgz",
|
||||
"integrity": "sha512-7Pe/1ayh3GgWPEg4mmT4ax77LJ1wC+XlnIFvQ94bLP2DsUnOpnruQQR3Jw7r+Frthk94QqDNxo3FjSg8h9PXeQ==",
|
||||
"version": "7.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.7.0.tgz",
|
||||
"integrity": "sha512-5Ar4OsZpJ54s21sy5oDNNW9gQtd4NuxCaiM7+JDTOU07D6VvlpLjYzAVCMB1+JzokN+08dAVomlx+b7bhJd3ww==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/client-runtime-utils": "7.6.0"
|
||||
"@prisma/client-runtime-utils": "7.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19 || ^22.12 || >=24.0"
|
||||
@@ -1255,9 +1255,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client-runtime-utils": {
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.6.0.tgz",
|
||||
"integrity": "sha512-fD7jlqubsZvVODKvsp9lOpXVecx2aWGxC2l35Ioz2t+teUJ5CfR0SAMsi7UkU1VvaZmmm+DS6BdujF622nY7tQ==",
|
||||
"version": "7.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.7.0.tgz",
|
||||
"integrity": "sha512-BLyd0UpFYOtyJFTHm7jS9vesHW7P83abibodQMiIofqjBKzDHQ1VAsQkdfvXyYDkPlONPfOTz7/rv3x/+CQqvQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/config": {
|
||||
@@ -1313,16 +1313,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.6.0.tgz",
|
||||
"integrity": "sha512-Sn5edRzhHqgRV2M+A0eIbY442B4mReWWf3pKs/LKreYgW7oa/up8JtK/s4iv/EQA097cyboZ08mmkpbLp+tZ3w==",
|
||||
"version": "7.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.7.0.tgz",
|
||||
"integrity": "sha512-7fmcbT7HHXBq/b+3h/dO1JI3fd8l8q7erf7xP7pRprh58hmSSnG8mg9K3yjW3h9WaHWUwngVFpSxxxivaitQ2w==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "7.6.0",
|
||||
"@prisma/debug": "7.7.0",
|
||||
"@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711",
|
||||
"@prisma/fetch-engine": "7.6.0",
|
||||
"@prisma/get-platform": "7.6.0"
|
||||
"@prisma/fetch-engine": "7.7.0",
|
||||
"@prisma/get-platform": "7.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/engines-version": {
|
||||
@@ -1331,45 +1331,33 @@
|
||||
"integrity": "sha512-r51DLcJ8bDRSrBEJF3J4cinoWyGA7rfP2mG6lD90VqIbGNOkbfcLcXalSVjq5Y6brQS3vcjrq4GbyUb1Cb7vkw==",
|
||||
"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": {
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.6.0.tgz",
|
||||
"integrity": "sha512-ohZDwXvtmnbzOcutR2D13lDWpZP1wQjmPyztmt0AwXLzQI7q95EE7NYCvS+M6N6SivT+BM0NOqLmTH3wms4L3A==",
|
||||
"version": "7.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.7.0.tgz",
|
||||
"integrity": "sha512-MEUNzvKxvYnJ7kgvd6oNRnMmmiGNS9TYLB2weMeIXplnHdL/UWEGnvavYGnN7KLJ2n0iI4dDAyzSkHI3c7AscQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "7.6.0"
|
||||
"@prisma/debug": "7.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.6.0.tgz",
|
||||
"integrity": "sha512-N575Ni95c3FkduWY/eKTHqNYgNbceZ1tQaSknVtJjpKmiiBXmniESn/GTxsDvICC4ZeiNrXxioGInzQrCdx16w==",
|
||||
"version": "7.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.7.0.tgz",
|
||||
"integrity": "sha512-TfyzveBQoK4xALzsTpVhB/0KG1N8zOK0ap+RnBMkzGUu3f98fnQ4QtXa2wlKPhsO2X8a3N5ugFQgcKNoHGmDfw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "7.6.0",
|
||||
"@prisma/debug": "7.7.0",
|
||||
"@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": {
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.6.0.tgz",
|
||||
"integrity": "sha512-ohZDwXvtmnbzOcutR2D13lDWpZP1wQjmPyztmt0AwXLzQI7q95EE7NYCvS+M6N6SivT+BM0NOqLmTH3wms4L3A==",
|
||||
"version": "7.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.7.0.tgz",
|
||||
"integrity": "sha512-MEUNzvKxvYnJ7kgvd6oNRnMmmiGNS9TYLB2weMeIXplnHdL/UWEGnvavYGnN7KLJ2n0iI4dDAyzSkHI3c7AscQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "7.6.0"
|
||||
"@prisma/debug": "7.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/get-platform": {
|
||||
@@ -4223,15 +4211,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.6.0.tgz",
|
||||
"integrity": "sha512-OKJIPT81K3+F+AayIkY/Y3mkF2NWoFh7lZApaaqPYy7EHILKdO0VsmGkP+hDKYTySHsFSyLWXm/JgcR1B8fY1Q==",
|
||||
"version": "7.7.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.7.0.tgz",
|
||||
"integrity": "sha512-HlgwRBt1uEFB9LStHL4HLYDvoi4BNu1rYA0hPG0zCAEyK9SaZBqp7E5Rjpc3Qh8Lex/ye/svoHZ0OWoFNhWxuQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/config": "7.6.0",
|
||||
"@prisma/config": "7.7.0",
|
||||
"@prisma/dev": "0.24.3",
|
||||
"@prisma/engines": "7.6.0",
|
||||
"@prisma/engines": "7.7.0",
|
||||
"@prisma/studio-core": "0.27.3",
|
||||
"mysql2": "3.15.3",
|
||||
"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": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.30.0",
|
||||
"@prisma/adapter-pg": "^7.6.0",
|
||||
"@prisma/client": "^7.6.0",
|
||||
"@prisma/client": "^7.7.0",
|
||||
"@prisma/config": "^7.6.0",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
@@ -34,7 +34,7 @@
|
||||
"next": "^16.1.6",
|
||||
"pg": "^8.19.0",
|
||||
"postcss": "^8.5.10",
|
||||
"prisma": "^7.6.0",
|
||||
"prisma": "^7.7.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
@@ -46,4 +46,4 @@
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@ model User {
|
||||
updatedAt DateTime @updatedAt
|
||||
bio String?
|
||||
location String?
|
||||
countryId String?
|
||||
phone String?
|
||||
isSuspended Boolean @default(false)
|
||||
suspensionReason String?
|
||||
countryId String?
|
||||
businesses Business?
|
||||
comments Comment[]
|
||||
conversations ConversationParticipant[]
|
||||
@@ -35,7 +35,10 @@ model Business {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
category String
|
||||
suggestedCategory String?
|
||||
location String
|
||||
countryId String?
|
||||
city String?
|
||||
description String
|
||||
logoUrl String
|
||||
coverUrl String?
|
||||
@@ -66,26 +69,37 @@ model Business {
|
||||
isSuspended Boolean @default(false)
|
||||
suspensionReason String?
|
||||
plan Plan @default(STARTER)
|
||||
city String?
|
||||
countryId String?
|
||||
country Country? @relation(fields: [countryId], references: [id])
|
||||
owner User @relation(fields: [ownerId], references: [id])
|
||||
comments Comment[]
|
||||
conversations Conversation[]
|
||||
offers Offer[]
|
||||
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 {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
code String @unique
|
||||
flag String?
|
||||
code String @unique // ISO alpha-2
|
||||
flag String? // Emoji or URL
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
businesses Business[]
|
||||
users User[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model PricingPlan {
|
||||
@@ -93,7 +107,9 @@ model PricingPlan {
|
||||
tier Plan @unique
|
||||
name String
|
||||
priceXOF String
|
||||
yearlyPriceXOF String?
|
||||
priceEUR String
|
||||
yearlyPriceEUR String?
|
||||
description String
|
||||
features String[]
|
||||
offerLimit Int @default(1)
|
||||
@@ -172,34 +188,40 @@ model AnalyticsEvent {
|
||||
label String?
|
||||
value Float?
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
browser String?
|
||||
city String?
|
||||
country String?
|
||||
device String?
|
||||
ip String?
|
||||
country String?
|
||||
city String?
|
||||
browser String?
|
||||
os String?
|
||||
device String?
|
||||
referrer String?
|
||||
userId String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Rating {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
value Int
|
||||
userId String
|
||||
businessId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comment String?
|
||||
reply String?
|
||||
replyAt DateTime?
|
||||
status RatingStatus @default(PENDING)
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
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])
|
||||
}
|
||||
|
||||
enum RatingStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
model Conversation {
|
||||
id String @id @default(uuid())
|
||||
businessId String
|
||||
@@ -244,33 +266,6 @@ model MessageReport {
|
||||
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 {
|
||||
PENDING
|
||||
RESOLVED
|
||||
@@ -283,6 +278,12 @@ enum UserRole {
|
||||
ADMIN
|
||||
}
|
||||
|
||||
enum Plan {
|
||||
STARTER
|
||||
BOOSTER
|
||||
EMPIRE
|
||||
}
|
||||
|
||||
enum OfferType {
|
||||
PRODUCT
|
||||
SERVICE
|
||||
@@ -292,6 +293,30 @@ enum InterviewType {
|
||||
VIDEO
|
||||
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 {
|
||||
id String @id @default(uuid())
|
||||
type String @unique // 'CGU' or 'CGV'
|
||||
|
||||
52
prisma/seed-categories.ts
Normal file
52
prisma/seed-categories.ts
Normal 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
126
prisma/seed-events.ts
Normal 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
15
scratch/check-cats.ts
Normal 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();
|
||||
38
scratch/migrate-categories.ts
Normal file
38
scratch/migrate-categories.ts
Normal 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());
|
||||
12
types.ts
12
types.ts
@@ -41,6 +41,8 @@ export interface Business {
|
||||
name: string;
|
||||
slug?: string;
|
||||
category: string;
|
||||
categoryId?: string;
|
||||
suggestedCategory?: string;
|
||||
location: string; // Legacy: City, Country
|
||||
countryId?: string;
|
||||
country?: Country;
|
||||
@@ -133,13 +135,3 @@ export interface Rating {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const CATEGORIES = [
|
||||
"Technologie & IT",
|
||||
"Agriculture & Agrobusiness",
|
||||
"Mode & Textile",
|
||||
"Cosmétique & Beauté",
|
||||
"Services aux entreprises",
|
||||
"Restauration & Alimentation",
|
||||
"Construction & BTP",
|
||||
"Éducation & Formation"
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user