1. Système d'Analytics (Nouveauté Majeure)

Modèle de Données : Ajout de la table AnalyticsEvent dans prisma/schema.prisma pour suivre les vues de pages, les clics et le temps de rétention (DWELL_TIME).
API Backend : Création de la route app/api/analytics/route.ts pour enregistrer les événements en base de données.
Tracking Client : Création du composant AnalyticsTracker.tsx (intégré dans le layout principal) qui capture automatiquement :
Le changement de page (Page Views).
Les clics sur les boutons et liens importants.
Le temps passé sur chaque page.
2. Intégration Base de Données (PostgreSQL/Prisma)
Fiches Entreprises : Mise à jour de app/directory/[id]/page.tsx pour récupérer les données réelles depuis PostgreSQL via Prisma au lieu d'utiliser les données de test (mockData).
Navigation Dashboard : Correction du bouton "Voir ma fiche" dans le tableau de bord pour qu'il redirige correctement vers le profil public de l'entrepreneur.
3. Administration & Metrics
Nouvelle Application Admin : Initialisation d'un dossier admin/ (application Next.js séparée) destinée à la gestion de la plateforme et à la visualisation des statistiques (Metrics & Analytics).
4. Authentification & Inscription
Nouvelle Page : Création de app/register/page.tsx pour permettre l'inscription des nouveaux utilisateurs.
Gestion de Session : Amélioration de UserProvider.tsx pour une meilleure gestion de l'état utilisateur à travers l'application.
5. Maintenance & Types
Mise à jour des types globaux dans types.ts et rafraîchissement du package-lock.json suite à l'ajout de dépendances.
This commit is contained in:
2026-04-11 22:20:41 +02:00
parent d88ba7c53c
commit b227657f19
100 changed files with 14142 additions and 157 deletions

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../lib/prisma';
export async function POST(request: NextRequest) {
console.log('>>> ANALYTICS API HIT');
try {
const body = await request.json();
console.log('>>> PAYLOAD:', JSON.stringify(body));
const { type, path, label, value, metadata } = body;
if (!type || !path) {
return NextResponse.json({ error: 'Type et Path requis' }, { status: 400 });
}
const event = await prisma.analyticsEvent.create({
data: {
type,
path,
label: label || null,
value: value || null,
metadata: metadata || {},
},
});
return NextResponse.json({ success: true, id: event.id });
} catch (error) {
console.error('Error recording analytics event:', error);
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
}
}

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../lib/prisma';
import bcrypt from 'bcryptjs';
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
if (!email || !password) {
return NextResponse.json(
{ error: 'Email et mot de passe requis' },
{ status: 400 }
);
}
// Find user
const user = await prisma.user.findUnique({
where: { email }
});
if (!user) {
return NextResponse.json(
{ error: 'Identifiants invalides' },
{ status: 401 }
);
}
// Check password
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return NextResponse.json(
{ error: 'Identifiants invalides' },
{ status: 401 }
);
}
// Omit password from response
const { password: _, ...userWithoutPassword } = user;
return NextResponse.json(
{ message: 'Connexion réussie', user: userWithoutPassword },
{ status: 200 }
);
} catch (error) {
console.error('Login error:', error);
return NextResponse.json(
{ error: 'Une erreur est survenue lors de la connexion' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../lib/prisma';
import bcrypt from 'bcryptjs';
export async function POST(request: NextRequest) {
try {
const { name, email, password } = await request.json();
// Basic validation
if (!name || !email || !password) {
return NextResponse.json(
{ error: 'Tous les champs sont obligatoires' },
{ status: 400 }
);
}
if (password.length < 6) {
return NextResponse.json(
{ error: 'Le mot de passe doit contenir au moins 6 caractères' },
{ status: 400 }
);
}
// Check if user already exists
const existingUser = await prisma.user.findUnique({
where: { email }
});
if (existingUser) {
return NextResponse.json(
{ error: 'Un utilisateur avec cet email existe déjà' },
{ status: 400 }
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
const user = await prisma.user.create({
data: {
name,
email,
password: hashedPassword,
role: 'VISITOR' // Default role
}
});
// Omit password from response
const { password: _, ...userWithoutPassword } = user;
return NextResponse.json(
{ message: 'Compte créé avec succès', user: userWithoutPassword },
{ status: 201 }
);
} catch (error) {
console.error('Registration error:', error);
return NextResponse.json(
{ error: 'Une erreur est survenue lors de l\'inscription' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../../lib/prisma';
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
if (!id) {
return NextResponse.json({ error: 'ID requis' }, { status: 400 });
}
// Attempt to increment in DB
// Note: We don't care about mock businesses here as they are static
const business = await prisma.business.update({
where: { id },
data: {
viewCount: {
increment: 1,
},
},
});
return NextResponse.json({ success: true, viewCount: business.viewCount });
} catch (error) {
// If business not found in DB (might be mock), just return success but no update
console.error('Error incrementing view count:', error);
return NextResponse.json({ success: false, error: 'Business not found or error' }, { status: 404 });
}
}

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../../lib/prisma'
// GET /api/businesses/me — Fetch business for the current user
export async function GET(request: NextRequest) {
try {
const userId = request.headers.get('x-user-id')
if (!userId) {
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
}
const business = await prisma.business.findFirst({
where: { ownerId: userId },
include: { offers: true }
})
if (!business) {
return NextResponse.json(null) // Return null if no business yet
}
return NextResponse.json(business)
} catch (error) {
console.error('GET /api/businesses/me error:', error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}

View File

@@ -1,49 +1,128 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '../../../lib/prisma'
import { MOCK_BUSINESSES } from '../../../lib/mockData'
// GET /api/businesses — List all businesses
// GET /api/businesses — List all businesses (Mock + DB)
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const category = searchParams.get('category')
const q = searchParams.get('q')
const q = searchParams.get('q')?.toLowerCase()
const featured = searchParams.get('featured')
const where: Record<string, unknown> = {}
// 1. Fetch from Database
const where: any = {}
if (category && category !== 'All') where.category = category
if (featured === 'true') where.isFeatured = true
if (q) {
where.OR = [
{ name: { contains: q, mode: 'insensitive' } },
{ description: { contains: q, mode: 'insensitive' } },
{ tags: { hasSome: [q] } },
// Simplified tags check
{ tags: { has: q } }
]
}
const businesses = await prisma.business.findMany({
const dbBusinesses = await prisma.business.findMany({
where,
include: { owner: true, offers: true },
orderBy: { createdAt: 'desc' },
})
return NextResponse.json(businesses)
// 2. Filter Mock Businesses
const filteredMocks = MOCK_BUSINESSES.filter(biz => {
// Category filter
if (category && category !== 'All' && biz.category !== category) return false;
// Featured filter
if (featured === 'true' && !biz.isFeatured) return false;
// Search query filter
if (q) {
const matches =
biz.name.toLowerCase().includes(q) ||
biz.description.toLowerCase().includes(q) ||
biz.tags.some(t => t.toLowerCase().includes(q));
if (!matches) return false;
}
return true;
});
// 3. Combine and return
// Prioritize DB entries over mocks to show user-created data first
const combined = [...dbBusinesses, ...filteredMocks];
return NextResponse.json(combined)
} catch (error) {
console.error('GET /api/businesses error:', error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
// POST /api/businesses — Create a business
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const business = await prisma.business.create({
data: body,
include: { owner: true },
})
return NextResponse.json(business, { status: 201 })
const headerUserId = request.headers.get('x-user-id')
const { ownerId: bodyOwnerId, ...data } = body
const ownerId = bodyOwnerId || headerUserId
if (!ownerId) {
return NextResponse.json({ error: 'ownerId est requis (via body ou x-user-id header)' }, { status: 400 })
}
// 1. Sanitize data - ONLY pass fields that exist in the Prisma Business model
// We must exclude 'id', 'offers', 'owner', 'createdAt', 'updatedAt' from the data object
const cleanData: any = {
name: data.name || "Nouvelle Entreprise",
category: data.category || "Autre",
location: data.location || "Ma Ville",
description: data.description || "",
logoUrl: data.logoUrl || "https://picsum.photos/200/200?random=default",
videoUrl: data.videoUrl || null,
contactEmail: data.contactEmail || "",
contactPhone: data.contactPhone || null,
socialLinks: data.socialLinks || {},
verified: data.verified ?? false,
viewCount: data.viewCount ?? 0,
rating: data.rating ?? 0,
tags: Array.isArray(data.tags) ? data.tags : [],
isFeatured: data.isFeatured ?? false,
founderName: data.founderName || null,
founderImageUrl: data.founderImageUrl || null,
keyMetric: data.keyMetric || null,
}
let business;
const existing = await prisma.business.findFirst({
where: { ownerId: ownerId }
});
console.log('Upserting business for ownerId:', ownerId, 'Existing:', !!existing);
if (existing) {
// When updating, we use the sanitized cleanData and the existing record's id
business = await prisma.business.update({
where: { id: existing.id },
data: cleanData,
include: { owner: true }
});
} else {
// When creating, we add the ownerId to the sanitized cleanData
business = await prisma.business.create({
data: {
...cleanData,
ownerId: ownerId
},
include: { owner: true }
});
}
console.log('Business saved successfully:', business.id);
return NextResponse.json(business, { status: 200 })
} catch (error) {
console.error('POST /api/businesses error:', error)
return NextResponse.json({ error: 'Erreur lors de la création' }, { status: 500 })
return NextResponse.json({ error: 'Erreur lors de lenregistrement' }, { status: 500 })
}
}

View File

@@ -1,7 +1,7 @@
"use client";
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { LayoutDashboard, Edit3, ShoppingBag, CreditCard, LogOut, Eye } from 'lucide-react';
import { useRouter } from 'next/navigation';
@@ -17,13 +17,73 @@ const DashboardPage = () => {
const { user, logout } = useUser();
const router = useRouter();
const [currentView, setCurrentView] = useState<'overview' | 'profile' | 'offers' | 'subscription'>('overview');
const [business, setBusiness] = useState(MOCK_BUSINESSES[0]); // In real app, fetch by user.id
const [business, setBusiness] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
// Fetch user's business
useEffect(() => {
if (!user || !isMounted) return;
const fetchBusiness = async () => {
try {
const res = await fetch('/api/businesses/me', {
headers: { 'x-user-id': user.id }
});
const data = await res.json();
if (data && !data.error) {
setBusiness(data);
}
} catch (error) {
console.error('Error fetching dashboard business:', error);
} finally {
setLoading(false);
}
};
fetchBusiness();
}, [user, isMounted]);
if (!isMounted) return null;
if (!user) {
if (typeof window !== 'undefined') router.push('/login');
return null;
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="flex flex-col items-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-600"></div>
<p className="mt-4 text-gray-600 font-medium">Chargement de votre espace...</p>
</div>
</div>
);
}
// Default business structure for new users
const displayBusiness = business || {
id: 'new',
name: "Ma Boutique",
category: "Technologie & IT",
location: "Ma Ville",
description: "Décrivez votre entreprise ici...",
logoUrl: "https://picsum.photos/200/200?random=default",
contactEmail: user?.email || "",
contactPhone: "",
tags: [],
socialLinks: {},
verified: false,
viewCount: 0,
rating: 0,
offers: []
};
return (
<div className="min-h-screen bg-gray-100 flex">
{/* 1. Sidebar */}
@@ -88,7 +148,7 @@ const DashboardPage = () => {
{currentView === 'subscription' && 'Mon Abonnement'}
</h1>
<div className="flex items-center space-x-4">
<a href={`/directory`} target="_blank" rel="noopener noreferrer" className="hidden sm:inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none">
<a href={displayBusiness.id === 'new' ? '/directory' : `/directory/${displayBusiness.id}`} target="_blank" rel="noopener noreferrer" className="hidden sm:inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none">
<Eye className="-ml-1 mr-2 h-4 w-4 text-gray-500" />
Voir ma fiche
</a>
@@ -97,8 +157,8 @@ const DashboardPage = () => {
<main className="flex-1 overflow-y-auto p-6">
<div className="max-w-7xl mx-auto">
{currentView === 'overview' && <DashboardOverview business={business} />}
{currentView === 'profile' && <DashboardProfile business={business} setBusiness={setBusiness} />}
{currentView === 'overview' && <DashboardOverview business={displayBusiness} />}
{currentView === 'profile' && <DashboardProfile business={displayBusiness} setBusiness={setBusiness} />}
{currentView === 'offers' && <DashboardOffers />}
{currentView === 'subscription' && (
<div className="space-y-6">

View File

@@ -6,11 +6,12 @@ import Link from 'next/link';
import { useParams } from 'next/navigation';
import { ArrowLeft, MapPin, Globe, Mail, Phone, CheckCircle, Star, Facebook, Linkedin, Instagram, Play, Share2, Send, Award, Quote } from 'lucide-react';
import { MOCK_BUSINESSES, MOCK_OFFERS } from '../../../lib/mockData';
import { OfferType } from '../../../types';
import { Business, OfferType } from '../../../types';
const BusinessDetailPage = () => {
const { id } = useParams();
const business = MOCK_BUSINESSES.find(b => b.id === id);
const [business, setBusiness] = useState<Business | null>(null);
const [loading, setLoading] = useState(true);
const [contactForm, setContactForm] = useState({
name: '',
@@ -20,13 +21,59 @@ const BusinessDetailPage = () => {
const [formStatus, setFormStatus] = useState<'idle' | 'sending' | 'success'>('idle');
const businessOffers = useMemo(() => {
if (!business) return [];
// If the business has its own offers (from DB), use them
if (business.offers && business.offers.length > 0) return business.offers;
// Otherwise fallback to mock offers for mock businesses
return MOCK_OFFERS.filter(o => o.businessId === id && o.active);
}, [id]);
}, [business, id]);
useEffect(() => {
window.scrollTo(0, 0);
const loadBusiness = async () => {
setLoading(true);
// 1. Try Mock Data First (Instant)
const mock = MOCK_BUSINESSES.find(b => b.id === id);
if (mock) {
setBusiness(mock);
setLoading(false);
return;
}
// 2. Try API (Database)
try {
const res = await fetch(`/api/businesses/${id}`);
if (res.ok) {
const data = await res.json();
setBusiness(data);
// 3. Increment views silently
fetch(`/api/businesses/${id}/view`, { method: 'POST' }).catch(console.error);
} else {
setBusiness(null);
}
} catch (err) {
console.error("Error fetching business:", err);
setBusiness(null);
} finally {
setLoading(false);
}
};
if (id) loadBusiness();
}, [id]);
if (loading) {
return (
<div className="min-h-[60vh] flex flex-col items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-brand-600 mb-4"></div>
<p className="text-gray-500">Chargement du profil...</p>
</div>
);
}
if (!business) {
return (
<div className="min-h-[60vh] flex flex-col items-center justify-center">

View File

@@ -3,39 +3,70 @@
import React, { useState, useEffect, useMemo, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { Search } from 'lucide-react';
import { MOCK_BUSINESSES } from '../../lib/mockData';
import { CATEGORIES } from '../../types';
import { Search, Loader2 } from 'lucide-react';
import { CATEGORIES, Business } from '../../types';
import BusinessCard from '../../components/BusinessCard';
import DirectoryHero from '../../components/DirectoryHero';
const DirectoryPageContent = () => {
const [businesses, setBusinesses] = useState<Business[]>([]);
const [featuredBusiness, setFeaturedBusiness] = useState<Business | null>(null);
const [loading, setLoading] = useState(true);
const [filterCategory, setFilterCategory] = useState('All');
const [searchQuery, setSearchQuery] = useState('');
const searchParams = useSearchParams();
// 1. Fetch Featured Headliner once
useEffect(() => {
const fetchHeadliner = async () => {
try {
const res = await fetch('/api/businesses?featured=true');
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
setFeaturedBusiness(data[0]);
}
} catch (error) {
console.error('Error fetching headliner:', error);
}
};
fetchHeadliner();
}, []);
// 2. Fetch businesses from API
useEffect(() => {
const fetchBusinesses = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filterCategory !== 'All') params.append('category', filterCategory);
if (searchQuery) params.append('q', searchQuery);
const res = await fetch(`/api/businesses?${params.toString()}`);
const data = await res.json();
if (Array.isArray(data)) {
setBusinesses(data);
}
} catch (error) {
console.error('Error fetching businesses:', error);
} finally {
setLoading(false);
}
};
const timeoutId = setTimeout(fetchBusinesses, 300); // Debounce
return () => clearTimeout(timeoutId);
}, [filterCategory, searchQuery]);
useEffect(() => {
const q = searchParams.get('q');
if (q) setSearchQuery(q);
}, [searchParams]);
const featuredBusiness = useMemo(() => {
return MOCK_BUSINESSES.find(b => b.isFeatured);
}, []);
const filteredBusinesses = useMemo(() => {
return MOCK_BUSINESSES.filter(b => {
const matchesCategory = filterCategory === 'All' || b.category === filterCategory;
const matchesSearch = b.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
b.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
b.tags.some(t => t.toLowerCase().includes(searchQuery.toLowerCase()));
return matchesCategory && matchesSearch;
});
}, [filterCategory, searchQuery]);
const filteredBusinesses = businesses;
return (
<div>
{/* New Hero Section */}
{/* Tête d'affiche (Headliner) - Always visible regardless of filters */}
{featuredBusiness && <DirectoryHero featuredBusiness={featuredBusiness} />}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
@@ -95,7 +126,12 @@ const DirectoryPageContent = () => {
<span className="text-sm text-gray-500">{filteredBusinesses.length} résultats</span>
</div>
{filteredBusinesses.length > 0 ? (
{loading ? (
<div className="flex flex-col items-center justify-center py-20 bg-white rounded-xl border border-gray-100 shadow-sm">
<Loader2 className="w-12 h-12 text-brand-600 animate-spin mb-4" />
<p className="text-gray-500 font-medium">Chargement des entreprises...</p>
</div>
) : filteredBusinesses.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredBusinesses.map(biz => (
<BusinessCard key={biz.id} business={biz} />

View File

@@ -3,6 +3,7 @@ import Script from 'next/script';
import Navbar from '../components/Navbar';
import Footer from '../components/Footer';
import { UserProvider } from '../components/UserProvider';
import AnalyticsTracker from '../components/AnalyticsTracker';
import { Metadata } from 'next';
import './globals.css';
@@ -20,6 +21,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</head>
<body className="bg-gray-50 text-gray-900 antialiased min-h-screen flex flex-col font-sans">
<UserProvider>
<AnalyticsTracker />
<Navbar />
<main className="flex-grow">
{children}

View File

@@ -3,19 +3,43 @@
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useUser } from '../../components/UserProvider';
const LoginPage = () => {
const { login } = useUser();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Mock login
login();
router.push('/dashboard');
setLoading(true);
setError('');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Erreur lors de la connexion');
}
// Real login with user data from DB
login(data.user);
router.push('/dashboard');
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
@@ -25,10 +49,15 @@ const LoginPage = () => {
<div className="mx-auto h-12 w-12 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold text-2xl">A</div>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 font-serif">Connexion à votre espace</h2>
<p className="mt-2 text-sm text-gray-600">
Ou <a href="#" className="font-medium text-brand-600 hover:text-brand-500">créez votre compte entreprise pour 1</a>
Ou <Link href="/register" className="font-medium text-brand-600 hover:text-brand-500">créez votre compte entreprise pour 1</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded text-red-700 text-sm">
{error}
</div>
)}
<div className="rounded-md shadow-sm -space-y-px">
<div>
<input
@@ -52,13 +81,14 @@ const LoginPage = () => {
</div>
</div>
<div>
<button type="submit" className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500">
Se connecter
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 disabled:opacity-70"
>
{loading ? 'Connexion...' : 'Se connecter'}
</button>
</div>
<div className="text-sm text-center text-gray-500">
(Compte démo : n'importe quel email/mdp fonctionne)
</div>
</form>
</div>
</div>

View File

@@ -1,17 +1,34 @@
"use client";
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Search, MapPin, Briefcase, TrendingUp } from 'lucide-react';
import { CATEGORIES } from '../types';
import { MOCK_BUSINESSES } from '../lib/mockData';
import { Search, MapPin, Briefcase, TrendingUp, Loader2 } from 'lucide-react';
import { CATEGORIES, Business } from '../types';
import BusinessCard from '../components/BusinessCard';
const HomePage = () => {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState('');
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchFeatured = async () => {
try {
const res = await fetch('/api/businesses?featured=true');
const data = await res.json();
if (Array.isArray(data)) {
setFeaturedBusinesses(data.slice(0, 4)); // Show top 4
}
} catch (error) {
console.error('Error fetching featured businesses:', error);
} finally {
setLoading(false);
}
};
fetchFeatured();
}, []);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
@@ -23,7 +40,6 @@ const HomePage = () => {
{/* Hero Section */}
<div className="relative bg-dark-900 overflow-hidden">
<div className="absolute inset-0 opacity-40">
{/* UPDATED IMAGE: Group of diverse/African professionals */}
<img src="https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1740&q=80" className="w-full h-full object-cover" alt="African entrepreneurs team" />
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32">
@@ -84,11 +100,18 @@ const HomePage = () => {
Voir tout l'annuaire <TrendingUp className="w-4 h-4 ml-1" />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{MOCK_BUSINESSES.map(biz => (
<BusinessCard key={biz.id} business={biz} />
))}
</div>
{loading ? (
<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-4 gap-6">
{featuredBusinesses.map(biz => (
<BusinessCard key={biz.id} business={biz} />
))}
</div>
)}
</div>
</div>
</div>

203
app/register/page.tsx Normal file
View File

@@ -0,0 +1,203 @@
"use client";
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { User, Mail, Lock, ArrowRight, CheckCircle, AlertCircle } from 'lucide-react';
const RegisterPage = () => {
const router = useRouter();
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: ''
});
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
setError('');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
if (formData.password !== formData.confirmPassword) {
setError('Les mots de passe ne correspondent pas');
setLoading(false);
return;
}
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name,
email: formData.email,
password: formData.password
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Une erreur est survenue");
}
setSuccess(true);
setTimeout(() => {
router.push('/login');
}, 2000);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8 bg-[radial-gradient(circle_at_top_right,_var(--color-brand-100),_transparent_40%),_radial-gradient(circle_at_bottom_left,_var(--color-brand-50),_transparent_40%)]">
<div className="max-w-md w-full">
<div className="text-center mb-10">
<Link href="/" className="inline-flex items-center justify-center h-16 w-16 bg-brand-600 rounded-2xl shadow-lg shadow-brand-200 text-white font-bold text-3xl mb-6 hover:scale-105 transition-transform">
A
</Link>
<h2 className="text-4xl font-extrabold text-dark-900 font-serif mb-2">Rejoignez l'aventure</h2>
<p className="text-gray-600">Créez votre compte pour propulser votre entreprise</p>
</div>
<div className="bg-white/80 backdrop-blur-xl p-8 rounded-3xl shadow-2xl border border-white shadow-brand-100/50">
{success ? (
<div className="text-center py-10 animate-in fade-in zoom-in duration-500">
<div className="inline-flex items-center justify-center h-20 w-20 bg-green-100 text-green-600 rounded-full mb-6">
<CheckCircle size={40} />
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-2">Compte créé !</h3>
<p className="text-gray-600">Redirection vers la page de connexion...</p>
</div>
) : (
<form className="space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded-r-lg flex items-center gap-3 text-red-700 animate-in slide-in-from-top-2 duration-300">
<AlertCircle size={20} className="shrink-0" />
<p className="text-sm font-medium">{error}</p>
</div>
)}
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-semibold text-gray-700 ml-1">Nom complet</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-brand-600 transition-colors">
<User size={18} />
</div>
<input
name="name"
type="text"
required
className="block w-full pl-10 pr-3 py-3 border border-gray-200 rounded-xl bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 transition-all text-gray-900 placeholder-gray-400"
placeholder="Ex: Jean Dupont"
value={formData.name}
onChange={handleChange}
/>
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-semibold text-gray-700 ml-1">Adresse email</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-brand-600 transition-colors">
<Mail size={18} />
</div>
<input
name="email"
type="email"
required
className="block w-full pl-10 pr-3 py-3 border border-gray-200 rounded-xl bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 transition-all text-gray-900 placeholder-gray-400"
placeholder="votre@email.com"
value={formData.email}
onChange={handleChange}
/>
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-semibold text-gray-700 ml-1">Mot de passe</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-brand-600 transition-colors">
<Lock size={18} />
</div>
<input
name="password"
type="password"
required
className="block w-full pl-10 pr-3 py-3 border border-gray-200 rounded-xl bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 transition-all text-gray-900 placeholder-gray-400"
placeholder="••••••••"
value={formData.password}
onChange={handleChange}
/>
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-semibold text-gray-700 ml-1">Confirmer le mot de passe</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-brand-600 transition-colors">
<Lock size={18} />
</div>
<input
name="confirmPassword"
type="password"
required
className="block w-full pl-10 pr-3 py-3 border border-gray-200 rounded-xl bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 transition-all text-gray-900 placeholder-gray-400"
placeholder="••••••••"
value={formData.confirmPassword}
onChange={handleChange}
/>
</div>
</div>
</div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center items-center py-3.5 px-4 border border-transparent text-sm font-bold rounded-xl text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 transition-all shadow-lg shadow-brand-200 disabled:opacity-70 disabled:cursor-not-allowed overflow-hidden"
>
{loading ? (
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
S'inscrire
<ArrowRight className="ml-2 group-hover:translate-x-1 transition-transform" size={18} />
</>
)}
</button>
<div className="text-center mt-6">
<p className="text-gray-600 text-sm">
Déjà un compte ?{' '}
<Link href="/login" className="font-bold text-brand-600 hover:text-brand-700 transition-colors underline-offset-4 hover:underline">
Se connecter
</Link>
</p>
</div>
</form>
)}
</div>
<p className="mt-8 text-center text-xs text-gray-400 uppercase tracking-widest font-semibold">
Afropreunariat &copy; 2026
</p>
</div>
</div>
);
};
export default RegisterPage;