This commit is contained in:
soufiane 2025-11-17 23:38:02 +01:00
parent ed75871a28
commit 2f7abde4ea
107 changed files with 18241 additions and 131 deletions

243
README.md
View File

@ -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 <repo-url>
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.

232
app/about/page.tsx Normal file
View File

@ -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 (
<div className="py-12">
{/* Hero Section */}
<section className="text-center mb-16">
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
À propos de Thé Tip Top Nice
</h1>
<p className="text-lg md:text-xl text-gray-600 max-w-3xl mx-auto mb-8">
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.
</p>
<Link href="#histoire">
<Button size="lg" className="px-8">
Explorer notre univers
</Button>
</Link>
</section>
{/* Histoire Section */}
<section id="histoire" className="mb-16">
<div className="grid lg:grid-cols-2 gap-12 items-center">
<div>
<h2 className="text-3xl font-bold text-gray-900 mb-6">
Notre histoire
</h2>
<p className="text-lg text-gray-700 mb-4 font-semibold">
Quinze ans de passion infusée.
</p>
<p className="text-gray-600 leading-relaxed mb-4">
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.
</p>
<p className="text-gray-600 leading-relaxed">
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.
</p>
</div>
{/* Image Placeholder */}
<div className="relative">
<div className="bg-gradient-to-br from-primary-100 to-green-100 rounded-2xl overflow-hidden aspect-square flex items-center justify-center">
<div className="text-center p-8">
<div className="mb-4 flex justify-center">
<Image
src="/logos/logo.svg"
alt="Logo Thé Tip Top"
width={200}
height={200}
className="object-contain"
/>
</div>
<p className="text-gray-600 font-medium">Notre boutique à Nice</p>
<p className="text-sm text-gray-500 mt-2">Image à venir</p>
</div>
</div>
</div>
</div>
</section>
{/* Engagements Section */}
<section className="mb-16">
<h2 className="text-3xl font-bold text-center text-gray-900 mb-12">
Nos engagements
</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Engagement 1 */}
<Card className="text-center hover:shadow-lg transition-shadow">
<CardContent className="pt-8">
<div className="text-5xl mb-4">🌱</div>
<h3 className="text-xl font-bold mb-3 text-gray-900">
Agriculture responsable
</h3>
<p className="text-gray-600 text-sm">
Des jardins certifiés et des pratiques respectueuses de la biodiversité,
pour des thés propres et expressifs.
</p>
</CardContent>
</Card>
{/* Engagement 2 */}
<Card className="text-center hover:shadow-lg transition-shadow">
<CardContent className="pt-8">
<div className="text-5xl mb-4">🤝</div>
<h3 className="text-xl font-bold mb-3 text-gray-900">
Filière équitable
</h3>
<p className="text-gray-600 text-sm">
Des partenariats durables et rémunérateurs pour les fermes qui nous
confient leurs récoltes.
</p>
</CardContent>
</Card>
{/* Engagement 3 */}
<Card className="text-center hover:shadow-lg transition-shadow">
<CardContent className="pt-8">
<div className="text-5xl mb-4"></div>
<h3 className="text-xl font-bold mb-3 text-gray-900">
Fraîcheur & préparation
</h3>
<p className="text-gray-600 text-sm">
Conditionnement soigné et rotations courtes pour préserver les arômes.
</p>
</CardContent>
</Card>
{/* Engagement 4 */}
<Card className="text-center hover:shadow-lg transition-shadow">
<CardContent className="pt-8">
<div className="text-5xl mb-4">📚</div>
<h3 className="text-xl font-bold mb-3 text-gray-900">
Conseil & transmission
</h3>
<p className="text-gray-600 text-sm">
Ateliers, initiations et fiches pratiques : nous aimons partager nos
méthodes d'infusion.
</p>
</CardContent>
</Card>
</div>
</section>
{/* CTA Section */}
<section className="mb-16">
<Card className="max-w-4xl mx-auto bg-gradient-to-r from-primary-600 to-primary-700 text-white">
<CardContent className="py-12 text-center">
<h2 className="text-3xl font-bold mb-4 text-white">
Participez à notre grand jeu autour du thé !
</h2>
<p className="text-lg mb-8 text-white">
À gagner : sélections premium, accessoires de dégustation et surprises maison.
Chaque achat vous donne une chance supplémentaire.
</p>
<Link href={ROUTES.GAME}>
<Button variant="outline" size="lg" className="bg-white text-primary-600 hover:bg-primary-50 border-white">
Découvrir le jeu
</Button>
</Link>
</CardContent>
</Card>
</section>
{/* Quote Section */}
<section className="mb-16">
<div className="max-w-3xl mx-auto text-center">
<blockquote className="text-2xl md:text-3xl font-light text-gray-700 italic mb-4">
« Un thé bien infusé, c'est une minute pour soi et un souvenir à partager. »
</blockquote>
<p className="text-gray-600 font-medium"> L'équipe Thé Tip Top</p>
</div>
</section>
{/* Info Section */}
<section className="bg-gradient-to-r from-primary-50 to-green-50 py-12 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
<div className="max-w-5xl mx-auto">
<div className="grid md:grid-cols-3 gap-8 text-center">
{/* Localisation */}
<Card>
<CardHeader>
<div className="text-4xl mb-2">📍</div>
<CardTitle className="text-xl"> nous trouver</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600">
18 Avenue Thiers<br />
06000 Nice, France
</p>
<p className="text-sm text-gray-500 mt-2">
Au cœur de Nice, proches des transports.
</p>
</CardContent>
</Card>
{/* Horaires */}
<Card>
<CardHeader>
<div className="text-4xl mb-2">🕐</div>
<CardTitle className="text-xl">Horaires</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600">
Du mardi au samedi<br />
10h 19h
</p>
<p className="text-sm text-gray-500 mt-2">
Fermé dimanche et lundi
</p>
</CardContent>
</Card>
{/* Contact */}
<Card>
<CardHeader>
<div className="text-4xl mb-2"></div>
<CardTitle className="text-xl">Contact</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 mb-2">
<a href="mailto:contact@thetiptop.fr" className="hover:text-primary-600 transition-colors">
contact@thetiptop.fr
</a>
</p>
<p className="text-gray-600 mb-2">
<a href="tel:+33123456789" className="hover:text-primary-600 transition-colors">
01 23 45 67 89
</a>
</p>
<p className="text-sm text-gray-500 mt-2">
Conseils personnalisés par e-mail ou en boutique.
</p>
</CardContent>
</Card>
</div>
</div>
</section>
</div>
);
}

View File

@ -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<AdminStatistics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="p-8">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-32 bg-gray-200 rounded"></div>
))}
</div>
</div>
</div>
);
}
if (error || !stats) {
return (
<div className="p-8">
<div className="bg-red-50 border border-red-200 text-red-800 px-6 py-4 rounded-lg mb-6 flex items-center gap-3">
<AlertCircle className="w-5 h-5" />
<span>{error || "Erreur lors du chargement des statistiques"}</span>
</div>
<button
onClick={loadStatistics}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition"
>
Réessayer
</button>
</div>
);
}
// 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 (
<div className="p-8 bg-gray-50 min-h-screen">
{/* Header avec contrôles */}
<div className="mb-8">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900">Dashboard Administrateur Avancé</h1>
<p className="text-gray-600 mt-2">
Statistiques complètes et analyses en temps réel
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
{/* Auto-refresh toggle */}
<label className="flex items-center gap-2 bg-white px-4 py-2 rounded-lg border border-gray-200">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="rounded"
/>
<span className="text-sm">Auto-refresh ({refreshInterval}s)</span>
</label>
{/* Export button */}
<button
onClick={exportToCSV}
className="flex items-center gap-2 bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition"
>
<Download className="w-4 h-4" />
Export CSV
</button>
{/* Refresh button */}
<button
onClick={loadStatistics}
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
>
<RefreshCw className="w-4 h-4" />
Rafraîchir
</button>
</div>
</div>
{/* Filter period */}
<div className="mt-4 flex items-center gap-2">
<Filter className="w-4 h-4 text-gray-600" />
<span className="text-sm text-gray-600">Période:</span>
{["all", "week", "month", "year"].map((period) => (
<button
key={period}
onClick={() => setSelectedPeriod(period)}
className={`px-3 py-1 rounded text-sm ${
selectedPeriod === period
? "bg-blue-600 text-white"
: "bg-white text-gray-700 border border-gray-200 hover:bg-gray-50"
}`}
>
{period === "all" && "Tout"}
{period === "week" && "Semaine"}
{period === "month" && "Mois"}
{period === "year" && "Année"}
</button>
))}
</div>
</div>
{/* Stats Cards - Principales */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatCard
title="Utilisateurs"
value={stats?.users?.total || 0}
icon={<Users className="w-6 h-6" />}
color="blue"
link="/admin/utilisateurs"
trend="+12%"
/>
<StatCard
title="Tickets Distribués"
value={stats?.tickets?.distributed || 0}
subtitle={`${ticketDistributedPercent}% du total`}
icon={<Ticket className="w-6 h-6" />}
color="green"
link="/admin/tickets"
trend="+8%"
/>
<StatCard
title="Tickets Utilisés"
value={stats?.tickets?.used || 0}
subtitle={`${ticketUsedPercent}% des distribués`}
icon={<BarChart3 className="w-6 h-6" />}
color="purple"
link="/admin/tickets"
trend="+15%"
/>
<StatCard
title="Lots Gagnés"
value={stats?.prizes?.distributed || 0}
icon={<Gift className="w-6 h-6" />}
color="yellow"
link="/admin/tickets"
trend="+10%"
/>
</div>
{/* Graphiques en ligne */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Graphique répartition des lots */}
{prizeChartData.length > 0 && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Gift className="w-5 h-5" />
Répartition des Lots
</h2>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={prizeChartData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percentage }) => `${name}: ${percentage}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{prizeChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
)}
{/* Graphique répartition par genre */}
{genderChartData.length > 0 && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<UserIcon className="w-5 h-5" />
Répartition par Genre
</h2>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={genderChartData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, value }) => `${name}: ${value}`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{genderChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
)}
</div>
{/* Graphique tranches d'âge */}
{ageChartData.length > 0 && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Calendar className="w-5 h-5" />
Répartition par Âge
</h2>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={ageChartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="range" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="count" fill="#3b82f6" name="Nombre de participants" />
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* Section détaillée existante */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Statistiques Tickets détaillées */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
<Ticket className="w-5 h-5" />
Statistiques des Tickets
</h2>
<Link
href="/admin/tickets"
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
>
Voir tout
</Link>
</div>
<div className="space-y-3">
<StatRow label="Total des tickets" value={stats?.tickets?.total || 0} />
<StatRow
label="Tickets distribués"
value={stats?.tickets?.distributed || 0}
color="green"
percentage={ticketDistributedPercent}
/>
<StatRow
label="Tickets utilisés"
value={stats?.tickets?.used || 0}
color="purple"
percentage={ticketUsedPercent}
/>
<StatRow label="En attente" value={stats?.tickets?.pending || 0} color="yellow" />
<StatRow label="Réclamés" value={stats?.tickets?.claimed || 0} color="green" />
<StatRow label="Rejetés" value={stats?.tickets?.rejected || 0} color="red" />
</div>
</div>
{/* Utilisateurs */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
<Users className="w-5 h-5" />
Utilisateurs
</h2>
<Link
href="/admin/utilisateurs"
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
>
Voir tout
</Link>
</div>
<div className="space-y-3">
<StatRow label="Total" value={stats?.users?.total || 0} />
<StatRow label="Clients" value={stats?.users?.clients || 0} color="green" />
<StatRow label="Employés" value={stats?.users?.employees || 0} color="purple" />
<StatRow label="Admins" value={stats?.users?.admins || 0} color="blue" />
<StatRow
label="Emails vérifiés"
value={stats?.users?.verifiedEmails || 0}
color="green"
/>
</div>
</div>
</div>
{/* Top Villes */}
{stats?.demographics?.topCities && stats.demographics.topCities.length > 0 && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<MapPin className="w-5 h-5" />
Top 10 Villes des Participants
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{stats.demographics.topCities.slice(0, 10).map((city, idx) => (
<CityCard
key={idx}
rank={idx + 1}
city={city.city}
count={city.count}
percentage={city.percentage}
/>
))}
</div>
</div>
)}
</div>
);
}
// 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 (
<Link
href={link}
className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition"
>
<div className="flex items-center justify-between mb-4">
<div className={`p-3 rounded-lg ${colors[color]}`}>{icon}</div>
{trend && (
<div className="flex items-center gap-1 text-green-600 text-sm font-medium">
<TrendingUp className="w-4 h-4" />
{trend}
</div>
)}
</div>
<h3 className="text-sm font-medium text-gray-600 mb-1">{title}</h3>
<p className="text-3xl font-bold text-gray-900">{(value || 0).toLocaleString("fr-FR")}</p>
{subtitle && <p className="text-xs text-gray-500 mt-1">{subtitle}</p>}
</Link>
);
}
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 (
<div className="flex items-center justify-between py-2 border-b border-gray-100 last:border-0">
<span className="text-gray-700">{label}</span>
<div className="flex items-center gap-2">
<span className={`font-semibold ${color ? colors[color] : "text-gray-900"}`}>
{(value || 0).toLocaleString("fr-FR")}
</span>
{percentage !== undefined && <span className="text-xs text-gray-500">({percentage}%)</span>}
</div>
</div>
);
}
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 (
<div className={`rounded-lg border-2 p-4 ${rankColor} hover:shadow-md transition`}>
<div className="flex items-center justify-between mb-2">
<span className="text-lg font-bold">#{rank}</span>
<MapPin className="w-4 h-4" />
</div>
<h4 className="font-semibold text-sm mb-1 truncate" title={city}>
{city}
</h4>
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold">{count}</span>
<span className="text-xs">({percentage.toFixed(1)}%)</span>
</div>
</div>
);
}

View File

@ -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<AdminStatistics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="p-8">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-32 bg-gray-200 rounded"></div>
))}
</div>
</div>
</div>
);
}
if (error || !stats) {
return (
<div className="p-8">
<div className="bg-red-50 border border-red-200 text-red-800 px-6 py-4 rounded-lg mb-6 flex items-center gap-3">
<AlertCircle className="w-5 h-5" />
<span>{error || "Erreur lors du chargement des statistiques"}</span>
</div>
<button
onClick={loadStatistics}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition"
>
Réessayer
</button>
</div>
);
}
// 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 (
<div className="p-8 bg-gray-50 min-h-screen">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Dashboard Administrateur</h1>
<p className="text-gray-600 mt-2">
Statistiques complètes du jeu-concours Thé Tip Top
</p>
</div>
<button
onClick={loadStatistics}
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
>
<RefreshCw className="w-4 h-4" />
Rafraîchir
</button>
</div>
{/* Stats Cards - Principales */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatCard
title="Utilisateurs"
value={stats?.users?.total || 0}
icon={<Users className="w-6 h-6" />}
color="blue"
link="/admin/utilisateurs"
/>
<StatCard
title="Tickets Distribués"
value={stats?.tickets?.distributed || 0}
subtitle={`${ticketDistributedPercent}% du total`}
icon={<Ticket className="w-6 h-6" />}
color="green"
link="/admin/tickets"
/>
<StatCard
title="Tickets Utilisés"
value={stats?.tickets?.used || 0}
subtitle={`${ticketUsedPercent}% des distribués`}
icon={<BarChart3 className="w-6 h-6" />}
color="purple"
link="/admin/tickets"
/>
<StatCard
title="Lots Gagnés"
value={stats?.prizes?.distributed || 0}
icon={<Gift className="w-6 h-6" />}
color="yellow"
link="/admin/tickets"
/>
</div>
{/* Section des Tickets */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Statistiques Tickets détaillées */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
<Ticket className="w-5 h-5" />
Statistiques des Tickets
</h2>
<Link
href="/admin/tickets"
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
>
Voir tout
</Link>
</div>
<div className="space-y-3">
<StatRow
label="Total des tickets"
value={stats?.tickets?.total || 0}
/>
<StatRow
label="Tickets distribués"
value={stats?.tickets?.distributed || 0}
color="green"
percentage={ticketDistributedPercent}
/>
<StatRow
label="Tickets utilisés"
value={stats?.tickets?.used || 0}
color="purple"
percentage={ticketUsedPercent}
/>
<StatRow
label="En attente"
value={stats?.tickets?.pending || 0}
color="yellow"
/>
<StatRow
label="Réclamés"
value={stats?.tickets?.claimed || 0}
color="green"
/>
<StatRow
label="Rejetés"
value={stats?.tickets?.rejected || 0}
color="red"
/>
</div>
</div>
{/* Utilisateurs */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
<Users className="w-5 h-5" />
Utilisateurs
</h2>
<Link
href="/admin/utilisateurs"
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
>
Voir tout
</Link>
</div>
<div className="space-y-3">
<StatRow label="Total" value={stats?.users?.total || 0} />
<StatRow label="Clients" value={stats?.users?.clients || 0} color="green" />
<StatRow label="Employés" value={stats?.users?.employees || 0} color="purple" />
<StatRow label="Admins" value={stats?.users?.admins || 0} color="blue" />
<StatRow
label="Emails vérifiés"
value={stats?.users?.verifiedEmails || 0}
color="green"
/>
</div>
</div>
</div>
{/* Répartition des Lots par Catégorie */}
{stats?.prizes?.byCategory && stats.prizes.byCategory.length > 0 && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2 mb-6">
<Gift className="w-5 h-5" />
Répartition des Lots Gagnés par Catégorie
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{stats.prizes.byCategory.map((prize) => (
<PrizeCard
key={prize.prizeId}
name={prize.prizeName}
type={prize.prizeType}
count={prize.count}
percentage={prize.percentage}
/>
))}
</div>
</div>
)}
{/* Statistiques Démographiques */}
{stats?.demographics && (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<UserIcon className="w-6 h-6" />
Statistiques Démographiques des Participants
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Répartition par Genre */}
{stats.demographics.gender && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<UserIcon className="w-5 h-5" />
Répartition par Genre
</h3>
<div className="space-y-3">
<DemoStatRow
label="Hommes"
value={stats.demographics.gender.male}
total={stats.users.total}
color="blue"
/>
<DemoStatRow
label="Femmes"
value={stats.demographics.gender.female}
total={stats.users.total}
color="pink"
/>
<DemoStatRow
label="Autre"
value={stats.demographics.gender.other}
total={stats.users.total}
color="purple"
/>
<DemoStatRow
label="Non spécifié"
value={stats.demographics.gender.notSpecified}
total={stats.users.total}
color="gray"
/>
</div>
</div>
)}
{/* Répartition par Âge */}
{stats.demographics.ageRanges && stats.demographics.ageRanges.length > 0 && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Calendar className="w-5 h-5" />
Répartition par Âge
</h3>
<div className="space-y-3">
{stats.demographics.ageRanges.map((range, idx) => (
<DemoStatRow
key={idx}
label={range.range}
value={range.count}
percentage={range.percentage}
color="green"
/>
))}
</div>
</div>
)}
</div>
{/* Top Villes */}
{stats.demographics.topCities && stats.demographics.topCities.length > 0 && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<MapPin className="w-5 h-5" />
Top 10 Villes des Participants
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{stats.demographics.topCities.slice(0, 10).map((city, idx) => (
<CityCard
key={idx}
rank={idx + 1}
city={city.city}
count={city.count}
percentage={city.percentage}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
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 (
<Link
href={link}
className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition"
>
<div className="flex items-center justify-between mb-4">
<div className={`p-3 rounded-lg ${colors[color]}`}>{icon}</div>
<TrendingUp className="w-5 h-5 text-gray-400" />
</div>
<h3 className="text-sm font-medium text-gray-600 mb-1">{title}</h3>
<p className="text-3xl font-bold text-gray-900">
{(value || 0).toLocaleString("fr-FR")}
</p>
{subtitle && (
<p className="text-xs text-gray-500 mt-1">{subtitle}</p>
)}
</Link>
);
}
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 (
<div className="flex items-center justify-between py-2 border-b border-gray-100 last:border-0">
<span className="text-gray-700">{label}</span>
<div className="flex items-center gap-2">
<span
className={`font-semibold ${color ? colors[color] : "text-gray-900"}`}
>
{(value || 0).toLocaleString("fr-FR")}
</span>
{percentage !== undefined && (
<span className="text-xs text-gray-500">({percentage}%)</span>
)}
</div>
</div>
);
}
// 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 (
<div className="bg-gradient-to-br from-gray-50 to-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h4 className="font-semibold text-gray-900 text-sm mb-1">{name}</h4>
<span className={`text-xs px-2 py-1 rounded ${color}`}>
{type === "PHYSICAL" ? "Physique" : "Réduction"}
</span>
</div>
</div>
<div className="flex items-end justify-between mt-3">
<div>
<p className="text-2xl font-bold text-gray-900">{count}</p>
<p className="text-xs text-gray-500">distribués</p>
</div>
<div className="text-right">
<p className="text-lg font-semibold text-blue-600">{percentage.toFixed(1)}%</p>
</div>
</div>
{/* Barre de progression */}
<div className="mt-3 bg-gray-200 rounded-full h-2 overflow-hidden">
<div
className="bg-blue-600 h-full rounded-full transition-all"
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
</div>
);
}
// 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 (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">{label}</span>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900">
{value.toLocaleString("fr-FR")}
</span>
<span className="text-xs text-gray-500">
({calculatedPercentage.toFixed(1)}%)
</span>
</div>
</div>
<div className="bg-gray-200 rounded-full h-2 overflow-hidden">
<div
className={`${barColor} h-full rounded-full transition-all`}
style={{ width: `${Math.min(calculatedPercentage, 100)}%` }}
/>
</div>
</div>
);
}
// 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 (
<div className={`rounded-lg border-2 p-4 ${rankColor} hover:shadow-md transition`}>
<div className="flex items-center justify-between mb-2">
<span className="text-lg font-bold">#{rank}</span>
<MapPin className="w-4 h-4" />
</div>
<h4 className="font-semibold text-sm mb-1 truncate" title={city}>
{city}
</h4>
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold">{count}</span>
<span className="text-xs">({percentage.toFixed(1)}%)</span>
</div>
</div>
);
}

View File

@ -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<AdminStatistics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="p-8">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-32 bg-gray-200 rounded"></div>
))}
</div>
</div>
</div>
);
}
if (error || !stats) {
return (
<div className="p-8">
<div className="bg-red-50 border border-red-200 text-red-800 px-6 py-4 rounded-lg mb-6 flex items-center gap-3">
<AlertCircle className="w-5 h-5" />
<span>{error || "Erreur lors du chargement des statistiques"}</span>
</div>
<button
onClick={loadStatistics}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition"
>
Réessayer
</button>
</div>
);
}
// 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 (
<div className="p-8 bg-gray-50 min-h-screen">
{/* Header avec contrôles */}
<div className="mb-8">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900">Dashboard Administrateur Avancé</h1>
<p className="text-gray-600 mt-2">
Statistiques complètes et analyses en temps réel
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
{/* Auto-refresh toggle */}
<label className="flex items-center gap-2 bg-white px-4 py-2 rounded-lg border border-gray-200">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="rounded"
/>
<span className="text-sm">Auto-refresh ({refreshInterval}s)</span>
</label>
{/* Export button */}
<button
onClick={exportToCSV}
className="flex items-center gap-2 bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition"
>
<Download className="w-4 h-4" />
Export CSV
</button>
{/* Refresh button */}
<button
onClick={loadStatistics}
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
>
<RefreshCw className="w-4 h-4" />
Rafraîchir
</button>
</div>
</div>
{/* Filter period */}
<div className="mt-4 flex items-center gap-2">
<Filter className="w-4 h-4 text-gray-600" />
<span className="text-sm text-gray-600">Période:</span>
{["all", "week", "month", "year"].map((period) => (
<button
key={period}
onClick={() => setSelectedPeriod(period)}
className={`px-3 py-1 rounded text-sm ${
selectedPeriod === period
? "bg-blue-600 text-white"
: "bg-white text-gray-700 border border-gray-200 hover:bg-gray-50"
}`}
>
{period === "all" && "Tout"}
{period === "week" && "Semaine"}
{period === "month" && "Mois"}
{period === "year" && "Année"}
</button>
))}
</div>
</div>
{/* Stats Cards - Principales */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatCard
title="Utilisateurs"
value={stats?.users?.total || 0}
icon={<Users className="w-6 h-6" />}
color="blue"
link="/admin/utilisateurs"
trend="+12%"
/>
<StatCard
title="Tickets Distribués"
value={stats?.tickets?.distributed || 0}
subtitle={`${ticketDistributedPercent}% du total`}
icon={<Ticket className="w-6 h-6" />}
color="green"
link="/admin/tickets"
trend="+8%"
/>
<StatCard
title="Tickets Utilisés"
value={stats?.tickets?.used || 0}
subtitle={`${ticketUsedPercent}% des distribués`}
icon={<BarChart3 className="w-6 h-6" />}
color="purple"
link="/admin/tickets"
trend="+15%"
/>
<StatCard
title="Lots Gagnés"
value={stats?.prizes?.distributed || 0}
icon={<Gift className="w-6 h-6" />}
color="yellow"
link="/admin/tickets"
trend="+10%"
/>
</div>
{/* Graphiques en ligne */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Graphique répartition des lots */}
{prizeChartData.length > 0 && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Gift className="w-5 h-5" />
Répartition des Lots
</h2>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={prizeChartData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percentage }) => `${name}: ${percentage}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{prizeChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
)}
{/* Graphique répartition par genre */}
{genderChartData.length > 0 && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<UserIcon className="w-5 h-5" />
Répartition par Genre
</h2>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={genderChartData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, value }) => `${name}: ${value}`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{genderChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
)}
</div>
{/* Graphique tranches d'âge */}
{ageChartData.length > 0 && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Calendar className="w-5 h-5" />
Répartition par Âge
</h2>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={ageChartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="range" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="count" fill="#3b82f6" name="Nombre de participants" />
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* Section détaillée existante */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Statistiques Tickets détaillées */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
<Ticket className="w-5 h-5" />
Statistiques des Tickets
</h2>
<Link
href="/admin/tickets"
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
>
Voir tout
</Link>
</div>
<div className="space-y-3">
<StatRow label="Total des tickets" value={stats?.tickets?.total || 0} />
<StatRow
label="Tickets distribués"
value={stats?.tickets?.distributed || 0}
color="green"
percentage={ticketDistributedPercent}
/>
<StatRow
label="Tickets utilisés"
value={stats?.tickets?.used || 0}
color="purple"
percentage={ticketUsedPercent}
/>
<StatRow label="En attente" value={stats?.tickets?.pending || 0} color="yellow" />
<StatRow label="Réclamés" value={stats?.tickets?.claimed || 0} color="green" />
<StatRow label="Rejetés" value={stats?.tickets?.rejected || 0} color="red" />
</div>
</div>
{/* Utilisateurs */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
<Users className="w-5 h-5" />
Utilisateurs
</h2>
<Link
href="/admin/utilisateurs"
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
>
Voir tout
</Link>
</div>
<div className="space-y-3">
<StatRow label="Total" value={stats?.users?.total || 0} />
<StatRow label="Clients" value={stats?.users?.clients || 0} color="green" />
<StatRow label="Employés" value={stats?.users?.employees || 0} color="purple" />
<StatRow label="Admins" value={stats?.users?.admins || 0} color="blue" />
<StatRow
label="Emails vérifiés"
value={stats?.users?.verifiedEmails || 0}
color="green"
/>
</div>
</div>
</div>
{/* Top Villes */}
{stats?.demographics?.topCities && stats.demographics.topCities.length > 0 && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<MapPin className="w-5 h-5" />
Top 10 Villes des Participants
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{stats.demographics.topCities.slice(0, 10).map((city, idx) => (
<CityCard
key={idx}
rank={idx + 1}
city={city.city}
count={city.count}
percentage={city.percentage}
/>
))}
</div>
</div>
)}
</div>
);
}
// 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 (
<Link
href={link}
className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition"
>
<div className="flex items-center justify-between mb-4">
<div className={`p-3 rounded-lg ${colors[color]}`}>{icon}</div>
{trend && (
<div className="flex items-center gap-1 text-green-600 text-sm font-medium">
<TrendingUp className="w-4 h-4" />
{trend}
</div>
)}
</div>
<h3 className="text-sm font-medium text-gray-600 mb-1">{title}</h3>
<p className="text-3xl font-bold text-gray-900">{(value || 0).toLocaleString("fr-FR")}</p>
{subtitle && <p className="text-xs text-gray-500 mt-1">{subtitle}</p>}
</Link>
);
}
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 (
<div className="flex items-center justify-between py-2 border-b border-gray-100 last:border-0">
<span className="text-gray-700">{label}</span>
<div className="flex items-center gap-2">
<span className={`font-semibold ${color ? colors[color] : "text-gray-900"}`}>
{(value || 0).toLocaleString("fr-FR")}
</span>
{percentage !== undefined && <span className="text-xs text-gray-500">({percentage}%)</span>}
</div>
</div>
);
}
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 (
<div className={`rounded-lg border-2 p-4 ${rankColor} hover:shadow-md transition`}>
<div className="flex items-center justify-between mb-2">
<span className="text-lg font-bold">#{rank}</span>
<MapPin className="w-4 h-4" />
</div>
<h4 className="font-semibold text-sm mb-1 truncate" title={city}>
{city}
</h4>
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold">{count}</span>
<span className="text-xs">({percentage.toFixed(1)}%)</span>
</div>
</div>
);
}

81
app/admin/layout.tsx Normal file
View File

@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Loading size="lg" />
</div>
);
}
if (!isAuthenticated || (user?.role !== "ADMIN" && user?.role !== "admin")) {
return null;
}
return (
<div className="flex min-h-screen bg-gray-50">
<Sidebar />
<div className="flex-1 flex flex-col">
{/* Admin Header */}
<header className="bg-white shadow-sm border-b border-gray-200 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Logo size="md" showText={false} />
<div>
<h1 className="text-2xl font-bold text-gray-900">Thé Tip Top - Administration</h1>
<p className="text-sm text-gray-500 mt-1">
Connecté en tant que <span className="font-medium">{user?.firstName} {user?.lastName}</span>
</p>
</div>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
<LogOut className="w-4 h-4" />
Déconnexion
</button>
</div>
</header>
{/* Main Content */}
<main className="flex-1 overflow-auto">{children}</main>
</div>
</div>
);
}

7
app/admin/lots/page.tsx Normal file
View File

@ -0,0 +1,7 @@
'use client';
import PrizeManagement from '@/components/admin/PrizeManagement';
export default function LotsPage() {
return <PrizeManagement />;
}

View File

@ -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<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:', 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 (
<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) => `${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-3 gap-4">
<div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={filters.verified}
onChange={(e) => setFilters({ ...filters, verified: e.target.checked })}
className="w-4 h-4"
/>
<span className="text-sm text-gray-700">Emails vérifiés uniquement</span>
</label>
</div>
<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>
);
}

40
app/admin/page.tsx Normal file
View File

@ -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 (
<div className="min-h-[calc(100vh-8rem)] flex items-center justify-center">
<Loading size="lg" />
</div>
);
}
return null;
}

View File

@ -0,0 +1,21 @@
"use client";
import TicketManagement from "@/components/admin/TicketManagement";
export default function AdminTicketsPage() {
return (
<div className="p-8">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900">
Gestion des tickets
</h1>
<p className="text-gray-600 mt-2">
Consultez et gérez tous les tickets du jeu-concours
</p>
</div>
<div className="bg-white rounded-lg shadow-sm">
<TicketManagement />
</div>
</div>
);
}

648
app/admin/tirages/page.tsx Normal file
View File

@ -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<Participant[]>([]);
const [loading, setLoading] = useState(false);
const [drawResult, setDrawResult] = useState<DrawResult | null>(null);
const [existingDraw, setExistingDraw] = useState<ExistingDraw | null>(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 (
<div className="p-6 max-w-7xl mx-auto">
{/* En-tête avec titre du prix */}
<div className="mb-6">
<h1 className="text-3xl font-bold mb-2 flex items-center gap-3">
<Trophy className="w-10 h-10 text-yellow-600" />
Tirage au Sort - {prizeName}
</h1>
<p className="text-gray-600 text-lg">
Prix à gagner : <span className="font-bold text-purple-600">{prizeValue}</span>
Participants ayant joué au moins {minTickets} ticket{minTickets > 1 ? 's' : ''}
</p>
</div>
{/* Alerte si un tirage existe déjà */}
{hasExistingDraw && existingDraw && (
<Card className="p-6 mb-6 border-yellow-500 bg-yellow-50">
<div className="flex items-start gap-4">
<AlertCircle className="w-6 h-6 text-yellow-600 flex-shrink-0 mt-1" />
<div className="flex-1">
<h3 className="font-bold text-yellow-900 mb-2">Un tirage a déjà é effectué!</h3>
<div className="space-y-1 text-sm text-yellow-800">
<p><strong>Date:</strong> {new Date(existingDraw.draw_date).toLocaleString('fr-FR')}</p>
<p><strong>Gagnant:</strong> {existingDraw.winner_name} ({existingDraw.winner_email})</p>
<p><strong>Prix:</strong> {existingDraw.prize_name} - {existingDraw.prize_value}</p>
<p><strong>Participants éligibles:</strong> {existingDraw.eligible_participants} / {existingDraw.total_participants}</p>
<p>
<strong>Statut:</strong>{' '}
<span
className={`px-2 py-1 rounded text-xs ${
existingDraw.status === 'CLAIMED'
? 'bg-green-100 text-green-800'
: existingDraw.status === 'NOTIFIED'
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{existingDraw.status}
</span>
</p>
</div>
<div className="flex gap-2 mt-4 flex-wrap">
<Button onClick={downloadReport} size="sm" variant="outline">
<Download className="w-4 h-4 mr-1" />
Télécharger rapport
</Button>
{existingDraw.status === 'COMPLETED' && (
<Button onClick={markAsNotified} size="sm" variant="outline">
<Mail className="w-4 h-4 mr-1" />
Marquer comme notifié
</Button>
)}
{existingDraw.status === 'NOTIFIED' && (
<Button onClick={markAsClaimed} size="sm" variant="outline">
<CheckCircle className="w-4 h-4 mr-1" />
Marquer comme récupéré
</Button>
)}
<Button
onClick={deleteDraw}
size="sm"
variant="outline"
className="border-red-300 text-red-700 hover:bg-red-50 hover:border-red-400"
>
<Trash2 className="w-4 h-4 mr-1" />
Annuler ce tirage
</Button>
</div>
</div>
</div>
</Card>
)}
{/* Liste des participants éligibles */}
<Card className="p-6 mb-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold flex items-center gap-2">
<Users className="w-6 h-6 text-blue-600" />
Participants Éligibles
</h2>
<p className="text-gray-600 text-sm mt-1">
{loading
? 'Chargement en cours...'
: participants.length > 0
? `${participants.length} participant${participants.length > 1 ? 's' : ''} éligible${participants.length > 1 ? 's' : ''} au tirage`
: 'Aucun participant chargé'}
</p>
</div>
<Button
onClick={loadParticipants}
isLoading={loading}
disabled={loading}
size="sm"
>
<RefreshCw className="w-4 h-4 mr-2" />
Actualiser
</Button>
</div>
{loading && (
<div className="text-center py-12">
<RefreshCw className="w-12 h-12 text-blue-600 animate-spin mx-auto mb-4" />
<p className="text-gray-600">Chargement des participants éligibles...</p>
</div>
)}
{!loading && participants.length === 0 && (
<div className="text-center py-12 bg-gray-50 rounded-lg">
<Users className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 text-lg font-medium">Aucun participant éligible</p>
<p className="text-gray-500 text-sm mt-2">
Vérifiez que des participants ont joué au moins {minTickets} ticket{minTickets > 1 ? 's' : ''}
</p>
</div>
)}
{!loading && participants.length > 0 && (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gradient-to-r from-blue-50 to-purple-50">
<tr>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
#
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Nom Complet
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-4 text-center text-xs font-bold text-gray-700 uppercase tracking-wider">
Tickets Joués
</th>
<th className="px-6 py-4 text-center text-xs font-bold text-gray-700 uppercase tracking-wider">
Lots Gagnés
</th>
<th className="px-6 py-4 text-center text-xs font-bold text-gray-700 uppercase tracking-wider">
Statut
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{participants.map((participant, index) => (
<tr key={participant.id} className="hover:bg-blue-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{index + 1}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<User className="w-5 h-5 text-blue-500 mr-2" />
<span className="text-sm font-medium text-gray-900">
{participant.first_name} {participant.last_name}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
{participant.email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
{participant.tickets_played}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-purple-100 text-purple-800">
{participant.prizes_won}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
{participant.is_verified ? (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
<CheckCircle className="w-4 h-4 mr-1" />
Vérifié
</span>
) : (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
<AlertCircle className="w-4 h-4 mr-1" />
Non vérifié
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Bouton de tirage au sort */}
<div className="mt-8 p-6 bg-gradient-to-r from-yellow-50 to-orange-50 rounded-lg border-2 border-yellow-200">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-bold text-gray-900 mb-1">
Prêt à lancer le tirage au sort ?
</h3>
<p className="text-sm text-gray-600">
{participants.length} participant{participants.length > 1 ? 's ont' : ' a'} une chance égale de gagner {prizeName} ({prizeValue})
</p>
</div>
<Button
onClick={conductDraw}
disabled={loading}
size="lg"
className="bg-gradient-to-r from-yellow-500 to-orange-500 hover:from-yellow-600 hover:to-orange-600"
>
<Trophy className="w-5 h-5 mr-2" />
Lancer le Tirage
</Button>
</div>
</div>
</>
)}
</Card>
{/* Résultat du tirage */}
{drawResult && (
<Card className="p-6 bg-gradient-to-br from-yellow-50 to-orange-50 border-yellow-500">
<div className="text-center">
<Trophy className="w-16 h-16 text-yellow-600 mx-auto mb-4" />
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Félicitations au gagnant!
</h2>
<div className="bg-white rounded-lg p-6 my-6 shadow-lg">
<User className="w-12 h-12 text-blue-600 mx-auto mb-3" />
<h3 className="text-2xl font-bold text-gray-900 mb-2">
{drawResult.winner.name}
</h3>
<p className="text-gray-600 mb-1">{drawResult.winner.email}</p>
<p className="text-sm text-gray-500">
{drawResult.winner.ticketsPlayed} ticket(s) joué(s)
</p>
</div>
<div className="bg-white rounded-lg p-6 shadow-lg">
<Gift className="w-12 h-12 text-purple-600 mx-auto mb-3" />
<h3 className="text-xl font-bold text-gray-900 mb-1">
{drawResult.prize.name}
</h3>
<p className="text-2xl font-bold text-purple-600">
{drawResult.prize.value}
</p>
</div>
<div className="mt-6 grid grid-cols-3 gap-4 text-center">
<div>
<p className="text-2xl font-bold text-gray-900">
{drawResult.statistics.totalParticipants}
</p>
<p className="text-sm text-gray-600">Total participants</p>
</div>
<div>
<p className="text-2xl font-bold text-blue-600">
{drawResult.statistics.eligibleParticipants}
</p>
<p className="text-sm text-gray-600">Éligibles</p>
</div>
<div>
<p className="text-2xl font-bold text-green-600">1</p>
<p className="text-sm text-gray-600">Gagnant</p>
</div>
</div>
<div className="mt-6 flex gap-3 justify-center">
<Button onClick={downloadReport}>
<Download className="w-4 h-4 mr-2" />
Télécharger le rapport
</Button>
<Button variant="outline" onClick={() => window.location.reload()}>
<RefreshCw className="w-4 h-4 mr-2" />
Rafraîchir
</Button>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,21 @@
"use client";
import UserManagement from "@/components/admin/UserManagement";
export default function AdminUtilisateursPage() {
return (
<div className="p-8">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900">
Gestion des utilisateurs
</h1>
<p className="text-gray-600 mt-2">
Gérez tous les comptes utilisateurs de la plateforme
</p>
</div>
<div className="bg-white rounded-lg shadow-sm">
<UserManagement />
</div>
</div>
);
}

255
app/client/page.tsx Normal file
View File

@ -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<Ticket[]>([]);
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 (
<div className="min-h-[calc(100vh-8rem)] flex items-center justify-center">
<Loading size="lg" />
</div>
);
}
if (!isAuthenticated) {
return null;
}
const getStatusBadge = (status: string) => {
switch (status) {
case "CLAIMED":
return <Badge variant="success">Réclamé</Badge>;
case "PENDING":
return <Badge variant="warning">En attente</Badge>;
case "REJECTED":
return <Badge variant="danger">Rejeté</Badge>;
default:
return <Badge variant="default">{status}</Badge>;
}
};
return (
<div className="py-8">
{/* Welcome Section */}
<div className="mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-2">
Bonjour {user?.firstName} ! 👋
</h1>
<p className="text-gray-600">
Bienvenue dans votre espace client
</p>
</div>
{/* Quick Action */}
<div className="mb-8">
<Card className="bg-gradient-to-r from-primary-500 to-primary-600 text-white">
<CardContent className="py-8">
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold mb-2">
Vous avez un nouveau ticket ?
</h2>
<p className="text-primary-50">
Entrez votre code et découvrez votre gain instantanément
</p>
</div>
<Link href={ROUTES.GAME}>
<Button
size="lg"
className="bg-white text-black hover:bg-gray-50 border-2 border-black"
>
Jouer maintenant 🎮
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
{/* Statistics Cards */}
<div className="grid md:grid-cols-3 gap-6 mb-8">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">
Total Participations
</p>
<p className="text-3xl font-bold text-gray-900 mt-2">
{stats.total}
</p>
</div>
<div className="text-4xl">🎫</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">
Gains réclamés
</p>
<p className="text-3xl font-bold text-green-600 mt-2">
{stats.claimed}
</p>
</div>
<div className="text-4xl"></div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">
En attente
</p>
<p className="text-3xl font-bold text-yellow-600 mt-2">
{stats.pending}
</p>
</div>
<div className="text-4xl"></div>
</div>
</CardContent>
</Card>
</div>
{/* Recent Tickets */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Mes derniers tickets</CardTitle>
<Link href={ROUTES.HISTORY}>
<Button variant="outline" size="sm">
Voir tout l'historique
</Button>
</Link>
</div>
</CardHeader>
<CardContent>
{tickets.length === 0 ? (
<div className="text-center py-12">
<div className="text-6xl mb-4">🎲</div>
<p className="text-gray-600 mb-4">
Vous n'avez pas encore participé au jeu
</p>
<Link href={ROUTES.GAME}>
<Button className="bg-white text-black hover:bg-gray-50 border-2 border-black">Jouer maintenant</Button>
</Link>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Code Ticket
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Gain
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{tickets.slice(0, 5).map((ticket) => {
const prizeConfig = ticket.prize
? PRIZE_CONFIG[ticket.prize.type]
: null;
return (
<tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<span className="font-mono text-sm font-medium text-gray-900">
{ticket.code}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{prizeConfig && (
<>
<span className="text-2xl mr-2">
{prizeConfig.icon}
</span>
<span className="text-sm text-gray-900">
{prizeConfig.name}
</span>
</>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(ticket.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{ticket.playedAt ? new Date(ticket.playedAt).toLocaleDateString("fr-FR") : "-"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

342
app/contact/page.tsx Normal file
View File

@ -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<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="py-12">
{/* Hero Section */}
<section className="text-center mb-16">
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
Contactez-nous
</h1>
<p className="text-lg md:text-xl text-gray-600 max-w-3xl mx-auto">
Une question, une suggestion ? Notre équipe est pour vous accompagner.
</p>
</section>
<div className="grid lg:grid-cols-2 gap-12 mb-16">
{/* Contact Form */}
<div>
<Card className="shadow-xl">
<CardHeader className="bg-gradient-to-r from-primary-50 to-green-50">
<CardTitle className="text-2xl text-primary-800">
Envoyez-nous un message
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Nom complet */}
<div>
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700 mb-2">
Nom complet <span className="text-red-500">*</span>
</label>
<input
id="fullName"
name="fullName"
type="text"
required
value={formData.fullName}
onChange={handleChange}
placeholder="Votre nom et prénom"
className="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email <span className="text-red-500">*</span>
</label>
<input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleChange}
placeholder="votre@email.com"
className="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
{/* Sujet */}
<div>
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-2">
Sujet <span className="text-red-500">*</span>
</label>
<input
id="subject"
name="subject"
type="text"
required
value={formData.subject}
onChange={handleChange}
placeholder="L'objet de votre message"
className="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
{/* Message */}
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-2">
Message <span className="text-red-500">*</span>
</label>
<textarea
id="message"
name="message"
required
value={formData.message}
onChange={handleChange}
placeholder="Décrivez votre demande..."
rows={6}
className="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
/>
</div>
{/* Checkbox Robot */}
<div className="flex items-center gap-3">
<input
id="notRobot"
name="notRobot"
type="checkbox"
required
checked={formData.notRobot}
onChange={handleCheckboxChange}
className="w-5 h-5 text-primary-600 border-gray-300 rounded focus:ring-2 focus:ring-primary-500"
/>
<label htmlFor="notRobot" className="text-gray-700 select-none cursor-pointer">
Je ne suis pas un robot 🤖
</label>
</div>
{/* Submit Button */}
<div>
<Button
type="submit"
isLoading={isSubmitting}
disabled={isSubmitting}
size="lg"
className="w-full"
>
{isSubmitting ? "Envoi en cours..." : "Envoyer le message"}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
{/* Contact Info */}
<div className="space-y-6">
<Card className="shadow-lg">
<CardHeader>
<CardTitle className="text-2xl">Nos coordonnées</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Adresse */}
<div className="flex items-start gap-4">
<div className="text-3xl">📍</div>
<div>
<h3 className="font-semibold text-gray-900 mb-1">Adresse</h3>
<p className="text-gray-600">
123 Avenue des Thés<br />
06000 Nice, France
</p>
</div>
</div>
{/* Téléphone */}
<div className="flex items-start gap-4">
<div className="text-3xl">📞</div>
<div>
<h3 className="font-semibold text-gray-900 mb-1">Téléphone</h3>
<p className="text-gray-600">
<a href="tel:+33187673218" className="hover:text-primary-600 transition-colors">
+33 1 87 67 32 18
</a>
</p>
</div>
</div>
{/* Email */}
<div className="flex items-start gap-4">
<div className="text-3xl"></div>
<div>
<h3 className="font-semibold text-gray-900 mb-1">Email</h3>
<p className="text-gray-600">
<a href="mailto:contact@the-tip-top.com" className="hover:text-primary-600 transition-colors">
contact@the-tip-top.com
</a>
</p>
</div>
</div>
{/* Horaires */}
<div className="flex items-start gap-4">
<div className="text-3xl">🕐</div>
<div>
<h3 className="font-semibold text-gray-900 mb-1">Horaires d'ouverture</h3>
<p className="text-gray-600 text-sm">
Lundi - Vendredi : 9h - 19h<br />
Samedi : 9h - 18h<br />
Dimanche : 10h - 17h
</p>
</div>
</div>
</CardContent>
</Card>
{/* Localisation */}
<Card className="shadow-lg">
<CardHeader>
<CardTitle className="text-xl">Localisation</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-lg overflow-hidden h-64">
<iframe
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2885.2563866557893!2d7.261953!1d43.7031922!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x12cdd0106a852d31%3A0x40819a5fd970220!2sNice%2C%20France!5e0!3m2!1sfr!2sfr!4v1234567890123"
width="100%"
height="100%"
style={{ border: 0 }}
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
title="Localisation Thé Tip Top Nice"
/>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Boutiques Section */}
<section className="bg-gradient-to-r from-primary-50 to-green-50 py-12 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
<div className="max-w-6xl mx-auto">
<h2 className="text-3xl font-bold text-center text-gray-900 mb-4">
Nos boutiques
</h2>
<p className="text-center text-gray-600 mb-12">
Retrouvez-nous dans nos points de vente niçois
</p>
<div className="grid md:grid-cols-3 gap-8">
{/* Boutique Centre-Ville */}
<Card className="bg-white hover:shadow-xl transition-shadow">
<CardHeader>
<div className="text-4xl text-center mb-2">🏪</div>
<CardTitle className="text-center text-xl">Boutique Centre-Ville</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
<p className="text-gray-700 font-medium">15 rue de la Paix</p>
<p className="text-gray-600 text-sm">06000 Nice</p>
</div>
<div className="text-center pt-3 border-t border-gray-200">
<p className="text-primary-600 font-semibold mb-1">
<a href="tel:+33493123456" className="hover:text-primary-700">
04 93 12 34 56
</a>
</p>
<p className="text-gray-600 text-sm">
9h-19h du lundi au samedi
</p>
</div>
</CardContent>
</Card>
{/* Boutique Vieux-Nice */}
<Card className="bg-white hover:shadow-xl transition-shadow">
<CardHeader>
<div className="text-4xl text-center mb-2">🏪</div>
<CardTitle className="text-center text-xl">Boutique Vieux-Nice</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
<p className="text-gray-700 font-medium">28 rue des Rosiers</p>
<p className="text-gray-600 text-sm">06300 Nice</p>
</div>
<div className="text-center pt-3 border-t border-gray-200">
<p className="text-primary-600 font-semibold mb-1">
<a href="tel:+33494872416" className="hover:text-primary-700">
04 94 87 24 16
</a>
</p>
<p className="text-gray-600 text-sm">
10h-20h du lundi au dimanche
</p>
</div>
</CardContent>
</Card>
{/* Boutique Promenade */}
<Card className="bg-white hover:shadow-xl transition-shadow">
<CardHeader>
<div className="text-4xl text-center mb-2">🏪</div>
<CardTitle className="text-center text-xl">Boutique Promenade</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
<p className="text-gray-700 font-medium">42 Promenade des Anglais</p>
<p className="text-gray-600 text-sm">06000 Nice</p>
</div>
<div className="text-center pt-3 border-t border-gray-200">
<p className="text-primary-600 font-semibold mb-1">
<a href="tel:+33493296714" className="hover:text-primary-700">
04 93 29 67 14
</a>
</p>
<p className="text-gray-600 text-sm">
9h30-19h30 du mardi au samedi
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</section>
</div>
);
}

302
app/cookies/page.tsx Normal file
View File

@ -0,0 +1,302 @@
'use client';
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import Button from "@/components/Button";
export default function CookiesPage() {
const [preferences, setPreferences] = useState({
essential: true,
analytics: false,
marketing: false,
});
const handleToggle = (type: 'analytics' | 'marketing') => {
setPreferences(prev => ({
...prev,
[type]: !prev[type]
}));
};
const handleAcceptAll = () => {
setPreferences({
essential: true,
analytics: true,
marketing: true,
});
alert('Vos préférences ont été enregistrées');
};
const handleRejectAll = () => {
setPreferences({
essential: true,
analytics: false,
marketing: false,
});
alert('Vos préférences ont été enregistrées');
};
const handleSavePreferences = () => {
alert('Vos préférences ont été enregistrées');
};
return (
<div className="py-12">
<div className="max-w-4xl mx-auto">
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-8 text-center">
Gestion des cookies
</h1>
<div className="mb-8 text-center text-gray-600">
<p>Gérez vos préférences en matière de cookies</p>
</div>
{/* Introduction */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-xl">Qu'est-ce qu'un cookie ?</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-700">
Un cookie est un petit fichier texte déposé sur votre terminal (ordinateur, tablette,
smartphone) lors de la visite d'un site internet.
</p>
<p className="text-gray-700">
Les cookies permettent de reconnaître votre navigateur et de mémoriser certaines
informations concernant votre visite (langue, préférences, etc.).
</p>
</CardContent>
</Card>
{/* Types de cookies */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-xl">Les cookies que nous utilisons</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Cookies essentiels */}
<div className="border-l-4 border-primary-600 pl-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-gray-900">Cookies essentiels</h3>
<span className="text-sm text-gray-500 bg-gray-100 px-3 py-1 rounded">
Toujours actifs
</span>
</div>
<p className="text-gray-700 mb-2">
Ces cookies sont nécessaires au fonctionnement du site et ne peuvent pas être
désactivés. Ils permettent notamment :
</p>
<ul className="list-disc list-inside space-y-1 text-gray-700 ml-4">
<li>La gestion de votre session de connexion</li>
<li>La sécurité du site</li>
<li>Le bon fonctionnement du panier et des formulaires</li>
</ul>
<p className="text-sm text-gray-600 mt-2">
Durée de conservation : Session / 1 an
</p>
</div>
{/* Cookies analytiques */}
<div className="border-l-4 border-blue-600 pl-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-gray-900">Cookies analytiques</h3>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={preferences.analytics}
onChange={() => handleToggle('analytics')}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-300 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
</label>
</div>
<p className="text-gray-700 mb-2">
Ces cookies nous permettent de mesurer l'audience du site et d'analyser la
navigation afin d'améliorer nos services :
</p>
<ul className="list-disc list-inside space-y-1 text-gray-700 ml-4">
<li>Nombre de visiteurs et pages consultées</li>
<li>Durée de visite et parcours utilisateur</li>
<li>Analyse des performances du site</li>
</ul>
<p className="text-sm text-gray-600 mt-2">
Outils : Google Analytics, Matomo<br />
Durée de conservation : 13 mois
</p>
</div>
{/* Cookies marketing */}
<div className="border-l-4 border-purple-600 pl-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-gray-900">Cookies marketing</h3>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={preferences.marketing}
onChange={() => handleToggle('marketing')}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-300 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
</label>
</div>
<p className="text-gray-700 mb-2">
Ces cookies permettent de vous proposer des publicités adaptées à vos centres
d'intérêt :
</p>
<ul className="list-disc list-inside space-y-1 text-gray-700 ml-4">
<li>Personnalisation des contenus publicitaires</li>
<li>Mesure de l'efficacité des campagnes</li>
<li>Partage sur les réseaux sociaux</li>
</ul>
<p className="text-sm text-gray-600 mt-2">
Partenaires : Facebook, Google Ads<br />
Durée de conservation : 13 mois
</p>
</div>
</CardContent>
</Card>
{/* Boutons d'action */}
<Card className="mb-8">
<CardContent className="pt-6">
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
onClick={handleAcceptAll}
variant="primary"
size="lg"
className="px-8"
>
Tout accepter
</Button>
<Button
onClick={handleSavePreferences}
variant="outline"
size="lg"
className="px-8"
>
Enregistrer mes choix
</Button>
<Button
onClick={handleRejectAll}
variant="outline"
size="lg"
className="px-8"
>
Tout refuser
</Button>
</div>
</CardContent>
</Card>
{/* Gestion des cookies */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-xl">Comment gérer vos cookies ?</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-700">
Vous pouvez à tout moment modifier vos préférences en matière de cookies en
revenant sur cette page.
</p>
<p className="text-gray-700">
Vous pouvez également gérer les cookies directement depuis votre navigateur :
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
<li>
<strong>Chrome :</strong>{" "}
<a
href="https://support.google.com/chrome/answer/95647"
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 underline"
>
Gérer les cookies sur Chrome
</a>
</li>
<li>
<strong>Firefox :</strong>{" "}
<a
href="https://support.mozilla.org/fr/kb/activer-desactiver-cookies"
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 underline"
>
Gérer les cookies sur Firefox
</a>
</li>
<li>
<strong>Safari :</strong>{" "}
<a
href="https://support.apple.com/fr-fr/HT201265"
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 underline"
>
Gérer les cookies sur Safari
</a>
</li>
<li>
<strong>Edge :</strong>{" "}
<a
href="https://support.microsoft.com/fr-fr/microsoft-edge/supprimer-les-cookies-dans-microsoft-edge-63947406-40ac-c3b8-57b9-2a946a29ae09"
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 underline"
>
Gérer les cookies sur Edge
</a>
</li>
</ul>
</CardContent>
</Card>
{/* Informations complémentaires */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-xl">En savoir plus</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-700">
Pour plus d'informations sur les cookies et la protection de vos données personnelles,
vous pouvez consulter :
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
<li>
Notre{" "}
<a href="/privacy" className="text-primary-600 hover:text-primary-700 underline">
Politique de confidentialité
</a>
</li>
<li>
Le site de la CNIL :{" "}
<a
href="https://www.cnil.fr/fr/cookies-et-autres-traceurs"
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 underline"
>
www.cnil.fr
</a>
</li>
</ul>
</CardContent>
</Card>
{/* Contact */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-xl">Contact</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-700">
Pour toute question concernant les cookies :{" "}
<a href="mailto:contact@thetiptop.fr" className="text-primary-600 hover:text-primary-700 underline">
contact@thetiptop.fr
</a>
</p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,229 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks';
import { Card } from '@/components/ui';
import { Loading } from '@/components/ui/Loading';
import { ROUTES } from '@/utils/constants';
import toast from 'react-hot-toast';
import Link from 'next/link';
interface PendingTicket {
id: string;
code: string;
status: string;
played_at: string;
user_email: string;
user_name: string;
user_phone: string;
prize_name: string;
prize_type: string;
prize_value: string;
}
export default function EmployeDashboardPage() {
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
const router = useRouter();
const [stats, setStats] = useState({
pendingTickets: 0,
claimedToday: 0,
totalClaimed: 0,
});
const [pendingTickets, setPendingTickets] = useState<PendingTicket[]>([]);
const [loadingTickets, setLoadingTickets] = useState(true);
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.push(ROUTES.LOGIN);
return;
}
if (isAuthenticated && user?.role !== 'EMPLOYEE' && user?.role !== 'employee') {
router.push(ROUTES.HOME);
toast.error('Accès refusé : rôle employé requis');
return;
}
if (isAuthenticated) {
loadPendingTickets();
loadEmployeeStats();
}
}, [authLoading, isAuthenticated, user, router]);
const loadPendingTickets = async () => {
try {
setLoadingTickets(true);
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/employee/pending-tickets?limit=10`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) throw new Error('Erreur lors du chargement');
const data = await response.json();
setPendingTickets(data.data || []);
setStats((prev) => ({
...prev,
pendingTickets: data.pagination?.total || data.data?.length || 0,
}));
} catch (error: any) {
console.error('Error loading pending tickets:', error);
} finally {
setLoadingTickets(false);
}
};
const loadEmployeeStats = async () => {
try {
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/employee/stats`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) throw new Error('Erreur lors du chargement des statistiques');
const result = await response.json();
const statsData = result.data;
setStats((prev) => ({
...prev,
claimedToday: parseInt(statsData.claimed_today) || 0,
totalClaimed: parseInt(statsData.total_approved) || 0,
}));
} catch (error: any) {
console.error('Error loading employee stats:', error);
}
};
if (authLoading) {
return (
<div className="min-h-[calc(100vh-8rem)] flex items-center justify-center">
<Loading size="lg" />
</div>
);
}
if (!isAuthenticated || (user?.role !== 'EMPLOYEE' && user?.role !== 'employee')) {
return null;
}
return (
<div className="py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-2">
Tableau de bord employé
</h1>
<p className="text-gray-600">
Bienvenue {user?.firstName}, voici un aperçu de votre activité
</p>
</div>
{/* Statistics Cards */}
<div className="grid md:grid-cols-3 gap-6 mb-8">
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">
Tickets en attente
</p>
<p className="text-3xl font-bold text-yellow-600 mt-2">
{stats.pendingTickets}
</p>
</div>
<div className="text-4xl"></div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">
Réclamés aujourd'hui
</p>
<p className="text-3xl font-bold text-green-600 mt-2">
{stats.claimedToday}
</p>
</div>
<div className="text-4xl"></div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">
Total réclamés
</p>
<p className="text-3xl font-bold text-blue-600 mt-2">
{stats.totalClaimed}
</p>
</div>
<div className="text-4xl">📊</div>
</div>
</Card>
</div>
{/* Quick Actions */}
<div className="grid md:grid-cols-3 gap-6">
<Link href="/employe/verification">
<Card className="p-6 hover:shadow-lg transition-shadow cursor-pointer">
<div className="flex items-center">
<div className="text-5xl mr-4">🔍</div>
<div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
Validation des gains
</h3>
<p className="text-gray-600">
Rechercher et valider les tickets des clients
</p>
</div>
</div>
</Card>
</Link>
<Link href="/employe/gains-client">
<Card className="p-6 hover:shadow-lg transition-shadow cursor-pointer">
<div className="flex items-center">
<div className="text-5xl mr-4">🎁</div>
<div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
Gains du Client
</h3>
<p className="text-gray-600">
Rechercher tous les gains d'un client
</p>
</div>
</div>
</Card>
</Link>
<Link href="/employe/historique">
<Card className="p-6 hover:shadow-lg transition-shadow cursor-pointer">
<div className="flex items-center">
<div className="text-5xl mr-4">📜</div>
<div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
Historique
</h3>
<p className="text-gray-600">
Consulter l'historique des validations
</p>
</div>
</div>
</Card>
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,363 @@
'use client';
import { useState } from 'react';
import { Card } from '@/components/ui/Card';
import Button from '@/components/Button';
import toast from 'react-hot-toast';
import {
Search,
User,
Gift,
CheckCircle,
Clock,
XCircle,
Phone,
Mail,
Package,
} from 'lucide-react';
interface ClientPrize {
ticketId: string;
ticketCode: string;
status: string;
playedAt: string;
claimedAt: string | null;
validatedAt: string | null;
validatedBy: string | null;
prize: {
id: string;
name: string;
type: string;
value: string;
description: string;
};
}
interface ClientData {
client: {
id: string;
email: string;
firstName: string;
lastName: string;
phone: string;
};
prizes: ClientPrize[];
totalPrizes: number;
pendingPrizes: number;
claimedPrizes: number;
}
export default function GainsClientPage() {
const [searchType, setSearchType] = useState<'email' | 'phone'>('email');
const [searchValue, setSearchValue] = useState('');
const [loading, setLoading] = useState(false);
const [clientData, setClientData] = useState<ClientData | null>(null);
const [validatingTicketId, setValidatingTicketId] = useState<string | null>(null);
const handleSearch = async () => {
if (!searchValue.trim()) {
toast.error('Veuillez entrer un email ou un téléphone');
return;
}
setLoading(true);
try {
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
const queryParam = searchType === 'email' ? `email=${encodeURIComponent(searchValue)}` : `phone=${encodeURIComponent(searchValue)}`;
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/employee/client-prizes?${queryParam}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Client non trouvé');
}
const data = await response.json();
setClientData(data.data);
toast.success(`✅ Client trouvé: ${data.data.client.firstName} ${data.data.client.lastName}`);
} catch (error: any) {
console.error('Error searching client:', error);
toast.error(error.message || 'Erreur lors de la recherche');
setClientData(null);
} finally {
setLoading(false);
}
};
const handleValidatePrize = async (ticketId: string) => {
if (!confirm('Confirmer la remise de ce lot au client?')) {
return;
}
setValidatingTicketId(ticketId);
try {
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/employee/validate-ticket`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
ticketId,
action: 'APPROVE',
}),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erreur lors de la validation');
}
toast.success('✅ Lot marqué comme remis!');
// Recharger les données du client
handleSearch();
} catch (error: any) {
console.error('Error validating prize:', error);
toast.error(error.message || 'Erreur lors de la validation');
} finally {
setValidatingTicketId(null);
}
};
const getStatusBadge = (status: string) => {
const badges = {
PENDING: (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
<Clock className="w-4 h-4 mr-1" />
À remettre
</span>
),
CLAIMED: (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
<CheckCircle className="w-4 h-4 mr-1" />
Remis
</span>
),
REJECTED: (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
<XCircle className="w-4 h-4 mr-1" />
Rejeté
</span>
),
};
return badges[status as keyof typeof badges] || badges.PENDING;
};
return (
<div className="p-6 max-w-7xl mx-auto">
{/* Header */}
<div className="mb-6">
<h1 className="text-3xl font-bold mb-2 flex items-center gap-3">
<Gift className="w-10 h-10 text-purple-600" />
Gains du Client
</h1>
<p className="text-gray-600">
Recherchez un client pour visualiser tous ses gains et les remettre
</p>
</div>
{/* Search Section */}
<Card className="p-6 mb-6">
<h2 className="text-xl font-bold mb-4">Rechercher un client</h2>
<div className="space-y-4">
{/* Search Type Selection */}
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="searchType"
value="email"
checked={searchType === 'email'}
onChange={(e) => setSearchType(e.target.value as 'email')}
className="w-4 h-4"
/>
<Mail className="w-5 h-5 text-gray-600" />
<span className="text-sm font-medium">Email</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="searchType"
value="phone"
checked={searchType === 'phone'}
onChange={(e) => setSearchType(e.target.value as 'phone')}
className="w-4 h-4"
/>
<Phone className="w-5 h-5 text-gray-600" />
<span className="text-sm font-medium">Téléphone</span>
</label>
</div>
{/* Search Input */}
<div className="flex gap-4">
<input
type={searchType === 'email' ? 'email' : 'tel'}
placeholder={searchType === 'email' ? 'exemple@email.com' : '06 12 34 56 78'}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<Button
onClick={handleSearch}
isLoading={loading}
disabled={loading}
className="bg-purple-600 hover:bg-purple-700"
>
<Search className="w-5 h-5 mr-2" />
Rechercher
</Button>
</div>
</div>
</Card>
{/* Client Info & Prizes */}
{clientData && (
<>
{/* Client Info Card */}
<Card className="p-6 mb-6 bg-gradient-to-r from-purple-50 to-blue-50 border-purple-200">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="p-3 bg-white rounded-lg shadow-sm">
<User className="w-8 h-8 text-purple-600" />
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900">
{clientData.client.firstName} {clientData.client.lastName}
</h2>
<div className="mt-2 space-y-1">
<p className="text-sm text-gray-600 flex items-center gap-2">
<Mail className="w-4 h-4" />
{clientData.client.email}
</p>
{clientData.client.phone && (
<p className="text-sm text-gray-600 flex items-center gap-2">
<Phone className="w-4 h-4" />
{clientData.client.phone}
</p>
)}
</div>
</div>
</div>
<div className="text-right">
<div className="grid grid-cols-3 gap-4">
<div className="bg-white rounded-lg p-3 shadow-sm">
<p className="text-xs text-gray-600">Total</p>
<p className="text-2xl font-bold text-gray-900">{clientData.totalPrizes}</p>
</div>
<div className="bg-white rounded-lg p-3 shadow-sm">
<p className="text-xs text-yellow-600">À remettre</p>
<p className="text-2xl font-bold text-yellow-600">{clientData.pendingPrizes}</p>
</div>
<div className="bg-white rounded-lg p-3 shadow-sm">
<p className="text-xs text-green-600">Remis</p>
<p className="text-2xl font-bold text-green-600">{clientData.claimedPrizes}</p>
</div>
</div>
</div>
</div>
</Card>
{/* Prizes List */}
<Card className="p-6">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<Package className="w-6 h-6 text-purple-600" />
Lots gagnés ({clientData.prizes.length})
</h2>
{clientData.prizes.length === 0 ? (
<div className="text-center py-12 bg-gray-50 rounded-lg">
<Gift className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600">Ce client n'a pas encore gagné de lots</p>
</div>
) : (
<div className="space-y-4">
{clientData.prizes.map((prize) => (
<div
key={prize.ticketId}
className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold text-gray-900">
{prize.prize.name}
</h3>
{getStatusBadge(prize.status)}
</div>
<p className="text-sm text-gray-600 mb-3">
{prize.prize.description}
</p>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">Code ticket:</span>
<span className="ml-2 font-mono font-semibold">{prize.ticketCode}</span>
</div>
<div>
<span className="text-gray-600">Valeur:</span>
<span className="ml-2 font-semibold text-purple-600">
{prize.prize.value}
</span>
</div>
<div>
<span className="text-gray-600">Gagné le:</span>
<span className="ml-2">
{new Date(prize.playedAt).toLocaleDateString('fr-FR')}
</span>
</div>
{prize.claimedAt && (
<div>
<span className="text-gray-600">Remis le:</span>
<span className="ml-2">
{new Date(prize.claimedAt).toLocaleDateString('fr-FR')}
</span>
</div>
)}
{prize.validatedBy && (
<div className="col-span-2">
<span className="text-gray-600">Remis par:</span>
<span className="ml-2 font-medium">{prize.validatedBy}</span>
</div>
)}
</div>
</div>
{prize.status === 'PENDING' && (
<Button
onClick={() => handleValidatePrize(prize.ticketId)}
isLoading={validatingTicketId === prize.ticketId}
disabled={validatingTicketId === prize.ticketId}
className="ml-4 bg-green-600 hover:bg-green-700"
>
<CheckCircle className="w-5 h-5 mr-2" />
Marquer comme remis
</Button>
)}
</div>
</div>
))}
</div>
)}
</Card>
</>
)}
</div>
);
}

View File

@ -0,0 +1,293 @@
'use client';
import { useState, useEffect } from 'react';
import { useAuth } from '@/hooks';
import { Card } from '@/components/ui';
import { Loading } from '@/components/ui/Loading';
import toast from 'react-hot-toast';
import {
CheckCircle,
XCircle,
Clock,
Calendar,
User,
Gift,
RefreshCw,
} from 'lucide-react';
interface HistoryTicket {
id: string;
code: string;
status: string;
played_at: string;
claimed_at: string | null;
validated_at: string | null;
rejection_reason: string | null;
user_email: string;
user_name: string;
prize_name: string;
prize_value: string;
}
export default function EmployeeHistoryPage() {
const { user, isAuthenticated } = useAuth();
const [history, setHistory] = useState<HistoryTicket[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<'ALL' | 'CLAIMED' | 'REJECTED'>('ALL');
useEffect(() => {
if (isAuthenticated) {
loadHistory();
}
}, [isAuthenticated]);
const loadHistory = async () => {
try {
setLoading(true);
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
// Charger tous les tickets validés par cet employé
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/employee/history`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
throw new Error('Erreur lors du chargement de l\'historique');
}
const data = await response.json();
setHistory(data.data || []);
} catch (error: any) {
console.error('Error loading history:', error);
toast.error(error.message || 'Erreur lors du chargement de l\'historique');
} finally {
setLoading(false);
}
};
const getStatusBadge = (status: string) => {
const badges = {
PENDING: (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
<Clock className="w-4 h-4 mr-1" />
En attente
</span>
),
CLAIMED: (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
<CheckCircle className="w-4 h-4 mr-1" />
Validé
</span>
),
REJECTED: (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
<XCircle className="w-4 h-4 mr-1" />
Rejeté
</span>
),
};
return badges[status as keyof typeof badges] || badges.PENDING;
};
const filteredHistory = history.filter((ticket) => {
if (filter === 'ALL') return true;
return ticket.status === filter;
});
const stats = {
total: history.length,
claimed: history.filter((t) => t.status === 'CLAIMED').length,
rejected: history.filter((t) => t.status === 'REJECTED').length,
};
if (loading) {
return (
<div className="min-h-[calc(100vh-8rem)] flex items-center justify-center">
<Loading size="lg" />
</div>
);
}
return (
<div className="p-6 max-w-7xl mx-auto">
{/* Header */}
<div className="mb-6">
<h1 className="text-3xl font-bold mb-2 flex items-center gap-3">
<Calendar className="w-10 h-10 text-purple-600" />
Historique des Validations
</h1>
<p className="text-gray-600">
Consultez l'historique de vos validations de tickets
</p>
</div>
{/* Statistics Cards */}
<div className="grid md:grid-cols-3 gap-6 mb-6">
<Card className="p-6 bg-gradient-to-br from-blue-50 to-blue-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total traités</p>
<p className="text-3xl font-bold text-blue-600 mt-2">{stats.total}</p>
</div>
<div className="text-4xl">📊</div>
</div>
</Card>
<Card className="p-6 bg-gradient-to-br from-green-50 to-green-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Validés</p>
<p className="text-3xl font-bold text-green-600 mt-2">{stats.claimed}</p>
</div>
<div className="text-4xl"></div>
</div>
</Card>
<Card className="p-6 bg-gradient-to-br from-red-50 to-red-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Rejetés</p>
<p className="text-3xl font-bold text-red-600 mt-2">{stats.rejected}</p>
</div>
<div className="text-4xl"></div>
</div>
</Card>
</div>
{/* Filters */}
<Card className="p-4 mb-6">
<div className="flex items-center justify-between">
<div className="flex gap-2">
<button
onClick={() => setFilter('ALL')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === 'ALL'
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Tous ({history.length})
</button>
<button
onClick={() => setFilter('CLAIMED')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === 'CLAIMED'
? 'bg-green-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Validés ({stats.claimed})
</button>
<button
onClick={() => setFilter('REJECTED')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === 'REJECTED'
? 'bg-red-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Rejetés ({stats.rejected})
</button>
</div>
<button
onClick={loadHistory}
className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
<RefreshCw className="w-4 h-4" />
Actualiser
</button>
</div>
</Card>
{/* History List */}
<Card className="p-6">
{filteredHistory.length === 0 ? (
<div className="text-center py-16">
<div className="text-6xl mb-4">📝</div>
<p className="text-gray-600 mb-2 font-medium">
Aucun ticket dans l'historique
</p>
<p className="text-sm text-gray-500">
{filter === 'ALL'
? 'Vous n\'avez pas encore validé de tickets'
: `Aucun ticket ${filter === 'CLAIMED' ? 'validé' : 'rejeté'}`}
</p>
</div>
) : (
<div className="space-y-4">
{filteredHistory.map((ticket) => (
<div
key={ticket.id}
className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<span className="font-mono font-bold text-lg text-gray-900">
{ticket.code}
</span>
{getStatusBadge(ticket.status)}
</div>
<div className="grid md:grid-cols-2 gap-4 mb-3">
<div className="flex items-start gap-2">
<User className="w-5 h-5 text-gray-400 mt-0.5" />
<div>
<p className="text-sm font-medium text-gray-600">Client</p>
<p className="text-sm text-gray-900">{ticket.user_name}</p>
<p className="text-xs text-gray-500">{ticket.user_email}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Gift className="w-5 h-5 text-gray-400 mt-0.5" />
<div>
<p className="text-sm font-medium text-gray-600">Lot</p>
<p className="text-sm text-gray-900">{ticket.prize_name}</p>
<p className="text-xs text-gray-500">{ticket.prize_value}</p>
</div>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500">
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
<span>
Validé le{' '}
{ticket.validated_at
? new Date(ticket.validated_at).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: 'N/A'}
</span>
</div>
</div>
{ticket.rejection_reason && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm font-medium text-red-800 mb-1">
Raison du rejet:
</p>
<p className="text-sm text-red-700">{ticket.rejection_reason}</p>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</Card>
</div>
);
}

133
app/employe/layout.tsx Normal file
View File

@ -0,0 +1,133 @@
"use client";
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, Ticket, BarChart3 } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import Logo from "@/components/Logo";
export default function EmployeLayout({
children,
}: {
children: React.ReactNode;
}) {
const { user, isAuthenticated, isLoading, logout } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push("/login");
return;
}
if (isAuthenticated && user?.role !== "EMPLOYEE" && user?.role !== "employee") {
router.push("/");
toast.error("Accès refusé : rôle employé requis");
return;
}
}, [isLoading, isAuthenticated, user, router]);
const handleLogout = async () => {
await logout();
router.push("/login");
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Loading size="lg" />
</div>
);
}
if (!isAuthenticated || (user?.role !== "EMPLOYEE" && user?.role !== "employee")) {
return null;
}
const navItems = [
{
label: "Validation des Tickets",
href: "/employe/verification",
icon: <Ticket className="w-5 h-5" />,
},
{
label: "Dashboard",
href: "/employe/dashboard",
icon: <BarChart3 className="w-5 h-5" />,
},
];
const isActive = (href: string) => pathname === href;
return (
<div className="flex min-h-screen bg-gray-50">
{/* Sidebar */}
<aside className="w-64 bg-white shadow-lg border-r border-gray-200 flex flex-col">
{/* Logo/Header */}
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Thé Tip Top</h2>
<p className="text-sm text-gray-500 mt-1">Espace Employé</p>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-2">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`
flex items-center gap-3 px-4 py-3 rounded-lg transition-colors
${
isActive(item.href)
? "bg-green-600 text-white"
: "text-gray-700 hover:bg-gray-100"
}
`}
>
{item.icon}
<span className="font-medium">{item.label}</span>
</Link>
))}
</nav>
</aside>
{/* Main Content */}
<div className="flex-1 flex flex-col">
{/* Header */}
<header className="bg-white shadow-sm border-b border-gray-200 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Logo size="md" showText={false} />
<div>
<h1 className="text-2xl font-bold text-gray-900">
Thé Tip Top - Espace Employé
</h1>
<p className="text-sm text-gray-500 mt-1">
Connecté en tant que{" "}
<span className="font-medium">
{user?.firstName} {user?.lastName}
</span>
</p>
</div>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
<LogOut className="w-4 h-4" />
Déconnexion
</button>
</div>
</header>
{/* Page Content */}
<main className="flex-1 overflow-auto">{children}</main>
</div>
</div>
);
}

17
app/employe/page.tsx Normal file
View File

@ -0,0 +1,17 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
/**
* Page de redirection employé vers le dashboard
*/
export default function EmployePage() {
const router = useRouter();
useEffect(() => {
router.replace('/employe/dashboard');
}, [router]);
return null;
}

View File

@ -0,0 +1,462 @@
"use client";
import { useState, useEffect } from "react";
import { employeeService } from "@/services/employee.service";
import { Ticket } from "@/types";
import toast from "react-hot-toast";
import {
Search,
CheckCircle,
XCircle,
RefreshCw,
AlertCircle,
Users,
Clock,
BarChart3,
} from "lucide-react";
export default function EmployeeVerificationPage() {
const [tickets, setTickets] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(true);
const [searchCode, setSearchCode] = useState("");
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [showModal, setShowModal] = useState(false);
const [validating, setValidating] = useState(false);
const [rejectReason, setRejectReason] = useState("");
const [showRejectInput, setShowRejectInput] = useState(false);
useEffect(() => {
loadPendingTickets();
}, []);
const loadPendingTickets = async () => {
try {
setLoading(true);
const data = await employeeService.getPendingTickets();
setTickets(data);
} catch (error: any) {
console.error("Error loading tickets:", error);
toast.error("Erreur lors du chargement des tickets");
} finally {
setLoading(false);
}
};
const handleSearch = () => {
if (!searchCode.trim()) {
toast.error("Veuillez entrer un code de ticket");
return;
}
const ticket = tickets.find(
(t) => t.code.toLowerCase() === searchCode.toLowerCase()
);
if (ticket) {
setSelectedTicket(ticket);
setShowModal(true);
setSearchCode("");
} else {
toast.error("Ticket non trouvé ou déjà traité");
}
};
const handleValidate = async () => {
if (!selectedTicket) return;
setValidating(true);
try {
await employeeService.validateTicket(selectedTicket.id, "validate");
toast.success("✅ Ticket validé ! Le lot peut être remis au client.");
setShowModal(false);
setSelectedTicket(null);
loadPendingTickets();
} catch (error: any) {
toast.error(error.message || "Erreur lors de la validation");
} finally {
setValidating(false);
}
};
const handleReject = async () => {
if (!selectedTicket) return;
if (!rejectReason.trim()) {
toast.error("Veuillez indiquer la raison du rejet");
return;
}
setValidating(true);
try {
await employeeService.validateTicket(
selectedTicket.id,
"reject",
rejectReason
);
toast.success("Ticket rejeté");
setShowModal(false);
setSelectedTicket(null);
setShowRejectInput(false);
setRejectReason("");
loadPendingTickets();
} catch (error: any) {
toast.error(error.message || "Erreur lors du rejet");
} finally {
setValidating(false);
}
};
const getStatusBadge = (status: string) => {
const badges = {
PENDING: (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<Clock className="w-3 h-3 mr-1" />
En attente
</span>
),
REJECTED: (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<XCircle className="w-3 h-3 mr-1" />
Rejeté
</span>
),
CLAIMED: (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<CheckCircle className="w-3 h-3 mr-1" />
Réclamé
</span>
),
};
return badges[status] || badges.PENDING;
};
if (loading) {
return (
<div className="p-8">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="h-32 bg-gray-200 rounded"></div>
</div>
</div>
);
}
return (
<div className="p-8 bg-gray-50 min-h-screen">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Validation des Tickets
</h1>
<p className="text-gray-600">
Scannez ou recherchez un code pour valider les lots gagnés
</p>
</div>
{/* Search Section */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Rechercher un ticket
</h2>
<div className="flex gap-4">
<div className="flex-1">
<input
type="text"
placeholder="Entrez le code du ticket (ex: ABC123)"
value={searchCode}
onChange={(e) => setSearchCode(e.target.value.toUpperCase())}
onKeyPress={(e) => e.key === "Enter" && handleSearch()}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent font-mono text-lg"
maxLength={10}
/>
</div>
<button
onClick={handleSearch}
className="flex items-center gap-2 px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
>
<Search className="w-5 h-5" />
Rechercher
</button>
</div>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<StatCard
title="Tickets en attente"
value={tickets.length}
icon={<Clock className="w-6 h-6" />}
color="yellow"
/>
<StatCard
title="Aujourd'hui"
value={0}
icon={<Users className="w-6 h-6" />}
color="green"
/>
<StatCard
title="Total traités"
value={0}
icon={<BarChart3 className="w-6 h-6" />}
color="blue"
/>
</div>
{/* Tickets Table */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
Tickets en attente ({tickets.length})
</h2>
<button
onClick={loadPendingTickets}
className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
<RefreshCw className="w-4 h-4" />
Actualiser
</button>
</div>
<div className="overflow-x-auto">
{tickets.length === 0 ? (
<div className="text-center py-16">
<div className="text-6xl mb-4"></div>
<p className="text-gray-600 mb-2 font-medium">
Aucun ticket en attente
</p>
<p className="text-sm text-gray-500">
Tous les tickets ont é traités !
</p>
</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Code Ticket
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Client
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Lot Gagné
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{tickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<span className="font-mono text-sm font-semibold text-gray-900">
{ticket.code}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm">
<div className="font-medium text-gray-900">
{ticket.user?.firstName} {ticket.user?.lastName}
</div>
<div className="text-gray-500">{ticket.user?.email}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{ticket.prize?.name || "N/A"}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{ticket.playedAt
? new Date(ticket.playedAt).toLocaleDateString("fr-FR")
: "N/A"}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(ticket.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => {
setSelectedTicket(ticket);
setShowModal(true);
}}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Traiter
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Modal de validation */}
{showModal && selectedTicket && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-2xl w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Détails du Ticket
</h2>
<div className="space-y-6">
{/* Ticket Info */}
<div className="bg-gray-50 rounded-lg p-6">
<div className="grid grid-cols-2 gap-6">
<div>
<p className="text-sm font-medium text-gray-600 mb-1">
Code du ticket
</p>
<p className="text-xl font-mono font-bold text-gray-900">
{selectedTicket.code}
</p>
</div>
<div>
<p className="text-sm font-medium text-gray-600 mb-1">Statut</p>
{getStatusBadge(selectedTicket.status)}
</div>
</div>
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-sm font-medium text-gray-600 mb-1">
Lot gagné
</p>
<p className="text-lg font-semibold text-green-600">
{selectedTicket.prize?.name}
</p>
<p className="text-sm text-gray-500 mt-1">
{selectedTicket.prize?.description}
</p>
</div>
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-sm font-medium text-gray-600 mb-1">Client</p>
<p className="text-base text-gray-900">
{selectedTicket.user?.firstName} {selectedTicket.user?.lastName}
</p>
<p className="text-sm text-gray-500">
{selectedTicket.user?.email}
</p>
{selectedTicket.user?.phone && (
<p className="text-sm text-gray-500">
{selectedTicket.user?.phone}
</p>
)}
</div>
</div>
{/* Reject Reason Input */}
{showRejectInput && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Raison du rejet *
</label>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="Ex: Ticket endommagé, code illisible..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
rows={3}
/>
</div>
)}
{/* Actions */}
<div className="flex gap-3 justify-end pt-4">
{!showRejectInput ? (
<>
<button
onClick={() => {
setShowModal(false);
setSelectedTicket(null);
}}
disabled={validating}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 font-medium"
>
Annuler
</button>
<button
onClick={() => setShowRejectInput(true)}
disabled={validating}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium flex items-center gap-2"
>
<XCircle className="w-4 h-4" />
Rejeter
</button>
<button
onClick={handleValidate}
disabled={validating}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium flex items-center gap-2"
>
<CheckCircle className="w-4 h-4" />
{validating ? "Validation..." : "Valider et Remettre"}
</button>
</>
) : (
<>
<button
onClick={() => {
setShowRejectInput(false);
setRejectReason("");
}}
disabled={validating}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 font-medium"
>
Retour
</button>
<button
onClick={handleReject}
disabled={validating || !rejectReason.trim()}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<XCircle className="w-4 h-4" />
{validating ? "Rejet..." : "Confirmer le Rejet"}
</button>
</>
)}
</div>
</div>
</div>
</div>
)}
</div>
);
}
interface StatCardProps {
title: string;
value: number;
icon: React.ReactNode;
color: "yellow" | "green" | "blue";
}
function StatCard({ title, value, icon, color }: StatCardProps) {
const colors = {
yellow: "bg-yellow-100 text-yellow-600",
green: "bg-green-100 text-green-600",
blue: "bg-blue-100 text-blue-600",
};
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="text-3xl font-bold text-gray-900 mt-2">
{value.toLocaleString("fr-FR")}
</p>
</div>
<div className={`p-3 rounded-lg ${colors[color]}`}>{icon}</div>
</div>
</div>
);
}

View File

@ -0,0 +1,468 @@
"use client";
import { useState, useEffect } from "react";
import { employeeService } from "@/services/employee.service";
import { Ticket } from "@/types";
import toast from "react-hot-toast";
import {
Search,
CheckCircle,
XCircle,
RefreshCw,
AlertCircle,
Users,
Clock,
BarChart3,
} from "lucide-react";
export default function EmployeeVerificationPage() {
const [tickets, setTickets] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(true);
const [searchCode, setSearchCode] = useState("");
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [showModal, setShowModal] = useState(false);
const [validating, setValidating] = useState(false);
const [rejectReason, setRejectReason] = useState("");
const [showRejectInput, setShowRejectInput] = useState(false);
useEffect(() => {
loadPendingTickets();
}, []);
const loadPendingTickets = async () => {
try {
setLoading(true);
const data = await employeeService.getPendingTickets();
setTickets(data);
} catch (error: any) {
console.error("Error loading tickets:", error);
toast.error("Erreur lors du chargement des tickets");
} finally {
setLoading(false);
}
};
const handleSearch = () => {
if (!searchCode.trim()) {
toast.error("Veuillez entrer un code de ticket");
return;
}
const ticket = tickets.find(
(t) => t.code.toLowerCase() === searchCode.toLowerCase()
);
if (ticket) {
setSelectedTicket(ticket);
setShowModal(true);
setSearchCode("");
} else {
toast.error("Ticket non trouvé ou déjà traité");
}
};
const handleValidate = async () => {
if (!selectedTicket) return;
setValidating(true);
try {
await employeeService.validateTicket(selectedTicket.id, "APPROVE");
toast.success("✅ Ticket validé ! Le lot peut être remis au client.");
setShowModal(false);
setSelectedTicket(null);
loadPendingTickets();
} catch (error: any) {
toast.error(error.message || "Erreur lors de la validation");
} finally {
setValidating(false);
}
};
const handleReject = async () => {
if (!selectedTicket) return;
if (!rejectReason.trim()) {
toast.error("Veuillez indiquer la raison du rejet");
return;
}
setValidating(true);
try {
await employeeService.validateTicket(
selectedTicket.id,
"REJECT",
rejectReason
);
toast.success("Ticket rejeté");
setShowModal(false);
setSelectedTicket(null);
setShowRejectInput(false);
setRejectReason("");
loadPendingTickets();
} catch (error: any) {
toast.error(error.message || "Erreur lors du rejet");
} finally {
setValidating(false);
}
};
const getStatusBadge = (status: string) => {
const badges = {
PENDING: (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<Clock className="w-3 h-3 mr-1" />
En attente
</span>
),
REJECTED: (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<XCircle className="w-3 h-3 mr-1" />
Rejeté
</span>
),
CLAIMED: (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<CheckCircle className="w-3 h-3 mr-1" />
Réclamé
</span>
),
};
return badges[status] || badges.PENDING;
};
if (loading) {
return (
<div className="p-8">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="h-32 bg-gray-200 rounded"></div>
</div>
</div>
);
}
return (
<div className="p-8 bg-gray-50 min-h-screen">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Validation des Tickets
</h1>
<p className="text-gray-600">
Scannez ou recherchez un code pour valider les lots gagnés
</p>
</div>
{/* Search Section */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Rechercher un ticket
</h2>
<div className="flex gap-4">
<div className="flex-1">
<input
type="text"
placeholder="Entrez le code du ticket (ex: ABC123)"
value={searchCode}
onChange={(e) => setSearchCode(e.target.value.toUpperCase())}
onKeyPress={(e) => e.key === "Enter" && handleSearch()}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent font-mono text-lg"
maxLength={10}
/>
</div>
<button
onClick={handleSearch}
className="flex items-center gap-2 px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
>
<Search className="w-5 h-5" />
Rechercher
</button>
</div>
</div>
{/* Lots en attente de remise */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
Lots en attente de remise
</h2>
<button
onClick={loadPendingTickets}
className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
<RefreshCw className="w-4 h-4" />
Actualiser
</button>
</div>
<div className="overflow-x-auto">
{tickets.length === 0 ? (
<div className="text-center py-16">
<div className="text-6xl mb-4"></div>
<p className="text-gray-600 mb-2 font-medium">
Aucun lot en attente
</p>
<p className="text-sm text-gray-500">
Tous les lots ont é remis !
</p>
</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Client
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Lot Gagné
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Action
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{tickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div>
<div className="text-sm font-medium text-gray-900">
{ticket.user_name || 'N/A'}
</div>
<div className="text-sm text-gray-500">
{ticket.user_email || 'N/A'}
</div>
</div>
</td>
<td className="px-6 py-4">
<div>
<div className="text-sm font-medium text-gray-900">
{ticket.prize_name || 'N/A'}
</div>
<div className="text-xs text-gray-500">
Code: {ticket.code}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{ticket.played_at
? new Date(ticket.played_at).toLocaleDateString('fr-FR')
: 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => {
setSelectedTicket(ticket);
setShowModal(true);
}}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Traiter
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Modal de validation */}
{showModal && selectedTicket && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-2xl w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Détails du Ticket
</h2>
<div className="space-y-6">
{/* Ticket Code */}
<div className="bg-gray-50 rounded-lg p-6">
<p className="text-sm font-medium text-gray-600 mb-2">
Code du ticket
</p>
<p className="text-xl font-mono font-bold text-gray-900">
{selectedTicket.code}
</p>
</div>
{/* Client Info */}
<div className="bg-blue-50 rounded-lg p-6 border-l-4 border-blue-500">
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center gap-2">
<Users className="w-5 h-5 text-blue-600" />
Informations du Client
</h3>
<div className="space-y-2">
<div>
<p className="text-sm font-medium text-gray-600">Nom complet</p>
<p className="text-base font-semibold text-gray-900">
{selectedTicket.user_name || `${selectedTicket.user?.firstName} ${selectedTicket.user?.lastName}` || 'N/A'}
</p>
</div>
<div>
<p className="text-sm font-medium text-gray-600">Email</p>
<p className="text-sm text-gray-900">
{selectedTicket.user_email || selectedTicket.user?.email || 'N/A'}
</p>
</div>
{(selectedTicket.user_phone || selectedTicket.user?.phone) && (
<div>
<p className="text-sm font-medium text-gray-600">Téléphone</p>
<p className="text-sm text-gray-900">
{selectedTicket.user_phone || selectedTicket.user?.phone}
</p>
</div>
)}
</div>
</div>
{/* Prize Info */}
<div className="bg-green-50 rounded-lg p-6 border-l-4 border-green-500">
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
Lot Gagné
</h3>
<div className="space-y-2">
<div>
<p className="text-xl font-bold text-green-600">
{selectedTicket.prize_name || selectedTicket.prize?.name || 'N/A'}
</p>
<p className="text-sm text-gray-600 mt-1">
{selectedTicket.prize?.description || 'Description non disponible'}
</p>
</div>
{selectedTicket.prize_value && (
<div className="mt-2">
<p className="text-sm font-medium text-gray-600">Valeur</p>
<p className="text-base font-semibold text-gray-900">
{selectedTicket.prize_value}
</p>
</div>
)}
{selectedTicket.played_at && (
<div>
<p className="text-sm font-medium text-gray-600">Date de gain</p>
<p className="text-sm text-gray-900">
{new Date(selectedTicket.played_at).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div>
)}
</div>
</div>
{/* Reject Reason Input */}
{showRejectInput && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Raison du rejet *
</label>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="Ex: Ticket endommagé, code illisible..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
rows={3}
/>
</div>
)}
{/* Actions */}
<div className="flex gap-3 justify-end pt-4">
{!showRejectInput ? (
<>
<button
onClick={() => {
setShowModal(false);
setSelectedTicket(null);
}}
disabled={validating}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 font-medium"
>
Annuler
</button>
<button
onClick={() => setShowRejectInput(true)}
disabled={validating}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium flex items-center gap-2"
>
<XCircle className="w-4 h-4" />
Rejeter
</button>
<button
onClick={handleValidate}
disabled={validating}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium flex items-center gap-2"
>
<CheckCircle className="w-4 h-4" />
{validating ? "Validation..." : "Valider et Remettre"}
</button>
</>
) : (
<>
<button
onClick={() => {
setShowRejectInput(false);
setRejectReason("");
}}
disabled={validating}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 font-medium"
>
Retour
</button>
<button
onClick={handleReject}
disabled={validating || !rejectReason.trim()}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<XCircle className="w-4 h-4" />
{validating ? "Rejet..." : "Confirmer le Rejet"}
</button>
</>
)}
</div>
</div>
</div>
</div>
)}
</div>
);
}
interface StatCardProps {
title: string;
value: number;
icon: React.ReactNode;
color: "yellow" | "green" | "blue";
}
function StatCard({ title, value, icon, color }: StatCardProps) {
const colors = {
yellow: "bg-yellow-100 text-yellow-600",
green: "bg-green-100 text-green-600",
blue: "bg-blue-100 text-blue-600",
};
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="text-3xl font-bold text-gray-900 mt-2">
{value.toLocaleString("fr-FR")}
</p>
</div>
<div className={`p-3 rounded-lg ${colors[color]}`}>{icon}</div>
</div>
</div>
);
}

285
app/faq/FAQContent.tsx Normal file
View File

@ -0,0 +1,285 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import Button from "@/components/Button";
import { ROUTES } from "@/utils/constants";
interface FAQ {
question: string;
answer: string;
}
interface FAQCategory {
category: string;
icon: string;
faqs: FAQ[];
}
const faqData: FAQCategory[] = [
{
category: "Participation au Jeu",
icon: "🎮",
faqs: [
{
question: "Comment participer au jeu-concours Thé Tip Top ?",
answer: "Pour participer, vous devez d'abord créer un compte sur notre plateforme. Ensuite, connectez-vous et saisissez le code unique présent sur votre ticket de caisse. Chaque achat en magasin vous donne droit à un code pour tenter votre chance !",
},
{
question: "Qui peut participer au jeu-concours ?",
answer: "Le jeu-concours est ouvert à toute personne majeure résidant en France métropolitaine. Les employés de Thé Tip Top et leurs familles directes ne sont pas éligibles.",
},
{
question: "Combien de fois puis-je participer ?",
answer: "Vous pouvez participer autant de fois que vous le souhaitez ! Chaque achat en magasin vous donne un nouveau code à jouer. Il n'y a pas de limite au nombre de participations.",
},
{
question: "Où trouver mon code de participation ?",
answer: "Votre code de participation se trouve sur votre ticket de caisse après chaque achat en magasin Thé Tip Top. Il est clairement indiqué dans une zone dédiée du ticket.",
},
{
question: "Mon code ne fonctionne pas, que faire ?",
answer: "Vérifiez d'abord que vous avez bien saisi le code sans erreur (attention aux caractères similaires comme 0/O ou 1/I). Si le problème persiste, contactez notre service client via la page Contact avec une photo de votre ticket.",
},
],
},
{
category: "Lots et Gains",
icon: "🎁",
faqs: [
{
question: "Quels sont les lots à gagner ?",
answer: "Vous pouvez gagner une variété de lots : des infuseurs à thé, des boîtes de thé signature (100g ou 200g), des coffrets découverte, et même un an de thé d'une valeur de 360€ ! Consultez notre page Lots pour voir tous les prix disponibles.",
},
{
question: "Comment savoir si j'ai gagné ?",
answer: "Vous saurez immédiatement si vous avez gagné après avoir saisi votre code. Un message s'affichera à l'écran vous indiquant votre lot. Vous recevrez également une confirmation par email avec les détails de votre gain.",
},
{
question: "Comment récupérer mon lot ?",
answer: "Pour les petits lots (infuseurs, boîtes de thé), vous pouvez les récupérer directement en magasin en présentant votre confirmation de gain. Pour les lots plus importants, nous vous contacterons pour organiser la livraison.",
},
{
question: "Combien de temps ai-je pour récupérer mon lot ?",
answer: "Vous avez 30 jours à compter de la date de gain pour récupérer votre lot. Passé ce délai, le lot ne pourra plus être réclamé.",
},
{
question: "Puis-je échanger mon lot contre un autre ?",
answer: "Les lots ne sont pas échangeables ni remboursables. Cependant, pour les coffrets et boîtes de thé, vous pourrez choisir parmi plusieurs variétés lors de la récupération.",
},
{
question: "Y a-t-il des frais pour recevoir mon lot ?",
answer: "Non, tous les lots sont entièrement gratuits, sans frais de livraison ni frais cachés.",
},
],
},
{
category: "Compte et Profil",
icon: "👤",
faqs: [
{
question: "Comment créer un compte ?",
answer: "Cliquez sur le bouton 'S'inscrire' en haut de la page, remplissez le formulaire avec vos informations (nom, prénom, email, mot de passe) et validez. Vous recevrez un email de confirmation pour activer votre compte.",
},
{
question: "J'ai oublié mon mot de passe, que faire ?",
answer: "Sur la page de connexion, cliquez sur 'Mot de passe oublié'. Saisissez votre adresse email et vous recevrez un lien pour réinitialiser votre mot de passe.",
},
{
question: "Comment modifier mes informations personnelles ?",
answer: "Connectez-vous à votre compte et accédez à la page 'Mon Profil'. Vous pourrez y modifier vos informations personnelles, votre adresse email et votre mot de passe.",
},
{
question: "Puis-je supprimer mon compte ?",
answer: "Oui, vous pouvez demander la suppression de votre compte en contactant notre service client via la page Contact. Notez que cette action est irréversible et que vous perdrez l'accès à vos lots non récupérés.",
},
{
question: "Mes données personnelles sont-elles sécurisées ?",
answer: "Absolument. Nous prenons la protection de vos données très au sérieux. Toutes les informations sont cryptées et stockées de manière sécurisée. Consultez notre Politique de Confidentialité pour plus de détails.",
},
],
},
{
category: "Problèmes Techniques",
icon: "🔧",
faqs: [
{
question: "Le site ne s'affiche pas correctement, que faire ?",
answer: "Essayez de vider le cache de votre navigateur et de rafraîchir la page. Assurez-vous également d'utiliser un navigateur récent (Chrome, Firefox, Safari, Edge). Si le problème persiste, contactez-nous.",
},
{
question: "Je n'arrive pas à me connecter",
answer: "Vérifiez que vous utilisez la bonne adresse email et le bon mot de passe. Si vous avez oublié votre mot de passe, utilisez la fonction 'Mot de passe oublié'. Assurez-vous également que les cookies sont activés dans votre navigateur.",
},
{
question: "Je n'ai pas reçu l'email de confirmation",
answer: "Vérifiez votre dossier spam ou courrier indésirable. Si vous ne trouvez toujours pas l'email après quelques minutes, vous pouvez demander un nouvel envoi depuis la page de connexion.",
},
{
question: "Le site est-il compatible avec les mobiles ?",
answer: "Oui, notre site est entièrement responsive et optimisé pour tous les appareils (smartphones, tablettes, ordinateurs). Vous pouvez participer depuis n'importe quel appareil.",
},
{
question: "Puis-je utiliser le site depuis l'étranger ?",
answer: "Vous pouvez accéder au site depuis l'étranger, mais seules les personnes résidant en France métropolitaine peuvent participer au jeu-concours et récupérer des lots.",
},
],
},
{
category: "Informations Générales",
icon: "",
faqs: [
{
question: "Quelle est la durée du jeu-concours ?",
answer: "Le jeu-concours se déroule du [date de début] au [date de fin]. Consultez notre page d'accueil pour les dates exactes et le temps restant.",
},
{
question: "Comment contacter le service client ?",
answer: "Vous pouvez nous contacter via notre page Contact, par email à contact@thetiptop.fr, ou par téléphone au [numéro]. Notre équipe est disponible du lundi au vendredi de 9h à 18h.",
},
{
question: "Où se trouvent les magasins Thé Tip Top ?",
answer: "Nous avons des magasins dans toute la France. Consultez notre page À Propos ou contactez-nous pour trouver le magasin le plus proche de chez vous.",
},
{
question: "Puis-je offrir mon lot à quelqu'un d'autre ?",
answer: "Oui, les lots sont cessibles. Vous pouvez offrir votre lot à un tiers, mais la personne devra présenter votre confirmation de gain pour le récupérer.",
},
{
question: "Le jeu-concours est-il gratuit ?",
answer: "Oui, la participation au jeu-concours est entièrement gratuite. Vous devez simplement effectuer un achat en magasin pour obtenir un code de participation.",
},
{
question: "Où puis-je consulter le règlement du jeu ?",
answer: "Le règlement complet du jeu-concours est disponible sur notre page Règlement du Jeu. Nous vous recommandons de le lire attentivement avant de participer.",
},
],
},
];
function FAQItem({ faq, isOpen, onClick }: { faq: FAQ; isOpen: boolean; onClick: () => void }) {
return (
<div className="border-b border-gray-200 last:border-b-0">
<button
className="w-full py-4 px-6 text-left flex justify-between items-center hover:bg-gray-50 transition-colors"
onClick={onClick}
aria-expanded={isOpen}
>
<span className="font-medium text-gray-900 pr-8">{faq.question}</span>
<span className={`text-primary-600 text-xl flex-shrink-0 transition-transform ${isOpen ? 'rotate-45' : ''}`}>
+
</span>
</button>
{isOpen && (
<div className="px-6 pb-4 text-gray-600 leading-relaxed">
{faq.answer}
</div>
)}
</div>
);
}
export default function FAQContent() {
const [openItems, setOpenItems] = useState<{ [key: string]: boolean }>({});
const toggleItem = (categoryIndex: number, faqIndex: number) => {
const key = `${categoryIndex}-${faqIndex}`;
setOpenItems((prev) => ({
...prev,
[key]: !prev[key],
}));
};
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="max-w-4xl mx-auto px-4">
{/* Hero Section */}
<div className="text-center mb-12">
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-4">
Questions Fréquentes
</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
Trouvez rapidement les réponses à vos questions sur le jeu-concours Thé Tip Top.
Si vous ne trouvez pas la réponse que vous cherchez, n'hésitez pas à nous contacter.
</p>
</div>
{/* Search Hint */}
<div className="mb-8 p-4 bg-blue-50 border border-blue-200 rounded-lg text-center">
<p className="text-sm text-blue-800">
💡 <strong>Astuce :</strong> Utilisez Ctrl+F (Cmd+F sur Mac) pour rechercher un mot-clé dans cette page
</p>
</div>
{/* FAQ Categories */}
<div className="space-y-8">
{faqData.map((category, categoryIndex) => (
<Card key={categoryIndex} className="overflow-hidden">
<CardHeader className="bg-gradient-to-r from-primary-50 to-primary-100">
<CardTitle className="flex items-center gap-3 text-primary-900">
<span className="text-2xl">{category.icon}</span>
<span>{category.category}</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{category.faqs.map((faq, faqIndex) => (
<FAQItem
key={faqIndex}
faq={faq}
isOpen={openItems[`${categoryIndex}-${faqIndex}`] || false}
onClick={() => toggleItem(categoryIndex, faqIndex)}
/>
))}
</CardContent>
</Card>
))}
</div>
{/* CTA Section */}
<div className="mt-12 text-center bg-white rounded-lg shadow-lg p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Vous n'avez pas trouvé votre réponse ?
</h2>
<p className="text-gray-600 mb-6">
Notre équipe est pour vous aider ! Contactez-nous et nous vous répondrons dans les plus brefs délais.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link href="/contact">
<Button variant="primary" className="w-full sm:w-auto">
Nous Contacter
</Button>
</Link>
<Link href={ROUTES.HOME}>
<Button variant="outline" className="w-full sm:w-auto">
Retour à l'Accueil
</Button>
</Link>
</div>
</div>
{/* Quick Links */}
<div className="mt-8 text-center text-sm text-gray-600">
<p className="mb-2">Pages utiles :</p>
<div className="flex flex-wrap justify-center gap-4">
<Link href="/rules" className="text-primary-600 hover:text-primary-700 underline">
Règlement du Jeu
</Link>
<span className="text-gray-400"></span>
<Link href={ROUTES.LOTS} className="text-primary-600 hover:text-primary-700 underline">
Lots à Gagner
</Link>
<span className="text-gray-400"></span>
<Link href="/about" className="text-primary-600 hover:text-primary-700 underline">
À Propos
</Link>
<span className="text-gray-400"></span>
<Link href="/privacy" className="text-primary-600 hover:text-primary-700 underline">
Confidentialité
</Link>
</div>
</div>
</div>
</div>
);
}

11
app/faq/page.tsx Normal file
View File

@ -0,0 +1,11 @@
import type { Metadata } from "next";
import FAQContent from "./FAQContent";
export const metadata: Metadata = {
title: "FAQ - Questions Fréquentes | Thé Tip Top",
description: "Trouvez les réponses à toutes vos questions sur le jeu-concours Thé Tip Top, les lots, la participation et plus encore.",
};
export default function FAQPage() {
return <FAQContent />;
}

View File

@ -1,26 +1,64 @@
@import "tailwindcss";
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@layer base {
body {
@apply bg-gray-50 text-gray-900 antialiased;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
h1 {
@apply text-4xl font-bold;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
h2 {
@apply text-3xl font-semibold;
}
h3 {
@apply text-2xl font-semibold;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
}
.btn-primary {
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
}
.btn-secondary {
@apply btn bg-secondary-500 text-white hover:bg-secondary-600 focus:ring-secondary-400;
}
.btn-outline {
@apply btn border-2 border-primary-600 text-primary-600 hover:bg-primary-50 focus:ring-primary-500;
}
.input {
@apply w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
}
.card {
@apply bg-white rounded-xl shadow-md p-6;
}
}
@layer utilities {
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.2s ease-out;
}
}

327
app/historique/page.tsx Normal file
View File

@ -0,0 +1,327 @@
"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 { Calendar, Search } from "lucide-react";
export default function HistoriquePage() {
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
const router = useRouter();
const [tickets, setTickets] = useState<Ticket[]>([]);
const [filteredTickets, setFilteredTickets] = useState<Ticket[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState<'ALL' | 'CLAIMED' | 'PENDING' | 'REJECTED'>('ALL');
const [searchQuery, setSearchQuery] = useState('');
const [stats, setStats] = useState({
total: 0,
claimed: 0,
pending: 0,
rejected: 0,
});
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.push(ROUTES.LOGIN);
return;
}
if (isAuthenticated) {
loadUserTickets();
}
}, [authLoading, isAuthenticated, router]);
useEffect(() => {
filterTickets();
}, [tickets, filter, searchQuery]);
const loadUserTickets = async () => {
try {
const response = await gameService.getMyTickets(1, 1000);
const ticketsData = response?.data || [];
setTickets(ticketsData);
setFilteredTickets(ticketsData);
const total = ticketsData.length;
const claimed = ticketsData.filter((t: Ticket) => t.status === "CLAIMED").length;
const pending = ticketsData.filter((t: Ticket) => t.status === "PENDING").length;
const rejected = ticketsData.filter((t: Ticket) => t.status === "REJECTED").length;
setStats({ total, claimed, pending, rejected });
} catch (error) {
console.error("Error loading tickets:", error);
} finally {
setIsLoading(false);
}
};
const filterTickets = () => {
let result = [...tickets];
if (filter !== 'ALL') {
result = result.filter((t) => t.status === filter);
}
if (searchQuery) {
result = result.filter((t) =>
t.code.toLowerCase().includes(searchQuery.toLowerCase())
);
}
setFilteredTickets(result);
};
if (authLoading || isLoading) {
return (
<div className="min-h-[calc(100vh-8rem)] flex items-center justify-center">
<Loading size="lg" />
</div>
);
}
if (!isAuthenticated) {
return null;
}
const getStatusBadge = (status: string) => {
switch (status) {
case "CLAIMED":
return <Badge variant="success">Réclamé</Badge>;
case "PENDING":
return <Badge variant="warning">En attente</Badge>;
case "REJECTED":
return <Badge variant="danger">Rejeté</Badge>;
default:
return <Badge variant="default">{status}</Badge>;
}
};
return (
<div className="py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-2 flex items-center gap-3">
<Calendar className="w-10 h-10 text-primary-600" />
Historique de mes participations
</h1>
<p className="text-gray-600">
Consultez l'historique complet de vos participations et gains
</p>
</div>
<div className="grid md:grid-cols-4 gap-6 mb-8">
<Card className="bg-gradient-to-br from-blue-50 to-blue-100">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total</p>
<p className="text-3xl font-bold text-blue-600 mt-2">{stats.total}</p>
</div>
<div className="text-4xl">📊</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-green-50 to-green-100">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Réclamés</p>
<p className="text-3xl font-bold text-green-600 mt-2">{stats.claimed}</p>
</div>
<div className="text-4xl"></div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-yellow-50 to-yellow-100">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">En attente</p>
<p className="text-3xl font-bold text-yellow-600 mt-2">{stats.pending}</p>
</div>
<div className="text-4xl"></div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-red-50 to-red-100">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Rejetés</p>
<p className="text-3xl font-bold text-red-600 mt-2">{stats.rejected}</p>
</div>
<div className="text-4xl"></div>
</div>
</CardContent>
</Card>
</div>
<Card className="mb-6">
<CardContent className="pt-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Rechercher par code ticket..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setFilter('ALL')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === 'ALL'
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Tous ({tickets.length})
</button>
<button
onClick={() => setFilter('CLAIMED')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === 'CLAIMED'
? 'bg-green-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Réclamés ({stats.claimed})
</button>
<button
onClick={() => setFilter('PENDING')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === 'PENDING'
? 'bg-yellow-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
En attente ({stats.pending})
</button>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Tous mes tickets ({filteredTickets.length})</CardTitle>
</CardHeader>
<CardContent>
{filteredTickets.length === 0 ? (
<div className="text-center py-12">
<div className="text-6xl mb-4">🎲</div>
<p className="text-gray-600 mb-4">
{searchQuery || filter !== 'ALL'
? 'Aucun ticket trouvé avec ces filtres'
: 'Vous n\'avez pas encore participé au jeu'}
</p>
{!searchQuery && filter === 'ALL' && (
<Button onClick={() => router.push(ROUTES.GAME)} className="bg-white text-black hover:bg-gray-50 border-2 border-black">
Jouer maintenant
</Button>
)}
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Code Ticket
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Gain
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date de participation
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date de réclamation
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredTickets.map((ticket) => {
const prizeConfig = ticket.prize
? PRIZE_CONFIG[ticket.prize.type]
: null;
return (
<tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<span className="font-mono text-sm font-medium text-gray-900">
{ticket.code}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{prizeConfig && (
<>
<span className="text-2xl mr-2">
{prizeConfig.icon}
</span>
<div>
<p className="text-sm font-medium text-gray-900">
{prizeConfig.name}
</p>
<p className="text-xs text-gray-500">
{ticket.prize?.value}
</p>
</div>
</>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(ticket.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{ticket.playedAt
? new Date(ticket.playedAt).toLocaleDateString("fr-FR", {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: "-"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{ticket.claimedAt
? new Date(ticket.claimedAt).toLocaleDateString("fr-FR", {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: "-"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

56
app/icon.svg Normal file
View File

@ -0,0 +1,56 @@
<!-- Generator: visioncortex VTracer 0.6.4 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 263" preserveAspectRatio="xMidYMid meet">
<path d="M0 0 C2.137 0.251 3.459 0.459 5 2 C5.25 4.375 5.25 4.375 5 7 C3.562 8.625 3.562 8.625 2 10 C-0.463 17.388 1.374 23.02 4.438 29.812 C5.083 31.298 5.727 32.784 6.371 34.27 C6.659 34.915 6.947 35.561 7.244 36.226 C7.909 37.786 8.464 39.391 9 41 C10.033 41.086 11.065 41.173 12.129 41.262 C21.205 42.1 30.131 43.428 39.062 45.25 C40.013 45.43 40.963 45.61 41.942 45.796 C49.702 47.456 55.753 50.665 61.312 56.375 C63.504 59.784 65.288 63.33 67 67 C67.461 66.613 67.923 66.227 68.398 65.828 C79.469 56.989 91.07 55.031 105 56 C119.133 57.689 119.133 57.689 124 62 C123.812 64.25 123.812 64.25 123 67 C122.259 67.548 121.518 68.096 120.754 68.66 C117.171 71.704 115.368 75.107 113.125 79.188 C109.504 85.548 105.777 91.43 101 97 C100.399 97.755 99.799 98.511 99.18 99.289 C91.187 108.964 81.289 114.503 69 117 C65.629 117.264 62.369 117.274 59 117 C58.598 118.106 58.598 118.106 58.188 119.234 C52.197 133.187 37.405 143.39 23.743 148.912 C2.437 156.899 -18.575 157.221 -39.879 148.434 C-53.175 142.326 -64.768 133.973 -72 121 C-72.45 120.197 -72.9 119.394 -73.363 118.566 C-79.501 106.96 -81.179 95.697 -81.188 82.688 C-81.2 81.706 -81.212 80.724 -81.225 79.713 C-81.259 62.418 -81.259 62.418 -76.062 56.688 C-68.192 48.948 -57.425 46.826 -46.938 44.688 C-45.735 44.424 -44.533 44.16 -43.295 43.889 C-36.435 42.448 -30.009 41.61 -23 42 C-23.231 41.304 -23.461 40.609 -23.699 39.892 C-26.151 32.378 -27.075 26.324 -23.98 18.871 C-22.421 15.895 -20.693 13.988 -18 12 C-15.188 12.188 -15.188 12.188 -13 13 C-12.25 14.688 -12.25 14.688 -12 17 C-13.875 19.562 -13.875 19.562 -16 22 C-18.483 29.449 -16.145 34.774 -13 41.5 C-10.31 47.253 -8.449 51.591 -9 58 C-7.02 57.67 -5.04 57.34 -3 57 C-2.783 55.577 -2.783 55.577 -2.562 54.125 C-2 51 -2 51 -1 49 C0.22 41.764 -1.417 36.422 -4.5 30 C-7.514 23.635 -9.682 18.146 -8 11 C-6.117 6.737 -3.537 3.056 0 0 Z M-37.562 51.688 C-38.515 51.865 -39.468 52.042 -40.45 52.224 C-46.472 53.392 -52.382 54.774 -58.25 56.562 C-58.911 56.76 -59.571 56.957 -60.252 57.161 C-63.786 58.308 -64.865 58.798 -67 62 C-61.929 64.9 -56.984 67.503 -51 67 C-48.778 66.189 -46.729 65.177 -44.613 64.121 C-36.38 60.589 -26.883 59.594 -18 59 C-17.066 54.469 -17.066 54.469 -18 50 C-24.744 49.815 -30.94 50.441 -37.562 51.688 Z M7 50 C5.515 53.96 5.515 53.96 4 58 C5.653 59.653 7.742 59.331 9.965 59.546 C17.554 60.285 24.43 61.778 31.594 64.411 C36.755 66.229 40.248 66.079 45.301 64.098 C45.965 63.756 46.629 63.414 47.312 63.062 C48.328 62.554 48.328 62.554 49.363 62.035 C51.171 61.089 51.171 61.089 52 59 C37.925 52.931 22.242 50.505 7 50 Z M-12 65 C-12.66 66.32 -13.32 67.64 -14 69 C5.992 69.895 5.992 69.895 26 70 C25.217 67.852 25.217 67.852 23.285 67.402 C11.5 64.938 -0.009 64.882 -12 65 Z M72.812 74.438 C71.061 76.914 69.386 79.3 68 82 C68.33 82.66 68.66 83.32 69 84 C69.801 83.004 69.801 83.004 70.617 81.988 C76.613 74.882 82.216 70.928 91 68 C92.408 67.92 93.82 67.892 95.23 67.902 C96.434 67.907 96.434 67.907 97.662 67.912 C98.495 67.92 99.329 67.929 100.188 67.938 C101.032 67.942 101.877 67.947 102.748 67.951 C104.832 67.963 106.916 67.981 109 68 C109.33 67.34 109.66 66.68 110 66 C105.893 64.631 102.052 64.776 97.75 64.75 C96.916 64.729 96.082 64.709 95.223 64.688 C86.33 64.632 78.889 67.667 72.812 74.438 Z M-41 69 C-41 69.33 -41 69.66 -41 70 C-38.646 70.197 -36.293 70.383 -33.938 70.562 C-31.971 70.719 -31.971 70.719 -29.965 70.879 C-26.077 70.998 -22.809 70.669 -19 70 C-19.66 68.68 -20.32 67.36 -21 66 C-28.081 65.591 -34.199 67.096 -41 69 Z M44 74 C41.777 76.937 41.777 76.937 41 80 C40.732 80.743 40.464 81.485 40.188 82.25 C40.095 83.116 40.095 83.116 40 84 C40.99 84.99 40.99 84.99 42 86 C44.603 85.68 44.603 85.68 47 85 C48.178 97.663 41.505 110.127 33.816 119.727 C24.969 129.344 12.751 135.641 -0.41 136.336 C-17.633 136.671 -34.037 133.285 -47.086 121.176 C-57.665 109.15 -61.672 95.735 -61 80 C-60.723 77.991 -60.418 75.984 -60 74 C-61.456 73.329 -62.915 72.663 -64.375 72 C-65.187 71.629 -65.999 71.257 -66.836 70.875 C-68.92 69.875 -68.92 69.875 -71 70 C-75.262 82.787 -71.866 101.418 -66.375 113.375 C-58.557 127.602 -45.303 137.883 -30 143.004 C-11.056 148.332 9.398 147.322 27 138 C42.141 128.586 52.645 116.466 57 99 C57.984 92.87 58.152 86.889 58.125 80.688 C58.129 79.795 58.133 78.902 58.137 77.982 C58.135 77.126 58.134 76.269 58.133 75.387 C58.131 74.23 58.131 74.23 58.129 73.05 C58.075 70.799 58.075 70.799 57 68 C52.372 68 47.745 71.5 44 74 Z M-48 77 C-46.515 77.99 -46.515 77.99 -45 79 C-45 78.34 -45 77.68 -45 77 C-45.99 77 -46.98 77 -48 77 Z M-55 80 C-55.858 91.677 -53.343 102.595 -46.562 112.23 C-44.876 114.14 -43.087 115.547 -41 117 C-41.899 114.679 -42.792 112.395 -43.953 110.191 C-48.053 102.098 -49 92.997 -49 84 C-49.619 83.711 -50.237 83.422 -50.875 83.125 C-53 82 -53 82 -55 80 Z " fill="#694633" transform="translate(160,27)"/>
<path d="M0 0 C3.064 5.587 1.843 10.745 0.316 16.641 C-3.952 29.919 -12.878 39.98 -25.125 46.375 C-36.183 51.86 -51.43 51.693 -63.188 48.375 C-68.572 46.427 -72.851 43.94 -77 40 C-77.812 39.23 -77.812 39.23 -78.641 38.445 C-86.874 29.755 -89.418 18.647 -90 7 C-89.539 4.559 -89.539 4.559 -89 3 C-87.783 3.433 -86.566 3.866 -85.312 4.312 C-73.563 8.16 -62.535 9.506 -50.25 9.375 C-49.128 9.37 -49.128 9.37 -47.984 9.364 C-32.765 9.277 -18.449 8.224 -4.625 1.312 C-2 0 -2 0 0 0 Z " fill="#C1C333" transform="translate(200,108)"/>
<path d="M0 0 C0.711 -0.008 1.422 -0.015 2.154 -0.023 C5.913 -0.009 7.611 0.116 10.812 2.25 C7.295 8.96 3.303 15.151 -1.188 21.25 C-2.116 22.542 -2.116 22.542 -3.062 23.859 C-10.785 34.135 -19.567 39.587 -32.188 42.25 C-35.074 42.348 -35.074 42.348 -37.188 42.25 C-34.761 28.792 -20.416 21.512 -9.925 14.221 C-8.893 13.509 -8.893 13.509 -7.84 12.781 C-7.2 12.338 -6.561 11.894 -5.902 11.438 C-4.5 10.466 -3.096 9.498 -1.691 8.531 C-1.195 8.108 -0.699 7.686 -0.188 7.25 C-0.188 6.59 -0.188 5.93 -0.188 5.25 C-9.873 6.668 -17.618 14.177 -24.684 20.517 C-27.7 23.182 -30.34 25.022 -34.188 26.25 C-33.532 19.302 -33.532 19.302 -30.188 16.25 C-29.143 14.981 -28.102 13.71 -27.062 12.438 C-19.637 3.913 -11.327 -0.125 0 0 Z " fill="#C3C434" transform="translate(259.1875,93.75)"/>
<path d="M0 0 C1.675 0.286 3.344 0.618 5 1 C6.188 13.767 -0.571 26.346 -8.406 35.949 C-17.628 45.89 -29.674 52.612 -43.41 53.336 C-44.961 53.366 -46.512 53.378 -48.062 53.375 C-48.886 53.373 -49.709 53.372 -50.558 53.37 C-58.518 53.213 -65.496 51.651 -73 49 C-73.869 48.7 -74.738 48.399 -75.633 48.09 C-78.382 46.952 -80.534 45.919 -81.918 43.191 C-82.5 41.25 -82.5 41.25 -83 38 C-81.515 37.505 -81.515 37.505 -80 37 C-77.906 38.109 -77.906 38.109 -75.5 39.75 C-66.982 45.005 -59.247 46.245 -49.375 46.312 C-48.194 46.321 -48.194 46.321 -46.989 46.33 C-35.117 46.183 -25.306 42.764 -16.41 34.633 C-7.183 24.776 -2.697 14.29 -1 1 C-0.67 0.67 -0.34 0.34 0 0 Z " fill="#684633" transform="translate(203,111)"/>
<path d="M0 0 C3.17 1.281 4.578 2.52 6.812 5.062 C8.575 9.423 8.785 13.368 8 18 C6.635 20.903 5.212 22.582 3 25 C-1.995 26.665 -7.008 26.866 -11.875 24.75 C-15.634 21.655 -17.114 19.453 -17.875 14.625 C-18.218 10.272 -17.548 7.652 -15 4 C-10.753 -0.89 -6.07 -0.82 0 0 Z M-8 7 C-10.327 10.491 -10.503 11.892 -10 16 C-8.125 18.377 -8.125 18.377 -6 20 C-2.75 18.912 -2.75 18.912 0 17 C0.229 12.983 0.229 12.983 0 9 C-1.887 6.693 -1.887 6.693 -5 6.688 C-5.99 6.791 -6.98 6.894 -8 7 Z " fill="#684736" transform="translate(293,200)"/>
<path d="M0 0 C2.625 0.375 2.625 0.375 5 1 C6.082 4.365 5.925 5.298 5 9 C7.97 9 10.94 9 14 9 C13.959 8.072 13.918 7.144 13.875 6.188 C14 3 14 3 16 0 C18.97 0.495 18.97 0.495 22 1 C22 8.92 22 16.84 22 25 C20.02 25.33 18.04 25.66 16 26 C13.571 22.356 13.838 20.288 14 16 C11.03 16 8.06 16 5 16 C5.206 16.907 5.412 17.815 5.625 18.75 C6.013 22.11 5.804 23.241 4 26 C1.563 25.625 1.563 25.625 -1 25 C-2.885 21.23 -2.185 16.648 -2.188 12.5 C-2.2 11.524 -2.212 10.548 -2.225 9.543 C-2.227 8.611 -2.228 7.679 -2.23 6.719 C-2.235 5.862 -2.239 5.006 -2.243 4.123 C-2 2 -2 2 0 0 Z " fill="#694837" transform="translate(63,200)"/>
<path d="M0 0 C0 0.66 0 1.32 0 2 C-13.17 9.489 -28.054 10.214 -42.879 10.295 C-44.322 10.307 -45.766 10.327 -47.209 10.357 C-57.411 10.566 -67.496 10.139 -77 6 C-78.41 3.861 -78.41 3.861 -79 2 C-77.914 2.069 -76.828 2.139 -75.708 2.21 C-64.677 2.886 -53.677 3.235 -42.625 3.25 C-41.825 3.252 -41.025 3.255 -40.2 3.257 C-29.108 3.269 -18.387 2.777 -7.465 0.656 C-4 0 -4 0 0 0 Z " fill="#C1C039" transform="translate(195,103)"/>
<path d="M0 0 C-0.375 1.938 -0.375 1.938 -1 4 C-1.99 4.495 -1.99 4.495 -3 5 C-0.525 5.99 -0.525 5.99 2 7 C2 8.65 2 10.3 2 12 C-0.709 13.354 -3.009 13.065 -6 13 C-5.107 15.358 -5.107 15.358 -1.938 16.125 C-0.483 16.558 -0.483 16.558 1 17 C1 18.32 1 19.64 1 21 C0 22 0 22 -3.062 22.062 C-4.032 22.042 -5.001 22.021 -6 22 C-6 22.99 -6 23.98 -6 25 C-4.298 24.969 -4.298 24.969 -2.562 24.938 C1 25 1 25 2 26 C2.041 27.666 2.043 29.334 2 31 C-0.58 32.29 -2.557 32.204 -5.438 32.25 C-6.406 32.276 -7.374 32.302 -8.371 32.328 C-11 32 -11 32 -12.646 30.785 C-14.429 28.435 -14.346 27.041 -14.293 24.113 C-14.283 23.175 -14.274 22.238 -14.264 21.271 C-14.239 20.295 -14.213 19.319 -14.188 18.312 C-14.174 17.324 -14.16 16.336 -14.146 15.318 C-14.111 12.878 -14.062 10.439 -14 8 C-13.01 7.67 -12.02 7.34 -11 7 C-11.33 5.35 -11.66 3.7 -12 2 C-7.528 -0.662 -5.066 -1.327 0 0 Z " fill="#684736" transform="translate(109,194)"/>
<path d="M0 0 C5.576 2.152 5.576 2.152 7 5 C7.584 12.241 7.584 12.241 5.125 15.438 C1.562 18.057 -0.607 18.095 -5 18 C-4.938 19.423 -4.938 19.423 -4.875 20.875 C-5 24 -5 24 -7 26 C-9.562 25.75 -9.562 25.75 -12 25 C-13.822 21.356 -13.228 17.074 -13.25 13.062 C-13.271 12.143 -13.291 11.223 -13.312 10.275 C-13.318 9.392 -13.323 8.508 -13.328 7.598 C-13.337 6.788 -13.347 5.979 -13.356 5.145 C-12.217 -1.712 -5.411 -0.639 0 0 Z M-5 7 C-5 8.65 -5 10.3 -5 12 C-3.35 11.34 -1.7 10.68 0 10 C-0.33 9.01 -0.66 8.02 -1 7 C-2.32 7 -3.64 7 -5 7 Z " fill="#694735" transform="translate(215,200)"/>
<path d="M0 0 C4.433 0.083 7.491 0.807 11.25 3.312 C12.433 5.679 12.384 7.18 12.375 9.812 C12.379 11.004 12.379 11.004 12.383 12.219 C12.25 14.312 12.25 14.312 11.25 16.312 C8.071 18.314 4.937 18.81 1.25 19.312 C0.26 22.778 0.26 22.778 -0.75 26.312 C-2.73 25.983 -4.71 25.653 -6.75 25.312 C-6.808 21.521 -6.844 17.729 -6.875 13.938 C-6.892 12.857 -6.909 11.777 -6.926 10.664 C-6.932 9.633 -6.939 8.602 -6.945 7.539 C-6.956 6.586 -6.966 5.633 -6.977 4.651 C-6.534 0.092 -3.964 0.018 0 0 Z M1.25 7.312 C0.92 8.632 0.59 9.952 0.25 11.312 C1.24 11.808 1.24 11.808 2.25 12.312 C3.24 11.653 4.23 10.993 5.25 10.312 C5.25 9.653 5.25 8.993 5.25 8.312 C3.93 7.982 2.61 7.653 1.25 7.312 Z " fill="#664534" transform="translate(317.75,199.6875)"/>
<path d="M0 0 C3.064 5.587 1.843 10.745 0.316 16.641 C-3.952 29.919 -12.878 39.98 -25.125 46.375 C-36.183 51.86 -51.43 51.693 -63.188 48.375 C-68.572 46.427 -72.851 43.94 -77 40 C-77.812 39.23 -77.812 39.23 -78.641 38.445 C-86.874 29.755 -89.418 18.647 -90 7 C-89.67 5.68 -89.34 4.36 -89 3 C-86.03 3.99 -83.06 4.98 -80 6 C-84 7 -84 7 -86 6 C-86.653 17.211 -86.653 17.211 -82 27 C-81.732 27.804 -81.464 28.609 -81.188 29.438 C-78.28 35.711 -73.445 40.485 -67 43 C-67 43.66 -67 44.32 -67 45 C-66.268 45.143 -65.536 45.286 -64.781 45.434 C-63.109 45.774 -61.44 46.135 -59.781 46.535 C-46.464 49.614 -33.919 48.718 -22 42 C-21.093 41.505 -20.185 41.01 -19.25 40.5 C-15.662 38.108 -13.377 36.131 -12 32 C-11.402 31.423 -10.804 30.845 -10.188 30.25 C-4.39 24.287 -1.896 17.323 -1.875 9.125 C-1.907 7.416 -1.945 5.708 -2 4 C-4.31 4.33 -6.62 4.66 -9 5 C-6.455 2.201 -3.924 0 0 0 Z M-10.812 5.375 C-10.214 5.581 -9.616 5.787 -9 6 C-11.31 6.33 -13.62 6.66 -16 7 C-13 5 -13 5 -10.812 5.375 Z " fill="#BCB346" transform="translate(200,108)"/>
<path d="M0 0 C0.711 -0.008 1.422 -0.015 2.154 -0.023 C5.913 -0.009 7.611 0.116 10.812 2.25 C10.025 3.711 9.232 5.168 8.438 6.625 C7.997 7.437 7.556 8.249 7.102 9.086 C5.812 11.25 5.812 11.25 3.812 13.25 C3.442 7.697 3.442 7.697 5.312 5.438 C5.808 5.046 6.303 4.654 6.812 4.25 C6.812 3.92 6.812 3.59 6.812 3.25 C-5.702 2.769 -15.809 12.564 -24.734 20.568 C-27.733 23.214 -30.366 25.026 -34.188 26.25 C-33.532 19.302 -33.532 19.302 -30.188 16.25 C-29.143 14.981 -28.102 13.71 -27.062 12.438 C-19.637 3.913 -11.327 -0.125 0 0 Z M-0.188 4.25 C0.473 4.25 1.132 4.25 1.812 4.25 C1.483 5.24 1.152 6.23 0.812 7.25 C0.483 6.26 0.152 5.27 -0.188 4.25 Z " fill="#C3C141" transform="translate(259.1875,93.75)"/>
<path d="M0 0 C0.685 0.008 1.37 0.015 2.076 0.023 C2.757 0.016 3.439 0.008 4.141 0 C7.853 0.015 9.475 0.164 12.639 2.273 C12.309 3.923 11.979 5.573 11.639 7.273 C9.989 7.273 8.339 7.273 6.639 7.273 C6.662 8.477 6.685 9.681 6.709 10.922 C6.728 12.497 6.746 14.073 6.764 15.648 C6.789 16.84 6.789 16.84 6.814 18.055 C6.832 20.128 6.742 22.202 6.639 24.273 C5.979 24.933 5.319 25.593 4.639 26.273 C0.083 25.718 0.083 25.718 -1.361 24.273 C-1.435 21.411 -1.454 18.573 -1.424 15.711 C-1.419 14.905 -1.415 14.098 -1.41 13.268 C-1.398 11.269 -1.38 9.271 -1.361 7.273 C-3.011 6.943 -4.661 6.613 -6.361 6.273 C-6.986 4.398 -6.986 4.398 -7.361 2.273 C-4.878 -0.21 -3.395 0.013 0 0 Z " fill="#694837" transform="translate(40.361328125,199.7265625)"/>
<path d="M0 0 C1.028 -0.012 1.028 -0.012 2.076 -0.023 C7.184 -0.003 7.184 -0.003 9.438 2.25 C9.062 4.375 9.062 4.375 8.438 6.25 C6.787 6.58 5.137 6.91 3.438 7.25 C3.461 8.454 3.484 9.658 3.508 10.898 C3.527 12.474 3.545 14.049 3.562 15.625 C3.588 16.816 3.588 16.816 3.613 18.031 C3.631 20.105 3.541 22.179 3.438 24.25 C2.778 24.91 2.118 25.57 1.438 26.25 C-1.125 26 -1.125 26 -3.562 25.25 C-5.157 22.062 -4.664 18.621 -4.625 15.125 C-4.62 14.371 -4.616 13.617 -4.611 12.84 C-4.6 10.977 -4.582 9.113 -4.562 7.25 C-6.213 6.92 -7.863 6.59 -9.562 6.25 C-9.893 4.93 -10.222 3.61 -10.562 2.25 C-6.926 -0.175 -4.2 -0.048 0 0 Z " fill="#644332" transform="translate(166.5625,199.75)"/>
<path d="M0 0 C0.685 0.008 1.37 0.015 2.076 0.023 C2.757 0.016 3.439 0.008 4.141 0 C7.853 0.015 9.475 0.164 12.639 2.273 C12.451 4.148 12.451 4.148 11.639 6.273 C9.989 6.933 8.339 7.593 6.639 8.273 C6.309 13.883 5.979 19.493 5.639 25.273 C3.989 25.603 2.339 25.933 0.639 26.273 C-1.361 24.273 -1.361 24.273 -1.557 20.359 C-1.543 18.789 -1.519 17.219 -1.486 15.648 C-1.477 14.847 -1.468 14.045 -1.459 13.219 C-1.435 11.237 -1.4 9.255 -1.361 7.273 C-3.011 6.943 -4.661 6.613 -6.361 6.273 C-6.986 4.398 -6.986 4.398 -7.361 2.273 C-4.878 -0.21 -3.395 0.013 0 0 Z " fill="#6A4838" transform="translate(255.361328125,199.7265625)"/>
<path d="M0 0 C0.763 0.206 1.526 0.412 2.312 0.625 C0.797 4.908 -2.39 6.817 -6 9.25 C-7.267 10.128 -8.533 11.008 -9.797 11.891 C-10.442 12.34 -11.087 12.789 -11.752 13.252 C-14.812 15.423 -17.784 17.702 -20.75 20 C-22.283 21.187 -22.283 21.187 -23.848 22.398 C-26.474 24.458 -29.086 26.534 -31.688 28.625 C-31.688 25.047 -31.152 22.907 -29.688 19.625 C-27.545 17.6 -25.546 15.91 -23.188 14.188 C-22.563 13.714 -21.938 13.241 -21.294 12.754 C-3.653 -0.51 -3.653 -0.51 0 0 Z M-34.688 29.625 C-33.643 32.758 -33.753 33.615 -34.688 36.625 C-35.347 36.625 -36.007 36.625 -36.688 36.625 C-35.812 31.875 -35.812 31.875 -34.688 29.625 Z " fill="#6A472A" transform="translate(257.6875,98.375)"/>
<path d="M0 0 C4.556 0.556 4.556 0.556 6 2 C6.252 5.639 6.185 9.291 6.188 12.938 C6.2 13.966 6.212 14.994 6.225 16.053 C6.228 17.525 6.228 17.525 6.23 19.027 C6.235 19.932 6.239 20.837 6.243 21.769 C6 24 6 24 4 26 C1.438 25.75 1.438 25.75 -1 25 C-2.885 21.23 -2.185 16.648 -2.188 12.5 C-2.2 11.524 -2.212 10.548 -2.225 9.543 C-2.227 8.611 -2.228 7.679 -2.23 6.719 C-2.235 5.862 -2.239 5.006 -2.243 4.123 C-2 2 -2 2 0 0 Z " fill="#6A4837" transform="translate(186,200)"/>
<path d="M0 0 C1.286 -0.001 2.572 -0.003 3.896 -0.004 C4.889 -0.001 4.889 -0.001 5.902 0.002 C7.903 0.008 9.903 0.002 11.904 -0.004 C19.034 0.003 26.031 0.216 33.105 1.133 C32.775 1.793 32.445 2.453 32.105 3.133 C26.238 4.515 19.91 4.27 13.914 4.23 C13.083 4.229 12.253 4.228 11.397 4.226 C8.779 4.221 6.161 4.208 3.543 4.195 C1.753 4.19 -0.036 4.186 -1.826 4.182 C-6.182 4.171 -10.538 4.153 -14.895 4.133 C-14.565 3.143 -14.235 2.153 -13.895 1.133 C-9.219 0.384 -4.734 0.005 0 0 Z " fill="#C5C04B" transform="translate(147.89453125,95.8671875)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C1.34 1.598 0.68 2.196 0 2.812 C-2.337 4.875 -2.337 4.875 -2 8 C-3.727 8.508 -5.457 9.006 -7.188 9.5 C-8.15 9.778 -9.113 10.057 -10.105 10.344 C-12.853 10.967 -15.196 11.123 -18 11 C-17.34 10.67 -16.68 10.34 -16 10 C-16 9.34 -16 8.68 -16 8 C-17.21 8.244 -17.21 8.244 -18.444 8.494 C-22.634 9.09 -26.674 9.125 -30.898 9.098 C-31.733 9.096 -32.568 9.095 -33.429 9.093 C-36.077 9.088 -38.726 9.075 -41.375 9.062 C-43.178 9.057 -44.982 9.053 -46.785 9.049 C-51.19 9.038 -55.595 9.021 -60 9 C-60 8.67 -60 8.34 -60 8 C-58.634 7.978 -58.634 7.978 -57.24 7.956 C-28.819 8.202 -28.819 8.202 -1.785 0.672 C-1.196 0.45 -0.607 0.228 0 0 Z M5 1 C5.66 1.66 6.32 2.32 7 3 C5.035 4.068 3.031 5.066 1 6 C0.34 5.67 -0.32 5.34 -1 5 C0.98 3.68 2.96 2.36 5 1 Z " fill="#725623" transform="translate(193,105)"/>
<path d="M0 0 C0.33 0 0.66 0 1 0 C1.38 6.199 0.835 10.448 -2 16 C-3.381 12.233 -2.596 9.552 -1.562 5.75 C-1.275 4.672 -0.988 3.595 -0.691 2.484 C-0.463 1.665 -0.235 0.845 0 0 Z M-4 16 C-3 19 -3 19 -4.039 21.305 C-6.984 26.078 -9.778 30.234 -14 34 C-14.598 34.598 -15.196 35.196 -15.812 35.812 C-16.204 36.204 -16.596 36.596 -17 37 C-17.66 36.67 -18.32 36.34 -19 36 C-18.336 35.31 -17.672 34.621 -16.988 33.91 C-11.752 28.337 -7.275 22.981 -4 16 Z M-21 37 C-20.34 37.33 -19.68 37.66 -19 38 C-19.33 38.66 -19.66 39.32 -20 40 C-23.062 40.625 -23.062 40.625 -26 41 C-24.35 39.68 -22.7 38.36 -21 37 Z M-29 41 C-28.01 41.33 -27.02 41.66 -26 42 C-27.32 42.33 -28.64 42.66 -30 43 C-29.67 42.34 -29.34 41.68 -29 41 Z M-66 43 C-64.948 43.164 -63.896 43.327 -62.812 43.496 C-56.887 44.237 -50.962 44.107 -45 44.062 C-43.844 44.058 -42.687 44.053 -41.496 44.049 C-38.664 44.037 -35.832 44.021 -33 44 C-36.335 46.223 -37.44 46.256 -41.332 46.266 C-42.378 46.268 -43.424 46.271 -44.502 46.273 C-45.594 46.266 -46.687 46.258 -47.812 46.25 C-48.893 46.258 -49.974 46.265 -51.088 46.273 C-56.429 46.26 -61.021 46.19 -66 44 C-66 43.67 -66 43.34 -66 43 Z M-33 43 C-30 44 -30 44 -30 44 Z " fill="#72571E" transform="translate(202,113)"/>
<path d="M0 0 C1.043 0.071 2.085 0.143 3.16 0.217 C15.491 1.01 27.772 1.119 40.125 1.062 C42.066 1.057 44.008 1.053 45.949 1.049 C50.633 1.038 55.316 1.021 60 1 C52.657 5.237 43.754 4.196 35.562 4.188 C34.556 4.189 34.556 4.189 33.529 4.19 C23.883 4.189 14.485 3.864 5 2 C7.64 2.99 10.28 3.98 13 5 C13 5.33 13 5.66 13 6 C7.906 5.568 4.155 5.246 0 2 C0 1.34 0 0.68 0 0 Z " fill="#C0B747" transform="translate(116,105)"/>
<path d="M0 0 C0.495 0.99 0.495 0.99 1 2 C-7.291 13.51 -15.948 20.035 -30 23 C-32.887 23.098 -32.887 23.098 -35 23 C-34.01 20.03 -33.02 17.06 -32 14 C-31.01 14 -30.02 14 -29 14 C-29.66 14.66 -30.32 15.32 -31 16 C-31.648 18.571 -31.648 18.571 -32 21 C-20.533 18.218 -12.858 15.053 -5 6 C-4.67 5.34 -4.34 4.68 -4 4 C-3.34 4 -2.68 4 -2 4 C-1.34 2.68 -0.68 1.36 0 0 Z " fill="#BAB045" transform="translate(257,113)"/>
<path d="M0 0 C0.66 0.99 1.32 1.98 2 3 C2.99 3.66 3.98 4.32 5 5 C-0.94 9.95 -0.94 9.95 -7 15 C-7.66 14.67 -8.32 14.34 -9 14 C-8.541 9.872 -8.234 7.657 -5 5 C-4.113 4.113 -3.226 3.226 -2.312 2.312 C-1.549 1.549 -0.786 0.786 0 0 Z " fill="#B9AE44" transform="translate(234,105)"/>
<path d="M0 0 C2.604 -0.054 5.208 -0.094 7.812 -0.125 C8.55 -0.142 9.288 -0.159 10.049 -0.176 C13.912 -0.211 15.709 -0.194 19 2 C18.213 3.461 17.42 4.918 16.625 6.375 C16.184 7.187 15.743 7.999 15.289 8.836 C14 11 14 11 12 13 C11.63 7.447 11.63 7.447 13.5 5.188 C14.243 4.6 14.243 4.6 15 4 C15 3.67 15 3.34 15 3 C10.69 2.834 7.721 2.768 4 5 C2.339 5.68 0.673 6.349 -1 7 C-0.34 6.01 0.32 5.02 1 4 C1.66 4 2.32 4 3 4 C3 3.34 3 2.68 3 2 C1.35 2.33 -0.3 2.66 -2 3 C-1.34 2.67 -0.68 2.34 0 2 C0 1.34 0 0.68 0 0 Z M8 4 C8.66 4 9.32 4 10 4 C9.67 4.99 9.34 5.98 9 7 C8.67 6.01 8.34 5.02 8 4 Z " fill="#BDB44B" transform="translate(251,94)"/>
<path d="M0 0 C2.97 0.99 5.94 1.98 9 3 C5 4 5 4 3 3 C2.347 14.211 2.347 14.211 7 24 C8.378 27.032 9 28.657 9 32 C1.398 25.755 0.371 16.553 -0.801 7.355 C-1 4 -1 4 0 0 Z " fill="#BDB552" transform="translate(111,111)"/>
<path d="M0 0 C3.237 -0.294 5.008 0.004 8 1.375 C19.11 6.384 32.399 5.247 44.312 5.125 C46.036 5.115 47.759 5.106 49.482 5.098 C53.655 5.076 57.827 5.042 62 5 C62 5.33 62 5.66 62 6 C54.573 7.023 47.24 7.255 39.749 7.295 C38.264 7.307 36.779 7.327 35.294 7.357 C24.031 7.581 8.962 7.875 0 0 Z " fill="#6D501C" transform="translate(113,111)"/>
<path d="M0 0 C2.696 1.54 5.14 3.113 7.562 5.062 C9.671 6.738 11.554 7.913 14 9 C14 9.66 14 10.32 14 11 C14.639 11.124 15.279 11.248 15.938 11.375 C16.948 11.581 17.959 11.787 19 12 C19.759 12.146 20.519 12.291 21.301 12.441 C22.109 12.605 22.917 12.769 23.75 12.938 C24.529 13.091 25.307 13.244 26.109 13.402 C26.733 13.6 27.357 13.797 28 14 C28.33 14.66 28.66 15.32 29 16 C17.041 15.097 8.075 11.073 0 2 C0 1.34 0 0.68 0 0 Z " fill="#B7AC44" transform="translate(119,142)"/>
<path d="M0 0 C0.33 0.99 0.66 1.98 1 3 C-0.938 6.188 -0.938 6.188 -3 9 C-3.33 8.01 -3.66 7.02 -4 6 C-2.062 2.812 -2.062 2.812 0 0 Z M-6 11 C-6 14 -6 14 -6 14 Z M-8 14 C-8 17.693 -8.882 19.005 -11 22 C-13.688 24.312 -13.688 24.312 -16 26 C-14.59 22.961 -12.911 20.406 -10.875 17.75 C-10.336 17.044 -9.797 16.337 -9.242 15.609 C-8.832 15.078 -8.422 14.547 -8 14 Z M-17 26 C-17 26.99 -17 27.98 -17 29 C-17.66 28.67 -18.32 28.34 -19 28 C-18.34 27.34 -17.68 26.68 -17 26 Z M-23 31 C-22.34 31.33 -21.68 31.66 -21 32 C-21.99 32 -22.98 32 -24 32 C-23.67 31.67 -23.34 31.34 -23 31 Z M-24 33 C-24 33.66 -24 34.32 -24 35 C-25.65 35.33 -27.3 35.66 -29 36 C-26.25 33 -26.25 33 -24 33 Z " fill="#71561D" transform="translate(269,96)"/>
<path d="M0 0 C-0.33 1.32 -0.66 2.64 -1 4 C-2.456 4.363 -3.915 4.715 -5.375 5.062 C-6.593 5.358 -6.593 5.358 -7.836 5.66 C-10 6 -10 6 -12 5 C-11.812 3.125 -11.812 3.125 -11 1 C-7.112 -1.333 -4.319 -1.004 0 0 Z " fill="#654738" transform="translate(109,194)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C1.68 1.99 0.36 2.98 -1 4 C-1.66 3.67 -2.32 3.34 -3 3 C-2.01 2.01 -1.02 1.02 0 0 Z M-5 4 C-4.34 4.33 -3.68 4.66 -3 5 C-5.64 6.98 -8.28 8.96 -11 11 C-11.33 10.34 -11.66 9.68 -12 9 C-9.69 7.35 -7.38 5.7 -5 4 Z M-14 11 C-13.34 11.33 -12.68 11.66 -12 12 C-13.98 12.99 -13.98 12.99 -16 14 C-15.34 13.01 -14.68 12.02 -14 11 Z M-22 16 C-20.68 16.33 -19.36 16.66 -18 17 C-19.65 18.32 -21.3 19.64 -23 21 C-23 18 -23 18 -22 16 Z M-26 22 C-24.956 25.133 -25.066 25.99 -26 29 C-26.66 29 -27.32 29 -28 29 C-27.125 24.25 -27.125 24.25 -26 22 Z " fill="#71561E" transform="translate(249,106)"/>
<path d="M0 0 C2.599 4.739 1.949 8.883 1 14 C0.67 14.99 0.34 15.98 0 17 C-0.66 17 -1.32 17 -2 17 C-2 12.71 -2 8.42 -2 4 C-4.31 4.33 -6.62 4.66 -9 5 C-6.455 2.201 -3.924 0 0 0 Z M-10.812 5.375 C-10.214 5.581 -9.616 5.787 -9 6 C-11.31 6.33 -13.62 6.66 -16 7 C-13 5 -13 5 -10.812 5.375 Z " fill="#B8AE49" transform="translate(200,108)"/>
<path d="M0 0 C0.875 0.052 1.749 0.104 2.65 0.158 C4.788 0.287 6.925 0.424 9.062 0.562 C9.062 0.892 9.062 1.222 9.062 1.562 C4.442 1.892 -0.178 2.222 -4.938 2.562 C-4.938 2.892 -4.938 3.222 -4.938 3.562 C-9.227 3.562 -13.518 3.562 -17.938 3.562 C-17.607 2.573 -17.278 1.582 -16.938 0.562 C-11.175 -0.493 -5.832 -0.386 0 0 Z " fill="#B5AA48" transform="translate(150.9375,96.4375)"/>
<path d="M0 0 C0.33 0.66 0.66 1.32 1 2 C0.34 1.67 -0.32 1.34 -1 1 C-0.67 0.67 -0.34 0.34 0 0 Z M-3 2 C-2.34 2.33 -1.68 2.66 -1 3 C-1.99 3 -2.98 3 -4 3 C-3.67 2.67 -3.34 2.34 -3 2 Z M-6 4 C-5.34 4.33 -4.68 4.66 -4 5 C-5.32 5.99 -6.64 6.98 -8 8 C-8.66 7.67 -9.32 7.34 -10 7 C-8.68 6.01 -7.36 5.02 -6 4 Z M-12 8 C-11.34 8.33 -10.68 8.66 -10 9 C-11.98 9.99 -11.98 9.99 -14 11 C-13.34 10.01 -12.68 9.02 -12 8 Z M-15 11 C-15 11.99 -15 12.98 -15 14 C-16.609 15.5 -16.609 15.5 -18.75 17 C-19.446 17.495 -20.142 17.99 -20.859 18.5 C-21.566 18.995 -22.272 19.49 -23 20 C-24.337 20.996 -25.671 21.994 -27 23 C-27.66 22.67 -28.32 22.34 -29 22 C-27.609 20.724 -26.212 19.454 -24.812 18.188 C-24.035 17.48 -23.258 16.772 -22.457 16.043 C-20.08 14.067 -17.689 12.514 -15 11 Z " fill="#BFB646" transform="translate(258,102)"/>
<path d="M0 0 C5.94 0.33 11.88 0.66 18 1 C18 1.33 18 1.66 18 2 C13.71 2 9.42 2 5 2 C7.64 2.99 10.28 3.98 13 5 C13 5.33 13 5.66 13 6 C7.906 5.568 4.155 5.246 0 2 C0 1.34 0 0.68 0 0 Z " fill="#BCB247" transform="translate(116,105)"/>
<path d="M0 0 C3.91 -0.355 6.322 0.553 9.824 2.207 C13.597 3.582 17.556 3.956 21.523 4.473 C22.341 4.647 23.158 4.821 24 5 C24.33 5.66 24.66 6.32 25 7 C16.352 6.456 6.871 5.72 0 0 Z " fill="#765D20" transform="translate(113,111)"/>
<path d="M0 0 C4.29 0.33 8.58 0.66 13 1 C12.67 1.66 12.34 2.32 12 3 C5.327 4.076 -1.254 4.113 -8 4 C-8 3.67 -8 3.34 -8 3 C-5.36 2.67 -2.72 2.34 0 2 C0 1.34 0 0.68 0 0 Z " fill="#B1A85C" transform="translate(168,96)"/>
<path d="M0 0 C0.701 0.248 1.402 0.495 2.125 0.75 C0.736 4.106 -0.792 5.823 -3.875 7.75 C-4.535 7.75 -5.195 7.75 -5.875 7.75 C-5.875 7.09 -5.875 6.43 -5.875 5.75 C-6.865 5.09 -7.855 4.43 -8.875 3.75 C-3.583 -0.312 -3.583 -0.312 0 0 Z M-4.875 2.75 C-4.875 3.41 -4.875 4.07 -4.875 4.75 C-3.555 4.42 -2.235 4.09 -0.875 3.75 C-0.875 2.76 -0.875 1.77 -0.875 0.75 C-2.337 0.658 -2.337 0.658 -4.875 2.75 Z " fill="#73581F" transform="translate(257.875,98.25)"/>
<path d="M0 0 C0 0.66 0 1.32 0 2 C-1.242 2.702 -2.494 3.386 -3.75 4.062 C-4.794 4.637 -4.794 4.637 -5.859 5.223 C-8.361 6.131 -9.533 5.883 -12 5 C-10.68 4.67 -9.36 4.34 -8 4 C-8 3.34 -8 2.68 -8 2 C-8.66 1.67 -9.32 1.34 -10 1 C-6.565 0.375 -3.509 0 0 0 Z " fill="#B6AA44" transform="translate(195,103)"/>
<path d="M0 0 C3.381 3.114 5.709 5.541 7 10 C7 10.99 7 11.98 7 13 C3.664 10.009 1.755 7.124 0 3 C0 2.01 0 1.02 0 0 Z " fill="#C2BA50" transform="translate(113,130)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C1.34 1.66 0.68 2.32 0 3 C-0.648 5.571 -0.648 5.571 -1 8 C-0.01 8.33 0.98 8.66 2 9 C1 10 1 10 -1.562 10.062 C-2.367 10.042 -3.171 10.021 -4 10 C-3.524 8.52 -3.044 7.041 -2.562 5.562 C-2.296 4.739 -2.029 3.915 -1.754 3.066 C-1 1 -1 1 0 0 Z " fill="#B8AD44" transform="translate(226,126)"/>
<path d="M0 0 C0.99 0.66 1.98 1.32 3 2 C0.03 4.31 -2.94 6.62 -6 9 C-6 6 -6 6 -4.688 4.395 C-4.131 3.872 -3.574 3.35 -3 2.812 C-2.443 2.283 -1.886 1.753 -1.312 1.207 C-0.663 0.61 -0.663 0.61 0 0 Z " fill="#C5C33A" transform="translate(233,109)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C2.67 2.32 2.34 3.64 2 5 C0.35 5.33 -1.3 5.66 -3 6 C-1.125 1.125 -1.125 1.125 0 0 Z " fill="#674637" transform="translate(106,193)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C2.213 2.461 1.42 3.918 0.625 5.375 C0.184 6.187 -0.257 6.999 -0.711 7.836 C-2 10 -2 10 -4 12 C-4.371 6.435 -4.371 6.435 -2.562 4.312 C-1.789 3.663 -1.789 3.663 -1 3 C-0.67 2.01 -0.34 1.02 0 0 Z " fill="#BBB144" transform="translate(267,95)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C-0.97 3.64 -3.94 6.28 -7 9 C-7.66 8.67 -8.32 8.34 -9 8 C-6.03 5.36 -3.06 2.72 0 0 Z " fill="#B7AB44" transform="translate(238,116)"/>
<path d="M0 0 C1.65 0 3.3 0 5 0 C5 0.66 5 1.32 5 2 C3.02 2.33 1.04 2.66 -1 3 C-0.67 2.01 -0.34 1.02 0 0 Z " fill="#694331" transform="translate(172,113)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C0.719 2.707 -0.618 4.374 -2 6 C-2.66 6 -3.32 6 -4 6 C-2.848 3.532 -1.952 1.952 0 0 Z " fill="#5C463A" transform="translate(298,219)"/>
<path d="M0 0 C0 0.99 0 1.98 0 3 C-2.5 5.188 -2.5 5.188 -5 7 C-3.75 3.347 -3.329 2.219 0 0 Z " fill="#C1BA4D" transform="translate(234,105)"/>
<path d="M0 0 C-0.33 0.99 -0.66 1.98 -1 3 C-2.98 3.33 -4.96 3.66 -7 4 C-5.533 1.066 -3.26 0 0 0 Z " fill="#B8AD47" transform="translate(173,153)"/>
<path d="M0 0 C1.65 0.99 3.3 1.98 5 3 C4.01 3.33 3.02 3.66 2 4 C1.34 2.68 0.68 1.36 0 0 Z " fill="#B6AB43" transform="translate(119,142)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C2.01 2.485 2.01 2.485 1 4 C0.34 3.67 -0.32 3.34 -1 3 C-0.67 2.01 -0.34 1.02 0 0 Z " fill="#AFA33F" transform="translate(267,95)"/>
<path d="M0 0 C1.32 0.66 2.64 1.32 4 2 C2.68 2.33 1.36 2.66 0 3 C0 2.01 0 1.02 0 0 Z " fill="#634D40" transform="translate(79,223)"/>
<path d="" fill="#000000" transform="translate(0,0)"/>
</svg>

After

Width:  |  Height:  |  Size: 28 KiB

209
app/jeux/page.tsx Normal file
View File

@ -0,0 +1,209 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAuth } from "@/contexts/AuthContext";
import { useGame } from "@/hooks/useGame";
import { ticketCodeSchema, TicketCodeFormData } from "@/lib/validations";
import { Input } from "@/components/ui/Input";
import Button from "@/components/Button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
import { Modal } from "@/components/ui/Modal";
import { PRIZE_CONFIG } from "@/utils/constants";
import { PlayGameResponse } from "@/types";
import { useRouter } from "next/navigation";
import { ROUTES } from "@/utils/constants";
import Link from "next/link";
export default function JeuxPage() {
const { user, isAuthenticated } = useAuth();
const { play, isPlaying } = useGame();
const router = useRouter();
const [showResultModal, setShowResultModal] = useState(false);
const [gameResult, setGameResult] = useState<PlayGameResponse | null>(null);
const [errorMessage, setErrorMessage] = useState<string>("");
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<TicketCodeFormData>({
resolver: zodResolver(ticketCodeSchema),
});
const onSubmit = async (data: TicketCodeFormData) => {
// Réinitialiser le message d'erreur
setErrorMessage("");
// Si non connecté, rediriger vers login
if (!isAuthenticated) {
router.push(`${ROUTES.LOGIN}?redirect=/jeux`);
return;
}
const result = await play(data.ticketCode);
if (result) {
setGameResult(result);
setShowResultModal(true);
setErrorMessage("");
reset();
} else {
// En cas d'erreur, afficher un message personnalisé
setErrorMessage("Ce code a déjà été utilisé ou est invalide. Si vous avez déjà utilisé ce code, consultez vos tickets dans 'Mes lots'.");
}
};
const closeModal = () => {
setShowResultModal(false);
setGameResult(null);
};
const prizeConfig = gameResult?.prize
? PRIZE_CONFIG[gameResult.prize.type as keyof typeof PRIZE_CONFIG]
: null;
return (
<div className="py-8">
{/* Formulaire Section */}
<section className="mb-16">
<div className="max-w-2xl mx-auto">
<Card className="shadow-xl">
<CardHeader className="bg-gradient-to-r from-primary-50 to-green-50">
<CardTitle className="text-center text-2xl md:text-3xl text-primary-800">
🎁 Jouez maintenant !
</CardTitle>
</CardHeader>
<CardContent className="pt-8">
<div className="mb-6 text-center">
{isAuthenticated ? (
<p className="text-gray-700">
Bonjour <span className="font-bold text-primary-600">{user?.firstName}</span>,
entrez le code de 10 caractères présent sur votre ticket de caisse
</p>
) : (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
<p className="text-yellow-800 text-sm">
💡 Vous devez être connecté pour valider votre code.
<Link href={ROUTES.LOGIN} className="font-semibold underline ml-1">
Connectez-vous
</Link>
</p>
</div>
)}
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<label htmlFor="ticketCode" className="block text-sm font-medium text-gray-700 mb-2">
Code du ticket
</label>
<input
id="ticketCode"
type="text"
placeholder="TTP2025ABC"
{...register("ticketCode")}
className="w-full px-6 py-4 text-center text-2xl font-mono font-bold uppercase border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent tracking-widest"
maxLength={10}
/>
{errors.ticketCode && (
<p className="mt-2 text-sm text-red-600">{errors.ticketCode.message}</p>
)}
{errorMessage && (
<div className="mt-3 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-800 font-medium mb-2">
{errorMessage}
</p>
<Link
href={ROUTES.MY_LOTS}
className="text-sm text-red-600 hover:text-red-800 underline font-medium"
>
Voir vos tickets déjà utilisés
</Link>
</div>
)}
<p className="mt-2 text-sm text-gray-500 text-center">
Format: TTP2025ABC (10 caractères)
</p>
</div>
<div className="flex justify-center">
<Button
type="submit"
isLoading={isPlaying}
disabled={isPlaying}
size="lg"
className="px-12 py-4 text-lg"
>
{isPlaying ? "Vérification en cours..." : "🎲 Tenter ma chance !"}
</Button>
</div>
</form>
{!isAuthenticated && (
<div className="mt-6 text-center">
<p className="text-sm text-gray-600 mb-3">
Pas encore de compte ?
</p>
<Link href={ROUTES.REGISTER}>
<Button variant="outline" size="sm">
Créer un compte gratuitement
</Button>
</Link>
</div>
)}
{isAuthenticated && (
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800 mb-2">
💡 <strong>Bon à savoir :</strong>
</p>
<ul className="text-sm text-blue-700 space-y-1 list-disc list-inside">
<li>Chaque code ne peut être utilisé qu'une seule fois</li>
<li>Consultez vos tickets sur la page <Link href={ROUTES.MY_LOTS} className="underline font-medium">Mes lots</Link></li>
</ul>
</div>
)}
</CardContent>
</Card>
</div>
</section>
{/* Result Modal */}
<Modal
isOpen={showResultModal}
onClose={closeModal}
title="Résultat"
size="md"
>
{gameResult && prizeConfig && (
<div className="text-center py-6">
<div className="text-6xl mb-4">{prizeConfig.icon}</div>
<h3 className="text-2xl font-bold text-gray-900 mb-2">
Félicitations ! 🎉
</h3>
<p className="text-lg text-gray-700 mb-4">
Vous avez gagné :
</p>
<div
className={`inline-block px-6 py-3 rounded-full text-lg font-semibold mb-6 ${prizeConfig.color}`}
>
{prizeConfig.name}
</div>
<p className="text-gray-600 mb-6">
{gameResult.message || "Présentez-vous en magasin pour récupérer votre lot !"}
</p>
<div className="flex gap-3 justify-center">
<Button onClick={closeModal} variant="outline">
Fermer
</Button>
<Button onClick={() => router.push(ROUTES.MY_LOTS)}>
Voir mes lots
</Button>
</div>
</div>
)}
</Modal>
</div>
);
}

27
app/layout-client.tsx Normal file
View File

@ -0,0 +1,27 @@
"use client";
import { usePathname } from "next/navigation";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
export default function LayoutClient({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
// Ne pas afficher Header/Footer dans l'espace admin et employé
const isAdminRoute = pathname?.startsWith("/admin");
const isEmployeRoute = pathname?.startsWith("/employe");
if (isAdminRoute || isEmployeRoute) {
return <>{children}</>;
}
return (
<>
<Header />
<main className="flex-1 container mx-auto px-4 py-6 sm:px-6 lg:px-8">
{children}
</main>
<Footer />
</>
);
}

View File

@ -1,16 +1,74 @@
export const metadata = {
title: 'Next.js',
description: 'Generated by Next.js',
}
import type { Metadata } from "next";
import "./globals.css";
import { AuthProvider } from "@/contexts/AuthContext";
import { Toaster } from "react-hot-toast";
import LayoutClient from "./layout-client";
import { GoogleOAuthProvider } from "@react-oauth/google";
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
export const metadata: Metadata = {
title: "Thé Tip Top - Jeu Concours",
description: "Participez au grand jeu-concours Thé Tip Top et gagnez des lots exceptionnels ! 100% de tickets gagnants.",
keywords: "thé, concours, jeu, tip top, nice, gain, lot, infuseur, coffret",
authors: [{ name: "Thé Tip Top" }],
icons: {
icon: [
{ url: '/favicon.svg', type: 'image/svg+xml' },
{ url: '/icon.svg', type: 'image/svg+xml' },
{ url: '/logos/logo.svg', type: 'image/svg+xml' },
],
shortcut: '/favicon.svg',
apple: '/logos/logo.svg',
},
openGraph: {
title: "Thé Tip Top - Jeu Concours",
description: "Participez au grand jeu-concours et gagnez des lots exceptionnels !",
type: "website",
locale: "fr_FR",
images: [
{
url: '/logos/logo.svg',
width: 1200,
height: 630,
alt: 'Thé Tip Top Logo',
},
],
},
};
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
<html lang="fr">
<body className="min-h-screen flex flex-col bg-gray-50">
<GoogleOAuthProvider clientId={googleClientId}>
<AuthProvider>
<LayoutClient>{children}</LayoutClient>
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: '#fff',
color: '#333',
},
success: {
iconTheme: {
primary: '#10B981',
secondary: '#fff',
},
},
error: {
iconTheme: {
primary: '#EF4444',
secondary: '#fff',
},
},
}}
/>
</AuthProvider>
</GoogleOAuthProvider>
</body>
</html>
)
);
}

195
app/legal/page.tsx Normal file
View File

@ -0,0 +1,195 @@
import type { Metadata } from "next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
export const metadata: Metadata = {
title: "Mentions légales - Thé Tip Top",
description: "Mentions légales et informations juridiques de Thé Tip Top",
};
export default function LegalPage() {
return (
<div className="py-12">
<div className="max-w-4xl mx-auto">
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-8 text-center">
Mentions légales
</h1>
{/* Éditeur du site */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-2xl">Éditeur du site</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="font-semibold text-gray-900">Dénomination sociale :</p>
<p className="text-gray-700">Thé Tip Top SA</p>
</div>
<div>
<p className="font-semibold text-gray-900">Forme juridique :</p>
<p className="text-gray-700">Société Anonyme (SA)</p>
</div>
<div>
<p className="font-semibold text-gray-900">Capital social :</p>
<p className="text-gray-700">150 000 </p>
</div>
<div>
<p className="font-semibold text-gray-900">Siège social :</p>
<p className="text-gray-700">18 rue Léon Frot, 75011 Paris</p>
</div>
<div>
<p className="font-semibold text-gray-900">RCS :</p>
<p className="text-gray-700">Paris B 812 456 789</p>
</div>
<div>
<p className="font-semibold text-gray-900">SIREN :</p>
<p className="text-gray-700">812 456 789</p>
</div>
<div>
<p className="font-semibold text-gray-900">SIRET :</p>
<p className="text-gray-700">81245678900032</p>
</div>
<div>
<p className="font-semibold text-gray-900">Code APE / NAF :</p>
<p className="text-gray-700">1083Z - Transformation du thé et du café</p>
</div>
<div>
<p className="font-semibold text-gray-900">Numéro de TVA intracommunautaire :</p>
<p className="text-gray-700">FR 12 987654321</p>
</div>
</CardContent>
</Card>
{/* Responsable de publication */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-2xl">Responsable de publication</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-700">Monsieur Eric Bourdon, Gérant</p>
</CardContent>
</Card>
{/* Conception et accompagnement digital */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-2xl">Conception et accompagnement digital</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="font-semibold text-gray-900">Dénomination sociale :</p>
<p className="text-gray-700">Furious Ducks</p>
</div>
<div>
<p className="font-semibold text-gray-900">Forme juridique :</p>
<p className="text-gray-700">Société à Responsabilité Limitée (SARL)</p>
</div>
<div>
<p className="font-semibold text-gray-900">Capital social :</p>
<p className="text-gray-700">45 000 </p>
</div>
<div>
<p className="font-semibold text-gray-900">Siège social :</p>
<p className="text-gray-700">14 avenue de la Création Numérique, 75012 Paris</p>
</div>
<div>
<p className="font-semibold text-gray-900">RCS :</p>
<p className="text-gray-700">Paris B 498 321 765</p>
</div>
<div>
<p className="font-semibold text-gray-900">SIREN :</p>
<p className="text-gray-700">498 321 765</p>
</div>
<div>
<p className="font-semibold text-gray-900">SIRET :</p>
<p className="text-gray-700">49832176500029</p>
</div>
<div>
<p className="font-semibold text-gray-900">Code APE / NAF :</p>
<p className="text-gray-700">6201Z - Programmation informatique</p>
</div>
<div>
<p className="font-semibold text-gray-900">Numéro de TVA intracommunautaire :</p>
<p className="text-gray-700">FR 56 498321765</p>
</div>
<div>
<p className="font-semibold text-gray-900">Dirigeant :</p>
<p className="text-gray-700">Monsieur Guido Brasletti</p>
</div>
</CardContent>
</Card>
{/* Hébergement */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-2xl">Hébergement</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-700 mb-3">
Le site est hébergé par OVH SAS (ou prestataire équivalent à confirmer par le client),
</p>
<p className="text-gray-700">2 rue Kellermann, 59100 Roubaix, France</p>
<p className="text-gray-700">Téléphone : +33 (0)9 72 10 10 07</p>
</CardContent>
</Card>
{/* Propriété intellectuelle */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-2xl">Propriété intellectuelle</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-700">
L'ensemble des éléments figurant sur le site (textes, images, vidéos, logos, icônes,
graphismes) est la propriété exclusive de Thé Tip Top SA, sauf mention contraire.
</p>
<p className="text-gray-700">
Toute reproduction ou diffusion, totale ou partielle, non autorisée est interdite.
</p>
<p className="text-gray-700">
Les contenus tiers (ex. images libres de droit, modules externes) sont utilisés dans
le respect de leurs licences.
</p>
</CardContent>
</Card>
{/* Protection des données personnelles */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-2xl">Protection des données personnelles</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-700">
Les données collectées via le site sont traitées par Thé Tip Top SA conformément au
Règlement Général sur la Protection des Données (RGPD).
</p>
<p className="text-gray-700">
Chaque utilisateur dispose d'un droit d'accès, de rectification et de suppression de
ses données en adressant une demande à :{" "}
<a href="mailto:contact@thetiptop.fr" className="text-primary-600 hover:text-primary-700 underline">
contact@thetiptop.fr
</a>
</p>
</CardContent>
</Card>
{/* Contact */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-2xl">Contact</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-700 mb-3">
Pour toute question concernant ces mentions légales ou le traitement de vos données personnelles :
</p>
<p className="text-gray-700">
Email :{" "}
<a href="mailto:contact@thetiptop.fr" className="text-primary-600 hover:text-primary-700 underline">
contact@thetiptop.fr
</a>
</p>
</CardContent>
</Card>
</div>
</div>
);
}

215
app/login/page.tsx Normal file
View File

@ -0,0 +1,215 @@
"use client";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAuth } from "@/contexts/AuthContext";
import { loginSchema, LoginFormData } from "@/lib/validations";
import { Input } from "@/components/ui/Input";
import Button from "@/components/Button";
import { Card } from "@/components/ui/Card";
import Link from "next/link";
import { ROUTES } from "@/utils/constants";
import { useGoogleLogin } from "@react-oauth/google";
import { initFacebookSDK, loginWithFacebook } from "@/lib/facebook-sdk";
import toast from "react-hot-toast";
export default function LoginPage() {
const { login, googleLogin, facebookLogin } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const [isFacebookLoading, setIsFacebookLoading] = useState(false);
const [isFacebookSDKLoaded, setIsFacebookSDKLoaded] = useState(false);
useEffect(() => {
// Initialiser le SDK Facebook au chargement de la page
initFacebookSDK()
.then(() => {
setIsFacebookSDKLoaded(true);
console.log('Facebook SDK loaded successfully');
})
.catch((error) => {
console.error('Failed to load Facebook SDK:', error);
});
}, []);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormData) => {
setIsSubmitting(true);
try {
await login(data);
} catch (error) {
console.error("Login error:", error);
} finally {
setIsSubmitting(false);
}
};
const handleGoogleLoginSuccess = useGoogleLogin({
onSuccess: async (tokenResponse) => {
setIsGoogleLoading(true);
console.log('🔑 Token Google reçu:', tokenResponse);
try {
await googleLogin(tokenResponse.access_token);
} catch (error) {
console.error("Google login error:", error);
} finally {
setIsGoogleLoading(false);
}
},
onError: (error) => {
console.error("Google login failed:", error);
setIsGoogleLoading(false);
},
flow: 'implicit',
scope: 'openid email profile',
});
const handleFacebookLogin = async () => {
if (!isFacebookSDKLoaded) {
toast.error("Le SDK Facebook n'est pas encore chargé. Veuillez réessayer dans quelques secondes.");
return;
}
setIsFacebookLoading(true);
try {
const accessToken = await loginWithFacebook();
await facebookLogin(accessToken);
} catch (error: any) {
console.error("Facebook login error:", error);
if (error.message !== 'Facebook login cancelled') {
toast.error("Erreur lors de la connexion avec Facebook");
}
} finally {
setIsFacebookLoading(false);
}
};
return (
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12">
<Card className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Connexion</h1>
<p className="text-gray-600">
Connectez-vous pour participer au jeu-concours
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Input
id="email"
type="email"
label="Email"
placeholder="votre.email@example.com"
error={errors.email?.message}
{...register("email")}
required
/>
<Input
id="password"
type="password"
label="Mot de passe"
placeholder="••••••••"
error={errors.password?.message}
{...register("password")}
required
/>
<div className="flex items-center justify-between text-sm">
<Link
href="/forgot-password"
className="text-blue-600 hover:text-blue-700 hover:underline"
>
Mot de passe oublié ?
</Link>
</div>
<Button
type="submit"
isLoading={isSubmitting}
disabled={isSubmitting}
fullWidth
size="lg"
>
Se connecter
</Button>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">
Ou continuer avec
</span>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-3">
<Button
type="button"
variant="outline"
onClick={() => handleGoogleLoginSuccess()}
disabled={isGoogleLoading}
isLoading={isGoogleLoading}
fullWidth
>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Google
</Button>
<Button
type="button"
variant="outline"
onClick={handleFacebookLogin}
disabled={isFacebookLoading || !isFacebookSDKLoaded}
isLoading={isFacebookLoading}
fullWidth
>
<svg className="w-5 h-5 mr-2" fill="#1877F2" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg>
Facebook
</Button>
</div>
</div>
<p className="mt-8 text-center text-sm text-gray-600">
Vous n'avez pas de compte ?{" "}
<Link
href={ROUTES.REGISTER}
className="font-medium text-blue-600 hover:text-blue-700 hover:underline"
>
Créer un compte
</Link>
</p>
</Card>
</div>
);
}

258
app/lots/page.tsx Normal file
View File

@ -0,0 +1,258 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import Button from "@/components/Button";
import { ROUTES } from "@/utils/constants";
export const metadata: Metadata = {
title: "Nos Gains - Thé Tip Top",
description: "Découvrez nos gains d'exception d'une valeur pouvant atteindre 100€",
};
export default function LotsPage() {
return (
<div className="py-12">
{/* Hero Section */}
<section className="text-center mb-16">
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
Nos gains d'exception
</h1>
<p className="text-lg md:text-xl text-gray-600 max-w-3xl mx-auto">
Des récompenses d'une valeur pouvant atteindre 100 vous attendent !
Explorez notre sélection de thés premium et d'accessoires exclusifs.
</p>
</section>
{/* Prizes Grid */}
<section className="mb-16">
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-6xl mx-auto">
{/* Infuseur */}
<Card className="hover:shadow-xl transition-shadow">
<CardHeader>
<div className="text-5xl text-center mb-3">🍵</div>
<CardTitle className="text-center text-xl">Infuseur de thé</CardTitle>
</CardHeader>
<CardContent className="text-center">
<p className="text-gray-600 leading-relaxed">
Un infuseur en acier inoxydable de qualité supérieure pour révéler
tous les arômes de vos thés.
</p>
</CardContent>
</Card>
{/* Thé détox */}
<Card className="hover:shadow-xl transition-shadow">
<CardHeader>
<div className="text-5xl text-center mb-3">🌿</div>
<CardTitle className="text-center text-xl">Boite de 100g d'un thé détox</CardTitle>
</CardHeader>
<CardContent className="text-center">
<p className="text-gray-600 leading-relaxed">
Un mélange exclusif de plantes biologiques pour purifier votre
organisme en douceur.
</p>
</CardContent>
</Card>
{/* Coffret 39€ */}
<Card className="hover:shadow-xl transition-shadow">
<CardHeader>
<div className="text-5xl text-center mb-3">🎁</div>
<CardTitle className="text-center text-xl">Coffret Découverte de 39</CardTitle>
</CardHeader>
<CardContent className="text-center">
<p className="text-gray-600 leading-relaxed">
Une sélection de 6 thés d'exception du monde entier dans un élégant
coffret cadeau.
</p>
</CardContent>
</Card>
{/* Thé signature */}
<Card className="hover:shadow-xl transition-shadow">
<CardHeader>
<div className="text-5xl text-center mb-3"></div>
<CardTitle className="text-center text-xl">Thé signature</CardTitle>
</CardHeader>
<CardContent className="text-center">
<p className="text-gray-600 leading-relaxed">
Un mélange de thé exclusif, soigneusement élaboré par nos maîtres artisans,
offrant des arômes uniques et une expérience authentique à chaque tasse.
</p>
</CardContent>
</Card>
{/* Coffret 69€ */}
<Card className="hover:shadow-xl transition-shadow">
<CardHeader>
<div className="text-5xl text-center mb-3">🏆</div>
<CardTitle className="text-center text-xl">Coffret Découverte de 69</CardTitle>
</CardHeader>
<CardContent className="text-center">
<p className="text-gray-600 leading-relaxed">
Un coffret premium contenant une sélection de nos meilleurs thés
d'exception avec accessoires assortis.
</p>
</CardContent>
</Card>
{/* CTA Card */}
<Card className="bg-gradient-to-br from-primary-600 to-primary-700 text-white hover:shadow-xl transition-shadow flex items-center justify-center">
<CardContent className="text-center py-8">
<div className="text-5xl mb-4">🎮</div>
<h3 className="text-2xl font-bold mb-4">Participer maintenant</h3>
<Link href={ROUTES.GAME}>
<Button
variant="outline"
className="bg-white text-primary-600 hover:bg-primary-50 border-white"
>
Jouer
</Button>
</Link>
</CardContent>
</Card>
</div>
</section>
{/* Comment participer Section */}
<section className="mb-16 bg-gradient-to-r from-primary-50 to-green-50 py-12 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
<div className="max-w-5xl mx-auto">
<h2 className="text-3xl font-bold text-center text-gray-900 mb-4">
Comment participer ?
</h2>
<p className="text-center text-gray-600 mb-12 max-w-2xl mx-auto">
Trois étapes simples pour tenter votre chance et repartir avec nos gains d'exception.
</p>
<div className="grid md:grid-cols-3 gap-8">
{/* Étape 1 */}
<Card className="bg-white text-center hover:shadow-lg transition-shadow">
<CardContent className="pt-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary-600 text-white text-3xl font-bold mb-4">
1
</div>
<h3 className="text-xl font-bold mb-3 text-gray-900">
Créez votre compte
</h3>
<p className="text-gray-600">
Ouvrez gratuitement votre espace et rejoignez notre communauté de passionnés.
</p>
</CardContent>
</Card>
{/* Étape 2 */}
<Card className="bg-white text-center hover:shadow-lg transition-shadow">
<CardContent className="pt-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary-600 text-white text-3xl font-bold mb-4">
2
</div>
<h3 className="text-xl font-bold mb-3 text-gray-900">
Participez
</h3>
<p className="text-gray-600">
Prenez part au jeu-concours et validez votre participation chaque jour.
</p>
</CardContent>
</Card>
{/* Étape 3 */}
<Card className="bg-white text-center hover:shadow-lg transition-shadow">
<CardContent className="pt-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary-600 text-white text-3xl font-bold mb-4">
3
</div>
<h3 className="text-xl font-bold mb-3 text-gray-900">
Remportez des gains
</h3>
<p className="text-gray-600">
Gagnez des récompenses d'exception et découvrez l'univers premium de Thé Tip Top.
</p>
</CardContent>
</Card>
</div>
</div>
</section>
{/* Stats & Features Section */}
<section className="mb-16">
<div className="max-w-5xl mx-auto">
{/* Main Stat */}
<div className="text-center mb-12">
<div className="text-6xl mb-4">🎉</div>
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-3">
Déjà plus de 1000 gains remis !
</h2>
<p className="text-lg text-gray-600">
Rejoignez les milliers de participants qui ont déjà découvert nos thés d'exception.
</p>
</div>
{/* Features Grid */}
<div className="grid md:grid-cols-3 gap-8">
{/* Feature 1 */}
<Card className="text-center hover:shadow-lg transition-shadow">
<CardContent className="pt-8">
<div className="text-5xl mb-4">🏆</div>
<h3 className="text-xl font-bold mb-2 text-gray-900">
Gagnant à tous les coups
</h3>
<p className="text-gray-600 text-sm">
Chaque participant repart avec une récompense.
</p>
</CardContent>
</Card>
{/* Feature 2 */}
<Card className="text-center hover:shadow-lg transition-shadow">
<CardContent className="pt-8">
<div className="text-5xl mb-4">🌱</div>
<h3 className="text-xl font-bold mb-2 text-gray-900">
Thés bio certifiés
</h3>
<p className="text-gray-600 text-sm">
Une qualité contrôlée et garantie.
</p>
</CardContent>
</Card>
{/* Feature 3 */}
<Card className="text-center hover:shadow-lg transition-shadow">
<CardContent className="pt-8">
<div className="text-5xl mb-4"></div>
<h3 className="text-xl font-bold mb-2 text-gray-900">
Satisfait ou remboursé
</h3>
<p className="text-gray-600 text-sm">
Une expérience 100% sereine.
</p>
</CardContent>
</Card>
</div>
</div>
</section>
{/* Final CTA */}
<section className="text-center">
<Card className="max-w-2xl mx-auto bg-gradient-to-r from-primary-600 to-primary-700 text-white">
<CardContent className="py-12">
<h2 className="text-3xl font-bold mb-4">
Prêt à tenter votre chance ?
</h2>
<p className="text-lg mb-8 text-primary-50">
Inscrivez-vous gratuitement et participez au jeu-concours dès maintenant
</p>
<Link href={ROUTES.REGISTER}>
<Button
size="lg"
variant="outline"
className="bg-white text-primary-600 hover:bg-primary-50 border-white px-10"
>
Créer mon compte
</Button>
</Link>
</CardContent>
</Card>
</section>
</div>
);
}

104
app/mes-lots/debug/page.tsx Normal file
View File

@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
import Button from "@/components/Button";
import { API_ENDPOINTS, API_BASE_URL } from "@/utils/constants";
export default function DebugTicketsPage() {
const { user, isAuthenticated } = useAuth();
const [response, setResponse] = useState<any>(null);
const [loading, setLoading] = useState(false);
const testAPI = async () => {
setLoading(true);
try {
const token = localStorage.getItem('auth_token');
console.log('🔍 Test de l\'API /game/my-tickets');
console.log('📍 URL:', `${API_BASE_URL}${API_ENDPOINTS.GAME.MY_TICKETS}`);
console.log('🔑 Token:', token ? 'Présent' : 'Absent');
console.log('👤 User:', user);
const res = await fetch(`${API_BASE_URL}${API_ENDPOINTS.GAME.MY_TICKETS}?page=1&limit=10`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` }),
},
});
console.log('📡 Status:', res.status);
const data = await res.json();
console.log('📦 Données reçues:', data);
setResponse({
status: res.status,
statusText: res.statusText,
data: data,
token: token ? 'Présent ✅' : 'Absent ❌',
user: user,
url: `${API_BASE_URL}${API_ENDPOINTS.GAME.MY_TICKETS}`,
});
} catch (error: any) {
console.error('❌ Erreur:', error);
setResponse({
error: error.message,
token: localStorage.getItem('auth_token') ? 'Présent ✅' : 'Absent ❌',
user: user,
});
} finally {
setLoading(false);
}
};
return (
<div className="py-8">
<div className="max-w-4xl mx-auto">
<Card>
<CardHeader>
<CardTitle>🔧 Debug - Chargement des tickets</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="p-4 bg-gray-50 rounded">
<h3 className="font-semibold mb-2">État actuel :</h3>
<ul className="text-sm space-y-1">
<li> Authentifié : {isAuthenticated ? 'Oui' : 'Non'}</li>
<li>👤 Utilisateur : {user?.firstName} {user?.lastName}</li>
<li>📧 Email : {user?.email}</li>
<li>🎭 Rôle : {user?.role}</li>
<li>🔑 Token : {localStorage.getItem('auth_token') ? 'Présent' : 'Absent'}</li>
</ul>
</div>
<Button onClick={testAPI} disabled={loading} className="w-full">
{loading ? 'Test en cours...' : '🧪 Tester l\'API /game/my-tickets'}
</Button>
{response && (
<div className="p-4 bg-blue-50 rounded border border-blue-200">
<h3 className="font-semibold mb-2">Résultat du test :</h3>
<pre className="text-xs overflow-auto max-h-96 bg-white p-3 rounded">
{JSON.stringify(response, null, 2)}
</pre>
</div>
)}
<div className="p-4 bg-yellow-50 rounded border border-yellow-200">
<h3 className="font-semibold mb-2">💡 Instructions :</h3>
<ol className="text-sm space-y-2 list-decimal list-inside">
<li>Cliquez sur le bouton "Tester l'API"</li>
<li>Vérifiez le statut de la réponse (devrait être 200)</li>
<li>Vérifiez que des tickets sont retournés</li>
<li>Ouvrez la console (F12) pour voir les logs détaillés</li>
</ol>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

279
app/mes-lots/page.tsx Normal file
View File

@ -0,0 +1,279 @@
"use client";
import { useState, useEffect } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useGame } from "@/hooks/useGame";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/Table";
import { Loading } from "@/components/ui/Loading";
import { PRIZE_CONFIG, TICKET_STATUS_LABELS, TICKET_STATUS_COLORS } from "@/utils/constants";
import { formatDateTime } from "@/utils/helpers";
import { Ticket } from "@/types";
import { useRouter } from "next/navigation";
import { ROUTES } from "@/utils/constants";
import Button from "@/components/Button";
export default function HistoriquePage() {
const { user, isAuthenticated } = useAuth();
const { getMyTickets, isLoadingTickets } = useGame();
const router = useRouter();
const [tickets, setTickets] = useState<Ticket[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isAuthenticated && user?.role?.toLowerCase() === "client") {
loadTickets();
}
}, [isAuthenticated, user, currentPage]);
const loadTickets = async () => {
try {
setError(null);
console.log('🎫 Chargement des tickets, page:', currentPage);
const result = await getMyTickets(currentPage, 10);
console.log('📦 Résultat API:', result);
if (result) {
console.log('✅ Tickets reçus:', result.data);
setTickets(result.data || []);
setTotalPages(result.totalPages || 1);
} else {
console.log('❌ Aucun résultat reçu');
setTickets([]);
setError('Impossible de charger vos tickets. Veuillez réessayer.');
}
} catch (err) {
console.error('❌ Erreur lors du chargement:', err);
setError('Erreur lors du chargement de vos tickets.');
setTickets([]);
}
};
if (!isAuthenticated || user?.role?.toLowerCase() !== "client") {
return (
<div className="min-h-[calc(100vh-8rem)] flex items-center justify-center">
<Card className="max-w-md text-center">
<CardContent className="py-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Accès non autorisé
</h2>
<p className="text-gray-600 mb-6">
Cette page est réservée aux clients
</p>
<p className="text-sm text-gray-500 mb-4">
Votre rôle actuel : {user?.role || 'Non défini'}
</p>
<Button onClick={() => router.push(ROUTES.HOME)}>
Retour à l'accueil
</Button>
</CardContent>
</Card>
</div>
);
}
const getStatusBadge = (status: string) => {
const statusLower = status.toLowerCase();
const variant =
statusLower === "claimed"
? "success"
: statusLower === "pending"
? "warning"
: "danger";
return (
<Badge variant={variant}>
{TICKET_STATUS_LABELS[statusLower as keyof typeof TICKET_STATUS_LABELS] || status}
</Badge>
);
};
return (
<div className="py-8">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold text-gray-900">
Historique de mes gains
</h1>
<Button onClick={() => router.push(ROUTES.GAME)}>
Jouer à nouveau
</Button>
</div>
{error && (
<Card className="mb-6">
<CardContent className="py-6">
<div className="flex items-center gap-3 text-red-600">
<span className="text-2xl"></span>
<div>
<p className="font-semibold">Erreur de chargement</p>
<p className="text-sm text-red-500">{error}</p>
<Button
variant="outline"
size="sm"
onClick={loadTickets}
className="mt-3"
>
Réessayer
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{isLoadingTickets ? (
<Loading text="Chargement de l'historique..." />
) : tickets.length === 0 && !error ? (
<Card>
<CardContent className="py-16 text-center">
<div className="text-6xl mb-4">🎲</div>
<h2 className="text-2xl font-semibold text-gray-900 mb-2">
Aucun ticket pour le moment
</h2>
<p className="text-gray-600 mb-6">
Commencez à jouer pour voir vos gains apparaître ici
</p>
<Button onClick={() => router.push(ROUTES.GAME)} size="lg" className="bg-white text-black hover:bg-gray-50 border-2 border-black">
Jouer maintenant
</Button>
</CardContent>
</Card>
) : tickets.length > 0 ? (
<>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">
Total de tickets
</p>
<p className="text-3xl font-bold text-gray-900">
{tickets.length}
</p>
</div>
<div className="text-4xl">🎫</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">
Gains réclamés
</p>
<p className="text-3xl font-bold text-green-600">
{tickets.filter((t) => t.status.toLowerCase() === "claimed").length}
</p>
</div>
<div className="text-4xl"></div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">
En attente
</p>
<p className="text-3xl font-bold text-yellow-600">
{tickets.filter((t) => t.status.toLowerCase() === "pending").length}
</p>
</div>
<div className="text-4xl"></div>
</div>
</CardContent>
</Card>
</div>
{/* Tickets Table */}
<Card>
<CardHeader>
<CardTitle>Mes tickets</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Lot gagné</TableHead>
<TableHead>Statut</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tickets.map((ticket) => (
<TableRow key={ticket.id}>
<TableCell className="font-mono font-semibold">
{ticket.code}
</TableCell>
<TableCell>
{ticket.prize && (
<div className="flex items-center gap-2">
<span className="text-2xl">
{PRIZE_CONFIG[ticket.prize.type as keyof typeof PRIZE_CONFIG]?.icon || '🎁'}
</span>
<span>
{ticket.prize.name || PRIZE_CONFIG[ticket.prize.type as keyof typeof PRIZE_CONFIG]?.name}
</span>
</div>
)}
</TableCell>
<TableCell>{getStatusBadge(ticket.status)}</TableCell>
<TableCell className="text-gray-600">
{ticket.playedAt ? formatDateTime(ticket.playedAt) : ticket.createdAt ? formatDateTime(ticket.createdAt) : '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6 pt-6 border-t">
<p className="text-sm text-gray-600">
Page {currentPage} sur {totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
Précédent
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
setCurrentPage((p) => Math.min(totalPages, p + 1))
}
disabled={currentPage === totalPages}
>
Suivant
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</>
) : null}
</div>
</div>
);
}

View File

@ -1,65 +1,198 @@
import Image from "next/image";
import type { Metadata } from "next";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
export default function Home() {
export const metadata: Metadata = {
title: "Thé Tip Top - Jeu Concours",
description: "Participez au jeu-concours Thé Tip Top et gagnez des lots exceptionnels !",
};
export default function HomePage() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
Branche Dev
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<div className="py-12">
{/* Hero Section */}
<section className="relative overflow-hidden bg-gradient-to-br from-primary-50 via-white to-green-50 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 py-20 mb-16">
<div className="max-w-4xl mx-auto">
<div className="text-center">
<div className="inline-block mb-4">
<span className="bg-primary-100 text-primary-700 px-4 py-2 rounded-full text-sm font-semibold">
Grand Jeu-Concours 2025
</span>
</div>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 mb-6">
Gagnez des lots{' '}
<span className="text-primary-600">exceptionnels</span>
</h1>
<p className="text-lg md:text-xl text-gray-600 mb-8 leading-relaxed">
Participez à notre jeu-concours et tentez de remporter des infuseurs à thé,
des coffrets découverte, des thés signature et bien plus encore !
</p>
<div className="flex gap-4 justify-center flex-wrap">
<Link href="/jeux">
<button className="px-8 py-4 bg-white text-black font-semibold rounded-lg hover:bg-gray-50 transition-all transform hover:scale-105 text-lg shadow-lg border-2 border-black">
🎮 Jouer maintenant
</button>
</Link>
</div>
<div className="mt-8 flex items-center justify-center gap-6 text-sm text-gray-600 flex-wrap">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-primary-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span>100% gagnant</span>
</div>
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-primary-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span>Inscription gratuite</span>
</div>
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-primary-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span>Résultat immédiat</span>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</section>
{/* Features Section */}
<section className="mb-16">
<h2 className="text-3xl font-bold text-center text-gray-900 mb-12">
Comment ça marche ?
</h2>
<div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
<Card hover className="text-center">
<CardContent className="pt-8">
<div className="text-5xl mb-4">🛒</div>
<h3 className="text-xl font-semibold mb-2">1. Achetez</h3>
<p className="text-gray-600">
Effectuez un achat chez Thé Tip Top et recevez votre ticket de
caisse avec un code unique
</p>
</CardContent>
</Card>
<Card hover className="text-center">
<CardContent className="pt-8">
<div className="text-5xl mb-4">🎮</div>
<h3 className="text-xl font-semibold mb-2">2. Jouez</h3>
<p className="text-gray-600">
Entrez le code de votre ticket sur notre site pour découvrir
instantanément votre gain
</p>
</CardContent>
</Card>
<Card hover className="text-center">
<CardContent className="pt-8">
<div className="text-5xl mb-4">🎁</div>
<h3 className="text-xl font-semibold mb-2">3. Gagnez</h3>
<p className="text-gray-600">
Récupérez votre lot en magasin ou profitez de votre réduction
immédiate
</p>
</CardContent>
</Card>
</div>
</main>
</section>
{/* Prizes Section */}
<section className="mb-16 bg-gradient-to-r from-primary-50 to-green-50 py-12 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
<div className="max-w-5xl mx-auto">
<h2 className="text-3xl font-bold text-center text-gray-900 mb-12">
Lots à gagner
</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<Card className="bg-white">
<CardHeader>
<div className="text-4xl text-center mb-2">🌿</div>
<CardTitle className="text-center">Infuseur à thé</CardTitle>
</CardHeader>
<CardContent className="text-center text-gray-600">
Un infuseur élégant pour préparer votre thé préféré
</CardContent>
</Card>
<Card className="bg-white">
<CardHeader>
<div className="text-4xl text-center mb-2">🍵</div>
<CardTitle className="text-center">Thé signature 100g</CardTitle>
</CardHeader>
<CardContent className="text-center text-gray-600">
Notre thé signature exclusif en sachet de 100g
</CardContent>
</Card>
<Card className="bg-white">
<CardHeader>
<div className="text-4xl text-center mb-2"></div>
<CardTitle className="text-center">Thé gratuit</CardTitle>
</CardHeader>
<CardContent className="text-center text-gray-600">
Une boisson offerte lors de votre prochaine visite
</CardContent>
</Card>
<Card className="bg-white">
<CardHeader>
<div className="text-4xl text-center mb-2">🎁</div>
<CardTitle className="text-center">
Coffret découverte
</CardTitle>
</CardHeader>
<CardContent className="text-center text-gray-600">
Un coffret découverte d'une valeur de 39
</CardContent>
</Card>
<Card className="bg-white">
<CardHeader>
<div className="text-4xl text-center mb-2">🏆</div>
<CardTitle className="text-center">
Coffret prestige
</CardTitle>
</CardHeader>
<CardContent className="text-center text-gray-600">
Notre coffret prestige d'une valeur de 69
</CardContent>
</Card>
<Card className="bg-white border-2 border-yellow-400">
<CardHeader>
<div className="text-4xl text-center mb-2"></div>
<CardTitle className="text-center text-yellow-600">
100% gagnant !
</CardTitle>
</CardHeader>
<CardContent className="text-center text-gray-600">
Chaque ticket est gagnant, tentez votre chance dès maintenant
</CardContent>
</Card>
</div>
</div>
</section>
{/* CTA Section */}
<section className="text-center">
<Card className="max-w-2xl mx-auto bg-gradient-to-r from-primary-600 to-primary-700 text-white">
<CardContent className="py-12">
<h2 className="text-3xl font-bold mb-4 text-white">
Prêt à tenter votre chance ?
</h2>
<p className="text-lg mb-8 text-white">
Inscrivez-vous gratuitement et participez au jeu-concours
</p>
<Link href="/register">
<button className="px-10 py-4 bg-white text-primary-600 font-bold rounded-lg hover:bg-primary-50 transition-colors text-lg shadow-lg border-2 border-white">
Créer mon compte
</button>
</Link>
</CardContent>
</Card>
</section>
</div>
);
}

102
app/privacy/page.tsx Normal file
View File

@ -0,0 +1,102 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Politique de confidentialité - Thé Tip Top",
description: "Politique de confidentialité et protection des données personnelles de Thé Tip Top",
};
export default function PrivacyPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-primary-50 to-white py-12 px-4">
<div className="max-w-4xl mx-auto bg-white rounded-lg shadow-lg p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-6">
Politique de confidentialité
</h1>
<p className="text-sm text-gray-600 mb-8">
Dernière mise à jour : 17 janvier 2025
</p>
<div className="space-y-6 text-gray-700">
<section>
<h2 className="text-2xl font-semibold text-gray-900 mb-3">
1. Introduction
</h2>
<p>
Bienvenue sur Thé Tip Top. Nous nous engageons à protéger et à respecter
votre vie privée. Cette politique explique comment nous collectons, utilisons
et protégeons vos données personnelles.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold text-gray-900 mb-3">
2. Données collectées
</h2>
<p className="mb-2">Nous collectons les informations suivantes :</p>
<ul className="list-disc pl-6 space-y-2">
<li>Informations d identification : nom, prénom, adresse e-mail</li>
<li>Informations de profil : photo de profil (si fournie via OAuth)</li>
<li>Informations de connexion : via Google ou Facebook OAuth</li>
<li>Données de participation : codes de tickets, gains remportés</li>
<li>Données de navigation : cookies, adresse IP</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-semibold text-gray-900 mb-3">
3. Utilisation des données
</h2>
<p className="mb-2">Vos données sont utilisées pour :</p>
<ul className="list-disc pl-6 space-y-2">
<li>Gérer votre compte et votre participation au jeu-concours</li>
<li>Vous permettre de récupérer vos gains</li>
<li>Améliorer nos services et votre expérience utilisateur</li>
<li>Vous envoyer des communications relatives au concours</li>
<li>Respecter nos obligations légales</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-semibold text-gray-900 mb-3">
4. Vos droits (RGPD)
</h2>
<p className="mb-2">Conformément au RGPD, vous disposez des droits suivants :</p>
<ul className="list-disc pl-6 space-y-2">
<li><strong>Droit d accès :</strong> obtenir une copie de vos données</li>
<li><strong>Droit de rectification :</strong> corriger vos données inexactes</li>
<li><strong>Droit à l effacement :</strong> demander la suppression de vos données</li>
<li><strong>Droit d opposition :</strong> vous opposer au traitement de vos données</li>
</ul>
<p className="mt-3">
Pour exercer ces droits, contactez-nous à : <a href="mailto:privacy@thetiptop.fr" className="text-primary-600 hover:underline">privacy@thetiptop.fr</a>
</p>
</section>
<section>
<h2 className="text-2xl font-semibold text-gray-900 mb-3">
5. Contact
</h2>
<p>
Pour toute question concernant cette politique de confidentialité,
vous pouvez nous contacter :
</p>
<ul className="list-none space-y-2 mt-3">
<li><strong>Email :</strong> <a href="mailto:privacy@thetiptop.fr" className="text-primary-600 hover:underline">privacy@thetiptop.fr</a></li>
<li><strong>Adresse :</strong> 18 Avenue Thiers, 06000 Nice, France</li>
</ul>
</section>
</div>
<div className="mt-8 pt-6 border-t border-gray-200">
<a
href="/"
className="text-primary-600 hover:text-primary-700 font-medium"
>
Retour à l accueil
</a>
</div>
</div>
</div>
);
}

285
app/profil/page.tsx Normal file
View File

@ -0,0 +1,285 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAuth } from "@/contexts/AuthContext";
import { profileUpdateSchema, ProfileUpdateFormData } from "@/lib/validations";
import { Input } from "@/components/ui/Input";
import Button from "@/components/Button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { userService } from "@/services/user.service";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import { ROUTES } from "@/utils/constants";
import { formatDate } from "@/utils/helpers";
export default function ProfilePage() {
const { user, isAuthenticated, refreshUser } = useAuth();
const router = useRouter();
const [isEditing, setIsEditing] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<ProfileUpdateFormData>({
resolver: zodResolver(profileUpdateSchema),
defaultValues: {
firstName: user?.firstName || "",
lastName: user?.lastName || "",
phone: user?.phone || "",
},
});
if (!isAuthenticated || !user) {
router.push(ROUTES.LOGIN);
return null;
}
const onSubmit = async (data: ProfileUpdateFormData) => {
setIsSubmitting(true);
try {
await userService.updateProfile(data);
await refreshUser();
toast.success("Profil mis à jour avec succès");
setIsEditing(false);
} catch (error: any) {
toast.error(error.message || "Erreur lors de la mise à jour du profil");
} finally {
setIsSubmitting(false);
}
};
const handleCancel = () => {
reset({
firstName: user?.firstName || "",
lastName: user?.lastName || "",
phone: user?.phone || "",
});
setIsEditing(false);
};
const getRoleBadgeVariant = (role: string) => {
switch (role) {
case "admin":
return "danger";
case "employee":
return "warning";
default:
return "info";
}
};
const getRoleLabel = (role: string) => {
switch (role) {
case "admin":
return "Administrateur";
case "employee":
return "Employé";
default:
return "Client";
}
};
return (
<div className="py-8 max-w-4xl mx-auto">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Mon profil</h1>
<div className="grid md:grid-cols-3 gap-6">
{/* Profile Info Card */}
<div className="md:col-span-2">
<Card>
<CardHeader>
<CardTitle>Informations personnelles</CardTitle>
</CardHeader>
<CardContent>
{!isEditing ? (
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-500">
Prénom
</label>
<p className="text-lg text-gray-900">{user.firstName}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">
Nom
</label>
<p className="text-lg text-gray-900">{user.lastName}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">
Email
</label>
<p className="text-lg text-gray-900">{user.email}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">
Téléphone
</label>
<p className="text-lg text-gray-900">
{user.phone || "Non renseigné"}
</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">
Rôle
</label>
<div className="mt-1">
<Badge variant={getRoleBadgeVariant(user.role)}>
{getRoleLabel(user.role)}
</Badge>
</div>
</div>
<div className="pt-4">
<Button onClick={() => setIsEditing(true)}>
Modifier mes informations
</Button>
</div>
</div>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Input
id="firstName"
type="text"
label="Prénom"
error={errors.firstName?.message}
{...register("firstName")}
required
/>
<Input
id="lastName"
type="text"
label="Nom"
error={errors.lastName?.message}
{...register("lastName")}
required
/>
<Input
id="email"
type="email"
label="Email"
value={user.email}
disabled
helperText="L'email ne peut pas être modifié"
/>
<Input
id="phone"
type="tel"
label="Téléphone"
error={errors.phone?.message}
{...register("phone")}
/>
<div className="flex gap-3">
<Button
type="submit"
isLoading={isSubmitting}
disabled={isSubmitting}
>
Enregistrer
</Button>
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isSubmitting}
>
Annuler
</Button>
</div>
</form>
)}
</CardContent>
</Card>
</div>
{/* Account Status Card */}
<div>
<Card>
<CardHeader>
<CardTitle>Statut du compte</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-500">
Email vérifié
</label>
<div className="mt-1">
{user.isEmailVerified ? (
<Badge variant="success">Vérifié </Badge>
) : (
<Badge variant="warning">Non vérifié</Badge>
)}
</div>
</div>
<div>
<label className="text-sm font-medium text-gray-500">
Membre depuis
</label>
<p className="text-sm text-gray-900 mt-1">
{formatDate(user.createdAt)}
</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">
Dernière modification
</label>
<p className="text-sm text-gray-900 mt-1">
{formatDate(user.updatedAt)}
</p>
</div>
</CardContent>
</Card>
{/* Quick Actions Card */}
<Card className="mt-6">
<CardHeader>
<CardTitle>Actions rapides</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{user.role === "client" && (
<>
<Button
variant="outline"
fullWidth
onClick={() => router.push(ROUTES.GAME)}
>
Jouer
</Button>
<Button
variant="outline"
fullWidth
onClick={() => router.push(ROUTES.HISTORY)}
>
Historique
</Button>
</>
)}
{user.role === "employee" && (
<Button
variant="outline"
fullWidth
onClick={() => router.push(ROUTES.EMPLOYEE_DASHBOARD)}
>
Tableau de bord
</Button>
)}
{user.role === "admin" && (
<Button
variant="outline"
fullWidth
onClick={() => router.push(ROUTES.ADMIN_DASHBOARD)}
>
Administration
</Button>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}

160
app/register/page.tsx Normal file
View File

@ -0,0 +1,160 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAuth } from "@/contexts/AuthContext";
import { registerSchema, RegisterFormData } from "@/lib/validations";
import { Input } from "@/components/ui/Input";
import Button from "@/components/Button";
import { Card } from "@/components/ui/Card";
import Link from "next/link";
import { ROUTES } from "@/utils/constants";
export default function RegisterPage() {
const { register: registerUser } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
});
const onSubmit = async (data: RegisterFormData) => {
setIsSubmitting(true);
try {
await registerUser(data);
} catch (error) {
console.error("Registration error:", error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12">
<Card className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Inscription</h1>
<p className="text-gray-600">
Créez un compte pour participer au jeu-concours
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Input
id="firstName"
type="text"
label="Prénom"
placeholder="Jean"
error={errors.firstName?.message}
{...register("firstName")}
required
/>
<Input
id="lastName"
type="text"
label="Nom"
placeholder="Dupont"
error={errors.lastName?.message}
{...register("lastName")}
required
/>
</div>
<Input
id="email"
type="email"
label="Email"
placeholder="votre.email@example.com"
error={errors.email?.message}
{...register("email")}
required
/>
<Input
id="phone"
type="tel"
label="Téléphone"
placeholder="0612345678"
error={errors.phone?.message}
helperText="Optionnel - Format: 06 12 34 56 78"
{...register("phone")}
/>
<Input
id="password"
type="password"
label="Mot de passe"
placeholder="••••••••"
error={errors.password?.message}
helperText="Min. 8 caractères, 1 majuscule, 1 minuscule, 1 chiffre"
{...register("password")}
required
/>
<Input
id="confirmPassword"
type="password"
label="Confirmer le mot de passe"
placeholder="••••••••"
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
required
/>
<div className="flex items-start">
<div className="flex items-center h-5">
<input
id="terms"
type="checkbox"
required
className="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300"
/>
</div>
<label htmlFor="terms" className="ml-2 text-sm text-gray-700">
J'accepte les{" "}
<Link
href="/terms"
className="text-blue-600 hover:text-blue-700 hover:underline"
>
conditions d'utilisation
</Link>{" "}
et la{" "}
<Link
href="/privacy"
className="text-blue-600 hover:text-blue-700 hover:underline"
>
politique de confidentialité
</Link>
</label>
</div>
<Button
type="submit"
isLoading={isSubmitting}
disabled={isSubmitting}
fullWidth
size="lg"
>
S'inscrire
</Button>
</form>
<p className="mt-8 text-center text-sm text-gray-600">
Vous avez déjà un compte ?{" "}
<Link
href={ROUTES.LOGIN}
className="font-medium text-blue-600 hover:text-blue-700 hover:underline"
>
Se connecter
</Link>
</p>
</Card>
</div>
);
}

289
app/rules/page.tsx Normal file
View File

@ -0,0 +1,289 @@
import type { Metadata } from "next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
export const metadata: Metadata = {
title: "Règlement du jeu - Thé Tip Top",
description: "Règlement officiel du jeu-concours Thé Tip Top",
};
export default function RulesPage() {
return (
<div className="py-12">
<div className="max-w-4xl mx-auto">
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-8 text-center">
Règlement du jeu-concours
</h1>
<div className="mb-6 text-center text-gray-600">
<p className="mb-2">Jeu-concours Thé Tip Top 2025</p>
<p>Du 01/01/2025 au 31/12/2025</p>
</div>
{/* Article 1 */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-xl">Article 1 - Société organisatrice</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-700">
Le jeu-concours « Thé Tip Top 2025 » est organisé par :
</p>
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-gray-700"><strong>Thé Tip Top SA</strong></p>
<p className="text-gray-700">Société Anonyme au capital de 150 000 </p>
<p className="text-gray-700">Siège social : 18 rue Léon Frot, 75011 Paris</p>
<p className="text-gray-700">RCS Paris B 812 456 789</p>
<p className="text-gray-700">Email : contact@thetiptop.fr</p>
</div>
</CardContent>
</Card>
{/* Article 2 */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-xl">Article 2 - Durée du jeu</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-700">
Le jeu-concours se déroule du <strong>1er janvier 2025 à 00h00</strong> au{" "}
<strong>31 décembre 2025 à 23h59</strong> (heure de Paris).
</p>
<p className="text-gray-700">
Seules les participations enregistrées pendant cette période seront prises en compte.
</p>
</CardContent>
</Card>
{/* Article 3 */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-xl">Article 3 - Conditions de participation</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-700">
Le jeu est ouvert à toute personne physique majeure résidant en France métropolitaine.
</p>
<p className="text-gray-700">
Sont exclus de la participation :
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
<li>Les membres du personnel de Thé Tip Top SA et de ses prestataires</li>
<li>Les membres de leur famille (conjoint, ascendants, descendants)</li>
<li>Toute personne ayant participé à l'élaboration du jeu</li>
</ul>
<p className="text-gray-700">
La participation au jeu implique l'acceptation pleine et entière du présent règlement.
</p>
</CardContent>
</Card>
{/* Article 4 */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-xl">Article 4 - Modalités de participation</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-700">
Pour participer au jeu, le participant doit :
</p>
<ol className="list-decimal list-inside space-y-2 text-gray-700 ml-4">
<li>Effectuer un achat dans l'une des boutiques Thé Tip Top participantes</li>
<li>Récupérer son ticket de caisse comportant un code unique à 10 caractères</li>
<li>Se rendre sur le site www.thetiptop.fr</li>
<li>Créer un compte ou se connecter à son compte existant</li>
<li>Saisir le code figurant sur son ticket dans l'espace dédié</li>
<li>Découvrir instantanément son gain</li>
</ol>
<p className="text-gray-700 mt-4">
<strong>Important :</strong> Chaque code ne peut être utilisé qu'une seule fois.
Toute tentative de fraude entraînera l'exclusion du participant.
</p>
</CardContent>
</Card>
{/* Article 5 */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-xl">Article 5 - Dotations</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-700">
Les dotations suivantes sont mises en jeu :
</p>
<div className="space-y-4 mt-4">
<div className="bg-primary-50 p-4 rounded-lg">
<p className="font-semibold text-gray-900">🍵 Infuseur à thé</p>
<p className="text-gray-700">Un infuseur en acier inoxydable de qualité supérieure</p>
<p className="text-sm text-gray-600">Probabilité : 1 ticket sur 4</p>
</div>
<div className="bg-primary-50 p-4 rounded-lg">
<p className="font-semibold text-gray-900">🌿 Boîte de 100g de thé détox ou signature</p>
<p className="text-gray-700">Un mélange exclusif de plantes biologiques</p>
<p className="text-sm text-gray-600">Probabilité : 1 ticket sur 5</p>
</div>
<div className="bg-primary-50 p-4 rounded-lg">
<p className="font-semibold text-gray-900"> Thé gratuit en magasin</p>
<p className="text-gray-700">Une boisson offerte lors de votre prochaine visite</p>
<p className="text-sm text-gray-600">Probabilité : 1 ticket sur 2</p>
</div>
<div className="bg-primary-50 p-4 rounded-lg">
<p className="font-semibold text-gray-900">🎁 Coffret Découverte 39</p>
<p className="text-gray-700">Une sélection de 6 thés d'exception</p>
<p className="text-sm text-gray-600">Probabilité : 1 ticket sur 10</p>
</div>
<div className="bg-primary-50 p-4 rounded-lg">
<p className="font-semibold text-gray-900">🏆 Coffret Prestige 69</p>
<p className="text-gray-700">Notre coffret premium avec accessoires</p>
<p className="text-sm text-gray-600">Probabilité : très rare</p>
</div>
</div>
<p className="text-gray-700 mt-4">
<strong>100% des tickets sont gagnants !</strong> Chaque participant repart avec au
minimum une récompense.
</p>
</CardContent>
</Card>
{/* Article 6 */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-xl">Article 6 - Désignation des gagnants</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-700">
Le gain est attribué de manière instantanée et aléatoire au moment de la saisie du code,
selon les probabilités définies à l'article 5.
</p>
<p className="text-gray-700">
Le participant est immédiatement informé de son gain sur le site.
</p>
</CardContent>
</Card>
{/* Article 7 */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-xl">Article 7 - Remise des lots</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-700">
Les lots doivent être réclamés dans un délai de <strong>60 jours</strong> à compter
de la date de participation.
</p>
<p className="text-gray-700">
La remise des lots s'effectue selon les modalités suivantes :
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
<li><strong>Thé gratuit :</strong> À retirer en boutique sur présentation du justificatif</li>
<li><strong>Autres lots :</strong> Retrait en boutique ou envoi par courrier (frais de port offerts)</li>
</ul>
<p className="text-gray-700">
Les lots ne peuvent être ni échangés, ni remboursés, ni convertis en espèces.
</p>
</CardContent>
</Card>
{/* Article 8 */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-xl">Article 8 - Données personnelles</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-700">
Les données personnelles collectées dans le cadre du jeu font l'objet d'un traitement
informatique destiné à gérer la participation au jeu et l'attribution des lots.
</p>
<p className="text-gray-700">
Pour plus d'informations, consultez notre{" "}
<a href="/privacy" className="text-primary-600 hover:text-primary-700 underline">
Politique de confidentialité
</a>.
</p>
</CardContent>
</Card>
{/* Article 9 */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-xl">Article 9 - Responsabilité</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-700">
La responsabilité de Thé Tip Top SA ne saurait être engagée en cas de :
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
<li>Force majeure ou événement indépendant de sa volonté</li>
<li>Défaillance technique du réseau Internet</li>
<li>Utilisation frauduleuse des codes</li>
<li>Mauvaise utilisation des lots par les gagnants</li>
</ul>
</CardContent>
</Card>
{/* Article 10 */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-xl">Article 10 - Modification et annulation</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-700">
Thé Tip Top SA se réserve le droit d'annuler, de reporter, de prolonger, d'écourter
ou de modifier tout ou partie du jeu en cas de force majeure.
</p>
<p className="text-gray-700">
Toute modification fera l'objet d'une information sur le site www.thetiptop.fr.
</p>
</CardContent>
</Card>
{/* Article 11 */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-xl">Article 11 - Dépôt du règlement</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-700">
Le présent règlement est déposé auprès d'un huissier de justice et peut être consulté
sur le site www.thetiptop.fr.
</p>
<p className="text-gray-700">
Un exemplaire peut être adressé gratuitement à toute personne qui en fait la demande
à l'adresse : contact@thetiptop.fr
</p>
</CardContent>
</Card>
{/* Article 12 */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-xl">Article 12 - Litiges</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-gray-700">
Le présent règlement est régi par le droit français.
</p>
<p className="text-gray-700">
Tout litige relatif à l'interprétation ou à l'exécution du présent règlement sera
de la compétence exclusive des tribunaux français.
</p>
</CardContent>
</Card>
{/* Contact */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-xl">Contact</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-700">
Pour toute question relative au jeu-concours :{" "}
<a href="mailto:contact@thetiptop.fr" className="text-primary-600 hover:text-primary-700 underline">
contact@thetiptop.fr
</a>
</p>
</CardContent>
</Card>
</div>
</div>
);
}

167
app/terms/page.tsx Normal file
View File

@ -0,0 +1,167 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Conditions d'utilisation - Thé Tip Top",
description: "Conditions générales d'utilisation du jeu-concours Thé Tip Top",
};
export default function TermsPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-primary-50 to-white py-12 px-4">
<div className="max-w-4xl mx-auto bg-white rounded-lg shadow-lg p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-6">
Conditions d utilisation
</h1>
<p className="text-sm text-gray-600 mb-8">
Dernière mise à jour : 17 janvier 2025
</p>
<div className="space-y-6 text-gray-700">
<section>
<h2 className="text-2xl font-semibold text-gray-900 mb-3">
1. Présentation
</h2>
<p>
Bienvenue sur le site du jeu-concours Thé Tip Top. En accédant à ce site
et en participant au jeu, vous acceptez d être lié par les présentes
conditions d utilisation.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold text-gray-900 mb-3">
2. Objet du jeu-concours
</h2>
<p>
Thé Tip Top organise un jeu-concours gratuit et sans obligation d achat
permettant aux participants de gagner des lots en entrant des codes de
participation fournis lors d achats en magasin.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold text-gray-900 mb-3">
3. Conditions de participation
</h2>
<ul className="list-disc pl-6 space-y-2">
<li>Être â de 18 ans ou plus</li>
<li>Résider en France métropolitaine</li>
<li>Créer un compte avec une adresse e-mail valide</li>
<li>Accepter les présentes conditions d utilisation</li>
<li>Accepter la politique de confidentialité</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-semibold text-gray-900 mb-3">
4. Modalités de participation
</h2>
<p className="mb-2">Pour participer :</p>
<ol className="list-decimal pl-6 space-y-2">
<li>Créez un compte sur le site</li>
<li>Entrez le code unique figurant sur votre ticket d achat</li>
<li>Découvrez instantanément votre gain</li>
<li>Suivez les instructions pour récupérer votre lot</li>
</ol>
</section>
<section>
<h2 className="text-2xl font-semibold text-gray-900 mb-3">
5. Lots à gagner
</h2>
<p className="mb-2">Les lots suivants sont disponibles :</p>
<ul className="list-disc pl-6 space-y-2">
<li><strong>Infuseur à thé</strong> (39) - 60% des tickets</li>
<li><strong>Thé détox ou infusion</strong> (39) - 20% des tickets</li>
<li><strong>Thé signature</strong> (39) - 10% des tickets</li>
<li><strong>Coffret découverte</strong> (39) - 6% des tickets</li>
<li><strong>Coffret prestige</strong> (69) - 4% des tickets</li>
<li><strong>Grand Prix : Un an de thé</strong> (360) - 1 gagnant par tirage au sort</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-semibold text-gray-900 mb-3">
6. Récupération des lots
</h2>
<p>
Les lots doivent être récupérés dans un délai de 60 jours à compter de
la date de gain. Passé ce délai, les lots non réclamés seront considérés
comme abandonnés et ne pourront plus être réclamés.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold text-gray-900 mb-3">
7. Limitation de responsabilité
</h2>
<p>
Thé Tip Top ne peut être tenu responsable en cas de dysfonctionnement
technique, de force majeure, ou de tout événement indépendant de sa volonté
empêchant le bon déroulement du jeu-concours.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold text-gray-900 mb-3">
8. Propriété intellectuelle
</h2>
<p>
Tous les éléments du site (textes, images, logos, marques) sont la propriété
exclusive de Thé Tip Top ou de ses partenaires. Toute reproduction,
représentation ou utilisation est strictement interdite sans autorisation
préalable.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold text-gray-900 mb-3">
9. Modification des conditions
</h2>
<p>
Thé Tip Top se réserve le droit de modifier les présentes conditions à
tout moment. Les modifications entreront en vigueur dès leur publication
sur le site.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold text-gray-900 mb-3">
10. Droit applicable et juridiction
</h2>
<p>
Les présentes conditions sont régies par le droit français. En cas de
litige, et à défaut d accord amiable, les tribunaux français seront
seuls compétents.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold text-gray-900 mb-3">
11. Contact
</h2>
<p>
Pour toute question concernant ces conditions d utilisation, vous pouvez
nous contacter :
</p>
<ul className="list-none space-y-2 mt-3">
<li><strong>Email :</strong> <a href="mailto:contact@thetiptop.fr" className="text-primary-600 hover:underline">contact@thetiptop.fr</a></li>
<li><strong>Adresse :</strong> 18 Avenue Thiers, 06000 Nice, France</li>
<li><strong>Téléphone :</strong> +33 4 93 00 00 00</li>
</ul>
</section>
</div>
<div className="mt-8 pt-6 border-t border-gray-200">
<a
href="/"
className="text-primary-600 hover:text-primary-700 font-medium"
>
Retour à l accueil
</a>
</div>
</div>
</div>
);
}

152
app/test-tickets/page.tsx Normal file
View File

@ -0,0 +1,152 @@
"use client";
import { useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
import Button from "@/components/Button";
import { useRouter } from "next/navigation";
import { ROUTES } from "@/utils/constants";
export default function TestTicketsPage() {
const { user, isAuthenticated } = useAuth();
const router = useRouter();
const [testCode, setTestCode] = useState("");
const generateTestCode = () => {
const code = `TTP${Date.now().toString().slice(-7)}`;
setTestCode(code);
return code;
};
const scenarios = [
{
title: "Scénario 1 : Nouveau ticket",
description: "Génère un nouveau code unique que vous pouvez utiliser",
action: "Générer un code",
buttonVariant: "default" as const,
onClick: () => {
const code = generateTestCode();
alert(`Code généré : ${code}\n\nCopiez ce code et utilisez-le sur la page /jeux`);
}
},
{
title: "Scénario 2 : Vérifier mes tickets",
description: "Voir tous vos tickets déjà utilisés",
action: "Aller sur Mes lots",
buttonVariant: "outline" as const,
onClick: () => router.push(ROUTES.MY_LOTS)
},
{
title: "Scénario 3 : Diagnostic",
description: "Tester l'API et voir les détails techniques",
action: "Ouvrir le diagnostic",
buttonVariant: "outline" as const,
onClick: () => router.push('/mes-lots/debug')
},
];
if (!isAuthenticated) {
return (
<div className="min-h-[calc(100vh-8rem)] flex items-center justify-center">
<Card className="max-w-md text-center">
<CardContent className="py-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Connexion requise
</h2>
<p className="text-gray-600 mb-6">
Vous devez être connecté pour accéder à cette page
</p>
<Button onClick={() => router.push(ROUTES.LOGIN)}>
Se connecter
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="py-8">
<div className="max-w-4xl mx-auto">
<Card className="mb-6">
<CardHeader>
<CardTitle>🧪 Page de test - Tickets</CardTitle>
</CardHeader>
<CardContent>
<div className="p-4 bg-blue-50 rounded border border-blue-200 mb-6">
<p className="text-sm text-blue-800">
<strong>👤 Connecté en tant que :</strong> {user?.firstName} {user?.lastName} ({user?.email})
</p>
<p className="text-sm text-blue-800">
<strong>🎭 Rôle :</strong> {user?.role}
</p>
</div>
{testCode && (
<div className="p-4 bg-green-50 rounded border border-green-200 mb-6">
<p className="text-sm text-green-800 mb-2">
<strong> Code généré :</strong>
</p>
<div className="flex items-center gap-3">
<code className="text-2xl font-mono font-bold bg-white px-4 py-2 rounded border-2 border-green-300">
{testCode}
</code>
<Button
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(testCode);
alert('Code copié !');
}}
>
📋 Copier
</Button>
<Button
size="sm"
onClick={() => router.push(ROUTES.GAME)}
>
Aller jouer
</Button>
</div>
</div>
)}
<div className="space-y-4">
{scenarios.map((scenario, index) => (
<Card key={index}>
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold text-lg mb-2">
{scenario.title}
</h3>
<p className="text-sm text-gray-600 mb-3">
{scenario.description}
</p>
</div>
<Button
variant={scenario.buttonVariant}
onClick={scenario.onClick}
>
{scenario.action}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
<div className="mt-6 p-4 bg-yellow-50 rounded border border-yellow-200">
<h3 className="font-semibold mb-2"> Important :</h3>
<ul className="text-sm space-y-1 list-disc list-inside text-yellow-800">
<li>Les codes générés ici sont des codes de TEST</li>
<li>Ils ne seront valides QUE si votre backend accepte de créer des tickets à la volée</li>
<li>Si le backend vérifie que le code existe en base, créez d'abord des tickets via SQL</li>
<li>Consultez le fichier <code className="bg-yellow-100 px-1">docs/create-test-tickets.sql</code></li>
</ul>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

90
components/Button.tsx Normal file
View File

@ -0,0 +1,90 @@
import { ButtonHTMLAttributes, forwardRef } from "react";
import { cn } from "@/utils/helpers";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "outline" | "danger" | "success";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
fullWidth?: boolean;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
className,
variant = "primary",
size = "md",
isLoading = false,
fullWidth = false,
disabled,
...props
},
ref
) => {
const baseStyles =
"inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed";
const variants = {
primary:
"bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500",
secondary:
"bg-secondary-500 text-white hover:bg-secondary-600 focus:ring-secondary-500",
outline:
"border-2 border-primary-600 text-primary-600 hover:bg-primary-50 focus:ring-primary-500",
danger:
"bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
success:
"bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500",
};
const sizes = {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-base",
lg: "px-6 py-3 text-lg",
};
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={cn(
baseStyles,
variants[variant],
sizes[size],
fullWidth && "w-full",
className
)}
{...props}
>
{isLoading && (
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{children}
</button>
);
}
);
Button.displayName = "Button";
export default Button;

197
components/Footer.tsx Normal file
View File

@ -0,0 +1,197 @@
'use client';
import Link from 'next/link';
import Logo from './Logo';
import { ROUTES } from '@/utils/constants';
export default function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="bg-primary-700 text-white">
{/* Main Footer */}
<div className="container mx-auto px-4 py-12">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{/* Company Info */}
<div>
<div className="mb-4">
<Logo variant="white" size="md" showText={true} />
</div>
<p className="text-sm text-white/80 mb-4">
Votre boutique de thé premium à Nice. Participez à notre grand
jeu-concours 2024 et gagnez des lots exceptionnels !
</p>
<div className="flex gap-4">
<a
href="https://facebook.com"
target="_blank"
rel="noopener noreferrer"
className="text-white/80 hover:text-white transition-colors"
aria-label="Facebook"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg>
</a>
<a
href="https://instagram.com"
target="_blank"
rel="noopener noreferrer"
className="text-white/80 hover:text-white transition-colors"
aria-label="Instagram"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z" />
</svg>
</a>
</div>
</div>
{/* Quick Links */}
<div>
<h3 className="text-lg font-semibold text-white mb-4">
Liens rapides
</h3>
<ul className="space-y-2">
<li>
<Link
href={ROUTES.HOME}
className="text-sm text-white/80 hover:text-white transition-colors"
>
Accueil
</Link>
</li>
<li>
<Link
href={ROUTES.GAME}
className="text-sm text-white/80 hover:text-white transition-colors"
>
Participer au jeu
</Link>
</li>
<li>
<Link
href="/about"
className="text-sm text-white/80 hover:text-white transition-colors"
>
À propos
</Link>
</li>
<li>
<Link
href="/contact"
className="text-sm text-white/80 hover:text-white transition-colors"
>
Contact
</Link>
</li>
<li>
<Link
href="/faq"
className="text-sm text-white/80 hover:text-white transition-colors"
>
FAQ
</Link>
</li>
</ul>
</div>
{/* Legal */}
<div>
<h3 className="text-lg font-semibold text-white mb-4">Légal</h3>
<ul className="space-y-2">
<li>
<Link
href="/terms"
className="text-sm text-white/80 hover:text-white transition-colors"
>
Conditions d'utilisation
</Link>
</li>
<li>
<Link
href="/privacy"
className="text-sm text-white/80 hover:text-white transition-colors"
>
Politique de confidentialité
</Link>
</li>
<li>
<Link
href="/rules"
className="text-sm text-white/80 hover:text-white transition-colors"
>
Règlement du jeu
</Link>
</li>
<li>
<Link
href="/cookies"
className="text-sm text-white/80 hover:text-white transition-colors"
>
Gestion des cookies
</Link>
</li>
<li>
<Link
href="/legal"
className="text-sm text-white/80 hover:text-white transition-colors"
>
Mentions légales
</Link>
</li>
</ul>
</div>
{/* Contact */}
<div>
<h3 className="text-lg font-semibold text-white mb-4">Contact</h3>
<ul className="space-y-3 text-sm text-white/80">
<li className="flex items-start gap-2">
<span className="mt-1">📍</span>
<span>
18 Avenue Thiers
<br />
06000 Nice, France
</span>
</li>
<li className="flex items-center gap-2">
<span>📞</span>
<a
href="tel:+33123456789"
className="hover:text-white transition-colors"
>
01 23 45 67 89
</a>
</li>
<li className="flex items-center gap-2">
<span></span>
<a
href="mailto:contact@thetiptop.fr"
className="hover:text-white transition-colors"
>
contact@thetiptop.fr
</a>
</li>
<li className="flex items-center gap-2">
<span>🕐</span>
<span>Lun - Sam: 9h - 19h</span>
</li>
</ul>
</div>
</div>
</div>
{/* Bottom Bar */}
<div className="border-t border-white/10 bg-primary-700">
<div className="container mx-auto px-4 py-6">
<div className="flex items-center justify-center text-sm">
<p className="text-white/80">
© {currentYear} Thé Tip Top. Tous droits réservés.
</p>
</div>
</div>
</div>
</footer>
);
}

332
components/Header.tsx Normal file
View File

@ -0,0 +1,332 @@
'use client';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import Button from './Button';
import Logo from './Logo';
import { ROUTES } from '@/utils/constants';
import { useState, useRef, useEffect } from 'react';
export default function Header() {
const { user, isAuthenticated, logout } = useAuth();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isParticiperDropdownOpen, setIsParticiperDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsParticiperDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const getDashboardRoute = () => {
if (!user) return ROUTES.HOME;
switch (user.role) {
case 'admin':
return ROUTES.ADMIN_DASHBOARD;
case 'employee':
return ROUTES.EMPLOYEE_DASHBOARD;
default:
return ROUTES.CLIENT_DASHBOARD;
}
};
return (
<header className="bg-white border-b border-gray-200 sticky top-0 z-50 shadow-sm">
{/* Main Header */}
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-18">
{/* Logo */}
<Link href={ROUTES.HOME} className="group">
<Logo size="md" showText={true} className="group-hover:scale-105 transition-transform" />
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-6">
<Link
href={ROUTES.HOME}
className="text-gray-700 hover:text-primary-600 font-medium transition-colors"
>
Accueil
</Link>
<Link
href={ROUTES.GAME}
className="text-gray-700 hover:text-primary-600 font-medium transition-colors"
>
Jeu
</Link>
<Link
href={ROUTES.LOTS}
className="text-gray-700 hover:text-primary-600 font-medium transition-colors"
>
Gains
</Link>
<Link
href="/about"
className="text-gray-700 hover:text-primary-600 font-medium transition-colors"
>
À propos
</Link>
<Link
href="/contact"
className="text-gray-700 hover:text-primary-600 font-medium transition-colors"
>
Contact
</Link>
{/* Participer with Dropdown - Green Button */}
{isAuthenticated ? (
<Link
href={ROUTES.GAME}
className="bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-2 rounded-lg transition-all hover:shadow-lg"
>
🎯 Participer
</Link>
) : (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsParticiperDropdownOpen(!isParticiperDropdownOpen)}
className="bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-2 rounded-lg transition-all hover:shadow-lg flex items-center gap-2"
>
🎯 Participer
<svg
className={`w-4 h-4 transition-transform ${isParticiperDropdownOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isParticiperDropdownOpen && (
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-xl border border-gray-200 py-2 z-50 animate-fadeIn">
<Link
href={ROUTES.LOGIN}
onClick={() => setIsParticiperDropdownOpen(false)}
className="block px-4 py-3 text-gray-700 hover:bg-green-50 hover:text-green-700 transition-colors"
>
<span className="font-medium block">Connexion</span>
<p className="text-xs text-gray-500 mt-0.5">J'ai déjà un compte</p>
</Link>
<div className="border-t border-gray-100 my-1"></div>
<Link
href={ROUTES.REGISTER}
onClick={() => setIsParticiperDropdownOpen(false)}
className="block px-4 py-3 text-gray-700 hover:bg-green-50 hover:text-green-700 transition-colors"
>
<span className="font-medium block">Inscription</span>
<p className="text-xs text-gray-500 mt-0.5">Créer un nouveau compte</p>
</Link>
</div>
)}
</div>
)}
</nav>
{/* Desktop Auth Buttons */}
<div className="hidden md:flex items-center gap-3">
{isAuthenticated && (
<>
<Link href={ROUTES.PROFILE}>
<Button variant="outline" size="sm">
👤 {user?.firstName}
</Button>
</Link>
{user?.role === 'CLIENT' && (
<Link href={ROUTES.HISTORY}>
<Button variant="outline" size="sm">
🏆 Mes gains
</Button>
</Link>
)}
<Button variant="outline" size="sm" onClick={logout}>
Déconnexion
</Button>
</>
)}
</div>
{/* Mobile Menu Button */}
<button
onClick={toggleMobileMenu}
className="md:hidden p-2 text-gray-600 hover:text-gray-900 focus:outline-none"
aria-label="Toggle menu"
>
{isMobileMenuOpen ? (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
) : (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
)}
</button>
</div>
{/* Mobile Menu */}
{isMobileMenuOpen && (
<div className="md:hidden py-4 border-t border-gray-200 animate-fadeIn">
<nav className="flex flex-col gap-3">
<Link
href={ROUTES.HOME}
className="text-gray-700 hover:text-primary-600 font-medium py-2 transition-colors"
onClick={() => setIsMobileMenuOpen(false)}
>
Accueil
</Link>
<Link
href={ROUTES.GAME}
className="text-gray-700 hover:text-primary-600 font-medium py-2 transition-colors"
onClick={() => setIsMobileMenuOpen(false)}
>
Jeu
</Link>
<Link
href={ROUTES.LOTS}
className="text-gray-700 hover:text-primary-600 font-medium py-2 transition-colors"
onClick={() => setIsMobileMenuOpen(false)}
>
Gains
</Link>
<Link
href="/about"
className="text-gray-700 hover:text-primary-600 font-medium py-2 transition-colors"
onClick={() => setIsMobileMenuOpen(false)}
>
À propos
</Link>
<Link
href="/contact"
className="text-gray-700 hover:text-primary-600 font-medium py-2 transition-colors"
onClick={() => setIsMobileMenuOpen(false)}
>
Contact
</Link>
{/* Participer Mobile - Green Button */}
{isAuthenticated ? (
<Link
href={ROUTES.GAME}
className="bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-3 rounded-lg transition-all hover:shadow-lg text-center block"
onClick={() => setIsMobileMenuOpen(false)}
>
🎯 Participer
</Link>
) : (
<div>
<button
onClick={() => setIsParticiperDropdownOpen(!isParticiperDropdownOpen)}
className="bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-3 rounded-lg transition-all hover:shadow-lg flex items-center justify-center gap-2 w-full"
>
🎯 Participer
<svg
className={`w-4 h-4 transition-transform ${isParticiperDropdownOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isParticiperDropdownOpen && (
<div className="mt-2 space-y-2 animate-fadeIn bg-green-50 rounded-lg p-3">
<Link
href={ROUTES.LOGIN}
onClick={() => {
setIsMobileMenuOpen(false);
setIsParticiperDropdownOpen(false);
}}
className="block text-green-800 hover:text-green-900 font-medium py-2 px-3 bg-white rounded-md hover:bg-green-100 transition-colors"
>
Connexion
</Link>
<Link
href={ROUTES.REGISTER}
onClick={() => {
setIsMobileMenuOpen(false);
setIsParticiperDropdownOpen(false);
}}
className="block text-green-800 hover:text-green-900 font-medium py-2 px-3 bg-white rounded-md hover:bg-green-100 transition-colors"
>
Inscription
</Link>
</div>
)}
</div>
)}
{isAuthenticated && (
<div className="border-t border-gray-200 pt-3 mt-3 space-y-2">
<Link
href={ROUTES.PROFILE}
onClick={() => setIsMobileMenuOpen(false)}
>
<Button variant="outline" size="sm" fullWidth>
👤 {user?.firstName}
</Button>
</Link>
{user?.role === 'CLIENT' && (
<Link
href={ROUTES.HISTORY}
onClick={() => setIsMobileMenuOpen(false)}
>
<Button variant="outline" size="sm" fullWidth>
🏆 Mes gains
</Button>
</Link>
)}
<Button
variant="outline"
size="sm"
fullWidth
onClick={() => {
logout();
setIsMobileMenuOpen(false);
}}
>
Déconnexion
</Button>
</div>
)}
</nav>
</div>
)}
</div>
</header>
);
}

81
components/Logo.tsx Normal file
View File

@ -0,0 +1,81 @@
'use client';
import Image from 'next/image';
import { useState } from 'react';
interface LogoProps {
variant?: 'default' | 'white';
size?: 'sm' | 'md' | 'lg';
showText?: boolean;
className?: string;
}
export default function Logo({
variant = 'default',
size = 'md',
showText = true,
className = ''
}: LogoProps) {
const [imageError, setImageError] = useState(false);
// Tailles selon le size prop
const sizes = {
sm: { width: 80, height: 40, textSize: 'text-sm', iconSize: 'text-2xl' },
md: { width: 120, height: 50, textSize: 'text-base', iconSize: 'text-3xl' },
lg: { width: 160, height: 60, textSize: 'text-lg', iconSize: 'text-4xl' },
};
const sizeConfig = sizes[size];
// Déterminer le chemin du logo selon la variante
const logoPath = variant === 'white'
? '/logos/logo-white.svg'
: '/logos/logo.svg';
const logoPathPNG = variant === 'white'
? '/logos/logo-white.png'
: '/logos/logo.png';
// Couleurs de texte selon la variante
const textColor = variant === 'white' ? 'text-white' : 'text-gray-900';
const subtextColor = variant === 'white' ? 'text-gray-300' : 'text-gray-600';
// Si une image personnalisée existe et n'a pas d'erreur, l'utiliser
if (!imageError) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<Image
src={logoPath}
alt="Thé Tip Top Logo"
width={sizeConfig.width}
height={sizeConfig.height}
priority
className="object-contain"
onError={() => {
// Si SVG échoue, essayer PNG
const img = document.createElement('img');
img.src = logoPathPNG;
img.onerror = () => setImageError(true);
}}
/>
</div>
);
}
// Fallback : utiliser l'emoji et le texte
return (
<div className={`flex items-center gap-2 ${className}`}>
<span className={sizeConfig.iconSize}>🍵</span>
{showText && (
<div className="flex flex-col">
<span className={`font-bold ${sizeConfig.textSize} ${textColor}`}>
Thé Tip Top
</span>
<span className={`text-xs ${subtextColor} hidden sm:block`}>
Jeu-Concours 2024
</span>
</div>
)}
</div>
);
}

181
components/Navbar.tsx Normal file
View File

@ -0,0 +1,181 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/contexts/AuthContext";
import { NAV_ITEMS, ROUTES } from "@/utils/constants";
import { useState } from "react";
import Button from "./Button";
export default function Navbar() {
const { user, isAuthenticated, logout, isLoading } = useAuth();
const pathname = usePathname();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const filteredNavItems = NAV_ITEMS.filter((item) => {
if (!user) return false;
return item.roles.includes(user.role);
});
const isActive = (href: string) => pathname === href;
const handleLogout = async () => {
await logout();
setMobileMenuOpen(false);
};
return (
<nav className="bg-white border-b shadow-sm sticky top-0 z-50">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<Link
href="/"
className="flex items-center text-2xl font-bold text-blue-600 hover:text-blue-700 transition-colors"
>
🍵 Thé Tip Top
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-6">
{isAuthenticated && filteredNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`text-sm font-medium transition-colors ${
isActive(item.href)
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-700 hover:text-blue-600"
}`}
>
{item.label}
</Link>
))}
{!isLoading && (
<>
{!isAuthenticated ? (
<div className="flex items-center space-x-3">
<Link href={ROUTES.LOGIN}>
<Button variant="outline" size="sm">
Connexion
</Button>
</Link>
<Link href={ROUTES.REGISTER}>
<Button size="sm">Inscription</Button>
</Link>
</div>
) : (
<div className="flex items-center space-x-4">
<div className="text-sm text-gray-700">
<span className="font-medium">{user?.firstName} {user?.lastName}</span>
<span className="block text-xs text-gray-500">{user?.email}</span>
</div>
<Button variant="outline" size="sm" onClick={handleLogout}>
Déconnexion
</Button>
</div>
)}
</>
)}
</div>
{/* Mobile Menu Button */}
<button
className="md:hidden p-2 rounded-md text-gray-700 hover:bg-gray-100"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Menu mobile"
>
<svg
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
{mobileMenuOpen ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
)}
</svg>
</button>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="md:hidden py-4 border-t">
<div className="flex flex-col space-y-3">
{isAuthenticated && filteredNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive(item.href)
? "bg-blue-50 text-blue-600"
: "text-gray-700 hover:bg-gray-50"
}`}
onClick={() => setMobileMenuOpen(false)}
>
{item.label}
</Link>
))}
{!isLoading && (
<>
{!isAuthenticated ? (
<>
<Link
href={ROUTES.LOGIN}
onClick={() => setMobileMenuOpen(false)}
>
<Button variant="outline" size="sm" fullWidth>
Connexion
</Button>
</Link>
<Link
href={ROUTES.REGISTER}
onClick={() => setMobileMenuOpen(false)}
>
<Button size="sm" fullWidth>
Inscription
</Button>
</Link>
</>
) : (
<>
<div className="px-3 py-2 text-sm border-t pt-3">
<span className="font-medium text-gray-900">
{user?.firstName} {user?.lastName}
</span>
<span className="block text-xs text-gray-500 mt-1">
{user?.email}
</span>
</div>
<Button
variant="outline"
size="sm"
fullWidth
onClick={handleLogout}
>
Déconnexion
</Button>
</>
)}
</>
)}
</div>
</div>
)}
</div>
</nav>
);
}

View File

@ -0,0 +1,178 @@
'use client';
import { useState, useEffect } from 'react';
import { adminService } from '@/services/admin.service';
import { Prize, CreatePrizeData, UpdatePrizeData } from '@/types';
export default function PrizeManagement() {
const [prizes, setPrizes] = useState<Prize[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingPrize, setEditingPrize] = useState<Prize | null>(null);
// Form state
const [formData, setFormData] = useState<CreatePrizeData>({
name: '',
type: 'PHYSICAL',
description: '',
value: '',
probability: 0,
stock: 0,
});
useEffect(() => {
loadPrizes();
}, []);
const loadPrizes = async () => {
try {
setLoading(true);
setError(null);
const data = await adminService.getAllPrizes();
setPrizes(data || []);
} catch (err: any) {
setError(err.message || 'Erreur lors du chargement des prix');
setPrizes([]);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingPrize) {
await adminService.updatePrize(editingPrize.id, formData as UpdatePrizeData);
} else {
await adminService.createPrize(formData);
}
resetForm();
loadPrizes();
} catch (err: any) {
alert(err.message || 'Erreur lors de la sauvegarde');
}
};
const handleEdit = (prize: Prize) => {
setEditingPrize(prize);
setFormData({
name: prize.name,
type: prize.type,
description: prize.description,
value: prize.value,
probability: prize.probability,
stock: prize.stock,
});
setIsModalOpen(true);
};
const handleDelete = async (prizeId: string) => {
if (!confirm('Êtes-vous sûr de vouloir supprimer ce prix ?')) return;
try {
await adminService.deletePrize(prizeId);
loadPrizes();
} catch (err: any) {
alert(err.message || 'Erreur lors de la suppression');
}
};
const resetForm = () => {
setFormData({
name: '',
type: 'PHYSICAL',
description: '',
value: '',
probability: 0,
stock: 0,
});
setEditingPrize(null);
setIsModalOpen(false);
};
if (loading) {
return <div className="text-center py-8">Chargement des prix...</div>;
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Gestion des Prix</h1>
</div>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{/* Liste des prix */}
{prizes.length === 0 ? (
<div className="text-center py-12 text-gray-500">
Aucun prix trouvé. Cliquez sur "Ajouter un prix" pour commencer.
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{prizes.map((prize) => (
<div
key={prize.id}
className="border rounded-lg p-4 shadow-sm hover:shadow-md transition"
>
<div className="flex justify-between items-start mb-3">
<h3 className="font-semibold text-lg">{prize.name}</h3>
<span
className={`px-2 py-1 rounded text-xs ${
prize.isActive
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{prize.isActive ? 'Actif' : 'Inactif'}
</span>
</div>
<p className="text-sm text-gray-600 mb-2">{prize.description}</p>
<div className="space-y-1 text-sm mb-3">
<p><strong>Type:</strong> {prize.type}</p>
<p><strong>Valeur:</strong> {prize.value}</p>
{/* Affichage spécial pour le Grand Prix (tirage au sort) */}
{prize.type === 'GRAND_PRIZE' ? (
<>
<p><strong>Probabilité:</strong> N/A (Tirage au sort)</p>
<div className="mt-2 p-2 bg-purple-50 border border-purple-200 rounded">
<p className="text-purple-800 font-semibold text-xs">
🎯 TIRAGE AU SORT
</p>
<p className="text-purple-700 text-xs mt-1">
Attribué lors du tirage final parmi les participants éligibles
</p>
</div>
</>
) : (
<>
<p><strong>Probabilité:</strong> {(prize.probability * 100).toFixed(1)}%</p>
<p>
<strong>Stock généré:</strong> {prize.initialStock !== undefined ? prize.initialStock : prize.stock}
</p>
<p>
<strong>Stock restant:</strong>{' '}
<span className={(prize.initialStock - prize.ticketsUsed) === 0 ? 'text-red-600 font-semibold' : 'text-green-600 font-semibold'}>
{prize.initialStock - (prize.ticketsUsed || 0)}
</span>
</p>
{prize.ticketsUsed !== undefined && prize.ticketsUsed > 0 && (
<p><strong>Tickets utilisés:</strong> {prize.ticketsUsed}</p>
)}
</>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}

376
components/admin/README.md Normal file
View File

@ -0,0 +1,376 @@
# 📦 Composants d'Administration - The Tip Top
Documentation des composants d'administration pour le frontend Next.js.
---
## 📁 Structure
```
components/admin/
├── index.ts # Export centralisé
├── PrizeManagement.tsx # Gestion des prix
├── UserManagement.tsx # Gestion des utilisateurs
├── TicketManagement.tsx # Gestion des tickets
├── Statistics.tsx # Statistiques globales
└── README.md # Ce fichier
```
---
## 🎯 Composants disponibles
### 1. **PrizeManagement** - Gestion des Prix
Gère tous les prix du jeu (création, modification, suppression).
#### Fonctionnalités:
- ✅ Liste tous les prix avec pagination
- ✅ Créer un nouveau prix
- ✅ Modifier un prix existant
- ✅ Supprimer (désactiver) un prix
- ✅ Affichage du stock et probabilités
- ✅ Filtrage par statut (actif/inactif)
#### Utilisation:
```tsx
import { PrizeManagement } from '@/components/admin';
export default function AdminPrizesPage() {
return (
<div>
<PrizeManagement />
</div>
);
}
```
#### Props:
Aucune (composant autonome)
---
### 2. **UserManagement** - Gestion des Utilisateurs
Gère tous les utilisateurs de la plateforme.
#### Fonctionnalités:
- ✅ Liste paginée des utilisateurs
- ✅ Créer un nouvel employé
- ✅ Modifier le rôle d'un utilisateur
- ✅ Vérifier/Dévérifier un email
- ✅ Supprimer un utilisateur
- ✅ Filtrage par rôle (CLIENT, EMPLOYEE, ADMIN)
#### Utilisation:
```tsx
import { UserManagement } from '@/components/admin';
export default function AdminUsersPage() {
return (
<div>
<UserManagement />
</div>
);
}
```
#### Props:
Aucune (composant autonome)
---
### 3. **TicketManagement** - Gestion des Tickets
Visualise tous les tickets du système avec filtres avancés.
#### Fonctionnalités:
- ✅ Liste paginée de tous les tickets
- ✅ Filtrage par statut (PENDING, VALIDATED, REJECTED, CLAIMED)
- ✅ Visualisation des détails d'un ticket
- ✅ Affichage des informations utilisateur et prix
- ✅ Historique complet (dates de jeu, validation, etc.)
#### Utilisation:
```tsx
import { TicketManagement } from '@/components/admin';
export default function AdminTicketsPage() {
return (
<div>
<TicketManagement />
</div>
);
}
```
#### Props:
Aucune (composant autonome)
---
### 4. **Statistics** - Statistiques Globales
Affiche les statistiques globales de la plateforme.
#### Fonctionnalités:
- ✅ Statistiques utilisateurs (total, par rôle, emails vérifiés)
- ✅ Statistiques tickets (total, par statut)
- ✅ Statistiques prix (total, actifs, stock, distribués)
- ✅ Rafraîchissement manuel
#### Utilisation:
```tsx
import { Statistics } from '@/components/admin';
export default function AdminDashboard() {
return (
<div>
<Statistics />
</div>
);
}
```
#### Props:
Aucune (composant autonome)
---
## 🔌 Intégration dans Next.js
### Exemple de page admin complète
Créer un fichier `app/admin/dashboard/page.tsx`:
```tsx
'use client';
import { useState } from 'react';
import {
Statistics,
PrizeManagement,
UserManagement,
TicketManagement
} from '@/components/admin';
export default function AdminDashboard() {
const [activeTab, setActiveTab] = useState('statistics');
return (
<div className="min-h-screen bg-gray-100">
{/* Navigation tabs */}
<div className="bg-white shadow">
<nav className="flex space-x-4 p-4">
<button
onClick={() => setActiveTab('statistics')}
className={`px-4 py-2 rounded ${
activeTab === 'statistics'
? 'bg-blue-600 text-white'
: 'bg-gray-200'
}`}
>
Statistiques
</button>
<button
onClick={() => setActiveTab('prizes')}
className={`px-4 py-2 rounded ${
activeTab === 'prizes'
? 'bg-blue-600 text-white'
: 'bg-gray-200'
}`}
>
Prix
</button>
<button
onClick={() => setActiveTab('users')}
className={`px-4 py-2 rounded ${
activeTab === 'users'
? 'bg-blue-600 text-white'
: 'bg-gray-200'
}`}
>
Utilisateurs
</button>
<button
onClick={() => setActiveTab('tickets')}
className={`px-4 py-2 rounded ${
activeTab === 'tickets'
? 'bg-blue-600 text-white'
: 'bg-gray-200'
}`}
>
Tickets
</button>
</nav>
</div>
{/* Content */}
<div className="container mx-auto">
{activeTab === 'statistics' && <Statistics />}
{activeTab === 'prizes' && <PrizeManagement />}
{activeTab === 'users' && <UserManagement />}
{activeTab === 'tickets' && <TicketManagement />}
</div>
</div>
);
}
```
---
## 🔐 Protection des routes admin
Utilisez un middleware pour protéger les routes admin:
```typescript
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth_token');
const user = request.cookies.get('user_data');
if (!token || !user) {
return NextResponse.redirect(new URL('/login', request.url));
}
const userData = JSON.parse(user.value);
if (userData.role !== 'ADMIN') {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: '/admin/:path*',
};
```
---
## 📡 Services utilisés
Tous les composants utilisent le service `adminService` qui communique avec l'API backend:
```typescript
import { adminService } from '@/services/admin.service';
// Exemples d'utilisation
const stats = await adminService.getStatistics();
const prizes = await adminService.getAllPrizes();
const users = await adminService.getAllUsers(page, limit);
const tickets = await adminService.getAllTickets(page, limit, filters);
```
---
## 🎨 Personnalisation
### Thèmes et styles
Les composants utilisent Tailwind CSS. Pour personnaliser:
1. Modifiez les classes Tailwind directement dans les composants
2. Ou créez des thèmes dans `tailwind.config.js`
### Traductions
Les composants sont en français. Pour internationaliser:
1. Utilisez `next-intl` ou `react-i18next`
2. Remplacez les chaînes hardcodées par des clés de traduction
---
## ⚙️ Configuration requise
### Variables d'environnement
```env
NEXT_PUBLIC_API_URL=http://localhost:4000/api
```
### Dépendances
- React 18+
- Next.js 14+
- TypeScript
- Tailwind CSS
---
## 🐛 Gestion des erreurs
Tous les composants gèrent les erreurs avec:
- Messages d'erreur utilisateur-friendly
- Affichage visuel des erreurs
- Logging des erreurs en console (développement)
---
## 📊 Exemple de flux complet
### 1. Créer un prix
```tsx
// L'utilisateur clique sur "Ajouter un prix"
// → Modal s'ouvre
// → Remplit le formulaire
// → Soumet
// → adminService.createPrize() est appelé
// → Liste des prix est rafraîchie
```
### 2. Modifier un utilisateur
```tsx
// L'utilisateur clique sur "Modifier" sur un user
// → Modal s'ouvre avec les données
// → Modifie le rôle
// → Soumet
// → adminService.updateUser() est appelé
// → Liste des users est rafraîchie
```
---
## ✅ Checklist d'intégration
- [ ] Installer les dépendances requises
- [ ] Configurer les variables d'environnement
- [ ] Ajouter le service `adminService`
- [ ] Ajouter les types TypeScript
- [ ] Protéger les routes admin avec middleware
- [ ] Créer les pages admin
- [ ] Tester chaque composant
- [ ] Ajouter la gestion des erreurs globale
---
## 🚀 Déploiement
Avant de déployer:
1. Vérifier que toutes les routes API sont sécurisées
2. Tester l'authentification admin
3. Vérifier les permissions CORS
4. Activer le cache pour les statistiques
5. Optimiser les images et assets
---
## 📞 Support
Pour toute question ou problème:
- Vérifier la console du navigateur
- Vérifier les logs de l'API backend
- Consulter la documentation de l'API
---
**Version:** 1.0.0
**Dernière mise à jour:** 2025-11-08
**Auteur:** The Tip Top Dev Team

View File

@ -0,0 +1,127 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Users,
Ticket,
BarChart3,
Gift,
Menu,
X
} from "lucide-react";
import { useState } from "react";
interface NavItem {
label: string;
href: string;
icon: React.ReactNode;
}
export default function Sidebar() {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const navItems: NavItem[] = [
{
label: "Dashboard",
href: "/admin/dashboard",
icon: <LayoutDashboard className="w-5 h-5" />,
},
{
label: "Utilisateurs",
href: "/admin/utilisateurs",
icon: <Users className="w-5 h-5" />,
},
{
label: "Tickets",
href: "/admin/tickets",
icon: <Ticket className="w-5 h-5" />,
},
{
label: "Lots & Prix",
href: "/admin/lots",
icon: <Gift className="w-5 h-5" />,
},
{
label: "Données Marketing",
href: "/admin/marketing-data",
icon: <BarChart3 className="w-5 h-5" />,
},
{
label: "Tirages",
href: "/admin/tirages",
icon: <Gift className="w-5 h-5" />,
},
];
const isActive = (href: string) => {
return pathname === href;
};
return (
<>
{/* Mobile menu button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="lg:hidden fixed top-4 left-4 z-50 p-2 rounded-md bg-white shadow-md hover:bg-gray-50"
>
{isOpen ? (
<X className="w-6 h-6 text-gray-600" />
) : (
<Menu className="w-6 h-6 text-gray-600" />
)}
</button>
{/* Overlay for mobile */}
{isOpen && (
<div
className="lg:hidden fixed inset-0 bg-black bg-opacity-50 z-30"
onClick={() => setIsOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`
fixed top-0 left-0 h-full bg-white shadow-lg z-40 transition-transform duration-300 ease-in-out
${isOpen ? "translate-x-0" : "-translate-x-full"}
lg:translate-x-0 lg:static
w-64
`}
>
<div className="flex flex-col h-full">
{/* Logo/Header */}
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Admin Panel</h2>
<p className="text-sm text-gray-500 mt-1">Thé Tip Top</p>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-2">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
onClick={() => setIsOpen(false)}
className={`
flex items-center gap-3 px-4 py-3 rounded-lg transition-colors
${
isActive(item.href)
? "bg-blue-600 text-white"
: "text-gray-700 hover:bg-gray-100"
}
`}
>
{item.icon}
<span className="font-medium">{item.label}</span>
</Link>
))}
</nav>
</div>
</aside>
</>
);
}

View File

@ -0,0 +1,146 @@
'use client';
import { useState, useEffect } from 'react';
import { adminService } from '@/services/admin.service';
import { AdminStatistics } from '@/types';
export default function Statistics() {
const [stats, setStats] = useState<AdminStatistics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <div className="text-center py-8">Chargement des statistiques...</div>;
}
if (error || !stats) {
return (
<div className="p-6">
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error || 'Erreur lors du chargement des statistiques'}
</div>
<button
onClick={loadStatistics}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
>
Réessayer
</button>
</div>
);
}
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6">Statistiques</h1>
{/* Utilisateurs */}
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Utilisateurs</h2>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
<StatCard
title="Total"
value={stats.users.total}
color="bg-blue-100 text-blue-800"
/>
<StatCard
title="Clients"
value={stats.users.clients}
color="bg-green-100 text-green-800"
/>
<StatCard
title="Employés"
value={stats.users.employees}
color="bg-purple-100 text-purple-800"
/>
<StatCard
title="Admins"
value={stats.users.admins}
color="bg-red-100 text-red-800"
/>
<StatCard
title="Emails vérifiés"
value={stats.users.verifiedEmails}
color="bg-yellow-100 text-yellow-800"
/>
</div>
</div>
{/* Tickets */}
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Tickets</h2>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
<StatCard
title="Total"
value={stats.tickets.total}
color="bg-blue-100 text-blue-800"
/>
<StatCard
title="En attente"
value={stats.tickets.pending}
color="bg-yellow-100 text-yellow-800"
/>
<StatCard
title="Réclamés"
value={stats.tickets.claimed}
color="bg-green-100 text-green-800"
/>
<StatCard
title="Rejetés"
value={stats.tickets.rejected}
color="bg-red-100 text-red-800"
/>
<StatCard
title="Récupérés"
value={stats.tickets.claimed}
color="bg-purple-100 text-purple-800"
/>
</div>
</div>
{/* Bouton rafraîchir */}
<div className="mt-8 text-center">
<button
onClick={loadStatistics}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
>
Rafraîchir
</button>
</div>
</div>
);
}
interface StatCardProps {
title: string;
value: number;
color: string;
}
function StatCard({ title, value, color }: StatCardProps) {
return (
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600 mb-2">{title}</p>
<p className={`text-3xl font-bold ${color}`}>
{(value || 0).toLocaleString('fr-FR')}
</p>
</div>
);
}

View File

@ -0,0 +1,498 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { adminService } from '@/services/admin.service';
import { Ticket, PaginatedResponse } from '@/types';
export default function TicketManagement() {
const [tickets, setTickets] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalTickets, setTotalTickets] = useState(0);
const [filterStatus, setFilterStatus] = useState<string>('');
const [filterPrizeType, setFilterPrizeType] = useState<string>('');
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [showDebug, setShowDebug] = useState(false);
const loadTickets = useCallback(async () => {
try {
setLoading(true);
setError(null);
// Construire les filtres
const filters: any = {};
if (filterStatus) filters.status = filterStatus;
if (filterPrizeType) filters.prizeType = filterPrizeType;
const response = await adminService.getAllTickets(
page,
20,
Object.keys(filters).length > 0 ? filters : undefined
);
// Vérifier si la réponse est directement un tableau ou un objet avec data
let ticketsData: Ticket[] = [];
let total = 0;
let totalPagesCount = 1;
if (Array.isArray(response)) {
// Si la réponse est directement un tableau
console.log('📦 Réponse est un tableau direct');
ticketsData = response;
total = response.length;
totalPagesCount = 1;
} else if (response.data && Array.isArray(response.data)) {
// Si la réponse est un objet avec data
console.log('📦 Réponse est un objet avec data');
ticketsData = response.data;
total = response.total || response.data.length;
totalPagesCount = response.totalPages || 1;
} else {
console.warn('⚠️ Format de réponse inattendu:', response);
}
console.log('🎯 Tickets à afficher:', ticketsData);
setTickets(ticketsData);
setTotalPages(totalPagesCount);
setTotalTickets(total);
} catch (err: any) {
console.error('❌ Erreur lors du chargement:', err);
// Messages d'erreur personnalisés selon le type d'erreur
let errorMessage = 'Erreur lors du chargement des tickets';
if (err.status === 401) {
errorMessage = '🔐 Non autorisé (401) - Votre session a expiré ou votre token est invalide. Veuillez vous reconnecter.';
} else if (err.status === 403) {
errorMessage = '🚫 Accès refusé (403) - Vous n\'avez pas les permissions administrateur nécessaires.';
} else if (err.status === 0 || err.message.includes('fetch')) {
errorMessage = '🔌 Impossible de contacter le serveur. Vérifiez que le backend est démarré sur http://localhost:4000';
} else {
errorMessage = err.message || errorMessage;
}
setError(errorMessage);
setTickets([]);
setTotalTickets(0);
} finally {
setLoading(false);
}
}, [page, filterStatus, filterPrizeType]);
useEffect(() => {
loadTickets();
}, [loadTickets]);
const getStatusBadgeColor = (status: string) => {
switch (status) {
case 'PENDING':
return 'bg-yellow-100 text-yellow-800';
case 'REJECTED':
return 'bg-red-100 text-red-800';
case 'CLAIMED':
return 'bg-green-100 text-green-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'PENDING':
return 'En attente';
case 'REJECTED':
return 'Rejeté';
case 'CLAIMED':
return 'Réclamé';
default:
return status;
}
};
if (loading && tickets.length === 0) {
return <div className="text-center py-8">Chargement des tickets...</div>;
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Gestion des Tickets</h1>
<button
onClick={loadTickets}
disabled={loading}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Chargement...' : 'Actualiser'}
</button>
</div>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
<p className="font-bold">Erreur de chargement:</p>
<p className="mt-1">{error}</p>
{error.includes('401') && (
<div className="mt-3 space-y-2">
<p className="text-sm font-medium"> Le header Authorization est bien envoyé</p>
<p className="text-sm"> Mais votre token est invalide ou a expiré</p>
<div className="flex gap-2 mt-2">
<a
href="/login"
className="text-sm bg-red-600 text-white px-3 py-1 rounded hover:bg-red-700"
>
Se reconnecter
</a>
<a
href="/admin/diagnostic"
className="text-sm bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700"
>
🩺 Diagnostic complet
</a>
</div>
</div>
)}
{error.includes('403') && (
<div className="mt-3 space-y-2">
<p className="text-sm">Vous n'avez pas le rôle ADMIN requis.</p>
<a
href="/admin/diagnostic"
className="inline-block text-sm bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700"
>
🩺 Voir le diagnostic complet
</a>
</div>
)}
{!error.includes('401') && !error.includes('403') && (
<div className="mt-3 space-y-2">
<p className="text-sm">Vérifiez que le backend est démarré sur http://localhost:4000</p>
<a
href="/admin/diagnostic"
className="inline-block text-sm bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700"
>
🩺 Lancer le diagnostic
</a>
</div>
)}
</div>
)}
{/* Info et Filtres */}
<div className="mb-4 flex justify-between items-center">
<div className="text-sm text-gray-600">
{totalTickets > 0 && (
<span>
{totalTickets} ticket{totalTickets > 1 ? 's' : ''} au total
{(filterStatus || filterPrizeType) && ' (filtré)'}
</span>
)}
</div>
<div className="flex gap-3">
{/* Filtre par type de lot */}
<select
value={filterPrizeType}
onChange={(e) => {
setFilterPrizeType(e.target.value);
setPage(1);
}}
className="border rounded px-3 py-2"
>
<option value="">Tous les lots</option>
<option value="INFUSEUR">Infuseur à thé</option>
<option value="THE_GRATUIT">Thé détox/infusion 100g</option>
<option value="THE_SIGNATURE">Thé signature 100g</option>
<option value="COFFRET_DECOUVERTE">Coffret découverte 39</option>
<option value="COFFRET_PRESTIGE">Coffret prestige 69</option>
</select>
{/* Filtre par statut */}
<select
value={filterStatus}
onChange={(e) => {
setFilterStatus(e.target.value);
setPage(1);
}}
className="border rounded px-3 py-2"
>
<option value="">Tous les statuts</option>
<option value="PENDING">En attente</option>
<option value="REJECTED">Rejeté</option>
<option value="CLAIMED">Réclamé</option>
</select>
{/* Bouton pour réinitialiser les filtres */}
{(filterStatus || filterPrizeType) && (
<button
onClick={() => {
setFilterStatus('');
setFilterPrizeType('');
setPage(1);
}}
className="text-sm text-gray-600 hover:text-gray-900 underline"
>
Réinitialiser
</button>
)}
</div>
</div>
{/* Table des tickets */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Code Ticket
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Lot Gagné
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Distribué le
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Utilisé par
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{tickets.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center">
<div className="flex flex-col items-center justify-center">
<div className="text-6xl mb-4">🎫</div>
<p className="text-gray-900 font-medium text-lg mb-2">
{!error ? 'Aucun ticket trouvé' : 'Impossible de charger les tickets'}
</p>
<p className="text-gray-500 text-sm mb-4">
{filterStatus
? `Aucun ticket avec le statut "${getStatusLabel(filterStatus)}"`
: 'Aucun ticket n\'a été créé pour le moment'
}
</p>
{!error && (
<p className="text-gray-400 text-xs">
Les tickets apparaîtront ici une fois que des utilisateurs auront joué au jeu
</p>
)}
</div>
</td>
</tr>
) : (
tickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-gray-50">
{/* CODE TICKET */}
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-mono font-medium text-gray-900">
{ticket.code}
</div>
</td>
{/* LOT GAGNÉ */}
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-gray-900">
{ticket.prize?.name || 'N/A'}
</div>
</td>
{/* STATUT */}
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusBadgeColor(ticket.status)}`}>
{getStatusLabel(ticket.status)}
</span>
</td>
{/* DISTRIBUÉ LE */}
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{ticket.createdAt ? new Date(ticket.createdAt).toLocaleDateString('fr-FR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}) : 'N/A'}
</td>
{/* UTILISÉ PAR */}
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{ticket.user ? `${ticket.user.firstName} ${ticket.user.lastName}` : '-'}
</div>
{ticket.user && (
<div className="text-xs text-gray-500">
{ticket.user.email}
</div>
)}
</td>
{/* ACTIONS */}
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => setSelectedTicket(ticket)}
className="text-blue-600 hover:text-blue-900"
>
Détails
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex justify-center gap-2 mt-4">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 border rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
Précédent
</button>
<span className="px-4 py-2">
Page {page} sur {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 border rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
Suivant
</button>
</div>
{/* Panneau de Debug */}
<div className="mt-8">
<button
onClick={() => setShowDebug(!showDebug)}
className="text-sm text-gray-600 hover:text-gray-900 underline"
>
{showDebug ? '🔽 Masquer les infos de debug' : '🔍 Afficher les infos de debug'}
</button>
{showDebug && (
<div className="mt-4 bg-gray-100 rounded-lg p-4 border border-gray-300">
<h3 className="font-bold text-sm mb-2">📊 Informations de Debug</h3>
<div className="space-y-2 text-xs font-mono">
<div>
<strong>État:</strong> {loading ? 'Chargement...' : 'Chargé'}
</div>
<div>
<strong>Erreur:</strong> {error || 'Aucune'}
</div>
<div>
<strong>Nombre de tickets:</strong> {tickets.length}
</div>
<div>
<strong>Total tickets (API):</strong> {totalTickets}
</div>
<div>
<strong>Page actuelle:</strong> {page} / {totalPages}
</div>
<div>
<strong>Filtre statut:</strong> {filterStatus || 'Aucun'}
</div>
<div>
<strong>URL API:</strong> {process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api'}
</div>
<div className="pt-2">
<strong>Tickets reçus:</strong>
<pre className="mt-2 bg-white p-2 rounded overflow-auto max-h-60">
{JSON.stringify(tickets, null, 2)}
</pre>
</div>
</div>
<div className="mt-4">
<p className="text-xs text-gray-600">
💡 Astuce: Ouvrez la console du navigateur (F12) pour voir les logs détaillés
</p>
</div>
</div>
)}
</div>
{/* Modal détails ticket */}
{selectedTicket && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">Détails du ticket</h2>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-gray-500">Code</p>
<p className="mt-1 text-sm font-mono">{selectedTicket.code}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500">Statut</p>
<p className="mt-1">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusBadgeColor(selectedTicket.status)}`}>
{getStatusLabel(selectedTicket.status)}
</span>
</p>
</div>
</div>
<div>
<p className="text-sm font-medium text-gray-500">Utilisateur</p>
<p className="mt-1 text-sm">
{selectedTicket.user ? `${selectedTicket.user.firstName} ${selectedTicket.user.lastName}` : 'N/A'}
</p>
<p className="text-sm text-gray-500">{selectedTicket.user?.email}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500">Prix gagné</p>
<p className="mt-1 text-sm">{selectedTicket.prize?.name}</p>
<p className="text-sm text-gray-500">{selectedTicket.prize?.description}</p>
<p className="text-sm font-medium">{selectedTicket.prize?.value}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-gray-500">Date de jeu</p>
<p className="mt-1 text-sm">
{selectedTicket.playedAt ? new Date(selectedTicket.playedAt).toLocaleString('fr-FR') : 'N/A'}
</p>
</div>
{selectedTicket.validatedAt && (
<div>
<p className="text-sm font-medium text-gray-500">Date de validation</p>
<p className="mt-1 text-sm">
{new Date(selectedTicket.validatedAt).toLocaleString('fr-FR')}
</p>
</div>
)}
</div>
{selectedTicket.rejectionReason && (
<div>
<p className="text-sm font-medium text-gray-500">Raison du rejet</p>
<p className="mt-1 text-sm text-red-600">{selectedTicket.rejectionReason}</p>
</div>
)}
</div>
<div className="mt-6">
<button
onClick={() => setSelectedTicket(null)}
className="w-full bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400"
>
Fermer
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,376 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { adminService } from '@/services/admin.service';
import { User, CreateEmployeeData, UpdateUserData, PaginatedResponse } from '@/types';
export default function UserManagement() {
const router = useRouter();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [filterRole, setFilterRole] = useState<string>('');
// Modals
const [isCreateEmployeeModalOpen, setIsCreateEmployeeModalOpen] = useState(false);
const [isEditUserModalOpen, setIsEditUserModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
// Form data
const [employeeFormData, setEmployeeFormData] = useState<CreateEmployeeData>({
email: '',
password: '',
firstName: '',
lastName: '',
});
const [userFormData, setUserFormData] = useState<UpdateUserData>({});
useEffect(() => {
loadUsers();
}, [page, filterRole]);
const loadUsers = async () => {
try {
setLoading(true);
setError(null);
const response: PaginatedResponse<User> = await adminService.getAllUsers(
page,
20,
filterRole ? { role: filterRole } : undefined
);
setUsers(response.data || []);
setTotalPages(response.totalPages || 1);
} catch (err: any) {
setError(err.message || 'Erreur lors du chargement des utilisateurs');
setUsers([]);
} finally {
setLoading(false);
}
};
const handleCreateEmployee = async (e: React.FormEvent) => {
e.preventDefault();
try {
await adminService.createEmployee(employeeFormData);
setIsCreateEmployeeModalOpen(false);
setEmployeeFormData({ email: '', password: '', firstName: '', lastName: '' });
loadUsers();
} catch (err: any) {
alert(err.message || 'Erreur lors de la création de l\'employé');
}
};
const handleEditUser = (user: User) => {
setEditingUser(user);
setUserFormData({
role: user.role,
isVerified: user.isVerified,
firstName: user.firstName,
lastName: user.lastName,
});
setIsEditUserModalOpen(true);
};
const handleUpdateUser = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingUser) return;
try {
await adminService.updateUser(editingUser.id, userFormData);
setIsEditUserModalOpen(false);
setEditingUser(null);
loadUsers();
} catch (err: any) {
alert(err.message || 'Erreur lors de la modification de l\'utilisateur');
}
};
const handleDeleteUser = async (userId: string) => {
if (!confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ?')) return;
try {
await adminService.deleteUser(userId);
loadUsers();
} catch (err: any) {
alert(err.message || 'Erreur lors de la suppression');
}
};
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'ADMIN':
return 'bg-red-100 text-red-800';
case 'EMPLOYEE':
return 'bg-blue-100 text-blue-800';
case 'CLIENT':
return 'bg-green-100 text-green-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
if (loading && users.length === 0) {
return <div className="text-center py-8">Chargement des utilisateurs...</div>;
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Gestion des Utilisateurs</h1>
<button
onClick={() => setIsCreateEmployeeModalOpen(true)}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
>
Créer un employé
</button>
</div>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{/* Filtres */}
<div className="mb-4">
<select
value={filterRole}
onChange={(e) => {
setFilterRole(e.target.value);
setPage(1);
}}
className="border rounded px-3 py-2"
>
<option value="">Tous les rôles</option>
<option value="CLIENT">Clients</option>
<option value="EMPLOYEE">Employés</option>
<option value="ADMIN">Administrateurs</option>
</select>
</div>
{/* Table des utilisateurs */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Utilisateur
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Rôle
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date création
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">
Aucun utilisateur trouvé
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{user.firstName} {user.lastName}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getRoleBadgeColor(user.role)}`}>
{user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${user.isVerified ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}`}>
{user.isVerified ? 'Vérifié' : 'Non vérifié'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString('fr-FR')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button
onClick={() => router.push(`/admin/utilisateurs/${user.id}`)}
className="text-green-600 hover:text-green-900"
>
Détails
</button>
<button
onClick={() => handleEditUser(user)}
className="text-blue-600 hover:text-blue-900"
>
Modifier
</button>
<button
onClick={() => handleDeleteUser(user.id)}
className="text-red-600 hover:text-red-900"
>
Supprimer
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex justify-center gap-2 mt-4">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 border rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
Précédent
</button>
<span className="px-4 py-2">
Page {page} sur {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 border rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
Suivant
</button>
</div>
{/* Modal créer employé */}
{isCreateEmployeeModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4">Créer un employé</h2>
<form onSubmit={handleCreateEmployee} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
value={employeeFormData.email}
onChange={(e) => setEmployeeFormData({ ...employeeFormData, email: e.target.value })}
className="w-full border rounded px-3 py-2"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Mot de passe</label>
<input
type="password"
value={employeeFormData.password}
onChange={(e) => setEmployeeFormData({ ...employeeFormData, password: e.target.value })}
className="w-full border rounded px-3 py-2"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Prénom</label>
<input
type="text"
value={employeeFormData.firstName}
onChange={(e) => setEmployeeFormData({ ...employeeFormData, firstName: e.target.value })}
className="w-full border rounded px-3 py-2"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Nom</label>
<input
type="text"
value={employeeFormData.lastName}
onChange={(e) => setEmployeeFormData({ ...employeeFormData, lastName: e.target.value })}
className="w-full border rounded px-3 py-2"
required
/>
</div>
<div className="flex gap-2 pt-4">
<button
type="submit"
className="flex-1 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Créer
</button>
<button
type="button"
onClick={() => setIsCreateEmployeeModalOpen(false)}
className="flex-1 bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400"
>
Annuler
</button>
</div>
</form>
</div>
</div>
)}
{/* Modal modifier utilisateur */}
{isEditUserModalOpen && editingUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4">Modifier l'utilisateur</h2>
<form onSubmit={handleUpdateUser} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Rôle</label>
<select
value={userFormData.role}
onChange={(e) => setUserFormData({ ...userFormData, role: e.target.value as 'CLIENT' | 'EMPLOYEE' | 'ADMIN' })}
className="w-full border rounded px-3 py-2"
>
<option value="CLIENT">Client</option>
<option value="EMPLOYEE">Employé</option>
<option value="ADMIN">Administrateur</option>
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isVerified"
checked={userFormData.isVerified}
onChange={(e) => setUserFormData({ ...userFormData, isVerified: e.target.checked })}
className="rounded"
/>
<label htmlFor="isVerified" className="text-sm font-medium">
Email vérifié
</label>
</div>
<div className="flex gap-2 pt-4">
<button
type="submit"
className="flex-1 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Mettre à jour
</button>
<button
type="button"
onClick={() => setIsEditUserModalOpen(false)}
className="flex-1 bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400"
>
Annuler
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,9 @@
/**
* Composants d'administration
* Export centralisé de tous les composants admin
*/
export { default as PrizeManagement } from './PrizeManagement';
export { default as UserManagement } from './UserManagement';
export { default as TicketManagement } from './TicketManagement';
export { default as Statistics } from './Statistics';

View File

@ -0,0 +1,59 @@
'use client';
import React, { ChangeEvent } from 'react';
interface FormCheckboxProps {
label: string;
name: string;
checked: boolean;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
error?: string;
touched?: boolean;
required?: boolean;
disabled?: boolean;
className?: string;
}
/**
* Composant de checkbox de formulaire réutilisable
*/
export default function FormCheckbox({
label,
name,
checked,
onChange,
error,
touched,
required = false,
disabled = false,
className = '',
}: FormCheckboxProps) {
const showError = touched && error;
return (
<div className={`mb-4 ${className}`}>
<div className="flex items-center">
<input
id={name}
name={name}
type="checkbox"
checked={checked}
onChange={onChange}
disabled={disabled}
required={required}
className="w-4 h-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500"
/>
<label
htmlFor={name}
className="ml-2 text-sm text-gray-700 cursor-pointer"
>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
</div>
{showError && (
<p className="mt-1 text-sm text-red-500">{error}</p>
)}
</div>
);
}

View File

@ -0,0 +1,73 @@
'use client';
import React, { ChangeEvent } from 'react';
interface FormFieldProps {
label: string;
name: string;
type?: 'text' | 'email' | 'password' | 'tel' | 'number' | 'date';
value: string | number;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
onBlur?: (e: ChangeEvent<HTMLInputElement>) => void;
error?: string;
touched?: boolean;
placeholder?: string;
required?: boolean;
disabled?: boolean;
className?: string;
autoComplete?: string;
}
/**
* Composant de champ de formulaire réutilisable
*/
export default function FormField({
label,
name,
type = 'text',
value,
onChange,
onBlur,
error,
touched,
placeholder,
required = false,
disabled = false,
className = '',
autoComplete,
}: FormFieldProps) {
const showError = touched && error;
return (
<div className={`mb-4 ${className}`}>
<label
htmlFor={name}
className="block text-sm font-medium text-gray-700 mb-2"
>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
onBlur={onBlur}
placeholder={placeholder}
disabled={disabled}
required={required}
autoComplete={autoComplete}
className={`
w-full px-4 py-2 border rounded-lg
focus:outline-none focus:ring-2 focus:ring-primary-500
disabled:bg-gray-100 disabled:cursor-not-allowed
${showError ? 'border-red-500' : 'border-gray-300'}
`}
/>
{showError && (
<p className="mt-1 text-sm text-red-500">{error}</p>
)}
</div>
);
}

View File

@ -0,0 +1,84 @@
'use client';
import React, { ChangeEvent } from 'react';
interface SelectOption {
value: string | number;
label: string;
}
interface FormSelectProps {
label: string;
name: string;
value: string | number;
onChange: (e: ChangeEvent<HTMLSelectElement>) => void;
onBlur?: (e: ChangeEvent<HTMLSelectElement>) => void;
options: SelectOption[];
error?: string;
touched?: boolean;
placeholder?: string;
required?: boolean;
disabled?: boolean;
className?: string;
}
/**
* Composant de select de formulaire réutilisable
*/
export default function FormSelect({
label,
name,
value,
onChange,
onBlur,
options,
error,
touched,
placeholder,
required = false,
disabled = false,
className = '',
}: FormSelectProps) {
const showError = touched && error;
return (
<div className={`mb-4 ${className}`}>
<label
htmlFor={name}
className="block text-sm font-medium text-gray-700 mb-2"
>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
<select
id={name}
name={name}
value={value}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
required={required}
className={`
w-full px-4 py-2 border rounded-lg
focus:outline-none focus:ring-2 focus:ring-primary-500
disabled:bg-gray-100 disabled:cursor-not-allowed
${showError ? 'border-red-500' : 'border-gray-300'}
`}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{showError && (
<p className="mt-1 text-sm text-red-500">{error}</p>
)}
</div>
);
}

View File

@ -0,0 +1,70 @@
'use client';
import React, { ChangeEvent } from 'react';
interface FormTextareaProps {
label: string;
name: string;
value: string;
onChange: (e: ChangeEvent<HTMLTextAreaElement>) => void;
onBlur?: (e: ChangeEvent<HTMLTextAreaElement>) => void;
error?: string;
touched?: boolean;
placeholder?: string;
required?: boolean;
disabled?: boolean;
rows?: number;
className?: string;
}
/**
* Composant de textarea de formulaire réutilisable
*/
export default function FormTextarea({
label,
name,
value,
onChange,
onBlur,
error,
touched,
placeholder,
required = false,
disabled = false,
rows = 4,
className = '',
}: FormTextareaProps) {
const showError = touched && error;
return (
<div className={`mb-4 ${className}`}>
<label
htmlFor={name}
className="block text-sm font-medium text-gray-700 mb-2"
>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
<textarea
id={name}
name={name}
value={value}
onChange={onChange}
onBlur={onBlur}
placeholder={placeholder}
disabled={disabled}
required={required}
rows={rows}
className={`
w-full px-4 py-2 border rounded-lg
focus:outline-none focus:ring-2 focus:ring-primary-500
disabled:bg-gray-100 disabled:cursor-not-allowed
${showError ? 'border-red-500' : 'border-gray-300'}
`}
/>
{showError && (
<p className="mt-1 text-sm text-red-500">{error}</p>
)}
</div>
);
}

View File

@ -0,0 +1,4 @@
export { default as FormField } from './FormField';
export { default as FormTextarea } from './FormTextarea';
export { default as FormSelect } from './FormSelect';
export { default as FormCheckbox } from './FormCheckbox';

43
components/ui/Badge.tsx Normal file
View File

@ -0,0 +1,43 @@
import React from 'react';
import { cn } from '@/utils/helpers';
interface BadgeProps {
children: React.ReactNode;
variant?: 'default' | 'success' | 'danger' | 'warning' | 'info';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export const Badge: React.FC<BadgeProps> = ({
children,
variant = 'default',
size = 'md',
className,
}) => {
const variants = {
default: 'bg-gray-100 text-gray-800',
success: 'bg-green-100 text-green-800',
danger: 'bg-red-100 text-red-800',
warning: 'bg-yellow-100 text-yellow-800',
info: 'bg-blue-100 text-blue-800',
};
const sizes = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-2.5 py-1 text-sm',
lg: 'px-3 py-1.5 text-base',
};
return (
<span
className={cn(
'inline-flex items-center font-medium rounded-full',
variants[variant],
sizes[size],
className
)}
>
{children}
</span>
);
};

84
components/ui/Card.tsx Normal file
View File

@ -0,0 +1,84 @@
import React from 'react';
import { cn } from '@/utils/helpers';
interface CardProps {
children: React.ReactNode;
className?: string;
padding?: 'none' | 'sm' | 'md' | 'lg';
hover?: boolean;
}
export const Card: React.FC<CardProps> = ({
children,
className,
padding = 'md',
hover = false,
}) => {
const paddingClasses = {
none: '',
sm: 'p-3',
md: 'p-6',
lg: 'p-8',
};
return (
<div
className={cn(
'bg-white rounded-lg shadow-md border border-gray-200',
paddingClasses[padding],
hover && 'transition-shadow hover:shadow-lg',
className
)}
>
{children}
</div>
);
};
interface CardHeaderProps {
children: React.ReactNode;
className?: string;
}
export const CardHeader: React.FC<CardHeaderProps> = ({ children, className }) => {
return (
<div className={cn('mb-4', className)}>
{children}
</div>
);
};
interface CardTitleProps {
children: React.ReactNode;
className?: string;
}
export const CardTitle: React.FC<CardTitleProps> = ({ children, className }) => {
return (
<h3 className={cn('text-xl font-semibold text-gray-900', className)}>
{children}
</h3>
);
};
interface CardContentProps {
children: React.ReactNode;
className?: string;
}
export const CardContent: React.FC<CardContentProps> = ({ children, className }) => {
return <div className={cn('text-gray-700', className)}>{children}</div>;
};
interface CardFooterProps {
children: React.ReactNode;
className?: string;
}
export const CardFooter: React.FC<CardFooterProps> = ({ children, className }) => {
return (
<div className={cn('mt-4 pt-4 border-t border-gray-200', className)}>
{children}
</div>
);
};

58
components/ui/Input.tsx Normal file
View File

@ -0,0 +1,58 @@
import React, { InputHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/utils/helpers';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, helperText, className, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label
htmlFor={props.id}
className="block text-sm font-medium text-gray-700 mb-1"
>
{label}
{props.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<input
ref={ref}
className={cn(
'w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 transition-colors',
error
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500 focus:border-blue-500',
props.disabled && 'bg-gray-100 cursor-not-allowed',
className
)}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={
error ? `${props.id}-error` : helperText ? `${props.id}-helper` : undefined
}
{...props}
/>
{error && (
<p
id={`${props.id}-error`}
className="mt-1 text-sm text-red-600"
role="alert"
>
{error}
</p>
)}
{!error && helperText && (
<p id={`${props.id}-helper`} className="mt-1 text-sm text-gray-500">
{helperText}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';

56
components/ui/Loading.tsx Normal file
View File

@ -0,0 +1,56 @@
import React from 'react';
import { cn } from '@/utils/helpers';
interface LoadingProps {
size?: 'sm' | 'md' | 'lg';
fullScreen?: boolean;
text?: string;
}
export const Loading: React.FC<LoadingProps> = ({
size = 'md',
fullScreen = false,
text,
}) => {
const sizes = {
sm: 'w-6 h-6',
md: 'w-12 h-12',
lg: 'w-16 h-16',
};
const spinner = (
<div className="flex flex-col items-center justify-center gap-3">
<svg
className={cn('animate-spin text-blue-600', sizes[size])}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{text && <p className="text-gray-600 text-sm">{text}</p>}
</div>
);
if (fullScreen) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-white bg-opacity-90 z-50">
{spinner}
</div>
);
}
return <div className="flex items-center justify-center p-8">{spinner}</div>;
};

107
components/ui/Modal.tsx Normal file
View File

@ -0,0 +1,107 @@
'use client';
import React, { useEffect, useRef } from 'react';
import { cn } from '@/utils/helpers';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
showCloseButton?: boolean;
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
size = 'md',
showCloseButton = true,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = 'unset';
};
}, [isOpen, onClose]);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
if (!isOpen) return null;
const sizes = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined}
>
<div
ref={modalRef}
className={cn(
'relative w-full bg-white rounded-lg shadow-xl animate-fade-in',
sizes[size]
)}
>
{(title || showCloseButton) && (
<div className="flex items-center justify-between p-4 border-b">
{title && (
<h2 id="modal-title" className="text-xl font-semibold text-gray-900">
{title}
</h2>
)}
{showCloseButton && (
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Fermer"
>
<svg
className="w-6 h-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
)}
<div className="p-6">{children}</div>
</div>
</div>
);
};

94
components/ui/Table.tsx Normal file
View File

@ -0,0 +1,94 @@
import React from 'react';
import { cn } from '@/utils/helpers';
interface TableProps {
children: React.ReactNode;
className?: string;
}
export const Table: React.FC<TableProps> = ({ children, className }) => {
return (
<div className="overflow-x-auto">
<table className={cn('min-w-full divide-y divide-gray-200', className)}>
{children}
</table>
</div>
);
};
interface TableHeaderProps {
children: React.ReactNode;
className?: string;
}
export const TableHeader: React.FC<TableHeaderProps> = ({ children, className }) => {
return (
<thead className={cn('bg-gray-50', className)}>
{children}
</thead>
);
};
interface TableBodyProps {
children: React.ReactNode;
className?: string;
}
export const TableBody: React.FC<TableBodyProps> = ({ children, className }) => {
return (
<tbody className={cn('bg-white divide-y divide-gray-200', className)}>
{children}
</tbody>
);
};
interface TableRowProps {
children: React.ReactNode;
className?: string;
onClick?: () => void;
}
export const TableRow: React.FC<TableRowProps> = ({ children, className, onClick }) => {
return (
<tr
className={cn(
onClick && 'cursor-pointer hover:bg-gray-50 transition-colors',
className
)}
onClick={onClick}
>
{children}
</tr>
);
};
interface TableHeadProps {
children: React.ReactNode;
className?: string;
}
export const TableHead: React.FC<TableHeadProps> = ({ children, className }) => {
return (
<th
className={cn(
'px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider',
className
)}
>
{children}
</th>
);
};
interface TableCellProps {
children: React.ReactNode;
className?: string;
}
export const TableCell: React.FC<TableCellProps> = ({ children, className }) => {
return (
<td className={cn('px-6 py-4 whitespace-nowrap text-sm text-gray-900', className)}>
{children}
</td>
);
};

6
components/ui/index.ts Normal file
View File

@ -0,0 +1,6 @@
export { Input } from './Input';
export { Modal } from './Modal';
export { Card, CardHeader, CardTitle, CardContent, CardFooter } from './Card';
export { Loading } from './Loading';
export { Badge } from './Badge';
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './Table';

187
contexts/AuthContext.tsx Normal file
View File

@ -0,0 +1,187 @@
'use client';
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { User, LoginCredentials, RegisterData, AuthResponse } from '@/types';
import { authService } from '@/services/auth.service';
import { setToken, removeToken, getToken, storage } from '@/utils/helpers';
import { STORAGE_KEYS, ROUTES } from '@/utils/constants';
import toast from 'react-hot-toast';
interface AuthContextType {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (credentials: LoginCredentials) => Promise<void>;
register: (data: RegisterData) => Promise<void>;
logout: () => Promise<void>;
googleLogin: (token: string) => Promise<void>;
facebookLogin: (token: string) => Promise<void>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
interface AuthProviderProps {
children: React.ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
const loadUser = useCallback(async () => {
const token = getToken();
if (!token) {
setIsLoading(false);
return;
}
try {
const userData = await authService.getCurrentUser();
setUser(userData);
storage.set(STORAGE_KEYS.USER, JSON.stringify(userData));
} catch (error) {
console.error('Error loading user:', error);
removeToken();
storage.remove(STORAGE_KEYS.USER);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadUser();
}, [loadUser]);
const login = async (credentials: LoginCredentials) => {
try {
const response: AuthResponse = await authService.login(credentials);
setToken(response.token);
setUser(response.user);
storage.set(STORAGE_KEYS.USER, JSON.stringify(response.user));
toast.success('Connexion réussie !');
// Redirect based on role (support both uppercase and lowercase)
const role = response.user.role.toUpperCase();
if (role === 'ADMIN') {
router.push(ROUTES.ADMIN_DASHBOARD);
} else if (role === 'EMPLOYEE') {
router.push(ROUTES.EMPLOYEE_DASHBOARD);
} else {
router.push(ROUTES.CLIENT_DASHBOARD);
}
} catch (error: any) {
toast.error(error.message || 'Erreur lors de la connexion');
throw error;
}
};
const register = async (data: RegisterData) => {
try {
const response: AuthResponse = await authService.register(data);
setToken(response.token);
setUser(response.user);
storage.set(STORAGE_KEYS.USER, JSON.stringify(response.user));
toast.success('Inscription réussie !');
router.push(ROUTES.CLIENT_DASHBOARD);
} catch (error: any) {
toast.error(error.message || 'Erreur lors de l\'inscription');
throw error;
}
};
const logout = async () => {
try {
await authService.logout();
} catch (error) {
console.error('Error during logout:', error);
} finally {
removeToken();
storage.remove(STORAGE_KEYS.USER);
setUser(null);
toast.success('Déconnexion réussie');
router.push(ROUTES.LOGIN);
}
};
const googleLogin = async (token: string) => {
try {
const response: AuthResponse = await authService.googleLogin(token);
setToken(response.token);
setUser(response.user);
storage.set(STORAGE_KEYS.USER, JSON.stringify(response.user));
toast.success('Connexion avec Google réussie !');
// Redirect based on role (support both uppercase and lowercase)
const role = response.user.role.toUpperCase();
if (role === 'ADMIN') {
router.push(ROUTES.ADMIN_DASHBOARD);
} else if (role === 'EMPLOYEE') {
router.push(ROUTES.EMPLOYEE_DASHBOARD);
} else {
router.push(ROUTES.CLIENT_DASHBOARD);
}
} catch (error: any) {
toast.error(error.message || 'Erreur lors de la connexion avec Google');
throw error;
}
};
const facebookLogin = async (token: string) => {
try {
const response: AuthResponse = await authService.facebookLogin(token);
setToken(response.token);
setUser(response.user);
storage.set(STORAGE_KEYS.USER, JSON.stringify(response.user));
toast.success('Connexion avec Facebook réussie !');
// Redirect based on role (support both uppercase and lowercase)
const role = response.user.role.toUpperCase();
if (role === 'ADMIN') {
router.push(ROUTES.ADMIN_DASHBOARD);
} else if (role === 'EMPLOYEE') {
router.push(ROUTES.EMPLOYEE_DASHBOARD);
} else {
router.push(ROUTES.CLIENT_DASHBOARD);
}
} catch (error: any) {
toast.error(error.message || 'Erreur lors de la connexion avec Facebook');
throw error;
}
};
const refreshUser = async () => {
try {
const userData = await authService.getCurrentUser();
setUser(userData);
storage.set(STORAGE_KEYS.USER, JSON.stringify(userData));
} catch (error) {
console.error('Error refreshing user:', error);
throw error;
}
};
const value: AuthContextType = {
user,
isLoading,
isAuthenticated: !!user,
login,
register,
logout,
googleLogin,
facebookLogin,
refreshUser,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

View File

@ -0,0 +1,202 @@
-- ============================================
-- Script pour créer des tickets de test
-- ============================================
-- Ce script crée 10 tickets de test avec différents statuts
-- Exécutez ce script dans votre base de données backend
-- PRÉREQUIS :
-- 1. Vous devez avoir au moins 1 utilisateur dans la table users
-- 2. Vous devez avoir au moins 1 prix dans la table prizes
-- ============================================
-- Étape 1 : Vérifier que vous avez des utilisateurs et des prix
-- ============================================
SELECT 'Nombre d''utilisateurs :' as info, COUNT(*) as count FROM users;
SELECT 'Nombre de prix :' as info, COUNT(*) as count FROM prizes;
-- Si le résultat est 0, vous devez d'abord créer des utilisateurs et des prix !
-- ============================================
-- Étape 2 : Créer des tickets de test
-- ============================================
-- IMPORTANT : Adaptez ce script selon votre schéma de base de données
-- Les noms de colonnes peuvent varier (ex: user_id vs userId)
-- Ticket 1 : En attente
INSERT INTO tickets (code, user_id, prize_id, status, played_at, created_at, updated_at)
SELECT
'TEST-PENDING-001',
(SELECT id FROM users ORDER BY RANDOM() LIMIT 1),
(SELECT id FROM prizes ORDER BY RANDOM() LIMIT 1),
'PENDING',
NOW() - INTERVAL '2 days',
NOW(),
NOW()
WHERE EXISTS (SELECT 1 FROM users) AND EXISTS (SELECT 1 FROM prizes);
-- Ticket 2 : Validé
INSERT INTO tickets (code, user_id, prize_id, status, played_at, validated_at, created_at, updated_at)
SELECT
'TEST-VALIDATED-002',
(SELECT id FROM users ORDER BY RANDOM() LIMIT 1),
(SELECT id FROM prizes ORDER BY RANDOM() LIMIT 1),
'VALIDATED',
NOW() - INTERVAL '3 days',
NOW() - INTERVAL '1 day',
NOW(),
NOW()
WHERE EXISTS (SELECT 1 FROM users) AND EXISTS (SELECT 1 FROM prizes);
-- Ticket 3 : Rejeté
INSERT INTO tickets (code, user_id, prize_id, status, played_at, rejection_reason, created_at, updated_at)
SELECT
'TEST-REJECTED-003',
(SELECT id FROM users ORDER BY RANDOM() LIMIT 1),
(SELECT id FROM prizes ORDER BY RANDOM() LIMIT 1),
'REJECTED',
NOW() - INTERVAL '4 days',
'Ticket illisible',
NOW(),
NOW()
WHERE EXISTS (SELECT 1 FROM users) AND EXISTS (SELECT 1 FROM prizes);
-- Ticket 4 : Récupéré
INSERT INTO tickets (code, user_id, prize_id, status, played_at, validated_at, created_at, updated_at)
SELECT
'TEST-CLAIMED-004',
(SELECT id FROM users ORDER BY RANDOM() LIMIT 1),
(SELECT id FROM prizes ORDER BY RANDOM() LIMIT 1),
'CLAIMED',
NOW() - INTERVAL '5 days',
NOW() - INTERVAL '2 days',
NOW(),
NOW()
WHERE EXISTS (SELECT 1 FROM users) AND EXISTS (SELECT 1 FROM prizes);
-- Ticket 5 : En attente
INSERT INTO tickets (code, user_id, prize_id, status, played_at, created_at, updated_at)
SELECT
'TEST-PENDING-005',
(SELECT id FROM users ORDER BY RANDOM() LIMIT 1),
(SELECT id FROM prizes ORDER BY RANDOM() LIMIT 1),
'PENDING',
NOW() - INTERVAL '1 day',
NOW(),
NOW()
WHERE EXISTS (SELECT 1 FROM users) AND EXISTS (SELECT 1 FROM prizes);
-- Ticket 6 : Validé
INSERT INTO tickets (code, user_id, prize_id, status, played_at, validated_at, created_at, updated_at)
SELECT
'TEST-VALIDATED-006',
(SELECT id FROM users ORDER BY RANDOM() LIMIT 1),
(SELECT id FROM prizes ORDER BY RANDOM() LIMIT 1),
'VALIDATED',
NOW() - INTERVAL '6 days',
NOW() - INTERVAL '3 days',
NOW(),
NOW()
WHERE EXISTS (SELECT 1 FROM users) AND EXISTS (SELECT 1 FROM prizes);
-- Ticket 7 : En attente
INSERT INTO tickets (code, user_id, prize_id, status, played_at, created_at, updated_at)
SELECT
'TEST-PENDING-007',
(SELECT id FROM users ORDER BY RANDOM() LIMIT 1),
(SELECT id FROM prizes ORDER BY RANDOM() LIMIT 1),
'PENDING',
NOW(),
NOW(),
NOW()
WHERE EXISTS (SELECT 1 FROM users) AND EXISTS (SELECT 1 FROM prizes);
-- Ticket 8 : Validé
INSERT INTO tickets (code, user_id, prize_id, status, played_at, validated_at, created_at, updated_at)
SELECT
'TEST-VALIDATED-008',
(SELECT id FROM users ORDER BY RANDOM() LIMIT 1),
(SELECT id FROM prizes ORDER BY RANDOM() LIMIT 1),
'VALIDATED',
NOW() - INTERVAL '7 days',
NOW() - INTERVAL '4 days',
NOW(),
NOW()
WHERE EXISTS (SELECT 1 FROM users) AND EXISTS (SELECT 1 FROM prizes);
-- Ticket 9 : Récupéré
INSERT INTO tickets (code, user_id, prize_id, status, played_at, validated_at, created_at, updated_at)
SELECT
'TEST-CLAIMED-009',
(SELECT id FROM users ORDER BY RANDOM() LIMIT 1),
(SELECT id FROM prizes ORDER BY RANDOM() LIMIT 1),
'CLAIMED',
NOW() - INTERVAL '8 days',
NOW() - INTERVAL '5 days',
NOW(),
NOW()
WHERE EXISTS (SELECT 1 FROM users) AND EXISTS (SELECT 1 FROM prizes);
-- Ticket 10 : En attente
INSERT INTO tickets (code, user_id, prize_id, status, played_at, created_at, updated_at)
SELECT
'TEST-PENDING-010',
(SELECT id FROM users ORDER BY RANDOM() LIMIT 1),
(SELECT id FROM prizes ORDER BY RANDOM() LIMIT 1),
'PENDING',
NOW() - INTERVAL '12 hours',
NOW(),
NOW()
WHERE EXISTS (SELECT 1 FROM users) AND EXISTS (SELECT 1 FROM prizes);
-- ============================================
-- Étape 3 : Vérifier que les tickets ont été créés
-- ============================================
SELECT
'Tickets créés avec succès !' as message,
COUNT(*) as total_tickets
FROM tickets
WHERE code LIKE 'TEST-%';
-- Afficher les tickets créés
SELECT
code,
status,
played_at,
created_at
FROM tickets
WHERE code LIKE 'TEST-%'
ORDER BY created_at DESC;
-- ============================================
-- NOTES IMPORTANTES
-- ============================================
-- Si vous utilisez UUID au lieu d'auto-increment :
-- Ajoutez la colonne id avec gen_random_uuid() ou uuid_generate_v4()
-- Exemple avec UUID :
/*
INSERT INTO tickets (id, code, user_id, prize_id, status, played_at, created_at, updated_at)
SELECT
gen_random_uuid(),
'TEST-UUID-001',
(SELECT id FROM users ORDER BY RANDOM() LIMIT 1),
(SELECT id FROM prizes ORDER BY RANDOM() LIMIT 1),
'PENDING',
NOW(),
NOW(),
NOW()
WHERE EXISTS (SELECT 1 FROM users) AND EXISTS (SELECT 1 FROM prizes);
*/
-- Si vos colonnes utilisent camelCase (userId au lieu de user_id) :
-- Utilisez des guillemets doubles : "userId", "prizeId", etc.
-- ============================================
-- Pour supprimer tous les tickets de test :
-- ============================================
-- DELETE FROM tickets WHERE code LIKE 'TEST-%';

18
eslint.config.js Normal file
View File

@ -0,0 +1,18 @@
const { defineConfig, globalIgnores } = require("eslint/config");
const nextVitals = require("eslint-config-next/core-web-vitals");
const nextTs = require("eslint-config-next/typescript");
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
module.exports = eslintConfig;

4
hooks/index.ts Normal file
View File

@ -0,0 +1,4 @@
export { useAuth } from '@/contexts/AuthContext';
export { useForm } from './useForm';
export { useToast } from './useToast';
export { useGame } from './useGame';

17
hooks/useAuth.ts Normal file
View File

@ -0,0 +1,17 @@
import { useContext } from 'react';
import { AuthContext } from '@/contexts/AuthContext';
/**
* Hook personnalisé pour accéder au contexte d'authentification
* @returns Contexte d'authentification avec user, login, register, logout, etc.
* @throws Error si utilisé en dehors du AuthProvider
*/
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth doit être utilisé à l\'intérieur d\'un AuthProvider');
}
return context;
}

149
hooks/useForm.ts Normal file
View File

@ -0,0 +1,149 @@
import { useState, ChangeEvent, FormEvent } from 'react';
interface UseFormOptions<T> {
initialValues: T;
onSubmit: (values: T) => void | Promise<void>;
validate?: (values: T) => Partial<Record<keyof T, string>>;
}
/**
* Hook personnalisé pour gérer les formulaires
* @param options Configuration du formulaire (initialValues, onSubmit, validate)
* @returns Méthodes et états pour gérer le formulaire
*/
export function useForm<T extends Record<string, any>>({
initialValues,
onSubmit,
validate,
}: UseFormOptions<T>) {
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
/**
* Gère le changement de valeur d'un champ
*/
const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const { name, value, type } = e.target;
const fieldValue =
type === 'checkbox' ? (e.target as HTMLInputElement).checked : value;
setValues((prev) => ({
...prev,
[name]: fieldValue,
}));
// Valider le champ si une fonction de validation est fournie
if (validate && touched[name as keyof T]) {
const validationErrors = validate({
...values,
[name]: fieldValue,
});
setErrors((prev) => ({
...prev,
[name]: validationErrors[name as keyof T],
}));
}
};
/**
* Marque un champ comme touché (pour afficher les erreurs)
*/
const handleBlur = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const { name } = e.target;
setTouched((prev) => ({
...prev,
[name]: true,
}));
// Valider le champ au blur
if (validate) {
const validationErrors = validate(values);
setErrors((prev) => ({
...prev,
[name]: validationErrors[name as keyof T],
}));
}
};
/**
* Gère la soumission du formulaire
*/
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
// Marquer tous les champs comme touchés
const allTouched = Object.keys(values).reduce(
(acc, key) => ({ ...acc, [key]: true }),
{}
);
setTouched(allTouched);
// Valider tous les champs
if (validate) {
const validationErrors = validate(values);
setErrors(validationErrors);
// Ne pas soumettre si des erreurs existent
if (Object.keys(validationErrors).length > 0) {
return;
}
}
// Soumettre le formulaire
setIsSubmitting(true);
try {
await onSubmit(values);
} finally {
setIsSubmitting(false);
}
};
/**
* Réinitialise le formulaire
*/
const reset = () => {
setValues(initialValues);
setErrors({});
setTouched({});
setIsSubmitting(false);
};
/**
* Définit manuellement une valeur
*/
const setValue = (name: keyof T, value: any) => {
setValues((prev) => ({
...prev,
[name]: value,
}));
};
/**
* Définit manuellement une erreur
*/
const setError = (name: keyof T, error: string) => {
setErrors((prev) => ({
...prev,
[name]: error,
}));
};
return {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit,
reset,
setValue,
setError,
};
}

59
hooks/useGame.ts Normal file
View File

@ -0,0 +1,59 @@
import { useState } from 'react';
import { gameService } from '@/services/game.service';
import { PlayGameResponse, Ticket, PaginatedResponse } from '@/types';
import toast from 'react-hot-toast';
export const useGame = () => {
const [isPlaying, setIsPlaying] = useState(false);
const [isLoadingTickets, setIsLoadingTickets] = useState(false);
const play = async (ticketCode: string): Promise<PlayGameResponse | null> => {
setIsPlaying(true);
try {
const result = await gameService.play(ticketCode);
toast.success(result.message || 'Vous avez gagné !');
return result;
} catch (error: any) {
console.error('🎲 [useGame] Erreur lors du jeu:', error);
const errorMessage = error.message || error.data?.message || 'Erreur lors de la participation';
toast.error(errorMessage);
return null;
} finally {
setIsPlaying(false);
}
};
const getMyTickets = async (
page = 1,
limit = 10
): Promise<PaginatedResponse<Ticket> | null> => {
setIsLoadingTickets(true);
try {
const tickets = await gameService.getMyTickets(page, limit);
return tickets;
} catch (error: any) {
toast.error(error.message || 'Erreur lors du chargement des tickets');
return null;
} finally {
setIsLoadingTickets(false);
}
};
const getTicketDetails = async (ticketId: string): Promise<Ticket | null> => {
try {
const ticket = await gameService.getTicketDetails(ticketId);
return ticket;
} catch (error: any) {
toast.error(error.message || 'Erreur lors du chargement du ticket');
return null;
}
};
return {
play,
getMyTickets,
getTicketDetails,
isPlaying,
isLoadingTickets,
};
};

110
hooks/useToast.ts Normal file
View File

@ -0,0 +1,110 @@
import toast, { Toast, ToastOptions } from 'react-hot-toast';
/**
* Hook personnalisé pour gérer les notifications toast
* Wrapper autour de react-hot-toast avec des méthodes simplifiées
*/
export function useToast() {
/**
* Affiche une notification de succès
*/
const success = (message: string, options?: ToastOptions) => {
return toast.success(message, {
duration: 4000,
position: 'top-right',
...options,
});
};
/**
* Affiche une notification d'erreur
*/
const error = (message: string, options?: ToastOptions) => {
return toast.error(message, {
duration: 5000,
position: 'top-right',
...options,
});
};
/**
* Affiche une notification d'information
*/
const info = (message: string, options?: ToastOptions) => {
return toast(message, {
duration: 4000,
position: 'top-right',
icon: '',
...options,
});
};
/**
* Affiche une notification d'avertissement
*/
const warning = (message: string, options?: ToastOptions) => {
return toast(message, {
duration: 4000,
position: 'top-right',
icon: '⚠️',
...options,
});
};
/**
* Affiche une notification de chargement
*/
const loading = (message: string, options?: ToastOptions) => {
return toast.loading(message, {
position: 'top-right',
...options,
});
};
/**
* Affiche une notification promise (loading -> success/error)
*/
const promise = <T,>(
promise: Promise<T>,
messages: {
loading: string;
success: string | ((data: T) => string);
error: string | ((error: any) => string);
},
options?: ToastOptions
) => {
return toast.promise(
promise,
messages,
{
position: 'top-right',
...options,
}
);
};
/**
* Ferme une notification spécifique
*/
const dismiss = (toastId?: string) => {
toast.dismiss(toastId);
};
/**
* Ferme toutes les notifications
*/
const dismissAll = () => {
toast.dismiss();
};
return {
success,
error,
info,
warning,
loading,
promise,
dismiss,
dismissAll,
};
}

34
jest.config.js Normal file
View File

@ -0,0 +1,34 @@
const nextJest = require('next/jest');
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
});
// Add any custom config to be passed to Jest
const customJestConfig = {
// Add more setup options before each test is run
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
// Handle module aliases (this will be automatically configured for you soon)
'^@/(.*)$': '<rootDir>/$1',
},
testMatch: [
'**/__tests__/**/*.[jt]s?(x)',
'**/?(*.)+(spec|test).[jt]s?(x)',
],
collectCoverageFrom: [
'app/**/*.{js,jsx,ts,tsx}',
'components/**/*.{js,jsx,ts,tsx}',
'services/**/*.{js,jsx,ts,tsx}',
'utils/**/*.{js,jsx,ts,tsx}',
'!**/*.d.ts',
'!**/node_modules/**',
'!**/.next/**',
'!**/coverage/**',
],
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig);

26
jest.setup.js Normal file
View File

@ -0,0 +1,26 @@
// Learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
// Mock Next.js router
jest.mock('next/navigation', () => ({
useRouter() {
return {
push: jest.fn(),
replace: jest.fn(),
prefetch: jest.fn(),
back: jest.fn(),
pathname: '/',
query: {},
asPath: '/',
};
},
usePathname() {
return '/';
},
useSearchParams() {
return new URLSearchParams();
},
}));
// Mock environment variables
process.env.NEXT_PUBLIC_API_URL = 'http://localhost:4000/api';

0
lib/auth.ts Normal file
View File

71
lib/facebook-sdk.ts Normal file
View File

@ -0,0 +1,71 @@
/**
* Initialisation du SDK Facebook
*/
declare global {
interface Window {
FB: any;
fbAsyncInit: () => void;
}
}
export const initFacebookSDK = (): Promise<void> => {
return new Promise((resolve) => {
// Si le SDK est déjà chargé
if (window.FB) {
resolve();
return;
}
// Définir la fonction de callback
window.fbAsyncInit = function() {
window.FB.init({
appId: process.env.NEXT_PUBLIC_FACEBOOK_APP_ID,
cookie: true,
xfbml: true,
version: 'v18.0'
});
resolve();
};
// Charger le SDK
(function(d, s, id) {
const fjs = d.getElementsByTagName(s)[0];
if (d.getElementById(id)) return;
const js = d.createElement(s) as HTMLScriptElement;
js.id = id;
js.src = "https://connect.facebook.net/fr_FR/sdk.js";
fjs?.parentNode?.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));
});
};
export const loginWithFacebook = (): Promise<string> => {
return new Promise((resolve, reject) => {
if (!window.FB) {
reject(new Error('Facebook SDK not loaded'));
return;
}
window.FB.login((response: any) => {
if (response.authResponse) {
resolve(response.authResponse.accessToken);
} else {
reject(new Error('Facebook login cancelled'));
}
}, { scope: 'email,public_profile' });
});
};
export const getFacebookLoginStatus = (): Promise<any> => {
return new Promise((resolve, reject) => {
if (!window.FB) {
reject(new Error('Facebook SDK not loaded'));
return;
}
window.FB.getLoginStatus((response: any) => {
resolve(response);
});
});
};

106
lib/validations.ts Normal file
View File

@ -0,0 +1,106 @@
import { z } from 'zod';
// Login Schema
export const loginSchema = z.object({
email: z
.string()
.min(1, 'L\'email est requis')
.email('Email invalide'),
password: z
.string()
.min(1, 'Le mot de passe est requis')
.min(8, 'Le mot de passe doit contenir au moins 8 caractères'),
});
export type LoginFormData = z.infer<typeof loginSchema>;
// Register Schema
export const registerSchema = z.object({
email: z
.string()
.min(1, 'L\'email est requis')
.email('Email invalide'),
password: z
.string()
.min(1, 'Le mot de passe est requis')
.min(8, 'Le mot de passe doit contenir au moins 8 caractères')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre'
),
confirmPassword: z
.string()
.min(1, 'Veuillez confirmer le mot de passe'),
firstName: z
.string()
.min(1, 'Le prénom est requis')
.min(2, 'Le prénom doit contenir au moins 2 caractères'),
lastName: z
.string()
.min(1, 'Le nom est requis')
.min(2, 'Le nom doit contenir au moins 2 caractères'),
phone: z
.string()
.optional()
.refine((val) => !val || /^(\+33|0)[1-9](\d{2}){4}$/.test(val), {
message: 'Numéro de téléphone invalide',
}),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Les mots de passe ne correspondent pas',
path: ['confirmPassword'],
});
export type RegisterFormData = z.infer<typeof registerSchema>;
// Ticket Code Schema
export const ticketCodeSchema = z.object({
ticketCode: z
.string()
.min(1, 'Le code du ticket est requis')
.length(10, 'Le code doit contenir exactement 10 caractères')
.regex(/^[A-Z0-9]{10}$/, 'Le code doit contenir uniquement des lettres majuscules et des chiffres'),
});
export type TicketCodeFormData = z.infer<typeof ticketCodeSchema>;
// Profile Update Schema
export const profileUpdateSchema = z.object({
firstName: z
.string()
.min(1, 'Le prénom est requis')
.min(2, 'Le prénom doit contenir au moins 2 caractères'),
lastName: z
.string()
.min(1, 'Le nom est requis')
.min(2, 'Le nom doit contenir au moins 2 caractères'),
phone: z
.string()
.optional()
.refine((val) => !val || /^(\+33|0)[1-9](\d{2}){4}$/.test(val), {
message: 'Numéro de téléphone invalide',
}),
});
export type ProfileUpdateFormData = z.infer<typeof profileUpdateSchema>;
// Password Change Schema
export const passwordChangeSchema = z.object({
currentPassword: z
.string()
.min(1, 'Le mot de passe actuel est requis'),
newPassword: z
.string()
.min(8, 'Le nouveau mot de passe doit contenir au moins 8 caractères')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre'
),
confirmNewPassword: z
.string()
.min(1, 'Veuillez confirmer le nouveau mot de passe'),
}).refine((data) => data.newPassword === data.confirmNewPassword, {
message: 'Les mots de passe ne correspondent pas',
path: ['confirmNewPassword'],
});
export type PasswordChangeFormData = z.infer<typeof passwordChangeSchema>;

42
middleware.ts Normal file
View File

@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Routes only accessible when not authenticated
const authRoutes = ['/login', '/register'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Get token from cookies or headers
const token = request.cookies.get('auth_token')?.value ||
request.headers.get('authorization')?.replace('Bearer ', '');
// Check if route is auth route
const isAuthRoute = authRoutes.some(route => pathname.startsWith(route));
// If accessing auth routes with token in cookies, redirect to home
// Note: We only check cookies here, not localStorage
// Client-side protection is handled by the components themselves
if (isAuthRoute && token) {
return NextResponse.redirect(new URL('/', request.url));
}
// Allow all other routes to pass through
// Authentication will be handled on the client side by the components
// This is necessary because tokens stored in localStorage are not accessible in middleware
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public files (public folder)
*/
'/((?!api|_next/static|_next/image|favicon.ico|.*\\..*|public).*)',
],
};

1685
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,22 +10,32 @@
"test": "echo \"No tests configured\" && exit 0"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@react-oauth/google": "^0.12.2",
"axios": "^1.13.1",
"bootstrap": "^5.3.3",
"lucide-react": "^0.553.0",
"next": "14.2.4",
"next-auth": "^4.24.7",
"nodemailer": "^7.0.10",
"react": "18.2.0",
"react-dom": "18.2.0"
"react-dom": "18.2.0",
"react-hook-form": "^7.66.0",
"react-hot-toast": "^2.6.0",
"recharts": "^3.4.1",
"zod": "^4.1.12"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "24.9.2",
"@types/react": "19.2.2",
"autoprefixer": "^10.4.21",
"eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.5.0",
"jiti": "^2.6.1",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.13",
"typescript": "5.9.3",
"typescript-eslint": "^8.46.3"
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

237
public/diagnostic.html Normal file
View File

@ -0,0 +1,237 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Diagnostic Token - Admin</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Courier New', monospace;
background: #1a1a1a;
color: #00ff00;
padding: 20px;
line-height: 1.6;
}
.container { max-width: 1200px; margin: 0 auto; }
h1 { color: #00ffff; margin-bottom: 20px; font-size: 24px; }
.section { background: #2a2a2a; padding: 20px; margin-bottom: 20px; border-radius: 8px; border: 1px solid #444; }
.section h2 { color: #ffff00; margin-bottom: 15px; font-size: 18px; }
.log { padding: 10px; margin: 5px 0; border-radius: 4px; }
.success { background: #1a4d1a; color: #00ff00; }
.error { background: #4d1a1a; color: #ff6b6b; }
.warning { background: #4d4d1a; color: #ffff00; }
.info { background: #1a1a4d; color: #6bb6ff; }
button {
background: #00ff00;
color: #000;
border: none;
padding: 12px 24px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
border-radius: 4px;
margin: 5px;
}
button:hover { background: #00cc00; }
.token-box {
background: #000;
padding: 15px;
border-radius: 4px;
word-break: break-all;
margin: 10px 0;
border: 1px solid #444;
}
pre { white-space: pre-wrap; word-wrap: break-word; }
</style>
</head>
<body>
<div class="container">
<h1>🔍 DIAGNOSTIC COMPLET - ERREUR 403 FORBIDDEN</h1>
<div class="section">
<h2>📋 ÉTAPE 1 : VÉRIFICATION LOCAL STORAGE</h2>
<div id="step1"></div>
<button onclick="checkLocalStorage()">Vérifier LocalStorage</button>
</div>
<div class="section">
<h2>🔧 ÉTAPE 2 : INSTALLATION DU BON TOKEN</h2>
<div id="step2"></div>
<button onclick="installToken()">Installer le Token avec la Bonne Clé</button>
</div>
<div class="section">
<h2>🧪 ÉTAPE 3 : TEST DE CONNEXION AU BACKEND</h2>
<div id="step3"></div>
<button onclick="testBackend()">Tester le Backend</button>
</div>
<div class="section">
<h2>📡 ÉTAPE 4 : TEST DE L'ENDPOINT TIRAGE</h2>
<div id="step4"></div>
<button onclick="testDrawEndpoint()">Tester l'Endpoint Tirage</button>
</div>
<div class="section">
<h2>🚀 ÉTAPE 5 : REDIRECTION VERS LA PAGE</h2>
<button onclick="goToTirage()">Aller à la Page de Tirage</button>
</div>
</div>
<script>
const CORRECT_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1YTIzOThkZi00NWNiLTQyOGQtOWY1ZS04YzQwNzQxNzEyNGIiLCJpYXQiOjE3NjMwODM0NjksImV4cCI6MTc2MzY4ODI2OX0.fCxlIzy-RkCqvCwjatHmIZ5pjqC61Vs-RAnZwulNd_Q';
function log(target, message, type = 'info') {
const div = document.getElementById(target);
const logDiv = document.createElement('div');
logDiv.className = `log ${type}`;
logDiv.innerHTML = message;
div.appendChild(logDiv);
}
function clear(target) {
document.getElementById(target).innerHTML = '';
}
function checkLocalStorage() {
clear('step1');
// Vérifier toutes les clés possibles
const keys = ['token', 'auth_token', 'user_data', 'authToken'];
log('step1', '📊 ANALYSE DU LOCAL STORAGE:', 'info');
keys.forEach(key => {
const value = localStorage.getItem(key);
if (value) {
const preview = value.length > 50 ? value.substring(0, 50) + '...' : value;
log('step1', `✅ Clé "${key}" trouvée: ${preview}`, 'success');
} else {
log('step1', `❌ Clé "${key}" NON trouvée`, 'error');
}
});
// Lister TOUTES les clés présentes
log('step1', '<br>📋 Toutes les clés présentes dans localStorage:', 'info');
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
log('step1', ` • ${key}`, 'info');
}
}
function installToken() {
clear('step2');
log('step2', '🔧 Installation du token avec TOUTES les clés possibles...', 'info');
try {
// Nettoyer d'abord
localStorage.clear();
sessionStorage.clear();
log('step2', '✅ LocalStorage et SessionStorage vidés', 'success');
// Installer avec TOUTES les clés possibles
localStorage.setItem('auth_token', CORRECT_TOKEN);
localStorage.setItem('token', CORRECT_TOKEN);
localStorage.setItem('authToken', CORRECT_TOKEN);
log('step2', '✅ Token installé avec les clés:', 'success');
log('step2', ' • auth_token ✓', 'success');
log('step2', ' • token ✓', 'success');
log('step2', ' • authToken ✓', 'success');
// Vérifier
const check = localStorage.getItem('auth_token');
if (check === CORRECT_TOKEN) {
log('step2', '<br>✅ VÉRIFICATION RÉUSSIE: Token correctement enregistré', 'success');
} else {
log('step2', '<br>❌ ERREUR: Token non enregistré correctement', 'error');
}
} catch (error) {
log('step2', `❌ ERREUR: ${error.message}`, 'error');
}
}
async function testBackend() {
clear('step3');
log('step3', '🧪 Test de connexion au backend...', 'info');
try {
const response = await fetch('http://localhost:4000');
const data = await response.json();
if (response.ok) {
log('step3', `✅ Backend accessible: ${data.message}`, 'success');
} else {
log('step3', `❌ Backend répond mais avec erreur: ${response.status}`, 'error');
}
} catch (error) {
log('step3', `❌ Backend NON accessible: ${error.message}`, 'error');
log('step3', '💡 Vérifiez que le backend est démarré sur le port 4000', 'warning');
}
}
async function testDrawEndpoint() {
clear('step4');
log('step4', '📡 Test de l\'endpoint /api/draw/eligible-participants...', 'info');
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
if (!token) {
log('step4', '❌ Aucun token trouvé! Installez d\'abord le token (Étape 2)', 'error');
return;
}
log('step4', `🔑 Token utilisé: ${token.substring(0, 50)}...`, 'info');
try {
log('step4', '<br>📤 Envoi de la requête...', 'info');
const response = await fetch('http://localhost:4000/api/draw/eligible-participants?minTickets=1&verified=true', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
log('step4', `📥 Réponse reçue: Status ${response.status} ${response.statusText}`,
response.ok ? 'success' : 'error');
const data = await response.json();
if (response.ok) {
log('step4', `<br>✅ SUCCÈS! ${data.data.total} participants trouvés:`, 'success');
data.data.participants.forEach((p, i) => {
log('step4', ` ${i+1}. ${p.first_name} ${p.last_name} - ${p.tickets_played} ticket(s)`, 'success');
});
log('step4', '<br>🎉 LE PROBLÈME EST RÉSOLU! Vous pouvez aller sur la page de tirage.', 'success');
} else {
log('step4', `<br>❌ ERREUR ${response.status}: ${data.message || data.error || 'Erreur inconnue'}`, 'error');
if (response.status === 403) {
log('step4', '<br>🔍 Diagnostic de l\'erreur 403:', 'warning');
log('step4', ' Causes possibles:', 'warning');
log('step4', ' 1. Token invalide ou expiré', 'warning');
log('step4', ' 2. Utilisateur n\'a pas le rôle ADMIN', 'warning');
log('step4', ' 3. JWT_SECRET différent entre frontend et backend', 'warning');
}
}
} catch (error) {
log('step4', `❌ ERREUR RÉSEAU: ${error.message}`, 'error');
log('step4', '💡 Le backend n\'est peut-être pas accessible', 'warning');
}
}
function goToTirage() {
window.location.href = 'http://localhost:3000/admin/tirages';
}
// Auto-exécution au chargement
window.addEventListener('load', () => {
setTimeout(() => {
checkLocalStorage();
}, 500);
});
</script>
</body>
</html>

56
public/favicon.svg Normal file
View File

@ -0,0 +1,56 @@
<!-- Generator: visioncortex VTracer 0.6.4 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 263" preserveAspectRatio="xMidYMid meet">
<path d="M0 0 C2.137 0.251 3.459 0.459 5 2 C5.25 4.375 5.25 4.375 5 7 C3.562 8.625 3.562 8.625 2 10 C-0.463 17.388 1.374 23.02 4.438 29.812 C5.083 31.298 5.727 32.784 6.371 34.27 C6.659 34.915 6.947 35.561 7.244 36.226 C7.909 37.786 8.464 39.391 9 41 C10.033 41.086 11.065 41.173 12.129 41.262 C21.205 42.1 30.131 43.428 39.062 45.25 C40.013 45.43 40.963 45.61 41.942 45.796 C49.702 47.456 55.753 50.665 61.312 56.375 C63.504 59.784 65.288 63.33 67 67 C67.461 66.613 67.923 66.227 68.398 65.828 C79.469 56.989 91.07 55.031 105 56 C119.133 57.689 119.133 57.689 124 62 C123.812 64.25 123.812 64.25 123 67 C122.259 67.548 121.518 68.096 120.754 68.66 C117.171 71.704 115.368 75.107 113.125 79.188 C109.504 85.548 105.777 91.43 101 97 C100.399 97.755 99.799 98.511 99.18 99.289 C91.187 108.964 81.289 114.503 69 117 C65.629 117.264 62.369 117.274 59 117 C58.598 118.106 58.598 118.106 58.188 119.234 C52.197 133.187 37.405 143.39 23.743 148.912 C2.437 156.899 -18.575 157.221 -39.879 148.434 C-53.175 142.326 -64.768 133.973 -72 121 C-72.45 120.197 -72.9 119.394 -73.363 118.566 C-79.501 106.96 -81.179 95.697 -81.188 82.688 C-81.2 81.706 -81.212 80.724 -81.225 79.713 C-81.259 62.418 -81.259 62.418 -76.062 56.688 C-68.192 48.948 -57.425 46.826 -46.938 44.688 C-45.735 44.424 -44.533 44.16 -43.295 43.889 C-36.435 42.448 -30.009 41.61 -23 42 C-23.231 41.304 -23.461 40.609 -23.699 39.892 C-26.151 32.378 -27.075 26.324 -23.98 18.871 C-22.421 15.895 -20.693 13.988 -18 12 C-15.188 12.188 -15.188 12.188 -13 13 C-12.25 14.688 -12.25 14.688 -12 17 C-13.875 19.562 -13.875 19.562 -16 22 C-18.483 29.449 -16.145 34.774 -13 41.5 C-10.31 47.253 -8.449 51.591 -9 58 C-7.02 57.67 -5.04 57.34 -3 57 C-2.783 55.577 -2.783 55.577 -2.562 54.125 C-2 51 -2 51 -1 49 C0.22 41.764 -1.417 36.422 -4.5 30 C-7.514 23.635 -9.682 18.146 -8 11 C-6.117 6.737 -3.537 3.056 0 0 Z M-37.562 51.688 C-38.515 51.865 -39.468 52.042 -40.45 52.224 C-46.472 53.392 -52.382 54.774 -58.25 56.562 C-58.911 56.76 -59.571 56.957 -60.252 57.161 C-63.786 58.308 -64.865 58.798 -67 62 C-61.929 64.9 -56.984 67.503 -51 67 C-48.778 66.189 -46.729 65.177 -44.613 64.121 C-36.38 60.589 -26.883 59.594 -18 59 C-17.066 54.469 -17.066 54.469 -18 50 C-24.744 49.815 -30.94 50.441 -37.562 51.688 Z M7 50 C5.515 53.96 5.515 53.96 4 58 C5.653 59.653 7.742 59.331 9.965 59.546 C17.554 60.285 24.43 61.778 31.594 64.411 C36.755 66.229 40.248 66.079 45.301 64.098 C45.965 63.756 46.629 63.414 47.312 63.062 C48.328 62.554 48.328 62.554 49.363 62.035 C51.171 61.089 51.171 61.089 52 59 C37.925 52.931 22.242 50.505 7 50 Z M-12 65 C-12.66 66.32 -13.32 67.64 -14 69 C5.992 69.895 5.992 69.895 26 70 C25.217 67.852 25.217 67.852 23.285 67.402 C11.5 64.938 -0.009 64.882 -12 65 Z M72.812 74.438 C71.061 76.914 69.386 79.3 68 82 C68.33 82.66 68.66 83.32 69 84 C69.801 83.004 69.801 83.004 70.617 81.988 C76.613 74.882 82.216 70.928 91 68 C92.408 67.92 93.82 67.892 95.23 67.902 C96.434 67.907 96.434 67.907 97.662 67.912 C98.495 67.92 99.329 67.929 100.188 67.938 C101.032 67.942 101.877 67.947 102.748 67.951 C104.832 67.963 106.916 67.981 109 68 C109.33 67.34 109.66 66.68 110 66 C105.893 64.631 102.052 64.776 97.75 64.75 C96.916 64.729 96.082 64.709 95.223 64.688 C86.33 64.632 78.889 67.667 72.812 74.438 Z M-41 69 C-41 69.33 -41 69.66 -41 70 C-38.646 70.197 -36.293 70.383 -33.938 70.562 C-31.971 70.719 -31.971 70.719 -29.965 70.879 C-26.077 70.998 -22.809 70.669 -19 70 C-19.66 68.68 -20.32 67.36 -21 66 C-28.081 65.591 -34.199 67.096 -41 69 Z M44 74 C41.777 76.937 41.777 76.937 41 80 C40.732 80.743 40.464 81.485 40.188 82.25 C40.095 83.116 40.095 83.116 40 84 C40.99 84.99 40.99 84.99 42 86 C44.603 85.68 44.603 85.68 47 85 C48.178 97.663 41.505 110.127 33.816 119.727 C24.969 129.344 12.751 135.641 -0.41 136.336 C-17.633 136.671 -34.037 133.285 -47.086 121.176 C-57.665 109.15 -61.672 95.735 -61 80 C-60.723 77.991 -60.418 75.984 -60 74 C-61.456 73.329 -62.915 72.663 -64.375 72 C-65.187 71.629 -65.999 71.257 -66.836 70.875 C-68.92 69.875 -68.92 69.875 -71 70 C-75.262 82.787 -71.866 101.418 -66.375 113.375 C-58.557 127.602 -45.303 137.883 -30 143.004 C-11.056 148.332 9.398 147.322 27 138 C42.141 128.586 52.645 116.466 57 99 C57.984 92.87 58.152 86.889 58.125 80.688 C58.129 79.795 58.133 78.902 58.137 77.982 C58.135 77.126 58.134 76.269 58.133 75.387 C58.131 74.23 58.131 74.23 58.129 73.05 C58.075 70.799 58.075 70.799 57 68 C52.372 68 47.745 71.5 44 74 Z M-48 77 C-46.515 77.99 -46.515 77.99 -45 79 C-45 78.34 -45 77.68 -45 77 C-45.99 77 -46.98 77 -48 77 Z M-55 80 C-55.858 91.677 -53.343 102.595 -46.562 112.23 C-44.876 114.14 -43.087 115.547 -41 117 C-41.899 114.679 -42.792 112.395 -43.953 110.191 C-48.053 102.098 -49 92.997 -49 84 C-49.619 83.711 -50.237 83.422 -50.875 83.125 C-53 82 -53 82 -55 80 Z " fill="#694633" transform="translate(160,27)"/>
<path d="M0 0 C3.064 5.587 1.843 10.745 0.316 16.641 C-3.952 29.919 -12.878 39.98 -25.125 46.375 C-36.183 51.86 -51.43 51.693 -63.188 48.375 C-68.572 46.427 -72.851 43.94 -77 40 C-77.812 39.23 -77.812 39.23 -78.641 38.445 C-86.874 29.755 -89.418 18.647 -90 7 C-89.539 4.559 -89.539 4.559 -89 3 C-87.783 3.433 -86.566 3.866 -85.312 4.312 C-73.563 8.16 -62.535 9.506 -50.25 9.375 C-49.128 9.37 -49.128 9.37 -47.984 9.364 C-32.765 9.277 -18.449 8.224 -4.625 1.312 C-2 0 -2 0 0 0 Z " fill="#C1C333" transform="translate(200,108)"/>
<path d="M0 0 C0.711 -0.008 1.422 -0.015 2.154 -0.023 C5.913 -0.009 7.611 0.116 10.812 2.25 C7.295 8.96 3.303 15.151 -1.188 21.25 C-2.116 22.542 -2.116 22.542 -3.062 23.859 C-10.785 34.135 -19.567 39.587 -32.188 42.25 C-35.074 42.348 -35.074 42.348 -37.188 42.25 C-34.761 28.792 -20.416 21.512 -9.925 14.221 C-8.893 13.509 -8.893 13.509 -7.84 12.781 C-7.2 12.338 -6.561 11.894 -5.902 11.438 C-4.5 10.466 -3.096 9.498 -1.691 8.531 C-1.195 8.108 -0.699 7.686 -0.188 7.25 C-0.188 6.59 -0.188 5.93 -0.188 5.25 C-9.873 6.668 -17.618 14.177 -24.684 20.517 C-27.7 23.182 -30.34 25.022 -34.188 26.25 C-33.532 19.302 -33.532 19.302 -30.188 16.25 C-29.143 14.981 -28.102 13.71 -27.062 12.438 C-19.637 3.913 -11.327 -0.125 0 0 Z " fill="#C3C434" transform="translate(259.1875,93.75)"/>
<path d="M0 0 C1.675 0.286 3.344 0.618 5 1 C6.188 13.767 -0.571 26.346 -8.406 35.949 C-17.628 45.89 -29.674 52.612 -43.41 53.336 C-44.961 53.366 -46.512 53.378 -48.062 53.375 C-48.886 53.373 -49.709 53.372 -50.558 53.37 C-58.518 53.213 -65.496 51.651 -73 49 C-73.869 48.7 -74.738 48.399 -75.633 48.09 C-78.382 46.952 -80.534 45.919 -81.918 43.191 C-82.5 41.25 -82.5 41.25 -83 38 C-81.515 37.505 -81.515 37.505 -80 37 C-77.906 38.109 -77.906 38.109 -75.5 39.75 C-66.982 45.005 -59.247 46.245 -49.375 46.312 C-48.194 46.321 -48.194 46.321 -46.989 46.33 C-35.117 46.183 -25.306 42.764 -16.41 34.633 C-7.183 24.776 -2.697 14.29 -1 1 C-0.67 0.67 -0.34 0.34 0 0 Z " fill="#684633" transform="translate(203,111)"/>
<path d="M0 0 C3.17 1.281 4.578 2.52 6.812 5.062 C8.575 9.423 8.785 13.368 8 18 C6.635 20.903 5.212 22.582 3 25 C-1.995 26.665 -7.008 26.866 -11.875 24.75 C-15.634 21.655 -17.114 19.453 -17.875 14.625 C-18.218 10.272 -17.548 7.652 -15 4 C-10.753 -0.89 -6.07 -0.82 0 0 Z M-8 7 C-10.327 10.491 -10.503 11.892 -10 16 C-8.125 18.377 -8.125 18.377 -6 20 C-2.75 18.912 -2.75 18.912 0 17 C0.229 12.983 0.229 12.983 0 9 C-1.887 6.693 -1.887 6.693 -5 6.688 C-5.99 6.791 -6.98 6.894 -8 7 Z " fill="#684736" transform="translate(293,200)"/>
<path d="M0 0 C2.625 0.375 2.625 0.375 5 1 C6.082 4.365 5.925 5.298 5 9 C7.97 9 10.94 9 14 9 C13.959 8.072 13.918 7.144 13.875 6.188 C14 3 14 3 16 0 C18.97 0.495 18.97 0.495 22 1 C22 8.92 22 16.84 22 25 C20.02 25.33 18.04 25.66 16 26 C13.571 22.356 13.838 20.288 14 16 C11.03 16 8.06 16 5 16 C5.206 16.907 5.412 17.815 5.625 18.75 C6.013 22.11 5.804 23.241 4 26 C1.563 25.625 1.563 25.625 -1 25 C-2.885 21.23 -2.185 16.648 -2.188 12.5 C-2.2 11.524 -2.212 10.548 -2.225 9.543 C-2.227 8.611 -2.228 7.679 -2.23 6.719 C-2.235 5.862 -2.239 5.006 -2.243 4.123 C-2 2 -2 2 0 0 Z " fill="#694837" transform="translate(63,200)"/>
<path d="M0 0 C0 0.66 0 1.32 0 2 C-13.17 9.489 -28.054 10.214 -42.879 10.295 C-44.322 10.307 -45.766 10.327 -47.209 10.357 C-57.411 10.566 -67.496 10.139 -77 6 C-78.41 3.861 -78.41 3.861 -79 2 C-77.914 2.069 -76.828 2.139 -75.708 2.21 C-64.677 2.886 -53.677 3.235 -42.625 3.25 C-41.825 3.252 -41.025 3.255 -40.2 3.257 C-29.108 3.269 -18.387 2.777 -7.465 0.656 C-4 0 -4 0 0 0 Z " fill="#C1C039" transform="translate(195,103)"/>
<path d="M0 0 C-0.375 1.938 -0.375 1.938 -1 4 C-1.99 4.495 -1.99 4.495 -3 5 C-0.525 5.99 -0.525 5.99 2 7 C2 8.65 2 10.3 2 12 C-0.709 13.354 -3.009 13.065 -6 13 C-5.107 15.358 -5.107 15.358 -1.938 16.125 C-0.483 16.558 -0.483 16.558 1 17 C1 18.32 1 19.64 1 21 C0 22 0 22 -3.062 22.062 C-4.032 22.042 -5.001 22.021 -6 22 C-6 22.99 -6 23.98 -6 25 C-4.298 24.969 -4.298 24.969 -2.562 24.938 C1 25 1 25 2 26 C2.041 27.666 2.043 29.334 2 31 C-0.58 32.29 -2.557 32.204 -5.438 32.25 C-6.406 32.276 -7.374 32.302 -8.371 32.328 C-11 32 -11 32 -12.646 30.785 C-14.429 28.435 -14.346 27.041 -14.293 24.113 C-14.283 23.175 -14.274 22.238 -14.264 21.271 C-14.239 20.295 -14.213 19.319 -14.188 18.312 C-14.174 17.324 -14.16 16.336 -14.146 15.318 C-14.111 12.878 -14.062 10.439 -14 8 C-13.01 7.67 -12.02 7.34 -11 7 C-11.33 5.35 -11.66 3.7 -12 2 C-7.528 -0.662 -5.066 -1.327 0 0 Z " fill="#684736" transform="translate(109,194)"/>
<path d="M0 0 C5.576 2.152 5.576 2.152 7 5 C7.584 12.241 7.584 12.241 5.125 15.438 C1.562 18.057 -0.607 18.095 -5 18 C-4.938 19.423 -4.938 19.423 -4.875 20.875 C-5 24 -5 24 -7 26 C-9.562 25.75 -9.562 25.75 -12 25 C-13.822 21.356 -13.228 17.074 -13.25 13.062 C-13.271 12.143 -13.291 11.223 -13.312 10.275 C-13.318 9.392 -13.323 8.508 -13.328 7.598 C-13.337 6.788 -13.347 5.979 -13.356 5.145 C-12.217 -1.712 -5.411 -0.639 0 0 Z M-5 7 C-5 8.65 -5 10.3 -5 12 C-3.35 11.34 -1.7 10.68 0 10 C-0.33 9.01 -0.66 8.02 -1 7 C-2.32 7 -3.64 7 -5 7 Z " fill="#694735" transform="translate(215,200)"/>
<path d="M0 0 C4.433 0.083 7.491 0.807 11.25 3.312 C12.433 5.679 12.384 7.18 12.375 9.812 C12.379 11.004 12.379 11.004 12.383 12.219 C12.25 14.312 12.25 14.312 11.25 16.312 C8.071 18.314 4.937 18.81 1.25 19.312 C0.26 22.778 0.26 22.778 -0.75 26.312 C-2.73 25.983 -4.71 25.653 -6.75 25.312 C-6.808 21.521 -6.844 17.729 -6.875 13.938 C-6.892 12.857 -6.909 11.777 -6.926 10.664 C-6.932 9.633 -6.939 8.602 -6.945 7.539 C-6.956 6.586 -6.966 5.633 -6.977 4.651 C-6.534 0.092 -3.964 0.018 0 0 Z M1.25 7.312 C0.92 8.632 0.59 9.952 0.25 11.312 C1.24 11.808 1.24 11.808 2.25 12.312 C3.24 11.653 4.23 10.993 5.25 10.312 C5.25 9.653 5.25 8.993 5.25 8.312 C3.93 7.982 2.61 7.653 1.25 7.312 Z " fill="#664534" transform="translate(317.75,199.6875)"/>
<path d="M0 0 C3.064 5.587 1.843 10.745 0.316 16.641 C-3.952 29.919 -12.878 39.98 -25.125 46.375 C-36.183 51.86 -51.43 51.693 -63.188 48.375 C-68.572 46.427 -72.851 43.94 -77 40 C-77.812 39.23 -77.812 39.23 -78.641 38.445 C-86.874 29.755 -89.418 18.647 -90 7 C-89.67 5.68 -89.34 4.36 -89 3 C-86.03 3.99 -83.06 4.98 -80 6 C-84 7 -84 7 -86 6 C-86.653 17.211 -86.653 17.211 -82 27 C-81.732 27.804 -81.464 28.609 -81.188 29.438 C-78.28 35.711 -73.445 40.485 -67 43 C-67 43.66 -67 44.32 -67 45 C-66.268 45.143 -65.536 45.286 -64.781 45.434 C-63.109 45.774 -61.44 46.135 -59.781 46.535 C-46.464 49.614 -33.919 48.718 -22 42 C-21.093 41.505 -20.185 41.01 -19.25 40.5 C-15.662 38.108 -13.377 36.131 -12 32 C-11.402 31.423 -10.804 30.845 -10.188 30.25 C-4.39 24.287 -1.896 17.323 -1.875 9.125 C-1.907 7.416 -1.945 5.708 -2 4 C-4.31 4.33 -6.62 4.66 -9 5 C-6.455 2.201 -3.924 0 0 0 Z M-10.812 5.375 C-10.214 5.581 -9.616 5.787 -9 6 C-11.31 6.33 -13.62 6.66 -16 7 C-13 5 -13 5 -10.812 5.375 Z " fill="#BCB346" transform="translate(200,108)"/>
<path d="M0 0 C0.711 -0.008 1.422 -0.015 2.154 -0.023 C5.913 -0.009 7.611 0.116 10.812 2.25 C10.025 3.711 9.232 5.168 8.438 6.625 C7.997 7.437 7.556 8.249 7.102 9.086 C5.812 11.25 5.812 11.25 3.812 13.25 C3.442 7.697 3.442 7.697 5.312 5.438 C5.808 5.046 6.303 4.654 6.812 4.25 C6.812 3.92 6.812 3.59 6.812 3.25 C-5.702 2.769 -15.809 12.564 -24.734 20.568 C-27.733 23.214 -30.366 25.026 -34.188 26.25 C-33.532 19.302 -33.532 19.302 -30.188 16.25 C-29.143 14.981 -28.102 13.71 -27.062 12.438 C-19.637 3.913 -11.327 -0.125 0 0 Z M-0.188 4.25 C0.473 4.25 1.132 4.25 1.812 4.25 C1.483 5.24 1.152 6.23 0.812 7.25 C0.483 6.26 0.152 5.27 -0.188 4.25 Z " fill="#C3C141" transform="translate(259.1875,93.75)"/>
<path d="M0 0 C0.685 0.008 1.37 0.015 2.076 0.023 C2.757 0.016 3.439 0.008 4.141 0 C7.853 0.015 9.475 0.164 12.639 2.273 C12.309 3.923 11.979 5.573 11.639 7.273 C9.989 7.273 8.339 7.273 6.639 7.273 C6.662 8.477 6.685 9.681 6.709 10.922 C6.728 12.497 6.746 14.073 6.764 15.648 C6.789 16.84 6.789 16.84 6.814 18.055 C6.832 20.128 6.742 22.202 6.639 24.273 C5.979 24.933 5.319 25.593 4.639 26.273 C0.083 25.718 0.083 25.718 -1.361 24.273 C-1.435 21.411 -1.454 18.573 -1.424 15.711 C-1.419 14.905 -1.415 14.098 -1.41 13.268 C-1.398 11.269 -1.38 9.271 -1.361 7.273 C-3.011 6.943 -4.661 6.613 -6.361 6.273 C-6.986 4.398 -6.986 4.398 -7.361 2.273 C-4.878 -0.21 -3.395 0.013 0 0 Z " fill="#694837" transform="translate(40.361328125,199.7265625)"/>
<path d="M0 0 C1.028 -0.012 1.028 -0.012 2.076 -0.023 C7.184 -0.003 7.184 -0.003 9.438 2.25 C9.062 4.375 9.062 4.375 8.438 6.25 C6.787 6.58 5.137 6.91 3.438 7.25 C3.461 8.454 3.484 9.658 3.508 10.898 C3.527 12.474 3.545 14.049 3.562 15.625 C3.588 16.816 3.588 16.816 3.613 18.031 C3.631 20.105 3.541 22.179 3.438 24.25 C2.778 24.91 2.118 25.57 1.438 26.25 C-1.125 26 -1.125 26 -3.562 25.25 C-5.157 22.062 -4.664 18.621 -4.625 15.125 C-4.62 14.371 -4.616 13.617 -4.611 12.84 C-4.6 10.977 -4.582 9.113 -4.562 7.25 C-6.213 6.92 -7.863 6.59 -9.562 6.25 C-9.893 4.93 -10.222 3.61 -10.562 2.25 C-6.926 -0.175 -4.2 -0.048 0 0 Z " fill="#644332" transform="translate(166.5625,199.75)"/>
<path d="M0 0 C0.685 0.008 1.37 0.015 2.076 0.023 C2.757 0.016 3.439 0.008 4.141 0 C7.853 0.015 9.475 0.164 12.639 2.273 C12.451 4.148 12.451 4.148 11.639 6.273 C9.989 6.933 8.339 7.593 6.639 8.273 C6.309 13.883 5.979 19.493 5.639 25.273 C3.989 25.603 2.339 25.933 0.639 26.273 C-1.361 24.273 -1.361 24.273 -1.557 20.359 C-1.543 18.789 -1.519 17.219 -1.486 15.648 C-1.477 14.847 -1.468 14.045 -1.459 13.219 C-1.435 11.237 -1.4 9.255 -1.361 7.273 C-3.011 6.943 -4.661 6.613 -6.361 6.273 C-6.986 4.398 -6.986 4.398 -7.361 2.273 C-4.878 -0.21 -3.395 0.013 0 0 Z " fill="#6A4838" transform="translate(255.361328125,199.7265625)"/>
<path d="M0 0 C0.763 0.206 1.526 0.412 2.312 0.625 C0.797 4.908 -2.39 6.817 -6 9.25 C-7.267 10.128 -8.533 11.008 -9.797 11.891 C-10.442 12.34 -11.087 12.789 -11.752 13.252 C-14.812 15.423 -17.784 17.702 -20.75 20 C-22.283 21.187 -22.283 21.187 -23.848 22.398 C-26.474 24.458 -29.086 26.534 -31.688 28.625 C-31.688 25.047 -31.152 22.907 -29.688 19.625 C-27.545 17.6 -25.546 15.91 -23.188 14.188 C-22.563 13.714 -21.938 13.241 -21.294 12.754 C-3.653 -0.51 -3.653 -0.51 0 0 Z M-34.688 29.625 C-33.643 32.758 -33.753 33.615 -34.688 36.625 C-35.347 36.625 -36.007 36.625 -36.688 36.625 C-35.812 31.875 -35.812 31.875 -34.688 29.625 Z " fill="#6A472A" transform="translate(257.6875,98.375)"/>
<path d="M0 0 C4.556 0.556 4.556 0.556 6 2 C6.252 5.639 6.185 9.291 6.188 12.938 C6.2 13.966 6.212 14.994 6.225 16.053 C6.228 17.525 6.228 17.525 6.23 19.027 C6.235 19.932 6.239 20.837 6.243 21.769 C6 24 6 24 4 26 C1.438 25.75 1.438 25.75 -1 25 C-2.885 21.23 -2.185 16.648 -2.188 12.5 C-2.2 11.524 -2.212 10.548 -2.225 9.543 C-2.227 8.611 -2.228 7.679 -2.23 6.719 C-2.235 5.862 -2.239 5.006 -2.243 4.123 C-2 2 -2 2 0 0 Z " fill="#6A4837" transform="translate(186,200)"/>
<path d="M0 0 C1.286 -0.001 2.572 -0.003 3.896 -0.004 C4.889 -0.001 4.889 -0.001 5.902 0.002 C7.903 0.008 9.903 0.002 11.904 -0.004 C19.034 0.003 26.031 0.216 33.105 1.133 C32.775 1.793 32.445 2.453 32.105 3.133 C26.238 4.515 19.91 4.27 13.914 4.23 C13.083 4.229 12.253 4.228 11.397 4.226 C8.779 4.221 6.161 4.208 3.543 4.195 C1.753 4.19 -0.036 4.186 -1.826 4.182 C-6.182 4.171 -10.538 4.153 -14.895 4.133 C-14.565 3.143 -14.235 2.153 -13.895 1.133 C-9.219 0.384 -4.734 0.005 0 0 Z " fill="#C5C04B" transform="translate(147.89453125,95.8671875)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C1.34 1.598 0.68 2.196 0 2.812 C-2.337 4.875 -2.337 4.875 -2 8 C-3.727 8.508 -5.457 9.006 -7.188 9.5 C-8.15 9.778 -9.113 10.057 -10.105 10.344 C-12.853 10.967 -15.196 11.123 -18 11 C-17.34 10.67 -16.68 10.34 -16 10 C-16 9.34 -16 8.68 -16 8 C-17.21 8.244 -17.21 8.244 -18.444 8.494 C-22.634 9.09 -26.674 9.125 -30.898 9.098 C-31.733 9.096 -32.568 9.095 -33.429 9.093 C-36.077 9.088 -38.726 9.075 -41.375 9.062 C-43.178 9.057 -44.982 9.053 -46.785 9.049 C-51.19 9.038 -55.595 9.021 -60 9 C-60 8.67 -60 8.34 -60 8 C-58.634 7.978 -58.634 7.978 -57.24 7.956 C-28.819 8.202 -28.819 8.202 -1.785 0.672 C-1.196 0.45 -0.607 0.228 0 0 Z M5 1 C5.66 1.66 6.32 2.32 7 3 C5.035 4.068 3.031 5.066 1 6 C0.34 5.67 -0.32 5.34 -1 5 C0.98 3.68 2.96 2.36 5 1 Z " fill="#725623" transform="translate(193,105)"/>
<path d="M0 0 C0.33 0 0.66 0 1 0 C1.38 6.199 0.835 10.448 -2 16 C-3.381 12.233 -2.596 9.552 -1.562 5.75 C-1.275 4.672 -0.988 3.595 -0.691 2.484 C-0.463 1.665 -0.235 0.845 0 0 Z M-4 16 C-3 19 -3 19 -4.039 21.305 C-6.984 26.078 -9.778 30.234 -14 34 C-14.598 34.598 -15.196 35.196 -15.812 35.812 C-16.204 36.204 -16.596 36.596 -17 37 C-17.66 36.67 -18.32 36.34 -19 36 C-18.336 35.31 -17.672 34.621 -16.988 33.91 C-11.752 28.337 -7.275 22.981 -4 16 Z M-21 37 C-20.34 37.33 -19.68 37.66 -19 38 C-19.33 38.66 -19.66 39.32 -20 40 C-23.062 40.625 -23.062 40.625 -26 41 C-24.35 39.68 -22.7 38.36 -21 37 Z M-29 41 C-28.01 41.33 -27.02 41.66 -26 42 C-27.32 42.33 -28.64 42.66 -30 43 C-29.67 42.34 -29.34 41.68 -29 41 Z M-66 43 C-64.948 43.164 -63.896 43.327 -62.812 43.496 C-56.887 44.237 -50.962 44.107 -45 44.062 C-43.844 44.058 -42.687 44.053 -41.496 44.049 C-38.664 44.037 -35.832 44.021 -33 44 C-36.335 46.223 -37.44 46.256 -41.332 46.266 C-42.378 46.268 -43.424 46.271 -44.502 46.273 C-45.594 46.266 -46.687 46.258 -47.812 46.25 C-48.893 46.258 -49.974 46.265 -51.088 46.273 C-56.429 46.26 -61.021 46.19 -66 44 C-66 43.67 -66 43.34 -66 43 Z M-33 43 C-30 44 -30 44 -30 44 Z " fill="#72571E" transform="translate(202,113)"/>
<path d="M0 0 C1.043 0.071 2.085 0.143 3.16 0.217 C15.491 1.01 27.772 1.119 40.125 1.062 C42.066 1.057 44.008 1.053 45.949 1.049 C50.633 1.038 55.316 1.021 60 1 C52.657 5.237 43.754 4.196 35.562 4.188 C34.556 4.189 34.556 4.189 33.529 4.19 C23.883 4.189 14.485 3.864 5 2 C7.64 2.99 10.28 3.98 13 5 C13 5.33 13 5.66 13 6 C7.906 5.568 4.155 5.246 0 2 C0 1.34 0 0.68 0 0 Z " fill="#C0B747" transform="translate(116,105)"/>
<path d="M0 0 C0.495 0.99 0.495 0.99 1 2 C-7.291 13.51 -15.948 20.035 -30 23 C-32.887 23.098 -32.887 23.098 -35 23 C-34.01 20.03 -33.02 17.06 -32 14 C-31.01 14 -30.02 14 -29 14 C-29.66 14.66 -30.32 15.32 -31 16 C-31.648 18.571 -31.648 18.571 -32 21 C-20.533 18.218 -12.858 15.053 -5 6 C-4.67 5.34 -4.34 4.68 -4 4 C-3.34 4 -2.68 4 -2 4 C-1.34 2.68 -0.68 1.36 0 0 Z " fill="#BAB045" transform="translate(257,113)"/>
<path d="M0 0 C0.66 0.99 1.32 1.98 2 3 C2.99 3.66 3.98 4.32 5 5 C-0.94 9.95 -0.94 9.95 -7 15 C-7.66 14.67 -8.32 14.34 -9 14 C-8.541 9.872 -8.234 7.657 -5 5 C-4.113 4.113 -3.226 3.226 -2.312 2.312 C-1.549 1.549 -0.786 0.786 0 0 Z " fill="#B9AE44" transform="translate(234,105)"/>
<path d="M0 0 C2.604 -0.054 5.208 -0.094 7.812 -0.125 C8.55 -0.142 9.288 -0.159 10.049 -0.176 C13.912 -0.211 15.709 -0.194 19 2 C18.213 3.461 17.42 4.918 16.625 6.375 C16.184 7.187 15.743 7.999 15.289 8.836 C14 11 14 11 12 13 C11.63 7.447 11.63 7.447 13.5 5.188 C14.243 4.6 14.243 4.6 15 4 C15 3.67 15 3.34 15 3 C10.69 2.834 7.721 2.768 4 5 C2.339 5.68 0.673 6.349 -1 7 C-0.34 6.01 0.32 5.02 1 4 C1.66 4 2.32 4 3 4 C3 3.34 3 2.68 3 2 C1.35 2.33 -0.3 2.66 -2 3 C-1.34 2.67 -0.68 2.34 0 2 C0 1.34 0 0.68 0 0 Z M8 4 C8.66 4 9.32 4 10 4 C9.67 4.99 9.34 5.98 9 7 C8.67 6.01 8.34 5.02 8 4 Z " fill="#BDB44B" transform="translate(251,94)"/>
<path d="M0 0 C2.97 0.99 5.94 1.98 9 3 C5 4 5 4 3 3 C2.347 14.211 2.347 14.211 7 24 C8.378 27.032 9 28.657 9 32 C1.398 25.755 0.371 16.553 -0.801 7.355 C-1 4 -1 4 0 0 Z " fill="#BDB552" transform="translate(111,111)"/>
<path d="M0 0 C3.237 -0.294 5.008 0.004 8 1.375 C19.11 6.384 32.399 5.247 44.312 5.125 C46.036 5.115 47.759 5.106 49.482 5.098 C53.655 5.076 57.827 5.042 62 5 C62 5.33 62 5.66 62 6 C54.573 7.023 47.24 7.255 39.749 7.295 C38.264 7.307 36.779 7.327 35.294 7.357 C24.031 7.581 8.962 7.875 0 0 Z " fill="#6D501C" transform="translate(113,111)"/>
<path d="M0 0 C2.696 1.54 5.14 3.113 7.562 5.062 C9.671 6.738 11.554 7.913 14 9 C14 9.66 14 10.32 14 11 C14.639 11.124 15.279 11.248 15.938 11.375 C16.948 11.581 17.959 11.787 19 12 C19.759 12.146 20.519 12.291 21.301 12.441 C22.109 12.605 22.917 12.769 23.75 12.938 C24.529 13.091 25.307 13.244 26.109 13.402 C26.733 13.6 27.357 13.797 28 14 C28.33 14.66 28.66 15.32 29 16 C17.041 15.097 8.075 11.073 0 2 C0 1.34 0 0.68 0 0 Z " fill="#B7AC44" transform="translate(119,142)"/>
<path d="M0 0 C0.33 0.99 0.66 1.98 1 3 C-0.938 6.188 -0.938 6.188 -3 9 C-3.33 8.01 -3.66 7.02 -4 6 C-2.062 2.812 -2.062 2.812 0 0 Z M-6 11 C-6 14 -6 14 -6 14 Z M-8 14 C-8 17.693 -8.882 19.005 -11 22 C-13.688 24.312 -13.688 24.312 -16 26 C-14.59 22.961 -12.911 20.406 -10.875 17.75 C-10.336 17.044 -9.797 16.337 -9.242 15.609 C-8.832 15.078 -8.422 14.547 -8 14 Z M-17 26 C-17 26.99 -17 27.98 -17 29 C-17.66 28.67 -18.32 28.34 -19 28 C-18.34 27.34 -17.68 26.68 -17 26 Z M-23 31 C-22.34 31.33 -21.68 31.66 -21 32 C-21.99 32 -22.98 32 -24 32 C-23.67 31.67 -23.34 31.34 -23 31 Z M-24 33 C-24 33.66 -24 34.32 -24 35 C-25.65 35.33 -27.3 35.66 -29 36 C-26.25 33 -26.25 33 -24 33 Z " fill="#71561D" transform="translate(269,96)"/>
<path d="M0 0 C-0.33 1.32 -0.66 2.64 -1 4 C-2.456 4.363 -3.915 4.715 -5.375 5.062 C-6.593 5.358 -6.593 5.358 -7.836 5.66 C-10 6 -10 6 -12 5 C-11.812 3.125 -11.812 3.125 -11 1 C-7.112 -1.333 -4.319 -1.004 0 0 Z " fill="#654738" transform="translate(109,194)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C1.68 1.99 0.36 2.98 -1 4 C-1.66 3.67 -2.32 3.34 -3 3 C-2.01 2.01 -1.02 1.02 0 0 Z M-5 4 C-4.34 4.33 -3.68 4.66 -3 5 C-5.64 6.98 -8.28 8.96 -11 11 C-11.33 10.34 -11.66 9.68 -12 9 C-9.69 7.35 -7.38 5.7 -5 4 Z M-14 11 C-13.34 11.33 -12.68 11.66 -12 12 C-13.98 12.99 -13.98 12.99 -16 14 C-15.34 13.01 -14.68 12.02 -14 11 Z M-22 16 C-20.68 16.33 -19.36 16.66 -18 17 C-19.65 18.32 -21.3 19.64 -23 21 C-23 18 -23 18 -22 16 Z M-26 22 C-24.956 25.133 -25.066 25.99 -26 29 C-26.66 29 -27.32 29 -28 29 C-27.125 24.25 -27.125 24.25 -26 22 Z " fill="#71561E" transform="translate(249,106)"/>
<path d="M0 0 C2.599 4.739 1.949 8.883 1 14 C0.67 14.99 0.34 15.98 0 17 C-0.66 17 -1.32 17 -2 17 C-2 12.71 -2 8.42 -2 4 C-4.31 4.33 -6.62 4.66 -9 5 C-6.455 2.201 -3.924 0 0 0 Z M-10.812 5.375 C-10.214 5.581 -9.616 5.787 -9 6 C-11.31 6.33 -13.62 6.66 -16 7 C-13 5 -13 5 -10.812 5.375 Z " fill="#B8AE49" transform="translate(200,108)"/>
<path d="M0 0 C0.875 0.052 1.749 0.104 2.65 0.158 C4.788 0.287 6.925 0.424 9.062 0.562 C9.062 0.892 9.062 1.222 9.062 1.562 C4.442 1.892 -0.178 2.222 -4.938 2.562 C-4.938 2.892 -4.938 3.222 -4.938 3.562 C-9.227 3.562 -13.518 3.562 -17.938 3.562 C-17.607 2.573 -17.278 1.582 -16.938 0.562 C-11.175 -0.493 -5.832 -0.386 0 0 Z " fill="#B5AA48" transform="translate(150.9375,96.4375)"/>
<path d="M0 0 C0.33 0.66 0.66 1.32 1 2 C0.34 1.67 -0.32 1.34 -1 1 C-0.67 0.67 -0.34 0.34 0 0 Z M-3 2 C-2.34 2.33 -1.68 2.66 -1 3 C-1.99 3 -2.98 3 -4 3 C-3.67 2.67 -3.34 2.34 -3 2 Z M-6 4 C-5.34 4.33 -4.68 4.66 -4 5 C-5.32 5.99 -6.64 6.98 -8 8 C-8.66 7.67 -9.32 7.34 -10 7 C-8.68 6.01 -7.36 5.02 -6 4 Z M-12 8 C-11.34 8.33 -10.68 8.66 -10 9 C-11.98 9.99 -11.98 9.99 -14 11 C-13.34 10.01 -12.68 9.02 -12 8 Z M-15 11 C-15 11.99 -15 12.98 -15 14 C-16.609 15.5 -16.609 15.5 -18.75 17 C-19.446 17.495 -20.142 17.99 -20.859 18.5 C-21.566 18.995 -22.272 19.49 -23 20 C-24.337 20.996 -25.671 21.994 -27 23 C-27.66 22.67 -28.32 22.34 -29 22 C-27.609 20.724 -26.212 19.454 -24.812 18.188 C-24.035 17.48 -23.258 16.772 -22.457 16.043 C-20.08 14.067 -17.689 12.514 -15 11 Z " fill="#BFB646" transform="translate(258,102)"/>
<path d="M0 0 C5.94 0.33 11.88 0.66 18 1 C18 1.33 18 1.66 18 2 C13.71 2 9.42 2 5 2 C7.64 2.99 10.28 3.98 13 5 C13 5.33 13 5.66 13 6 C7.906 5.568 4.155 5.246 0 2 C0 1.34 0 0.68 0 0 Z " fill="#BCB247" transform="translate(116,105)"/>
<path d="M0 0 C3.91 -0.355 6.322 0.553 9.824 2.207 C13.597 3.582 17.556 3.956 21.523 4.473 C22.341 4.647 23.158 4.821 24 5 C24.33 5.66 24.66 6.32 25 7 C16.352 6.456 6.871 5.72 0 0 Z " fill="#765D20" transform="translate(113,111)"/>
<path d="M0 0 C4.29 0.33 8.58 0.66 13 1 C12.67 1.66 12.34 2.32 12 3 C5.327 4.076 -1.254 4.113 -8 4 C-8 3.67 -8 3.34 -8 3 C-5.36 2.67 -2.72 2.34 0 2 C0 1.34 0 0.68 0 0 Z " fill="#B1A85C" transform="translate(168,96)"/>
<path d="M0 0 C0.701 0.248 1.402 0.495 2.125 0.75 C0.736 4.106 -0.792 5.823 -3.875 7.75 C-4.535 7.75 -5.195 7.75 -5.875 7.75 C-5.875 7.09 -5.875 6.43 -5.875 5.75 C-6.865 5.09 -7.855 4.43 -8.875 3.75 C-3.583 -0.312 -3.583 -0.312 0 0 Z M-4.875 2.75 C-4.875 3.41 -4.875 4.07 -4.875 4.75 C-3.555 4.42 -2.235 4.09 -0.875 3.75 C-0.875 2.76 -0.875 1.77 -0.875 0.75 C-2.337 0.658 -2.337 0.658 -4.875 2.75 Z " fill="#73581F" transform="translate(257.875,98.25)"/>
<path d="M0 0 C0 0.66 0 1.32 0 2 C-1.242 2.702 -2.494 3.386 -3.75 4.062 C-4.794 4.637 -4.794 4.637 -5.859 5.223 C-8.361 6.131 -9.533 5.883 -12 5 C-10.68 4.67 -9.36 4.34 -8 4 C-8 3.34 -8 2.68 -8 2 C-8.66 1.67 -9.32 1.34 -10 1 C-6.565 0.375 -3.509 0 0 0 Z " fill="#B6AA44" transform="translate(195,103)"/>
<path d="M0 0 C3.381 3.114 5.709 5.541 7 10 C7 10.99 7 11.98 7 13 C3.664 10.009 1.755 7.124 0 3 C0 2.01 0 1.02 0 0 Z " fill="#C2BA50" transform="translate(113,130)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C1.34 1.66 0.68 2.32 0 3 C-0.648 5.571 -0.648 5.571 -1 8 C-0.01 8.33 0.98 8.66 2 9 C1 10 1 10 -1.562 10.062 C-2.367 10.042 -3.171 10.021 -4 10 C-3.524 8.52 -3.044 7.041 -2.562 5.562 C-2.296 4.739 -2.029 3.915 -1.754 3.066 C-1 1 -1 1 0 0 Z " fill="#B8AD44" transform="translate(226,126)"/>
<path d="M0 0 C0.99 0.66 1.98 1.32 3 2 C0.03 4.31 -2.94 6.62 -6 9 C-6 6 -6 6 -4.688 4.395 C-4.131 3.872 -3.574 3.35 -3 2.812 C-2.443 2.283 -1.886 1.753 -1.312 1.207 C-0.663 0.61 -0.663 0.61 0 0 Z " fill="#C5C33A" transform="translate(233,109)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C2.67 2.32 2.34 3.64 2 5 C0.35 5.33 -1.3 5.66 -3 6 C-1.125 1.125 -1.125 1.125 0 0 Z " fill="#674637" transform="translate(106,193)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C2.213 2.461 1.42 3.918 0.625 5.375 C0.184 6.187 -0.257 6.999 -0.711 7.836 C-2 10 -2 10 -4 12 C-4.371 6.435 -4.371 6.435 -2.562 4.312 C-1.789 3.663 -1.789 3.663 -1 3 C-0.67 2.01 -0.34 1.02 0 0 Z " fill="#BBB144" transform="translate(267,95)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C-0.97 3.64 -3.94 6.28 -7 9 C-7.66 8.67 -8.32 8.34 -9 8 C-6.03 5.36 -3.06 2.72 0 0 Z " fill="#B7AB44" transform="translate(238,116)"/>
<path d="M0 0 C1.65 0 3.3 0 5 0 C5 0.66 5 1.32 5 2 C3.02 2.33 1.04 2.66 -1 3 C-0.67 2.01 -0.34 1.02 0 0 Z " fill="#694331" transform="translate(172,113)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C0.719 2.707 -0.618 4.374 -2 6 C-2.66 6 -3.32 6 -4 6 C-2.848 3.532 -1.952 1.952 0 0 Z " fill="#5C463A" transform="translate(298,219)"/>
<path d="M0 0 C0 0.99 0 1.98 0 3 C-2.5 5.188 -2.5 5.188 -5 7 C-3.75 3.347 -3.329 2.219 0 0 Z " fill="#C1BA4D" transform="translate(234,105)"/>
<path d="M0 0 C-0.33 0.99 -0.66 1.98 -1 3 C-2.98 3.33 -4.96 3.66 -7 4 C-5.533 1.066 -3.26 0 0 0 Z " fill="#B8AD47" transform="translate(173,153)"/>
<path d="M0 0 C1.65 0.99 3.3 1.98 5 3 C4.01 3.33 3.02 3.66 2 4 C1.34 2.68 0.68 1.36 0 0 Z " fill="#B6AB43" transform="translate(119,142)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C2.01 2.485 2.01 2.485 1 4 C0.34 3.67 -0.32 3.34 -1 3 C-0.67 2.01 -0.34 1.02 0 0 Z " fill="#AFA33F" transform="translate(267,95)"/>
<path d="M0 0 C1.32 0.66 2.64 1.32 4 2 C2.68 2.33 1.36 2.66 0 3 C0 2.01 0 1.02 0 0 Z " fill="#634D40" transform="translate(79,223)"/>
<path d="" fill="#000000" transform="translate(0,0)"/>
</svg>

After

Width:  |  Height:  |  Size: 28 KiB

237
public/fix-token.html Normal file
View File

@ -0,0 +1,237 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fix Token - Tirage au Sort</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.card {
background: white;
border-radius: 20px;
padding: 40px;
max-width: 600px;
width: 100%;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
h1 {
font-size: 32px;
color: #333;
margin-bottom: 10px;
text-align: center;
}
.emoji {
font-size: 48px;
text-align: center;
margin-bottom: 20px;
}
.status {
padding: 20px;
border-radius: 12px;
margin: 20px 0;
font-size: 16px;
text-align: center;
font-weight: 500;
}
.success { background: #d4edda; color: #155724; border: 2px solid #c3e6cb; }
.error { background: #f8d7da; color: #721c24; border: 2px solid #f5c6cb; }
.warning { background: #fff3cd; color: #856404; border: 2px solid #ffeeba; }
.info { background: #d1ecf1; color: #0c5460; border: 2px solid #bee5eb; }
button {
width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 18px;
font-size: 18px;
font-weight: 700;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s;
text-transform: uppercase;
letter-spacing: 1px;
}
button:hover { transform: translateY(-2px); box-shadow: 0 10px 25px rgba(0,0,0,0.2); }
button:active { transform: translateY(0); }
button:disabled { opacity: 0.6; cursor: not-allowed; }
.steps {
background: #f8f9fa;
padding: 20px;
border-radius: 12px;
margin: 20px 0;
}
.step {
display: flex;
align-items: center;
padding: 10px 0;
font-size: 14px;
}
.step-num {
background: #667eea;
color: white;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-right: 15px;
flex-shrink: 0;
}
.step.done .step-num { background: #28a745; }
.countdown {
font-size: 24px;
font-weight: bold;
color: #667eea;
text-align: center;
}
</style>
</head>
<body>
<div class="card">
<div class="emoji" id="emoji">🔑</div>
<h1 id="title">Mise à Jour du Token</h1>
<div id="status"></div>
<div class="steps">
<div class="step" id="step1">
<div class="step-num">1</div>
<div>Suppression de l'ancien token</div>
</div>
<div class="step" id="step2">
<div class="step-num">2</div>
<div>Installation du nouveau token</div>
</div>
<div class="step" id="step3">
<div class="step-num">3</div>
<div>Vérification du token</div>
</div>
<div class="step" id="step4">
<div class="step-num">4</div>
<div>Redirection vers la page de tirage</div>
</div>
</div>
<button id="fixBtn" onclick="fixToken()">
🚀 Corriger le Token Maintenant
</button>
</div>
<script>
const NEW_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1YTIzOThkZi00NWNiLTQyOGQtOWY1ZS04YzQwNzQxNzEyNGIiLCJpYXQiOjE3NjMwODM0NjksImV4cCI6MTc2MzY4ODI2OX0.fCxlIzy-RkCqvCwjatHmIZ5pjqC61Vs-RAnZwulNd_Q';
function showStatus(message, type) {
const status = document.getElementById('status');
status.className = 'status ' + type;
status.innerHTML = message;
}
function markStepDone(stepNum) {
document.getElementById('step' + stepNum).classList.add('done');
}
async function fixToken() {
const btn = document.getElementById('fixBtn');
btn.disabled = true;
btn.textContent = '⏳ Correction en cours...';
try {
// Étape 1
showStatus('🗑️ Suppression de l\'ancien token...', 'info');
await sleep(800);
localStorage.removeItem('token');
localStorage.removeItem('user');
sessionStorage.clear();
markStepDone(1);
// Étape 2
showStatus('✏️ Installation du nouveau token...', 'info');
await sleep(800);
localStorage.setItem('token', NEW_TOKEN);
markStepDone(2);
// Étape 3
showStatus('🔍 Vérification du token...', 'info');
await sleep(800);
const savedToken = localStorage.getItem('token');
if (savedToken !== NEW_TOKEN) {
throw new Error('Le token n\'a pas été enregistré correctement');
}
markStepDone(3);
// Test de connexion au backend
try {
const response = await fetch('http://localhost:4000/api/draw/eligible-participants?minTickets=1&verified=true', {
headers: {
'Authorization': 'Bearer ' + NEW_TOKEN
}
});
if (!response.ok) {
throw new Error('Erreur ' + response.status + ': ' + response.statusText);
}
const data = await response.json();
showStatus('✅ Token installé avec succès ! ' + data.data.total + ' participants trouvés', 'success');
document.getElementById('emoji').textContent = '✅';
} catch (err) {
showStatus('⚠️ Token installé mais serveur inaccessible. Vérifiez que le backend est lancé.', 'warning');
}
markStepDone(3);
// Étape 4 - Countdown
showStatus('✅ Token installé ! Redirection dans...', 'success');
for (let i = 3; i > 0; i--) {
document.getElementById('status').innerHTML =
`✅ Token installé avec succès !<br><div class="countdown">${i}</div>`;
await sleep(1000);
}
markStepDone(4);
window.location.href = 'http://localhost:3000/admin/tirages';
} catch (error) {
showStatus('❌ Erreur : ' + error.message, 'error');
document.getElementById('emoji').textContent = '❌';
btn.disabled = false;
btn.textContent = '🔄 Réessayer';
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Auto-démarrage si on est sur localhost:3000
window.addEventListener('load', () => {
if (window.location.hostname === 'localhost' && window.location.port === '3000') {
showStatus('📍 Détecté sur localhost:3000 - Prêt à corriger le token', 'info');
} else {
showStatus('⚠️ Cette page doit être ouverte depuis http://localhost:3000', 'warning');
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,53 @@
╔══════════════════════════════════════════════════════════════╗
║ 🍵 AJOUT RAPIDE DE LOGO ║
║ Thé Tip Top ║
╚══════════════════════════════════════════════════════════════╝
📌 ÉTAPES SIMPLES :
1⃣ Placez votre logo ici (dans ce dossier) :
public/logos/
2⃣ Nommez votre fichier EXACTEMENT :
logo.svg (SVG recommandé)
OU
logo.png (PNG avec fond transparent)
3⃣ (Optionnel) Version blanche pour le footer :
logo-white.svg
OU
logo-white.png
4⃣ Rafraîchissez votre navigateur (F5)
✅ C'est tout !
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📐 DIMENSIONS RECOMMANDÉES :
Largeur : 150-200px
Hauteur : 40-60px
Ratio : Environ 3:1 (horizontal)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🎯 EXEMPLE D'UTILISATION :
1. Renommez logo-example.svg en logo.svg
2. Rafraîchissez le navigateur
3. Voyez le résultat !
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📚 GUIDE COMPLET :
Consultez LOGO_GUIDE.md à la racine du projet
pour toutes les options avancées.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
💡 ASTUCE :
Si votre logo ne s'affiche pas, vérifiez :
- Le nom du fichier (logo.svg ou logo.png)
- L'emplacement (doit être dans public/logos/)
- La console du navigateur (F12)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

103
public/logos/README.md Normal file
View File

@ -0,0 +1,103 @@
# 📁 Logos Directory
Ce dossier contient les fichiers de logo pour l'application Thé Tip Top.
## 📝 Instructions pour ajouter votre logo
### Étape 1 : Préparer votre logo
Placez vos fichiers de logo dans ce dossier. Formats recommandés :
1. **logo.svg** - Format vectoriel (recommandé)
- Taille : Scalable
- Avantages : Qualité parfaite à toutes les tailles, fichier léger
2. **logo.png** - Format PNG avec fond transparent
- Taille recommandée : 500x500px minimum
- Avantages : Compatible partout, fond transparent
3. **logo-white.svg** ou **logo-white.png** - Version blanche pour le footer
- Pour affichage sur fond sombre
### Étape 2 : Nommer vos fichiers
```
public/logos/
├── logo.svg # Logo principal (couleur)
├── logo.png # Alternative PNG
├── logo-white.svg # Logo blanc pour footer
├── logo-icon.svg # Icône seule (favicon)
└── README.md # Ce fichier
```
### Étape 3 : Le code est déjà configuré !
Les fichiers Header.tsx et Footer.tsx sont déjà configurés pour utiliser automatiquement votre logo.
Le système utilise :
- `logo.svg` si disponible (priorité)
- `logo.png` en alternative
- 🍵 Emoji par défaut si aucun logo
### Formats de fichier acceptés
- ✅ SVG (recommandé) - Scalable, léger
- ✅ PNG - Avec transparence
- ✅ WEBP - Format moderne
- ⚠️ JPG - Moins recommandé (pas de transparence)
### Dimensions recommandées
**Logo principal (Header):**
- Largeur : 150-200px
- Hauteur : 40-60px
- Format : Horizontal ou carré
**Logo footer:**
- Largeur : 120-150px
- Hauteur : 40-50px
**Favicon/Icon:**
- 512x512px (carré)
- Format : SVG ou PNG
## 🎨 Exemple de création rapide
Si vous n'avez pas encore de logo, vous pouvez :
1. **Créer un SVG simple** avec un éditeur comme :
- Figma (gratuit)
- Canva (gratuit)
- Inkscape (gratuit, open-source)
2. **Utiliser un générateur** en ligne :
- Canva Logo Maker
- Looka
- Hatchful by Shopify
3. **Commander un logo** sur :
- Fiverr
- 99designs
- Upwork
## 🔧 Comment vérifier si ça fonctionne
1. Placez votre logo dans ce dossier
2. Rafraîchissez la page (F5)
3. Le logo devrait apparaître dans le Header et Footer
Si vous voyez toujours 🍵, vérifiez :
- Le nom du fichier (logo.svg ou logo.png)
- Le chemin (/public/logos/logo.svg)
- La console du navigateur pour les erreurs
## 💡 Astuce
Pour un meilleur rendu, utilisez un logo SVG optimisé :
```bash
# Installer SVGO pour optimiser votre SVG
npm install -g svgo
# Optimiser votre logo
svgo logo.svg -o logo-optimized.svg
```

View File

@ -0,0 +1,56 @@
<!-- Generator: visioncortex VTracer 0.6.4 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 263" preserveAspectRatio="xMidYMid meet">
<path d="M0 0 C2.137 0.251 3.459 0.459 5 2 C5.25 4.375 5.25 4.375 5 7 C3.562 8.625 3.562 8.625 2 10 C-0.463 17.388 1.374 23.02 4.438 29.812 C5.083 31.298 5.727 32.784 6.371 34.27 C6.659 34.915 6.947 35.561 7.244 36.226 C7.909 37.786 8.464 39.391 9 41 C10.033 41.086 11.065 41.173 12.129 41.262 C21.205 42.1 30.131 43.428 39.062 45.25 C40.013 45.43 40.963 45.61 41.942 45.796 C49.702 47.456 55.753 50.665 61.312 56.375 C63.504 59.784 65.288 63.33 67 67 C67.461 66.613 67.923 66.227 68.398 65.828 C79.469 56.989 91.07 55.031 105 56 C119.133 57.689 119.133 57.689 124 62 C123.812 64.25 123.812 64.25 123 67 C122.259 67.548 121.518 68.096 120.754 68.66 C117.171 71.704 115.368 75.107 113.125 79.188 C109.504 85.548 105.777 91.43 101 97 C100.399 97.755 99.799 98.511 99.18 99.289 C91.187 108.964 81.289 114.503 69 117 C65.629 117.264 62.369 117.274 59 117 C58.598 118.106 58.598 118.106 58.188 119.234 C52.197 133.187 37.405 143.39 23.743 148.912 C2.437 156.899 -18.575 157.221 -39.879 148.434 C-53.175 142.326 -64.768 133.973 -72 121 C-72.45 120.197 -72.9 119.394 -73.363 118.566 C-79.501 106.96 -81.179 95.697 -81.188 82.688 C-81.2 81.706 -81.212 80.724 -81.225 79.713 C-81.259 62.418 -81.259 62.418 -76.062 56.688 C-68.192 48.948 -57.425 46.826 -46.938 44.688 C-45.735 44.424 -44.533 44.16 -43.295 43.889 C-36.435 42.448 -30.009 41.61 -23 42 C-23.231 41.304 -23.461 40.609 -23.699 39.892 C-26.151 32.378 -27.075 26.324 -23.98 18.871 C-22.421 15.895 -20.693 13.988 -18 12 C-15.188 12.188 -15.188 12.188 -13 13 C-12.25 14.688 -12.25 14.688 -12 17 C-13.875 19.562 -13.875 19.562 -16 22 C-18.483 29.449 -16.145 34.774 -13 41.5 C-10.31 47.253 -8.449 51.591 -9 58 C-7.02 57.67 -5.04 57.34 -3 57 C-2.783 55.577 -2.783 55.577 -2.562 54.125 C-2 51 -2 51 -1 49 C0.22 41.764 -1.417 36.422 -4.5 30 C-7.514 23.635 -9.682 18.146 -8 11 C-6.117 6.737 -3.537 3.056 0 0 Z M-37.562 51.688 C-38.515 51.865 -39.468 52.042 -40.45 52.224 C-46.472 53.392 -52.382 54.774 -58.25 56.562 C-58.911 56.76 -59.571 56.957 -60.252 57.161 C-63.786 58.308 -64.865 58.798 -67 62 C-61.929 64.9 -56.984 67.503 -51 67 C-48.778 66.189 -46.729 65.177 -44.613 64.121 C-36.38 60.589 -26.883 59.594 -18 59 C-17.066 54.469 -17.066 54.469 -18 50 C-24.744 49.815 -30.94 50.441 -37.562 51.688 Z M7 50 C5.515 53.96 5.515 53.96 4 58 C5.653 59.653 7.742 59.331 9.965 59.546 C17.554 60.285 24.43 61.778 31.594 64.411 C36.755 66.229 40.248 66.079 45.301 64.098 C45.965 63.756 46.629 63.414 47.312 63.062 C48.328 62.554 48.328 62.554 49.363 62.035 C51.171 61.089 51.171 61.089 52 59 C37.925 52.931 22.242 50.505 7 50 Z M-12 65 C-12.66 66.32 -13.32 67.64 -14 69 C5.992 69.895 5.992 69.895 26 70 C25.217 67.852 25.217 67.852 23.285 67.402 C11.5 64.938 -0.009 64.882 -12 65 Z M72.812 74.438 C71.061 76.914 69.386 79.3 68 82 C68.33 82.66 68.66 83.32 69 84 C69.801 83.004 69.801 83.004 70.617 81.988 C76.613 74.882 82.216 70.928 91 68 C92.408 67.92 93.82 67.892 95.23 67.902 C96.434 67.907 96.434 67.907 97.662 67.912 C98.495 67.92 99.329 67.929 100.188 67.938 C101.032 67.942 101.877 67.947 102.748 67.951 C104.832 67.963 106.916 67.981 109 68 C109.33 67.34 109.66 66.68 110 66 C105.893 64.631 102.052 64.776 97.75 64.75 C96.916 64.729 96.082 64.709 95.223 64.688 C86.33 64.632 78.889 67.667 72.812 74.438 Z M-41 69 C-41 69.33 -41 69.66 -41 70 C-38.646 70.197 -36.293 70.383 -33.938 70.562 C-31.971 70.719 -31.971 70.719 -29.965 70.879 C-26.077 70.998 -22.809 70.669 -19 70 C-19.66 68.68 -20.32 67.36 -21 66 C-28.081 65.591 -34.199 67.096 -41 69 Z M44 74 C41.777 76.937 41.777 76.937 41 80 C40.732 80.743 40.464 81.485 40.188 82.25 C40.095 83.116 40.095 83.116 40 84 C40.99 84.99 40.99 84.99 42 86 C44.603 85.68 44.603 85.68 47 85 C48.178 97.663 41.505 110.127 33.816 119.727 C24.969 129.344 12.751 135.641 -0.41 136.336 C-17.633 136.671 -34.037 133.285 -47.086 121.176 C-57.665 109.15 -61.672 95.735 -61 80 C-60.723 77.991 -60.418 75.984 -60 74 C-61.456 73.329 -62.915 72.663 -64.375 72 C-65.187 71.629 -65.999 71.257 -66.836 70.875 C-68.92 69.875 -68.92 69.875 -71 70 C-75.262 82.787 -71.866 101.418 -66.375 113.375 C-58.557 127.602 -45.303 137.883 -30 143.004 C-11.056 148.332 9.398 147.322 27 138 C42.141 128.586 52.645 116.466 57 99 C57.984 92.87 58.152 86.889 58.125 80.688 C58.129 79.795 58.133 78.902 58.137 77.982 C58.135 77.126 58.134 76.269 58.133 75.387 C58.131 74.23 58.131 74.23 58.129 73.05 C58.075 70.799 58.075 70.799 57 68 C52.372 68 47.745 71.5 44 74 Z M-48 77 C-46.515 77.99 -46.515 77.99 -45 79 C-45 78.34 -45 77.68 -45 77 C-45.99 77 -46.98 77 -48 77 Z M-55 80 C-55.858 91.677 -53.343 102.595 -46.562 112.23 C-44.876 114.14 -43.087 115.547 -41 117 C-41.899 114.679 -42.792 112.395 -43.953 110.191 C-48.053 102.098 -49 92.997 -49 84 C-49.619 83.711 -50.237 83.422 -50.875 83.125 C-53 82 -53 82 -55 80 Z " fill="#694633" transform="translate(160,27)"/>
<path d="M0 0 C3.064 5.587 1.843 10.745 0.316 16.641 C-3.952 29.919 -12.878 39.98 -25.125 46.375 C-36.183 51.86 -51.43 51.693 -63.188 48.375 C-68.572 46.427 -72.851 43.94 -77 40 C-77.812 39.23 -77.812 39.23 -78.641 38.445 C-86.874 29.755 -89.418 18.647 -90 7 C-89.539 4.559 -89.539 4.559 -89 3 C-87.783 3.433 -86.566 3.866 -85.312 4.312 C-73.563 8.16 -62.535 9.506 -50.25 9.375 C-49.128 9.37 -49.128 9.37 -47.984 9.364 C-32.765 9.277 -18.449 8.224 -4.625 1.312 C-2 0 -2 0 0 0 Z " fill="#C1C333" transform="translate(200,108)"/>
<path d="M0 0 C0.711 -0.008 1.422 -0.015 2.154 -0.023 C5.913 -0.009 7.611 0.116 10.812 2.25 C7.295 8.96 3.303 15.151 -1.188 21.25 C-2.116 22.542 -2.116 22.542 -3.062 23.859 C-10.785 34.135 -19.567 39.587 -32.188 42.25 C-35.074 42.348 -35.074 42.348 -37.188 42.25 C-34.761 28.792 -20.416 21.512 -9.925 14.221 C-8.893 13.509 -8.893 13.509 -7.84 12.781 C-7.2 12.338 -6.561 11.894 -5.902 11.438 C-4.5 10.466 -3.096 9.498 -1.691 8.531 C-1.195 8.108 -0.699 7.686 -0.188 7.25 C-0.188 6.59 -0.188 5.93 -0.188 5.25 C-9.873 6.668 -17.618 14.177 -24.684 20.517 C-27.7 23.182 -30.34 25.022 -34.188 26.25 C-33.532 19.302 -33.532 19.302 -30.188 16.25 C-29.143 14.981 -28.102 13.71 -27.062 12.438 C-19.637 3.913 -11.327 -0.125 0 0 Z " fill="#C3C434" transform="translate(259.1875,93.75)"/>
<path d="M0 0 C1.675 0.286 3.344 0.618 5 1 C6.188 13.767 -0.571 26.346 -8.406 35.949 C-17.628 45.89 -29.674 52.612 -43.41 53.336 C-44.961 53.366 -46.512 53.378 -48.062 53.375 C-48.886 53.373 -49.709 53.372 -50.558 53.37 C-58.518 53.213 -65.496 51.651 -73 49 C-73.869 48.7 -74.738 48.399 -75.633 48.09 C-78.382 46.952 -80.534 45.919 -81.918 43.191 C-82.5 41.25 -82.5 41.25 -83 38 C-81.515 37.505 -81.515 37.505 -80 37 C-77.906 38.109 -77.906 38.109 -75.5 39.75 C-66.982 45.005 -59.247 46.245 -49.375 46.312 C-48.194 46.321 -48.194 46.321 -46.989 46.33 C-35.117 46.183 -25.306 42.764 -16.41 34.633 C-7.183 24.776 -2.697 14.29 -1 1 C-0.67 0.67 -0.34 0.34 0 0 Z " fill="#684633" transform="translate(203,111)"/>
<path d="M0 0 C3.17 1.281 4.578 2.52 6.812 5.062 C8.575 9.423 8.785 13.368 8 18 C6.635 20.903 5.212 22.582 3 25 C-1.995 26.665 -7.008 26.866 -11.875 24.75 C-15.634 21.655 -17.114 19.453 -17.875 14.625 C-18.218 10.272 -17.548 7.652 -15 4 C-10.753 -0.89 -6.07 -0.82 0 0 Z M-8 7 C-10.327 10.491 -10.503 11.892 -10 16 C-8.125 18.377 -8.125 18.377 -6 20 C-2.75 18.912 -2.75 18.912 0 17 C0.229 12.983 0.229 12.983 0 9 C-1.887 6.693 -1.887 6.693 -5 6.688 C-5.99 6.791 -6.98 6.894 -8 7 Z " fill="#684736" transform="translate(293,200)"/>
<path d="M0 0 C2.625 0.375 2.625 0.375 5 1 C6.082 4.365 5.925 5.298 5 9 C7.97 9 10.94 9 14 9 C13.959 8.072 13.918 7.144 13.875 6.188 C14 3 14 3 16 0 C18.97 0.495 18.97 0.495 22 1 C22 8.92 22 16.84 22 25 C20.02 25.33 18.04 25.66 16 26 C13.571 22.356 13.838 20.288 14 16 C11.03 16 8.06 16 5 16 C5.206 16.907 5.412 17.815 5.625 18.75 C6.013 22.11 5.804 23.241 4 26 C1.563 25.625 1.563 25.625 -1 25 C-2.885 21.23 -2.185 16.648 -2.188 12.5 C-2.2 11.524 -2.212 10.548 -2.225 9.543 C-2.227 8.611 -2.228 7.679 -2.23 6.719 C-2.235 5.862 -2.239 5.006 -2.243 4.123 C-2 2 -2 2 0 0 Z " fill="#694837" transform="translate(63,200)"/>
<path d="M0 0 C0 0.66 0 1.32 0 2 C-13.17 9.489 -28.054 10.214 -42.879 10.295 C-44.322 10.307 -45.766 10.327 -47.209 10.357 C-57.411 10.566 -67.496 10.139 -77 6 C-78.41 3.861 -78.41 3.861 -79 2 C-77.914 2.069 -76.828 2.139 -75.708 2.21 C-64.677 2.886 -53.677 3.235 -42.625 3.25 C-41.825 3.252 -41.025 3.255 -40.2 3.257 C-29.108 3.269 -18.387 2.777 -7.465 0.656 C-4 0 -4 0 0 0 Z " fill="#C1C039" transform="translate(195,103)"/>
<path d="M0 0 C-0.375 1.938 -0.375 1.938 -1 4 C-1.99 4.495 -1.99 4.495 -3 5 C-0.525 5.99 -0.525 5.99 2 7 C2 8.65 2 10.3 2 12 C-0.709 13.354 -3.009 13.065 -6 13 C-5.107 15.358 -5.107 15.358 -1.938 16.125 C-0.483 16.558 -0.483 16.558 1 17 C1 18.32 1 19.64 1 21 C0 22 0 22 -3.062 22.062 C-4.032 22.042 -5.001 22.021 -6 22 C-6 22.99 -6 23.98 -6 25 C-4.298 24.969 -4.298 24.969 -2.562 24.938 C1 25 1 25 2 26 C2.041 27.666 2.043 29.334 2 31 C-0.58 32.29 -2.557 32.204 -5.438 32.25 C-6.406 32.276 -7.374 32.302 -8.371 32.328 C-11 32 -11 32 -12.646 30.785 C-14.429 28.435 -14.346 27.041 -14.293 24.113 C-14.283 23.175 -14.274 22.238 -14.264 21.271 C-14.239 20.295 -14.213 19.319 -14.188 18.312 C-14.174 17.324 -14.16 16.336 -14.146 15.318 C-14.111 12.878 -14.062 10.439 -14 8 C-13.01 7.67 -12.02 7.34 -11 7 C-11.33 5.35 -11.66 3.7 -12 2 C-7.528 -0.662 -5.066 -1.327 0 0 Z " fill="#684736" transform="translate(109,194)"/>
<path d="M0 0 C5.576 2.152 5.576 2.152 7 5 C7.584 12.241 7.584 12.241 5.125 15.438 C1.562 18.057 -0.607 18.095 -5 18 C-4.938 19.423 -4.938 19.423 -4.875 20.875 C-5 24 -5 24 -7 26 C-9.562 25.75 -9.562 25.75 -12 25 C-13.822 21.356 -13.228 17.074 -13.25 13.062 C-13.271 12.143 -13.291 11.223 -13.312 10.275 C-13.318 9.392 -13.323 8.508 -13.328 7.598 C-13.337 6.788 -13.347 5.979 -13.356 5.145 C-12.217 -1.712 -5.411 -0.639 0 0 Z M-5 7 C-5 8.65 -5 10.3 -5 12 C-3.35 11.34 -1.7 10.68 0 10 C-0.33 9.01 -0.66 8.02 -1 7 C-2.32 7 -3.64 7 -5 7 Z " fill="#694735" transform="translate(215,200)"/>
<path d="M0 0 C4.433 0.083 7.491 0.807 11.25 3.312 C12.433 5.679 12.384 7.18 12.375 9.812 C12.379 11.004 12.379 11.004 12.383 12.219 C12.25 14.312 12.25 14.312 11.25 16.312 C8.071 18.314 4.937 18.81 1.25 19.312 C0.26 22.778 0.26 22.778 -0.75 26.312 C-2.73 25.983 -4.71 25.653 -6.75 25.312 C-6.808 21.521 -6.844 17.729 -6.875 13.938 C-6.892 12.857 -6.909 11.777 -6.926 10.664 C-6.932 9.633 -6.939 8.602 -6.945 7.539 C-6.956 6.586 -6.966 5.633 -6.977 4.651 C-6.534 0.092 -3.964 0.018 0 0 Z M1.25 7.312 C0.92 8.632 0.59 9.952 0.25 11.312 C1.24 11.808 1.24 11.808 2.25 12.312 C3.24 11.653 4.23 10.993 5.25 10.312 C5.25 9.653 5.25 8.993 5.25 8.312 C3.93 7.982 2.61 7.653 1.25 7.312 Z " fill="#664534" transform="translate(317.75,199.6875)"/>
<path d="M0 0 C3.064 5.587 1.843 10.745 0.316 16.641 C-3.952 29.919 -12.878 39.98 -25.125 46.375 C-36.183 51.86 -51.43 51.693 -63.188 48.375 C-68.572 46.427 -72.851 43.94 -77 40 C-77.812 39.23 -77.812 39.23 -78.641 38.445 C-86.874 29.755 -89.418 18.647 -90 7 C-89.67 5.68 -89.34 4.36 -89 3 C-86.03 3.99 -83.06 4.98 -80 6 C-84 7 -84 7 -86 6 C-86.653 17.211 -86.653 17.211 -82 27 C-81.732 27.804 -81.464 28.609 -81.188 29.438 C-78.28 35.711 -73.445 40.485 -67 43 C-67 43.66 -67 44.32 -67 45 C-66.268 45.143 -65.536 45.286 -64.781 45.434 C-63.109 45.774 -61.44 46.135 -59.781 46.535 C-46.464 49.614 -33.919 48.718 -22 42 C-21.093 41.505 -20.185 41.01 -19.25 40.5 C-15.662 38.108 -13.377 36.131 -12 32 C-11.402 31.423 -10.804 30.845 -10.188 30.25 C-4.39 24.287 -1.896 17.323 -1.875 9.125 C-1.907 7.416 -1.945 5.708 -2 4 C-4.31 4.33 -6.62 4.66 -9 5 C-6.455 2.201 -3.924 0 0 0 Z M-10.812 5.375 C-10.214 5.581 -9.616 5.787 -9 6 C-11.31 6.33 -13.62 6.66 -16 7 C-13 5 -13 5 -10.812 5.375 Z " fill="#BCB346" transform="translate(200,108)"/>
<path d="M0 0 C0.711 -0.008 1.422 -0.015 2.154 -0.023 C5.913 -0.009 7.611 0.116 10.812 2.25 C10.025 3.711 9.232 5.168 8.438 6.625 C7.997 7.437 7.556 8.249 7.102 9.086 C5.812 11.25 5.812 11.25 3.812 13.25 C3.442 7.697 3.442 7.697 5.312 5.438 C5.808 5.046 6.303 4.654 6.812 4.25 C6.812 3.92 6.812 3.59 6.812 3.25 C-5.702 2.769 -15.809 12.564 -24.734 20.568 C-27.733 23.214 -30.366 25.026 -34.188 26.25 C-33.532 19.302 -33.532 19.302 -30.188 16.25 C-29.143 14.981 -28.102 13.71 -27.062 12.438 C-19.637 3.913 -11.327 -0.125 0 0 Z M-0.188 4.25 C0.473 4.25 1.132 4.25 1.812 4.25 C1.483 5.24 1.152 6.23 0.812 7.25 C0.483 6.26 0.152 5.27 -0.188 4.25 Z " fill="#C3C141" transform="translate(259.1875,93.75)"/>
<path d="M0 0 C0.685 0.008 1.37 0.015 2.076 0.023 C2.757 0.016 3.439 0.008 4.141 0 C7.853 0.015 9.475 0.164 12.639 2.273 C12.309 3.923 11.979 5.573 11.639 7.273 C9.989 7.273 8.339 7.273 6.639 7.273 C6.662 8.477 6.685 9.681 6.709 10.922 C6.728 12.497 6.746 14.073 6.764 15.648 C6.789 16.84 6.789 16.84 6.814 18.055 C6.832 20.128 6.742 22.202 6.639 24.273 C5.979 24.933 5.319 25.593 4.639 26.273 C0.083 25.718 0.083 25.718 -1.361 24.273 C-1.435 21.411 -1.454 18.573 -1.424 15.711 C-1.419 14.905 -1.415 14.098 -1.41 13.268 C-1.398 11.269 -1.38 9.271 -1.361 7.273 C-3.011 6.943 -4.661 6.613 -6.361 6.273 C-6.986 4.398 -6.986 4.398 -7.361 2.273 C-4.878 -0.21 -3.395 0.013 0 0 Z " fill="#694837" transform="translate(40.361328125,199.7265625)"/>
<path d="M0 0 C1.028 -0.012 1.028 -0.012 2.076 -0.023 C7.184 -0.003 7.184 -0.003 9.438 2.25 C9.062 4.375 9.062 4.375 8.438 6.25 C6.787 6.58 5.137 6.91 3.438 7.25 C3.461 8.454 3.484 9.658 3.508 10.898 C3.527 12.474 3.545 14.049 3.562 15.625 C3.588 16.816 3.588 16.816 3.613 18.031 C3.631 20.105 3.541 22.179 3.438 24.25 C2.778 24.91 2.118 25.57 1.438 26.25 C-1.125 26 -1.125 26 -3.562 25.25 C-5.157 22.062 -4.664 18.621 -4.625 15.125 C-4.62 14.371 -4.616 13.617 -4.611 12.84 C-4.6 10.977 -4.582 9.113 -4.562 7.25 C-6.213 6.92 -7.863 6.59 -9.562 6.25 C-9.893 4.93 -10.222 3.61 -10.562 2.25 C-6.926 -0.175 -4.2 -0.048 0 0 Z " fill="#644332" transform="translate(166.5625,199.75)"/>
<path d="M0 0 C0.685 0.008 1.37 0.015 2.076 0.023 C2.757 0.016 3.439 0.008 4.141 0 C7.853 0.015 9.475 0.164 12.639 2.273 C12.451 4.148 12.451 4.148 11.639 6.273 C9.989 6.933 8.339 7.593 6.639 8.273 C6.309 13.883 5.979 19.493 5.639 25.273 C3.989 25.603 2.339 25.933 0.639 26.273 C-1.361 24.273 -1.361 24.273 -1.557 20.359 C-1.543 18.789 -1.519 17.219 -1.486 15.648 C-1.477 14.847 -1.468 14.045 -1.459 13.219 C-1.435 11.237 -1.4 9.255 -1.361 7.273 C-3.011 6.943 -4.661 6.613 -6.361 6.273 C-6.986 4.398 -6.986 4.398 -7.361 2.273 C-4.878 -0.21 -3.395 0.013 0 0 Z " fill="#6A4838" transform="translate(255.361328125,199.7265625)"/>
<path d="M0 0 C0.763 0.206 1.526 0.412 2.312 0.625 C0.797 4.908 -2.39 6.817 -6 9.25 C-7.267 10.128 -8.533 11.008 -9.797 11.891 C-10.442 12.34 -11.087 12.789 -11.752 13.252 C-14.812 15.423 -17.784 17.702 -20.75 20 C-22.283 21.187 -22.283 21.187 -23.848 22.398 C-26.474 24.458 -29.086 26.534 -31.688 28.625 C-31.688 25.047 -31.152 22.907 -29.688 19.625 C-27.545 17.6 -25.546 15.91 -23.188 14.188 C-22.563 13.714 -21.938 13.241 -21.294 12.754 C-3.653 -0.51 -3.653 -0.51 0 0 Z M-34.688 29.625 C-33.643 32.758 -33.753 33.615 -34.688 36.625 C-35.347 36.625 -36.007 36.625 -36.688 36.625 C-35.812 31.875 -35.812 31.875 -34.688 29.625 Z " fill="#6A472A" transform="translate(257.6875,98.375)"/>
<path d="M0 0 C4.556 0.556 4.556 0.556 6 2 C6.252 5.639 6.185 9.291 6.188 12.938 C6.2 13.966 6.212 14.994 6.225 16.053 C6.228 17.525 6.228 17.525 6.23 19.027 C6.235 19.932 6.239 20.837 6.243 21.769 C6 24 6 24 4 26 C1.438 25.75 1.438 25.75 -1 25 C-2.885 21.23 -2.185 16.648 -2.188 12.5 C-2.2 11.524 -2.212 10.548 -2.225 9.543 C-2.227 8.611 -2.228 7.679 -2.23 6.719 C-2.235 5.862 -2.239 5.006 -2.243 4.123 C-2 2 -2 2 0 0 Z " fill="#6A4837" transform="translate(186,200)"/>
<path d="M0 0 C1.286 -0.001 2.572 -0.003 3.896 -0.004 C4.889 -0.001 4.889 -0.001 5.902 0.002 C7.903 0.008 9.903 0.002 11.904 -0.004 C19.034 0.003 26.031 0.216 33.105 1.133 C32.775 1.793 32.445 2.453 32.105 3.133 C26.238 4.515 19.91 4.27 13.914 4.23 C13.083 4.229 12.253 4.228 11.397 4.226 C8.779 4.221 6.161 4.208 3.543 4.195 C1.753 4.19 -0.036 4.186 -1.826 4.182 C-6.182 4.171 -10.538 4.153 -14.895 4.133 C-14.565 3.143 -14.235 2.153 -13.895 1.133 C-9.219 0.384 -4.734 0.005 0 0 Z " fill="#C5C04B" transform="translate(147.89453125,95.8671875)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C1.34 1.598 0.68 2.196 0 2.812 C-2.337 4.875 -2.337 4.875 -2 8 C-3.727 8.508 -5.457 9.006 -7.188 9.5 C-8.15 9.778 -9.113 10.057 -10.105 10.344 C-12.853 10.967 -15.196 11.123 -18 11 C-17.34 10.67 -16.68 10.34 -16 10 C-16 9.34 -16 8.68 -16 8 C-17.21 8.244 -17.21 8.244 -18.444 8.494 C-22.634 9.09 -26.674 9.125 -30.898 9.098 C-31.733 9.096 -32.568 9.095 -33.429 9.093 C-36.077 9.088 -38.726 9.075 -41.375 9.062 C-43.178 9.057 -44.982 9.053 -46.785 9.049 C-51.19 9.038 -55.595 9.021 -60 9 C-60 8.67 -60 8.34 -60 8 C-58.634 7.978 -58.634 7.978 -57.24 7.956 C-28.819 8.202 -28.819 8.202 -1.785 0.672 C-1.196 0.45 -0.607 0.228 0 0 Z M5 1 C5.66 1.66 6.32 2.32 7 3 C5.035 4.068 3.031 5.066 1 6 C0.34 5.67 -0.32 5.34 -1 5 C0.98 3.68 2.96 2.36 5 1 Z " fill="#725623" transform="translate(193,105)"/>
<path d="M0 0 C0.33 0 0.66 0 1 0 C1.38 6.199 0.835 10.448 -2 16 C-3.381 12.233 -2.596 9.552 -1.562 5.75 C-1.275 4.672 -0.988 3.595 -0.691 2.484 C-0.463 1.665 -0.235 0.845 0 0 Z M-4 16 C-3 19 -3 19 -4.039 21.305 C-6.984 26.078 -9.778 30.234 -14 34 C-14.598 34.598 -15.196 35.196 -15.812 35.812 C-16.204 36.204 -16.596 36.596 -17 37 C-17.66 36.67 -18.32 36.34 -19 36 C-18.336 35.31 -17.672 34.621 -16.988 33.91 C-11.752 28.337 -7.275 22.981 -4 16 Z M-21 37 C-20.34 37.33 -19.68 37.66 -19 38 C-19.33 38.66 -19.66 39.32 -20 40 C-23.062 40.625 -23.062 40.625 -26 41 C-24.35 39.68 -22.7 38.36 -21 37 Z M-29 41 C-28.01 41.33 -27.02 41.66 -26 42 C-27.32 42.33 -28.64 42.66 -30 43 C-29.67 42.34 -29.34 41.68 -29 41 Z M-66 43 C-64.948 43.164 -63.896 43.327 -62.812 43.496 C-56.887 44.237 -50.962 44.107 -45 44.062 C-43.844 44.058 -42.687 44.053 -41.496 44.049 C-38.664 44.037 -35.832 44.021 -33 44 C-36.335 46.223 -37.44 46.256 -41.332 46.266 C-42.378 46.268 -43.424 46.271 -44.502 46.273 C-45.594 46.266 -46.687 46.258 -47.812 46.25 C-48.893 46.258 -49.974 46.265 -51.088 46.273 C-56.429 46.26 -61.021 46.19 -66 44 C-66 43.67 -66 43.34 -66 43 Z M-33 43 C-30 44 -30 44 -30 44 Z " fill="#72571E" transform="translate(202,113)"/>
<path d="M0 0 C1.043 0.071 2.085 0.143 3.16 0.217 C15.491 1.01 27.772 1.119 40.125 1.062 C42.066 1.057 44.008 1.053 45.949 1.049 C50.633 1.038 55.316 1.021 60 1 C52.657 5.237 43.754 4.196 35.562 4.188 C34.556 4.189 34.556 4.189 33.529 4.19 C23.883 4.189 14.485 3.864 5 2 C7.64 2.99 10.28 3.98 13 5 C13 5.33 13 5.66 13 6 C7.906 5.568 4.155 5.246 0 2 C0 1.34 0 0.68 0 0 Z " fill="#C0B747" transform="translate(116,105)"/>
<path d="M0 0 C0.495 0.99 0.495 0.99 1 2 C-7.291 13.51 -15.948 20.035 -30 23 C-32.887 23.098 -32.887 23.098 -35 23 C-34.01 20.03 -33.02 17.06 -32 14 C-31.01 14 -30.02 14 -29 14 C-29.66 14.66 -30.32 15.32 -31 16 C-31.648 18.571 -31.648 18.571 -32 21 C-20.533 18.218 -12.858 15.053 -5 6 C-4.67 5.34 -4.34 4.68 -4 4 C-3.34 4 -2.68 4 -2 4 C-1.34 2.68 -0.68 1.36 0 0 Z " fill="#BAB045" transform="translate(257,113)"/>
<path d="M0 0 C0.66 0.99 1.32 1.98 2 3 C2.99 3.66 3.98 4.32 5 5 C-0.94 9.95 -0.94 9.95 -7 15 C-7.66 14.67 -8.32 14.34 -9 14 C-8.541 9.872 -8.234 7.657 -5 5 C-4.113 4.113 -3.226 3.226 -2.312 2.312 C-1.549 1.549 -0.786 0.786 0 0 Z " fill="#B9AE44" transform="translate(234,105)"/>
<path d="M0 0 C2.604 -0.054 5.208 -0.094 7.812 -0.125 C8.55 -0.142 9.288 -0.159 10.049 -0.176 C13.912 -0.211 15.709 -0.194 19 2 C18.213 3.461 17.42 4.918 16.625 6.375 C16.184 7.187 15.743 7.999 15.289 8.836 C14 11 14 11 12 13 C11.63 7.447 11.63 7.447 13.5 5.188 C14.243 4.6 14.243 4.6 15 4 C15 3.67 15 3.34 15 3 C10.69 2.834 7.721 2.768 4 5 C2.339 5.68 0.673 6.349 -1 7 C-0.34 6.01 0.32 5.02 1 4 C1.66 4 2.32 4 3 4 C3 3.34 3 2.68 3 2 C1.35 2.33 -0.3 2.66 -2 3 C-1.34 2.67 -0.68 2.34 0 2 C0 1.34 0 0.68 0 0 Z M8 4 C8.66 4 9.32 4 10 4 C9.67 4.99 9.34 5.98 9 7 C8.67 6.01 8.34 5.02 8 4 Z " fill="#BDB44B" transform="translate(251,94)"/>
<path d="M0 0 C2.97 0.99 5.94 1.98 9 3 C5 4 5 4 3 3 C2.347 14.211 2.347 14.211 7 24 C8.378 27.032 9 28.657 9 32 C1.398 25.755 0.371 16.553 -0.801 7.355 C-1 4 -1 4 0 0 Z " fill="#BDB552" transform="translate(111,111)"/>
<path d="M0 0 C3.237 -0.294 5.008 0.004 8 1.375 C19.11 6.384 32.399 5.247 44.312 5.125 C46.036 5.115 47.759 5.106 49.482 5.098 C53.655 5.076 57.827 5.042 62 5 C62 5.33 62 5.66 62 6 C54.573 7.023 47.24 7.255 39.749 7.295 C38.264 7.307 36.779 7.327 35.294 7.357 C24.031 7.581 8.962 7.875 0 0 Z " fill="#6D501C" transform="translate(113,111)"/>
<path d="M0 0 C2.696 1.54 5.14 3.113 7.562 5.062 C9.671 6.738 11.554 7.913 14 9 C14 9.66 14 10.32 14 11 C14.639 11.124 15.279 11.248 15.938 11.375 C16.948 11.581 17.959 11.787 19 12 C19.759 12.146 20.519 12.291 21.301 12.441 C22.109 12.605 22.917 12.769 23.75 12.938 C24.529 13.091 25.307 13.244 26.109 13.402 C26.733 13.6 27.357 13.797 28 14 C28.33 14.66 28.66 15.32 29 16 C17.041 15.097 8.075 11.073 0 2 C0 1.34 0 0.68 0 0 Z " fill="#B7AC44" transform="translate(119,142)"/>
<path d="M0 0 C0.33 0.99 0.66 1.98 1 3 C-0.938 6.188 -0.938 6.188 -3 9 C-3.33 8.01 -3.66 7.02 -4 6 C-2.062 2.812 -2.062 2.812 0 0 Z M-6 11 C-6 14 -6 14 -6 14 Z M-8 14 C-8 17.693 -8.882 19.005 -11 22 C-13.688 24.312 -13.688 24.312 -16 26 C-14.59 22.961 -12.911 20.406 -10.875 17.75 C-10.336 17.044 -9.797 16.337 -9.242 15.609 C-8.832 15.078 -8.422 14.547 -8 14 Z M-17 26 C-17 26.99 -17 27.98 -17 29 C-17.66 28.67 -18.32 28.34 -19 28 C-18.34 27.34 -17.68 26.68 -17 26 Z M-23 31 C-22.34 31.33 -21.68 31.66 -21 32 C-21.99 32 -22.98 32 -24 32 C-23.67 31.67 -23.34 31.34 -23 31 Z M-24 33 C-24 33.66 -24 34.32 -24 35 C-25.65 35.33 -27.3 35.66 -29 36 C-26.25 33 -26.25 33 -24 33 Z " fill="#71561D" transform="translate(269,96)"/>
<path d="M0 0 C-0.33 1.32 -0.66 2.64 -1 4 C-2.456 4.363 -3.915 4.715 -5.375 5.062 C-6.593 5.358 -6.593 5.358 -7.836 5.66 C-10 6 -10 6 -12 5 C-11.812 3.125 -11.812 3.125 -11 1 C-7.112 -1.333 -4.319 -1.004 0 0 Z " fill="#654738" transform="translate(109,194)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C1.68 1.99 0.36 2.98 -1 4 C-1.66 3.67 -2.32 3.34 -3 3 C-2.01 2.01 -1.02 1.02 0 0 Z M-5 4 C-4.34 4.33 -3.68 4.66 -3 5 C-5.64 6.98 -8.28 8.96 -11 11 C-11.33 10.34 -11.66 9.68 -12 9 C-9.69 7.35 -7.38 5.7 -5 4 Z M-14 11 C-13.34 11.33 -12.68 11.66 -12 12 C-13.98 12.99 -13.98 12.99 -16 14 C-15.34 13.01 -14.68 12.02 -14 11 Z M-22 16 C-20.68 16.33 -19.36 16.66 -18 17 C-19.65 18.32 -21.3 19.64 -23 21 C-23 18 -23 18 -22 16 Z M-26 22 C-24.956 25.133 -25.066 25.99 -26 29 C-26.66 29 -27.32 29 -28 29 C-27.125 24.25 -27.125 24.25 -26 22 Z " fill="#71561E" transform="translate(249,106)"/>
<path d="M0 0 C2.599 4.739 1.949 8.883 1 14 C0.67 14.99 0.34 15.98 0 17 C-0.66 17 -1.32 17 -2 17 C-2 12.71 -2 8.42 -2 4 C-4.31 4.33 -6.62 4.66 -9 5 C-6.455 2.201 -3.924 0 0 0 Z M-10.812 5.375 C-10.214 5.581 -9.616 5.787 -9 6 C-11.31 6.33 -13.62 6.66 -16 7 C-13 5 -13 5 -10.812 5.375 Z " fill="#B8AE49" transform="translate(200,108)"/>
<path d="M0 0 C0.875 0.052 1.749 0.104 2.65 0.158 C4.788 0.287 6.925 0.424 9.062 0.562 C9.062 0.892 9.062 1.222 9.062 1.562 C4.442 1.892 -0.178 2.222 -4.938 2.562 C-4.938 2.892 -4.938 3.222 -4.938 3.562 C-9.227 3.562 -13.518 3.562 -17.938 3.562 C-17.607 2.573 -17.278 1.582 -16.938 0.562 C-11.175 -0.493 -5.832 -0.386 0 0 Z " fill="#B5AA48" transform="translate(150.9375,96.4375)"/>
<path d="M0 0 C0.33 0.66 0.66 1.32 1 2 C0.34 1.67 -0.32 1.34 -1 1 C-0.67 0.67 -0.34 0.34 0 0 Z M-3 2 C-2.34 2.33 -1.68 2.66 -1 3 C-1.99 3 -2.98 3 -4 3 C-3.67 2.67 -3.34 2.34 -3 2 Z M-6 4 C-5.34 4.33 -4.68 4.66 -4 5 C-5.32 5.99 -6.64 6.98 -8 8 C-8.66 7.67 -9.32 7.34 -10 7 C-8.68 6.01 -7.36 5.02 -6 4 Z M-12 8 C-11.34 8.33 -10.68 8.66 -10 9 C-11.98 9.99 -11.98 9.99 -14 11 C-13.34 10.01 -12.68 9.02 -12 8 Z M-15 11 C-15 11.99 -15 12.98 -15 14 C-16.609 15.5 -16.609 15.5 -18.75 17 C-19.446 17.495 -20.142 17.99 -20.859 18.5 C-21.566 18.995 -22.272 19.49 -23 20 C-24.337 20.996 -25.671 21.994 -27 23 C-27.66 22.67 -28.32 22.34 -29 22 C-27.609 20.724 -26.212 19.454 -24.812 18.188 C-24.035 17.48 -23.258 16.772 -22.457 16.043 C-20.08 14.067 -17.689 12.514 -15 11 Z " fill="#BFB646" transform="translate(258,102)"/>
<path d="M0 0 C5.94 0.33 11.88 0.66 18 1 C18 1.33 18 1.66 18 2 C13.71 2 9.42 2 5 2 C7.64 2.99 10.28 3.98 13 5 C13 5.33 13 5.66 13 6 C7.906 5.568 4.155 5.246 0 2 C0 1.34 0 0.68 0 0 Z " fill="#BCB247" transform="translate(116,105)"/>
<path d="M0 0 C3.91 -0.355 6.322 0.553 9.824 2.207 C13.597 3.582 17.556 3.956 21.523 4.473 C22.341 4.647 23.158 4.821 24 5 C24.33 5.66 24.66 6.32 25 7 C16.352 6.456 6.871 5.72 0 0 Z " fill="#765D20" transform="translate(113,111)"/>
<path d="M0 0 C4.29 0.33 8.58 0.66 13 1 C12.67 1.66 12.34 2.32 12 3 C5.327 4.076 -1.254 4.113 -8 4 C-8 3.67 -8 3.34 -8 3 C-5.36 2.67 -2.72 2.34 0 2 C0 1.34 0 0.68 0 0 Z " fill="#B1A85C" transform="translate(168,96)"/>
<path d="M0 0 C0.701 0.248 1.402 0.495 2.125 0.75 C0.736 4.106 -0.792 5.823 -3.875 7.75 C-4.535 7.75 -5.195 7.75 -5.875 7.75 C-5.875 7.09 -5.875 6.43 -5.875 5.75 C-6.865 5.09 -7.855 4.43 -8.875 3.75 C-3.583 -0.312 -3.583 -0.312 0 0 Z M-4.875 2.75 C-4.875 3.41 -4.875 4.07 -4.875 4.75 C-3.555 4.42 -2.235 4.09 -0.875 3.75 C-0.875 2.76 -0.875 1.77 -0.875 0.75 C-2.337 0.658 -2.337 0.658 -4.875 2.75 Z " fill="#73581F" transform="translate(257.875,98.25)"/>
<path d="M0 0 C0 0.66 0 1.32 0 2 C-1.242 2.702 -2.494 3.386 -3.75 4.062 C-4.794 4.637 -4.794 4.637 -5.859 5.223 C-8.361 6.131 -9.533 5.883 -12 5 C-10.68 4.67 -9.36 4.34 -8 4 C-8 3.34 -8 2.68 -8 2 C-8.66 1.67 -9.32 1.34 -10 1 C-6.565 0.375 -3.509 0 0 0 Z " fill="#B6AA44" transform="translate(195,103)"/>
<path d="M0 0 C3.381 3.114 5.709 5.541 7 10 C7 10.99 7 11.98 7 13 C3.664 10.009 1.755 7.124 0 3 C0 2.01 0 1.02 0 0 Z " fill="#C2BA50" transform="translate(113,130)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C1.34 1.66 0.68 2.32 0 3 C-0.648 5.571 -0.648 5.571 -1 8 C-0.01 8.33 0.98 8.66 2 9 C1 10 1 10 -1.562 10.062 C-2.367 10.042 -3.171 10.021 -4 10 C-3.524 8.52 -3.044 7.041 -2.562 5.562 C-2.296 4.739 -2.029 3.915 -1.754 3.066 C-1 1 -1 1 0 0 Z " fill="#B8AD44" transform="translate(226,126)"/>
<path d="M0 0 C0.99 0.66 1.98 1.32 3 2 C0.03 4.31 -2.94 6.62 -6 9 C-6 6 -6 6 -4.688 4.395 C-4.131 3.872 -3.574 3.35 -3 2.812 C-2.443 2.283 -1.886 1.753 -1.312 1.207 C-0.663 0.61 -0.663 0.61 0 0 Z " fill="#C5C33A" transform="translate(233,109)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C2.67 2.32 2.34 3.64 2 5 C0.35 5.33 -1.3 5.66 -3 6 C-1.125 1.125 -1.125 1.125 0 0 Z " fill="#674637" transform="translate(106,193)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C2.213 2.461 1.42 3.918 0.625 5.375 C0.184 6.187 -0.257 6.999 -0.711 7.836 C-2 10 -2 10 -4 12 C-4.371 6.435 -4.371 6.435 -2.562 4.312 C-1.789 3.663 -1.789 3.663 -1 3 C-0.67 2.01 -0.34 1.02 0 0 Z " fill="#BBB144" transform="translate(267,95)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C-0.97 3.64 -3.94 6.28 -7 9 C-7.66 8.67 -8.32 8.34 -9 8 C-6.03 5.36 -3.06 2.72 0 0 Z " fill="#B7AB44" transform="translate(238,116)"/>
<path d="M0 0 C1.65 0 3.3 0 5 0 C5 0.66 5 1.32 5 2 C3.02 2.33 1.04 2.66 -1 3 C-0.67 2.01 -0.34 1.02 0 0 Z " fill="#694331" transform="translate(172,113)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C0.719 2.707 -0.618 4.374 -2 6 C-2.66 6 -3.32 6 -4 6 C-2.848 3.532 -1.952 1.952 0 0 Z " fill="#5C463A" transform="translate(298,219)"/>
<path d="M0 0 C0 0.99 0 1.98 0 3 C-2.5 5.188 -2.5 5.188 -5 7 C-3.75 3.347 -3.329 2.219 0 0 Z " fill="#C1BA4D" transform="translate(234,105)"/>
<path d="M0 0 C-0.33 0.99 -0.66 1.98 -1 3 C-2.98 3.33 -4.96 3.66 -7 4 C-5.533 1.066 -3.26 0 0 0 Z " fill="#B8AD47" transform="translate(173,153)"/>
<path d="M0 0 C1.65 0.99 3.3 1.98 5 3 C4.01 3.33 3.02 3.66 2 4 C1.34 2.68 0.68 1.36 0 0 Z " fill="#B6AB43" transform="translate(119,142)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C2.01 2.485 2.01 2.485 1 4 C0.34 3.67 -0.32 3.34 -1 3 C-0.67 2.01 -0.34 1.02 0 0 Z " fill="#AFA33F" transform="translate(267,95)"/>
<path d="M0 0 C1.32 0.66 2.64 1.32 4 2 C2.68 2.33 1.36 2.66 0 3 C0 2.01 0 1.02 0 0 Z " fill="#634D40" transform="translate(79,223)"/>
<path d="" fill="#000000" transform="translate(0,0)"/>
</svg>

After

Width:  |  Height:  |  Size: 28 KiB

56
public/logos/logo.svg Normal file
View File

@ -0,0 +1,56 @@
<!-- Generator: visioncortex VTracer 0.6.4 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 263" preserveAspectRatio="xMidYMid meet">
<path d="M0 0 C2.137 0.251 3.459 0.459 5 2 C5.25 4.375 5.25 4.375 5 7 C3.562 8.625 3.562 8.625 2 10 C-0.463 17.388 1.374 23.02 4.438 29.812 C5.083 31.298 5.727 32.784 6.371 34.27 C6.659 34.915 6.947 35.561 7.244 36.226 C7.909 37.786 8.464 39.391 9 41 C10.033 41.086 11.065 41.173 12.129 41.262 C21.205 42.1 30.131 43.428 39.062 45.25 C40.013 45.43 40.963 45.61 41.942 45.796 C49.702 47.456 55.753 50.665 61.312 56.375 C63.504 59.784 65.288 63.33 67 67 C67.461 66.613 67.923 66.227 68.398 65.828 C79.469 56.989 91.07 55.031 105 56 C119.133 57.689 119.133 57.689 124 62 C123.812 64.25 123.812 64.25 123 67 C122.259 67.548 121.518 68.096 120.754 68.66 C117.171 71.704 115.368 75.107 113.125 79.188 C109.504 85.548 105.777 91.43 101 97 C100.399 97.755 99.799 98.511 99.18 99.289 C91.187 108.964 81.289 114.503 69 117 C65.629 117.264 62.369 117.274 59 117 C58.598 118.106 58.598 118.106 58.188 119.234 C52.197 133.187 37.405 143.39 23.743 148.912 C2.437 156.899 -18.575 157.221 -39.879 148.434 C-53.175 142.326 -64.768 133.973 -72 121 C-72.45 120.197 -72.9 119.394 -73.363 118.566 C-79.501 106.96 -81.179 95.697 -81.188 82.688 C-81.2 81.706 -81.212 80.724 -81.225 79.713 C-81.259 62.418 -81.259 62.418 -76.062 56.688 C-68.192 48.948 -57.425 46.826 -46.938 44.688 C-45.735 44.424 -44.533 44.16 -43.295 43.889 C-36.435 42.448 -30.009 41.61 -23 42 C-23.231 41.304 -23.461 40.609 -23.699 39.892 C-26.151 32.378 -27.075 26.324 -23.98 18.871 C-22.421 15.895 -20.693 13.988 -18 12 C-15.188 12.188 -15.188 12.188 -13 13 C-12.25 14.688 -12.25 14.688 -12 17 C-13.875 19.562 -13.875 19.562 -16 22 C-18.483 29.449 -16.145 34.774 -13 41.5 C-10.31 47.253 -8.449 51.591 -9 58 C-7.02 57.67 -5.04 57.34 -3 57 C-2.783 55.577 -2.783 55.577 -2.562 54.125 C-2 51 -2 51 -1 49 C0.22 41.764 -1.417 36.422 -4.5 30 C-7.514 23.635 -9.682 18.146 -8 11 C-6.117 6.737 -3.537 3.056 0 0 Z M-37.562 51.688 C-38.515 51.865 -39.468 52.042 -40.45 52.224 C-46.472 53.392 -52.382 54.774 -58.25 56.562 C-58.911 56.76 -59.571 56.957 -60.252 57.161 C-63.786 58.308 -64.865 58.798 -67 62 C-61.929 64.9 -56.984 67.503 -51 67 C-48.778 66.189 -46.729 65.177 -44.613 64.121 C-36.38 60.589 -26.883 59.594 -18 59 C-17.066 54.469 -17.066 54.469 -18 50 C-24.744 49.815 -30.94 50.441 -37.562 51.688 Z M7 50 C5.515 53.96 5.515 53.96 4 58 C5.653 59.653 7.742 59.331 9.965 59.546 C17.554 60.285 24.43 61.778 31.594 64.411 C36.755 66.229 40.248 66.079 45.301 64.098 C45.965 63.756 46.629 63.414 47.312 63.062 C48.328 62.554 48.328 62.554 49.363 62.035 C51.171 61.089 51.171 61.089 52 59 C37.925 52.931 22.242 50.505 7 50 Z M-12 65 C-12.66 66.32 -13.32 67.64 -14 69 C5.992 69.895 5.992 69.895 26 70 C25.217 67.852 25.217 67.852 23.285 67.402 C11.5 64.938 -0.009 64.882 -12 65 Z M72.812 74.438 C71.061 76.914 69.386 79.3 68 82 C68.33 82.66 68.66 83.32 69 84 C69.801 83.004 69.801 83.004 70.617 81.988 C76.613 74.882 82.216 70.928 91 68 C92.408 67.92 93.82 67.892 95.23 67.902 C96.434 67.907 96.434 67.907 97.662 67.912 C98.495 67.92 99.329 67.929 100.188 67.938 C101.032 67.942 101.877 67.947 102.748 67.951 C104.832 67.963 106.916 67.981 109 68 C109.33 67.34 109.66 66.68 110 66 C105.893 64.631 102.052 64.776 97.75 64.75 C96.916 64.729 96.082 64.709 95.223 64.688 C86.33 64.632 78.889 67.667 72.812 74.438 Z M-41 69 C-41 69.33 -41 69.66 -41 70 C-38.646 70.197 -36.293 70.383 -33.938 70.562 C-31.971 70.719 -31.971 70.719 -29.965 70.879 C-26.077 70.998 -22.809 70.669 -19 70 C-19.66 68.68 -20.32 67.36 -21 66 C-28.081 65.591 -34.199 67.096 -41 69 Z M44 74 C41.777 76.937 41.777 76.937 41 80 C40.732 80.743 40.464 81.485 40.188 82.25 C40.095 83.116 40.095 83.116 40 84 C40.99 84.99 40.99 84.99 42 86 C44.603 85.68 44.603 85.68 47 85 C48.178 97.663 41.505 110.127 33.816 119.727 C24.969 129.344 12.751 135.641 -0.41 136.336 C-17.633 136.671 -34.037 133.285 -47.086 121.176 C-57.665 109.15 -61.672 95.735 -61 80 C-60.723 77.991 -60.418 75.984 -60 74 C-61.456 73.329 -62.915 72.663 -64.375 72 C-65.187 71.629 -65.999 71.257 -66.836 70.875 C-68.92 69.875 -68.92 69.875 -71 70 C-75.262 82.787 -71.866 101.418 -66.375 113.375 C-58.557 127.602 -45.303 137.883 -30 143.004 C-11.056 148.332 9.398 147.322 27 138 C42.141 128.586 52.645 116.466 57 99 C57.984 92.87 58.152 86.889 58.125 80.688 C58.129 79.795 58.133 78.902 58.137 77.982 C58.135 77.126 58.134 76.269 58.133 75.387 C58.131 74.23 58.131 74.23 58.129 73.05 C58.075 70.799 58.075 70.799 57 68 C52.372 68 47.745 71.5 44 74 Z M-48 77 C-46.515 77.99 -46.515 77.99 -45 79 C-45 78.34 -45 77.68 -45 77 C-45.99 77 -46.98 77 -48 77 Z M-55 80 C-55.858 91.677 -53.343 102.595 -46.562 112.23 C-44.876 114.14 -43.087 115.547 -41 117 C-41.899 114.679 -42.792 112.395 -43.953 110.191 C-48.053 102.098 -49 92.997 -49 84 C-49.619 83.711 -50.237 83.422 -50.875 83.125 C-53 82 -53 82 -55 80 Z " fill="#694633" transform="translate(160,27)"/>
<path d="M0 0 C3.064 5.587 1.843 10.745 0.316 16.641 C-3.952 29.919 -12.878 39.98 -25.125 46.375 C-36.183 51.86 -51.43 51.693 -63.188 48.375 C-68.572 46.427 -72.851 43.94 -77 40 C-77.812 39.23 -77.812 39.23 -78.641 38.445 C-86.874 29.755 -89.418 18.647 -90 7 C-89.539 4.559 -89.539 4.559 -89 3 C-87.783 3.433 -86.566 3.866 -85.312 4.312 C-73.563 8.16 -62.535 9.506 -50.25 9.375 C-49.128 9.37 -49.128 9.37 -47.984 9.364 C-32.765 9.277 -18.449 8.224 -4.625 1.312 C-2 0 -2 0 0 0 Z " fill="#C1C333" transform="translate(200,108)"/>
<path d="M0 0 C0.711 -0.008 1.422 -0.015 2.154 -0.023 C5.913 -0.009 7.611 0.116 10.812 2.25 C7.295 8.96 3.303 15.151 -1.188 21.25 C-2.116 22.542 -2.116 22.542 -3.062 23.859 C-10.785 34.135 -19.567 39.587 -32.188 42.25 C-35.074 42.348 -35.074 42.348 -37.188 42.25 C-34.761 28.792 -20.416 21.512 -9.925 14.221 C-8.893 13.509 -8.893 13.509 -7.84 12.781 C-7.2 12.338 -6.561 11.894 -5.902 11.438 C-4.5 10.466 -3.096 9.498 -1.691 8.531 C-1.195 8.108 -0.699 7.686 -0.188 7.25 C-0.188 6.59 -0.188 5.93 -0.188 5.25 C-9.873 6.668 -17.618 14.177 -24.684 20.517 C-27.7 23.182 -30.34 25.022 -34.188 26.25 C-33.532 19.302 -33.532 19.302 -30.188 16.25 C-29.143 14.981 -28.102 13.71 -27.062 12.438 C-19.637 3.913 -11.327 -0.125 0 0 Z " fill="#C3C434" transform="translate(259.1875,93.75)"/>
<path d="M0 0 C1.675 0.286 3.344 0.618 5 1 C6.188 13.767 -0.571 26.346 -8.406 35.949 C-17.628 45.89 -29.674 52.612 -43.41 53.336 C-44.961 53.366 -46.512 53.378 -48.062 53.375 C-48.886 53.373 -49.709 53.372 -50.558 53.37 C-58.518 53.213 -65.496 51.651 -73 49 C-73.869 48.7 -74.738 48.399 -75.633 48.09 C-78.382 46.952 -80.534 45.919 -81.918 43.191 C-82.5 41.25 -82.5 41.25 -83 38 C-81.515 37.505 -81.515 37.505 -80 37 C-77.906 38.109 -77.906 38.109 -75.5 39.75 C-66.982 45.005 -59.247 46.245 -49.375 46.312 C-48.194 46.321 -48.194 46.321 -46.989 46.33 C-35.117 46.183 -25.306 42.764 -16.41 34.633 C-7.183 24.776 -2.697 14.29 -1 1 C-0.67 0.67 -0.34 0.34 0 0 Z " fill="#684633" transform="translate(203,111)"/>
<path d="M0 0 C3.17 1.281 4.578 2.52 6.812 5.062 C8.575 9.423 8.785 13.368 8 18 C6.635 20.903 5.212 22.582 3 25 C-1.995 26.665 -7.008 26.866 -11.875 24.75 C-15.634 21.655 -17.114 19.453 -17.875 14.625 C-18.218 10.272 -17.548 7.652 -15 4 C-10.753 -0.89 -6.07 -0.82 0 0 Z M-8 7 C-10.327 10.491 -10.503 11.892 -10 16 C-8.125 18.377 -8.125 18.377 -6 20 C-2.75 18.912 -2.75 18.912 0 17 C0.229 12.983 0.229 12.983 0 9 C-1.887 6.693 -1.887 6.693 -5 6.688 C-5.99 6.791 -6.98 6.894 -8 7 Z " fill="#684736" transform="translate(293,200)"/>
<path d="M0 0 C2.625 0.375 2.625 0.375 5 1 C6.082 4.365 5.925 5.298 5 9 C7.97 9 10.94 9 14 9 C13.959 8.072 13.918 7.144 13.875 6.188 C14 3 14 3 16 0 C18.97 0.495 18.97 0.495 22 1 C22 8.92 22 16.84 22 25 C20.02 25.33 18.04 25.66 16 26 C13.571 22.356 13.838 20.288 14 16 C11.03 16 8.06 16 5 16 C5.206 16.907 5.412 17.815 5.625 18.75 C6.013 22.11 5.804 23.241 4 26 C1.563 25.625 1.563 25.625 -1 25 C-2.885 21.23 -2.185 16.648 -2.188 12.5 C-2.2 11.524 -2.212 10.548 -2.225 9.543 C-2.227 8.611 -2.228 7.679 -2.23 6.719 C-2.235 5.862 -2.239 5.006 -2.243 4.123 C-2 2 -2 2 0 0 Z " fill="#694837" transform="translate(63,200)"/>
<path d="M0 0 C0 0.66 0 1.32 0 2 C-13.17 9.489 -28.054 10.214 -42.879 10.295 C-44.322 10.307 -45.766 10.327 -47.209 10.357 C-57.411 10.566 -67.496 10.139 -77 6 C-78.41 3.861 -78.41 3.861 -79 2 C-77.914 2.069 -76.828 2.139 -75.708 2.21 C-64.677 2.886 -53.677 3.235 -42.625 3.25 C-41.825 3.252 -41.025 3.255 -40.2 3.257 C-29.108 3.269 -18.387 2.777 -7.465 0.656 C-4 0 -4 0 0 0 Z " fill="#C1C039" transform="translate(195,103)"/>
<path d="M0 0 C-0.375 1.938 -0.375 1.938 -1 4 C-1.99 4.495 -1.99 4.495 -3 5 C-0.525 5.99 -0.525 5.99 2 7 C2 8.65 2 10.3 2 12 C-0.709 13.354 -3.009 13.065 -6 13 C-5.107 15.358 -5.107 15.358 -1.938 16.125 C-0.483 16.558 -0.483 16.558 1 17 C1 18.32 1 19.64 1 21 C0 22 0 22 -3.062 22.062 C-4.032 22.042 -5.001 22.021 -6 22 C-6 22.99 -6 23.98 -6 25 C-4.298 24.969 -4.298 24.969 -2.562 24.938 C1 25 1 25 2 26 C2.041 27.666 2.043 29.334 2 31 C-0.58 32.29 -2.557 32.204 -5.438 32.25 C-6.406 32.276 -7.374 32.302 -8.371 32.328 C-11 32 -11 32 -12.646 30.785 C-14.429 28.435 -14.346 27.041 -14.293 24.113 C-14.283 23.175 -14.274 22.238 -14.264 21.271 C-14.239 20.295 -14.213 19.319 -14.188 18.312 C-14.174 17.324 -14.16 16.336 -14.146 15.318 C-14.111 12.878 -14.062 10.439 -14 8 C-13.01 7.67 -12.02 7.34 -11 7 C-11.33 5.35 -11.66 3.7 -12 2 C-7.528 -0.662 -5.066 -1.327 0 0 Z " fill="#684736" transform="translate(109,194)"/>
<path d="M0 0 C5.576 2.152 5.576 2.152 7 5 C7.584 12.241 7.584 12.241 5.125 15.438 C1.562 18.057 -0.607 18.095 -5 18 C-4.938 19.423 -4.938 19.423 -4.875 20.875 C-5 24 -5 24 -7 26 C-9.562 25.75 -9.562 25.75 -12 25 C-13.822 21.356 -13.228 17.074 -13.25 13.062 C-13.271 12.143 -13.291 11.223 -13.312 10.275 C-13.318 9.392 -13.323 8.508 -13.328 7.598 C-13.337 6.788 -13.347 5.979 -13.356 5.145 C-12.217 -1.712 -5.411 -0.639 0 0 Z M-5 7 C-5 8.65 -5 10.3 -5 12 C-3.35 11.34 -1.7 10.68 0 10 C-0.33 9.01 -0.66 8.02 -1 7 C-2.32 7 -3.64 7 -5 7 Z " fill="#694735" transform="translate(215,200)"/>
<path d="M0 0 C4.433 0.083 7.491 0.807 11.25 3.312 C12.433 5.679 12.384 7.18 12.375 9.812 C12.379 11.004 12.379 11.004 12.383 12.219 C12.25 14.312 12.25 14.312 11.25 16.312 C8.071 18.314 4.937 18.81 1.25 19.312 C0.26 22.778 0.26 22.778 -0.75 26.312 C-2.73 25.983 -4.71 25.653 -6.75 25.312 C-6.808 21.521 -6.844 17.729 -6.875 13.938 C-6.892 12.857 -6.909 11.777 -6.926 10.664 C-6.932 9.633 -6.939 8.602 -6.945 7.539 C-6.956 6.586 -6.966 5.633 -6.977 4.651 C-6.534 0.092 -3.964 0.018 0 0 Z M1.25 7.312 C0.92 8.632 0.59 9.952 0.25 11.312 C1.24 11.808 1.24 11.808 2.25 12.312 C3.24 11.653 4.23 10.993 5.25 10.312 C5.25 9.653 5.25 8.993 5.25 8.312 C3.93 7.982 2.61 7.653 1.25 7.312 Z " fill="#664534" transform="translate(317.75,199.6875)"/>
<path d="M0 0 C3.064 5.587 1.843 10.745 0.316 16.641 C-3.952 29.919 -12.878 39.98 -25.125 46.375 C-36.183 51.86 -51.43 51.693 -63.188 48.375 C-68.572 46.427 -72.851 43.94 -77 40 C-77.812 39.23 -77.812 39.23 -78.641 38.445 C-86.874 29.755 -89.418 18.647 -90 7 C-89.67 5.68 -89.34 4.36 -89 3 C-86.03 3.99 -83.06 4.98 -80 6 C-84 7 -84 7 -86 6 C-86.653 17.211 -86.653 17.211 -82 27 C-81.732 27.804 -81.464 28.609 -81.188 29.438 C-78.28 35.711 -73.445 40.485 -67 43 C-67 43.66 -67 44.32 -67 45 C-66.268 45.143 -65.536 45.286 -64.781 45.434 C-63.109 45.774 -61.44 46.135 -59.781 46.535 C-46.464 49.614 -33.919 48.718 -22 42 C-21.093 41.505 -20.185 41.01 -19.25 40.5 C-15.662 38.108 -13.377 36.131 -12 32 C-11.402 31.423 -10.804 30.845 -10.188 30.25 C-4.39 24.287 -1.896 17.323 -1.875 9.125 C-1.907 7.416 -1.945 5.708 -2 4 C-4.31 4.33 -6.62 4.66 -9 5 C-6.455 2.201 -3.924 0 0 0 Z M-10.812 5.375 C-10.214 5.581 -9.616 5.787 -9 6 C-11.31 6.33 -13.62 6.66 -16 7 C-13 5 -13 5 -10.812 5.375 Z " fill="#BCB346" transform="translate(200,108)"/>
<path d="M0 0 C0.711 -0.008 1.422 -0.015 2.154 -0.023 C5.913 -0.009 7.611 0.116 10.812 2.25 C10.025 3.711 9.232 5.168 8.438 6.625 C7.997 7.437 7.556 8.249 7.102 9.086 C5.812 11.25 5.812 11.25 3.812 13.25 C3.442 7.697 3.442 7.697 5.312 5.438 C5.808 5.046 6.303 4.654 6.812 4.25 C6.812 3.92 6.812 3.59 6.812 3.25 C-5.702 2.769 -15.809 12.564 -24.734 20.568 C-27.733 23.214 -30.366 25.026 -34.188 26.25 C-33.532 19.302 -33.532 19.302 -30.188 16.25 C-29.143 14.981 -28.102 13.71 -27.062 12.438 C-19.637 3.913 -11.327 -0.125 0 0 Z M-0.188 4.25 C0.473 4.25 1.132 4.25 1.812 4.25 C1.483 5.24 1.152 6.23 0.812 7.25 C0.483 6.26 0.152 5.27 -0.188 4.25 Z " fill="#C3C141" transform="translate(259.1875,93.75)"/>
<path d="M0 0 C0.685 0.008 1.37 0.015 2.076 0.023 C2.757 0.016 3.439 0.008 4.141 0 C7.853 0.015 9.475 0.164 12.639 2.273 C12.309 3.923 11.979 5.573 11.639 7.273 C9.989 7.273 8.339 7.273 6.639 7.273 C6.662 8.477 6.685 9.681 6.709 10.922 C6.728 12.497 6.746 14.073 6.764 15.648 C6.789 16.84 6.789 16.84 6.814 18.055 C6.832 20.128 6.742 22.202 6.639 24.273 C5.979 24.933 5.319 25.593 4.639 26.273 C0.083 25.718 0.083 25.718 -1.361 24.273 C-1.435 21.411 -1.454 18.573 -1.424 15.711 C-1.419 14.905 -1.415 14.098 -1.41 13.268 C-1.398 11.269 -1.38 9.271 -1.361 7.273 C-3.011 6.943 -4.661 6.613 -6.361 6.273 C-6.986 4.398 -6.986 4.398 -7.361 2.273 C-4.878 -0.21 -3.395 0.013 0 0 Z " fill="#694837" transform="translate(40.361328125,199.7265625)"/>
<path d="M0 0 C1.028 -0.012 1.028 -0.012 2.076 -0.023 C7.184 -0.003 7.184 -0.003 9.438 2.25 C9.062 4.375 9.062 4.375 8.438 6.25 C6.787 6.58 5.137 6.91 3.438 7.25 C3.461 8.454 3.484 9.658 3.508 10.898 C3.527 12.474 3.545 14.049 3.562 15.625 C3.588 16.816 3.588 16.816 3.613 18.031 C3.631 20.105 3.541 22.179 3.438 24.25 C2.778 24.91 2.118 25.57 1.438 26.25 C-1.125 26 -1.125 26 -3.562 25.25 C-5.157 22.062 -4.664 18.621 -4.625 15.125 C-4.62 14.371 -4.616 13.617 -4.611 12.84 C-4.6 10.977 -4.582 9.113 -4.562 7.25 C-6.213 6.92 -7.863 6.59 -9.562 6.25 C-9.893 4.93 -10.222 3.61 -10.562 2.25 C-6.926 -0.175 -4.2 -0.048 0 0 Z " fill="#644332" transform="translate(166.5625,199.75)"/>
<path d="M0 0 C0.685 0.008 1.37 0.015 2.076 0.023 C2.757 0.016 3.439 0.008 4.141 0 C7.853 0.015 9.475 0.164 12.639 2.273 C12.451 4.148 12.451 4.148 11.639 6.273 C9.989 6.933 8.339 7.593 6.639 8.273 C6.309 13.883 5.979 19.493 5.639 25.273 C3.989 25.603 2.339 25.933 0.639 26.273 C-1.361 24.273 -1.361 24.273 -1.557 20.359 C-1.543 18.789 -1.519 17.219 -1.486 15.648 C-1.477 14.847 -1.468 14.045 -1.459 13.219 C-1.435 11.237 -1.4 9.255 -1.361 7.273 C-3.011 6.943 -4.661 6.613 -6.361 6.273 C-6.986 4.398 -6.986 4.398 -7.361 2.273 C-4.878 -0.21 -3.395 0.013 0 0 Z " fill="#6A4838" transform="translate(255.361328125,199.7265625)"/>
<path d="M0 0 C0.763 0.206 1.526 0.412 2.312 0.625 C0.797 4.908 -2.39 6.817 -6 9.25 C-7.267 10.128 -8.533 11.008 -9.797 11.891 C-10.442 12.34 -11.087 12.789 -11.752 13.252 C-14.812 15.423 -17.784 17.702 -20.75 20 C-22.283 21.187 -22.283 21.187 -23.848 22.398 C-26.474 24.458 -29.086 26.534 -31.688 28.625 C-31.688 25.047 -31.152 22.907 -29.688 19.625 C-27.545 17.6 -25.546 15.91 -23.188 14.188 C-22.563 13.714 -21.938 13.241 -21.294 12.754 C-3.653 -0.51 -3.653 -0.51 0 0 Z M-34.688 29.625 C-33.643 32.758 -33.753 33.615 -34.688 36.625 C-35.347 36.625 -36.007 36.625 -36.688 36.625 C-35.812 31.875 -35.812 31.875 -34.688 29.625 Z " fill="#6A472A" transform="translate(257.6875,98.375)"/>
<path d="M0 0 C4.556 0.556 4.556 0.556 6 2 C6.252 5.639 6.185 9.291 6.188 12.938 C6.2 13.966 6.212 14.994 6.225 16.053 C6.228 17.525 6.228 17.525 6.23 19.027 C6.235 19.932 6.239 20.837 6.243 21.769 C6 24 6 24 4 26 C1.438 25.75 1.438 25.75 -1 25 C-2.885 21.23 -2.185 16.648 -2.188 12.5 C-2.2 11.524 -2.212 10.548 -2.225 9.543 C-2.227 8.611 -2.228 7.679 -2.23 6.719 C-2.235 5.862 -2.239 5.006 -2.243 4.123 C-2 2 -2 2 0 0 Z " fill="#6A4837" transform="translate(186,200)"/>
<path d="M0 0 C1.286 -0.001 2.572 -0.003 3.896 -0.004 C4.889 -0.001 4.889 -0.001 5.902 0.002 C7.903 0.008 9.903 0.002 11.904 -0.004 C19.034 0.003 26.031 0.216 33.105 1.133 C32.775 1.793 32.445 2.453 32.105 3.133 C26.238 4.515 19.91 4.27 13.914 4.23 C13.083 4.229 12.253 4.228 11.397 4.226 C8.779 4.221 6.161 4.208 3.543 4.195 C1.753 4.19 -0.036 4.186 -1.826 4.182 C-6.182 4.171 -10.538 4.153 -14.895 4.133 C-14.565 3.143 -14.235 2.153 -13.895 1.133 C-9.219 0.384 -4.734 0.005 0 0 Z " fill="#C5C04B" transform="translate(147.89453125,95.8671875)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C1.34 1.598 0.68 2.196 0 2.812 C-2.337 4.875 -2.337 4.875 -2 8 C-3.727 8.508 -5.457 9.006 -7.188 9.5 C-8.15 9.778 -9.113 10.057 -10.105 10.344 C-12.853 10.967 -15.196 11.123 -18 11 C-17.34 10.67 -16.68 10.34 -16 10 C-16 9.34 -16 8.68 -16 8 C-17.21 8.244 -17.21 8.244 -18.444 8.494 C-22.634 9.09 -26.674 9.125 -30.898 9.098 C-31.733 9.096 -32.568 9.095 -33.429 9.093 C-36.077 9.088 -38.726 9.075 -41.375 9.062 C-43.178 9.057 -44.982 9.053 -46.785 9.049 C-51.19 9.038 -55.595 9.021 -60 9 C-60 8.67 -60 8.34 -60 8 C-58.634 7.978 -58.634 7.978 -57.24 7.956 C-28.819 8.202 -28.819 8.202 -1.785 0.672 C-1.196 0.45 -0.607 0.228 0 0 Z M5 1 C5.66 1.66 6.32 2.32 7 3 C5.035 4.068 3.031 5.066 1 6 C0.34 5.67 -0.32 5.34 -1 5 C0.98 3.68 2.96 2.36 5 1 Z " fill="#725623" transform="translate(193,105)"/>
<path d="M0 0 C0.33 0 0.66 0 1 0 C1.38 6.199 0.835 10.448 -2 16 C-3.381 12.233 -2.596 9.552 -1.562 5.75 C-1.275 4.672 -0.988 3.595 -0.691 2.484 C-0.463 1.665 -0.235 0.845 0 0 Z M-4 16 C-3 19 -3 19 -4.039 21.305 C-6.984 26.078 -9.778 30.234 -14 34 C-14.598 34.598 -15.196 35.196 -15.812 35.812 C-16.204 36.204 -16.596 36.596 -17 37 C-17.66 36.67 -18.32 36.34 -19 36 C-18.336 35.31 -17.672 34.621 -16.988 33.91 C-11.752 28.337 -7.275 22.981 -4 16 Z M-21 37 C-20.34 37.33 -19.68 37.66 -19 38 C-19.33 38.66 -19.66 39.32 -20 40 C-23.062 40.625 -23.062 40.625 -26 41 C-24.35 39.68 -22.7 38.36 -21 37 Z M-29 41 C-28.01 41.33 -27.02 41.66 -26 42 C-27.32 42.33 -28.64 42.66 -30 43 C-29.67 42.34 -29.34 41.68 -29 41 Z M-66 43 C-64.948 43.164 -63.896 43.327 -62.812 43.496 C-56.887 44.237 -50.962 44.107 -45 44.062 C-43.844 44.058 -42.687 44.053 -41.496 44.049 C-38.664 44.037 -35.832 44.021 -33 44 C-36.335 46.223 -37.44 46.256 -41.332 46.266 C-42.378 46.268 -43.424 46.271 -44.502 46.273 C-45.594 46.266 -46.687 46.258 -47.812 46.25 C-48.893 46.258 -49.974 46.265 -51.088 46.273 C-56.429 46.26 -61.021 46.19 -66 44 C-66 43.67 -66 43.34 -66 43 Z M-33 43 C-30 44 -30 44 -30 44 Z " fill="#72571E" transform="translate(202,113)"/>
<path d="M0 0 C1.043 0.071 2.085 0.143 3.16 0.217 C15.491 1.01 27.772 1.119 40.125 1.062 C42.066 1.057 44.008 1.053 45.949 1.049 C50.633 1.038 55.316 1.021 60 1 C52.657 5.237 43.754 4.196 35.562 4.188 C34.556 4.189 34.556 4.189 33.529 4.19 C23.883 4.189 14.485 3.864 5 2 C7.64 2.99 10.28 3.98 13 5 C13 5.33 13 5.66 13 6 C7.906 5.568 4.155 5.246 0 2 C0 1.34 0 0.68 0 0 Z " fill="#C0B747" transform="translate(116,105)"/>
<path d="M0 0 C0.495 0.99 0.495 0.99 1 2 C-7.291 13.51 -15.948 20.035 -30 23 C-32.887 23.098 -32.887 23.098 -35 23 C-34.01 20.03 -33.02 17.06 -32 14 C-31.01 14 -30.02 14 -29 14 C-29.66 14.66 -30.32 15.32 -31 16 C-31.648 18.571 -31.648 18.571 -32 21 C-20.533 18.218 -12.858 15.053 -5 6 C-4.67 5.34 -4.34 4.68 -4 4 C-3.34 4 -2.68 4 -2 4 C-1.34 2.68 -0.68 1.36 0 0 Z " fill="#BAB045" transform="translate(257,113)"/>
<path d="M0 0 C0.66 0.99 1.32 1.98 2 3 C2.99 3.66 3.98 4.32 5 5 C-0.94 9.95 -0.94 9.95 -7 15 C-7.66 14.67 -8.32 14.34 -9 14 C-8.541 9.872 -8.234 7.657 -5 5 C-4.113 4.113 -3.226 3.226 -2.312 2.312 C-1.549 1.549 -0.786 0.786 0 0 Z " fill="#B9AE44" transform="translate(234,105)"/>
<path d="M0 0 C2.604 -0.054 5.208 -0.094 7.812 -0.125 C8.55 -0.142 9.288 -0.159 10.049 -0.176 C13.912 -0.211 15.709 -0.194 19 2 C18.213 3.461 17.42 4.918 16.625 6.375 C16.184 7.187 15.743 7.999 15.289 8.836 C14 11 14 11 12 13 C11.63 7.447 11.63 7.447 13.5 5.188 C14.243 4.6 14.243 4.6 15 4 C15 3.67 15 3.34 15 3 C10.69 2.834 7.721 2.768 4 5 C2.339 5.68 0.673 6.349 -1 7 C-0.34 6.01 0.32 5.02 1 4 C1.66 4 2.32 4 3 4 C3 3.34 3 2.68 3 2 C1.35 2.33 -0.3 2.66 -2 3 C-1.34 2.67 -0.68 2.34 0 2 C0 1.34 0 0.68 0 0 Z M8 4 C8.66 4 9.32 4 10 4 C9.67 4.99 9.34 5.98 9 7 C8.67 6.01 8.34 5.02 8 4 Z " fill="#BDB44B" transform="translate(251,94)"/>
<path d="M0 0 C2.97 0.99 5.94 1.98 9 3 C5 4 5 4 3 3 C2.347 14.211 2.347 14.211 7 24 C8.378 27.032 9 28.657 9 32 C1.398 25.755 0.371 16.553 -0.801 7.355 C-1 4 -1 4 0 0 Z " fill="#BDB552" transform="translate(111,111)"/>
<path d="M0 0 C3.237 -0.294 5.008 0.004 8 1.375 C19.11 6.384 32.399 5.247 44.312 5.125 C46.036 5.115 47.759 5.106 49.482 5.098 C53.655 5.076 57.827 5.042 62 5 C62 5.33 62 5.66 62 6 C54.573 7.023 47.24 7.255 39.749 7.295 C38.264 7.307 36.779 7.327 35.294 7.357 C24.031 7.581 8.962 7.875 0 0 Z " fill="#6D501C" transform="translate(113,111)"/>
<path d="M0 0 C2.696 1.54 5.14 3.113 7.562 5.062 C9.671 6.738 11.554 7.913 14 9 C14 9.66 14 10.32 14 11 C14.639 11.124 15.279 11.248 15.938 11.375 C16.948 11.581 17.959 11.787 19 12 C19.759 12.146 20.519 12.291 21.301 12.441 C22.109 12.605 22.917 12.769 23.75 12.938 C24.529 13.091 25.307 13.244 26.109 13.402 C26.733 13.6 27.357 13.797 28 14 C28.33 14.66 28.66 15.32 29 16 C17.041 15.097 8.075 11.073 0 2 C0 1.34 0 0.68 0 0 Z " fill="#B7AC44" transform="translate(119,142)"/>
<path d="M0 0 C0.33 0.99 0.66 1.98 1 3 C-0.938 6.188 -0.938 6.188 -3 9 C-3.33 8.01 -3.66 7.02 -4 6 C-2.062 2.812 -2.062 2.812 0 0 Z M-6 11 C-6 14 -6 14 -6 14 Z M-8 14 C-8 17.693 -8.882 19.005 -11 22 C-13.688 24.312 -13.688 24.312 -16 26 C-14.59 22.961 -12.911 20.406 -10.875 17.75 C-10.336 17.044 -9.797 16.337 -9.242 15.609 C-8.832 15.078 -8.422 14.547 -8 14 Z M-17 26 C-17 26.99 -17 27.98 -17 29 C-17.66 28.67 -18.32 28.34 -19 28 C-18.34 27.34 -17.68 26.68 -17 26 Z M-23 31 C-22.34 31.33 -21.68 31.66 -21 32 C-21.99 32 -22.98 32 -24 32 C-23.67 31.67 -23.34 31.34 -23 31 Z M-24 33 C-24 33.66 -24 34.32 -24 35 C-25.65 35.33 -27.3 35.66 -29 36 C-26.25 33 -26.25 33 -24 33 Z " fill="#71561D" transform="translate(269,96)"/>
<path d="M0 0 C-0.33 1.32 -0.66 2.64 -1 4 C-2.456 4.363 -3.915 4.715 -5.375 5.062 C-6.593 5.358 -6.593 5.358 -7.836 5.66 C-10 6 -10 6 -12 5 C-11.812 3.125 -11.812 3.125 -11 1 C-7.112 -1.333 -4.319 -1.004 0 0 Z " fill="#654738" transform="translate(109,194)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C1.68 1.99 0.36 2.98 -1 4 C-1.66 3.67 -2.32 3.34 -3 3 C-2.01 2.01 -1.02 1.02 0 0 Z M-5 4 C-4.34 4.33 -3.68 4.66 -3 5 C-5.64 6.98 -8.28 8.96 -11 11 C-11.33 10.34 -11.66 9.68 -12 9 C-9.69 7.35 -7.38 5.7 -5 4 Z M-14 11 C-13.34 11.33 -12.68 11.66 -12 12 C-13.98 12.99 -13.98 12.99 -16 14 C-15.34 13.01 -14.68 12.02 -14 11 Z M-22 16 C-20.68 16.33 -19.36 16.66 -18 17 C-19.65 18.32 -21.3 19.64 -23 21 C-23 18 -23 18 -22 16 Z M-26 22 C-24.956 25.133 -25.066 25.99 -26 29 C-26.66 29 -27.32 29 -28 29 C-27.125 24.25 -27.125 24.25 -26 22 Z " fill="#71561E" transform="translate(249,106)"/>
<path d="M0 0 C2.599 4.739 1.949 8.883 1 14 C0.67 14.99 0.34 15.98 0 17 C-0.66 17 -1.32 17 -2 17 C-2 12.71 -2 8.42 -2 4 C-4.31 4.33 -6.62 4.66 -9 5 C-6.455 2.201 -3.924 0 0 0 Z M-10.812 5.375 C-10.214 5.581 -9.616 5.787 -9 6 C-11.31 6.33 -13.62 6.66 -16 7 C-13 5 -13 5 -10.812 5.375 Z " fill="#B8AE49" transform="translate(200,108)"/>
<path d="M0 0 C0.875 0.052 1.749 0.104 2.65 0.158 C4.788 0.287 6.925 0.424 9.062 0.562 C9.062 0.892 9.062 1.222 9.062 1.562 C4.442 1.892 -0.178 2.222 -4.938 2.562 C-4.938 2.892 -4.938 3.222 -4.938 3.562 C-9.227 3.562 -13.518 3.562 -17.938 3.562 C-17.607 2.573 -17.278 1.582 -16.938 0.562 C-11.175 -0.493 -5.832 -0.386 0 0 Z " fill="#B5AA48" transform="translate(150.9375,96.4375)"/>
<path d="M0 0 C0.33 0.66 0.66 1.32 1 2 C0.34 1.67 -0.32 1.34 -1 1 C-0.67 0.67 -0.34 0.34 0 0 Z M-3 2 C-2.34 2.33 -1.68 2.66 -1 3 C-1.99 3 -2.98 3 -4 3 C-3.67 2.67 -3.34 2.34 -3 2 Z M-6 4 C-5.34 4.33 -4.68 4.66 -4 5 C-5.32 5.99 -6.64 6.98 -8 8 C-8.66 7.67 -9.32 7.34 -10 7 C-8.68 6.01 -7.36 5.02 -6 4 Z M-12 8 C-11.34 8.33 -10.68 8.66 -10 9 C-11.98 9.99 -11.98 9.99 -14 11 C-13.34 10.01 -12.68 9.02 -12 8 Z M-15 11 C-15 11.99 -15 12.98 -15 14 C-16.609 15.5 -16.609 15.5 -18.75 17 C-19.446 17.495 -20.142 17.99 -20.859 18.5 C-21.566 18.995 -22.272 19.49 -23 20 C-24.337 20.996 -25.671 21.994 -27 23 C-27.66 22.67 -28.32 22.34 -29 22 C-27.609 20.724 -26.212 19.454 -24.812 18.188 C-24.035 17.48 -23.258 16.772 -22.457 16.043 C-20.08 14.067 -17.689 12.514 -15 11 Z " fill="#BFB646" transform="translate(258,102)"/>
<path d="M0 0 C5.94 0.33 11.88 0.66 18 1 C18 1.33 18 1.66 18 2 C13.71 2 9.42 2 5 2 C7.64 2.99 10.28 3.98 13 5 C13 5.33 13 5.66 13 6 C7.906 5.568 4.155 5.246 0 2 C0 1.34 0 0.68 0 0 Z " fill="#BCB247" transform="translate(116,105)"/>
<path d="M0 0 C3.91 -0.355 6.322 0.553 9.824 2.207 C13.597 3.582 17.556 3.956 21.523 4.473 C22.341 4.647 23.158 4.821 24 5 C24.33 5.66 24.66 6.32 25 7 C16.352 6.456 6.871 5.72 0 0 Z " fill="#765D20" transform="translate(113,111)"/>
<path d="M0 0 C4.29 0.33 8.58 0.66 13 1 C12.67 1.66 12.34 2.32 12 3 C5.327 4.076 -1.254 4.113 -8 4 C-8 3.67 -8 3.34 -8 3 C-5.36 2.67 -2.72 2.34 0 2 C0 1.34 0 0.68 0 0 Z " fill="#B1A85C" transform="translate(168,96)"/>
<path d="M0 0 C0.701 0.248 1.402 0.495 2.125 0.75 C0.736 4.106 -0.792 5.823 -3.875 7.75 C-4.535 7.75 -5.195 7.75 -5.875 7.75 C-5.875 7.09 -5.875 6.43 -5.875 5.75 C-6.865 5.09 -7.855 4.43 -8.875 3.75 C-3.583 -0.312 -3.583 -0.312 0 0 Z M-4.875 2.75 C-4.875 3.41 -4.875 4.07 -4.875 4.75 C-3.555 4.42 -2.235 4.09 -0.875 3.75 C-0.875 2.76 -0.875 1.77 -0.875 0.75 C-2.337 0.658 -2.337 0.658 -4.875 2.75 Z " fill="#73581F" transform="translate(257.875,98.25)"/>
<path d="M0 0 C0 0.66 0 1.32 0 2 C-1.242 2.702 -2.494 3.386 -3.75 4.062 C-4.794 4.637 -4.794 4.637 -5.859 5.223 C-8.361 6.131 -9.533 5.883 -12 5 C-10.68 4.67 -9.36 4.34 -8 4 C-8 3.34 -8 2.68 -8 2 C-8.66 1.67 -9.32 1.34 -10 1 C-6.565 0.375 -3.509 0 0 0 Z " fill="#B6AA44" transform="translate(195,103)"/>
<path d="M0 0 C3.381 3.114 5.709 5.541 7 10 C7 10.99 7 11.98 7 13 C3.664 10.009 1.755 7.124 0 3 C0 2.01 0 1.02 0 0 Z " fill="#C2BA50" transform="translate(113,130)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C1.34 1.66 0.68 2.32 0 3 C-0.648 5.571 -0.648 5.571 -1 8 C-0.01 8.33 0.98 8.66 2 9 C1 10 1 10 -1.562 10.062 C-2.367 10.042 -3.171 10.021 -4 10 C-3.524 8.52 -3.044 7.041 -2.562 5.562 C-2.296 4.739 -2.029 3.915 -1.754 3.066 C-1 1 -1 1 0 0 Z " fill="#B8AD44" transform="translate(226,126)"/>
<path d="M0 0 C0.99 0.66 1.98 1.32 3 2 C0.03 4.31 -2.94 6.62 -6 9 C-6 6 -6 6 -4.688 4.395 C-4.131 3.872 -3.574 3.35 -3 2.812 C-2.443 2.283 -1.886 1.753 -1.312 1.207 C-0.663 0.61 -0.663 0.61 0 0 Z " fill="#C5C33A" transform="translate(233,109)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C2.67 2.32 2.34 3.64 2 5 C0.35 5.33 -1.3 5.66 -3 6 C-1.125 1.125 -1.125 1.125 0 0 Z " fill="#674637" transform="translate(106,193)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C2.213 2.461 1.42 3.918 0.625 5.375 C0.184 6.187 -0.257 6.999 -0.711 7.836 C-2 10 -2 10 -4 12 C-4.371 6.435 -4.371 6.435 -2.562 4.312 C-1.789 3.663 -1.789 3.663 -1 3 C-0.67 2.01 -0.34 1.02 0 0 Z " fill="#BBB144" transform="translate(267,95)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C-0.97 3.64 -3.94 6.28 -7 9 C-7.66 8.67 -8.32 8.34 -9 8 C-6.03 5.36 -3.06 2.72 0 0 Z " fill="#B7AB44" transform="translate(238,116)"/>
<path d="M0 0 C1.65 0 3.3 0 5 0 C5 0.66 5 1.32 5 2 C3.02 2.33 1.04 2.66 -1 3 C-0.67 2.01 -0.34 1.02 0 0 Z " fill="#694331" transform="translate(172,113)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C0.719 2.707 -0.618 4.374 -2 6 C-2.66 6 -3.32 6 -4 6 C-2.848 3.532 -1.952 1.952 0 0 Z " fill="#5C463A" transform="translate(298,219)"/>
<path d="M0 0 C0 0.99 0 1.98 0 3 C-2.5 5.188 -2.5 5.188 -5 7 C-3.75 3.347 -3.329 2.219 0 0 Z " fill="#C1BA4D" transform="translate(234,105)"/>
<path d="M0 0 C-0.33 0.99 -0.66 1.98 -1 3 C-2.98 3.33 -4.96 3.66 -7 4 C-5.533 1.066 -3.26 0 0 0 Z " fill="#B8AD47" transform="translate(173,153)"/>
<path d="M0 0 C1.65 0.99 3.3 1.98 5 3 C4.01 3.33 3.02 3.66 2 4 C1.34 2.68 0.68 1.36 0 0 Z " fill="#B6AB43" transform="translate(119,142)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C2.01 2.485 2.01 2.485 1 4 C0.34 3.67 -0.32 3.34 -1 3 C-0.67 2.01 -0.34 1.02 0 0 Z " fill="#AFA33F" transform="translate(267,95)"/>
<path d="M0 0 C1.32 0.66 2.64 1.32 4 2 C2.68 2.33 1.36 2.66 0 3 C0 2.01 0 1.02 0 0 Z " fill="#634D40" transform="translate(79,223)"/>
<path d="" fill="#000000" transform="translate(0,0)"/>
</svg>

After

Width:  |  Height:  |  Size: 28 KiB

89
public/test-direct.html Normal file
View File

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html>
<head>
<title>Test Direct API</title>
<style>
body { font-family: monospace; padding: 20px; background: #1a1a1a; color: #0f0; }
button { padding: 10px 20px; margin: 10px; font-size: 16px; }
pre { background: #000; padding: 15px; border: 1px solid #0f0; overflow: auto; }
.success { color: #0f0; }
.error { color: #f00; }
</style>
</head>
<body>
<h1>🧪 Test Direct de l'API</h1>
<button onclick="testAPI()">Tester l'API avec Token Direct</button>
<button onclick="checkToken()">Vérifier Token LocalStorage</button>
<button onclick="installToken()">Installer Token</button>
<h2>Résultats :</h2>
<div id="results"></div>
<script>
const TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1YTIzOThkZi00NWNiLTQyOGQtOWY1ZS04YzQwNzQxNzEyNGIiLCJpYXQiOjE3NjMwODM0NjksImV4cCI6MTc2MzY4ODI2OX0.fCxlIzy-RkCqvCwjatHmIZ5pjqC61Vs-RAnZwulNd_Q';
function log(message, isError = false) {
const div = document.getElementById('results');
const pre = document.createElement('pre');
pre.className = isError ? 'error' : 'success';
pre.textContent = message;
div.appendChild(pre);
}
function installToken() {
localStorage.clear();
localStorage.setItem('auth_token', TOKEN);
localStorage.setItem('token', TOKEN);
log('✅ Token installé avec les clés: auth_token et token');
log('Token: ' + TOKEN.substring(0, 50) + '...');
}
function checkToken() {
const authToken = localStorage.getItem('auth_token');
const token = localStorage.getItem('token');
log('🔍 Vérification LocalStorage:');
log('auth_token: ' + (authToken ? authToken.substring(0, 50) + '...' : 'NON TROUVÉ'), !authToken);
log('token: ' + (token ? token.substring(0, 50) + '...' : 'NON TROUVÉ'), !token);
}
async function testAPI() {
log('🚀 Test de l\'API...');
log('URL: http://localhost:4000/api/draw/eligible-participants');
log('Token utilisé: ' + TOKEN.substring(0, 50) + '...');
try {
const response = await fetch('http://localhost:4000/api/draw/eligible-participants?minTickets=1&verified=true', {
method: 'GET',
headers: {
'Authorization': 'Bearer ' + TOKEN,
'Content-Type': 'application/json'
}
});
log('📥 Réponse: ' + response.status + ' ' + response.statusText, !response.ok);
const data = await response.json();
if (response.ok) {
log('✅ SUCCÈS!');
log('Participants: ' + data.data.total);
log(JSON.stringify(data, null, 2));
} else {
log('❌ ERREUR:', true);
log(JSON.stringify(data, null, 2), true);
}
} catch (error) {
log('💥 ERREUR RÉSEAU: ' + error.message, true);
log('Le serveur backend n\'est peut-être pas actif sur le port 4000', true);
}
}
// Auto-check au chargement
window.onload = () => {
checkToken();
};
</script>
</body>
</html>

View File

@ -0,0 +1,50 @@
/**
* Script de nettoyage des pages admin non nécessaires
*/
const fs = require('fs');
const path = require('path');
const pagesToDelete = [
// Pages non demandées
'app/admin/utilisateurs/[id]',
'app/admin/campagnes',
'app/admin/remises',
'app/admin/emailing',
'app/admin/statistiques',
// Pages de test/diagnostic
'app/admin/test-tickets',
'app/admin/test-simple',
'app/admin/check-auth',
'app/admin/diagnostic',
'app/admin/create-test-tickets',
];
console.log('🧹 Nettoyage des pages admin non nécessaires...\n');
let deleted = 0;
let notFound = 0;
pagesToDelete.forEach(pagePath => {
const fullPath = path.join(__dirname, '..', pagePath);
try {
if (fs.existsSync(fullPath)) {
fs.rmSync(fullPath, { recursive: true, force: true });
console.log(`✅ Supprimé: ${pagePath}`);
deleted++;
} else {
console.log(`⚠️ N'existe pas: ${pagePath}`);
notFound++;
}
} catch (error) {
console.error(`❌ Erreur lors de la suppression de ${pagePath}:`, error.message);
}
});
console.log(`\n📊 Résumé:`);
console.log(` • Pages supprimées: ${deleted}`);
console.log(` • Pages non trouvées: ${notFound}`);
console.log(` • Total traité: ${pagesToDelete.length}`);
console.log('\n✨ Nettoyage terminé!');

View File

@ -0,0 +1,19 @@
const fs = require('fs');
const path = require('path');
const pageToDelete = path.join(__dirname, '..', 'app', 'admin', 'generation-tickets');
console.log('🗑️ Suppression de la page génération tickets...\n');
try {
if (fs.existsSync(pageToDelete)) {
fs.rmSync(pageToDelete, { recursive: true, force: true });
console.log('✅ Page "generation-tickets" supprimée');
} else {
console.log('⚠️ Page déjà supprimée');
}
} catch (error) {
console.error('❌ Erreur:', error.message);
}
console.log('\n✨ Terminé!');

268
services/admin.service.ts Normal file
View File

@ -0,0 +1,268 @@
import { api } from './api';
import {
AdminStatistics,
Ticket,
User,
Prize,
CreatePrizeData,
UpdatePrizeData,
CreateEmployeeData,
UpdateUserData,
ApiResponse,
PaginatedResponse
} from '@/types';
const API_ENDPOINTS = {
STATISTICS: '/admin/statistics',
TICKETS: '/admin/tickets',
USERS: '/admin/users',
PRIZES: '/admin/prizes',
EMPLOYEES: '/admin/employees',
};
export const adminService = {
// ==================== STATISTIQUES ====================
/**
* Récupérer les statistiques globales
*/
getStatistics: async (): Promise<AdminStatistics> => {
const response = await api.get<ApiResponse<AdminStatistics>>(
API_ENDPOINTS.STATISTICS
);
return response.data!;
},
// ==================== GESTION DES PRIX ====================
/**
* Récupérer tous les prix
*/
getAllPrizes: async (): Promise<Prize[]> => {
const response = await api.get<ApiResponse<any[]>>(
API_ENDPOINTS.PRIZES
);
// Transformer snake_case en camelCase
const prizes = response.data?.map((prize: any) => ({
id: prize.id,
name: prize.name,
type: prize.type,
description: prize.description,
value: prize.value,
probability: prize.probability,
stock: prize.stock,
initialStock: prize.initial_stock || prize.initialStock,
ticketsUsed: prize.tickets_used || prize.ticketsUsed || 0,
isActive: prize.is_active !== undefined ? prize.is_active : prize.isActive,
imageUrl: prize.image_url || prize.imageUrl,
createdAt: prize.created_at || prize.createdAt,
updatedAt: prize.updated_at || prize.updatedAt,
})) || [];
return prizes;
},
/**
* Créer un nouveau prix
*/
createPrize: async (data: CreatePrizeData): Promise<Prize> => {
const response = await api.post<ApiResponse<Prize>>(
API_ENDPOINTS.PRIZES,
data
);
return response.data!;
},
/**
* Modifier un prix
*/
updatePrize: async (prizeId: string, data: UpdatePrizeData): Promise<Prize> => {
const response = await api.put<ApiResponse<Prize>>(
`${API_ENDPOINTS.PRIZES}/${prizeId}`,
data
);
return response.data!;
},
/**
* Supprimer (désactiver) un prix
*/
deletePrize: async (prizeId: string): Promise<void> => {
await api.delete<ApiResponse<void>>(
`${API_ENDPOINTS.PRIZES}/${prizeId}`
);
},
// ==================== GESTION DES UTILISATEURS ====================
/**
* Récupérer tous les utilisateurs (paginé)
*/
getAllUsers: async (
page = 1,
limit = 20,
filters?: {
role?: string;
}
): Promise<PaginatedResponse<User>> => {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
...(filters?.role && { role: filters.role }),
});
const response = await api.get<any>(
`${API_ENDPOINTS.USERS}?${params}`
);
// Adapter la réponse du backend au format attendu par le frontend
// Backend: { success: true, data: { users: [...], pagination: {...} } }
// Frontend attend: { data: [...], total, page, limit, totalPages }
if (response.data && response.data.users) {
// Convertir snake_case en camelCase pour chaque utilisateur
const users = response.data.users.map((user: any) => ({
id: user.id,
email: user.email,
firstName: user.first_name || user.firstName,
lastName: user.last_name || user.lastName,
phone: user.phone,
address: user.address,
city: user.city,
postalCode: user.postal_code || user.postalCode,
role: user.role,
isVerified: user.is_verified !== undefined ? user.is_verified : user.isVerified,
createdAt: user.created_at || user.createdAt,
ticketsCount: user.tickets_count || user.ticketsCount || 0,
}));
return {
data: users,
total: response.data.pagination.total,
page: response.data.pagination.page,
limit: response.data.pagination.limit,
totalPages: response.data.pagination.totalPages,
};
}
// Fallback si la structure est différente
return response.data || { data: [], total: 0, page: 1, limit: 20, totalPages: 0 };
},
/**
* Créer un nouvel employé
*/
createEmployee: async (data: CreateEmployeeData): Promise<User> => {
const response = await api.post<ApiResponse<User>>(
API_ENDPOINTS.EMPLOYEES,
data
);
return response.data!;
},
/**
* Modifier un utilisateur
*/
updateUser: async (userId: string, data: UpdateUserData): Promise<User> => {
const response = await api.put<ApiResponse<User>>(
`${API_ENDPOINTS.USERS}/${userId}`,
data
);
return response.data!;
},
/**
* Supprimer un utilisateur
*/
deleteUser: async (userId: string): Promise<void> => {
await api.delete<ApiResponse<void>>(
`${API_ENDPOINTS.USERS}/${userId}`
);
},
// ==================== GESTION DES TICKETS ====================
/**
* Récupérer tous les tickets (paginé avec filtres)
*/
getAllTickets: async (
page = 1,
limit = 20,
filters?: {
status?: string;
userId?: string;
prizeType?: string;
}
): Promise<PaginatedResponse<Ticket>> => {
const params = new URLSearchParams();
params.append('page', page.toString());
params.append('limit', limit.toString());
if (filters?.status) {
params.append('status', filters.status);
}
if (filters?.userId) {
params.append('userId', filters.userId);
}
if (filters?.prizeType) {
params.append('prizeType', filters.prizeType);
}
const url = `${API_ENDPOINTS.TICKETS}?${params}`;
console.log('🔍 Frontend - URL appelée:', url);
console.log('🔍 Frontend - Filters:', filters);
const response = await api.get<any>(url);
// Transformer les données du backend (format plat) en format attendu par le frontend
const transformTicket = (ticket: any): Ticket => ({
id: ticket.id,
code: ticket.code,
status: ticket.status,
playedAt: ticket.played_at || ticket.playedAt,
claimedAt: ticket.claimed_at || ticket.claimedAt,
validatedAt: ticket.validated_at || ticket.validatedAt,
createdAt: ticket.created_at || ticket.createdAt,
// Transformer les données utilisateur
user: ticket.user_email ? {
email: ticket.user_email,
firstName: ticket.user_name?.split(' ')[0] || '',
lastName: ticket.user_name?.split(' ').slice(1).join(' ') || '',
} as any : undefined,
// Transformer les données prize
prize: ticket.prize_name ? {
name: ticket.prize_name,
type: ticket.prize_type,
value: ticket.prize_value || '0',
} as any : undefined,
});
// Gérer différents formats de réponse de l'API
if (response.data && response.data.data) {
return {
data: response.data.data.map(transformTicket),
total: response.data.total,
page: response.data.page,
limit: response.data.limit,
totalPages: response.data.totalPages,
};
} else if (response.data && Array.isArray(response.data)) {
return {
data: response.data.map(transformTicket),
total: response.total || response.data.length,
page: response.page || page,
limit: response.limit || limit,
totalPages: response.totalPages || 1,
};
} else if (Array.isArray(response)) {
return {
data: response.map(transformTicket),
total: response.length,
page: page,
limit: limit,
totalPages: 1,
};
}
return response as PaginatedResponse<Ticket>;
},
};

149
services/api.ts Normal file
View File

@ -0,0 +1,149 @@
import { API_BASE_URL } from '@/utils/constants';
import { getToken } from '@/utils/helpers';
export interface FetchOptions extends RequestInit {
token?: string;
}
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public data?: any
) {
super(message);
this.name = 'ApiError';
}
}
async function fetchWithAuth(
endpoint: string,
options: FetchOptions = {}
): Promise<Response> {
const token = options.token || getToken();
// Logs de debug
console.log('🔐 [API] Préparation de la requête:', {
endpoint,
hasToken: !!token,
tokenPreview: token ? `${token.substring(0, 20)}...` : 'Aucun',
});
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
console.log('✅ [API] Header Authorization ajouté');
} else {
console.warn('⚠️ [API] Aucun token disponible - requête sans authentification');
}
const config: RequestInit = {
...options,
headers,
};
const url = endpoint.startsWith('http') ? endpoint : `${API_BASE_URL}${endpoint}`;
console.log('📡 [API] Envoi de la requête:', {
url,
method: config.method || 'GET',
headers: headers,
body: options.body,
});
try {
const response = await fetch(url, config);
if (!response.ok) {
let errorMessage = 'Une erreur est survenue';
let errorData;
try {
errorData = await response.json();
errorMessage = errorData.message || errorData.error || errorMessage;
console.error('❌ [API] Erreur du serveur:', {
status: response.status,
statusText: response.statusText,
errorData,
errorMessage,
});
} catch {
errorMessage = response.statusText || errorMessage;
console.error('❌ [API] Erreur sans JSON:', {
status: response.status,
statusText: response.statusText,
});
}
throw new ApiError(response.status, errorMessage, errorData);
}
return response;
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
throw new ApiError(0, 'Erreur de connexion au serveur');
}
}
export const api = {
get: async <T>(endpoint: string, options?: FetchOptions): Promise<T> => {
const response = await fetchWithAuth(endpoint, {
...options,
method: 'GET',
});
return response.json();
},
post: async <T>(
endpoint: string,
data?: any,
options?: FetchOptions
): Promise<T> => {
const response = await fetchWithAuth(endpoint, {
...options,
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
return response.json();
},
put: async <T>(
endpoint: string,
data?: any,
options?: FetchOptions
): Promise<T> => {
const response = await fetchWithAuth(endpoint, {
...options,
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
});
return response.json();
},
patch: async <T>(
endpoint: string,
data?: any,
options?: FetchOptions
): Promise<T> => {
const response = await fetchWithAuth(endpoint, {
...options,
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
});
return response.json();
},
delete: async <T>(endpoint: string, options?: FetchOptions): Promise<T> => {
const response = await fetchWithAuth(endpoint, {
...options,
method: 'DELETE',
});
return response.json();
},
};

90
services/auth.service.ts Normal file
View File

@ -0,0 +1,90 @@
import { api } from './api';
import { API_ENDPOINTS } from '@/utils/constants';
import {
LoginCredentials,
RegisterData,
AuthResponse,
User,
ApiResponse
} from '@/types';
export const authService = {
// Login with email and password
login: async (credentials: LoginCredentials): Promise<AuthResponse> => {
const response = await api.post<any>(
API_ENDPOINTS.AUTH.LOGIN,
credentials
);
// Le backend retourne {success, token, user} directement
return {
token: response.token,
user: response.user
};
},
// Register new user
register: async (data: RegisterData): Promise<AuthResponse> => {
const response = await api.post<any>(
API_ENDPOINTS.AUTH.REGISTER,
data
);
// Le backend retourne {success, token, user} directement
return {
token: response.token,
user: response.user
};
},
// Logout
logout: async (): Promise<void> => {
await api.post(API_ENDPOINTS.AUTH.LOGOUT);
},
// Get current user
getCurrentUser: async (): Promise<User> => {
const response = await api.get<any>(API_ENDPOINTS.AUTH.ME);
// Le backend retourne {success, data: user}
return response.data || response.user || response;
},
// Google OAuth
googleLogin: async (token: string): Promise<AuthResponse> => {
const response = await api.post<any>(
API_ENDPOINTS.AUTH.GOOGLE,
{ token }
);
// Le backend retourne {success, token, user} directement
return {
token: response.token,
user: response.user
};
},
// Facebook OAuth
facebookLogin: async (token: string): Promise<AuthResponse> => {
const response = await api.post<any>(
API_ENDPOINTS.AUTH.FACEBOOK,
{ token }
);
// Le backend retourne {success, token, user} directement
return {
token: response.token,
user: response.user
};
},
// Verify email
verifyEmail: async (token: string): Promise<void> => {
await api.post(API_ENDPOINTS.AUTH.VERIFY_EMAIL, { token });
},
// Forgot password
forgotPassword: async (email: string): Promise<void> => {
await api.post(API_ENDPOINTS.AUTH.FORGOT_PASSWORD, { email });
},
// Reset password
resetPassword: async (token: string, password: string): Promise<void> => {
await api.post(API_ENDPOINTS.AUTH.RESET_PASSWORD, { token, password });
},
};

View File

@ -0,0 +1,29 @@
import { api } from './api';
import { API_ENDPOINTS } from '@/utils/constants';
import {
Ticket,
ApiResponse,
} from '@/types';
export const employeeService = {
/**
* Récupérer la liste des tickets en attente de validation
* GET /api/employee/pending-tickets
*/
getPendingTickets: async (): Promise<Ticket[]> => {
const response = await api.get<ApiResponse<Ticket[]>>(API_ENDPOINTS.EMPLOYEE.PENDING_TICKETS);
return response.data || [];
},
/**
* Valider un ticket (marquer comme récupéré)
* POST /api/employee/validate-ticket
*/
validateTicket: async (ticketId: string, action: 'APPROVE' | 'REJECT', reason?: string): Promise<Ticket> => {
const response = await api.post<ApiResponse<Ticket>>(
API_ENDPOINTS.EMPLOYEE.VALIDATE_TICKET,
{ ticketId, action, rejectionReason: reason }
);
return response.data!;
},
};

50
services/game.service.ts Normal file
View File

@ -0,0 +1,50 @@
import { api } from './api';
import { API_ENDPOINTS } from '@/utils/constants';
import {
PlayGameRequest,
PlayGameResponse,
Ticket,
ApiResponse,
PaginatedResponse
} from '@/types';
export const gameService = {
// Play the game with a ticket code
play: async (ticketCode: string): Promise<PlayGameResponse> => {
const response = await api.post<ApiResponse<PlayGameResponse>>(
API_ENDPOINTS.GAME.PLAY,
{ ticketCode }
);
return response.data!;
},
// Get user's tickets
getMyTickets: async (page = 1, limit = 10): Promise<PaginatedResponse<Ticket>> => {
const response = await api.get<any>(
`${API_ENDPOINTS.GAME.MY_TICKETS}?page=${page}&limit=${limit}`
);
// Le backend retourne une structure imbriquée
// { success: true, data: { tickets: [...], pagination: {...} } }
if (response.data && response.data.tickets && response.data.pagination) {
return {
data: response.data.tickets,
total: response.data.pagination.total,
page: response.data.pagination.page,
limit: response.data.pagination.limit,
totalPages: response.data.pagination.totalPages,
};
}
// Fallback si la structure est différente
return response.data!;
},
// Get ticket details by ID
getTicketDetails: async (ticketId: string): Promise<Ticket> => {
const response = await api.get<ApiResponse<Ticket>>(
API_ENDPOINTS.GAME.TICKET_DETAILS(ticketId)
);
return response.data!;
},
};

Some files were not shown because too many files have changed in this diff Show More