diff --git a/admin/prisma/schema.prisma b/admin/prisma/schema.prisma index 6de2cfb..01a8d07 100644 --- a/admin/prisma/schema.prisma +++ b/admin/prisma/schema.prisma @@ -35,6 +35,7 @@ model Business { id String @id @default(uuid()) name String category String + suggestedCategory String? location String // Legacy location string countryId String? city String? @@ -74,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 { @@ -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) diff --git a/admin/src/app/actions/categories.ts b/admin/src/app/actions/categories.ts new file mode 100644 index 0000000..682539f --- /dev/null +++ b/admin/src/app/actions/categories.ts @@ -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 []; + } +} diff --git a/admin/src/app/actions/plans.ts b/admin/src/app/actions/plans.ts index 5e2d109..0155997 100644 --- a/admin/src/app/actions/plans.ts +++ b/admin/src/app/actions/plans.ts @@ -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), diff --git a/admin/src/app/categories/page.tsx b/admin/src/app/categories/page.tsx new file mode 100644 index 0000000..1b3eb36 --- /dev/null +++ b/admin/src/app/categories/page.tsx @@ -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([]); + const [suggestions, setSuggestions] = useState([]); + const [loading, setLoading] = useState(true); + const [isPending, startTransition] = useTransition(); + const [showAddModal, setShowAddModal] = useState(false); + const [editingCategory, setEditingCategory] = useState(null); + const [selectedSuggestion, setSelectedSuggestion] = useState(null); + + const renderIcon = (iconName: string) => { + const found = ICON_LIST.find(i => i.name === iconName); + if (found) { + const IconComp = found.icon; + return ; + } + return ; + }; + + 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 ( +
+ +
+ ); + } + + return ( +
+
+
+

Secteurs & Catégories

+

Gérez les secteurs d'activité disponibles pour les entrepreneurs.

+
+ +
+ +
+
+
+
+

Catégories Officielles

+ {categories.length} secteurs +
+
+ + + + + + + + + + {categories.map((cat) => ( + + + + + + ))} + +
NomStatutActions
+
+
+ {renderIcon(cat.icon)} +
+
+

{cat.name}

+

{cat.slug}

+
+
+
+ {cat.isActive ? ( + + Actif + + ) : ( + + Inactif + + )} + +
+ + +
+
+
+
+
+ +
+
+
+ +

Suggestions

+
+
+ {suggestions.length === 0 ? ( +
+
+ +
+

Aucune suggestion en attente.

+
+ ) : ( + suggestions.map((sugg) => ( +
+
+ Proposé + {new Date(sugg.createdAt).toLocaleDateString()} +
+

{sugg.suggestedCategory}

+
+ + Par : {sugg.name} +
+ +
+ )) + )} +
+
+
+
+ + {(showAddModal || editingCategory) && ( +
+
+
+
+ {selectedSuggestion && } +

+ {editingCategory ? 'Modifier la catégorie' : selectedSuggestion ? 'Valider la suggestion' : 'Nouvelle catégorie'} +

+
+ +
+ +
+
+
+
+ + { + 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" + /> +
+
+ + 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" + /> +
+ +
+ 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" + /> + +
+ + {selectedSuggestion && ( +
+

✨ **Note :** En créant cette catégorie, l'entreprise **{selectedSuggestion.name}** y sera automatiquement rattachée.

+
+ )} +
+ +
+ +
+ {ICON_LIST.map((item) => ( + + ))} +
+
+
+ +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/admin/src/app/plans/page.tsx b/admin/src/app/plans/page.tsx index b047122..12bef3e 100644 --- a/admin/src/app/plans/page.tsx +++ b/admin/src/app/plans/page.tsx @@ -9,6 +9,7 @@ export default function PlansPage() { const [plans, setPlans] = useState([]); const [loading, setLoading] = useState(true); const [editingPlan, setEditingPlan] = useState(null); + const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly'); useEffect(() => { async function loadPlans() { @@ -29,9 +30,27 @@ export default function PlansPage() { return (
-
-

Gestion des Tarifs & Forfaits

-

Modifiez les noms, prix, limites d'offres et avantages affichés sur le site principal.

+
+
+

Gestion des Tarifs & Forfaits

+

Modifiez les noms, prix et limites d'offres affichés sur le site.

+
+ + {/* Toggle Billing */} +
+ + +
@@ -55,16 +74,27 @@ export default function PlansPage() { )}
-

{plan.name}

+

{plan.name}

{plan.description}

{/* Price & Limit */}
-
-
{plan.priceXOF}
-
Limite : {plan.offerLimit} Offre(s)
-
+
+
+ {billingCycle === 'yearly' + ? (plan.yearlyPriceXOF || plan.priceXOF) + : plan.priceXOF + } + + / {billingCycle === 'yearly' ? 'an' : 'mois'} + +
+ {billingCycle === 'yearly' && plan.yearlyPriceXOF && ( +
PRIX ANNUEL ACTIF
+ )} +
Limite : {plan.offerLimit} Offre(s)
+
Avantages inclus :
diff --git a/admin/src/components/PlanEditModal.tsx b/admin/src/components/PlanEditModal.tsx index 859b08c..bc81493 100644 --- a/admin/src/components/PlanEditModal.tsx +++ b/admin/src/components/PlanEditModal.tsx @@ -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 />
- + setFormData({...formData, priceXOF: e.target.value})} @@ -108,13 +110,31 @@ export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalPr />
- + + 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" + /> +
+
+ 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" />
+
+ + 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" + /> +
diff --git a/admin/src/components/Sidebar.tsx b/admin/src/components/Sidebar.tsx index a210e86..f8f59d0 100644 --- a/admin/src/components/Sidebar.tsx +++ b/admin/src/components/Sidebar.tsx @@ -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' }, diff --git a/app/afrolife/page.tsx b/app/afrolife/page.tsx index 660b54c..d5f7763 100644 --- a/app/afrolife/page.tsx +++ b/app/afrolife/page.tsx @@ -119,54 +119,59 @@ export default async function AfroLifePage({ searchParams }: Props) { ) : (
- {items.map(item => ( - -
- {item.title} -
- - {/* Type Badge */} -
- - {item.type === 'VIDEO' ? : - item.type === 'EVENT' ? : } - {item.type === 'VIDEO' ? 'Vidéo' : - item.type === 'EVENT' ? 'Événement' : 'Interview'} - -
- - {/* Play Icon Overlay for Videos */} - {item.type === 'VIDEO' && ( -
-
- -
+ {items.map(item => { + const isPastEvent = item.type === 'EVENT' && new Date(item.date) < new Date(); + + return ( + +
+ {item.title} +
+ + {/* Type Badge */} +
+ + {item.type === 'VIDEO' ? : + item.type === 'EVENT' ? : } + {item.type === 'VIDEO' ? 'Vidéo' : + item.type === 'EVENT' ? (isPastEvent ? 'Événement Passé' : 'Événement') : 'Interview'} +
- )} -
- -
-
- {item.guestName} - - {item.companyName} - - {item.duration || 'N/A'} + + {/* Play Icon Overlay for Videos */} + {item.type === 'VIDEO' && ( +
+
+ +
+
+ )}
-

- {item.title} -

-
-
- - ))} + +
+
+ {item.guestName} + + {item.companyName} + + {item.duration || 'N/A'} +
+

+ {item.title} +

+
+
+ + ); + })}
)}
diff --git a/app/annuaire/page.tsx b/app/annuaire/page.tsx index 7a8fde6..3ecdc38 100644 --- a/app/annuaire/page.tsx +++ b/app/annuaire/page.tsx @@ -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([]); const [featuredBusiness, setFeaturedBusiness] = useState(null); const [countries, setCountries] = useState([]); + const [categories, setCategories] = useState([]); 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 = () => { />
- {CATEGORIES.map(cat => ( -
+ {categories.map(cat => ( +
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" /> - +
))}
diff --git a/app/api/businesses/route.ts b/app/api/businesses/route.ts index ffe623f..a93e47d 100644 --- a/app/api/businesses/route.ts +++ b/app/api/businesses/route.ts @@ -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 || "", diff --git a/app/api/categories/route.ts b/app/api/categories/route.ts new file mode 100644 index 0000000..e99dc0e --- /dev/null +++ b/app/api/categories/route.ts @@ -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 }); + } +} diff --git a/app/page.tsx b/app/page.tsx index dab10d8..ed8c259 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { Search, MapPin, Briefcase, TrendingUp, Loader2, ArrowRight } from 'lucide-react'; -import { CATEGORIES, Business, BlogPost } from '../types'; +import { Business, BlogPost } from '../types'; import BusinessCard from '../components/BusinessCard'; import { useUser } from '../components/UserProvider'; @@ -14,6 +14,7 @@ const HomePage = () => { const [searchTerm, setSearchTerm] = useState(''); const [featuredBusinesses, setFeaturedBusinesses] = useState([]); const [posts, setPosts] = useState([]); + const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [loadingPosts, setLoadingPosts] = useState(true); @@ -46,7 +47,15 @@ const HomePage = () => { 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) => { @@ -96,7 +105,7 @@ const HomePage = () => {

Secteurs en vedette

- {(settings?.homeCategories || CATEGORIES.slice(0, 4)).map((cat: string, idx: number) => ( + {(settings?.homeCategories || categories.slice(0, 4).map(c => c.name)).map((cat: string, idx: number) => ( { {sortedEvents.map((event, index) => { const eventDate = new Date(event.date); const isEven = index % 2 === 0; + const isPast = eventDate < new Date(); return ( -
+
{/* Marker */} -
+
{/* Content Card */}
-
+
{/* Image with overlay gradient */}
{ />
- + {eventDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })} @@ -59,11 +60,12 @@ const EventTimeline = ({ events }: EventTimelineProps) => { {/* Event Details */}
-
+
{eventDate.toLocaleDateString('fr-FR', { year: 'numeric' })} + {isPast && PASSÉ}
-

+

{event.title}

@@ -78,7 +80,7 @@ const EventTimeline = ({ events }: EventTimelineProps) => {
Voir les détails @@ -89,14 +91,16 @@ const EventTimeline = ({ events }: EventTimelineProps) => {
{/* Hover glow effect */} -
+ {!isPast && ( +
+ )}
{/* Date Column (Desktop Only) */}
- + {eventDate.toLocaleDateString('fr-FR', { month: 'long' })} diff --git a/components/PricingSection.tsx b/components/PricingSection.tsx index 15f94a0..cc94916 100644 --- a/components/PricingSection.tsx +++ b/components/PricingSection.tsx @@ -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 = () => {

{plan.description}

- {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 + ) + } - {plan.priceXOF !== 'Gratuit' && /mois} + {plan.priceXOF !== 'Gratuit' && ( + + {billingCycle === 'yearly' ? '/an' : '/mois'} + + )}
{plan.priceXOF !== 'Gratuit' && ( -

soit env. {plan.priceEUR} /mois

+

+ soit env. {billingCycle === 'yearly' ? (plan.yearlyPriceEUR || plan.priceEUR) : plan.priceEUR} {billingCycle === 'yearly' ? '/an' : '/mois'} +

)}
    @@ -193,8 +207,10 @@ const PricingSection = () => { {paymentStep === 'method' && ( <>
    -

    Vous avez choisi le plan {selectedPlan.name}

    -

    {selectedPlan.priceXOF}

    +

    Vous avez choisi le plan {selectedPlan.name} ({billingCycle === 'yearly' ? 'Annuel' : 'Mensuel'})

    +

    + {billingCycle === 'yearly' ? (selectedPlan.yearlyPriceXOF || selectedPlan.priceXOF) : selectedPlan.priceXOF} +

    Moyen de paiement

    @@ -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}
- { + 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" + > + + {categories.map(c => )} +
+ {formData.category === 'Autre' && ( +
+ + +

Elle sera vérifiée par nos administrateurs avant d'être ajoutée officiellement.

+
+ )}
diff --git a/package-lock.json b/package-lock.json index 0e91733..3c67476 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5d7cf93..301d3d8 100644 --- a/package.json +++ b/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 96bbcb7..189e6fe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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,6 +266,34 @@ model MessageReport { reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade) } +enum ReportStatus { + PENDING + RESOLVED + DISMISSED +} + +enum UserRole { + VISITOR + ENTREPRENEUR + ADMIN +} + +enum Plan { + STARTER + BOOSTER + EMPIRE +} + +enum OfferType { + PRODUCT + SERVICE +} + +enum InterviewType { + VIDEO + ARTICLE +} + model SiteSetting { id String @id @default("singleton") siteName String @default("Afrohub") @@ -267,39 +317,6 @@ model SiteSetting { updatedAt DateTime @updatedAt } -enum Plan { - STARTER - BOOSTER - EMPIRE -} - -enum RatingStatus { - PENDING - APPROVED - REJECTED -} - -enum ReportStatus { - PENDING - RESOLVED - DISMISSED -} - -enum UserRole { - VISITOR - ENTREPRENEUR - ADMIN -} - -enum OfferType { - PRODUCT - SERVICE -} - -enum InterviewType { - VIDEO - ARTICLE -} model LegalDocument { id String @id @default(uuid()) type String @unique // 'CGU' or 'CGV' diff --git a/prisma/seed-categories.ts b/prisma/seed-categories.ts new file mode 100644 index 0000000..cf9dffc --- /dev/null +++ b/prisma/seed-categories.ts @@ -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(); + }); diff --git a/prisma/seed-events.ts b/prisma/seed-events.ts new file mode 100644 index 0000000..f84a833 --- /dev/null +++ b/prisma/seed-events.ts @@ -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(); + }); diff --git a/scratch/check-cats.ts b/scratch/check-cats.ts new file mode 100644 index 0000000..df776e2 --- /dev/null +++ b/scratch/check-cats.ts @@ -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(); diff --git a/scratch/migrate-categories.ts b/scratch/migrate-categories.ts new file mode 100644 index 0000000..d76ee9a --- /dev/null +++ b/scratch/migrate-categories.ts @@ -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()); diff --git a/types.ts b/types.ts index 13ee132..71402cb 100644 --- a/types.ts +++ b/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" -];