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

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 || "",

View File

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

View File

@@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { 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<Business[]>([]);
const [posts, setPosts] = useState<BlogPost[]>([]);
const [categories, setCategories] = useState<any[]>([]);
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 = () => {
<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">
{(settings?.homeCategories || CATEGORIES.slice(0, 4)).map((cat: string, idx: number) => (
{(settings?.homeCategories || categories.slice(0, 4).map(c => c.name)).map((cat: string, idx: number) => (
<Link
key={idx}
href={`/annuaire?category=${encodeURIComponent(cat)}`}