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:
30
app/api/analytics/route.ts
Normal file
30
app/api/analytics/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
53
app/api/auth/login/route.ts
Normal file
53
app/api/auth/login/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
64
app/api/auth/register/route.ts
Normal file
64
app/api/auth/register/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
32
app/api/businesses/[id]/view/route.ts
Normal file
32
app/api/businesses/[id]/view/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
27
app/api/businesses/me/route.ts
Normal file
27
app/api/businesses/me/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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 l’enregistrement' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
45
app/page.tsx
45
app/page.tsx
@@ -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
203
app/register/page.tsx
Normal 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 © 2026
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
Reference in New Issue
Block a user