diff --git a/README.md b/README.md index e215bc4..a5a41c1 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,235 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# đŸ” ThĂ© Tip Top - Jeu Concours Frontend -## Getting Started +Application web moderne pour le jeu-concours "ThĂ© Tip Top" avec Next.js 14 et Tailwind CSS. -First, run the development server: +## 📋 Description -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +Site web complet permettant aux utilisateurs de : +- Se connecter via Google/Facebook OAuth ou formulaire classique +- Participer au jeu-concours en entrant un code de ticket (10 caractĂšres) +- Consulter leurs gains et historiques +- Permettre aux employĂ©s de vĂ©rifier les gains et marquer comme "remis" +- Permettre aux administrateurs de visualiser les statistiques globales du jeu + +## 🚀 Technologies utilisĂ©es + +- **Framework:** Next.js 14 (App Router) +- **Styling:** Tailwind CSS +- **Gestion d'Ă©tat:** React Context API (AuthContext) +- **Formulaires:** React Hook Form + Zod +- **Notifications:** React Hot Toast +- **Appels API:** Axios +- **Language:** TypeScript + +## 📁 Structure du projet + +``` +the-tip-top-frontend/ +├── app/ # Pages Next.js 14 (App Router) +│ ├── page.tsx # Page d'accueil +│ ├── login/page.tsx # Connexion +│ ├── register/page.tsx # Inscription +│ ├── jeux/page.tsx # Page de jeu +│ ├── client/page.tsx # Dashboard client +│ ├── employe/page.tsx # Dashboard employĂ© +│ ├── admin/page.tsx # Dashboard admin +│ ├── historique/page.tsx # Historique +│ ├── profile/page.tsx # Profil +│ ├── layout.tsx # Layout principal +│ └── globals.css # Styles globaux +│ +├── components/ # Composants rĂ©utilisables +│ ├── Header.tsx # Header avec navigation +│ ├── Footer.tsx # Footer complet +│ ├── Button.tsx # Bouton personnalisĂ© +│ └── ui/ # Composants UI +│ ├── Card.tsx +│ ├── Input.tsx +│ ├── Badge.tsx +│ ├── Modal.tsx +│ ├── Loading.tsx +│ └── Table.tsx +│ +├── contexts/ # Contextes React +│ └── AuthContext.tsx # Authentification +│ +├── services/ # Services API +│ ├── api.ts # Configuration Axios +│ ├── auth.service.ts # Auth +│ ├── user.service.ts # Utilisateur +│ ├── game.service.ts # Jeu +│ ├── employee.service.ts # EmployĂ© +│ └── admin.service.ts # Admin +│ +├── hooks/ # Hooks personnalisĂ©s +│ └── useGame.ts +│ +├── lib/ # Librairies +│ └── validations.ts # SchĂ©mas Zod +│ +├── types/ # Types TypeScript +│ └── index.ts +│ +└── utils/ # Utilitaires + ├── constants.ts # Constantes + └── helpers.ts # Helpers ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +## 🎹 FonctionnalitĂ©s -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +### Pages publiques +- ✅ **Page d'accueil** : PrĂ©sentation du jeu-concours +- ✅ **Connexion/Inscription** : Authentification complĂšte avec OAuth +- ✅ **Navigation responsive** : Header et Footer professionnels -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +### Espace Client (`/client`) +- ✅ Statistiques personnelles (participations, gains, en attente) +- ✅ Historique des tickets jouĂ©s +- ✅ AccĂšs rapide au jeu -## Learn More +### Page de jeu (`/jeux`) +- ✅ Saisie de code ticket avec validation +- ✅ RĂ©sultat instantanĂ© avec modal +- ✅ Protection : utilisateurs connectĂ©s uniquement -To learn more about Next.js, take a look at the following resources: +### Espace EmployĂ© (`/employe`) +- ✅ Recherche de ticket par code +- ✅ Liste des tickets en attente +- ✅ Validation des gains +- ✅ Protection : rĂŽle "employee" requis -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +### Espace Admin (`/admin`) +- ✅ Statistiques globales du jeu +- ✅ Distribution des lots +- ✅ Aperçu des statuts +- ✅ Derniers utilisateurs et tickets +- ✅ Protection : rĂŽle "admin" requis -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## ⚙ Installation -## Deploy on Vercel +1. **Cloner le projet** +```bash +git clone +cd the-tip-top-frontend +``` -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +2. **Installer les dĂ©pendances** +```bash +npm install +``` -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +3. **Configurer les variables d'environnement** +CrĂ©er un fichier `.env.local` : +```env +NEXT_PUBLIC_API_URL=http://localhost:5000/api +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET=your-secret-key +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret +FACEBOOK_CLIENT_ID=your-facebook-client-id +FACEBOOK_CLIENT_SECRET=your-facebook-client-secret +``` + +4. **Lancer le serveur de dĂ©veloppement** +```bash +npm run dev +``` + +Le site sera accessible sur **http://localhost:3000** + +## 🔗 Routes principales + +| Route | Description | Protection | +|-------|-------------|-----------| +| `/` | Page d'accueil | Publique | +| `/login` | Connexion | Publique | +| `/register` | Inscription | Publique | +| `/jeux` | Participer au jeu | AuthentifiĂ© | +| `/client` | Dashboard client | Client | +| `/employe` | Dashboard employĂ© | EmployĂ© | +| `/admin` | Dashboard admin | Admin | +| `/historique` | Historique | AuthentifiĂ© | +| `/profile` | Profil utilisateur | AuthentifiĂ© | + +## 🎯 Backend requis + +Le frontend se connecte Ă  un backend Express sur `http://localhost:5000` (configurable). + +### Endpoints attendus : + +**Authentification** +- `POST /api/auth/login` +- `POST /api/auth/register` +- `POST /api/auth/google` +- `POST /api/auth/facebook` +- `GET /api/auth/me` +- `POST /api/auth/logout` + +**Jeu** +- `POST /api/game/play` + +**Utilisateur** +- `GET /api/user/tickets` +- `GET /api/user/profile` + +**EmployĂ©** +- `GET /api/employee/pending-tickets` +- `POST /api/employee/validate/:id` + +**Admin** +- `GET /api/admin/statistics` +- `GET /api/admin/users` +- `GET /api/admin/tickets` + +## đŸ—ïž Build de production + +```bash +# CrĂ©er le build +npm run build + +# DĂ©marrer en production +npm start +``` + +## 🎹 ThĂšme et design + +Le projet utilise un thĂšme personnalisĂ© avec Tailwind CSS : +- **Couleur primaire** : Vert (thĂšme du thĂ©) +- **Couleur secondaire** : Jaune/Or +- **Design** : Moderne, responsive, accessible +- **Composants** : RĂ©utilisables et modulaires + +## đŸ“± Responsive + +L'application est entiĂšrement responsive : +- đŸ“± Mobile : < 768px +- đŸ’» Tablet : 768px - 1024px +- đŸ–„ïž Desktop : > 1024px + +## ♿ AccessibilitĂ© + +- Labels ARIA sur tous les Ă©lĂ©ments interactifs +- Navigation au clavier +- Contrastes de couleurs respectĂ©s +- Messages d'erreur descriptifs + +## đŸ§Ș Scripts disponibles + +```bash +npm run dev # DĂ©veloppement +npm run build # Build production +npm start # DĂ©marrer en production +npm run lint # Linter +``` + +## 📄 License + +Ce projet est sous licence privĂ©e - ThĂ© Tip Top © 2024 + +## đŸ‘„ Auteur + +DĂ©veloppĂ© pour ThĂ© Tip Top - Jeu Concours 2024 + +--- + +**Note:** Assurez-vous que le backend est dĂ©marrĂ© avant de lancer le frontend. diff --git a/app/about/page.tsx b/app/about/page.tsx new file mode 100644 index 0000000..9dc5c15 --- /dev/null +++ b/app/about/page.tsx @@ -0,0 +1,232 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import Image from "next/image"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import Button from "@/components/Button"; +import { ROUTES } from "@/utils/constants"; + +export const metadata: Metadata = { + title: "À propos - ThĂ© Tip Top", + description: "DĂ©couvrez l'histoire et les engagements de notre maison de thĂ© niçoise", +}; + +export default function AboutPage() { + return ( +
+ {/* Hero Section */} +
+

+ À propos de ThĂ© Tip Top Nice +

+

+ Depuis Nice, nous célébrons les thés d'exception et les moments à partager. + Découvrez notre histoire et ce qui guide notre maison au quotidien. +

+ + + +
+ + {/* Histoire Section */} +
+
+
+

+ Notre histoire +

+

+ Quinze ans de passion infusée. +

+

+ NĂ©e d'une envie simple — faire (re)dĂ©couvrir le vrai goĂ»t du thĂ© — notre maison + sĂ©lectionne des feuilles remarquables auprĂšs de producteurs engagĂ©s. Au fil des annĂ©es, + nous avons grandi sans rien cĂ©der Ă  nos exigences : traçabilitĂ©, fraĂźcheur, respect des savoir-faire. +

+

+ Aujourd'hui, notre boutique niçoise est le point de rencontre entre curieux et connaisseurs : + dégustations, conseils personnalisés et une carte qui évolue au rythme des récoltes. +

+
+ + {/* Image Placeholder */} +
+
+
+
+ Logo Thé Tip Top +
+

Notre boutique Ă  Nice

+

Image Ă  venir

+
+
+
+
+
+ + {/* Engagements Section */} +
+

+ Nos engagements +

+
+ {/* Engagement 1 */} + + +
đŸŒ±
+

+ Agriculture responsable +

+

+ Des jardins certifiés et des pratiques respectueuses de la biodiversité, + pour des thés propres et expressifs. +

+
+
+ + {/* Engagement 2 */} + + +
đŸ€
+

+ FiliÚre équitable +

+

+ Des partenariats durables et rémunérateurs pour les fermes qui nous + confient leurs récoltes. +

+
+
+ + {/* Engagement 3 */} + + +
❄
+

+ Fraßcheur & préparation +

+

+ Conditionnement soigné et rotations courtes pour préserver les arÎmes. +

+
+
+ + {/* Engagement 4 */} + + +
📚
+

+ Conseil & transmission +

+

+ Ateliers, initiations et fiches pratiques : nous aimons partager nos + méthodes d'infusion. +

+
+
+
+
+ + {/* CTA Section */} +
+ + +

+ Participez à notre grand jeu autour du thé ! +

+

+ À gagner : sĂ©lections premium, accessoires de dĂ©gustation et surprises maison. + Chaque achat vous donne une chance supplĂ©mentaire. +

+ + + +
+
+
+ + {/* Quote Section */} +
+
+
+ « Un thé bien infusé, c'est une minute pour soi et un souvenir à partager. » +
+

— L'Ă©quipe ThĂ© Tip Top

+
+
+ + {/* Info Section */} +
+
+
+ {/* Localisation */} + + +
📍
+ OĂč nous trouver +
+ +

+ 18 Avenue Thiers
+ 06000 Nice, France +

+

+ Au cƓur de Nice, proches des transports. +

+
+
+ + {/* Horaires */} + + +
🕐
+ Horaires +
+ +

+ Du mardi au samedi
+ 10h – 19h +

+

+ Fermé dimanche et lundi +

+
+
+ + {/* Contact */} + + +
✉
+ Contact +
+ +

+ + contact@thetiptop.fr + +

+

+ + 01 23 45 67 89 + +

+

+ Conseils personnalisés par e-mail ou en boutique. +

+
+
+
+
+
+
+ ); +} diff --git a/app/admin/dashboard/page-advanced.tsx b/app/admin/dashboard/page-advanced.tsx new file mode 100644 index 0000000..26685ef --- /dev/null +++ b/app/admin/dashboard/page-advanced.tsx @@ -0,0 +1,554 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { adminService } from "@/services/admin.service"; +import { AdminStatistics } from "@/types"; +import Link from "next/link"; +import { + Users, + Ticket, + TrendingUp, + AlertCircle, + Gift, + BarChart3, + RefreshCw, + MapPin, + User as UserIcon, + Calendar, + Download, + Filter, +} from "lucide-react"; +import { + PieChart, + Pie, + Cell, + BarChart, + Bar, + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from "recharts"; + +// Couleurs pour les graphiques +const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899"]; +const GENDER_COLORS = { + male: "#3b82f6", + female: "#ec4899", + other: "#8b5cf6", + notSpecified: "#6b7280", +}; + +export default function AdminDashboardAdvanced() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [autoRefresh, setAutoRefresh] = useState(false); + const [refreshInterval, setRefreshInterval] = useState(30); // secondes + const [selectedPeriod, setSelectedPeriod] = useState("all"); // all, week, month, year + + useEffect(() => { + loadStatistics(); + }, []); + + // Auto-refresh + useEffect(() => { + if (autoRefresh) { + const interval = setInterval(() => { + loadStatistics(); + }, refreshInterval * 1000); + return () => clearInterval(interval); + } + }, [autoRefresh, refreshInterval]); + + const loadStatistics = async () => { + try { + setLoading(true); + setError(null); + const data = await adminService.getStatistics(); + setStats(data); + } catch (err: any) { + setError(err.message || "Erreur lors du chargement des statistiques"); + setStats(null); + } finally { + setLoading(false); + } + }; + + // Export CSV + const exportToCSV = useCallback(() => { + if (!stats) return; + + const csv = [ + ["Type", "Métrique", "Valeur"], + ["Utilisateurs", "Total", stats.users.total], + ["Utilisateurs", "Clients", stats.users.clients], + ["Utilisateurs", "Employés", stats.users.employees], + ["Utilisateurs", "Admins", stats.users.admins], + ["Tickets", "Total", stats.tickets.total], + ["Tickets", "Distribués", stats.tickets.distributed], + ["Tickets", "Utilisés", stats.tickets.used], + ["Tickets", "En attente", stats.tickets.pending], + ["Lots", "Total distribué", stats.prizes.distributed], + ]; + + // Ajouter les données démographiques + if (stats.demographics?.gender) { + csv.push(["Démographie", "Hommes", stats.demographics.gender.male]); + csv.push(["Démographie", "Femmes", stats.demographics.gender.female]); + } + + const csvContent = csv.map((row) => row.join(",")).join("\n"); + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + link.setAttribute("href", url); + link.setAttribute("download", `statistiques_${new Date().toISOString()}.csv`); + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, [stats]); + + if (loading) { + return ( +
+
+
+
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+
+ ); + } + + if (error || !stats) { + return ( +
+
+ + {error || "Erreur lors du chargement des statistiques"} +
+ +
+ ); + } + + // Préparer les données pour les graphiques + const prizeChartData = stats.prizes.byCategory?.map((prize) => ({ + name: prize.prizeName, + value: prize.count, + percentage: prize.percentage, + })) || []; + + const genderChartData = stats.demographics?.gender + ? [ + { name: "Hommes", value: stats.demographics.gender.male, color: GENDER_COLORS.male }, + { name: "Femmes", value: stats.demographics.gender.female, color: GENDER_COLORS.female }, + { name: "Autre", value: stats.demographics.gender.other, color: GENDER_COLORS.other }, + { + name: "Non spécifié", + value: stats.demographics.gender.notSpecified, + color: GENDER_COLORS.notSpecified, + }, + ].filter((item) => item.value > 0) + : []; + + const ageChartData = stats.demographics?.ageRanges || []; + + const ticketDistributedPercent = stats.tickets.total > 0 + ? ((stats.tickets.distributed / stats.tickets.total) * 100).toFixed(1) + : 0; + const ticketUsedPercent = stats.tickets.distributed > 0 + ? ((stats.tickets.used / stats.tickets.distributed) * 100).toFixed(1) + : 0; + + return ( +
+ {/* Header avec contrĂŽles */} +
+
+
+

Dashboard Administrateur Avancé

+

+ Statistiques complÚtes et analyses en temps réel +

+
+ +
+ {/* Auto-refresh toggle */} + + + {/* Export button */} + + + {/* Refresh button */} + +
+
+ + {/* Filter period */} +
+ + Période: + {["all", "week", "month", "year"].map((period) => ( + + ))} +
+
+ + {/* Stats Cards - Principales */} +
+ } + color="blue" + link="/admin/utilisateurs" + trend="+12%" + /> + } + color="green" + link="/admin/tickets" + trend="+8%" + /> + } + color="purple" + link="/admin/tickets" + trend="+15%" + /> + } + color="yellow" + link="/admin/tickets" + trend="+10%" + /> +
+ + {/* Graphiques en ligne */} +
+ {/* Graphique répartition des lots */} + {prizeChartData.length > 0 && ( +
+

+ + Répartition des Lots +

+ + + `${name}: ${percentage}%`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {prizeChartData.map((entry, index) => ( + + ))} + + + + +
+ )} + + {/* Graphique répartition par genre */} + {genderChartData.length > 0 && ( +
+

+ + Répartition par Genre +

+ + + `${name}: ${value}`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {genderChartData.map((entry, index) => ( + + ))} + + + + +
+ )} +
+ + {/* Graphique tranches d'Ăąge */} + {ageChartData.length > 0 && ( +
+

+ + RĂ©partition par Âge +

+ + + + + + + + + + +
+ )} + + {/* Section détaillée existante */} +
+ {/* Statistiques Tickets détaillées */} +
+
+

+ + Statistiques des Tickets +

+ + Voir tout → + +
+
+ + + + + + +
+
+ + {/* Utilisateurs */} +
+
+

+ + Utilisateurs +

+ + Voir tout → + +
+
+ + + + + +
+
+
+ + {/* Top Villes */} + {stats?.demographics?.topCities && stats.demographics.topCities.length > 0 && ( +
+

+ + Top 10 Villes des Participants +

+
+ {stats.demographics.topCities.slice(0, 10).map((city, idx) => ( + + ))} +
+
+ )} +
+ ); +} + +// Composants réutilisables +interface StatCardProps { + title: string; + value: number; + subtitle?: string; + icon: React.ReactNode; + color: "blue" | "green" | "yellow" | "purple" | "red"; + link: string; + trend?: string; +} + +function StatCard({ title, value, subtitle, icon, color, link, trend }: StatCardProps) { + const colors = { + blue: "bg-blue-100 text-blue-600", + green: "bg-green-100 text-green-600", + yellow: "bg-yellow-100 text-yellow-600", + purple: "bg-purple-100 text-purple-600", + red: "bg-red-100 text-red-600", + }; + + return ( + +
+
{icon}
+ {trend && ( +
+ + {trend} +
+ )} +
+

{title}

+

{(value || 0).toLocaleString("fr-FR")}

+ {subtitle &&

{subtitle}

} + + ); +} + +interface StatRowProps { + label: string; + value: number; + color?: "green" | "yellow" | "red" | "purple" | "blue"; + percentage?: string | number; +} + +function StatRow({ label, value, color, percentage }: StatRowProps) { + const colors = { + green: "text-green-600", + yellow: "text-yellow-600", + red: "text-red-600", + purple: "text-purple-600", + blue: "text-blue-600", + }; + + return ( +
+ {label} +
+ + {(value || 0).toLocaleString("fr-FR")} + + {percentage !== undefined && ({percentage}%)} +
+
+ ); +} + +interface CityCardProps { + rank: number; + city: string; + count: number; + percentage: number; +} + +function CityCard({ rank, city, count, percentage }: CityCardProps) { + const rankColors = { + 1: "bg-yellow-100 text-yellow-800 border-yellow-300", + 2: "bg-gray-100 text-gray-800 border-gray-300", + 3: "bg-orange-100 text-orange-800 border-orange-300", + }; + + const rankColor = + rankColors[rank as keyof typeof rankColors] || "bg-blue-50 text-blue-800 border-blue-200"; + + return ( +
+
+ #{rank} + +
+

+ {city} +

+
+ {count} + ({percentage.toFixed(1)}%) +
+
+ ); +} diff --git a/app/admin/dashboard/page-backup.tsx b/app/admin/dashboard/page-backup.tsx new file mode 100644 index 0000000..d8ef4f1 --- /dev/null +++ b/app/admin/dashboard/page-backup.tsx @@ -0,0 +1,529 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { adminService } from "@/services/admin.service"; +import { AdminStatistics } from "@/types"; +import Link from "next/link"; +import { + Users, + Ticket, + TrendingUp, + AlertCircle, + Gift, + BarChart3, + RefreshCw, + MapPin, + User as UserIcon, + Calendar, +} from "lucide-react"; + +export default function AdminDashboard() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + loadStatistics(); + }, []); + + const loadStatistics = async () => { + try { + setLoading(true); + setError(null); + const data = await adminService.getStatistics(); + setStats(data); + } catch (err: any) { + setError(err.message || "Erreur lors du chargement des statistiques"); + setStats(null); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+
+
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+
+ ); + } + + if (error || !stats) { + return ( +
+
+ + {error || "Erreur lors du chargement des statistiques"} +
+ +
+ ); + } + + // Calculer les pourcentages pour les tickets + const ticketDistributedPercent = stats.tickets.total > 0 + ? ((stats.tickets.distributed / stats.tickets.total) * 100).toFixed(1) + : 0; + const ticketUsedPercent = stats.tickets.distributed > 0 + ? ((stats.tickets.used / stats.tickets.distributed) * 100).toFixed(1) + : 0; + + return ( +
+ {/* Header */} +
+
+

Dashboard Administrateur

+

+ Statistiques complÚtes du jeu-concours Thé Tip Top +

+
+ +
+ + {/* Stats Cards - Principales */} +
+ } + color="blue" + link="/admin/utilisateurs" + /> + } + color="green" + link="/admin/tickets" + /> + } + color="purple" + link="/admin/tickets" + /> + } + color="yellow" + link="/admin/tickets" + /> +
+ + {/* Section des Tickets */} +
+ {/* Statistiques Tickets détaillées */} +
+
+

+ + Statistiques des Tickets +

+ + Voir tout → + +
+
+ + + + + + +
+
+ + {/* Utilisateurs */} +
+
+

+ + Utilisateurs +

+ + Voir tout → + +
+
+ + + + + +
+
+
+ + {/* Répartition des Lots par Catégorie */} + {stats?.prizes?.byCategory && stats.prizes.byCategory.length > 0 && ( +
+

+ + Répartition des Lots Gagnés par Catégorie +

+
+ {stats.prizes.byCategory.map((prize) => ( + + ))} +
+
+ )} + + {/* Statistiques Démographiques */} + {stats?.demographics && ( +
+

+ + Statistiques Démographiques des Participants +

+ +
+ {/* Répartition par Genre */} + {stats.demographics.gender && ( +
+

+ + Répartition par Genre +

+
+ + + + +
+
+ )} + + {/* RĂ©partition par Âge */} + {stats.demographics.ageRanges && stats.demographics.ageRanges.length > 0 && ( +
+

+ + RĂ©partition par Âge +

+
+ {stats.demographics.ageRanges.map((range, idx) => ( + + ))} +
+
+ )} +
+ + {/* Top Villes */} + {stats.demographics.topCities && stats.demographics.topCities.length > 0 && ( +
+

+ + Top 10 Villes des Participants +

+
+ {stats.demographics.topCities.slice(0, 10).map((city, idx) => ( + + ))} +
+
+ )} +
+ )} + +
+ ); +} + +interface StatCardProps { + title: string; + value: number; + subtitle?: string; + icon: React.ReactNode; + color: "blue" | "green" | "yellow" | "purple" | "red"; + link: string; +} + +function StatCard({ title, value, subtitle, icon, color, link }: StatCardProps) { + const colors = { + blue: "bg-blue-100 text-blue-600", + green: "bg-green-100 text-green-600", + yellow: "bg-yellow-100 text-yellow-600", + purple: "bg-purple-100 text-purple-600", + red: "bg-red-100 text-red-600", + }; + + return ( + +
+
{icon}
+ +
+

{title}

+

+ {(value || 0).toLocaleString("fr-FR")} +

+ {subtitle && ( +

{subtitle}

+ )} + + ); +} + +interface StatRowProps { + label: string; + value: number; + color?: "green" | "yellow" | "red" | "purple" | "blue"; + percentage?: string | number; +} + +function StatRow({ label, value, color, percentage }: StatRowProps) { + const colors = { + green: "text-green-600", + yellow: "text-yellow-600", + red: "text-red-600", + purple: "text-purple-600", + blue: "text-blue-600", + }; + + return ( +
+ {label} +
+ + {(value || 0).toLocaleString("fr-FR")} + + {percentage !== undefined && ( + ({percentage}%) + )} +
+
+ ); +} + +// Composant pour afficher une carte de lot +interface PrizeCardProps { + name: string; + type: string; + count: number; + percentage: number; +} + +function PrizeCard({ name, type, count, percentage }: PrizeCardProps) { + const typeColors = { + PHYSICAL: "bg-purple-100 text-purple-800", + DISCOUNT: "bg-green-100 text-green-800", + }; + + const color = typeColors[type as keyof typeof typeColors] || "bg-gray-100 text-gray-800"; + + return ( +
+
+
+

{name}

+ + {type === "PHYSICAL" ? "Physique" : "Réduction"} + +
+
+
+
+

{count}

+

distribués

+
+
+

{percentage.toFixed(1)}%

+
+
+ {/* Barre de progression */} +
+
+
+
+ ); +} + +// Composant pour les statistiques démographiques +interface DemoStatRowProps { + label: string; + value: number; + total?: number; + percentage?: number; + color?: "blue" | "pink" | "purple" | "gray" | "green"; +} + +function DemoStatRow({ label, value, total, percentage, color }: DemoStatRowProps) { + const calculatedPercentage = percentage !== undefined + ? percentage + : total && total > 0 + ? (value / total) * 100 + : 0; + + const colors = { + blue: "bg-blue-500", + pink: "bg-pink-500", + purple: "bg-purple-500", + gray: "bg-gray-500", + green: "bg-green-500", + }; + + const barColor = color ? colors[color] : "bg-blue-500"; + + return ( +
+
+ {label} +
+ + {value.toLocaleString("fr-FR")} + + + ({calculatedPercentage.toFixed(1)}%) + +
+
+
+
+
+
+ ); +} + +// Composant pour afficher une carte de ville +interface CityCardProps { + rank: number; + city: string; + count: number; + percentage: number; +} + +function CityCard({ rank, city, count, percentage }: CityCardProps) { + const rankColors = { + 1: "bg-yellow-100 text-yellow-800 border-yellow-300", + 2: "bg-gray-100 text-gray-800 border-gray-300", + 3: "bg-orange-100 text-orange-800 border-orange-300", + }; + + const rankColor = rankColors[rank as keyof typeof rankColors] || "bg-blue-50 text-blue-800 border-blue-200"; + + return ( +
+
+ #{rank} + +
+

+ {city} +

+
+ {count} + ({percentage.toFixed(1)}%) +
+
+ ); +} + diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..26685ef --- /dev/null +++ b/app/admin/dashboard/page.tsx @@ -0,0 +1,554 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { adminService } from "@/services/admin.service"; +import { AdminStatistics } from "@/types"; +import Link from "next/link"; +import { + Users, + Ticket, + TrendingUp, + AlertCircle, + Gift, + BarChart3, + RefreshCw, + MapPin, + User as UserIcon, + Calendar, + Download, + Filter, +} from "lucide-react"; +import { + PieChart, + Pie, + Cell, + BarChart, + Bar, + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from "recharts"; + +// Couleurs pour les graphiques +const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899"]; +const GENDER_COLORS = { + male: "#3b82f6", + female: "#ec4899", + other: "#8b5cf6", + notSpecified: "#6b7280", +}; + +export default function AdminDashboardAdvanced() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [autoRefresh, setAutoRefresh] = useState(false); + const [refreshInterval, setRefreshInterval] = useState(30); // secondes + const [selectedPeriod, setSelectedPeriod] = useState("all"); // all, week, month, year + + useEffect(() => { + loadStatistics(); + }, []); + + // Auto-refresh + useEffect(() => { + if (autoRefresh) { + const interval = setInterval(() => { + loadStatistics(); + }, refreshInterval * 1000); + return () => clearInterval(interval); + } + }, [autoRefresh, refreshInterval]); + + const loadStatistics = async () => { + try { + setLoading(true); + setError(null); + const data = await adminService.getStatistics(); + setStats(data); + } catch (err: any) { + setError(err.message || "Erreur lors du chargement des statistiques"); + setStats(null); + } finally { + setLoading(false); + } + }; + + // Export CSV + const exportToCSV = useCallback(() => { + if (!stats) return; + + const csv = [ + ["Type", "Métrique", "Valeur"], + ["Utilisateurs", "Total", stats.users.total], + ["Utilisateurs", "Clients", stats.users.clients], + ["Utilisateurs", "Employés", stats.users.employees], + ["Utilisateurs", "Admins", stats.users.admins], + ["Tickets", "Total", stats.tickets.total], + ["Tickets", "Distribués", stats.tickets.distributed], + ["Tickets", "Utilisés", stats.tickets.used], + ["Tickets", "En attente", stats.tickets.pending], + ["Lots", "Total distribué", stats.prizes.distributed], + ]; + + // Ajouter les données démographiques + if (stats.demographics?.gender) { + csv.push(["Démographie", "Hommes", stats.demographics.gender.male]); + csv.push(["Démographie", "Femmes", stats.demographics.gender.female]); + } + + const csvContent = csv.map((row) => row.join(",")).join("\n"); + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + link.setAttribute("href", url); + link.setAttribute("download", `statistiques_${new Date().toISOString()}.csv`); + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, [stats]); + + if (loading) { + return ( +
+
+
+
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+
+ ); + } + + if (error || !stats) { + return ( +
+
+ + {error || "Erreur lors du chargement des statistiques"} +
+ +
+ ); + } + + // Préparer les données pour les graphiques + const prizeChartData = stats.prizes.byCategory?.map((prize) => ({ + name: prize.prizeName, + value: prize.count, + percentage: prize.percentage, + })) || []; + + const genderChartData = stats.demographics?.gender + ? [ + { name: "Hommes", value: stats.demographics.gender.male, color: GENDER_COLORS.male }, + { name: "Femmes", value: stats.demographics.gender.female, color: GENDER_COLORS.female }, + { name: "Autre", value: stats.demographics.gender.other, color: GENDER_COLORS.other }, + { + name: "Non spécifié", + value: stats.demographics.gender.notSpecified, + color: GENDER_COLORS.notSpecified, + }, + ].filter((item) => item.value > 0) + : []; + + const ageChartData = stats.demographics?.ageRanges || []; + + const ticketDistributedPercent = stats.tickets.total > 0 + ? ((stats.tickets.distributed / stats.tickets.total) * 100).toFixed(1) + : 0; + const ticketUsedPercent = stats.tickets.distributed > 0 + ? ((stats.tickets.used / stats.tickets.distributed) * 100).toFixed(1) + : 0; + + return ( +
+ {/* Header avec contrĂŽles */} +
+
+
+

Dashboard Administrateur Avancé

+

+ Statistiques complÚtes et analyses en temps réel +

+
+ +
+ {/* Auto-refresh toggle */} + + + {/* Export button */} + + + {/* Refresh button */} + +
+
+ + {/* Filter period */} +
+ + Période: + {["all", "week", "month", "year"].map((period) => ( + + ))} +
+
+ + {/* Stats Cards - Principales */} +
+ } + color="blue" + link="/admin/utilisateurs" + trend="+12%" + /> + } + color="green" + link="/admin/tickets" + trend="+8%" + /> + } + color="purple" + link="/admin/tickets" + trend="+15%" + /> + } + color="yellow" + link="/admin/tickets" + trend="+10%" + /> +
+ + {/* Graphiques en ligne */} +
+ {/* Graphique répartition des lots */} + {prizeChartData.length > 0 && ( +
+

+ + Répartition des Lots +

+ + + `${name}: ${percentage}%`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {prizeChartData.map((entry, index) => ( + + ))} + + + + +
+ )} + + {/* Graphique répartition par genre */} + {genderChartData.length > 0 && ( +
+

+ + Répartition par Genre +

+ + + `${name}: ${value}`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {genderChartData.map((entry, index) => ( + + ))} + + + + +
+ )} +
+ + {/* Graphique tranches d'Ăąge */} + {ageChartData.length > 0 && ( +
+

+ + RĂ©partition par Âge +

+ + + + + + + + + + +
+ )} + + {/* Section détaillée existante */} +
+ {/* Statistiques Tickets détaillées */} +
+
+

+ + Statistiques des Tickets +

+ + Voir tout → + +
+
+ + + + + + +
+
+ + {/* Utilisateurs */} +
+
+

+ + Utilisateurs +

+ + Voir tout → + +
+
+ + + + + +
+
+
+ + {/* Top Villes */} + {stats?.demographics?.topCities && stats.demographics.topCities.length > 0 && ( +
+

+ + Top 10 Villes des Participants +

+
+ {stats.demographics.topCities.slice(0, 10).map((city, idx) => ( + + ))} +
+
+ )} +
+ ); +} + +// Composants réutilisables +interface StatCardProps { + title: string; + value: number; + subtitle?: string; + icon: React.ReactNode; + color: "blue" | "green" | "yellow" | "purple" | "red"; + link: string; + trend?: string; +} + +function StatCard({ title, value, subtitle, icon, color, link, trend }: StatCardProps) { + const colors = { + blue: "bg-blue-100 text-blue-600", + green: "bg-green-100 text-green-600", + yellow: "bg-yellow-100 text-yellow-600", + purple: "bg-purple-100 text-purple-600", + red: "bg-red-100 text-red-600", + }; + + return ( + +
+
{icon}
+ {trend && ( +
+ + {trend} +
+ )} +
+

{title}

+

{(value || 0).toLocaleString("fr-FR")}

+ {subtitle &&

{subtitle}

} + + ); +} + +interface StatRowProps { + label: string; + value: number; + color?: "green" | "yellow" | "red" | "purple" | "blue"; + percentage?: string | number; +} + +function StatRow({ label, value, color, percentage }: StatRowProps) { + const colors = { + green: "text-green-600", + yellow: "text-yellow-600", + red: "text-red-600", + purple: "text-purple-600", + blue: "text-blue-600", + }; + + return ( +
+ {label} +
+ + {(value || 0).toLocaleString("fr-FR")} + + {percentage !== undefined && ({percentage}%)} +
+
+ ); +} + +interface CityCardProps { + rank: number; + city: string; + count: number; + percentage: number; +} + +function CityCard({ rank, city, count, percentage }: CityCardProps) { + const rankColors = { + 1: "bg-yellow-100 text-yellow-800 border-yellow-300", + 2: "bg-gray-100 text-gray-800 border-gray-300", + 3: "bg-orange-100 text-orange-800 border-orange-300", + }; + + const rankColor = + rankColors[rank as keyof typeof rankColors] || "bg-blue-50 text-blue-800 border-blue-200"; + + return ( +
+
+ #{rank} + +
+

+ {city} +

+
+ {count} + ({percentage.toFixed(1)}%) +
+
+ ); +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..39f868f --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,81 @@ +"use client"; + +import Sidebar from "@/components/admin/Sidebar"; +import { useAuth } from "@/contexts/AuthContext"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { Loading } from "@/components/ui/Loading"; +import toast from "react-hot-toast"; +import { LogOut } from "lucide-react"; +import Logo from "@/components/Logo"; + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { user, isAuthenticated, isLoading, logout } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push("/login"); + return; + } + + if (isAuthenticated && user?.role !== "ADMIN" && user?.role !== "admin") { + router.push("/"); + toast.error("AccÚs refusé : rÎle administrateur requis"); + return; + } + }, [isLoading, isAuthenticated, user, router]); + + const handleLogout = async () => { + await logout(); + router.push("/login"); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated || (user?.role !== "ADMIN" && user?.role !== "admin")) { + return null; + } + + return ( +
+ +
+ {/* Admin Header */} +
+
+
+ +
+

Thé Tip Top - Administration

+

+ Connecté en tant que {user?.firstName} {user?.lastName} +

+
+
+ +
+
+ + {/* Main Content */} +
{children}
+
+
+ ); +} diff --git a/app/admin/lots/page.tsx b/app/admin/lots/page.tsx new file mode 100644 index 0000000..25ab92a --- /dev/null +++ b/app/admin/lots/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import PrizeManagement from '@/components/admin/PrizeManagement'; + +export default function LotsPage() { + return ; +} diff --git a/app/admin/marketing-data/page.tsx b/app/admin/marketing-data/page.tsx new file mode 100644 index 0000000..5be74a9 --- /dev/null +++ b/app/admin/marketing-data/page.tsx @@ -0,0 +1,465 @@ +'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 { + 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(null); + const [loading, setLoading] = useState(true); + const [exportLoading, setExportLoading] = useState(false); + + // Filtres d'export + const [selectedSegment, setSelectedSegment] = useState('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:', process.env.NEXT_PUBLIC_API_URL); + console.log('Token present:', !!token); + + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_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( + `${process.env.NEXT_PUBLIC_API_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', + 'VĂ©rifiĂ©', + '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.is_verified ? 'Oui' : 'Non', + 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 ( +
+
+
+

Chargement des données marketing...

+
+
+ ); + } + + if (!stats) { + return ( +
+
+ Erreur lors du chargement des statistiques marketing +
+
+ ); + } + + return ( +
+ {/* En-tĂȘte */} +
+

+ + Données Marketing +

+

+ Statistiques et export des données pour vos campagnes d'emailing +

+
+ + {/* Statistiques globales */} +
+ +
+
+

Total Clients

+

+ {stats.totalClients.toLocaleString()} +

+
+ +
+
+ + +
+
+

Participants Actifs

+

+ {stats.activeParticipants.toLocaleString()} +

+

+ {((stats.activeParticipants / stats.totalClients) * 100).toFixed(1)}% du total +

+
+ +
+
+ + +
+
+

Gagnants

+

+ {stats.winners.toLocaleString()} +

+

+ {((stats.winners / stats.activeParticipants) * 100).toFixed(1)}% de conversion +

+
+ +
+
+ + +
+
+

Inactifs

+

+ {stats.inactiveParticipants.toLocaleString()} +

+

À rĂ©activer

+
+ +
+
+
+ + {/* Graphiques */} +
+ {/* Répartition par genre */} + +

+ + Répartition par Genre +

+ + + `${entry.gender}: ${entry.count}`} + > + {stats.byGender.map((entry, index) => ( + + ))} + + + + + +
+ + {/* Répartition par ùge */} + +

+ + RĂ©partition par Âge +

+ + + + + + + + + + +
+
+ + {/* Top villes */} + +

+ + Top Villes ({stats.byCity.length}) +

+
+ {stats.byCity.slice(0, 10).map((city, index) => ( +
+

#{index + 1}

+

{city.city}

+

{city.count}

+
+ ))} +
+
+ + {/* Section Export */} + +

+ + Exporter les Données pour Emailing +

+ +
+ {/* Sélection du segment */} +
+ + +
+ + {/* Filtres additionnels */} +
+
+ +
+ +
+ + +
+ +
+ + +
+
+ + {/* Boutons d'action */} +
+ + + +
+ +
+

+ Note: 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.). +

+
+
+
+
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..32605aa --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,40 @@ +"use client"; +import { useEffect } from "react"; +import { useAuth } from "@/contexts/AuthContext"; +import { useRouter } from "next/navigation"; +import { Loading } from "@/components/ui/Loading"; +import toast from "react-hot-toast"; + +export default function AdminPage() { + const { user, isAuthenticated, isLoading: authLoading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!authLoading && !isAuthenticated) { + router.push("/login"); + return; + } + + if (isAuthenticated && user?.role !== "ADMIN" && user?.role !== "admin") { + router.push("/"); + toast.error("AccÚs refusé : rÎle administrateur requis"); + return; + } + + // Redirect to dashboard + if (isAuthenticated && (user?.role === "ADMIN" || user?.role === "admin")) { + router.push("/admin/dashboard"); + return; + } + }, [authLoading, isAuthenticated, user, router]); + + if (authLoading) { + return ( +
+ +
+ ); + } + + return null; +} diff --git a/app/admin/tickets/page.tsx b/app/admin/tickets/page.tsx new file mode 100644 index 0000000..92e790a --- /dev/null +++ b/app/admin/tickets/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import TicketManagement from "@/components/admin/TicketManagement"; + +export default function AdminTicketsPage() { + return ( +
+
+

+ Gestion des tickets +

+

+ Consultez et gérez tous les tickets du jeu-concours +

+
+
+ +
+
+ ); +} diff --git a/app/admin/tirages/page.tsx b/app/admin/tirages/page.tsx new file mode 100644 index 0000000..59ba96b --- /dev/null +++ b/app/admin/tirages/page.tsx @@ -0,0 +1,648 @@ +'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 { + Trophy, + Users, + CheckCircle, + AlertCircle, + Download, + Mail, + Gift, + User, + RefreshCw, + Trash2, +} from 'lucide-react'; + +interface Participant { + id: string; + email: string; + first_name: string; + last_name: string; + is_verified: boolean; + tickets_played: number; + prizes_won: number; +} + +interface DrawResult { + draw: { + id: string; + drawDate: string; + status: string; + }; + winner: { + id: string; + email: string; + name: string; + ticketsPlayed: number; + }; + statistics: { + totalParticipants: number; + eligibleParticipants: number; + criteria: any; + }; + prize: { + name: string; + value: string; + }; +} + +interface ExistingDraw { + id: string; + draw_date: string; + winner_email: string; + winner_name: string; + prize_name: string; + prize_value: string; + total_participants: number; + eligible_participants: number; + status: string; + notified_at: string | null; + claimed_at: string | null; +} + +export default function TiragesPage() { + const [participants, setParticipants] = useState([]); + const [loading, setLoading] = useState(false); + const [drawResult, setDrawResult] = useState(null); + const [existingDraw, setExistingDraw] = useState(null); + const [hasExistingDraw, setHasExistingDraw] = useState(false); + + // CritĂšres + const [minTickets, setMinTickets] = useState(1); + const [verifiedOnly, setVerifiedOnly] = useState(true); + const [prizeName, setPrizeName] = useState('An de thĂ©'); + const [prizeValue, setPrizeValue] = useState('360'); + const [allowRedraw, setAllowRedraw] = useState(false); + + useEffect(() => { + checkExistingDraw(); + // Charger automatiquement les participants au chargement de la page + loadParticipants(); + }, []); + + const checkExistingDraw = async () => { + try { + const token = localStorage.getItem('auth_token') || localStorage.getItem('token'); + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/draw/check-existing`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + setHasExistingDraw(data.data.hasExistingDraw); + setExistingDraw(data.data.lastDraw); + } + } catch (error) { + console.error('Erreur:', error); + } + }; + + const loadParticipants = async () => { + setLoading(true); + try { + const token = localStorage.getItem('auth_token') || localStorage.getItem('token'); + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/draw/eligible-participants?minTickets=${minTickets}&verified=${verifiedOnly}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + if (!response.ok) throw new Error('Erreur lors du chargement'); + + const data = await response.json(); + setParticipants(data.data.participants); + toast.success(`${data.data.total} participants Ă©ligibles trouvĂ©s`); + } catch (error: any) { + toast.error(error.message || 'Erreur lors du chargement'); + setParticipants([]); + } finally { + setLoading(false); + } + }; + + const conductDraw = async () => { + if (participants.length === 0) { + toast.error('Veuillez d\'abord charger les participants Ă©ligibles'); + return; + } + + const confirmMessage = hasExistingDraw + ? `⚠ ATTENTION: Un tirage a dĂ©jĂ  Ă©tĂ© effectuĂ©!\n\nÊtes-vous ABSOLUMENT SÛR de vouloir effectuer un nouveau tirage parmi ${participants.length} participants Ă©ligibles?\n\nCeci remplacera le tirage prĂ©cĂ©dent!` + : `Êtes-vous sĂ»r de vouloir lancer le tirage au sort parmi ${participants.length} participants Ă©ligibles?\n\nCette action est irrĂ©versible!`; + + if (!confirm(confirmMessage)) return; + + setLoading(true); + try { + const token = localStorage.getItem('auth_token') || localStorage.getItem('token'); + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/draw/conduct`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + criteria: { + minTickets, + verified: verifiedOnly, + }, + prizeName, + prizeValue, + allowRedraw: hasExistingDraw, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Erreur lors du tirage'); + } + + const data = await response.json(); + setDrawResult(data.data); + setHasExistingDraw(true); + toast.success('🎉 Tirage au sort effectuĂ© avec succĂšs!'); + await checkExistingDraw(); + } catch (error: any) { + toast.error(error.message || 'Erreur lors du tirage'); + } finally { + setLoading(false); + } + }; + + const downloadReport = async () => { + if (!existingDraw) return; + + try { + const token = localStorage.getItem('auth_token') || localStorage.getItem('token'); + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/draw/${existingDraw.id}/report`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + if (!response.ok) throw new Error('Erreur lors de la gĂ©nĂ©ration du rapport'); + + const data = await response.json(); + const report = data.data; + + // CrĂ©er un rapport texte + const reportText = ` +================================================= + RAPPORT DE TIRAGE AU SORT - THÉ TIP TOP +================================================= + +📅 Date du tirage: ${new Date(report.draw.date).toLocaleString('fr-FR')} +đŸ‘€ EffectuĂ© par: ${report.draw.conductedBy.name} (${report.draw.conductedBy.email}) +📊 Statut: ${report.draw.status} + +------------------------------------------------- + GAGNANT +------------------------------------------------- +🏆 Nom: ${report.winner.firstName} ${report.winner.lastName} +📧 Email: ${report.winner.email} +đŸ“± TĂ©lĂ©phone: ${report.winner.phone || 'Non renseignĂ©'} +📍 Ville: ${report.winner.city || 'Non renseignĂ©e'} +đŸŽ« Nombre de tickets jouĂ©s: ${report.winner.totalTickets} + +------------------------------------------------- + PRIX +------------------------------------------------- +🎁 Nom: ${report.prize.name} +💰 Valeur: ${report.prize.value}€ + +------------------------------------------------- + STATISTIQUES +------------------------------------------------- +đŸ‘„ Total de participants: ${report.statistics.totalParticipants} +✅ Participants Ă©ligibles: ${report.statistics.eligibleParticipants} +📋 CritĂšres: + - Tickets minimum: ${report.statistics.criteria.minTickets} + - Email vĂ©rifiĂ©: ${report.statistics.criteria.verified ? 'Oui' : 'Non'} + +------------------------------------------------- + TICKETS DU GAGNANT +------------------------------------------------- +${report.winner.tickets.map((t: any, i: number) => + `${i + 1}. Code: ${t.code} | Lot: ${t.prize_name} | Statut: ${t.status} | JouĂ© le: ${new Date(t.played_at).toLocaleDateString('fr-FR')}` +).join('\n')} + +------------------------------------------------- +${report.draw.notifiedAt ? `📧 Gagnant notifiĂ© le: ${new Date(report.draw.notifiedAt).toLocaleString('fr-FR')}\n` : ''}${report.draw.claimedAt ? `✅ Lot rĂ©cupĂ©rĂ© le: ${new Date(report.draw.claimedAt).toLocaleString('fr-FR')}\n` : ''}------------------------------------------------- + +📝 Notes: ${report.draw.notes || 'Aucune note'} + +================================================= + GĂ©nĂ©rĂ© le ${new Date().toLocaleString('fr-FR')} +================================================= + `.trim(); + + // TĂ©lĂ©charger le rapport + const blob = new Blob([reportText], { type: 'text/plain;charset=utf-8' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `rapport-tirage-${existingDraw.id}.txt`; + link.click(); + + toast.success('Rapport tĂ©lĂ©chargĂ©!'); + } catch (error: any) { + toast.error(error.message || 'Erreur lors du tĂ©lĂ©chargement'); + } + }; + + const markAsNotified = async () => { + if (!existingDraw) return; + + try { + const token = localStorage.getItem('auth_token') || localStorage.getItem('token'); + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/draw/${existingDraw.id}/notify`, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + if (!response.ok) throw new Error('Erreur'); + + toast.success('Gagnant marquĂ© comme notifiĂ©'); + await checkExistingDraw(); + } catch (error: any) { + toast.error(error.message || 'Erreur'); + } + }; + + const markAsClaimed = async () => { + if (!existingDraw) return; + + const notes = prompt('Notes (optionnel):'); + + try { + const token = localStorage.getItem('auth_token') || localStorage.getItem('token'); + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/draw/${existingDraw.id}/claim`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ notes }), + } + ); + + if (!response.ok) throw new Error('Erreur'); + + toast.success('Lot marquĂ© comme rĂ©cupĂ©rĂ©'); + await checkExistingDraw(); + } catch (error: any) { + toast.error(error.message || 'Erreur'); + } + }; + + const deleteDraw = async () => { + if (!existingDraw) return; + + const confirmMessage = `⚠ ATTENTION: Cette action est IRRÉVERSIBLE!\n\nVoulez-vous vraiment annuler ce tirage au sort?\n\nGagnant: ${existingDraw.winner_name}\nEmail: ${existingDraw.winner_email}\nPrix: ${existingDraw.prize_name}`; + + if (!confirm(confirmMessage)) { + return; + } + + setLoading(true); + try { + const token = localStorage.getItem('auth_token') || localStorage.getItem('token'); + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/draw/${existingDraw.id}`, + { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + if (!response.ok) { + let errorMessage = 'Erreur lors de l\'annulation'; + try { + const error = await response.json(); + errorMessage = error.message || errorMessage; + } catch (e) { + // Si le parsing JSON Ă©choue, utiliser le message par dĂ©faut + errorMessage = `Erreur ${response.status}: ${response.statusText}`; + } + throw new Error(errorMessage); + } + + toast.success('đŸ—‘ïž Tirage au sort annulĂ© avec succĂšs!'); + setHasExistingDraw(false); + setExistingDraw(null); + setDrawResult(null); + setAllowRedraw(false); + await checkExistingDraw(); + await loadParticipants(); + } catch (error: any) { + console.error('Erreur lors de l\'annulation du tirage:', error); + toast.error(error.message || 'Erreur lors de l\'annulation'); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* En-tĂȘte avec titre du prix */} +
+

+ + Tirage au Sort - {prizeName} +

+

+ Prix Ă  gagner : {prizeValue}€ ‱ + Participants ayant jouĂ© au moins {minTickets} ticket{minTickets > 1 ? 's' : ''} +

+
+ + {/* Alerte si un tirage existe déjà */} + {hasExistingDraw && existingDraw && ( + +
+ +
+

Un tirage a déjà été effectué!

+
+

Date: {new Date(existingDraw.draw_date).toLocaleString('fr-FR')}

+

Gagnant: {existingDraw.winner_name} ({existingDraw.winner_email})

+

Prix: {existingDraw.prize_name} - {existingDraw.prize_value}€

+

Participants éligibles: {existingDraw.eligible_participants} / {existingDraw.total_participants}

+

+ Statut:{' '} + + {existingDraw.status} + +

+
+ +
+ + + {existingDraw.status === 'COMPLETED' && ( + + )} + + {existingDraw.status === 'NOTIFIED' && ( + + )} + + +
+
+
+
+ )} + + {/* Liste des participants éligibles */} + +
+
+

+ + Participants Éligibles +

+

+ {loading + ? 'Chargement en cours...' + : participants.length > 0 + ? `${participants.length} participant${participants.length > 1 ? 's' : ''} éligible${participants.length > 1 ? 's' : ''} au tirage` + : 'Aucun participant chargé'} +

+
+ +
+ + {loading && ( +
+ +

Chargement des participants éligibles...

+
+ )} + + {!loading && participants.length === 0 && ( +
+ +

Aucun participant éligible

+

+ Vérifiez que des participants ont joué au moins {minTickets} ticket{minTickets > 1 ? 's' : ''} +

+
+ )} + + {!loading && participants.length > 0 && ( + <> +
+ + + + + + + + + + + + + {participants.map((participant, index) => ( + + + + + + + + + ))} + +
+ # + + Nom Complet + + Email + + Tickets Joués + + Lots Gagnés + + Statut +
+ {index + 1} + +
+ + + {participant.first_name} {participant.last_name} + +
+
+ {participant.email} + + + {participant.tickets_played} + + + + {participant.prizes_won} + + + {participant.is_verified ? ( + + + Vérifié + + ) : ( + + + Non vérifié + + )} +
+
+ + {/* Bouton de tirage au sort */} +
+
+
+

+ PrĂȘt Ă  lancer le tirage au sort ? +

+

+ {participants.length} participant{participants.length > 1 ? 's ont' : ' a'} une chance Ă©gale de gagner {prizeName} ({prizeValue}€) +

+
+ +
+
+ + )} +
+ + {/* Résultat du tirage */} + {drawResult && ( + +
+ +

+ Félicitations au gagnant! +

+ +
+ +

+ {drawResult.winner.name} +

+

{drawResult.winner.email}

+

+ {drawResult.winner.ticketsPlayed} ticket(s) joué(s) +

+
+ +
+ +

+ {drawResult.prize.name} +

+

+ {drawResult.prize.value}€ +

+
+ +
+
+

+ {drawResult.statistics.totalParticipants} +

+

Total participants

+
+
+

+ {drawResult.statistics.eligibleParticipants} +

+

Éligibles

+
+
+

1

+

Gagnant

+
+
+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/app/admin/utilisateurs/page.tsx b/app/admin/utilisateurs/page.tsx new file mode 100644 index 0000000..f866077 --- /dev/null +++ b/app/admin/utilisateurs/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import UserManagement from "@/components/admin/UserManagement"; + +export default function AdminUtilisateursPage() { + return ( +
+
+

+ Gestion des utilisateurs +

+

+ Gérez tous les comptes utilisateurs de la plateforme +

+
+
+ +
+
+ ); +} diff --git a/app/client/page.tsx b/app/client/page.tsx new file mode 100644 index 0000000..dfc02c2 --- /dev/null +++ b/app/client/page.tsx @@ -0,0 +1,255 @@ +"use client"; +import { useEffect, useState } from "react"; +import { useAuth } from "@/contexts/AuthContext"; +import { useRouter } from "next/navigation"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card"; +import { Badge } from "@/components/ui/Badge"; +import Button from "@/components/Button"; +import { Loading } from "@/components/ui/Loading"; +import { Ticket } from "@/types"; +import { gameService } from "@/services/game.service"; +import { ROUTES, PRIZE_CONFIG } from "@/utils/constants"; +import Link from "next/link"; + +export default function ClientPage() { + const { user, isAuthenticated, isLoading: authLoading } = useAuth(); + const router = useRouter(); + const [tickets, setTickets] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [stats, setStats] = useState({ + total: 0, + claimed: 0, + pending: 0, + }); + + useEffect(() => { + if (!authLoading && !isAuthenticated) { + router.push(ROUTES.LOGIN); + return; + } + + if (isAuthenticated) { + loadUserTickets(); + } + }, [authLoading, isAuthenticated, router]); + + const loadUserTickets = async () => { + try { + const response = await gameService.getMyTickets(1, 100); + const ticketsData = response?.data || []; + setTickets(ticketsData); + + // Calculate stats + const total = ticketsData.length; + const claimed = ticketsData.filter((t: Ticket) => t.status === "CLAIMED").length; + const pending = ticketsData.filter((t: Ticket) => t.status === "PENDING").length; + + setStats({ total, claimed, pending }); + } catch (error) { + console.error("Error loading tickets:", error); + } finally { + setIsLoading(false); + } + }; + + if (authLoading || isLoading) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return null; + } + + const getStatusBadge = (status: string) => { + switch (status) { + case "CLAIMED": + return Réclamé; + case "PENDING": + return En attente; + case "REJECTED": + return Rejeté; + default: + return {status}; + } + }; + + return ( +
+ {/* Welcome Section */} +
+

+ Bonjour {user?.firstName} ! 👋 +

+

+ Bienvenue dans votre espace client +

+
+ + {/* Quick Action */} +
+ + +
+
+

+ Vous avez un nouveau ticket ? +

+

+ Entrez votre code et découvrez votre gain instantanément +

+
+ + + +
+
+
+
+ + {/* Statistics Cards */} +
+ + +
+
+

+ Total Participations +

+

+ {stats.total} +

+
+
đŸŽ«
+
+
+
+ + + +
+
+

+ Gains réclamés +

+

+ {stats.claimed} +

+
+
✅
+
+
+
+ + + +
+
+

+ En attente +

+

+ {stats.pending} +

+
+
⏳
+
+
+
+
+ + {/* Recent Tickets */} + + +
+ Mes derniers tickets + + + +
+
+ + {tickets.length === 0 ? ( +
+
đŸŽČ
+

+ Vous n'avez pas encore participé au jeu +

+ + + +
+ ) : ( +
+ + + + + + + + + + + {tickets.slice(0, 5).map((ticket) => { + const prizeConfig = ticket.prize + ? PRIZE_CONFIG[ticket.prize.type] + : null; + + return ( + + + + + + + ); + })} + +
+ Code Ticket + + Gain + + Statut + + Date +
+ + {ticket.code} + + +
+ {prizeConfig && ( + <> + + {prizeConfig.icon} + + + {prizeConfig.name} + + + )} +
+
+ {getStatusBadge(ticket.status)} + + {ticket.playedAt ? new Date(ticket.playedAt).toLocaleDateString("fr-FR") : "-"} +
+
+ )} +
+
+
+ ); +} diff --git a/app/contact/page.tsx b/app/contact/page.tsx new file mode 100644 index 0000000..5509819 --- /dev/null +++ b/app/contact/page.tsx @@ -0,0 +1,342 @@ +'use client'; + +import { useState } from "react"; +import type { Metadata } from "next"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import Button from "@/components/Button"; + +export default function ContactPage() { + const [formData, setFormData] = useState({ + fullName: '', + email: '', + subject: '', + message: '', + notRobot: false, + }); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleCheckboxChange = (e: React.ChangeEvent) => { + setFormData(prev => ({ ...prev, notRobot: e.target.checked })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + + // Simulation d'envoi + await new Promise(resolve => setTimeout(resolve, 1500)); + + console.log('Form submitted:', formData); + alert('Votre message a été envoyé avec succÚs !'); + + // Reset form + setFormData({ + fullName: '', + email: '', + subject: '', + message: '', + notRobot: false, + }); + setIsSubmitting(false); + }; + + return ( +
+ {/* Hero Section */} +
+

+ Contactez-nous +

+

+ Une question, une suggestion ? Notre équipe est là pour vous accompagner. +

+
+ +
+ {/* Contact Form */} +
+ + + + Envoyez-nous un message + + + +
+ {/* Nom complet */} +
+ + +
+ + {/* Email */} +
+ + +
+ + {/* Sujet */} +
+ + +
+ + {/* Message */} +
+ +