feat: implement core platform features including business directory, admin dashboard, authentication, and API infrastructure
Some checks failed
Build and Push App / build (push) Failing after 16m2s

This commit is contained in:
2026-04-18 22:10:19 +02:00
parent 6ec1a3ae84
commit 3e2063e4fa
89 changed files with 4405 additions and 967 deletions

View File

@@ -1,15 +1,16 @@
"use client";
import React, { useState, useEffect } from 'react';
import { PlusCircle, Edit3, Trash2, ShoppingBag, Image as ImageIcon, Loader2 } from 'lucide-react';
import { PlusCircle, Edit3, Trash2, ShoppingBag, Image as ImageIcon, Loader2, AlertCircle } from 'lucide-react';
import { Offer, OfferType } from '../../types';
import { toast } from 'react-hot-toast';
interface DashboardOffersProps {
businessId: string;
plan?: string;
}
const DashboardOffers = ({ businessId }: DashboardOffersProps) => {
const DashboardOffers = ({ businessId, plan = 'STARTER' }: DashboardOffersProps) => {
const [offers, setOffers] = useState<Offer[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -155,19 +156,42 @@ const DashboardOffers = ({ businessId }: DashboardOffersProps) => {
);
}
const offerLimit = plan === 'BOOSTER' ? 10 : (plan === 'EMPIRE' ? 1000 : 1);
const isLimitReached = offers.length >= offerLimit;
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold font-serif text-gray-900">Mes Offres</h2>
<div className="flex flex-col">
<h2 className="text-2xl font-bold font-serif text-gray-900">Mes Offres</h2>
<p className="text-xs text-gray-500 mt-1">
Utilisation : <span className="font-bold">{offers.length} / {offerLimit === 1000 ? 'Illimité' : offerLimit}</span>
</p>
</div>
<button
onClick={openAddModal}
className="flex items-center bg-brand-600 text-white px-4 py-2 rounded-md hover:bg-brand-700 font-medium text-sm shadow-sm transition-colors active:scale-95"
disabled={isLimitReached}
className={`flex items-center px-4 py-2 rounded-md font-medium text-sm shadow-sm transition-colors active:scale-95 ${isLimitReached ? 'bg-gray-300 text-gray-500 cursor-not-allowed' : 'bg-brand-600 text-white hover:bg-brand-700'}`}
>
<PlusCircle className="w-4 h-4 mr-2" />
Ajouter une offre
</button>
</div>
{isLimitReached && (
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-orange-600" />
<p className="text-sm text-orange-800">
Vous avez atteint la limite de votre plan <strong>{plan}</strong>.
</p>
</div>
<button className="text-xs font-bold text-brand-600 hover:text-brand-700 uppercase tracking-tight">
Passer au plan supérieur →
</button>
</div>
)}
{/* List View */}
{offers.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">

View File

@@ -3,61 +3,100 @@ import React from 'react';
import { Eye, Star } from 'lucide-react';
import { Business } from '../../types';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
BarChart,
Bar
} from 'recharts';
const MousePointerClick = ({className}: {className?:string}) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M14 4.1 12 6" /><path d="m5.1 8-2.9-.8" /><path d="m6 12-1.9 2" /><path d="M7.2 2.2 8 5.1" /><path d="M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z" />
</svg>
);
const DashboardOverview = ({ business, stats }: { business: Business, stats: { totalViews: number, contactClicks: number } | null }) => (
interface DayStat {
date: string;
views: number;
clicks: number;
}
const CustomTooltip = ({ active, payload, label, color }: any) => {
if (active && payload && payload.length) {
return (
<div className="bg-white p-3 shadow-xl border border-gray-100 rounded-lg">
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-1">{label}</p>
<p className="text-lg font-bold" style={{ color }}>
{payload[0].value} {payload[0].name === 'views' ? 'Vues' : 'Clics'}
</p>
</div>
);
}
return null;
};
const DashboardOverview = ({ business, stats }: {
business: Business,
stats: {
totalViews: number,
contactClicks: number,
dailyStats: DayStat[]
} | null
}) => (
<div className="space-y-6">
<h2 className="text-2xl font-bold font-serif text-gray-900">Tableau de bord</h2>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-3">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="bg-white overflow-hidden shadow-sm border border-gray-100 rounded-xl">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0 bg-brand-100 rounded-md p-3">
<div className="flex-shrink-0 bg-brand-50 rounded-lg p-3">
<Eye className="h-6 w-6 text-brand-600" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Vues de la fiche</dt>
<dd>
<div className="text-lg font-medium text-gray-900">{stats ? stats.totalViews : business.viewCount}</div>
<dd className="flex items-baseline">
<div className="text-2xl font-bold text-gray-900">{stats ? stats.totalViews : business.viewCount}</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="bg-white overflow-hidden shadow-sm border border-gray-100 rounded-xl">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0 bg-blue-100 rounded-md p-3">
<div className="flex-shrink-0 bg-blue-50 rounded-lg p-3">
<MousePointerClick className="h-6 w-6 text-blue-600" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Clics Contact</dt>
<dd>
<div className="text-lg font-medium text-gray-900">{stats ? stats.contactClicks : '...'}</div>
<dd className="flex items-baseline">
<div className="text-2xl font-bold text-gray-900">{stats ? stats.contactClicks : '...'}</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="bg-white overflow-hidden shadow-sm border border-gray-100 rounded-xl">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0 bg-yellow-100 rounded-md p-3">
<div className="flex-shrink-0 bg-yellow-50 rounded-lg p-3">
<Star className="h-6 w-6 text-yellow-600" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Note moyenne</dt>
<dd>
<div className="text-lg font-medium text-gray-900">{business.rating}/5</div>
<dd className="flex items-baseline">
<div className="text-2xl font-bold text-gray-900">{business.rating}/5</div>
</dd>
</dl>
</div>
@@ -66,11 +105,90 @@ const DashboardOverview = ({ business, stats }: { business: Business, stats: { t
</div>
</div>
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">Performances 30 derniers jours</h3>
<div className="h-64 bg-gray-50 rounded border border-dashed border-gray-200 flex items-center justify-center text-gray-400">
Graphique des visites (À venir en Phase 2)
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white shadow-sm border border-gray-100 rounded-xl p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">Historique des Visites</h3>
<div className="px-2 py-1 bg-brand-50 text-brand-700 text-[10px] font-bold uppercase rounded leading-none">30 derniers jours</div>
</div>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={stats?.dailyStats || []}>
<defs>
<linearGradient id="colorViews" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#d97706" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#d97706" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{fill: '#9ca3af', fontSize: 10}}
interval={6}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{fill: '#9ca3af', fontSize: 10}}
/>
<Tooltip content={<CustomTooltip color="#d97706" />} />
<Area
type="monotone"
dataKey="views"
name="views"
stroke="#d97706"
strokeWidth={3}
fillOpacity={1}
fill="url(#colorViews)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
<div className="bg-white shadow-sm border border-gray-100 rounded-xl p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">Interactions Contact</h3>
<div className="px-2 py-1 bg-blue-50 text-blue-700 text-[10px] font-bold uppercase rounded leading-none">30 derniers jours</div>
</div>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={stats?.dailyStats || []}>
<defs>
<linearGradient id="colorClicks" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#2563eb" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#2563eb" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{fill: '#9ca3af', fontSize: 10}}
interval={6}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{fill: '#9ca3af', fontSize: 10}}
/>
<Tooltip content={<CustomTooltip color="#2563eb" />} />
<Area
type="monotone"
dataKey="clicks"
name="clicks"
stroke="#2563eb"
strokeWidth={3}
fillOpacity={1}
fill="url(#colorClicks)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
);

View File

@@ -3,7 +3,7 @@
import React, { useState } from 'react';
import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram, CheckCircle, AlertCircle } from 'lucide-react';
import { Business, CATEGORIES } from '../../types';
import { Business, CATEGORIES, Country } from '../../types';
import { generateBusinessDescription } from '../../lib/geminiService';
import { toast } from 'react-hot-toast';
import { useUser } from '../UserProvider';
@@ -15,17 +15,56 @@ const getYouTubeId = (url: string) => {
return (match && match[2].length === 11) ? match[2] : null;
};
const normalizeBusinessData = (b: Business): Business => ({
...b,
name: b.name || 'Nouvelle Entreprise',
category: b.category || CATEGORIES[0],
description: b.description || '',
location: b.location || '',
countryId: b.countryId || '',
city: b.city || '',
contactEmail: b.contactEmail || '',
showEmail: b.showEmail ?? true,
showPhone: b.showPhone ?? true,
showSocials: b.showSocials ?? true,
slug: b.slug || '',
videoUrl: b.videoUrl || '',
contactPhone: b.contactPhone || '',
websiteUrl: b.websiteUrl || '',
socialLinks: {
facebook: b.socialLinks?.facebook || '',
linkedin: b.socialLinks?.linkedin || '',
instagram: b.socialLinks?.instagram || '',
website: b.socialLinks?.website || '',
}
});
const DashboardProfile = ({ business, setBusiness }: { business: Business, setBusiness: (b: Business) => void }) => {
const { login } = useUser();
const [formData, setFormData] = useState(business);
const [formData, setFormData] = useState<Business>(normalizeBusinessData(business));
const [countries, setCountries] = useState<Country[]>([]);
const [videoInput, setVideoInput] = useState(business.videoUrl || '');
const [videoPreviewId, setVideoPreviewId] = useState<string | null>(getYouTubeId(business.videoUrl || ''));
const [isGenerating, setIsGenerating] = useState(false);
const [isMounted, setIsMounted] = useState(false);
// Fetch countries
React.useEffect(() => {
const fetchCountries = async () => {
try {
const res = await fetch('/api/countries');
const data = await res.json();
if (!data.error) setCountries(data);
} catch (error) {
console.error('Error fetching countries:', error);
}
};
fetchCountries();
}, []);
// Sync formData when business prop changes (initial fetch)
React.useEffect(() => {
setFormData(business);
setFormData(normalizeBusinessData(business));
setVideoInput(business.videoUrl || '');
setVideoPreviewId(getYouTubeId(business.videoUrl || ''));
}, [business]);
@@ -91,6 +130,38 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
}
};
const handleDelete = async () => {
if (!window.confirm("⚠️ ATTENTION : Êtes-vous sûr de vouloir supprimer définitivement votre boutique ? Cette action supprimera également vos offres, vos messages et vos statistiques. Cette action est irréversible.")) {
return;
}
try {
const res = await fetch(`/api/businesses/${business.id}`, {
method: 'DELETE',
headers: {
'x-user-id': business.ownerId
}
});
const data = await res.json();
if (data.success) {
toast.success('Boutique supprimée avec succès.');
// Upgrade local storage user if role changed
const localUser = JSON.parse(localStorage.getItem('afro_user') || '{}');
if (localUser.id === business.ownerId) {
localUser.role = 'VISITOR';
localStorage.setItem('afro_user', JSON.stringify(localUser));
if (login) login(localUser);
}
setTimeout(() => window.location.href = '/dashboard', 1500);
} else {
toast.error(data.error || 'Erreur lors de la suppression');
}
} catch (error) {
console.error('Delete error:', error);
toast.error('Erreur réseau lors de la suppression');
}
};
const isNameOk = formData.name && formData.name !== "Nouvelle Entreprise";
const isDescOk = formData.description && formData.description.length >= 20;
const isLocOk = formData.location && formData.location !== "Ma Ville";
@@ -254,17 +325,35 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center"><Globe className="w-5 h-5 mr-2 text-blue-500" /> Coordonnées & Réseaux</h3>
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div className="sm:col-span-6">
<label className="block text-sm font-medium text-gray-700 mb-1">Localisation (Ville, Pays)</label>
<div className="sm:col-span-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Pays</label>
<select
name="countryId"
value={formData.countryId || ''}
onChange={handleInputChange}
className="block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm"
>
<option value="">Sélectionner un pays</option>
{countries.map(c => (
<option key={c.id} value={c.id}>{c.flag} {c.name}</option>
))}
</select>
</div>
<div className="sm:col-span-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Ville</label>
<input
type="text"
name="location"
value={formData.location}
name="city"
value={formData.city || ''}
onChange={handleInputChange}
placeholder="Ex: Abidjan, Côte d'Ivoire"
placeholder="Ex: Abidjan"
className="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"
/>
</div>
<div className="sm:col-span-6 hidden">
{/* Keep legacy field for compatibility if needed, but hide it */}
<input type="hidden" name="location" value={`${formData.city || ''}, ${countries.find(c => c.id === formData.countryId)?.name || ''}`} />
</div>
<div className="sm:col-span-3">
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-gray-700">Email Contact</label>
@@ -335,6 +424,29 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
</div>
</div>
</div>
{/* D. Zone de Danger */}
<div className="bg-red-50 border border-red-200 shadow-sm rounded-lg p-6">
<div className="flex items-center gap-2 mb-4 text-red-800">
<AlertCircle className="w-5 h-5" />
<h3 className="text-lg font-bold">Zone de Danger</h3>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<p className="text-sm font-bold text-red-900">Supprimer définitivement la boutique</p>
<p className="text-xs text-red-700 mt-1">
Une fois supprimée, votre boutique ne sera plus visible dans l'annuaire.
Toutes vos données (offres, messages, avis) seront perdues.
</p>
</div>
<button
onClick={handleDelete}
className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 font-bold text-sm shadow-sm transition-all active:scale-95 whitespace-nowrap"
>
Supprimer ma boutique
</button>
</div>
</div>
</div>
);
};