- Update sidebar and header with off-white (#faf8f5) background - Add ticket stats endpoint integration for global counts - Redesign tirages page with animation and improved layout - Add red accent color for admin avatar - Update various button styles and remove unnecessary elements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
506 lines
19 KiB
TypeScript
506 lines
19 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { Card } from '@/components/ui/Card';
|
|
import Button from '@/components/Button';
|
|
import toast from 'react-hot-toast';
|
|
import { API_BASE_URL } from '@/utils/constants';
|
|
import {
|
|
Mail,
|
|
Download,
|
|
Users,
|
|
TrendingUp,
|
|
MapPin,
|
|
UserCheck,
|
|
Gift,
|
|
BarChart3,
|
|
Filter,
|
|
FileText,
|
|
} from 'lucide-react';
|
|
import {
|
|
PieChart,
|
|
Pie,
|
|
Cell,
|
|
BarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
Legend,
|
|
ResponsiveContainer,
|
|
} from 'recharts';
|
|
|
|
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
|
|
|
|
interface MarketingStats {
|
|
totalClients: number;
|
|
activeParticipants: number;
|
|
inactiveParticipants: number;
|
|
winners: number;
|
|
nonWinners: number;
|
|
byCity: Array<{ city: string; count: number }>;
|
|
byAge: Array<{ range: string; count: number }>;
|
|
byGender: Array<{ gender: string; count: number }>;
|
|
}
|
|
|
|
export default function MarketingPage() {
|
|
const [stats, setStats] = useState<MarketingStats | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [exportLoading, setExportLoading] = useState(false);
|
|
|
|
// Filtres d'export
|
|
const [selectedSegment, setSelectedSegment] = useState<string>('all');
|
|
const [filters, setFilters] = useState({
|
|
verified: false,
|
|
city: '',
|
|
gender: '',
|
|
});
|
|
|
|
useEffect(() => {
|
|
loadMarketingStats();
|
|
}, []);
|
|
|
|
const loadMarketingStats = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
|
|
|
|
const response = await fetch(
|
|
`${API_BASE_URL}/admin/marketing/stats`,
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
let errorMessage = 'Erreur lors du chargement';
|
|
try {
|
|
const errorData = await response.json();
|
|
errorMessage = errorData.error || errorData.message || errorMessage;
|
|
} catch {
|
|
// Ignore parse error
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
const data = await response.json();
|
|
setStats(data.data);
|
|
} catch (error: any) {
|
|
console.error('Error loading marketing stats:', error);
|
|
toast.error(error.message || 'Erreur lors du chargement des statistiques marketing');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const exportSegmentData = async () => {
|
|
try {
|
|
setExportLoading(true);
|
|
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
|
|
|
|
// Construire les critères selon le segment sélectionné
|
|
const criteria: any = {};
|
|
|
|
if (selectedSegment === 'active') {
|
|
criteria.hasPlayed = true;
|
|
} else if (selectedSegment === 'inactive') {
|
|
criteria.hasPlayed = false;
|
|
} else if (selectedSegment === 'winners') {
|
|
criteria.hasWon = true;
|
|
} else if (selectedSegment === 'non-winners') {
|
|
criteria.hasPlayed = true;
|
|
criteria.hasWon = false;
|
|
}
|
|
|
|
const response = await fetch(
|
|
`${API_BASE_URL}/admin/marketing/export`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({
|
|
criteria,
|
|
filters: {
|
|
verified: filters.verified || undefined,
|
|
city: filters.city || undefined,
|
|
gender: filters.gender || undefined,
|
|
},
|
|
}),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) throw new Error('Erreur lors de l\'export');
|
|
|
|
const data = await response.json();
|
|
const users = data.data;
|
|
|
|
// Créer le CSV
|
|
const csvContent = [
|
|
[
|
|
'Email',
|
|
'Prénom',
|
|
'Nom',
|
|
'Téléphone',
|
|
'Ville',
|
|
'Code Postal',
|
|
'Genre',
|
|
'Âge',
|
|
'A joué',
|
|
'A gagné',
|
|
'Nombre de tickets',
|
|
],
|
|
...users.map((user: any) => [
|
|
user.email,
|
|
user.first_name || '',
|
|
user.last_name || '',
|
|
user.phone || '',
|
|
user.city || '',
|
|
user.postal_code || '',
|
|
user.gender || '',
|
|
user.age || '',
|
|
user.has_played ? 'Oui' : 'Non',
|
|
user.has_won ? 'Oui' : 'Non',
|
|
user.ticket_count || 0,
|
|
]),
|
|
]
|
|
.map((row) => row.join(','))
|
|
.join('\n');
|
|
|
|
// Télécharger le fichier
|
|
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const link = document.createElement('a');
|
|
link.href = URL.createObjectURL(blob);
|
|
link.download = `export-marketing-${selectedSegment}-${new Date().toISOString().split('T')[0]}.csv`;
|
|
link.click();
|
|
|
|
toast.success(`✅ ${users.length} contacts exportés avec succès!`);
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Erreur lors de l\'export');
|
|
} finally {
|
|
setExportLoading(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
<p className="text-gray-600">Chargement des données marketing...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!stats) {
|
|
return (
|
|
<div className="p-6">
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-800">
|
|
Erreur lors du chargement des statistiques marketing
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Filtrer les données vides/non spécifiées pour les graphiques
|
|
const filteredGenderData = stats.byGender.filter(
|
|
(g) => g.gender && !g.gender.toLowerCase().includes('not_specified') && !g.gender.toLowerCase().includes('non spécifié') && g.count > 0
|
|
);
|
|
|
|
const filteredAgeData = stats.byAge.filter(
|
|
(a) => a.range && !a.range.toLowerCase().includes('non spécifié') && a.count > 0
|
|
);
|
|
|
|
const filteredCityData = stats.byCity.filter(
|
|
(c) => c.city && c.city.trim() !== '' && !c.city.toLowerCase().includes('non spécifié') && c.count > 0
|
|
);
|
|
|
|
// Vérifier s'il y a des données démographiques valides
|
|
const hasDemographicData = filteredGenderData.length > 0 || filteredAgeData.length > 0 || filteredCityData.length > 0;
|
|
|
|
return (
|
|
<div className="min-h-full bg-gradient-to-br from-gray-50 via-white to-gray-50 p-8">
|
|
{/* En-tête */}
|
|
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 rounded-2xl p-6 mb-8">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-14 h-14 bg-white/20 rounded-xl flex items-center justify-center">
|
|
<Mail className="w-7 h-7 text-white" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-white">
|
|
Données Marketing
|
|
</h1>
|
|
<p className="text-indigo-200">
|
|
Statistiques et export des données pour vos campagnes d'emailing
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Statistiques globales */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
|
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-2xl p-6 border border-blue-200">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-blue-600 font-medium">Total Clients</p>
|
|
<p className="text-3xl font-bold text-blue-700 mt-1">
|
|
{stats.totalClients.toLocaleString()}
|
|
</p>
|
|
</div>
|
|
<div className="w-14 h-14 bg-blue-200 rounded-xl flex items-center justify-center">
|
|
<Users className="w-7 h-7 text-blue-600" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gradient-to-br from-green-50 to-green-100 rounded-2xl p-6 border border-green-200">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-green-600 font-medium">Participants Actifs</p>
|
|
<p className="text-3xl font-bold text-green-700 mt-1">
|
|
{stats.activeParticipants.toLocaleString()}
|
|
</p>
|
|
<p className="text-xs text-green-600 mt-1">
|
|
{((stats.activeParticipants / stats.totalClients) * 100).toFixed(1)}% du total
|
|
</p>
|
|
</div>
|
|
<div className="w-14 h-14 bg-green-200 rounded-xl flex items-center justify-center">
|
|
<UserCheck className="w-7 h-7 text-green-600" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gradient-to-br from-purple-50 to-purple-100 rounded-2xl p-6 border border-purple-200">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-purple-600 font-medium">Gagnants</p>
|
|
<p className="text-3xl font-bold text-purple-700 mt-1">
|
|
{stats.winners.toLocaleString()}
|
|
</p>
|
|
<p className="text-xs text-purple-600 mt-1">
|
|
{stats.activeParticipants > 0 ? ((stats.winners / stats.activeParticipants) * 100).toFixed(1) : 0}% de conversion
|
|
</p>
|
|
</div>
|
|
<div className="w-14 h-14 bg-purple-200 rounded-xl flex items-center justify-center">
|
|
<Gift className="w-7 h-7 text-purple-600" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gradient-to-br from-orange-50 to-orange-100 rounded-2xl p-6 border border-orange-200">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-orange-600 font-medium">Inactifs</p>
|
|
<p className="text-3xl font-bold text-orange-700 mt-1">
|
|
{stats.inactiveParticipants.toLocaleString()}
|
|
</p>
|
|
<p className="text-xs text-orange-600 mt-1">À réactiver</p>
|
|
</div>
|
|
<div className="w-14 h-14 bg-orange-200 rounded-xl flex items-center justify-center">
|
|
<TrendingUp className="w-7 h-7 text-orange-600" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Graphiques - Affichés uniquement s'il y a des données valides */}
|
|
{hasDemographicData && (
|
|
<>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
|
{/* Répartition par genre */}
|
|
{filteredGenderData.length > 0 && (
|
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
|
<div className="w-10 h-10 bg-blue-100 rounded-xl flex items-center justify-center">
|
|
<BarChart3 className="w-5 h-5 text-blue-600" />
|
|
</div>
|
|
Répartition par Genre
|
|
</h2>
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<PieChart>
|
|
<Pie
|
|
data={filteredGenderData}
|
|
dataKey="count"
|
|
nameKey="gender"
|
|
cx="50%"
|
|
cy="50%"
|
|
outerRadius={100}
|
|
label={(entry: any) => `${entry.gender}: ${entry.count}`}
|
|
>
|
|
{filteredGenderData.map((entry, index) => (
|
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip />
|
|
<Legend />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
|
|
{/* Répartition par âge */}
|
|
{filteredAgeData.length > 0 && (
|
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
|
<div className="w-10 h-10 bg-green-100 rounded-xl flex items-center justify-center">
|
|
<BarChart3 className="w-5 h-5 text-green-600" />
|
|
</div>
|
|
Répartition par Âge
|
|
</h2>
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<BarChart data={filteredAgeData}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="range" />
|
|
<YAxis />
|
|
<Tooltip />
|
|
<Legend />
|
|
<Bar dataKey="count" fill="#10b981" name="Participants" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Top villes */}
|
|
{filteredCityData.length > 0 && (
|
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6">
|
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
|
<div className="w-10 h-10 bg-purple-100 rounded-xl flex items-center justify-center">
|
|
<MapPin className="w-5 h-5 text-purple-600" />
|
|
</div>
|
|
Top Villes ({filteredCityData.length})
|
|
</h2>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
|
{filteredCityData.slice(0, 10).map((city, index) => (
|
|
<div
|
|
key={index}
|
|
className="bg-gradient-to-br from-purple-50 to-indigo-50 p-4 rounded-xl border border-purple-100 hover:shadow-md transition-shadow"
|
|
>
|
|
<span className="inline-block px-2 py-0.5 bg-purple-200 text-purple-700 text-xs font-bold rounded-full mb-2">#{index + 1}</span>
|
|
<p className="font-bold text-gray-900 truncate">{city.city}</p>
|
|
<p className="text-2xl font-bold text-purple-600">{city.count}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Section Export */}
|
|
<div className="bg-[#faf8f5] rounded-2xl border border-gray-200 overflow-hidden">
|
|
<div className="px-6 py-4 border-b border-gray-200">
|
|
<h2 className="text-xl font-bold text-[#1e3a5f] flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-white rounded-xl flex items-center justify-center border border-gray-200">
|
|
<Download className="w-5 h-5 text-[#1e3a5f]" />
|
|
</div>
|
|
Exporter les Données pour Emailing
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-5">
|
|
{/* Sélection du segment */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
|
Segment à exporter
|
|
</label>
|
|
<select
|
|
value={selectedSegment}
|
|
onChange={(e) => setSelectedSegment(e.target.value)}
|
|
className="w-full px-4 py-3 bg-white border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
>
|
|
<option value="all">Tous les clients ({stats.totalClients})</option>
|
|
<option value="active">
|
|
Participants actifs ({stats.activeParticipants})
|
|
</option>
|
|
<option value="inactive">
|
|
Participants inactifs ({stats.inactiveParticipants})
|
|
</option>
|
|
<option value="winners">Gagnants ({stats.winners})</option>
|
|
<option value="non-winners">Non-gagnants ({stats.nonWinners})</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Filtres additionnels - Affichés uniquement si données disponibles */}
|
|
{(filteredCityData.length > 0 || filteredGenderData.length > 0) && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{filteredCityData.length > 0 && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Ville</label>
|
|
<select
|
|
value={filters.city}
|
|
onChange={(e) => setFilters({ ...filters, city: e.target.value })}
|
|
className="w-full px-4 py-3 bg-white border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
>
|
|
<option value="">Toutes les villes</option>
|
|
{filteredCityData.slice(0, 10).map((city) => (
|
|
<option key={city.city} value={city.city}>
|
|
{city.city} ({city.count})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{filteredGenderData.length > 0 && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Genre</label>
|
|
<select
|
|
value={filters.gender}
|
|
onChange={(e) => setFilters({ ...filters, gender: e.target.value })}
|
|
className="w-full px-4 py-3 bg-white border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
>
|
|
<option value="">Tous les genres</option>
|
|
{filteredGenderData.map((g) => (
|
|
<option key={g.gender} value={g.gender}>
|
|
{g.gender} ({g.count})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Boutons d'action */}
|
|
<div className="flex gap-3 pt-2">
|
|
<Button
|
|
onClick={exportSegmentData}
|
|
isLoading={exportLoading}
|
|
disabled={exportLoading}
|
|
className="!bg-white !text-black border border-gray-200 hover:!bg-gray-100"
|
|
>
|
|
<Download className="w-5 h-5 mr-2" />
|
|
Exporter en CSV
|
|
</Button>
|
|
|
|
<Button variant="outline" onClick={loadMarketingStats} className="bg-white">
|
|
<FileText className="w-5 h-5 mr-2" />
|
|
Actualiser les données
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="p-4 bg-white border border-gray-200 rounded-xl">
|
|
<div className="flex items-start gap-3">
|
|
<div className="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
|
<Mail className="w-4 h-4 text-gray-600" />
|
|
</div>
|
|
<p className="text-sm text-gray-600">
|
|
<strong>Note:</strong> L'export générera un fichier CSV avec les emails, noms,
|
|
prénoms et autres données de contact. Utilisez ce fichier pour vos campagnes
|
|
d'emailing avec votre outil préféré (Mailchimp, SendGrid, etc.).
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|