dev
This commit is contained in:
parent
ed75871a28
commit
2f7abde4ea
243
README.md
243
README.md
|
|
@ -1,36 +1,235 @@
|
|||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# 🍵 Thé Tip Top - Jeu Concours Frontend
|
||||
|
||||
## Getting Started
|
||||
Application web moderne pour le jeu-concours "Thé Tip Top" avec Next.js 14 et Tailwind CSS.
|
||||
|
||||
First, run the development server:
|
||||
## 📋 Description
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
Site web complet permettant aux utilisateurs de :
|
||||
- Se connecter via Google/Facebook OAuth ou formulaire classique
|
||||
- Participer au jeu-concours en entrant un code de ticket (10 caractères)
|
||||
- Consulter leurs gains et historiques
|
||||
- Permettre aux employés de vérifier les gains et marquer comme "remis"
|
||||
- Permettre aux administrateurs de visualiser les statistiques globales du jeu
|
||||
|
||||
## 🚀 Technologies utilisées
|
||||
|
||||
- **Framework:** Next.js 14 (App Router)
|
||||
- **Styling:** Tailwind CSS
|
||||
- **Gestion d'état:** React Context API (AuthContext)
|
||||
- **Formulaires:** React Hook Form + Zod
|
||||
- **Notifications:** React Hot Toast
|
||||
- **Appels API:** Axios
|
||||
- **Language:** TypeScript
|
||||
|
||||
## 📁 Structure du projet
|
||||
|
||||
```
|
||||
the-tip-top-frontend/
|
||||
├── app/ # Pages Next.js 14 (App Router)
|
||||
│ ├── page.tsx # Page d'accueil
|
||||
│ ├── login/page.tsx # Connexion
|
||||
│ ├── register/page.tsx # Inscription
|
||||
│ ├── jeux/page.tsx # Page de jeu
|
||||
│ ├── client/page.tsx # Dashboard client
|
||||
│ ├── employe/page.tsx # Dashboard employé
|
||||
│ ├── admin/page.tsx # Dashboard admin
|
||||
│ ├── historique/page.tsx # Historique
|
||||
│ ├── profile/page.tsx # Profil
|
||||
│ ├── layout.tsx # Layout principal
|
||||
│ └── globals.css # Styles globaux
|
||||
│
|
||||
├── components/ # Composants réutilisables
|
||||
│ ├── Header.tsx # Header avec navigation
|
||||
│ ├── Footer.tsx # Footer complet
|
||||
│ ├── Button.tsx # Bouton personnalisé
|
||||
│ └── ui/ # Composants UI
|
||||
│ ├── Card.tsx
|
||||
│ ├── Input.tsx
|
||||
│ ├── Badge.tsx
|
||||
│ ├── Modal.tsx
|
||||
│ ├── Loading.tsx
|
||||
│ └── Table.tsx
|
||||
│
|
||||
├── contexts/ # Contextes React
|
||||
│ └── AuthContext.tsx # Authentification
|
||||
│
|
||||
├── services/ # Services API
|
||||
│ ├── api.ts # Configuration Axios
|
||||
│ ├── auth.service.ts # Auth
|
||||
│ ├── user.service.ts # Utilisateur
|
||||
│ ├── game.service.ts # Jeu
|
||||
│ ├── employee.service.ts # Employé
|
||||
│ └── admin.service.ts # Admin
|
||||
│
|
||||
├── hooks/ # Hooks personnalisés
|
||||
│ └── useGame.ts
|
||||
│
|
||||
├── lib/ # Librairies
|
||||
│ └── validations.ts # Schémas Zod
|
||||
│
|
||||
├── types/ # Types TypeScript
|
||||
│ └── index.ts
|
||||
│
|
||||
└── utils/ # Utilitaires
|
||||
├── constants.ts # Constantes
|
||||
└── helpers.ts # Helpers
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
## 🎨 Fonctionnalités
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
### Pages publiques
|
||||
- ✅ **Page d'accueil** : Présentation du jeu-concours
|
||||
- ✅ **Connexion/Inscription** : Authentification complète avec OAuth
|
||||
- ✅ **Navigation responsive** : Header et Footer professionnels
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
### Espace Client (`/client`)
|
||||
- ✅ Statistiques personnelles (participations, gains, en attente)
|
||||
- ✅ Historique des tickets joués
|
||||
- ✅ Accès rapide au jeu
|
||||
|
||||
## Learn More
|
||||
### Page de jeu (`/jeux`)
|
||||
- ✅ Saisie de code ticket avec validation
|
||||
- ✅ Résultat instantané avec modal
|
||||
- ✅ Protection : utilisateurs connectés uniquement
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
### Espace Employé (`/employe`)
|
||||
- ✅ Recherche de ticket par code
|
||||
- ✅ Liste des tickets en attente
|
||||
- ✅ Validation des gains
|
||||
- ✅ Protection : rôle "employee" requis
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
### Espace Admin (`/admin`)
|
||||
- ✅ Statistiques globales du jeu
|
||||
- ✅ Distribution des lots
|
||||
- ✅ Aperçu des statuts
|
||||
- ✅ Derniers utilisateurs et tickets
|
||||
- ✅ Protection : rôle "admin" requis
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
## ⚙️ Installation
|
||||
|
||||
## Deploy on Vercel
|
||||
1. **Cloner le projet**
|
||||
```bash
|
||||
git clone <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
232
app/about/page.tsx
Normal 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">Où 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>
|
||||
);
|
||||
}
|
||||
554
app/admin/dashboard/page-advanced.tsx
Normal file
554
app/admin/dashboard/page-advanced.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
529
app/admin/dashboard/page-backup.tsx
Normal file
529
app/admin/dashboard/page-backup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
554
app/admin/dashboard/page.tsx
Normal file
554
app/admin/dashboard/page.tsx
Normal 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
81
app/admin/layout.tsx
Normal 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
7
app/admin/lots/page.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import PrizeManagement from '@/components/admin/PrizeManagement';
|
||||
|
||||
export default function LotsPage() {
|
||||
return <PrizeManagement />;
|
||||
}
|
||||
465
app/admin/marketing-data/page.tsx
Normal file
465
app/admin/marketing-data/page.tsx
Normal 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
40
app/admin/page.tsx
Normal 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;
|
||||
}
|
||||
21
app/admin/tickets/page.tsx
Normal file
21
app/admin/tickets/page.tsx
Normal 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
648
app/admin/tirages/page.tsx
Normal 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à été 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>
|
||||
);
|
||||
}
|
||||
21
app/admin/utilisateurs/page.tsx
Normal file
21
app/admin/utilisateurs/page.tsx
Normal 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
255
app/client/page.tsx
Normal 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
342
app/contact/page.tsx
Normal 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 là 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
302
app/cookies/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
229
app/employe/dashboard/page.tsx
Normal file
229
app/employe/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
363
app/employe/gains-client/page.tsx
Normal file
363
app/employe/gains-client/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
293
app/employe/historique/page.tsx
Normal file
293
app/employe/historique/page.tsx
Normal 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
133
app/employe/layout.tsx
Normal 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
17
app/employe/page.tsx
Normal 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;
|
||||
}
|
||||
462
app/employe/verification/page-new.tsx
Normal file
462
app/employe/verification/page-new.tsx
Normal 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 été 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>
|
||||
);
|
||||
}
|
||||
468
app/employe/verification/page.tsx
Normal file
468
app/employe/verification/page.tsx
Normal 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 été 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
285
app/faq/FAQContent.tsx
Normal 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 là 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
11
app/faq/page.tsx
Normal 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 />;
|
||||
}
|
||||
|
|
@ -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
327
app/historique/page.tsx
Normal 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
56
app/icon.svg
Normal 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
209
app/jeux/page.tsx
Normal 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
27
app/layout-client.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
195
app/legal/page.tsx
Normal 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
215
app/login/page.tsx
Normal 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
258
app/lots/page.tsx
Normal 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
104
app/mes-lots/debug/page.tsx
Normal 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
279
app/mes-lots/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
249
app/page.tsx
249
app/page.tsx
|
|
@ -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
102
app/privacy/page.tsx
Normal 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
285
app/profil/page.tsx
Normal 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
160
app/register/page.tsx
Normal 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
289
app/rules/page.tsx
Normal 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
167
app/terms/page.tsx
Normal 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 âgé 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
152
app/test-tickets/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
0
components/AuthProviders.tsx
Normal file
0
components/AuthProviders.tsx
Normal file
90
components/Button.tsx
Normal file
90
components/Button.tsx
Normal 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
197
components/Footer.tsx
Normal 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
332
components/Header.tsx
Normal 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
81
components/Logo.tsx
Normal 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
181
components/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
components/admin/PrizeManagement.tsx
Normal file
178
components/admin/PrizeManagement.tsx
Normal 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
376
components/admin/README.md
Normal 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
|
||||
127
components/admin/Sidebar.tsx
Normal file
127
components/admin/Sidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
146
components/admin/Statistics.tsx
Normal file
146
components/admin/Statistics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
498
components/admin/TicketManagement.tsx
Normal file
498
components/admin/TicketManagement.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
376
components/admin/UserManagement.tsx
Normal file
376
components/admin/UserManagement.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
components/admin/index.ts
Normal file
9
components/admin/index.ts
Normal 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';
|
||||
59
components/forms/FormCheckbox.tsx
Normal file
59
components/forms/FormCheckbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
components/forms/FormField.tsx
Normal file
73
components/forms/FormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
components/forms/FormSelect.tsx
Normal file
84
components/forms/FormSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
components/forms/FormTextarea.tsx
Normal file
70
components/forms/FormTextarea.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
components/forms/index.ts
Normal file
4
components/forms/index.ts
Normal 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
43
components/ui/Badge.tsx
Normal 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
84
components/ui/Card.tsx
Normal 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
58
components/ui/Input.tsx
Normal 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
56
components/ui/Loading.tsx
Normal 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
107
components/ui/Modal.tsx
Normal 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
94
components/ui/Table.tsx
Normal 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
6
components/ui/index.ts
Normal 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
187
contexts/AuthContext.tsx
Normal 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>;
|
||||
};
|
||||
202
docs/create-test-tickets.sql
Normal file
202
docs/create-test-tickets.sql
Normal 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
18
eslint.config.js
Normal 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
4
hooks/index.ts
Normal 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
17
hooks/useAuth.ts
Normal 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
149
hooks/useForm.ts
Normal 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
59
hooks/useGame.ts
Normal 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
110
hooks/useToast.ts
Normal 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
34
jest.config.js
Normal 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
26
jest.setup.js
Normal 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
0
lib/auth.ts
Normal file
71
lib/facebook-sdk.ts
Normal file
71
lib/facebook-sdk.ts
Normal 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
106
lib/validations.ts
Normal 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
42
middleware.ts
Normal 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
1685
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
|
|
@ -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
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
237
public/diagnostic.html
Normal file
237
public/diagnostic.html
Normal 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
56
public/favicon.svg
Normal 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
237
public/fix-token.html
Normal 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>
|
||||
53
public/logos/QUICK_START.txt
Normal file
53
public/logos/QUICK_START.txt
Normal 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
103
public/logos/README.md
Normal 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
|
||||
```
|
||||
56
public/logos/logo-white.svg
Normal file
56
public/logos/logo-white.svg
Normal 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
56
public/logos/logo.svg
Normal 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
89
public/test-direct.html
Normal 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>
|
||||
50
scripts/clean-admin-pages.js
Normal file
50
scripts/clean-admin-pages.js
Normal 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é!');
|
||||
19
scripts/remove-generation-page.js
Normal file
19
scripts/remove-generation-page.js
Normal 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
268
services/admin.service.ts
Normal 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
149
services/api.ts
Normal 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
90
services/auth.service.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
29
services/employee.service.ts
Normal file
29
services/employee.service.ts
Normal 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
50
services/game.service.ts
Normal 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
Loading…
Reference in New Issue
Block a user