- Replace email verification status with active/inactive status - Add user archiving (soft delete) instead of hard delete - Add search by name/email in user management - Add status filter (active/inactive) in user management - Simplify actions to show only "Détails" button - Add Google Maps with clickable marker on contact page - Update button labels from "Désactiver" to "Supprimer" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
453 lines
15 KiB
TypeScript
453 lines
15 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');
|
|
|
|
console.log('Loading marketing stats...');
|
|
console.log('API URL:', API_BASE_URL);
|
|
console.log('Token present:', !!token);
|
|
|
|
const response = await fetch(
|
|
`${API_BASE_URL}/admin/marketing/stats`,
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
}
|
|
);
|
|
|
|
console.log('Response status:', response.status);
|
|
|
|
if (!response.ok) {
|
|
let errorMessage = 'Erreur lors du chargement';
|
|
try {
|
|
const errorData = await response.json();
|
|
errorMessage = errorData.error || errorData.message || errorMessage;
|
|
console.error('Error from API:', errorData);
|
|
} catch (e) {
|
|
console.error('Failed to parse error response');
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
const data = await response.json();
|
|
console.log('Marketing stats loaded:', data);
|
|
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>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 max-w-7xl mx-auto">
|
|
{/* En-tête */}
|
|
<div className="mb-6">
|
|
<h1 className="text-3xl font-bold mb-2 flex items-center gap-3">
|
|
<Mail className="w-10 h-10 text-blue-600" />
|
|
Données Marketing
|
|
</h1>
|
|
<p className="text-gray-600">
|
|
Statistiques et export des données pour vos campagnes d'emailing
|
|
</p>
|
|
</div>
|
|
|
|
{/* Statistiques globales */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
|
<Card className="p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-600">Total Clients</p>
|
|
<p className="text-3xl font-bold text-gray-900 mt-1">
|
|
{stats.totalClients.toLocaleString()}
|
|
</p>
|
|
</div>
|
|
<Users className="w-12 h-12 text-blue-500" />
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-600">Participants Actifs</p>
|
|
<p className="text-3xl font-bold text-green-600 mt-1">
|
|
{stats.activeParticipants.toLocaleString()}
|
|
</p>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
{((stats.activeParticipants / stats.totalClients) * 100).toFixed(1)}% du total
|
|
</p>
|
|
</div>
|
|
<UserCheck className="w-12 h-12 text-green-500" />
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-600">Gagnants</p>
|
|
<p className="text-3xl font-bold text-purple-600 mt-1">
|
|
{stats.winners.toLocaleString()}
|
|
</p>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
{((stats.winners / stats.activeParticipants) * 100).toFixed(1)}% de conversion
|
|
</p>
|
|
</div>
|
|
<Gift className="w-12 h-12 text-purple-500" />
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-600">Inactifs</p>
|
|
<p className="text-3xl font-bold text-orange-600 mt-1">
|
|
{stats.inactiveParticipants.toLocaleString()}
|
|
</p>
|
|
<p className="text-xs text-gray-500 mt-1">À réactiver</p>
|
|
</div>
|
|
<TrendingUp className="w-12 h-12 text-orange-500" />
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Graphiques */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
|
{/* Répartition par genre */}
|
|
<Card className="p-6">
|
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
|
<BarChart3 className="w-6 h-6 text-blue-600" />
|
|
Répartition par Genre
|
|
</h2>
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<PieChart>
|
|
<Pie
|
|
data={stats.byGender}
|
|
dataKey="count"
|
|
nameKey="gender"
|
|
cx="50%"
|
|
cy="50%"
|
|
outerRadius={100}
|
|
label={(entry: any) => `${entry.gender}: ${entry.count}`}
|
|
>
|
|
{stats.byGender.map((entry, index) => (
|
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip />
|
|
<Legend />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</Card>
|
|
|
|
{/* Répartition par âge */}
|
|
<Card className="p-6">
|
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
|
<BarChart3 className="w-6 h-6 text-green-600" />
|
|
Répartition par Âge
|
|
</h2>
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<BarChart data={stats.byAge}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="range" />
|
|
<YAxis />
|
|
<Tooltip />
|
|
<Legend />
|
|
<Bar dataKey="count" fill="#10b981" name="Participants" />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Top villes */}
|
|
<Card className="p-6 mb-6">
|
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
|
<MapPin className="w-6 h-6 text-purple-600" />
|
|
Top Villes ({stats.byCity.length})
|
|
</h2>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
|
{stats.byCity.slice(0, 10).map((city, index) => (
|
|
<div
|
|
key={index}
|
|
className="bg-gradient-to-br from-purple-50 to-blue-50 p-4 rounded-lg border border-purple-200"
|
|
>
|
|
<p className="text-sm text-gray-600">#{index + 1}</p>
|
|
<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>
|
|
</Card>
|
|
|
|
{/* Section Export */}
|
|
<Card className="p-6">
|
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
|
<Download className="w-6 h-6 text-blue-600" />
|
|
Exporter les Données pour Emailing
|
|
</h2>
|
|
|
|
<div className="space-y-4">
|
|
{/* Sélection du segment */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Segment à exporter
|
|
</label>
|
|
<select
|
|
value={selectedSegment}
|
|
onChange={(e) => setSelectedSegment(e.target.value)}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg 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 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm text-gray-700 mb-1">Ville</label>
|
|
<select
|
|
value={filters.city}
|
|
onChange={(e) => setFilters({ ...filters, city: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
|
>
|
|
<option value="">Toutes les villes</option>
|
|
{stats.byCity.slice(0, 10).map((city) => (
|
|
<option key={city.city} value={city.city}>
|
|
{city.city} ({city.count})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm text-gray-700 mb-1">Genre</label>
|
|
<select
|
|
value={filters.gender}
|
|
onChange={(e) => setFilters({ ...filters, gender: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
|
>
|
|
<option value="">Tous les genres</option>
|
|
{stats.byGender.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-4">
|
|
<Button
|
|
onClick={exportSegmentData}
|
|
isLoading={exportLoading}
|
|
disabled={exportLoading}
|
|
className="bg-blue-600 hover:bg-blue-700"
|
|
>
|
|
<Download className="w-5 h-5 mr-2" />
|
|
Exporter en CSV
|
|
</Button>
|
|
|
|
<Button variant="outline" onClick={loadMarketingStats}>
|
|
<FileText className="w-5 h-5 mr-2" />
|
|
Actualiser les données
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
|
<p className="text-sm text-blue-800">
|
|
<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>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|