Compare commits
62 Commits
release-1.
...
23c548992d
| Author | SHA1 | Date | |
|---|---|---|---|
| 23c548992d | |||
| 7513bf45d6 | |||
| bdcdf1a4da | |||
| 79e10946b8 | |||
| 79f463d280 | |||
| ad147eb930 | |||
| a09e9f17b5 | |||
| a40a62d600 | |||
| 12685c1b22 | |||
| 3a0dd25ed2 | |||
| a572fa1a15 | |||
| f9d72d45e2 | |||
|
|
2cf399d6d0 | ||
|
|
e895325254 | ||
|
|
fa21d9af2a | ||
| 6ee3dfdb87 | |||
| ecda263518 | |||
| 1f11a4e3dd | |||
| 117ebac770 | |||
| d9d2eaba3d | |||
| 047d34f59b | |||
| 0f1ad0b2ef | |||
| b57186ad6d | |||
| a9afa48ec2 | |||
| a1da7b9e17 | |||
| 3e12fa2d01 | |||
| 09e6c86ca0 | |||
| 87577cd14c | |||
|
|
8adf780da2 | ||
|
|
bd46e0982e | ||
|
|
464d666343 | ||
|
|
20c865a071 | ||
| cdbacfebb5 | |||
| 68dbf02c51 | |||
| deb847f26f | |||
| 9bb9ab8e51 | |||
|
|
2790fc89ff | ||
| d071f737bb | |||
|
|
4d31b03116 | ||
|
|
a2543cf4ce | ||
|
|
3258f06ecb | ||
|
|
6d96ea958a | ||
|
|
894ebdc000 | ||
|
|
1c68b2bc0d | ||
|
|
2f7b2d2b64 | ||
|
|
902e304a5e | ||
|
|
7f816c165b | ||
|
|
4f0593bc38 | ||
|
|
b6506435d5 | ||
|
|
65d6b23233 | ||
|
|
68a9650223 | ||
|
|
efd10d6754 | ||
|
|
fba0a7133c | ||
|
|
2d25c91c55 | ||
|
|
1cf2a24ede | ||
|
|
4ea733440f | ||
|
|
4727a3a268 | ||
|
|
428bf2597a | ||
|
|
e2d045c0ab | ||
|
|
25be38027d | ||
|
|
72e7771d95 | ||
|
|
c1ca68da9a |
12
Jenkinsfile
vendored
@@ -49,6 +49,7 @@ pipeline {
|
|||||||
env.DEPLOY_NETWORK = 'tiptop-dev'
|
env.DEPLOY_NETWORK = 'tiptop-dev'
|
||||||
|
|
||||||
env.FRONTEND_API_URL = "https://api-dev.dsp5-archi-o24a-4-5-g4.fr"
|
env.FRONTEND_API_URL = "https://api-dev.dsp5-archi-o24a-4-5-g4.fr"
|
||||||
|
env.FRONTEND_GA_ID = ""
|
||||||
|
|
||||||
} else if (env.BRANCH_NAME.startsWith('release-')) {
|
} else if (env.BRANCH_NAME.startsWith('release-')) {
|
||||||
env.DEPLOY_ENABLED = 'true'
|
env.DEPLOY_ENABLED = 'true'
|
||||||
@@ -59,6 +60,7 @@ pipeline {
|
|||||||
env.DEPLOY_NETWORK = 'tiptop-preprod'
|
env.DEPLOY_NETWORK = 'tiptop-preprod'
|
||||||
|
|
||||||
env.FRONTEND_API_URL = "https://api-preprod.dsp5-archi-o24a-4-5-g4.fr"
|
env.FRONTEND_API_URL = "https://api-preprod.dsp5-archi-o24a-4-5-g4.fr"
|
||||||
|
env.FRONTEND_GA_ID = ""
|
||||||
|
|
||||||
} else if (env.BRANCH_NAME == 'main') {
|
} else if (env.BRANCH_NAME == 'main') {
|
||||||
env.DEPLOY_ENABLED = 'true'
|
env.DEPLOY_ENABLED = 'true'
|
||||||
@@ -69,6 +71,7 @@ pipeline {
|
|||||||
env.DEPLOY_NETWORK = 'tiptop-prod'
|
env.DEPLOY_NETWORK = 'tiptop-prod'
|
||||||
|
|
||||||
env.FRONTEND_API_URL = "https://api.dsp5-archi-o24a-4-5-g4.fr"
|
env.FRONTEND_API_URL = "https://api.dsp5-archi-o24a-4-5-g4.fr"
|
||||||
|
env.FRONTEND_GA_ID = "G-4FTNTPJDRJ"
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
env.DEPLOY_ENABLED = 'false'
|
env.DEPLOY_ENABLED = 'false'
|
||||||
@@ -78,6 +81,7 @@ pipeline {
|
|||||||
env.COMPOSE_PROJECT = ''
|
env.COMPOSE_PROJECT = ''
|
||||||
env.DEPLOY_NETWORK = ''
|
env.DEPLOY_NETWORK = ''
|
||||||
env.FRONTEND_API_URL = ''
|
env.FRONTEND_API_URL = ''
|
||||||
|
env.FRONTEND_GA_ID = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "Branche : ${env.BRANCH_NAME}"
|
echo "Branche : ${env.BRANCH_NAME}"
|
||||||
@@ -163,6 +167,7 @@ pipeline {
|
|||||||
npm ci --prefer-offline --no-audit --no-fund &&
|
npm ci --prefer-offline --no-audit --no-fund &&
|
||||||
npx prisma generate &&
|
npx prisma generate &&
|
||||||
npx prisma migrate deploy &&
|
npx prisma migrate deploy &&
|
||||||
|
npx prisma db seed &&
|
||||||
npm test
|
npm test
|
||||||
"
|
"
|
||||||
'''
|
'''
|
||||||
@@ -228,9 +233,10 @@ pipeline {
|
|||||||
backend
|
backend
|
||||||
|
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg VITE_API_URL="${FRONTEND_API_URL}" \
|
--build-arg VITE_API_URL="${FRONTEND_API_URL}" \
|
||||||
-t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \
|
--build-arg VITE_GA_ID="${FRONTEND_GA_ID}" \
|
||||||
frontend
|
-t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \
|
||||||
|
frontend
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
781
README.md
Normal file
@@ -0,0 +1,781 @@
|
|||||||
|
# Thé Tip Top – Plateforme du jeu-concours
|
||||||
|
|
||||||
|
## Présentation
|
||||||
|
|
||||||
|
**Thé Tip Top** est une application web complète développée dans le cadre d’un projet de certification **Expert en Stratégie et Transformation Digitale**.
|
||||||
|
|
||||||
|
Le projet simule le système d’information d’un jeu-concours organisé à l’occasion de l’ouverture de la 10ᵉ boutique Thé Tip Top à Nice.
|
||||||
|
|
||||||
|
La plateforme permet aux clients de participer avec un code unique présent sur leur ticket, de consulter leurs gains et de les récupérer en boutique. Elle fournit également des interfaces dédiées aux employés et aux administrateurs.
|
||||||
|
|
||||||
|
Au-delà de l’application, le projet met en œuvre une chaîne complète de **CI/CD**, plusieurs environnements isolés, un registre Docker privé, un reverse proxy HTTPS et une stack de supervision.
|
||||||
|
|
||||||
|
> **Projet étudiant — client fictif Thé Tip Top.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fonctionnalités principales
|
||||||
|
|
||||||
|
### Espace client
|
||||||
|
|
||||||
|
- création de compte ;
|
||||||
|
- vérification de l’adresse email ;
|
||||||
|
- connexion email / mot de passe ;
|
||||||
|
- connexion avec Google OAuth 2.0 ;
|
||||||
|
- gestion du profil ;
|
||||||
|
- saisie d’un code ticket unique ;
|
||||||
|
- participation au jeu-concours ;
|
||||||
|
- consultation des gains.
|
||||||
|
|
||||||
|
### Espace employé
|
||||||
|
|
||||||
|
- authentification avec rôle `EMPLOYEE` ;
|
||||||
|
- recherche d’un gain ;
|
||||||
|
- contrôle des informations associées au ticket ;
|
||||||
|
- validation de la remise d’un lot.
|
||||||
|
|
||||||
|
### Espace administrateur
|
||||||
|
|
||||||
|
- authentification avec rôle `ADMIN` ;
|
||||||
|
- tableau de bord et statistiques ;
|
||||||
|
- consultation des participants ;
|
||||||
|
- gestion des données nécessaires à l’administration ;
|
||||||
|
- création de comptes employés ;
|
||||||
|
- configuration du lancement du jeu-concours ;
|
||||||
|
- tirage au sort final ;
|
||||||
|
- consultation du grand gagnant.
|
||||||
|
|
||||||
|
### Jeu-concours
|
||||||
|
|
||||||
|
Le système gère plusieurs phases :
|
||||||
|
|
||||||
|
```text
|
||||||
|
NOT_CONFIGURED
|
||||||
|
↓
|
||||||
|
SCHEDULED
|
||||||
|
↓
|
||||||
|
OPEN
|
||||||
|
↓
|
||||||
|
CLAIM_ONLY
|
||||||
|
↓
|
||||||
|
CLOSED
|
||||||
|
```
|
||||||
|
|
||||||
|
Parmi les principales règles métier :
|
||||||
|
|
||||||
|
- un ticket ne peut être utilisé qu’une seule fois ;
|
||||||
|
- les tickets du jeu sont gagnants ;
|
||||||
|
- les gains sont associés aux tickets ;
|
||||||
|
- seuls les clients participent au jeu ;
|
||||||
|
- plusieurs participations d’un même client n’augmentent pas ses chances au tirage final ;
|
||||||
|
- un seul grand gagnant peut être enregistré ;
|
||||||
|
- le tirage final respecte les contraintes temporelles définies dans l’application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Architecture générale
|
||||||
|
|
||||||
|
```text
|
||||||
|
UTILISATEURS
|
||||||
|
│
|
||||||
|
HTTPS
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────┐
|
||||||
|
│ Traefik │
|
||||||
|
└────┬────┘
|
||||||
|
│
|
||||||
|
┌────────────┴────────────┐
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
Frontend React Backend API
|
||||||
|
Vite + Nginx Node.js + Express
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ PostgreSQL
|
||||||
|
│ + Prisma
|
||||||
|
│
|
||||||
|
└────────── API REST ─────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Stack technique
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- React
|
||||||
|
- Vite
|
||||||
|
- React Router
|
||||||
|
- Tailwind CSS
|
||||||
|
- Vitest
|
||||||
|
- Testing Library
|
||||||
|
- ESLint
|
||||||
|
- Nginx
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
- Node.js 22
|
||||||
|
- Express.js
|
||||||
|
- Prisma ORM 6
|
||||||
|
- PostgreSQL 16
|
||||||
|
- JWT
|
||||||
|
- Passport.js
|
||||||
|
- Google OAuth 2.0
|
||||||
|
- Bcrypt
|
||||||
|
- Zod
|
||||||
|
- Nodemailer
|
||||||
|
- Helmet
|
||||||
|
- express-rate-limit
|
||||||
|
- Swagger / OpenAPI
|
||||||
|
- Jest
|
||||||
|
- Supertest
|
||||||
|
|
||||||
|
## DevOps
|
||||||
|
|
||||||
|
- Git
|
||||||
|
- Gitea
|
||||||
|
- Jenkins
|
||||||
|
- Docker
|
||||||
|
- Docker Compose
|
||||||
|
- Docker Registry privé
|
||||||
|
- Traefik
|
||||||
|
- HTTPS
|
||||||
|
- Let's Encrypt
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
- Prometheus
|
||||||
|
- Grafana
|
||||||
|
- cAdvisor
|
||||||
|
- Node Exporter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Structure du dépôt
|
||||||
|
|
||||||
|
```text
|
||||||
|
THE-TIP-TOP/
|
||||||
|
│
|
||||||
|
├── backend/
|
||||||
|
│ ├── prisma/
|
||||||
|
│ │ ├── migrations/
|
||||||
|
│ │ ├── schema.prisma
|
||||||
|
│ │ └── seed.js
|
||||||
|
│ ├── src/
|
||||||
|
│ ├── tests/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── README.md
|
||||||
|
│
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ ├── context/
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ └── test/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── nginx.conf
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── README.md
|
||||||
|
│
|
||||||
|
├── deploy/
|
||||||
|
│ ├── dev/
|
||||||
|
│ │ └── docker-compose.yml
|
||||||
|
│ ├── preprod/
|
||||||
|
│ │ └── docker-compose.yml
|
||||||
|
│ └── prod/
|
||||||
|
│ └── docker-compose.yml
|
||||||
|
│
|
||||||
|
├── Jenkinsfile
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour les informations spécifiques à chaque application :
|
||||||
|
|
||||||
|
- [`backend/README.md`](backend/README.md)
|
||||||
|
- [`frontend/README.md`](frontend/README.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Environnements
|
||||||
|
|
||||||
|
Le projet utilise trois environnements distincts.
|
||||||
|
|
||||||
|
| Environnement | Branche | Frontend | API |
|
||||||
|
|---|---|---|---|
|
||||||
|
| DEV | `develop` | `https://dev.dsp5-archi-o24a-4-5-g4.fr` | `https://api-dev.dsp5-archi-o24a-4-5-g4.fr` |
|
||||||
|
| PREPROD | `release-*` | `https://preprod.dsp5-archi-o24a-4-5-g4.fr` | `https://api-preprod.dsp5-archi-o24a-4-5-g4.fr` |
|
||||||
|
| PROD | `main` | `https://dsp5-archi-o24a-4-5-g4.fr` | `https://api.dsp5-archi-o24a-4-5-g4.fr` |
|
||||||
|
|
||||||
|
Les bases PostgreSQL et conteneurs applicatifs sont séparés entre les environnements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Stratégie Git
|
||||||
|
|
||||||
|
Le workflow de branches est organisé autour de plusieurs niveaux de validation.
|
||||||
|
|
||||||
|
```text
|
||||||
|
feature/*
|
||||||
|
│
|
||||||
|
│ Pull Request / Merge
|
||||||
|
▼
|
||||||
|
develop
|
||||||
|
│
|
||||||
|
│ CI + déploiement DEV
|
||||||
|
▼
|
||||||
|
release-*
|
||||||
|
│
|
||||||
|
│ CI + déploiement PREPROD
|
||||||
|
▼
|
||||||
|
main
|
||||||
|
│
|
||||||
|
│ validation manuelle Jenkins
|
||||||
|
▼
|
||||||
|
PRODUCTION
|
||||||
|
```
|
||||||
|
|
||||||
|
### `feature/*`
|
||||||
|
|
||||||
|
Branches utilisées pour le développement des fonctionnalités.
|
||||||
|
|
||||||
|
### `develop`
|
||||||
|
|
||||||
|
Branche d’intégration. Un changement validé sur cette branche est déployé automatiquement en DEV.
|
||||||
|
|
||||||
|
### `release-*`
|
||||||
|
|
||||||
|
Branches de préparation d’une version. Elles sont déployées en PREPROD pour validation avant production.
|
||||||
|
|
||||||
|
### `main`
|
||||||
|
|
||||||
|
Branche stable de production. Le déploiement PROD nécessite une validation manuelle dans Jenkins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pipeline CI/CD
|
||||||
|
|
||||||
|
Le fichier `Jenkinsfile` placé à la racine du dépôt définit la chaîne d’intégration et de déploiement.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Push Gitea
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Jenkins
|
||||||
|
│
|
||||||
|
├── Checkout
|
||||||
|
│
|
||||||
|
├── Vérification workspace
|
||||||
|
│
|
||||||
|
├── PostgreSQL CI temporaire
|
||||||
|
│
|
||||||
|
├── Prisma migrations
|
||||||
|
│
|
||||||
|
├── Prisma seed
|
||||||
|
│
|
||||||
|
├── Tests backend Jest
|
||||||
|
│
|
||||||
|
├── Publication JUnit
|
||||||
|
│
|
||||||
|
├── Frontend ESLint
|
||||||
|
│
|
||||||
|
├── Frontend Vite build
|
||||||
|
│
|
||||||
|
├── Docker build
|
||||||
|
│
|
||||||
|
├── Push Registry privé
|
||||||
|
│
|
||||||
|
└── Docker Compose deploy
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DEV / PREPROD / PROD
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comportement selon la branche
|
||||||
|
|
||||||
|
### Feature
|
||||||
|
|
||||||
|
```text
|
||||||
|
Push
|
||||||
|
↓
|
||||||
|
Tests
|
||||||
|
↓
|
||||||
|
Lint
|
||||||
|
↓
|
||||||
|
Build
|
||||||
|
```
|
||||||
|
|
||||||
|
Pas de déploiement automatique.
|
||||||
|
|
||||||
|
### Develop
|
||||||
|
|
||||||
|
```text
|
||||||
|
Push develop
|
||||||
|
↓
|
||||||
|
CI
|
||||||
|
↓
|
||||||
|
Images Docker
|
||||||
|
↓
|
||||||
|
Registry
|
||||||
|
↓
|
||||||
|
Déploiement DEV
|
||||||
|
```
|
||||||
|
|
||||||
|
### Release
|
||||||
|
|
||||||
|
```text
|
||||||
|
Push release-*
|
||||||
|
↓
|
||||||
|
CI
|
||||||
|
↓
|
||||||
|
Images Docker
|
||||||
|
↓
|
||||||
|
Registry
|
||||||
|
↓
|
||||||
|
Déploiement PREPROD
|
||||||
|
```
|
||||||
|
|
||||||
|
### Main
|
||||||
|
|
||||||
|
```text
|
||||||
|
Push main
|
||||||
|
↓
|
||||||
|
CI
|
||||||
|
↓
|
||||||
|
Images Docker
|
||||||
|
↓
|
||||||
|
Registry
|
||||||
|
↓
|
||||||
|
Validation Jenkins
|
||||||
|
↓
|
||||||
|
Déploiement PROD
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette validation constitue une barrière supplémentaire avant la mise en production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tests automatisés
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
Les tests utilisent **Jest** et **Supertest**.
|
||||||
|
|
||||||
|
Ils couvrent notamment :
|
||||||
|
|
||||||
|
- inscription ;
|
||||||
|
- connexion ;
|
||||||
|
- parcours E2E ;
|
||||||
|
- autorisations ;
|
||||||
|
- participation ;
|
||||||
|
- phases du jeu ;
|
||||||
|
- tirage au sort ;
|
||||||
|
- notifications liées aux gains ;
|
||||||
|
- notification du grand gagnant ;
|
||||||
|
- authentification Google ;
|
||||||
|
- gestion du mot de passe ;
|
||||||
|
- comportements liés aux emails.
|
||||||
|
|
||||||
|
Commande :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans Jenkins, PostgreSQL est lancé temporairement pour les tests. Les migrations et données initiales sont appliquées avant l’exécution des suites.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
Les tests utilisent **Vitest** et **Testing Library**.
|
||||||
|
|
||||||
|
Ils couvrent notamment :
|
||||||
|
|
||||||
|
- connexion ;
|
||||||
|
- inscription ;
|
||||||
|
- formulaire d’inscription ;
|
||||||
|
- saisie du code unique ;
|
||||||
|
- routes protégées ;
|
||||||
|
- contexte d’authentification ;
|
||||||
|
- gestion des erreurs d’authentification ;
|
||||||
|
- services ;
|
||||||
|
- bannière de cookies.
|
||||||
|
|
||||||
|
Commandes :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run lint
|
||||||
|
npm test -- --run
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Authentification et sécurité
|
||||||
|
|
||||||
|
Le projet met en œuvre plusieurs mécanismes de sécurité.
|
||||||
|
|
||||||
|
## Authentification
|
||||||
|
|
||||||
|
- JWT ;
|
||||||
|
- email / mot de passe ;
|
||||||
|
- Google OAuth 2.0 ;
|
||||||
|
- vérification email.
|
||||||
|
|
||||||
|
## Protection des mots de passe
|
||||||
|
|
||||||
|
Les mots de passe sont hachés avec **Bcrypt** avant stockage.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Les données reçues par l’API sont validées avec **Zod**.
|
||||||
|
|
||||||
|
## Contrôle d’accès
|
||||||
|
|
||||||
|
Des middlewares backend contrôlent :
|
||||||
|
|
||||||
|
- l’authentification ;
|
||||||
|
- le rôle ;
|
||||||
|
- l’accès aux routes protégées.
|
||||||
|
|
||||||
|
## Sécurité HTTP
|
||||||
|
|
||||||
|
Le backend utilise notamment :
|
||||||
|
|
||||||
|
- Helmet ;
|
||||||
|
- rate limiting ;
|
||||||
|
- validation des entrées.
|
||||||
|
|
||||||
|
## HTTPS
|
||||||
|
|
||||||
|
Traefik assure le routage HTTP/HTTPS vers les différents services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Gestion des secrets
|
||||||
|
|
||||||
|
Les secrets ne doivent jamais être stockés directement dans le dépôt Git.
|
||||||
|
|
||||||
|
Sont notamment concernés :
|
||||||
|
|
||||||
|
```text
|
||||||
|
JWT_SECRET
|
||||||
|
DATABASE_URL / mots de passe PostgreSQL
|
||||||
|
MAIL_USER
|
||||||
|
MAIL_PASS
|
||||||
|
GOOGLE_CLIENT_ID
|
||||||
|
GOOGLE_CLIENT_SECRET
|
||||||
|
FACEBOOK_APP_ID
|
||||||
|
FACEBOOK_APP_SECRET
|
||||||
|
identifiants du Docker Registry
|
||||||
|
```
|
||||||
|
|
||||||
|
Les valeurs sensibles de déploiement sont externalisées sur le serveur ou injectées par Jenkins.
|
||||||
|
|
||||||
|
> Les fichiers contenant des secrets réels ne doivent jamais être ajoutés au dépôt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
|
||||||
|
Le frontend et le backend disposent chacun de leur propre image Docker.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
```text
|
||||||
|
Node.js 22
|
||||||
|
↓
|
||||||
|
npm ci
|
||||||
|
↓
|
||||||
|
Prisma generate
|
||||||
|
↓
|
||||||
|
Application Express
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
Build multi-stage :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Node.js 22
|
||||||
|
↓
|
||||||
|
npm ci
|
||||||
|
↓
|
||||||
|
Vite build
|
||||||
|
↓
|
||||||
|
Nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Les images sont versionnées et publiées dans le Registry privé par Jenkins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Docker Compose
|
||||||
|
|
||||||
|
Chaque environnement possède son propre fichier Compose :
|
||||||
|
|
||||||
|
```text
|
||||||
|
deploy/dev/docker-compose.yml
|
||||||
|
deploy/preprod/docker-compose.yml
|
||||||
|
deploy/prod/docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque stack contient notamment :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frontend
|
||||||
|
Backend
|
||||||
|
PostgreSQL
|
||||||
|
```
|
||||||
|
|
||||||
|
Les stacks sont isolées avec leurs propres conteneurs, réseaux, données et paramètres d’environnement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Reverse proxy et HTTPS
|
||||||
|
|
||||||
|
**Traefik** constitue le point d’entrée HTTP/HTTPS de l’infrastructure.
|
||||||
|
|
||||||
|
Il permet notamment :
|
||||||
|
|
||||||
|
- le routage par nom de domaine ;
|
||||||
|
- l’exposition des frontends ;
|
||||||
|
- l’exposition des APIs ;
|
||||||
|
- la terminaison TLS ;
|
||||||
|
- la gestion des certificats HTTPS.
|
||||||
|
|
||||||
|
Seuls les ports nécessaires sont exposés publiquement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
|
||||||
|
La plateforme dispose d’une stack de supervision.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Node Exporter ──┐
|
||||||
|
│
|
||||||
|
cAdvisor ───────┼──► Prometheus ───► Grafana
|
||||||
|
│
|
||||||
|
Services ───────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prometheus
|
||||||
|
|
||||||
|
Collecte les métriques.
|
||||||
|
|
||||||
|
### Grafana
|
||||||
|
|
||||||
|
Permet de construire et consulter les tableaux de bord.
|
||||||
|
|
||||||
|
### cAdvisor
|
||||||
|
|
||||||
|
Expose les métriques relatives aux conteneurs Docker.
|
||||||
|
|
||||||
|
### Node Exporter
|
||||||
|
|
||||||
|
Expose les métriques système du serveur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Documentation API
|
||||||
|
|
||||||
|
Le backend expose une documentation Swagger / OpenAPI.
|
||||||
|
|
||||||
|
En environnement local :
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:5000/api-docs
|
||||||
|
```
|
||||||
|
|
||||||
|
Elle permet de :
|
||||||
|
|
||||||
|
- consulter les routes ;
|
||||||
|
- consulter les schémas ;
|
||||||
|
- examiner les paramètres ;
|
||||||
|
- tester les endpoints ;
|
||||||
|
- utiliser l’authentification prévue par l’API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Installation locale
|
||||||
|
|
||||||
|
## 1. Cloner le dépôt
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <URL_DU_DEPOT>
|
||||||
|
cd THE-TIP-TOP
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Configurer les variables d’environnement locales puis :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma migrate dev
|
||||||
|
npx prisma db seed
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Frontend
|
||||||
|
|
||||||
|
Dans un second terminal :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Le frontend local est généralement accessible sur :
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
et l’API sur :
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
Consulter les README des sous-projets pour les détails de configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Base de données
|
||||||
|
|
||||||
|
Le projet utilise **PostgreSQL 16** avec **Prisma ORM 6**.
|
||||||
|
|
||||||
|
Commandes Prisma utiles :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma migrate dev
|
||||||
|
npx prisma migrate deploy
|
||||||
|
npx prisma db seed
|
||||||
|
npx prisma studio
|
||||||
|
```
|
||||||
|
|
||||||
|
En environnement déployé, les migrations sont appliquées avec `prisma migrate deploy`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Qualité logicielle
|
||||||
|
|
||||||
|
Le projet applique plusieurs niveaux de contrôle :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Code
|
||||||
|
↓
|
||||||
|
ESLint
|
||||||
|
↓
|
||||||
|
Tests unitaires / fonctionnels
|
||||||
|
↓
|
||||||
|
Tests backend / E2E
|
||||||
|
↓
|
||||||
|
Build
|
||||||
|
↓
|
||||||
|
Docker Build
|
||||||
|
↓
|
||||||
|
Déploiement
|
||||||
|
```
|
||||||
|
|
||||||
|
Un échec dans une étape critique provoque l’échec du pipeline et empêche la promotion normale de la version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Infrastructure DevOps
|
||||||
|
|
||||||
|
Vue simplifiée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌───────────────┐
|
||||||
|
│ Gitea │
|
||||||
|
└───────┬───────┘
|
||||||
|
│ Webhook
|
||||||
|
▼
|
||||||
|
┌───────────────┐
|
||||||
|
│ Jenkins │
|
||||||
|
└───────┬───────┘
|
||||||
|
│
|
||||||
|
Tests / Build / Docker
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────┐
|
||||||
|
│Docker Registry│
|
||||||
|
└───────┬───────┘
|
||||||
|
│
|
||||||
|
┌───────────────┼───────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
DEV PREPROD PROD
|
||||||
|
│ │ │
|
||||||
|
└───────────────┼───────────────┘
|
||||||
|
▼
|
||||||
|
Traefik
|
||||||
|
│
|
||||||
|
HTTPS
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# État du projet
|
||||||
|
|
||||||
|
Le projet dispose notamment de :
|
||||||
|
|
||||||
|
- frontend React opérationnel ;
|
||||||
|
- backend Express opérationnel ;
|
||||||
|
- PostgreSQL et Prisma ;
|
||||||
|
- authentification JWT ;
|
||||||
|
- Google OAuth 2.0 ;
|
||||||
|
- vérification email ;
|
||||||
|
- gestion CLIENT / EMPLOYEE / ADMIN ;
|
||||||
|
- jeu-concours et gestion des gains ;
|
||||||
|
- tirage final ;
|
||||||
|
- tests automatisés ;
|
||||||
|
- documentation Swagger ;
|
||||||
|
- images Docker ;
|
||||||
|
- Registry privé ;
|
||||||
|
- pipeline Jenkins multibranch ;
|
||||||
|
- environnements DEV / PREPROD / PROD ;
|
||||||
|
- déploiements Docker Compose ;
|
||||||
|
- reverse proxy Traefik ;
|
||||||
|
- HTTPS ;
|
||||||
|
- monitoring Prometheus / Grafana.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Documentation complémentaire
|
||||||
|
|
||||||
|
Documentation backend :
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Documentation frontend :
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend/README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces fichiers détaillent respectivement l’API et l’application React.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Projet académique
|
||||||
|
|
||||||
|
Projet réalisé dans le cadre de la certification :
|
||||||
|
|
||||||
|
**Expert en Stratégie et Transformation Digitale**
|
||||||
|
|
||||||
|
Le cas **Thé Tip Top** est utilisé comme contexte métier fictif pour la conception, le développement, les tests, la sécurisation, l’industrialisation et le déploiement de l’application.
|
||||||
@@ -4,9 +4,18 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/the_tip_top_db"
|
|||||||
|
|
||||||
JWT_SECRET="your_secret_key"
|
JWT_SECRET="your_secret_key"
|
||||||
|
|
||||||
APP_URL=http://localhost:5000
|
APP_URL="http://localhost:5000"
|
||||||
MAIL_HOST=smtp.gmail.com
|
FRONTEND_URL="http://localhost:5173"
|
||||||
MAIL_PORT=587
|
MAIL_HOST="smtp.gmail.com"
|
||||||
MAIL_USER=yourmail_adress@gmail.com
|
MAIL_PORT="587"
|
||||||
MAIL_PASS=_______________________
|
MAIL_USER=abayomiandil62@gmail.com
|
||||||
MAIL_FROM="Thé Tip Top <yourmail_adress@gmail.com>"
|
MAIL_PASS=dumqamoupdrliygf
|
||||||
|
MAIL_FROM="Thé Tip Top <abayomiandil62@gmail.com>"
|
||||||
|
|
||||||
|
# Connexion externe (OAuth 2.0). Laisser vide désactive proprement le bouton
|
||||||
|
# correspondant : la route répond 503 au lieu de faire planter le serveur.
|
||||||
|
# URI de redirection à déclarer côté provider : APP_URL + /api/auth/google/callback
|
||||||
|
GOOGLE_CLIENT_ID=""
|
||||||
|
GOOGLE_CLIENT_SECRET=""
|
||||||
|
FACEBOOK_APP_ID=""
|
||||||
|
FACEBOOK_APP_SECRET=""
|
||||||
|
|||||||
1
backend/.gitignore
vendored
@@ -3,3 +3,4 @@ node_modules/
|
|||||||
.prisma
|
.prisma
|
||||||
coverage/
|
coverage/
|
||||||
dist/
|
dist/
|
||||||
|
test-results/
|
||||||
|
|||||||
@@ -1,368 +1,285 @@
|
|||||||
# Thé Tip Top – Backend API
|
# Thé Tip Top – Backend API
|
||||||
|
|
||||||
## Présentation
|
## Présentation
|
||||||
|
|
||||||
Ce projet correspond au backend du jeu-concours organisé par **Thé Tip Top** à l'occasion de l'ouverture de sa 10ᵉ boutique à Nice.
|
Backend REST du jeu-concours fictif **Thé Tip Top**, réalisé dans le cadre de la certification **Expert en Stratégie et Transformation Digitale**.
|
||||||
|
|
||||||
L'application permet aux clients de participer à un jeu-concours via un code présent sur leur ticket de caisse, de consulter leurs gains et de réclamer leurs lots en boutique.
|
L’API fournit les services des espaces **CLIENT**, **EMPLOYEE** et **ADMIN** et prend notamment en charge :
|
||||||
|
|
||||||
Le backend expose une **API** **REST** sécurisée utilisée par :
|
- inscription, vérification email et connexion ;
|
||||||
|
- authentification JWT et Google OAuth 2.0 ;
|
||||||
- l'application client (participants)
|
- rôles et autorisations ;
|
||||||
- l'espace employé en boutique
|
- tickets, participations et gains ;
|
||||||
- l'espace administrateur
|
- remise des lots ;
|
||||||
- les futurs services externes de l'écosystème Thé Tip Top
|
- configuration des phases du jeu ;
|
||||||
|
- tirage au sort final ;
|
||||||
|
- notifications email ;
|
||||||
|
- administration et statistiques.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fonctionnalités
|
## Stack technique
|
||||||
|
|
||||||
### Authentification
|
| Technologie | Utilisation |
|
||||||
|
|---|---|
|
||||||
|
| Node.js 22 | Runtime |
|
||||||
|
| Express.js | API REST |
|
||||||
|
| PostgreSQL 16 | Base de données |
|
||||||
|
| Prisma ORM 6 | ORM et migrations |
|
||||||
|
| JWT | Authentification |
|
||||||
|
| Passport.js | Authentification externe |
|
||||||
|
| Google OAuth 2.0 | Connexion Google |
|
||||||
|
| Bcrypt | Hachage des mots de passe |
|
||||||
|
| Zod | Validation |
|
||||||
|
| Nodemailer | Emails |
|
||||||
|
| Helmet | Sécurité HTTP |
|
||||||
|
| express-rate-limit | Rate limiting |
|
||||||
|
| Swagger / OpenAPI | Documentation |
|
||||||
|
| Jest / Supertest | Tests |
|
||||||
|
| Docker | Conteneurisation |
|
||||||
|
|
||||||
- Inscription utilisateur
|
## Authentification
|
||||||
- Connexion sécurisée
|
|
||||||
- Authentification **JWT**
|
|
||||||
- Gestion des rôles (**CLIENT**, **EMPLOYEE**, **ADMIN**)
|
|
||||||
- Hachage des mots de passe avec Bcrypt
|
|
||||||
- Validation des données avec **Zod**
|
|
||||||
|
|
||||||
### Jeu-concours
|
### Email / mot de passe
|
||||||
|
|
||||||
- Génération automatique des lots
|
Le parcours comprend l’inscription, le hachage Bcrypt, l’envoi d’un code de vérification par email, la validation du compte, la connexion et la génération d’un JWT.
|
||||||
- Génération des tickets gagnants
|
|
||||||
- Participation via code ticket unique
|
|
||||||
- Attribution automatique des gains
|
|
||||||
- Historique des participations
|
|
||||||
- Historique des gains remportés
|
|
||||||
|
|
||||||
### Espace Employé
|
### Google OAuth 2.0
|
||||||
|
|
||||||
- Recherche d'un gain via un code ticket
|
```text
|
||||||
- Vérification du statut du gain
|
Utilisateur
|
||||||
- Validation de la remise d'un lot
|
↓
|
||||||
- Mise à jour automatique du statut du gain
|
Frontend
|
||||||
|
↓
|
||||||
### Espace Administrateur
|
API /api/auth/google
|
||||||
|
↓
|
||||||
- Tableau de bord statistique
|
Google OAuth 2.0
|
||||||
- Liste des participants
|
↓
|
||||||
- Liste des contacts ayant accepté les communications marketing
|
Callback backend
|
||||||
- Création de comptes employés
|
↓
|
||||||
- Tirage au sort du grand gagnant
|
Création / récupération utilisateur
|
||||||
- Consultation du gagnant du gros lot
|
↓
|
||||||
|
JWT
|
||||||
### Documentation API
|
↓
|
||||||
|
Redirection frontend
|
||||||
- Documentation interactive avec **Swagger UI**
|
|
||||||
- Génération automatique de la documentation via **Swagger JSDoc**
|
|
||||||
- Tests des endpoints directement depuis le navigateur
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Technique
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- Node.js
|
|
||||||
- Express.js
|
|
||||||
|
|
||||||
### Base de données
|
|
||||||
|
|
||||||
- PostgreSQL
|
|
||||||
- **Prisma **ORM** 6**
|
|
||||||
- Prisma Client 6
|
|
||||||
- Prisma Migrate
|
|
||||||
- Prisma Studio
|
|
||||||
|
|
||||||
### Validation des données
|
|
||||||
|
|
||||||
- **Zod**
|
|
||||||
- Middleware de validation personnalisé
|
|
||||||
|
|
||||||
### Sécurité
|
|
||||||
|
|
||||||
- **JWT** (**JSON** Web Token)
|
|
||||||
- Bcrypt
|
|
||||||
- Gestion des rôles et permissions
|
|
||||||
- Validation des entrées utilisateur
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- Swagger UI
|
|
||||||
- Swagger JSDoc
|
|
||||||
- OpenAPI 3.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Cloner le projet
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone <url-du-repository>
|
|
||||||
cd the-tip-top-backend
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Installer les dépendances
|
Les secrets OAuth restent hors du dépôt Git.
|
||||||
|
|
||||||
|
## Rôles
|
||||||
|
|
||||||
|
### CLIENT
|
||||||
|
- participer au jeu ;
|
||||||
|
- consulter ses gains ;
|
||||||
|
- gérer son profil.
|
||||||
|
|
||||||
|
### EMPLOYEE
|
||||||
|
- rechercher et vérifier un gain ;
|
||||||
|
- valider la remise d’un lot.
|
||||||
|
|
||||||
|
### ADMIN
|
||||||
|
- consulter les statistiques ;
|
||||||
|
- créer des employés ;
|
||||||
|
- configurer le jeu ;
|
||||||
|
- effectuer le tirage final ;
|
||||||
|
- consulter le grand gagnant.
|
||||||
|
|
||||||
|
## Jeu-concours
|
||||||
|
|
||||||
|
Phases applicatives :
|
||||||
|
|
||||||
|
- `NOT_CONFIGURED`
|
||||||
|
- `SCHEDULED`
|
||||||
|
- `OPEN`
|
||||||
|
- `CLAIM_ONLY`
|
||||||
|
- `CLOSED`
|
||||||
|
|
||||||
|
Principales règles métier :
|
||||||
|
|
||||||
|
- un ticket ne peut être utilisé qu’une fois ;
|
||||||
|
- les tickets sont gagnants ;
|
||||||
|
- les comptes EMPLOYEE et ADMIN ne participent pas comme clients ;
|
||||||
|
- plusieurs participations d’un client n’augmentent pas ses chances au tirage final ;
|
||||||
|
- un seul grand gagnant peut être enregistré ;
|
||||||
|
- le tirage final respecte les contraintes temporelles du jeu.
|
||||||
|
|
||||||
|
## Notifications email
|
||||||
|
|
||||||
|
Nodemailer est utilisé notamment pour la vérification des comptes et les notifications liées aux gains et au tirage final. La configuration SMTP est fournie par variables d’environnement.
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
- Bcrypt ;
|
||||||
|
- JWT ;
|
||||||
|
- Google OAuth 2.0 ;
|
||||||
|
- Zod ;
|
||||||
|
- contrôle d’accès par rôle ;
|
||||||
|
- Helmet ;
|
||||||
|
- rate limiting ;
|
||||||
|
- secrets externalisés ;
|
||||||
|
- HTTPS via Traefik ;
|
||||||
|
- séparation DEV / PREPROD / PROD.
|
||||||
|
|
||||||
|
> Ne jamais versionner les mots de passe, tokens, secrets JWT, secrets OAuth ou identifiants SMTP.
|
||||||
|
|
||||||
|
## Modèle de données principal
|
||||||
|
|
||||||
|
- `User` : utilisateurs et rôles CLIENT / EMPLOYEE / ADMIN.
|
||||||
|
- `Ticket` : code unique, lot associé et état d’utilisation.
|
||||||
|
- `Lot` : lot, description, valeur et répartition.
|
||||||
|
- `Participation` : participation d’un utilisateur avec un ticket.
|
||||||
|
- `Claim` : gestion de la remise du gain.
|
||||||
|
- `GameSettings` : configuration temporelle du concours.
|
||||||
|
- `GrandPrizeWinner` : résultat du tirage final.
|
||||||
|
|
||||||
|
## Installation locale
|
||||||
|
|
||||||
|
Prérequis : Node.js 22, npm et PostgreSQL 16.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Variables d'environnement
|
Exemple de `.env` local :
|
||||||
|
|
||||||
Créer un fichier `.env` à la racine du projet :
|
```env
|
||||||
|
PORT=5000
|
||||||
|
NODE_ENV=development
|
||||||
```env **PORT**=**5000**```
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/the_tip_top_db"
|
||||||
|
JWT_SECRET="change_me"
|
||||||
```DATABASE_URL="postgresql://postgres:password@localhost:**5432**/the_tip_top_db*```
|
APP_URL="http://localhost:5000"
|
||||||
|
FRONTEND_URL="http://localhost:5173"
|
||||||
```JWT_SECRET=*your_secret_key" ```
|
MAIL_HOST="smtp.example.com"
|
||||||
|
MAIL_PORT=587
|
||||||
---
|
MAIL_USER="user@example.com"
|
||||||
|
MAIL_PASS="change_me"
|
||||||
## Base de données
|
MAIL_FROM="Thé Tip Top <user@example.com>"
|
||||||
|
GOOGLE_CLIENT_ID=""
|
||||||
### Générer les migrations
|
GOOGLE_CLIENT_SECRET=""
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma migrate dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Générer Prisma Client
|
Ces valeurs sont uniquement des exemples.
|
||||||
|
|
||||||
|
## Prisma
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx prisma generate
|
npx prisma generate
|
||||||
```
|
npx prisma migrate dev
|
||||||
|
npx prisma migrate deploy
|
||||||
### Exécuter le seed de la base
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma db seed
|
npx prisma db seed
|
||||||
```
|
|
||||||
|
|
||||||
### Générer les tickets du jeu-concours
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run generate:tickets
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ouvrir Prisma Studio
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma studio
|
npx prisma studio
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## Lancement
|
||||||
|
|
||||||
## Technologies utilisées
|
Développement :
|
||||||
|
|
||||||
| Technologie | Version |
|
|
||||||
| ----------- | ---------------- |
|
|
||||||
| Node.js | 20+ |
|
|
||||||
| Express.js | 5 |
|
|
||||||
| PostgreSQL | 16+ |
|
|
||||||
| Prisma ORM | 6 |
|
|
||||||
| JWT | Dernière version |
|
|
||||||
| Bcrypt | Dernière version |
|
|
||||||
| Zod | Dernière version |
|
|
||||||
| Swagger UI | Dernière version |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Lancement du projet
|
|
||||||
|
|
||||||
### Développement
|
|
||||||
|
|
||||||
Le projet utilise **Nodemon** afin de redémarrer automatiquement le serveur lors des modifications du code.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Serveur :
|
Démarrage standard :
|
||||||
|
|
||||||
```txt [http://localhost:**5000**](http://localhost:**5000**) ```
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
---
|
API locale généralement disponible sur `http://localhost:5000`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tests Jest/Supertest couvrent notamment l’inscription, la connexion, les autorisations, les participations, les phases du jeu, le tirage final, les notifications, l’authentification Google et des scénarios E2E.
|
||||||
|
|
||||||
|
Dans Jenkins, une base PostgreSQL temporaire est créée. Les migrations et le seed sont exécutés avant les tests, puis les résultats sont publiés au format JUnit.
|
||||||
|
|
||||||
## Documentation API
|
## Documentation API
|
||||||
|
|
||||||
La documentation Swagger est accessible à l'adresse :
|
Swagger UI est accessible localement sur :
|
||||||
|
|
||||||
```txt [http://localhost:**5000**/api-docs](http://localhost:**5000**/api-docs) ```
|
```text
|
||||||
|
http://localhost:5000/api-docs
|
||||||
Fonctionnalités disponibles :
|
|
||||||
|
|
||||||
- Consultation de toutes les routes
|
|
||||||
- Visualisation des schémas de données
|
|
||||||
- Test des endpoints
|
|
||||||
- Authentification via **JWT** avec le bouton **Authorize**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Gestion des rôles
|
|
||||||
|
|
||||||
### CLIENT
|
|
||||||
|
|
||||||
Peut :
|
|
||||||
|
|
||||||
- Participer au jeu-concours
|
|
||||||
- Consulter ses gains
|
|
||||||
- Consulter son profil
|
|
||||||
|
|
||||||
### EMPLOYEE
|
|
||||||
|
|
||||||
Peut :
|
|
||||||
|
|
||||||
- Rechercher un gain
|
|
||||||
- Vérifier les informations du gagnant
|
|
||||||
- Valider la remise d'un lot
|
|
||||||
|
|
||||||
### ADMIN
|
|
||||||
|
|
||||||
Peut :
|
|
||||||
|
|
||||||
- Accéder aux statistiques
|
|
||||||
- Créer des employés
|
|
||||||
- Gérer les campagnes emailing
|
|
||||||
- Effectuer le tirage au sort final
|
|
||||||
- Consulter le gagnant du gros lot
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Routes API
|
|
||||||
|
|
||||||
### Auth
|
|
||||||
|
|
||||||
| Méthode | Route |
|
|
||||||
| ------- | ------------------ |
|
|
||||||
| POST | /api/auth/register |
|
|
||||||
| POST | /api/auth/login |
|
|
||||||
| GET | /api/auth/me |
|
|
||||||
|
|
||||||
### Game
|
|
||||||
|
|
||||||
| Méthode | Route |
|
|
||||||
| ------- | --------------------- |
|
|
||||||
| POST | /api/game/participate |
|
|
||||||
| GET | /api/game/my-gains |
|
|
||||||
|
|
||||||
### Employee
|
|
||||||
|
|
||||||
| Méthode | Route |
|
|
||||||
| ------- | ---------------------------- |
|
|
||||||
| GET | /api/employee/ticket/ |
|
|
||||||
| PATCH | /api/employee/claim//deliver |
|
|
||||||
|
|
||||||
### Admin
|
|
||||||
|
|
||||||
| Méthode | Route |
|
|
||||||
| ------- | -------------------- |
|
|
||||||
| GET | /api/admin/stats |
|
|
||||||
| GET | /api/admin/users |
|
|
||||||
| GET | /api/admin/emailing |
|
|
||||||
| POST | /api/admin/employees |
|
|
||||||
| POST | /api/admin/draw |
|
|
||||||
| GET | /api/admin/draw |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Validation avec Zod
|
|
||||||
|
|
||||||
Toutes les données reçues par l'**API** sont validées avant traitement grâce à **Zod**.
|
|
||||||
|
|
||||||
Exemples :
|
|
||||||
|
|
||||||
- Inscription utilisateur
|
|
||||||
- Connexion
|
|
||||||
- Création d'employés
|
|
||||||
- Participation au jeu-concours
|
|
||||||
- Validation des paramètres de requête
|
|
||||||
|
|
||||||
Cette approche garantit :
|
|
||||||
|
|
||||||
- Des données cohérentes
|
|
||||||
- Une meilleure sécurité
|
|
||||||
- Des messages d'erreur explicites
|
|
||||||
- Une maintenance facilitée
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Modèle de données principal
|
|
||||||
|
|
||||||
### User
|
|
||||||
|
|
||||||
- **CLIENT**
|
|
||||||
- **EMPLOYEE**
|
|
||||||
- **ADMIN**
|
|
||||||
|
|
||||||
### Ticket
|
|
||||||
|
|
||||||
- Code unique
|
|
||||||
- Gain associé
|
|
||||||
- Utilisé / Non utilisé
|
|
||||||
|
|
||||||
### Lot
|
|
||||||
|
|
||||||
- Nom
|
|
||||||
- Description
|
|
||||||
- Valeur
|
|
||||||
- Pourcentage de répartition
|
|
||||||
|
|
||||||
### Participation
|
|
||||||
|
|
||||||
- Utilisateur
|
|
||||||
- Ticket
|
|
||||||
- Lot gagné
|
|
||||||
|
|
||||||
### Claim
|
|
||||||
|
|
||||||
- **PENDING**
|
|
||||||
- **DELIVERED**
|
|
||||||
- **CANCELLED**
|
|
||||||
|
|
||||||
### GrandPrizeWinner
|
|
||||||
|
|
||||||
- Gagnant du gros lot
|
|
||||||
- Date du tirage
|
|
||||||
- Récompense
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prisma ORM 6
|
|
||||||
|
|
||||||
Le projet utilise **Prisma **ORM** 6** pour :
|
|
||||||
|
|
||||||
- La modélisation des données
|
|
||||||
- Les migrations de base de données
|
|
||||||
- Les requêtes **SQL** typées
|
|
||||||
- La génération automatique du client Prisma
|
|
||||||
- L'administration de la base via Prisma Studio
|
|
||||||
|
|
||||||
Commandes utiles :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma migrate dev npx prisma generate npx prisma db seed npx prisma studio
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## CI/CD
|
||||||
|
|
||||||
## Règles métier
|
Le projet utilise Gitea, Jenkins, Docker et un Registry privé.
|
||||||
|
|
||||||
- Tous les tickets sont gagnants.
|
```text
|
||||||
- Un ticket ne peut être utilisé qu'une seule fois.
|
feature/* → CI uniquement
|
||||||
- Les gains sont associés aux tickets dès leur génération.
|
develop → DEV automatique
|
||||||
- Plusieurs participations n'augmentent pas les chances de remporter le gros lot.
|
release-* → PREPROD automatique
|
||||||
- Un seul gagnant du gros lot peut être enregistré.
|
main → validation manuelle → PROD
|
||||||
- Les gains non récupérés dans les délais peuvent être annulés.
|
```
|
||||||
|
|
||||||
---
|
Pipeline :
|
||||||
|
|
||||||
## Auteur
|
```text
|
||||||
|
Checkout
|
||||||
|
↓
|
||||||
|
Tests backend + PostgreSQL temporaire
|
||||||
|
↓
|
||||||
|
Lint + Build frontend
|
||||||
|
↓
|
||||||
|
Docker Build
|
||||||
|
↓
|
||||||
|
Registry
|
||||||
|
↓
|
||||||
|
Déploiement
|
||||||
|
↓
|
||||||
|
Contrôle des conteneurs
|
||||||
|
```
|
||||||
|
|
||||||
Projet réalisé dans le cadre de la certification : **Expert en Stratégie et Transformation Digitale**
|
## Environnements
|
||||||
|
|
||||||
|
| Environnement | Frontend | API |
|
||||||
|
|---|---|---|
|
||||||
|
| DEV | `https://dev.dsp5-archi-o24a-4-5-g4.fr` | `https://api-dev.dsp5-archi-o24a-4-5-g4.fr` |
|
||||||
|
| PREPROD | `https://preprod.dsp5-archi-o24a-4-5-g4.fr` | `https://api-preprod.dsp5-archi-o24a-4-5-g4.fr` |
|
||||||
|
| PROD | `https://dsp5-archi-o24a-4-5-g4.fr` | `https://api.dsp5-archi-o24a-4-5-g4.fr` |
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- Docker / Docker Compose ;
|
||||||
|
- Gitea ;
|
||||||
|
- Jenkins ;
|
||||||
|
- Docker Registry ;
|
||||||
|
- Traefik ;
|
||||||
|
- Let's Encrypt / HTTPS ;
|
||||||
|
- PostgreSQL ;
|
||||||
|
- Prometheus ;
|
||||||
|
- Grafana ;
|
||||||
|
- cAdvisor ;
|
||||||
|
- Node Exporter.
|
||||||
|
|
||||||
|
## Arborescence simplifiée
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/
|
||||||
|
├── prisma/
|
||||||
|
│ ├── migrations/
|
||||||
|
│ ├── schema.prisma
|
||||||
|
│ └── seed.js
|
||||||
|
├── src/
|
||||||
|
│ ├── config/
|
||||||
|
│ ├── controllers/
|
||||||
|
│ ├── docs/
|
||||||
|
│ ├── middlewares/
|
||||||
|
│ ├── routes/
|
||||||
|
│ ├── utils/
|
||||||
|
│ ├── validations/
|
||||||
|
│ └── app.js
|
||||||
|
├── tests/
|
||||||
|
├── Dockerfile
|
||||||
|
├── package.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Projet
|
||||||
|
|
||||||
|
Projet étudiant fictif réalisé dans le cadre de la certification **Expert en Stratégie et Transformation Digitale**.
|
||||||
|
|
||||||
Client fictif : **Thé Tip Top**.
|
Client fictif : **Thé Tip Top**.
|
||||||
165
backend/package-lock.json
generated
@@ -13,8 +13,12 @@
|
|||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^8.6.1",
|
||||||
|
"helmet": "^8.3.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^9.0.3",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
"swagger-jsdoc": "^6.3.0",
|
"swagger-jsdoc": "^6.3.0",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
@@ -605,22 +609,22 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
|
||||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/wasi-threads": "1.2.2",
|
"@emnapi/wasi-threads": "1.2.3",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -630,9 +634,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
||||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -2197,6 +2201,15 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/base64url": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.40",
|
"version": "2.10.40",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
||||||
@@ -3210,6 +3223,25 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "8.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz",
|
||||||
|
"integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"ip-address": "^10.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/exsolve": {
|
"node_modules/exsolve": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||||
@@ -3658,6 +3690,18 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/helmet": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/EvanHahn"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/html-escaper": {
|
"node_modules/html-escaper": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
@@ -3766,6 +3810,15 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ip-address": {
|
||||||
|
"version": "10.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
|
||||||
|
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -5487,9 +5540,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "9.0.1",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
|
||||||
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
|
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -5608,6 +5661,12 @@
|
|||||||
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth": {
|
||||||
|
"version": "0.10.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
|
||||||
|
"integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -5768,6 +5827,64 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/passport": {
|
||||||
|
"version": "0.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
|
||||||
|
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"passport-strategy": "1.x.x",
|
||||||
|
"pause": "0.0.1",
|
||||||
|
"utils-merge": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jaredhanson"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/passport-google-oauth20": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"passport-oauth2": "1.x.x"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/passport-oauth2": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64url": "3.x.x",
|
||||||
|
"oauth": "0.10.x",
|
||||||
|
"passport-strategy": "1.x.x",
|
||||||
|
"uid2": "0.0.x",
|
||||||
|
"utils-merge": "1.x.x"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jaredhanson"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/passport-strategy": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -5829,6 +5946,11 @@
|
|||||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pause": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||||
|
},
|
||||||
"node_modules/perfect-debounce": {
|
"node_modules/perfect-debounce": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
@@ -6774,6 +6896,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uid2": {
|
||||||
|
"version": "0.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
|
||||||
|
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/undefsafe": {
|
"node_modules/undefsafe": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||||
@@ -6866,6 +6994,15 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/utils-merge": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "14.0.1",
|
"version": "14.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
|
||||||
|
|||||||
@@ -20,8 +20,12 @@
|
|||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^8.6.1",
|
||||||
|
"helmet": "^8.3.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^9.0.3",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
"swagger-jsdoc": "^6.3.0",
|
"swagger-jsdoc": "^6.3.0",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GameSettings" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"startDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "GameSettings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AuthProvider" AS ENUM ('LOCAL', 'GOOGLE', 'FACEBOOK');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "avatarUrl" TEXT,
|
||||||
|
ADD COLUMN "provider" "AuthProvider" NOT NULL DEFAULT 'LOCAL',
|
||||||
|
ADD COLUMN "providerId" TEXT,
|
||||||
|
ALTER COLUMN "password" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_provider_providerId_key" ON "User"("provider", "providerId");
|
||||||
@@ -24,12 +24,20 @@ enum ClaimStatus {
|
|||||||
CANCELLED
|
CANCELLED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AuthProvider {
|
||||||
|
LOCAL
|
||||||
|
GOOGLE
|
||||||
|
FACEBOOK
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
firstname String
|
firstname String
|
||||||
lastname String
|
lastname String
|
||||||
email String @unique
|
email String @unique
|
||||||
password String
|
// Nul pour les comptes créés via un provider externe (Google, Facebook) :
|
||||||
|
// ces utilisateurs n'ont jamais choisi de mot de passe chez nous.
|
||||||
|
password String?
|
||||||
role Role @default(CLIENT)
|
role Role @default(CLIENT)
|
||||||
birthdate DateTime?
|
birthdate DateTime?
|
||||||
gender String?
|
gender String?
|
||||||
@@ -43,10 +51,16 @@ model User {
|
|||||||
resetPasswordToken String?
|
resetPasswordToken String?
|
||||||
resetPasswordExpires DateTime?
|
resetPasswordExpires DateTime?
|
||||||
|
|
||||||
|
provider AuthProvider @default(LOCAL)
|
||||||
|
providerId String?
|
||||||
|
avatarUrl String?
|
||||||
|
|
||||||
tickets Ticket[]
|
tickets Ticket[]
|
||||||
participations Participation[]
|
participations Participation[]
|
||||||
grandPrizeWinner GrandPrizeWinner?
|
grandPrizeWinner GrandPrizeWinner?
|
||||||
claimsValidated Claim[] @relation("EmployeeClaims")
|
claimsValidated Claim[] @relation("EmployeeClaims")
|
||||||
|
|
||||||
|
@@unique([provider, providerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Lot {
|
model Lot {
|
||||||
@@ -116,3 +130,12 @@ model GrandPrizeWinner {
|
|||||||
prize String @default("Un an de thé d'une valeur de 360€")
|
prize String @default("Un an de thé d'une valeur de 360€")
|
||||||
drawnAt DateTime @default(now())
|
drawnAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ligne unique de configuration : date de début du jeu-concours.
|
||||||
|
// Participation et réclamation des lots sont autorisées pendant 60 jours
|
||||||
|
// (30 jours de jeu + 30 jours supplémentaires) à compter de startDate.
|
||||||
|
model GameSettings {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
startDate DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log("Lots créés avec succès");
|
console.log("Lots créés avec succès");
|
||||||
|
|
||||||
|
const existingSettings = await prisma.gameSettings.findFirst();
|
||||||
|
if (!existingSettings) {
|
||||||
|
await prisma.gameSettings.create({ data: {} });
|
||||||
|
console.log("Date de début du jeu-concours initialisée à aujourd'hui.");
|
||||||
|
} else {
|
||||||
|
console.log("Date de début du jeu-concours déjà définie, inchangée.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const cors = require("cors");
|
const cors = require("cors");
|
||||||
|
const helmet = require("helmet");
|
||||||
|
const rateLimit = require("express-rate-limit");
|
||||||
require("dotenv").config();
|
require("dotenv").config();
|
||||||
|
|
||||||
const authRoutes = require("./routes/auth.routes");
|
const authRoutes = require("./routes/auth.routes");
|
||||||
@@ -9,11 +11,37 @@ const adminRoutes = require("./routes/admin.routes");
|
|||||||
|
|
||||||
const swaggerUi = require("swagger-ui-express");
|
const swaggerUi = require("swagger-ui-express");
|
||||||
const swaggerSpec = require("./docs/swagger");
|
const swaggerSpec = require("./docs/swagger");
|
||||||
|
const { passport } = require("./config/passport");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(cors());
|
const allowedOrigins = (process.env.FRONTEND_URL || "http://localhost:5173").split(",");
|
||||||
|
|
||||||
|
// La CSP par défaut de helmet bloque les scripts inline de Swagger UI :
|
||||||
|
// on l'applique partout sauf sur /api-docs, où une CSP dédiée (désactivée)
|
||||||
|
// est posée juste avant de monter Swagger, plus bas.
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.path.startsWith("/api-docs")) return next();
|
||||||
|
return helmet()(req, res, next);
|
||||||
|
});
|
||||||
|
app.use(cors({ origin: allowedOrigins }));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
// OAuth stateless : initialize() suffit, aucune session n'est ouverte.
|
||||||
|
app.use(passport.initialize());
|
||||||
|
|
||||||
|
// Anti-bruteforce sur les routes d'authentification uniquement : 20
|
||||||
|
// tentatives par IP toutes les 15 minutes, sans limiter le reste de l'API
|
||||||
|
// (participation au jeu, consultation des gains, etc.).
|
||||||
|
const authLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
limit: 20,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { message: "Trop de tentatives, réessayez dans quelques minutes." },
|
||||||
|
});
|
||||||
|
app.use("/api/auth/login", authLimiter);
|
||||||
|
app.use("/api/auth/register", authLimiter);
|
||||||
|
app.use("/api/auth/forgot-password", authLimiter);
|
||||||
|
|
||||||
// routes
|
// routes
|
||||||
app.use("/api/auth", authRoutes);
|
app.use("/api/auth", authRoutes);
|
||||||
@@ -22,7 +50,7 @@ app.use("/api/employee", employeeRoutes);
|
|||||||
app.use("/api/admin", adminRoutes);
|
app.use("/api/admin", adminRoutes);
|
||||||
|
|
||||||
// swagger
|
// swagger
|
||||||
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
app.use("/api-docs", helmet({ contentSecurityPolicy: false }), swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||||
|
|
||||||
// test route
|
// test route
|
||||||
app.get("/", (req, res) => {
|
app.get("/", (req, res) => {
|
||||||
|
|||||||
136
backend/src/config/passport.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
const passport = require("passport");
|
||||||
|
const GoogleStrategy = require("passport-google-oauth20").Strategy;
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const prisma = require("./prisma");
|
||||||
|
const transporter = require("./mailer");
|
||||||
|
|
||||||
|
// Un compte créé via un provider externe n'a pas de mot de passe. On lui en
|
||||||
|
// propose un dès l'inscription, via le même mécanisme de jeton que la
|
||||||
|
// réinitialisation : sans mot de passe, l'utilisateur dépend entièrement de
|
||||||
|
// Google pour se connecter, et le formulaire de changement de son profil lui
|
||||||
|
// reste inaccessible.
|
||||||
|
const PASSWORD_SETUP_EXPIRY_MS = 1000 * 60 * 60 * 24; // 24 h
|
||||||
|
|
||||||
|
async function sendPasswordSetupEmail(user) {
|
||||||
|
const token = crypto.randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
resetPasswordToken: token,
|
||||||
|
resetPasswordExpires: new Date(Date.now() + PASSWORD_SETUP_EXPIRY_MS),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
||||||
|
const setupUrl = `${frontendUrl}/definir-mot-de-passe/${token}`;
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.MAIL_FROM,
|
||||||
|
to: user.email,
|
||||||
|
subject: "Définissez votre mot de passe Thé Tip Top",
|
||||||
|
html: `
|
||||||
|
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||||
|
<h2 style="color:#006B6B">Bienvenue ${user.firstname} !</h2>
|
||||||
|
<p>Votre compte a été créé avec Google. Vous pouvez continuer à vous
|
||||||
|
connecter de cette façon, ou définir un mot de passe pour accéder
|
||||||
|
également à votre compte sans passer par Google.</p>
|
||||||
|
<p style="text-align:center;padding:20px 0">
|
||||||
|
<a href="${setupUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block">
|
||||||
|
Définir mon mot de passe
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#888;font-size:13px">Ce lien expire dans 24 heures. Si vous
|
||||||
|
ne souhaitez pas de mot de passe, ignorez simplement cet email : votre
|
||||||
|
connexion Google continuera de fonctionner.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentification stateless : le callback OAuth émet directement notre JWT,
|
||||||
|
// donc pas de session Express ni de serialize/deserialize à brancher.
|
||||||
|
|
||||||
|
const googleEnabled =
|
||||||
|
Boolean(process.env.GOOGLE_CLIENT_ID) && Boolean(process.env.GOOGLE_CLIENT_SECRET);
|
||||||
|
|
||||||
|
if (googleEnabled) {
|
||||||
|
passport.use(
|
||||||
|
new GoogleStrategy(
|
||||||
|
{
|
||||||
|
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
|
callbackURL: `${process.env.APP_URL || "http://localhost:5000"}/api/auth/google/callback`,
|
||||||
|
scope: ["profile", "email"],
|
||||||
|
},
|
||||||
|
async (accessToken, refreshToken, profile, done) => {
|
||||||
|
try {
|
||||||
|
const email = profile.emails?.[0]?.value;
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return done(null, false, {
|
||||||
|
message: "Aucune adresse email n'est associée à ce compte Google.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Compte déjà lié à ce compte Google : connexion directe.
|
||||||
|
const byProvider = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
provider_providerId: { provider: "GOOGLE", providerId: profile.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (byProvider) return done(null, byProvider);
|
||||||
|
|
||||||
|
// 2. Un compte existe avec le même email : on le rattache à Google.
|
||||||
|
// L'email étant déjà validé par Google, on en profite pour lever
|
||||||
|
// la vérification si elle était en attente.
|
||||||
|
const byEmail = await prisma.user.findUnique({ where: { email } });
|
||||||
|
|
||||||
|
if (byEmail) {
|
||||||
|
const linked = await prisma.user.update({
|
||||||
|
where: { id: byEmail.id },
|
||||||
|
data: {
|
||||||
|
provider: "GOOGLE",
|
||||||
|
providerId: profile.id,
|
||||||
|
isEmailVerified: true,
|
||||||
|
emailVerifyToken: null,
|
||||||
|
emailVerifyExpires: null,
|
||||||
|
avatarUrl: byEmail.avatarUrl || profile.photos?.[0]?.value || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return done(null, linked);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Première connexion : création du compte.
|
||||||
|
const created = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
firstname: profile.name?.givenName || profile.displayName || "Utilisateur",
|
||||||
|
lastname: profile.name?.familyName || "",
|
||||||
|
email,
|
||||||
|
password: null,
|
||||||
|
provider: "GOOGLE",
|
||||||
|
providerId: profile.id,
|
||||||
|
avatarUrl: profile.photos?.[0]?.value || null,
|
||||||
|
isEmailVerified: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Un envoi qui échoue ne doit pas bloquer la connexion : le compte
|
||||||
|
// est valide, et « mot de passe oublié » permet de rattraper.
|
||||||
|
try {
|
||||||
|
await sendPasswordSetupEmail(created);
|
||||||
|
} catch (mailError) {
|
||||||
|
console.error("Erreur envoi email de definition du mot de passe:", mailError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return done(null, created);
|
||||||
|
} catch (error) {
|
||||||
|
return done(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { passport, googleEnabled };
|
||||||
@@ -1,8 +1,42 @@
|
|||||||
const bcrypt = require("bcrypt");
|
const bcrypt = require("bcrypt");
|
||||||
|
const crypto = require("crypto");
|
||||||
const prisma = require("../config/prisma");
|
const prisma = require("../config/prisma");
|
||||||
|
const transporter = require("../config/mailer");
|
||||||
|
const { getGameWindow } = require("../utils/gameWindow");
|
||||||
|
|
||||||
|
const AGE_BRACKETS = ["-18", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"];
|
||||||
|
|
||||||
|
function getAgeBracket(birthdate) {
|
||||||
|
if (!birthdate) return "unknown";
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
let age = now.getFullYear() - birthdate.getFullYear();
|
||||||
|
const hasHadBirthdayThisYear =
|
||||||
|
now.getMonth() > birthdate.getMonth() ||
|
||||||
|
(now.getMonth() === birthdate.getMonth() && now.getDate() >= birthdate.getDate());
|
||||||
|
if (!hasHadBirthdayThisYear) age -= 1;
|
||||||
|
|
||||||
|
if (age < 18) return "-18";
|
||||||
|
if (age <= 24) return "18-24";
|
||||||
|
if (age <= 34) return "25-34";
|
||||||
|
if (age <= 44) return "35-44";
|
||||||
|
if (age <= 54) return "45-54";
|
||||||
|
if (age <= 64) return "55-64";
|
||||||
|
return "65+";
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeAgeStats(users) {
|
||||||
|
const counts = Object.fromEntries([...AGE_BRACKETS, "unknown"].map((b) => [b, 0]));
|
||||||
|
for (const user of users) {
|
||||||
|
counts[getAgeBracket(user.birthdate)] += 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
const getStats = async (req, res) => {
|
const getStats = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const gameWindow = await getGameWindow();
|
||||||
|
|
||||||
const [
|
const [
|
||||||
ticketsTotal,
|
ticketsTotal,
|
||||||
ticketsUsed,
|
ticketsUsed,
|
||||||
@@ -13,6 +47,7 @@ const getStats = async (req, res) => {
|
|||||||
users,
|
users,
|
||||||
men,
|
men,
|
||||||
women,
|
women,
|
||||||
|
clientsWithBirthdate,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
prisma.ticket.count(),
|
prisma.ticket.count(),
|
||||||
prisma.ticket.count({ where: { isUsed: true } }),
|
prisma.ticket.count({ where: { isUsed: true } }),
|
||||||
@@ -23,6 +58,7 @@ const getStats = async (req, res) => {
|
|||||||
prisma.user.count({ where: { role: "CLIENT" } }),
|
prisma.user.count({ where: { role: "CLIENT" } }),
|
||||||
prisma.user.count({ where: { role: "CLIENT", gender: "Homme" } }),
|
prisma.user.count({ where: { role: "CLIENT", gender: "Homme" } }),
|
||||||
prisma.user.count({ where: { role: "CLIENT", gender: "Femme" } }),
|
prisma.user.count({ where: { role: "CLIENT", gender: "Femme" } }),
|
||||||
|
prisma.user.findMany({ where: { role: "CLIENT" }, select: { birthdate: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const lotsStats = await prisma.lot.findMany({
|
const lotsStats = await prisma.lot.findMany({
|
||||||
@@ -44,10 +80,14 @@ const getStats = async (req, res) => {
|
|||||||
ticketsUsed,
|
ticketsUsed,
|
||||||
ticketsRemaining: ticketsTotal - ticketsUsed,
|
ticketsRemaining: ticketsTotal - ticketsUsed,
|
||||||
participations,
|
participations,
|
||||||
claimsPending,
|
// L'expiration n'est pas écrite en base : une fois la fenêtre de retrait
|
||||||
|
// fermée, les PENDING restants comptent comme annulés.
|
||||||
|
claimsPending: gameWindow.isOpen ? claimsPending : 0,
|
||||||
claimsDelivered,
|
claimsDelivered,
|
||||||
claimsCancelled,
|
claimsCancelled: gameWindow.isOpen ? claimsCancelled : claimsCancelled + claimsPending,
|
||||||
users,
|
users,
|
||||||
|
gameWindow,
|
||||||
|
ageStats: computeAgeStats(clientsWithBirthdate),
|
||||||
genderStats: {
|
genderStats: {
|
||||||
men,
|
men,
|
||||||
women,
|
women,
|
||||||
@@ -177,6 +217,31 @@ const createEmployee = async (req, res) => {
|
|||||||
|
|
||||||
const drawWinner = async (req, res) => {
|
const drawWinner = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// Le grand gagnant est tiré parmi tous les participants : le tirage n'a de
|
||||||
|
// sens qu'une fois la période de participation close. Avant l'ouverture,
|
||||||
|
// il n'existe aucun participant à départager.
|
||||||
|
const gameWindow = await getGameWindow();
|
||||||
|
|
||||||
|
if (!gameWindow.configured) {
|
||||||
|
return res.status(409).json({
|
||||||
|
message: "Le jeu-concours n'a pas encore été lancé : aucun tirage au sort n'est possible.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gameWindow.hasStarted) {
|
||||||
|
return res.status(409).json({
|
||||||
|
message: `Le jeu-concours n'a pas encore commencé. Il ouvre le ${gameWindow.startDate.toLocaleDateString("fr-FR")}.`,
|
||||||
|
startDate: gameWindow.startDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameWindow.isParticipationOpen) {
|
||||||
|
return res.status(409).json({
|
||||||
|
message: `Le tirage au sort ne peut avoir lieu qu'après la fin des participations, le ${gameWindow.participationDeadline.toLocaleDateString("fr-FR")}.`,
|
||||||
|
participationDeadline: gameWindow.participationDeadline,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const existingWinner = await prisma.grandPrizeWinner.findFirst({
|
const existingWinner = await prisma.grandPrizeWinner.findFirst({
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
@@ -197,6 +262,10 @@ const drawWinner = async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Un utilisateur = une entrée dans le pool, quel que soit son nombre de
|
||||||
|
// participations. On sélectionne des CLIENT distincts (pas des
|
||||||
|
// participations), donc avoir utilisé 10 tickets ou 1 seul ne change
|
||||||
|
// strictement rien aux chances de gagner le gros lot.
|
||||||
const participants = await prisma.user.findMany({
|
const participants = await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
role: "CLIENT",
|
role: "CLIENT",
|
||||||
@@ -218,7 +287,11 @@ const drawWinner = async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const randomIndex = Math.floor(Math.random() * participants.length);
|
// Tirage purement aléatoire et équiprobable parmi les participants
|
||||||
|
// distincts. crypto.randomInt (générateur cryptographiquement sûr,
|
||||||
|
// sans biais) plutôt que Math.random(), pour un tirage défendable
|
||||||
|
// devant l'huissier qui le supervise.
|
||||||
|
const randomIndex = crypto.randomInt(participants.length);
|
||||||
const winner = participants[randomIndex];
|
const winner = participants[randomIndex];
|
||||||
|
|
||||||
const savedWinner = await prisma.grandPrizeWinner.create({
|
const savedWinner = await prisma.grandPrizeWinner.create({
|
||||||
@@ -237,9 +310,41 @@ const drawWinner = async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Le tirage est un acte irréversible réalisé sous contrôle d'huissier :
|
||||||
|
// un email qui ne part pas ne doit surtout pas l'invalider. On signale
|
||||||
|
// simplement l'échec pour que l'administrateur prévienne autrement.
|
||||||
|
let emailSent = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.MAIL_FROM,
|
||||||
|
to: savedWinner.user.email,
|
||||||
|
subject: "Vous avez gagné le gros lot Thé Tip Top !",
|
||||||
|
html: `
|
||||||
|
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||||
|
<h2 style="color:#006B6B">Félicitations ${savedWinner.user.firstname} !</h2>
|
||||||
|
<p>Le tirage au sort du jeu-concours « 100% Gagnant » vient d'avoir lieu,
|
||||||
|
et c'est vous qui remportez le gros lot :</p>
|
||||||
|
<div style="font-size:20px;font-weight:bold;color:#006B6B;text-align:center;padding:20px;background:#F0F0F0;border-radius:12px">
|
||||||
|
${savedWinner.prize}
|
||||||
|
</div>
|
||||||
|
<p>Notre équipe vous recontactera très prochainement pour convenir des
|
||||||
|
modalités de remise de votre lot.</p>
|
||||||
|
<p style="color:#888;font-size:13px">Merci de votre participation et à bientôt chez Thé Tip Top.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (mailError) {
|
||||||
|
emailSent = false;
|
||||||
|
console.error("Erreur envoi email au grand gagnant:", mailError);
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
message: "Tirage au sort effectué avec succès.",
|
message: emailSent
|
||||||
|
? "Tirage au sort effectué avec succès. Le gagnant a été prévenu par email."
|
||||||
|
: "Tirage au sort effectué avec succès, mais l'email n'a pas pu être envoyé au gagnant. Merci de le contacter directement.",
|
||||||
totalEligibleParticipants: participants.length,
|
totalEligibleParticipants: participants.length,
|
||||||
|
emailSent,
|
||||||
winner: savedWinner,
|
winner: savedWinner,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -250,6 +355,52 @@ const drawWinner = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Lancement du jeu-concours : fixe (ou corrige) la date de début, dont
|
||||||
|
// découlent les deux échéances. Une seule ligne de configuration existe, on
|
||||||
|
// la met donc à jour si elle est déjà présente.
|
||||||
|
const setGameSettings = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { startDate } = req.body;
|
||||||
|
const parsedStartDate = new Date(startDate);
|
||||||
|
|
||||||
|
const existing = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.gameSettings.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { startDate: parsedStartDate },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.gameSettings.create({ data: { startDate: parsedStartDate } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameWindow = await getGameWindow();
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
message: existing
|
||||||
|
? "Dates du jeu-concours mises à jour."
|
||||||
|
: "Jeu-concours lancé avec succès.",
|
||||||
|
gameWindow,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur configuration du jeu:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
message: "Erreur serveur lors de la configuration du jeu-concours.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGameSettings = async (req, res) => {
|
||||||
|
try {
|
||||||
|
return res.json({ gameWindow: await getGameWindow() });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lecture configuration du jeu:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
message: "Erreur serveur lors de la lecture de la configuration.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getGrandPrizeWinner = async (req, res) => {
|
const getGrandPrizeWinner = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const winner = await prisma.grandPrizeWinner.findFirst({
|
const winner = await prisma.grandPrizeWinner.findFirst({
|
||||||
@@ -265,14 +416,8 @@ const getGrandPrizeWinner = async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!winner) {
|
|
||||||
return res.status(404).json({
|
|
||||||
message: "Aucun gagnant du gros lot n'a encore été tiré.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
winner,
|
winner: winner ?? null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur récupération gagnant:", error);
|
console.error("Erreur récupération gagnant:", error);
|
||||||
@@ -289,4 +434,6 @@ module.exports = {
|
|||||||
createEmployee,
|
createEmployee,
|
||||||
drawWinner,
|
drawWinner,
|
||||||
getGrandPrizeWinner,
|
getGrandPrizeWinner,
|
||||||
|
setGameSettings,
|
||||||
|
getGameSettings,
|
||||||
};
|
};
|
||||||
@@ -34,8 +34,8 @@ const register = async (req, res) => {
|
|||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
const emailVerifyToken = crypto.randomBytes(32).toString("hex");
|
const emailVerifyToken = String(Math.floor(1000 + Math.random() * 9000));
|
||||||
const emailVerifyExpires = new Date(Date.now() + 1000 * 60 * 60); // 1h
|
const emailVerifyExpires = new Date(Date.now() + 1000 * 60 * 15); // 15 min
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -62,23 +62,45 @@ const register = async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const verifyUrl = `${process.env.APP_URL}/api/auth/verify-email/${emailVerifyToken}`;
|
// Le code de validation est le seul moyen d'activer le compte. Si l'envoi
|
||||||
|
// échoue, laisser l'utilisateur en base le bloquerait définitivement : il
|
||||||
await transporter.sendMail({
|
// ne pourrait ni se connecter (email non vérifié) ni se réinscrire (email
|
||||||
from: process.env.MAIL_FROM,
|
// déjà pris). On annule donc la création pour que la tentative reste
|
||||||
to: email,
|
// rejouable.
|
||||||
subject: "Validation de votre compte Thé Tip Top",
|
try {
|
||||||
html: `
|
await transporter.sendMail({
|
||||||
<h2>Bienvenue ${firstname}</h2>
|
from: process.env.MAIL_FROM,
|
||||||
<p>Merci pour votre inscription.</p>
|
to: email,
|
||||||
<p>Veuillez cliquer sur le lien ci-dessous pour valider votre compte :</p>
|
subject: "Votre code de validation Thé Tip Top",
|
||||||
<a href="${verifyUrl}">Valider mon compte</a>
|
html: `
|
||||||
<p>Ce lien expire dans 1 heure.</p>
|
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||||
|
<h2 style="color:#006B6B">Bienvenue ${firstname} !</h2>
|
||||||
|
<p>Merci pour votre inscription sur Thé Tip Top.</p>
|
||||||
|
<p>Voici votre code de validation à 4 chiffres :</p>
|
||||||
|
<div style="font-size:36px;font-weight:bold;letter-spacing:12px;color:#006B6B;text-align:center;padding:20px 0">
|
||||||
|
${emailVerifyToken}
|
||||||
|
</div>
|
||||||
|
<p style="color:#888;font-size:13px">Ce code expire dans <strong>15 minutes</strong>.</p>
|
||||||
|
</div>
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
} catch (mailError) {
|
||||||
|
console.error("Erreur envoi du code de validation:", mailError);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.user.delete({ where: { id: user.id } });
|
||||||
|
} catch (rollbackError) {
|
||||||
|
console.error("Echec de l'annulation de l'inscription:", rollbackError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(502).json({
|
||||||
|
message:
|
||||||
|
"Votre compte n'a pas pu être créé : l'email de validation n'a pas pu être envoyé. Merci de réessayer dans quelques instants.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
message: "Inscription réussie. Un email de validation a été envoyé.",
|
message: "Inscription réussie. Un code de validation a été envoyé par email.",
|
||||||
user,
|
user,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -148,12 +170,20 @@ const login = async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.isEmailVerified) {
|
if (!user.isEmailVerified && user.role === "CLIENT") {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
message: "Veuillez valider votre adresse email avant de vous connecter.",
|
message: "Veuillez valider votre adresse email avant de vous connecter.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compte créé via un provider externe : aucun mot de passe à comparer.
|
||||||
|
if (!user.password) {
|
||||||
|
const providerLabel = user.provider === "FACEBOOK" ? "Facebook" : "Google";
|
||||||
|
return res.status(401).json({
|
||||||
|
message: `Ce compte utilise la connexion ${providerLabel}. Utilisez le bouton « ${providerLabel} » pour vous connecter.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||||
|
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
@@ -222,7 +252,10 @@ const forgotPassword = async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetUrl = `${process.env.APP_URL}/api/auth/reset-password/${resetPasswordToken}`;
|
// Le lien doit ouvrir le formulaire du front, pas la route POST de l'API :
|
||||||
|
// un clic depuis un email est une requête GET, qui tombait en 404.
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
||||||
|
const resetUrl = `${frontendUrl}/definir-mot-de-passe/${resetPasswordToken}`;
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: process.env.MAIL_FROM,
|
from: process.env.MAIL_FROM,
|
||||||
@@ -253,7 +286,7 @@ const forgotPassword = async (req, res) => {
|
|||||||
const resetPassword = async (req, res) => {
|
const resetPassword = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { token } = req.params;
|
const { token } = req.params;
|
||||||
const { password } = req.body;
|
const { password, confirmPassword } = req.body;
|
||||||
|
|
||||||
if (!password || password.length < 8) {
|
if (!password || password.length < 8) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -261,6 +294,14 @@ const resetPassword = async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// La confirmation est vérifiée côté serveur aussi : le contrôle du
|
||||||
|
// navigateur ne protège pas un appel direct à l'API.
|
||||||
|
if (confirmPassword !== undefined && password !== confirmPassword) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: "Les deux mots de passe ne correspondent pas.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
resetPasswordToken: token,
|
resetPasswordToken: token,
|
||||||
@@ -298,17 +339,199 @@ const resetPassword = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const verifyCode = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, code } = req.body;
|
||||||
|
|
||||||
|
if (!email || !code) {
|
||||||
|
return res.status(400).json({ message: "Email et code requis." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { email } });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ message: "Compte introuvable." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.isEmailVerified) {
|
||||||
|
return res.status(400).json({ message: "Ce compte est déjà vérifié." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.emailVerifyToken || user.emailVerifyToken !== String(code)) {
|
||||||
|
return res.status(400).json({ message: "Code invalide." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new Date() > user.emailVerifyExpires) {
|
||||||
|
return res.status(400).json({ message: "Code expiré. Veuillez vous réinscrire." });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { isEmailVerified: true, emailVerifyToken: null, emailVerifyExpires: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ id: user.id, role: user.role },
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: "24h" }
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
message: "Compte vérifié avec succès.",
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
firstname: user.firstname,
|
||||||
|
lastname: user.lastname,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur verifyCode:", error);
|
||||||
|
return res.status(500).json({ message: "Erreur serveur lors de la vérification." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fin du parcours OAuth : passport a posé l'utilisateur sur req.user, on émet
|
||||||
|
// le même JWT que la connexion classique et on renvoie le navigateur vers le
|
||||||
|
// front, qui récupère le token dans l'URL.
|
||||||
|
const oauthCallback = (req, res) => {
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.redirect(
|
||||||
|
`${frontendUrl}/auth/callback?error=${encodeURIComponent(
|
||||||
|
"Connexion externe refusée."
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ id: user.id, role: user.role },
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: "24h" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
id: user.id,
|
||||||
|
firstname: user.firstname,
|
||||||
|
lastname: user.lastname,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
})
|
||||||
|
).toString("base64");
|
||||||
|
|
||||||
|
return res.redirect(`${frontendUrl}/auth/callback?token=${token}&user=${payload}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur oauthCallback:", error);
|
||||||
|
return res.redirect(
|
||||||
|
`${frontendUrl}/auth/callback?error=${encodeURIComponent(
|
||||||
|
"Erreur serveur lors de la connexion externe."
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const me = async (req, res) => {
|
const me = async (req, res) => {
|
||||||
return res.json({
|
return res.json({
|
||||||
user: req.user,
|
user: req.user,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteAccount = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// 1. Supprimer le grand gagnant si c'est lui
|
||||||
|
await tx.grandPrizeWinner.deleteMany({ where: { userId } });
|
||||||
|
|
||||||
|
// 2. Récupérer les participations pour supprimer leurs claims
|
||||||
|
const participations = await tx.participation.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const participationIds = participations.map((p) => p.id);
|
||||||
|
|
||||||
|
// 3. Supprimer les claims
|
||||||
|
if (participationIds.length > 0) {
|
||||||
|
await tx.claim.deleteMany({ where: { participationId: { in: participationIds } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Supprimer les participations
|
||||||
|
await tx.participation.deleteMany({ where: { userId } });
|
||||||
|
|
||||||
|
// 5. Détacher les tickets du compte sans les remettre en jeu : un code
|
||||||
|
// figure sur un ticket de caisse physique et n'est jouable qu'une
|
||||||
|
// fois. Le réinitialiser permettrait de rejouer indéfiniment le même
|
||||||
|
// code en supprimant puis recréant un compte.
|
||||||
|
await tx.ticket.updateMany({
|
||||||
|
where: { userId },
|
||||||
|
data: { userId: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Supprimer l'utilisateur
|
||||||
|
await tx.user.delete({ where: { id: userId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ message: "Compte supprimé avec succès." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur deleteAccount:", error);
|
||||||
|
return res.status(500).json({ message: "Erreur serveur lors de la suppression du compte." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePassword = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { currentPassword, newPassword } = req.body;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
return res.status(400).json({ message: "Les deux mots de passe sont requis." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
return res.status(400).json({ message: "Le nouveau mot de passe doit faire au moins 8 caractères." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
|
||||||
|
if (!user.password) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message:
|
||||||
|
"Ce compte utilise une connexion externe et n'a pas de mot de passe à modifier.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await bcrypt.compare(currentPassword, user.password);
|
||||||
|
if (!isValid) {
|
||||||
|
return res.status(401).json({ message: "Mot de passe actuel incorrect." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashed = await bcrypt.hash(newPassword, 10);
|
||||||
|
await prisma.user.update({ where: { id: userId }, data: { password: hashed } });
|
||||||
|
|
||||||
|
return res.json({ message: "Mot de passe modifié avec succès." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur changePassword:", error);
|
||||||
|
return res.status(500).json({ message: "Erreur serveur lors du changement de mot de passe." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
register,
|
register,
|
||||||
login,
|
login,
|
||||||
me,
|
me,
|
||||||
verifyEmail,
|
verifyEmail,
|
||||||
|
verifyCode,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
forgotPassword
|
forgotPassword,
|
||||||
|
changePassword,
|
||||||
|
deleteAccount,
|
||||||
|
oauthCallback,
|
||||||
};
|
};
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
const prisma = require("../config/prisma");
|
const prisma = require("../config/prisma");
|
||||||
|
const { getGameWindow, effectiveClaimStatus } = require("../utils/gameWindow");
|
||||||
|
|
||||||
const getGainByTicketCode = async (req, res) => {
|
const getGainByTicketCode = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { code } = req.params;
|
const { code } = req.params;
|
||||||
|
|
||||||
|
const gameWindow = await getGameWindow();
|
||||||
|
|
||||||
const ticket = await prisma.ticket.findUnique({
|
const ticket = await prisma.ticket.findUnique({
|
||||||
where: { code: code.toUpperCase() },
|
where: { code: code.toUpperCase() },
|
||||||
include: {
|
include: {
|
||||||
@@ -49,7 +52,7 @@ const getGainByTicketCode = async (req, res) => {
|
|||||||
},
|
},
|
||||||
claim: {
|
claim: {
|
||||||
id: ticket.participation.claim.id,
|
id: ticket.participation.claim.id,
|
||||||
status: ticket.participation.claim.status,
|
status: effectiveClaimStatus(ticket.participation.claim.status, gameWindow),
|
||||||
claimedAt: ticket.participation.claim.claimedAt,
|
claimedAt: ticket.participation.claim.claimedAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -66,6 +69,8 @@ const deliverClaim = async (req, res) => {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const employeeId = req.user.id;
|
const employeeId = req.user.id;
|
||||||
|
|
||||||
|
const gameWindow = await getGameWindow();
|
||||||
|
|
||||||
const claim = await prisma.claim.findUnique({
|
const claim = await prisma.claim.findUnique({
|
||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
});
|
});
|
||||||
@@ -82,7 +87,7 @@ const deliverClaim = async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (claim.status === "CANCELLED") {
|
if (effectiveClaimStatus(claim.status, gameWindow) === "CANCELLED") {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
message: "Ce gain est expiré et ne peut plus être remis.",
|
message: "Ce gain est expiré et ne peut plus être remis.",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
const prisma = require("../config/prisma");
|
const prisma = require("../config/prisma");
|
||||||
|
const transporter = require("../config/mailer");
|
||||||
|
const {
|
||||||
|
getGameWindow,
|
||||||
|
effectiveClaimStatus,
|
||||||
|
participationClosedMessage,
|
||||||
|
} = require("../utils/gameWindow");
|
||||||
|
|
||||||
|
// Levée par la transaction quand le ticket a été consommé entre-temps.
|
||||||
|
class TicketDejaUtiliseError extends Error {}
|
||||||
|
|
||||||
const participate = async (req, res) => {
|
const participate = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { code } = req.body;
|
const { code } = req.body;
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
const gameWindow = await getGameWindow();
|
||||||
|
|
||||||
|
// Un code n'est jouable que pendant les 30 jours de jeu : ni avant
|
||||||
|
// l'ouverture, ni pendant la prolongation réservée aux retraits.
|
||||||
|
if (!gameWindow.isParticipationOpen) {
|
||||||
|
return res.status(410).json({
|
||||||
|
message: participationClosedMessage(gameWindow),
|
||||||
|
phase: gameWindow.phase,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const ticket = await prisma.ticket.findUnique({
|
const ticket = await prisma.ticket.findUnique({
|
||||||
where: { code },
|
where: { code },
|
||||||
include: {
|
include: {
|
||||||
@@ -25,16 +45,24 @@ const participate = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
const updatedTicket = await tx.ticket.update({
|
// Le contrôle isUsed ci-dessus ne suffit pas : deux requêtes simultanées
|
||||||
where: { id: ticket.id },
|
// peuvent le franchir ensemble. On repasse donc par un update
|
||||||
|
// conditionnel, atomique, qui ne touche le ticket que s'il est encore
|
||||||
|
// libre — le perdant de la course obtient 0 ligne modifiée.
|
||||||
|
const { count } = await tx.ticket.updateMany({
|
||||||
|
where: { id: ticket.id, isUsed: false },
|
||||||
data: {
|
data: {
|
||||||
isUsed: true,
|
isUsed: true,
|
||||||
usedAt: new Date(),
|
usedAt: new Date(),
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
include: {
|
});
|
||||||
lot: true,
|
|
||||||
},
|
if (count === 0) throw new TicketDejaUtiliseError();
|
||||||
|
|
||||||
|
const updatedTicket = await tx.ticket.findUnique({
|
||||||
|
where: { id: ticket.id },
|
||||||
|
include: { lot: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const participation = await tx.participation.create({
|
const participation = await tx.participation.create({
|
||||||
@@ -59,8 +87,41 @@ const participate = async (req, res) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// La participation est déjà enregistrée et consultable dans « Mes gains » :
|
||||||
|
// un email qui ne part pas ne doit pas l'annuler ni consommer le ticket
|
||||||
|
// pour rien. On se contente de signaler l'échec.
|
||||||
|
let emailSent = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.MAIL_FROM,
|
||||||
|
to: req.user.email,
|
||||||
|
subject: `Vous avez gagné : ${result.ticket.lot.name} !`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||||
|
<h2 style="color:#006B6B">Bravo ${req.user.firstname} !</h2>
|
||||||
|
<p>Votre code <strong>${result.ticket.code}</strong> est gagnant. Vous remportez :</p>
|
||||||
|
<div style="font-size:20px;font-weight:bold;color:#006B6B;text-align:center;padding:20px;background:#F0F0F0;border-radius:12px">
|
||||||
|
${result.ticket.lot.name}
|
||||||
|
</div>
|
||||||
|
${result.ticket.lot.description ? `<p style="text-align:center;color:#666">${result.ticket.lot.description}</p>` : ""}
|
||||||
|
<p>Présentez-vous en boutique avec ce code pour retirer votre lot${
|
||||||
|
gameWindow.claimDeadline
|
||||||
|
? `, avant le <strong>${gameWindow.claimDeadline.toLocaleDateString("fr-FR")}</strong>`
|
||||||
|
: ""
|
||||||
|
}.</p>
|
||||||
|
<p style="color:#888;font-size:13px">Retrouvez à tout moment vos gains dans votre espace « Mes gains ».</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (mailError) {
|
||||||
|
emailSent = false;
|
||||||
|
console.error("Erreur envoi email de gain:", mailError);
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
message: "Participation enregistrée avec succès.",
|
message: "Participation enregistrée avec succès.",
|
||||||
|
emailSent,
|
||||||
gain: {
|
gain: {
|
||||||
name: result.ticket.lot.name,
|
name: result.ticket.lot.name,
|
||||||
description: result.ticket.lot.description,
|
description: result.ticket.lot.description,
|
||||||
@@ -72,6 +133,12 @@ const participate = async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof TicketDejaUtiliseError) {
|
||||||
|
return res.status(409).json({
|
||||||
|
message: "Ce code a déjà été utilisé.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.error("Erreur participation:", error);
|
console.error("Erreur participation:", error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
message: "Erreur serveur lors de la participation.",
|
message: "Erreur serveur lors de la participation.",
|
||||||
@@ -83,6 +150,8 @@ const myGains = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
const gameWindow = await getGameWindow();
|
||||||
|
|
||||||
const participations = await prisma.participation.findMany({
|
const participations = await prisma.participation.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
include: {
|
include: {
|
||||||
@@ -102,7 +171,9 @@ const myGains = async (req, res) => {
|
|||||||
lot: participation.lot.name,
|
lot: participation.lot.name,
|
||||||
description: participation.lot.description,
|
description: participation.lot.description,
|
||||||
value: participation.lot.value,
|
value: participation.lot.value,
|
||||||
claimStatus: participation.claim?.status,
|
claimStatus: participation.claim
|
||||||
|
? effectiveClaimStatus(participation.claim.status, gameWindow)
|
||||||
|
: undefined,
|
||||||
wonAt: participation.createdAt,
|
wonAt: participation.createdAt,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const options = {
|
|||||||
},
|
},
|
||||||
servers: [
|
servers: [
|
||||||
{
|
{
|
||||||
url: "http://localhost:5000",
|
url: process.env.API_PUBLIC_URL || "http://localhost:5000",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
components: {
|
components: {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ const authMiddleware = async (req, res, next) => {
|
|||||||
lastname: true,
|
lastname: true,
|
||||||
email: true,
|
email: true,
|
||||||
role: true,
|
role: true,
|
||||||
|
provider: true,
|
||||||
|
password: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,7 +34,11 @@ const authMiddleware = async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
req.user = user;
|
// req.user est renvoyé tel quel par /api/auth/me : on remplace le hash par
|
||||||
|
// un simple booléen, qui suffit au profil pour proposer « définir » ou
|
||||||
|
// « modifier » son mot de passe.
|
||||||
|
const { password, ...rest } = user;
|
||||||
|
req.user = { ...rest, hasPassword: Boolean(password) };
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ const {
|
|||||||
createEmployee,
|
createEmployee,
|
||||||
drawWinner,
|
drawWinner,
|
||||||
getGrandPrizeWinner,
|
getGrandPrizeWinner,
|
||||||
|
setGameSettings,
|
||||||
|
getGameSettings,
|
||||||
} = require("../controllers/admin.controller");
|
} = require("../controllers/admin.controller");
|
||||||
|
|
||||||
const authMiddleware = require("../middlewares/auth.middleware");
|
const authMiddleware = require("../middlewares/auth.middleware");
|
||||||
const roleMiddleware = require("../middlewares/role.middleware");
|
const roleMiddleware = require("../middlewares/role.middleware");
|
||||||
const validate = require("../middlewares/validate.middleware");
|
const validate = require("../middlewares/validate.middleware");
|
||||||
const { createEmployeeSchema } = require("../validations/admin.validation");
|
const { createEmployeeSchema, gameSettingsSchema } = require("../validations/admin.validation");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -105,10 +107,48 @@ router.post("/draw", drawWinner);
|
|||||||
* - bearerAuth: []
|
* - bearerAuth: []
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Gagnant du gros lot
|
* description: Gagnant du gros lot, ou { winner - null } si aucun tirage n'a encore eu lieu
|
||||||
* 404:
|
|
||||||
* description: Aucun gagnant encore tiré
|
|
||||||
*/
|
*/
|
||||||
router.get("/draw", getGrandPrizeWinner);
|
router.get("/draw", getGrandPrizeWinner);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/admin/game-settings:
|
||||||
|
* get:
|
||||||
|
* summary: Fenêtre du jeu-concours (début, fin des participations, fin des retraits)
|
||||||
|
* tags:
|
||||||
|
* - Admin
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Configuration courante du jeu-concours
|
||||||
|
* put:
|
||||||
|
* summary: Lance le jeu-concours ou corrige sa date de début
|
||||||
|
* tags:
|
||||||
|
* - Admin
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - startDate
|
||||||
|
* properties:
|
||||||
|
* startDate:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* example: 2026-09-01T00:00:00.000Z
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Jeu-concours lancé ou mis à jour
|
||||||
|
* 400:
|
||||||
|
* description: Date de début invalide
|
||||||
|
*/
|
||||||
|
router.get("/game-settings", getGameSettings);
|
||||||
|
router.put("/game-settings", validate(gameSettingsSchema), setGameSettings);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const { register, login, verifyEmail, forgotPassword, resetPassword, me } = require("../controllers/auth.controller");
|
const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller");
|
||||||
const authMiddleware = require("../middlewares/auth.middleware");
|
const authMiddleware = require("../middlewares/auth.middleware");
|
||||||
const validate = require("../middlewares/validate.middleware");
|
const validate = require("../middlewares/validate.middleware");
|
||||||
const { registerSchema, loginSchema } = require("../validations/auth.validation");
|
const { registerSchema, loginSchema } = require("../validations/auth.validation");
|
||||||
|
const { passport, googleEnabled } = require("../config/passport");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -155,7 +156,61 @@ router.post("/forgot-password", forgotPassword);
|
|||||||
* 500:
|
* 500:
|
||||||
* description: Erreur serveur.
|
* description: Erreur serveur.
|
||||||
*/
|
*/
|
||||||
router.post("/reset-password/:token",resetPassword);
|
router.post("/reset-password/:token", resetPassword);
|
||||||
|
|
||||||
|
router.post("/verify-code", verifyCode);
|
||||||
|
router.patch("/change-password", authMiddleware, changePassword);
|
||||||
|
router.delete("/account", authMiddleware, deleteAccount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/auth/google:
|
||||||
|
* get:
|
||||||
|
* summary: Démarre la connexion via Google (redirection OAuth 2.0)
|
||||||
|
* tags:
|
||||||
|
* - Auth
|
||||||
|
* responses:
|
||||||
|
* 302:
|
||||||
|
* description: Redirection vers l'écran de consentement Google.
|
||||||
|
* 503:
|
||||||
|
* description: Connexion Google non configurée sur ce serveur.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/auth/google/callback:
|
||||||
|
* get:
|
||||||
|
* summary: Retour de Google, émet le JWT et redirige vers le front
|
||||||
|
* tags:
|
||||||
|
* - Auth
|
||||||
|
* responses:
|
||||||
|
* 302:
|
||||||
|
* description: Redirection vers FRONTEND_URL/auth/callback avec le token.
|
||||||
|
*/
|
||||||
|
if (googleEnabled) {
|
||||||
|
router.get(
|
||||||
|
"/google",
|
||||||
|
passport.authenticate("google", { scope: ["profile", "email"], session: false })
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/google/callback",
|
||||||
|
passport.authenticate("google", {
|
||||||
|
session: false,
|
||||||
|
failureRedirect: `${process.env.FRONTEND_URL || "http://localhost:5173"}/auth/callback?error=${encodeURIComponent(
|
||||||
|
"Connexion Google refusée."
|
||||||
|
)}`,
|
||||||
|
}),
|
||||||
|
oauthCallback
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Sans identifiants configurés, on répond explicitement plutôt que de
|
||||||
|
// laisser passport planter au démarrage.
|
||||||
|
const notConfigured = (req, res) =>
|
||||||
|
res.status(503).json({ message: "La connexion Google n'est pas configurée sur ce serveur." });
|
||||||
|
|
||||||
|
router.get("/google", notConfigured);
|
||||||
|
router.get("/google/callback", notConfigured);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const { participate, myGains } = require("../controllers/game.controller");
|
const { participate, myGains } = require("../controllers/game.controller");
|
||||||
const authMiddleware = require("../middlewares/auth.middleware");
|
const authMiddleware = require("../middlewares/auth.middleware");
|
||||||
|
const roleMiddleware = require("../middlewares/role.middleware");
|
||||||
const validate = require("../middlewares/validate.middleware");
|
const validate = require("../middlewares/validate.middleware");
|
||||||
const { participateSchema } = require("../validations/game.validation");
|
const { participateSchema } = require("../validations/game.validation");
|
||||||
|
|
||||||
@@ -20,7 +21,9 @@ const router = express.Router();
|
|||||||
* description: Participation enregistrée
|
* description: Participation enregistrée
|
||||||
*/
|
*/
|
||||||
|
|
||||||
router.post("/participate", authMiddleware, validate(participateSchema), participate);
|
// Le jeu est reserve aux clients : un ADMIN ou un EMPLOYEE qui joue
|
||||||
|
// consommerait des tickets et fausserait les statistiques.
|
||||||
|
router.post("/participate", authMiddleware, roleMiddleware("CLIENT"), validate(participateSchema), participate);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@@ -36,6 +39,6 @@ router.post("/participate", authMiddleware, validate(participateSchema), partici
|
|||||||
* description: Liste des gains
|
* description: Liste des gains
|
||||||
*/
|
*/
|
||||||
|
|
||||||
router.get("/my-gains", authMiddleware, myGains);
|
router.get("/my-gains", authMiddleware, roleMiddleware("CLIENT"), myGains);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
39
backend/src/scripts/createAdmin.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
const bcrypt = require('bcrypt')
|
||||||
|
const prisma = require('../config/prisma')
|
||||||
|
|
||||||
|
async function createAdmin() {
|
||||||
|
const email = 'admin@thetiptop.fr'
|
||||||
|
const password = 'Admin@TipTop2026!'
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email } })
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { email },
|
||||||
|
data: { isEmailVerified: true, role: 'ADMIN' },
|
||||||
|
})
|
||||||
|
console.log('Admin mis à jour :', email)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashed = await bcrypt.hash(password, 10)
|
||||||
|
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
firstname: 'Admin',
|
||||||
|
lastname: 'TipTop',
|
||||||
|
email,
|
||||||
|
password: hashed,
|
||||||
|
role: 'ADMIN',
|
||||||
|
isEmailVerified: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Admin créé avec succès')
|
||||||
|
console.log(' Email :', email)
|
||||||
|
console.log(' Mot de passe:', password)
|
||||||
|
}
|
||||||
|
|
||||||
|
createAdmin()
|
||||||
|
.catch((e) => { console.error('Erreur :', e.message); process.exit(1) })
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
100
backend/src/utils/gameWindow.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
const prisma = require("../config/prisma");
|
||||||
|
|
||||||
|
// Le concours traverse cinq états, et non deux : confondre « pas encore
|
||||||
|
// ouvert » avec « terminé » produit des messages faux côté joueur et laisse
|
||||||
|
// passer le tirage au sort avant le début du jeu.
|
||||||
|
const PHASES = {
|
||||||
|
NOT_CONFIGURED: "NOT_CONFIGURED",
|
||||||
|
SCHEDULED: "SCHEDULED",
|
||||||
|
OPEN: "OPEN",
|
||||||
|
CLAIM_ONLY: "CLAIM_ONLY",
|
||||||
|
CLOSED: "CLOSED",
|
||||||
|
};
|
||||||
|
|
||||||
|
const GAME_DURATION_DAYS = 30;
|
||||||
|
const CLAIM_EXTENSION_DAYS = 30;
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Deux échéances distinctes à partir de startDate :
|
||||||
|
// - participationDeadline (30 j) : dernier moment pour tester un code ;
|
||||||
|
// - claimDeadline (60 j) : dernier moment pour retirer un lot déjà gagné.
|
||||||
|
// Les 30 jours supplémentaires servent uniquement au retrait, pas au jeu.
|
||||||
|
// Tant qu'aucune GameSettings n'existe, le jeu est considéré comme non
|
||||||
|
// configuré et donc ouvert (comportement de dev par défaut).
|
||||||
|
async function getGameWindow(now = new Date()) {
|
||||||
|
const settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||||
|
|
||||||
|
// Tant que l'administrateur n'a pas fixé de date, le concours n'existe pas :
|
||||||
|
// rien n'est jouable et aucun tirage n'est possible. Ouvrir par défaut
|
||||||
|
// reviendrait à faire gagner des lots dans un jeu qui n'a jamais été lancé.
|
||||||
|
if (!settings) {
|
||||||
|
return {
|
||||||
|
configured: false,
|
||||||
|
phase: PHASES.NOT_CONFIGURED,
|
||||||
|
startDate: null,
|
||||||
|
participationDeadline: null,
|
||||||
|
claimDeadline: null,
|
||||||
|
isParticipationOpen: false,
|
||||||
|
isOpen: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const participationDeadline = new Date(settings.startDate.getTime() + GAME_DURATION_DAYS * DAY_MS);
|
||||||
|
const claimDeadline = new Date(
|
||||||
|
settings.startDate.getTime() + (GAME_DURATION_DAYS + CLAIM_EXTENSION_DAYS) * DAY_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasStarted = now >= settings.startDate;
|
||||||
|
const isParticipationOpen = hasStarted && now <= participationDeadline;
|
||||||
|
const isOpen = hasStarted && now <= claimDeadline;
|
||||||
|
|
||||||
|
let phase;
|
||||||
|
if (!hasStarted) phase = PHASES.SCHEDULED;
|
||||||
|
else if (isParticipationOpen) phase = PHASES.OPEN;
|
||||||
|
else if (isOpen) phase = PHASES.CLAIM_ONLY;
|
||||||
|
else phase = PHASES.CLOSED;
|
||||||
|
|
||||||
|
return {
|
||||||
|
configured: true,
|
||||||
|
phase,
|
||||||
|
startDate: settings.startDate,
|
||||||
|
participationDeadline,
|
||||||
|
claimDeadline,
|
||||||
|
hasStarted,
|
||||||
|
isParticipationOpen,
|
||||||
|
isOpen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'expiration d'un gain est *dérivée* de la fenêtre de jeu, jamais écrite en
|
||||||
|
// base : un PENDING dont le délai de retrait est passé est présenté comme
|
||||||
|
// CANCELLED, mais la ligne reste intacte. Corriger une startDate erronée
|
||||||
|
// suffit donc à rétablir les gains, et aucune lecture ne modifie la base.
|
||||||
|
function effectiveClaimStatus(status, gameWindow) {
|
||||||
|
if (status === "PENDING" && !gameWindow.isOpen) return "CANCELLED";
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message destiné au joueur qui tente de jouer hors de la fenêtre : chaque
|
||||||
|
// phase a sa propre explication, avec la date utile quand elle existe.
|
||||||
|
function participationClosedMessage(gameWindow) {
|
||||||
|
switch (gameWindow.phase) {
|
||||||
|
case PHASES.NOT_CONFIGURED:
|
||||||
|
return "Le jeu-concours n'est pas encore ouvert. Revenez prochainement.";
|
||||||
|
case PHASES.SCHEDULED:
|
||||||
|
return `Le jeu-concours ouvrira le ${gameWindow.startDate.toLocaleDateString("fr-FR")}.`;
|
||||||
|
case PHASES.CLAIM_ONLY:
|
||||||
|
return "La période de participation est terminée. Vous pouvez encore retirer les lots déjà gagnés.";
|
||||||
|
default:
|
||||||
|
return "Le jeu-concours est terminé. Vous ne pouvez plus tester de code ni réclamer de lot.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getGameWindow,
|
||||||
|
effectiveClaimStatus,
|
||||||
|
participationClosedMessage,
|
||||||
|
PHASES,
|
||||||
|
GAME_DURATION_DAYS,
|
||||||
|
CLAIM_EXTENSION_DAYS,
|
||||||
|
};
|
||||||
@@ -9,6 +9,13 @@ const createEmployeeSchema = z.object({
|
|||||||
gender: z.string().optional(),
|
gender: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const gameSettingsSchema = z.object({
|
||||||
|
startDate: z
|
||||||
|
.string()
|
||||||
|
.refine((value) => !Number.isNaN(new Date(value).getTime()), "Date de début invalide"),
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createEmployeeSchema,
|
createEmployeeSchema,
|
||||||
|
gameSettingsSchema,
|
||||||
};
|
};
|
||||||
87
backend/tests/draw.test.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
const bcrypt = require("bcrypt");
|
||||||
|
const prisma = require("../src/config/prisma");
|
||||||
|
|
||||||
|
// ------------------------
|
||||||
|
// Vérifie que le pool de participants au tirage du gros lot ne compte
|
||||||
|
// chaque client qu'une seule fois, quel que soit son nombre de
|
||||||
|
// participations. On rejoue ici exactement la requête utilisée par
|
||||||
|
// admin.controller.js::drawWinner, restreinte à des utilisateurs dédiés
|
||||||
|
// pour ne pas dépendre des autres données déjà présentes en base
|
||||||
|
// (et surtout ne jamais toucher aux vrais tickets du jeu-concours).
|
||||||
|
// ------------------------
|
||||||
|
|
||||||
|
let userManyParticipations;
|
||||||
|
let userOneParticipation;
|
||||||
|
let ticketIds = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const lot = await prisma.lot.findFirst();
|
||||||
|
if (!lot) throw new Error("Aucun lot en base : lancez `npx prisma db seed` avant ce test.");
|
||||||
|
|
||||||
|
const hashed = await bcrypt.hash("Password123", 10);
|
||||||
|
|
||||||
|
userManyParticipations = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
firstname: "Draw",
|
||||||
|
lastname: "MultiParticip",
|
||||||
|
email: "draw-test-multi@test.com",
|
||||||
|
password: hashed,
|
||||||
|
role: "CLIENT",
|
||||||
|
isEmailVerified: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
userOneParticipation = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
firstname: "Draw",
|
||||||
|
lastname: "SingleParticip",
|
||||||
|
email: "draw-test-single@test.com",
|
||||||
|
password: hashed,
|
||||||
|
role: "CLIENT",
|
||||||
|
isEmailVerified: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const codes = ["ZZDRAWTEST1", "ZZDRAWTEST2", "ZZDRAWTEST3", "ZZDRAWTEST4"];
|
||||||
|
const owners = [
|
||||||
|
userManyParticipations,
|
||||||
|
userManyParticipations,
|
||||||
|
userManyParticipations,
|
||||||
|
userOneParticipation,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < codes.length; i++) {
|
||||||
|
const ticket = await prisma.ticket.create({
|
||||||
|
data: { code: codes[i], lotId: lot.id, isUsed: true, usedAt: new Date(), userId: owners[i].id },
|
||||||
|
});
|
||||||
|
ticketIds.push(ticket.id);
|
||||||
|
await prisma.participation.create({
|
||||||
|
data: { userId: owners[i].id, ticketId: ticket.id, lotId: lot.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.participation.deleteMany({ where: { ticketId: { in: ticketIds } } });
|
||||||
|
await prisma.ticket.deleteMany({ where: { id: { in: ticketIds } } });
|
||||||
|
await prisma.user.deleteMany({
|
||||||
|
where: { id: { in: [userManyParticipations.id, userOneParticipation.id] } },
|
||||||
|
});
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un client avec plusieurs participations ne compte qu'une fois dans le pool du tirage", async () => {
|
||||||
|
const eligible = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
role: "CLIENT",
|
||||||
|
id: { in: [userManyParticipations.id, userOneParticipation.id] },
|
||||||
|
participations: { some: {} },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(eligible).toHaveLength(2);
|
||||||
|
expect(eligible.map((u) => u.id).sort()).toEqual(
|
||||||
|
[userManyParticipations.id, userOneParticipation.id].sort()
|
||||||
|
);
|
||||||
|
});
|
||||||
106
backend/tests/drawNotification.test.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
const request = require("supertest");
|
||||||
|
const app = require("../src/app");
|
||||||
|
const prisma = require("../src/config/prisma");
|
||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const bcrypt = require("bcrypt");
|
||||||
|
const mailer = require("../src/config/mailer");
|
||||||
|
|
||||||
|
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
|
||||||
|
|
||||||
|
const DAY = 86400000;
|
||||||
|
const PREFIX = "notif-";
|
||||||
|
const CODE = "NOTIFTICK1";
|
||||||
|
let ctx = {};
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } });
|
||||||
|
const ids = us.map((u) => u.id);
|
||||||
|
const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } });
|
||||||
|
await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } });
|
||||||
|
await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
|
||||||
|
await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } });
|
||||||
|
await prisma.ticket.deleteMany({ where: { code: CODE } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||||
|
const jourJ = (d) =>
|
||||||
|
prisma.gameSettings.update({
|
||||||
|
where: { id: ctx.settingsId },
|
||||||
|
data: { startDate: new Date(Date.now() - d * DAY) },
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||||
|
ctx.settingsId = row.id;
|
||||||
|
ctx.startDateOriginale = row.startDate;
|
||||||
|
|
||||||
|
const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
|
||||||
|
await prisma.ticket.create({ data: { code: CODE, lotId: lot.id } });
|
||||||
|
|
||||||
|
const h = await bcrypt.hash("Password123", 10);
|
||||||
|
ctx.admin = await prisma.user.create({ data: { firstname: "Alice", lastname: "Admin", email: PREFIX + "admin@test.local", password: h, role: "ADMIN", isEmailVerified: true } });
|
||||||
|
ctx.client = await prisma.user.create({ data: { firstname: "Chloe", lastname: "Martin", email: PREFIX + "chloe@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
|
||||||
|
ctx.tAdmin = tk(ctx.admin);
|
||||||
|
|
||||||
|
await jourJ(1);
|
||||||
|
mailer.sendMail.mockResolvedValue(true);
|
||||||
|
await request(app).post("/api/game/participate").set("Authorization", "Bearer " + tk(ctx.client)).send({ code: CODE }).expect(201);
|
||||||
|
await jourJ(35);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } });
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Notification du grand gagnant", () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
test("le gagnant recoit un email a son adresse", async () => {
|
||||||
|
mailer.sendMail.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin).expect(201);
|
||||||
|
|
||||||
|
expect(res.body.emailSent).toBe(true);
|
||||||
|
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const envoi = mailer.sendMail.mock.calls[0][0];
|
||||||
|
expect(envoi.to).toBe(ctx.client.email);
|
||||||
|
expect(envoi.subject).toMatch(/gros lot/i);
|
||||||
|
expect(envoi.html).toContain("Chloe");
|
||||||
|
expect(envoi.html).toContain("360");
|
||||||
|
|
||||||
|
await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un SMTP en panne n invalide pas le tirage", async () => {
|
||||||
|
mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible"));
|
||||||
|
|
||||||
|
const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.emailSent).toBe(false);
|
||||||
|
expect(res.body.message).toMatch(/contacter directement/);
|
||||||
|
|
||||||
|
// Le tirage reste enregistre : c est un acte irreversible.
|
||||||
|
const enBase = await prisma.grandPrizeWinner.findFirst({ where: { userId: ctx.client.id } });
|
||||||
|
expect(enBase).not.toBeNull();
|
||||||
|
|
||||||
|
await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("aucun email n est envoye si le tirage est refuse", async () => {
|
||||||
|
mailer.sendMail.mockResolvedValue(true);
|
||||||
|
await jourJ(1);
|
||||||
|
|
||||||
|
const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
|
||||||
|
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(mailer.sendMail).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await jourJ(35);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,125 +10,88 @@ jest.mock("../src/config/mailer", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// ------------------------
|
// ------------------------
|
||||||
// Réinitialisation DB avant tests
|
// Ce test tourne sur la même base que le reste du dev (500 000 tickets
|
||||||
|
// réels du jeu-concours). On ne touche donc JAMAIS les tables globales
|
||||||
|
// (pas de deleteMany sur claim/participation/ticket/user/lot) : tout ce
|
||||||
|
// que ce fichier crée est identifiable de façon unique et nettoyé dans
|
||||||
|
// afterAll, rien d'autre.
|
||||||
// ------------------------
|
// ------------------------
|
||||||
|
|
||||||
|
const testEmail = "e2e-flow-test@test.com";
|
||||||
|
const ticketCode = "ZZE2ETEST1";
|
||||||
|
let token;
|
||||||
|
let createdTicketId;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await prisma.claim.deleteMany();
|
await prisma.user.deleteMany({ where: { email: testEmail } });
|
||||||
await prisma.participation.deleteMany();
|
await prisma.ticket.deleteMany({ where: { code: ticketCode } });
|
||||||
await prisma.ticket.deleteMany();
|
|
||||||
await prisma.user.deleteMany();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: testEmail } });
|
||||||
|
if (user) {
|
||||||
|
await prisma.claim.deleteMany({ where: { participation: { userId: user.id } } });
|
||||||
|
await prisma.participation.deleteMany({ where: { userId: user.id } });
|
||||||
|
}
|
||||||
|
if (createdTicketId) {
|
||||||
|
await prisma.ticket.delete({ where: { id: createdTicketId } }).catch(() => {});
|
||||||
|
}
|
||||||
|
if (user) {
|
||||||
|
await prisma.user.delete({ where: { id: user.id } });
|
||||||
|
}
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("THE TIP TOP - FULL E2E FLOW", () => {
|
describe("THE TIP TOP - FULL E2E FLOW", () => {
|
||||||
let token;
|
|
||||||
let ticketCode = "ABC123";
|
|
||||||
|
|
||||||
// ------------------------
|
|
||||||
// 1. REGISTER
|
|
||||||
// ------------------------
|
|
||||||
test("Register user", async () => {
|
test("Register user", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app).post("/api/auth/register").send({
|
||||||
.post("/api/auth/register")
|
firstname: "E2E",
|
||||||
.send({
|
lastname: "Test",
|
||||||
firstname: "Test",
|
email: testEmail,
|
||||||
lastname: "User",
|
password: "Password123",
|
||||||
email: "test@test.com",
|
});
|
||||||
password: "Password123",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect([201, 409]).toContain(res.statusCode);
|
expect(res.statusCode).toBe(201);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------
|
|
||||||
// 2. LOGIN
|
|
||||||
// ------------------------
|
|
||||||
test("Login user", async () => {
|
test("Login user", async () => {
|
||||||
// Validation du user pour bypasser vérification email
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { email: "test@test.com" },
|
where: { email: testEmail },
|
||||||
data: { isEmailVerified: true },
|
data: { isEmailVerified: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app).post("/api/auth/login").send({
|
||||||
.post("/api/auth/login")
|
email: testEmail,
|
||||||
.send({
|
password: "Password123",
|
||||||
email: "test@test.com",
|
});
|
||||||
password: "Password123",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.body.token).toBeDefined();
|
expect(res.body.token).toBeDefined();
|
||||||
token = res.body.token;
|
token = res.body.token;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------
|
test("Create a dedicated ticket for this test", async () => {
|
||||||
// 3. PARTICIPATE GAME
|
const lot = await prisma.lot.findFirst();
|
||||||
// ------------------------
|
expect(lot).not.toBeNull();
|
||||||
test("Participate to game", async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post("/api/game/participate")
|
|
||||||
.set("Authorization", `Bearer ${token}`)
|
|
||||||
.send({ code: ticketCode });
|
|
||||||
|
|
||||||
expect([200, 201, 404, 409, 400]).toContain(res.statusCode);
|
|
||||||
console.log("Ticket in DB before participate:", ticketCode);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ------------------------
|
|
||||||
// 4. MY GAINS
|
|
||||||
// ------------------------
|
|
||||||
test("Get my gains", async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.get("/api/game/my-gains")
|
|
||||||
.set("Authorization", `Bearer ${token}`);
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.body.gains).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ------------------------
|
|
||||||
// 3. GENERER UN TICKET
|
|
||||||
// ------------------------
|
|
||||||
test("Create a ticket for testing", async () => {
|
|
||||||
const lot = await prisma.lot.create({
|
|
||||||
data: {
|
|
||||||
name: "Lot Test",
|
|
||||||
description: "Lot pour test E2E",
|
|
||||||
value: 10,
|
|
||||||
percentage: 100
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ticket = await prisma.ticket.create({
|
const ticket = await prisma.ticket.create({
|
||||||
data: {
|
data: { code: ticketCode, lotId: lot.id },
|
||||||
code: ticketCode,
|
|
||||||
lotId: lot.id
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
createdTicketId = ticket.id;
|
||||||
|
|
||||||
expect(ticket).toBeDefined();
|
|
||||||
expect(ticket.code).toBe(ticketCode);
|
expect(ticket.code).toBe(ticketCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------
|
|
||||||
// 4. PARTICIPATE GAME
|
|
||||||
// ------------------------
|
|
||||||
test("Participate to game", async () => {
|
test("Participate to game", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post("/api/game/participate")
|
.post("/api/game/participate")
|
||||||
.set("Authorization", `Bearer ${token}`)
|
.set("Authorization", `Bearer ${token}`)
|
||||||
.send({ code: ticketCode });
|
.send({ code: ticketCode });
|
||||||
|
|
||||||
expect([200, 201, 404, 409, 400]).toContain(res.statusCode);
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(res.body.gain).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------
|
|
||||||
// 5. MY GAINS
|
|
||||||
// ------------------------
|
|
||||||
test("Get my gains", async () => {
|
test("Get my gains", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.get("/api/game/my-gains")
|
.get("/api/game/my-gains")
|
||||||
@@ -136,11 +99,9 @@ describe("THE TIP TOP - FULL E2E FLOW", () => {
|
|||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.body.gains).toBeDefined();
|
expect(res.body.gains).toBeDefined();
|
||||||
|
expect(res.body.gains.some((g) => g.ticketCode === ticketCode)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------
|
|
||||||
// 6. ADMIN STATS (CLIENT access denied)
|
|
||||||
// ------------------------
|
|
||||||
test("Admin stats - CLIENT access denied", async () => {
|
test("Admin stats - CLIENT access denied", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.get("/api/admin/stats")
|
.get("/api/admin/stats")
|
||||||
|
|||||||
113
backend/tests/gainNotification.test.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
const request = require("supertest");
|
||||||
|
const app = require("../src/app");
|
||||||
|
const prisma = require("../src/config/prisma");
|
||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const bcrypt = require("bcrypt");
|
||||||
|
const mailer = require("../src/config/mailer");
|
||||||
|
|
||||||
|
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
|
||||||
|
|
||||||
|
const DAY = 86400000;
|
||||||
|
const PREFIX = "gain-";
|
||||||
|
const CODES = ["GAINTICK01", "GAINTICK02", "GAINTICK03"];
|
||||||
|
let ctx = {};
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } });
|
||||||
|
const ids = us.map((u) => u.id);
|
||||||
|
const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } });
|
||||||
|
await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } });
|
||||||
|
await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
|
||||||
|
await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } });
|
||||||
|
await prisma.ticket.deleteMany({ where: { code: { in: CODES } } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||||
|
const jourJ = (d) =>
|
||||||
|
prisma.gameSettings.update({
|
||||||
|
where: { id: ctx.settingsId },
|
||||||
|
data: { startDate: new Date(Date.now() - d * DAY) },
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||||
|
ctx.settingsId = row.id;
|
||||||
|
ctx.startDateOriginale = row.startDate;
|
||||||
|
|
||||||
|
ctx.lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
|
||||||
|
for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: ctx.lot.id } });
|
||||||
|
|
||||||
|
const h = await bcrypt.hash("Password123", 10);
|
||||||
|
ctx.client = await prisma.user.create({ data: { firstname: "Chloe", lastname: "Martin", email: PREFIX + "chloe@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
|
||||||
|
ctx.tClient = tk(ctx.client);
|
||||||
|
|
||||||
|
await jourJ(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } });
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Notification du gain a chaque participation", () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
test("le joueur recoit un email decrivant son lot", async () => {
|
||||||
|
mailer.sendMail.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/game/participate")
|
||||||
|
.set("Authorization", "Bearer " + ctx.tClient)
|
||||||
|
.send({ code: CODES[0] })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(res.body.emailSent).toBe(true);
|
||||||
|
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const envoi = mailer.sendMail.mock.calls[0][0];
|
||||||
|
expect(envoi.to).toBe(ctx.client.email);
|
||||||
|
expect(envoi.subject).toContain(ctx.lot.name);
|
||||||
|
expect(envoi.html).toContain("Chloe");
|
||||||
|
expect(envoi.html).toContain(CODES[0]);
|
||||||
|
expect(envoi.html).toContain(ctx.lot.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un SMTP en panne n annule pas la participation", async () => {
|
||||||
|
mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible"));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/game/participate")
|
||||||
|
.set("Authorization", "Bearer " + ctx.tClient)
|
||||||
|
.send({ code: CODES[1] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.emailSent).toBe(false);
|
||||||
|
|
||||||
|
// Le gain reste acquis et consultable.
|
||||||
|
const t = await prisma.ticket.findUnique({ where: { code: CODES[1] } });
|
||||||
|
expect(t.isUsed).toBe(true);
|
||||||
|
|
||||||
|
const gains = await request(app).get("/api/game/my-gains").set("Authorization", "Bearer " + ctx.tClient).expect(200);
|
||||||
|
expect(gains.body.gains.some((g) => g.ticketCode === CODES[1])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("aucun email si le code est refuse", async () => {
|
||||||
|
mailer.sendMail.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const rejoue = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
|
||||||
|
expect(rejoue.status).toBe(409);
|
||||||
|
|
||||||
|
const inconnu = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: "INEXISTAN1" });
|
||||||
|
expect(inconnu.status).toBe(404);
|
||||||
|
|
||||||
|
await jourJ(45);
|
||||||
|
const horsDelai = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[2] });
|
||||||
|
expect(horsDelai.status).toBe(410);
|
||||||
|
await jourJ(1);
|
||||||
|
|
||||||
|
expect(mailer.sendMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
163
backend/tests/gameFlow.test.js
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
const request = require("supertest");
|
||||||
|
const app = require("../src/app");
|
||||||
|
const prisma = require("../src/config/prisma");
|
||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const bcrypt = require("bcrypt");
|
||||||
|
|
||||||
|
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) }));
|
||||||
|
|
||||||
|
const emails = { adm: "v-adm@test.local", emp: "v-emp@test.local", a: "v-a@test.local", b: "v-b@test.local" };
|
||||||
|
const CODES = ["VERIFTICK1", "VERIFTICK2", "VERIFTICK3", "VERIFTICK4", "VERIFTICK5"];
|
||||||
|
const DAY = 86400000;
|
||||||
|
let ctx = {};
|
||||||
|
const log = (...a) => console.log(" ", ...a);
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
const us = await prisma.user.findMany({ where: { email: { in: [...Object.values(emails), "v-r1@test.local", "v-r2@test.local"] } } });
|
||||||
|
const ids = us.map((u) => u.id);
|
||||||
|
const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } });
|
||||||
|
await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } });
|
||||||
|
await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
|
||||||
|
await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } });
|
||||||
|
await prisma.ticket.deleteMany({ where: { code: { in: CODES } } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const setStart = (d) =>
|
||||||
|
prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: new Date(Date.now() - d * DAY) } });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
ctx.settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||||
|
ctx.orig = ctx.settings.startDate;
|
||||||
|
const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
|
||||||
|
for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: lot.id } });
|
||||||
|
const h = await bcrypt.hash("Password123", 10);
|
||||||
|
const mk = (e, r) => prisma.user.create({ data: { firstname: "V", lastname: r, email: e, password: h, role: r, isEmailVerified: true } });
|
||||||
|
ctx.adm = await mk(emails.adm, "ADMIN");
|
||||||
|
ctx.emp = await mk(emails.emp, "EMPLOYEE");
|
||||||
|
ctx.a = await mk(emails.a, "CLIENT");
|
||||||
|
ctx.b = await mk(emails.b, "CLIENT");
|
||||||
|
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||||
|
ctx.tadm = tk(ctx.adm);
|
||||||
|
ctx.temp = tk(ctx.emp);
|
||||||
|
ctx.ta = tk(ctx.a);
|
||||||
|
ctx.tb = tk(ctx.b);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: ctx.orig } });
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("VERIFICATION DES 7 CORRECTIFS", () => {
|
||||||
|
test("#1 ADMIN peut lancer le jeu", async () => {
|
||||||
|
const futur = new Date(Date.now() + 5 * DAY).toISOString();
|
||||||
|
const r = await request(app).put("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.tadm}`).send({ startDate: futur });
|
||||||
|
log("PUT game-settings ->", r.status, r.body.message);
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
|
||||||
|
const g = await request(app).get("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.tadm}`).expect(200);
|
||||||
|
log("GET game-settings -> participation ouverte :", g.body.gameWindow.isParticipationOpen, "(jeu pas encore commence)");
|
||||||
|
|
||||||
|
const bad = await request(app).put("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.tadm}`).send({ startDate: "pas-une-date" });
|
||||||
|
log("date invalide ->", bad.status, bad.body.message);
|
||||||
|
expect(bad.status).toBe(400);
|
||||||
|
|
||||||
|
const nonAdmin = await request(app).put("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.ta}`).send({ startDate: futur });
|
||||||
|
log("client tente de lancer le jeu ->", nonAdmin.status);
|
||||||
|
expect(nonAdmin.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#2 participation refusee apres 30 jours", async () => {
|
||||||
|
await setStart(45);
|
||||||
|
const r = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.ta}`).send({ code: CODES[0] });
|
||||||
|
log("jour 45 ->", r.status, r.body.message);
|
||||||
|
expect(r.status).toBe(410);
|
||||||
|
|
||||||
|
await setStart(1);
|
||||||
|
const ok = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.ta}`).send({ code: CODES[0] });
|
||||||
|
log("jour 1 ->", ok.status, ok.body.message || "accepte");
|
||||||
|
expect(ok.status).toBe(201);
|
||||||
|
ctx.claimId = ok.body.claim.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#3 expiration derivee et reversible", async () => {
|
||||||
|
await setStart(70);
|
||||||
|
const g1 = await request(app).get("/api/game/my-gains").set("Authorization", `Bearer ${ctx.ta}`).expect(200);
|
||||||
|
log("jour 70, statut affiche :", g1.body.gains.map((g) => g.claimStatus).join(","));
|
||||||
|
expect(g1.body.gains[0].claimStatus).toBe("CANCELLED");
|
||||||
|
|
||||||
|
const enBase = await prisma.claim.findUnique({ where: { id: ctx.claimId } });
|
||||||
|
log("statut REEL en base :", enBase.status, "(non ecrase)");
|
||||||
|
expect(enBase.status).toBe("PENDING");
|
||||||
|
|
||||||
|
await setStart(1);
|
||||||
|
const g2 = await request(app).get("/api/game/my-gains").set("Authorization", `Bearer ${ctx.ta}`).expect(200);
|
||||||
|
log("retour jour 1, statut :", g2.body.gains.map((g) => g.claimStatus).join(","), "(reversible)");
|
||||||
|
expect(g2.body.gains[0].claimStatus).toBe("PENDING");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#4 tirage refuse pendant le jeu", async () => {
|
||||||
|
await setStart(1);
|
||||||
|
const r = await request(app).post("/api/admin/draw").set("Authorization", `Bearer ${ctx.tadm}`);
|
||||||
|
log("tirage pendant le jeu ->", r.status, r.body.message);
|
||||||
|
expect(r.status).toBe(409);
|
||||||
|
|
||||||
|
await setStart(35);
|
||||||
|
const r2 = await request(app).post("/api/admin/draw").set("Authorization", `Bearer ${ctx.tadm}`);
|
||||||
|
log("tirage apres cloture ->", r2.status, r2.body.message, r2.body.totalEligibleParticipants ? `(${r2.body.totalEligibleParticipants} eligibles)` : "");
|
||||||
|
expect(r2.status).toBe(201);
|
||||||
|
|
||||||
|
await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: [ctx.a.id, ctx.b.id] } } });
|
||||||
|
await setStart(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#5 admin et employe ne peuvent plus jouer", async () => {
|
||||||
|
const r1 = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.tadm}`).send({ code: CODES[1] });
|
||||||
|
const r2 = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.temp}`).send({ code: CODES[1] });
|
||||||
|
log("ADMIN ->", r1.status, r1.body.message);
|
||||||
|
log("EMPLOYE ->", r2.status, r2.body.message);
|
||||||
|
expect(r1.status).toBe(403);
|
||||||
|
expect(r2.status).toBe(403);
|
||||||
|
|
||||||
|
const t = await prisma.ticket.findUnique({ where: { code: CODES[1] } });
|
||||||
|
log("le ticket reste vierge :", t.isUsed === false);
|
||||||
|
expect(t.isUsed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#6 suppression de compte ne remet pas le ticket en jeu", async () => {
|
||||||
|
await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.tb}`).send({ code: CODES[2] }).expect(201);
|
||||||
|
await request(app).delete("/api/auth/account").set("Authorization", `Bearer ${ctx.tb}`).expect(200);
|
||||||
|
|
||||||
|
const t = await prisma.ticket.findUnique({ where: { code: CODES[2] } });
|
||||||
|
log("apres suppression : isUsed =", t.isUsed, "| userId =", t.userId);
|
||||||
|
expect(t.isUsed).toBe(true);
|
||||||
|
|
||||||
|
const rejoue = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.ta}`).send({ code: CODES[2] });
|
||||||
|
log("tentative de rejouer le code ->", rejoue.status, rejoue.body.message);
|
||||||
|
expect(rejoue.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#7 course sur un meme code", async () => {
|
||||||
|
const h = await bcrypt.hash("Password123", 10);
|
||||||
|
const c1 = await prisma.user.create({ data: { firstname: "R", lastname: "1", email: "v-r1@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
|
||||||
|
const c2 = await prisma.user.create({ data: { firstname: "R", lastname: "2", email: "v-r2@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
|
||||||
|
const t1 = jwt.sign({ id: c1.id, role: "CLIENT" }, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||||
|
const t2 = jwt.sign({ id: c2.id, role: "CLIENT" }, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||||
|
|
||||||
|
const [r1, r2] = await Promise.all([
|
||||||
|
request(app).post("/api/game/participate").set("Authorization", `Bearer ${t1}`).send({ code: CODES[3] }),
|
||||||
|
request(app).post("/api/game/participate").set("Authorization", `Bearer ${t2}`).send({ code: CODES[3] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const codes = [r1.status, r2.status].sort();
|
||||||
|
log("statuts obtenus :", codes.join(" + "), "(201 + 409 attendu, aucun 500)");
|
||||||
|
expect(codes).toEqual([201, 409]);
|
||||||
|
|
||||||
|
const n = await prisma.participation.count({ where: { ticket: { code: CODES[3] } } });
|
||||||
|
log("participations pour ce ticket :", n);
|
||||||
|
expect(n).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
145
backend/tests/gamePhases.test.js
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
const request = require("supertest");
|
||||||
|
const app = require("../src/app");
|
||||||
|
const prisma = require("../src/config/prisma");
|
||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const bcrypt = require("bcrypt");
|
||||||
|
|
||||||
|
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) }));
|
||||||
|
|
||||||
|
const DAY = 86400000;
|
||||||
|
const PREFIX = "r2-";
|
||||||
|
const CODES = ["R2TICKET01", "R2TICKET02", "R2TICKET03", "R2TICKET04"];
|
||||||
|
let ctx = {};
|
||||||
|
const log = (...a) => console.log(" ", ...a);
|
||||||
|
const titre = (t) => console.log("\n===== " + t + " =====");
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } });
|
||||||
|
const ids = us.map((u) => u.id);
|
||||||
|
const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } });
|
||||||
|
await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } });
|
||||||
|
await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
|
||||||
|
await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } });
|
||||||
|
await prisma.ticket.deleteMany({ where: { code: { in: CODES } } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jourJ(offsetJours) {
|
||||||
|
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||||
|
const debut = new Date(Date.now() - offsetJours * DAY);
|
||||||
|
if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: debut } });
|
||||||
|
else await prisma.gameSettings.create({ data: { startDate: debut } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "2h" });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||||
|
ctx.startDateOriginale = row ? row.startDate : null;
|
||||||
|
|
||||||
|
const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
|
||||||
|
for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: lot.id } });
|
||||||
|
|
||||||
|
const h = await bcrypt.hash("Password123", 10);
|
||||||
|
ctx.admin = await prisma.user.create({ data: { firstname: "Alice", lastname: "Admin", email: PREFIX + "admin@test.local", password: h, role: "ADMIN", isEmailVerified: true } });
|
||||||
|
ctx.client = await prisma.user.create({ data: { firstname: "Zoe", lastname: "Cliente", email: PREFIX + "zoe@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
|
||||||
|
ctx.tAdmin = tk(ctx.admin);
|
||||||
|
ctx.tClient = tk(ctx.client);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||||
|
if (ctx.startDateOriginale) {
|
||||||
|
if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: ctx.startDateOriginale } });
|
||||||
|
else await prisma.gameSettings.create({ data: { startDate: ctx.startDateOriginale } });
|
||||||
|
}
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("VERIFICATION DES 3 CORRECTIFS DE PHASE", () => {
|
||||||
|
|
||||||
|
test("A. rien nest jouable avant tout lancement", async () => {
|
||||||
|
titre("A : aucune GameSettings en base");
|
||||||
|
await prisma.gameSettings.deleteMany({});
|
||||||
|
|
||||||
|
const vue = await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin).expect(200);
|
||||||
|
log("phase :", vue.body.gameWindow.phase, "| participations ouvertes :", vue.body.gameWindow.isParticipationOpen);
|
||||||
|
expect(vue.body.gameWindow.phase).toBe("NOT_CONFIGURED");
|
||||||
|
|
||||||
|
const essai = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
|
||||||
|
log("un client tente de jouer ->", essai.status, essai.body.message);
|
||||||
|
expect(essai.status).toBe(410);
|
||||||
|
|
||||||
|
const t = await prisma.ticket.findUnique({ where: { code: CODES[0] } });
|
||||||
|
log("le ticket reste vierge :", t.isUsed === false);
|
||||||
|
expect(t.isUsed).toBe(false);
|
||||||
|
|
||||||
|
const tirage = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
|
||||||
|
log("admin tente le tirage ->", tirage.status, tirage.body.message);
|
||||||
|
expect(tirage.status).toBe(409);
|
||||||
|
expect(await prisma.grandPrizeWinner.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B. jeu programme : message juste et tirage bloque", async () => {
|
||||||
|
titre("B : lancement programme a J+7");
|
||||||
|
const futur = new Date(Date.now() + 7 * DAY).toISOString();
|
||||||
|
const r = await request(app).put("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin).send({ startDate: futur });
|
||||||
|
log("PUT game-settings ->", r.status, "| phase :", r.body.gameWindow.phase);
|
||||||
|
expect(r.body.gameWindow.phase).toBe("SCHEDULED");
|
||||||
|
|
||||||
|
const anticipe = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
|
||||||
|
log("client joue avant ouverture ->", anticipe.status);
|
||||||
|
log("message :", JSON.stringify(anticipe.body.message));
|
||||||
|
expect(anticipe.body.message).toMatch(/ouvrira le/);
|
||||||
|
expect(anticipe.body.message).not.toMatch(/terminée/);
|
||||||
|
|
||||||
|
const tirage = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
|
||||||
|
log("tirage avant ouverture ->", tirage.status, tirage.body.message);
|
||||||
|
expect(tirage.status).toBe(409);
|
||||||
|
expect(tirage.body.message).toMatch(/pas encore commencé/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("C. les quatre phases dun jeu configure", async () => {
|
||||||
|
titre("C : parcours des phases");
|
||||||
|
|
||||||
|
await jourJ(1);
|
||||||
|
let w = (await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin)).body.gameWindow;
|
||||||
|
const ok = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
|
||||||
|
log("jour 1 | phase", w.phase, "| participate ->", ok.status);
|
||||||
|
expect(w.phase).toBe("OPEN");
|
||||||
|
expect(ok.status).toBe(201);
|
||||||
|
ctx.claimId = ok.body.claim.id;
|
||||||
|
|
||||||
|
await jourJ(35);
|
||||||
|
w = (await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin)).body.gameWindow;
|
||||||
|
const tard = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[1] });
|
||||||
|
log("jour 35 | phase", w.phase, "| participate ->", tard.status, JSON.stringify(tard.body.message));
|
||||||
|
expect(w.phase).toBe("CLAIM_ONLY");
|
||||||
|
expect(tard.body.message).toMatch(/participation est terminée/);
|
||||||
|
|
||||||
|
const tirage = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
|
||||||
|
log("tirage apres cloture ->", tirage.status, tirage.body.message);
|
||||||
|
expect(tirage.status).toBe(201);
|
||||||
|
|
||||||
|
await jourJ(70);
|
||||||
|
w = (await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin)).body.gameWindow;
|
||||||
|
const fini = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[2] });
|
||||||
|
log("jour 70 | phase", w.phase, "| participate ->", fini.status, JSON.stringify(fini.body.message));
|
||||||
|
expect(w.phase).toBe("CLOSED");
|
||||||
|
expect(fini.body.message).toMatch(/jeu-concours est terminé/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("D. non-regression : le parcours nominal fonctionne toujours", async () => {
|
||||||
|
titre("D : verification du parcours de base");
|
||||||
|
await jourJ(2);
|
||||||
|
const g = await request(app).get("/api/game/my-gains").set("Authorization", "Bearer " + ctx.tClient).expect(200);
|
||||||
|
log("gains du client :", JSON.stringify(g.body.gains.map((x) => x.claimStatus)));
|
||||||
|
expect(g.body.gains[0].claimStatus).toBe("PENDING");
|
||||||
|
|
||||||
|
const stats = await request(app).get("/api/admin/stats").set("Authorization", "Bearer " + ctx.tAdmin).expect(200);
|
||||||
|
log("stats : phase =", stats.body.gameWindow.phase, "| participations =", stats.body.participations);
|
||||||
|
expect(stats.body.gameWindow.phase).toBe("OPEN");
|
||||||
|
});
|
||||||
|
});
|
||||||
142
backend/tests/googlePassword.test.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
const request = require("supertest");
|
||||||
|
const app = require("../src/app");
|
||||||
|
const prisma = require("../src/config/prisma");
|
||||||
|
const mailer = require("../src/config/mailer");
|
||||||
|
const { passport, googleEnabled } = require("../src/config/passport");
|
||||||
|
|
||||||
|
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
|
||||||
|
|
||||||
|
const EMAIL = "google-pwd-test@test.local";
|
||||||
|
const PROFILE = {
|
||||||
|
id: "sim-google-id-123456",
|
||||||
|
displayName: "Chloe Martin",
|
||||||
|
name: { givenName: "Chloe", familyName: "Martin" },
|
||||||
|
emails: [{ value: EMAIL }],
|
||||||
|
photos: [{ value: "https://example.test/avatar.png" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.user.deleteMany({ where: { email: EMAIL } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rejoue le callback de la strategie Google sans sortir sur le reseau.
|
||||||
|
function verifierProfilGoogle() {
|
||||||
|
const strategy = passport._strategy("google");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
strategy._verify("access-token", "refresh-token", PROFILE, (err, user) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(user);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(cleanup);
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const decrire = googleEnabled ? describe : describe.skip;
|
||||||
|
|
||||||
|
decrire("Inscription Google et definition du mot de passe", () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
test("un compte Google est cree sans mot de passe et recoit un lien", async () => {
|
||||||
|
mailer.sendMail.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const user = await verifierProfilGoogle();
|
||||||
|
|
||||||
|
expect(user.provider).toBe("GOOGLE");
|
||||||
|
expect(user.password).toBeNull();
|
||||||
|
expect(user.isEmailVerified).toBe(true);
|
||||||
|
|
||||||
|
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
||||||
|
const envoi = mailer.sendMail.mock.calls[0][0];
|
||||||
|
expect(envoi.to).toBe(EMAIL);
|
||||||
|
expect(envoi.subject).toMatch(/mot de passe/i);
|
||||||
|
|
||||||
|
// Le lien doit viser le formulaire du front, pas la route POST de l API.
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
||||||
|
expect(envoi.html).toContain(`${frontendUrl}/definir-mot-de-passe/`);
|
||||||
|
|
||||||
|
const enBase = await prisma.user.findUnique({ where: { email: EMAIL } });
|
||||||
|
expect(enBase.resetPasswordToken).toHaveLength(64);
|
||||||
|
expect(enBase.resetPasswordExpires.getTime()).toBeGreaterThan(Date.now());
|
||||||
|
ctxToken = enBase.resetPasswordToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le profil signale que le compte n a pas de mot de passe", async () => {
|
||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const u = await prisma.user.findUnique({ where: { email: EMAIL } });
|
||||||
|
const token = jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||||
|
|
||||||
|
const res = await request(app).get("/api/auth/me").set("Authorization", "Bearer " + token).expect(200);
|
||||||
|
|
||||||
|
expect(res.body.user.hasPassword).toBe(false);
|
||||||
|
expect(res.body.user.provider).toBe("GOOGLE");
|
||||||
|
// Le hash ne doit jamais sortir de l API.
|
||||||
|
expect(res.body.user.password).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la connexion par mot de passe est refusee tant qu il n en existe pas", async () => {
|
||||||
|
const res = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "PeuImporte123" });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.message).toMatch(/connexion Google/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une confirmation qui ne correspond pas est refusee", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/auth/reset-password/" + ctxToken)
|
||||||
|
.send({ password: "MonMotDePasse1", confirmPassword: "AutreMotDePasse2" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.message).toMatch(/ne correspondent pas/);
|
||||||
|
|
||||||
|
const u = await prisma.user.findUnique({ where: { email: EMAIL } });
|
||||||
|
expect(u.password).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un mot de passe trop court est refuse", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/auth/reset-password/" + ctxToken)
|
||||||
|
.send({ password: "court", confirmPassword: "court" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.message).toMatch(/8 caractères/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le mot de passe defini permet ensuite de se connecter et de le changer", async () => {
|
||||||
|
const ok = await request(app)
|
||||||
|
.post("/api/auth/reset-password/" + ctxToken)
|
||||||
|
.send({ password: "MonMotDePasse1", confirmPassword: "MonMotDePasse1" })
|
||||||
|
.expect(200);
|
||||||
|
expect(ok.body.message).toMatch(/réinitialisé/);
|
||||||
|
|
||||||
|
const login = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "MonMotDePasse1" }).expect(200);
|
||||||
|
expect(login.body.token).toBeDefined();
|
||||||
|
|
||||||
|
// Le jeton a bien ete consomme.
|
||||||
|
const u = await prisma.user.findUnique({ where: { email: EMAIL } });
|
||||||
|
expect(u.resetPasswordToken).toBeNull();
|
||||||
|
|
||||||
|
const me = await request(app).get("/api/auth/me").set("Authorization", "Bearer " + login.body.token).expect(200);
|
||||||
|
expect(me.body.user.hasPassword).toBe(true);
|
||||||
|
|
||||||
|
const change = await request(app)
|
||||||
|
.patch("/api/auth/change-password")
|
||||||
|
.set("Authorization", "Bearer " + login.body.token)
|
||||||
|
.send({ currentPassword: "MonMotDePasse1", newPassword: "EncoreUnAutre2" });
|
||||||
|
expect(change.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une seconde connexion Google ne renvoie pas de nouveau lien", async () => {
|
||||||
|
mailer.sendMail.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const user = await verifierProfilGoogle();
|
||||||
|
|
||||||
|
expect(user.email).toBe(EMAIL);
|
||||||
|
expect(mailer.sendMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let ctxToken;
|
||||||
66
backend/tests/register.mail.test.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
const request = require("supertest");
|
||||||
|
const app = require("../src/app");
|
||||||
|
const prisma = require("../src/config/prisma");
|
||||||
|
const mailer = require("../src/config/mailer");
|
||||||
|
|
||||||
|
jest.mock("../src/config/mailer", () => ({
|
||||||
|
sendMail: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Comme le reste de la suite, ce fichier tourne sur la base de dev : il ne
|
||||||
|
// touche qu'au compte qu'il crée lui-même, identifié par cet email.
|
||||||
|
const testEmail = "rollback-mail-test@test.com";
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
firstname: "Camille",
|
||||||
|
lastname: "Martin",
|
||||||
|
email: testEmail,
|
||||||
|
password: "Password123",
|
||||||
|
birthdate: "1990-05-14",
|
||||||
|
gender: "Femme",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
await prisma.user.deleteMany({ where: { email: testEmail } });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.user.deleteMany({ where: { email: testEmail } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Inscription - echec d'envoi du code de validation", () => {
|
||||||
|
test("repond 502 et ne laisse aucun compte orphelin", async () => {
|
||||||
|
mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible"));
|
||||||
|
|
||||||
|
const res = await request(app).post("/api/auth/register").send(payload);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(502);
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: testEmail } });
|
||||||
|
expect(user).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("l'email reste reutilisable apres un echec d'envoi", async () => {
|
||||||
|
mailer.sendMail.mockRejectedValueOnce(new Error("SMTP indisponible"));
|
||||||
|
const echec = await request(app).post("/api/auth/register").send(payload);
|
||||||
|
expect(echec.statusCode).toBe(502);
|
||||||
|
|
||||||
|
mailer.sendMail.mockResolvedValue(true);
|
||||||
|
const succes = await request(app).post("/api/auth/register").send(payload);
|
||||||
|
|
||||||
|
expect(succes.statusCode).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("enregistre la date de naissance et le genre", async () => {
|
||||||
|
mailer.sendMail.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const res = await request(app).post("/api/auth/register").send(payload);
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: testEmail } });
|
||||||
|
expect(user.gender).toBe("Femme");
|
||||||
|
expect(user.birthdate.toISOString()).toBe("1990-05-14T00:00:00.000Z");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
const prisma = require("../src/config/prisma");
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await prisma.claim.deleteMany();
|
|
||||||
await prisma.participation.deleteMany();
|
|
||||||
await prisma.ticket.deleteMany();
|
|
||||||
await prisma.user.deleteMany();
|
|
||||||
|
|
||||||
// Création de lots
|
|
||||||
await prisma.lot.createMany({
|
|
||||||
data: [
|
|
||||||
{ name: "Infuseur", value: 10, percentage: 60, description: "Infuseur à thé" },
|
|
||||||
{ name: "Thé Détox", value: 15, percentage: 20, description: "Thé détox bio" },
|
|
||||||
{ name: "Thé Signature", value: 20, percentage: 10, description: "Thé signature" },
|
|
||||||
{ name: "Coffret 39€", value: 39, percentage: 6, description: "Coffret découverte" },
|
|
||||||
{ name: "Coffret 69€", value: 69, percentage: 4, description: "Coffret premium" },
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Création d'un ticket
|
|
||||||
await prisma.ticket.create({
|
|
||||||
data: {
|
|
||||||
code: "ABC123",
|
|
||||||
lotId: 1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
@@ -24,6 +24,9 @@ services:
|
|||||||
image: ${REGISTRY}/the-tip-top/backend:${IMAGE_TAG}
|
image: ${REGISTRY}/the-tip-top/backend:${IMAGE_TAG}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
env_file:
|
||||||
|
- /opt/furious-ducks/secrets/tiptop-mail.env
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -34,6 +37,8 @@ services:
|
|||||||
DATABASE_URL: postgresql://tiptop:tiptop_dev_password@postgres:5432/the_tip_top_db
|
DATABASE_URL: postgresql://tiptop:tiptop_dev_password@postgres:5432/the_tip_top_db
|
||||||
JWT_SECRET: dev_secret_change_me
|
JWT_SECRET: dev_secret_change_me
|
||||||
APP_URL: https://api-dev.dsp5-archi-o24a-4-5-g4.fr
|
APP_URL: https://api-dev.dsp5-archi-o24a-4-5-g4.fr
|
||||||
|
API_PUBLIC_URL: https://api-dev.dsp5-archi-o24a-4-5-g4.fr
|
||||||
|
FRONTEND_URL: https://dev.dsp5-archi-o24a-4-5-g4.fr
|
||||||
|
|
||||||
command: >
|
command: >
|
||||||
sh -c "
|
sh -c "
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ services:
|
|||||||
image: ${REGISTRY}/the-tip-top/backend:${IMAGE_TAG}
|
image: ${REGISTRY}/the-tip-top/backend:${IMAGE_TAG}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
env_file:
|
||||||
|
- /opt/furious-ducks/secrets/tiptop-mail.env
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -34,6 +37,8 @@ services:
|
|||||||
DATABASE_URL: postgresql://tiptop_preprod:tiptop_preprod_password@postgres:5432/the_tip_top_preprod
|
DATABASE_URL: postgresql://tiptop_preprod:tiptop_preprod_password@postgres:5432/the_tip_top_preprod
|
||||||
JWT_SECRET: preprod_secret_change_me
|
JWT_SECRET: preprod_secret_change_me
|
||||||
APP_URL: https://api-preprod.dsp5-archi-o24a-4-5-g4.fr
|
APP_URL: https://api-preprod.dsp5-archi-o24a-4-5-g4.fr
|
||||||
|
API_PUBLIC_URL: https://api-preprod.dsp5-archi-o24a-4-5-g4.fr
|
||||||
|
FRONTEND_URL: https://preprod.dsp5-archi-o24a-4-5-g4.fr
|
||||||
|
|
||||||
command: >
|
command: >
|
||||||
sh -c "
|
sh -c "
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ services:
|
|||||||
image: ${REGISTRY}/the-tip-top/backend:${IMAGE_TAG}
|
image: ${REGISTRY}/the-tip-top/backend:${IMAGE_TAG}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
env_file:
|
||||||
|
- /opt/furious-ducks/secrets/tiptop-mail.env
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -34,6 +37,8 @@ services:
|
|||||||
DATABASE_URL: postgresql://tiptop_prod:tiptop_prod_password@postgres:5432/the_tip_top_prod
|
DATABASE_URL: postgresql://tiptop_prod:tiptop_prod_password@postgres:5432/the_tip_top_prod
|
||||||
JWT_SECRET: prod_secret_change_me
|
JWT_SECRET: prod_secret_change_me
|
||||||
APP_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
|
APP_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
|
||||||
|
API_PUBLIC_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
|
||||||
|
FRONTEND_URL: https://dsp5-archi-o24a-4-5-g4.fr
|
||||||
|
|
||||||
command: >
|
command: >
|
||||||
sh -c "
|
sh -c "
|
||||||
|
|||||||
BIN
frontend/Design/Code gagnang.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
frontend/Design/Inscription reussit.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/Design/Saisir_code.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/Design/historique des gain .png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
frontend/Design/profil .png
Normal file
|
After Width: | Height: | Size: 13 KiB |
@@ -8,7 +8,10 @@ RUN npm ci
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ARG VITE_API_URL
|
ARG VITE_API_URL
|
||||||
|
ARG VITE_GA_ID
|
||||||
|
|
||||||
ENV VITE_API_URL=${VITE_API_URL}
|
ENV VITE_API_URL=${VITE_API_URL}
|
||||||
|
ENV VITE_GA_ID=${VITE_GA_ID}
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,275 @@
|
|||||||
# React + Vite
|
# Thé Tip Top – Frontend
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
## Présentation
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
Frontend React du jeu-concours fictif **Thé Tip Top**, réalisé dans le cadre de la certification **Expert en Stratégie et Transformation Digitale**.
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
L’interface communique avec l’API backend et propose des parcours adaptés aux visiteurs, clients, employés et administrateurs.
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
|
||||||
|
|
||||||
## React Compiler
|
## Stack technique
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| Technologie | Utilisation |
|
||||||
|
|---|---|
|
||||||
|
| React | Interface utilisateur |
|
||||||
|
| Vite | Développement et build |
|
||||||
|
| React Router | Navigation |
|
||||||
|
| Tailwind CSS | Interface responsive |
|
||||||
|
| Vitest | Tests |
|
||||||
|
| Testing Library | Tests fonctionnels |
|
||||||
|
| ESLint | Analyse statique |
|
||||||
|
| Nginx | Serveur web du conteneur |
|
||||||
|
| Docker | Conteneurisation |
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
## Fonctionnalités
|
||||||
|
|
||||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
### Visiteur
|
||||||
|
- accueil ;
|
||||||
|
- règles du jeu ;
|
||||||
|
- inscription ;
|
||||||
|
- connexion ;
|
||||||
|
- connexion Google ;
|
||||||
|
- pages légales et confidentialité ;
|
||||||
|
- CGU et accessibilité ;
|
||||||
|
- contact ;
|
||||||
|
- plan du site.
|
||||||
|
|
||||||
|
### Client
|
||||||
|
- profil ;
|
||||||
|
- saisie d’un code unique ;
|
||||||
|
- participation ;
|
||||||
|
- consultation des gains ;
|
||||||
|
- gestion de session.
|
||||||
|
|
||||||
|
### Employé
|
||||||
|
- recherche d’un gain ;
|
||||||
|
- vérification d’un ticket ;
|
||||||
|
- validation de la remise d’un lot.
|
||||||
|
|
||||||
|
### Administrateur
|
||||||
|
- statistiques ;
|
||||||
|
- gestion administrative ;
|
||||||
|
- création d’employés ;
|
||||||
|
- configuration du jeu ;
|
||||||
|
- tirage au sort ;
|
||||||
|
- consultation du grand gagnant.
|
||||||
|
|
||||||
|
## Authentification
|
||||||
|
|
||||||
|
Le frontend prend en charge l’authentification email/mot de passe, la vérification email, le JWT, les routes protégées et Google OAuth 2.0.
|
||||||
|
|
||||||
|
Flux OAuth :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frontend
|
||||||
|
↓
|
||||||
|
/api/auth/google
|
||||||
|
↓
|
||||||
|
Google
|
||||||
|
↓
|
||||||
|
Callback backend
|
||||||
|
↓
|
||||||
|
/auth/callback frontend
|
||||||
|
↓
|
||||||
|
JWT + utilisateur
|
||||||
|
↓
|
||||||
|
Redirection selon le rôle
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rôles
|
||||||
|
|
||||||
|
- `CLIENT`
|
||||||
|
- `EMPLOYEE`
|
||||||
|
- `ADMIN`
|
||||||
|
|
||||||
|
Les protections frontend améliorent l’expérience utilisateur ; les autorisations sensibles restent également contrôlées côté backend.
|
||||||
|
|
||||||
|
## Configuration API
|
||||||
|
|
||||||
|
Le frontend utilise :
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_URL=http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
Les services utilisent cette URL pour les routes `/api/auth`, `/api/game`, `/api/employee` et `/api/admin`.
|
||||||
|
|
||||||
|
Dans Jenkins, `VITE_API_URL` est injectée au moment du build Docker selon l’environnement cible.
|
||||||
|
|
||||||
|
## Installation locale
|
||||||
|
|
||||||
|
Prérequis : Node.js 22 et npm.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis, si nécessaire :
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_URL=http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
L’application Vite est généralement disponible sur `http://localhost:5173`.
|
||||||
|
|
||||||
|
## Qualité et tests
|
||||||
|
|
||||||
|
Lint :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- --run
|
||||||
|
```
|
||||||
|
|
||||||
|
Build :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tests couvrent notamment la connexion, l’inscription, la saisie de code, les routes protégées, le contexte d’authentification, les services et la bannière de cookies.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Le frontend utilise un build multi-stage :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Node.js
|
||||||
|
↓
|
||||||
|
npm ci
|
||||||
|
↓
|
||||||
|
Vite build
|
||||||
|
↓
|
||||||
|
dist/
|
||||||
|
↓
|
||||||
|
Nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
```text
|
||||||
|
feature/* → CI uniquement
|
||||||
|
develop → DEV automatique
|
||||||
|
release-* → PREPROD automatique
|
||||||
|
main → validation manuelle → PROD
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour le frontend, Jenkins exécute notamment :
|
||||||
|
|
||||||
|
1. `npm ci` ;
|
||||||
|
2. `npm run lint` ;
|
||||||
|
3. `npm run build` ;
|
||||||
|
4. Docker build ;
|
||||||
|
5. publication dans le Registry ;
|
||||||
|
6. déploiement dans l’environnement cible.
|
||||||
|
|
||||||
|
## Environnements
|
||||||
|
|
||||||
|
| Environnement | Frontend | API |
|
||||||
|
|---|---|---|
|
||||||
|
| DEV | `https://dev.dsp5-archi-o24a-4-5-g4.fr` | `https://api-dev.dsp5-archi-o24a-4-5-g4.fr` |
|
||||||
|
| PREPROD | `https://preprod.dsp5-archi-o24a-4-5-g4.fr` | `https://api-preprod.dsp5-archi-o24a-4-5-g4.fr` |
|
||||||
|
| PROD | `https://dsp5-archi-o24a-4-5-g4.fr` | `https://api.dsp5-archi-o24a-4-5-g4.fr` |
|
||||||
|
|
||||||
|
## Architecture applicative
|
||||||
|
|
||||||
|
```text
|
||||||
|
Navigateur
|
||||||
|
↓ HTTPS
|
||||||
|
Traefik
|
||||||
|
↓
|
||||||
|
Nginx / React
|
||||||
|
↓
|
||||||
|
API Express
|
||||||
|
↓
|
||||||
|
PostgreSQL
|
||||||
|
```
|
||||||
|
|
||||||
|
Le frontend n’accède jamais directement à PostgreSQL.
|
||||||
|
|
||||||
|
## Pages principales
|
||||||
|
|
||||||
|
Le projet comprend notamment les pages suivantes :
|
||||||
|
|
||||||
|
- accueil ;
|
||||||
|
- inscription ;
|
||||||
|
- connexion ;
|
||||||
|
- callback OAuth ;
|
||||||
|
- définition du mot de passe ;
|
||||||
|
- saisie du code unique ;
|
||||||
|
- gains ;
|
||||||
|
- profil ;
|
||||||
|
- espace employé ;
|
||||||
|
- espace administrateur ;
|
||||||
|
- règles du jeu ;
|
||||||
|
- contact ;
|
||||||
|
- mentions légales ;
|
||||||
|
- confidentialité ;
|
||||||
|
- CGU ;
|
||||||
|
- accessibilité ;
|
||||||
|
- plan du site ;
|
||||||
|
- page 404.
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
- routes protégées ;
|
||||||
|
- gestion centralisée de l’authentification ;
|
||||||
|
- traitement des erreurs 401 ;
|
||||||
|
- redirection selon le rôle ;
|
||||||
|
- HTTPS sur les environnements déployés ;
|
||||||
|
- aucun secret OAuth privé dans le frontend.
|
||||||
|
|
||||||
|
> Le `GOOGLE_CLIENT_SECRET`, le secret JWT, les identifiants SMTP et les mots de passe de base de données ne doivent jamais être exposés dans le frontend.
|
||||||
|
|
||||||
|
## Arborescence simplifiée
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ ├── context/
|
||||||
|
│ ├── pages/
|
||||||
|
│ ├── services/
|
||||||
|
│ ├── test/
|
||||||
|
│ └── App.jsx
|
||||||
|
├── Dockerfile
|
||||||
|
├── nginx.conf
|
||||||
|
├── package.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Infrastructure globale
|
||||||
|
|
||||||
|
```text
|
||||||
|
Développeur
|
||||||
|
↓
|
||||||
|
Gitea
|
||||||
|
↓
|
||||||
|
Jenkins
|
||||||
|
↓
|
||||||
|
Tests + Lint + Build
|
||||||
|
↓
|
||||||
|
Docker Registry
|
||||||
|
↓
|
||||||
|
DEV / PREPROD / PROD
|
||||||
|
↓
|
||||||
|
Traefik + HTTPS
|
||||||
|
```
|
||||||
|
|
||||||
|
Supervision : Prometheus, Grafana, cAdvisor et Node Exporter.
|
||||||
|
|
||||||
|
## Projet
|
||||||
|
|
||||||
|
Projet étudiant fictif réalisé dans le cadre de la certification **Expert en Stratégie et Transformation Digitale**.
|
||||||
|
|
||||||
|
Client fictif : **Thé Tip Top**.
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ttt-front</title>
|
<meta name="description" content="Participez au jeu-concours Thé Tip Top : saisissez le code de votre ticket et découvrez immédiatement votre lot." />
|
||||||
|
<title>Jeu concours - Thé Tip Top</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
1216
frontend/package-lock.json
generated
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
@@ -17,6 +19,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
@@ -24,7 +29,9 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"vite": "^8.0.12"
|
"vite": "^8.0.12",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.6 KiB |
23
frontend/public/robots.txt
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
User-agent: *
|
||||||
|
|
||||||
|
# Pages publiques autorisées
|
||||||
|
Allow: /
|
||||||
|
Allow: /accueil
|
||||||
|
Allow: /regles-jeu
|
||||||
|
Allow: /contact
|
||||||
|
Allow: /mentions-legales
|
||||||
|
Allow: /confidentialite
|
||||||
|
Allow: /sitemap
|
||||||
|
Allow: /inscription
|
||||||
|
Allow: /connexion
|
||||||
|
|
||||||
|
# Pages privées interdites aux robots
|
||||||
|
Disallow: /admin
|
||||||
|
Disallow: /employe
|
||||||
|
Disallow: /profile
|
||||||
|
Disallow: /mes-gains
|
||||||
|
Disallow: /saisir-code-unique
|
||||||
|
Disallow: /perdu
|
||||||
|
|
||||||
|
# Sitemap
|
||||||
|
Sitemap: https://thetiptop.fr/sitemap
|
||||||
@@ -1,23 +1,89 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from './context/AuthContext.jsx'
|
||||||
|
import Layout from './components/Layout.jsx'
|
||||||
|
import ProtectedRoute from './components/ProtectedRoute.jsx'
|
||||||
|
import CookieBanner from './components/CookieBanner.jsx'
|
||||||
import Connexion from './pages/Connexion'
|
import Connexion from './pages/Connexion'
|
||||||
import Inscription from './pages/Inscription'
|
import Inscription from './pages/Inscription'
|
||||||
import Participation from './pages/Participation'
|
import AuthCallback from './pages/AuthCallback'
|
||||||
|
import DefinirMotDePasse from './pages/DefinirMotDePasse'
|
||||||
|
import Accueil from './pages/Accueil'
|
||||||
|
import ReglesJeu from './pages/ReglesJeu'
|
||||||
import MesGains from './pages/MesGains'
|
import MesGains from './pages/MesGains'
|
||||||
import Employe from './pages/Employe'
|
import Employe from './pages/Employe'
|
||||||
import Admin from './pages/Admin'
|
import Admin from './pages/Admin'
|
||||||
|
import Profile from './pages/Profile'
|
||||||
|
import SaisirCodeUnique from './pages/SaisirCodeUnique.jsx'
|
||||||
|
import Perdu from './pages/Perdu'
|
||||||
|
import NotFound from './pages/NotFound'
|
||||||
|
import Sitemap from './pages/Sitemap'
|
||||||
|
import MentionsLegales from './pages/MentionsLegales'
|
||||||
|
import Confidentialite from './pages/Confidentialite'
|
||||||
|
import Accessibilite from './pages/Accessibilite'
|
||||||
|
import Cgu from './pages/Cgu'
|
||||||
|
import Contact from './pages/Contact.jsx'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/connexion" replace />} />
|
<Route path="/" element={<Navigate to="/accueil" replace />} />
|
||||||
<Route path="/connexion" element={<Connexion />} />
|
<Route path="/connexion" element={<Connexion />} />
|
||||||
<Route path="/inscription" element={<Inscription />} />
|
<Route path="/inscription" element={<Inscription />} />
|
||||||
<Route path="/participation" element={<Participation />} />
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||||
<Route path="/mes-gains" element={<MesGains />} />
|
<Route path="/definir-mot-de-passe/:token" element={<DefinirMotDePasse />} />
|
||||||
<Route path="/employe" element={<Employe />} />
|
|
||||||
<Route path="/admin" element={<Admin />} />
|
{/* Pages publiques */}
|
||||||
|
<Route path="/accueil" element={<Layout><Accueil /></Layout>} />
|
||||||
|
<Route path="/regles-jeu" element={<Layout><ReglesJeu /></Layout>} />
|
||||||
|
<Route path="/mentions-legales" element={<Layout><MentionsLegales /></Layout>} />
|
||||||
|
<Route path="/confidentialite" element={<Layout><Confidentialite /></Layout>} />
|
||||||
|
<Route path="/accessibilite" element={<Layout><Accessibilite /></Layout>} />
|
||||||
|
<Route path="/cgu" element={<Layout><Cgu /></Layout>} />
|
||||||
|
<Route path="/contact" element={<Layout><Contact /></Layout>} />
|
||||||
|
<Route path="/sitemap" element={<Layout><Sitemap /></Layout>} />
|
||||||
|
|
||||||
|
{/* Pages CLIENT */}
|
||||||
|
<Route path="/mes-gains" element={
|
||||||
|
<ProtectedRoute roles={['CLIENT']}>
|
||||||
|
<Layout><MesGains /></Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/saisir-code-unique" element={
|
||||||
|
<ProtectedRoute roles={['CLIENT']}>
|
||||||
|
<Layout><SaisirCodeUnique /></Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/profile" element={
|
||||||
|
<ProtectedRoute roles={['CLIENT']}>
|
||||||
|
<Layout><Profile /></Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/perdu" element={
|
||||||
|
<ProtectedRoute roles={['CLIENT']}>
|
||||||
|
<Layout><Perdu /></Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
{/* Page EMPLOYEE */}
|
||||||
|
<Route path="/employe" element={
|
||||||
|
<ProtectedRoute roles={['EMPLOYEE']}>
|
||||||
|
<Layout><Employe /></Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
{/* Page ADMIN */}
|
||||||
|
<Route path="/admin" element={
|
||||||
|
<ProtectedRoute roles={['ADMIN']}>
|
||||||
|
<Layout><Admin /></Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<CookieBanner />
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
frontend/src/assets/img/img-contact.jpg
Normal file
|
After Width: | Height: | Size: 7.2 MiB |
BIN
frontend/src/assets/img/logo.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
79
frontend/src/components/CookieBanner.jsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import { initAnalytics, trackPageView } from '../services/analytics'
|
||||||
|
|
||||||
|
export default function CookieBanner() {
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
const [consent, setConsent] = useState(() =>
|
||||||
|
localStorage.getItem('cookieConsent')
|
||||||
|
)
|
||||||
|
|
||||||
|
const visible = consent === null
|
||||||
|
|
||||||
|
// Si le consentement avait déjà été donné lors d'une précédente visite,
|
||||||
|
// Analytics est initialisé au chargement de l'application.
|
||||||
|
useEffect(() => {
|
||||||
|
if (consent !== 'accepted') return
|
||||||
|
|
||||||
|
initAnalytics()
|
||||||
|
trackPageView(location.pathname + location.search)
|
||||||
|
}, [consent, location.pathname, location.search])
|
||||||
|
|
||||||
|
const handleChoice = (choice) => {
|
||||||
|
localStorage.setItem('cookieConsent', choice)
|
||||||
|
setConsent(choice)
|
||||||
|
|
||||||
|
if (choice === 'accepted') {
|
||||||
|
initAnalytics()
|
||||||
|
trackPageView(location.pathname + location.search)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!visible) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 z-50 px-4 py-4 bg-white border-t border-gray-200 shadow-lg">
|
||||||
|
<div className="max-w-5xl mx-auto flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-semibold text-gray-800 mb-1">
|
||||||
|
Gestion des cookies
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500 leading-relaxed">
|
||||||
|
Les cookies strictement nécessaires au fonctionnement du site sont
|
||||||
|
utilisés automatiquement. Avec votre accord, nous utilisons également
|
||||||
|
Google Analytics afin de mesurer l'audience et améliorer le site. Vous
|
||||||
|
pouvez accepter ou refuser ces cookies statistiques. Consultez notre{' '}
|
||||||
|
<Link
|
||||||
|
to="/confidentialite"
|
||||||
|
className="text-[#006B6B] hover:underline"
|
||||||
|
>
|
||||||
|
politique de confidentialité
|
||||||
|
</Link>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-shrink-0 w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleChoice('refused')}
|
||||||
|
className="flex-1 sm:flex-none border border-gray-300 text-gray-600 hover:bg-gray-50 text-sm font-medium px-4 py-2.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Refuser
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleChoice('accepted')}
|
||||||
|
className="flex-1 sm:flex-none bg-[#006B6B] hover:bg-[#005959] text-white text-sm font-medium px-4 py-2.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Accepter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
246
frontend/src/components/Footer.jsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
function FooterLink({ to, children, className = '', pathname }) {
|
||||||
|
return pathname === to ? (
|
||||||
|
<span className={className}>{children}</span>
|
||||||
|
) : (
|
||||||
|
<Link to={to} className={className}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const isLoggedIn = !!user
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="bg-[#006B6B] text-white">
|
||||||
|
|
||||||
|
{/* Partie principale */}
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-10 grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
<div>
|
||||||
|
<FooterLink
|
||||||
|
to="/accueil"
|
||||||
|
pathname={pathname}
|
||||||
|
>
|
||||||
|
<h3 className="text-xl font-bold mb-3">
|
||||||
|
Thé Tip Top
|
||||||
|
</h3>
|
||||||
|
</FooterLink>
|
||||||
|
|
||||||
|
<p className="text-teal-100 text-sm leading-relaxed">
|
||||||
|
Participez au jeu-concours Thé Tip Top et tentez de gagner
|
||||||
|
des lots gourmands autour du thé.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-5">
|
||||||
|
Navigation
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<ul className="space-y-3 text-teal-100">
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<FooterLink
|
||||||
|
to="/accueil"
|
||||||
|
pathname={pathname}
|
||||||
|
className="hover:text-white transition"
|
||||||
|
>
|
||||||
|
Accueil
|
||||||
|
</FooterLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<FooterLink
|
||||||
|
to="/regles-jeu"
|
||||||
|
pathname={pathname}
|
||||||
|
className="hover:text-white transition"
|
||||||
|
>
|
||||||
|
Règles du jeu
|
||||||
|
</FooterLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<FooterLink
|
||||||
|
to="/saisir-code-unique"
|
||||||
|
pathname={pathname}
|
||||||
|
className="hover:text-white transition"
|
||||||
|
>
|
||||||
|
Tester mon code
|
||||||
|
</FooterLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{!isLoggedIn && (
|
||||||
|
<li>
|
||||||
|
<FooterLink
|
||||||
|
to="/inscription"
|
||||||
|
pathname={pathname}
|
||||||
|
className="hover:text-white transition"
|
||||||
|
>
|
||||||
|
Créer un compte
|
||||||
|
</FooterLink>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Informations légales */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-5">
|
||||||
|
Informations
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<ul className="space-y-3 text-teal-100">
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<FooterLink
|
||||||
|
to="/mentions-legales"
|
||||||
|
pathname={pathname}
|
||||||
|
className="hover:text-white transition"
|
||||||
|
>
|
||||||
|
Mentions légales
|
||||||
|
</FooterLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<FooterLink
|
||||||
|
to="/confidentialite"
|
||||||
|
pathname={pathname}
|
||||||
|
className="hover:text-white transition"
|
||||||
|
>
|
||||||
|
Politique de confidentialité
|
||||||
|
</FooterLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<FooterLink
|
||||||
|
to="/accessibilite"
|
||||||
|
pathname={pathname}
|
||||||
|
className="hover:text-white transition"
|
||||||
|
>
|
||||||
|
Accessibilité
|
||||||
|
</FooterLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<FooterLink
|
||||||
|
to="/cgu"
|
||||||
|
pathname={pathname}
|
||||||
|
className="hover:text-white transition"
|
||||||
|
>
|
||||||
|
CGU
|
||||||
|
</FooterLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<FooterLink
|
||||||
|
to="/contact"
|
||||||
|
pathname={pathname}
|
||||||
|
className="hover:text-white transition"
|
||||||
|
>
|
||||||
|
Contact
|
||||||
|
</FooterLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<FooterLink
|
||||||
|
to="/sitemap"
|
||||||
|
pathname={pathname}
|
||||||
|
className="hover:text-white transition"
|
||||||
|
>
|
||||||
|
Plan du site
|
||||||
|
</FooterLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-5">
|
||||||
|
Contact
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-teal-100 text-sm mb-3">
|
||||||
|
Une question sur le jeu ?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="tel:+33184802034"
|
||||||
|
className="block mt-2 font-medium hover:text-teal-300"
|
||||||
|
>
|
||||||
|
01 84 80 20 34
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="mailto:contact@thetiptop.fr"
|
||||||
|
className="inline-block mt-3 font-medium hover:text-teal-300"
|
||||||
|
>
|
||||||
|
contact@thetiptop.fr
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<p className="text-teal-100 text-sm mt-2">
|
||||||
|
18 rue Léon Frot
|
||||||
|
<br />
|
||||||
|
75011 Paris, France
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barre basse */}
|
||||||
|
<div className="border-t border-teal-800">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-5 flex flex-col md:flex-row justify-between items-center gap-3">
|
||||||
|
|
||||||
|
<p className="text-teal-200 text-sm text-center md:text-left">
|
||||||
|
© {new Date().getFullYear()} Thé Tip Top. Tous droits réservés.
|
||||||
|
<br className="md:hidden" />
|
||||||
|
<span className="md:before:content-['_—_']">
|
||||||
|
Projet étudiant fictif — aucun achat ni réservation réel ne peut être effectué sur ce site.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-5 text-sm text-teal-200">
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://www.facebook.com/profile.php?id=61590449280854"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-white transition"
|
||||||
|
>
|
||||||
|
Facebook
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://www.instagram.com/thetiptop.gp4?igsh=MXZ3YzZ0d3dkazBpeg%3D%3D&utm_source=qr"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-white transition"
|
||||||
|
>
|
||||||
|
Instagram
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://www.tiktok.com/@thetiptop4"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-white transition"
|
||||||
|
>
|
||||||
|
TikTok
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
151
frontend/src/components/Header.jsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import logo from '../assets/img/logo.png'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const isLoggedIn = !!user
|
||||||
|
const logoContent = <img src={logo} alt="Logo de Thé Tip Top" className="h-7 sm:h-8 md:h-9 lg:h-12 w-auto" />
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
setMenuOpen(false)
|
||||||
|
navigate('/connexion')
|
||||||
|
}
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ to: '/accueil', label: 'Accueil' },
|
||||||
|
{ to: '/regles-jeu', label: 'Règles du jeu' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const initials = user
|
||||||
|
? `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase()
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="bg-white shadow-sm relative z-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
|
||||||
|
{pathname === '/accueil' ? (
|
||||||
|
<span className="flex items-center flex-shrink-0">
|
||||||
|
{logoContent}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Link to ={'/accueil'} className='flex items-center flex-shrink-0'>
|
||||||
|
{logoContent}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nav desktop */}
|
||||||
|
<nav className="hidden md:flex items-center gap-4 lg:gap-6">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.to}
|
||||||
|
to={link.to}
|
||||||
|
className={`font-medium whitespace-nowrap transition-colors ${
|
||||||
|
pathname === link.to ? 'text-[#006B6B]' : 'text-gray-700 hover:text-[#006B6B]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link to="/profile" className="flex items-center gap-2 text-sm text-gray-700 hover:text-[#006B6B] font-medium transition-colors">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-xs font-bold">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<span className="whitespace-nowrap">{user.firstname}</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="text-sm text-gray-500 hover:text-red-500 transition-colors font-medium whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link to="/connexion" className="bg-[#006B6B] border border-transparent text-white px-4 py-2 rounded-lg hover:bg-[#005a5a] transition-colors text-sm whitespace-nowrap">
|
||||||
|
Connexion
|
||||||
|
</Link>
|
||||||
|
<Link to="/inscription" className="border border-[#006B6B] text-[#006B6B] px-4 py-2 rounded-lg hover:bg-[#006B6B] hover:text-white transition-colors text-sm whitespace-nowrap">
|
||||||
|
Inscription
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Hamburger mobile */}
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
|
className="md:hidden flex flex-col justify-center items-center w-9 h-9 gap-1.5"
|
||||||
|
aria-label="Ouvrir le menu"
|
||||||
|
>
|
||||||
|
<span className={`block w-6 h-0.5 bg-gray-700 transition-all duration-300 origin-center ${menuOpen ? 'rotate-45 translate-y-2' : ''}`} />
|
||||||
|
<span className={`block w-6 h-0.5 bg-gray-700 transition-all duration-300 ${menuOpen ? 'opacity-0' : ''}`} />
|
||||||
|
<span className={`block w-6 h-0.5 bg-gray-700 transition-all duration-300 origin-center ${menuOpen ? '-rotate-45 -translate-y-2' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Menu mobile déroulant */}
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="md:hidden bg-white border-t border-gray-100 px-4 pb-4">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.to}
|
||||||
|
to={link.to}
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className={`block py-3 font-medium border-b border-gray-50 ${
|
||||||
|
pathname === link.to ? 'text-[#006B6B]' : 'text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 pt-3">
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/mes-gains"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className="w-full text-center bg-[#006B6B] text-white px-4 py-2.5 rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
|
Mon espace ({user.firstname})
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full text-center border border-red-300 text-red-500 px-4 py-2.5 rounded-lg text-sm font-medium hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/connexion"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className="w-full text-center bg-[#006B6B] text-white px-4 py-2.5 rounded-lg hover:bg-[#005a5a] transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
Connexion
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/inscription"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className="w-full text-center border border-[#006B6B] text-[#006B6B] px-4 py-2.5 rounded-lg hover:bg-[#006B6B] hover:text-white transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
Inscription
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
frontend/src/components/Layout.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Header from './Header'
|
||||||
|
import Footer from './Footer'
|
||||||
|
|
||||||
|
export default function Layout({ children }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="flex-1">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
frontend/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
export default function ProtectedRoute({ children, roles }) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/connexion" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roles && !roles.includes(user.role)) {
|
||||||
|
const redirects = { ADMIN: '/admin', EMPLOYEE: '/employe', CLIENT: '/mes-gains' }
|
||||||
|
return <Navigate to={redirects[user.role] || '/connexion'} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
59
frontend/src/components/UserNav.jsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function UserNav({ active }) {
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
key: 'code',
|
||||||
|
to: '/saisir-code-unique',
|
||||||
|
label: 'Saisir un code',
|
||||||
|
shortLabel: 'Code',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gains',
|
||||||
|
to: '/mes-gains',
|
||||||
|
label: 'Mes gains',
|
||||||
|
shortLabel: 'Mes gains',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20 12V22H4V12M22 7H2v5h20V7zM12 22V7M12 7H7.5a2.5 2.5 0 010-5C11 2 12 7 12 7zM12 7h4.5a2.5 2.5 0 000-5C13 2 12 7 12 7z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'profile',
|
||||||
|
to: '/profile',
|
||||||
|
label: 'Profil',
|
||||||
|
shortLabel: 'Profil',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex border-b border-gray-200 mb-6">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Link
|
||||||
|
key={tab.key}
|
||||||
|
to={tab.to}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 py-3 text-xs sm:text-sm font-medium transition-colors border-b-2 ${
|
||||||
|
active === tab.key
|
||||||
|
? 'text-[#006B6B] border-[#006B6B]'
|
||||||
|
: 'text-gray-500 border-transparent hover:text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
<span className="hidden sm:inline">{tab.label}</span>
|
||||||
|
<span className="sm:hidden whitespace-nowrap">{tab.shortLabel}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
frontend/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
|
||||||
|
import { createContext, useContext, useState } from 'react'
|
||||||
|
|
||||||
|
const AuthContext = createContext(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(() =>
|
||||||
|
JSON.parse(localStorage.getItem('user') || 'null')
|
||||||
|
)
|
||||||
|
|
||||||
|
function login(userData, token) {
|
||||||
|
localStorage.setItem('token', token)
|
||||||
|
localStorage.setItem('user', JSON.stringify(userData))
|
||||||
|
setUser(userData)
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext)
|
||||||
|
}
|
||||||
17
frontend/src/hooks/useHandleAuthError.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
export function useHandleAuthError() {
|
||||||
|
const { logout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return useCallback((err) => {
|
||||||
|
if (err.status === 401) {
|
||||||
|
logout()
|
||||||
|
navigate('/connexion')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, [logout, navigate])
|
||||||
|
}
|
||||||
41
frontend/src/pages/Accessibilite.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export default function Accessibilite() {
|
||||||
|
return (
|
||||||
|
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||||
|
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||||
|
|
||||||
|
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
Déclaration d'accessibilité
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-gray-600 mb-8 sm:mb-10 leading-relaxed text-sm sm:text-base">
|
||||||
|
La société Thé Tip Top s'engage à rendre le site de son jeu-concours « 100% Gagnant »
|
||||||
|
accessible conformément à l'article 47 de la loi n° 2005-102 du 11 février 2005.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section className="mb-6 sm:mb-8">
|
||||||
|
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||||
|
État de conformité
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||||
|
Le site a été développé en conformité avec les normes d'accessibilité internationales
|
||||||
|
WCAG 2.1 niveau AA et le Référentiel Général d'Amélioration de l'Accessibilité
|
||||||
|
(RGAA 4.1) afin de garantir une accessibilité maximale aux utilisateurs en situation
|
||||||
|
de handicap.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||||
|
Signaler un problème d'accessibilité
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||||
|
Si vous rencontrez un obstacle à l'accessibilité sur le site, vous pouvez nous en faire
|
||||||
|
part afin que nous puissions y remédier :<br />
|
||||||
|
<strong>Email :</strong> contact@thetiptop.fr
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
frontend/src/pages/Accueil.jsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function Accueil() {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 min-h-screen">
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="max-w-7xl mx-auto px-4 sm:px-6 py-12 sm:py-16 md:py-24">
|
||||||
|
<div className="text-center max-w-3xl mx-auto">
|
||||||
|
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-[#006B6B] mb-4 sm:mb-6 leading-tight">
|
||||||
|
Bienvenue au jeu-concours Thé Tip Top
|
||||||
|
</h1>
|
||||||
|
<p className="text-base sm:text-lg md:text-xl text-gray-600 mb-8 sm:mb-10">
|
||||||
|
À l'occasion de l'ouverture de notre 10ᵉ boutique à Nice, participez à notre jeu-concours
|
||||||
|
et tentez de gagner des lots exceptionnels ! Tous les tickets sont gagnants, alors n'attendez
|
||||||
|
plus pour découvrir votre lot.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
|
||||||
|
<Link
|
||||||
|
to="/saisir-code-unique"
|
||||||
|
className="bg-[#006B6B] hover:bg-[#005555] text-white font-semibold px-6 sm:px-8 py-3 sm:py-4 rounded-xl transition-colors text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Tester mon code
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/regles-jeu"
|
||||||
|
className="border border-[#006B6B] text-[#006B6B] hover:bg-[#005555] hover:text-white font-semibold px-6 sm:px-8 py-3 sm:py-4 rounded-xl transition-colors text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Découvrir les règles
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Comment participer */}
|
||||||
|
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-20">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-6 sm:mb-8 text-center">
|
||||||
|
Comment participer ?
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{[
|
||||||
|
{ num: '1', text: 'Achat de 49 € minimum' },
|
||||||
|
{ num: '2', text: 'Recevez votre code unique' },
|
||||||
|
{ num: '3', text: 'Saisissez le code sur le site' },
|
||||||
|
{ num: '4', text: 'Découvrez votre lot' },
|
||||||
|
].map((step) => (
|
||||||
|
<div key={step.num} className="bg-white rounded-2xl shadow p-4 sm:p-6 text-center">
|
||||||
|
<div className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-2 sm:mb-3">{step.num}</div>
|
||||||
|
<p className="text-sm sm:text-base text-gray-700">{step.text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,71 +1,661 @@
|
|||||||
const MOCK_STATS = {
|
import { useState, useEffect } from 'react'
|
||||||
totalParticipants: 1248,
|
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi } from '../services/admin'
|
||||||
gainsRemis: 342,
|
import { searchTicketApi, deliverClaimApi } from '../services/employee'
|
||||||
gainsEnAttente: 906,
|
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||||
tauxParticipation: '78%',
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOCK_USERS = [
|
function StatCard({ label, value, sub }) {
|
||||||
{ id: 1, name: 'Jean Dupont', email: 'jean.dupont@email.com', gains: 2 },
|
|
||||||
{ id: 2, name: 'Marie Martin', email: 'marie.martin@email.com', gains: 1 },
|
|
||||||
{ id: 3, name: 'Paul Bernard', email: 'paul.bernard@email.com', gains: 3 },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function Admin() {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-6">
|
<div className="bg-white rounded-xl shadow p-4 text-center">
|
||||||
<div className="max-w-4xl mx-auto">
|
<p className="text-2xl sm:text-3xl font-bold text-[#006B6B]">{value ?? '—'}</p>
|
||||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">Tableau de bord Admin</h1>
|
<p className="text-xs sm:text-sm text-gray-500 mt-1">{label}</p>
|
||||||
|
{sub && <p className="text-xs text-gray-500 mt-0.5">{sub}</p>}
|
||||||
{/* Stats */}
|
</div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
)
|
||||||
{[
|
}
|
||||||
{ label: 'Participants', value: MOCK_STATS.totalParticipants },
|
|
||||||
{ label: 'Gains remis', value: MOCK_STATS.gainsRemis },
|
// Les cinq états renvoyés par l'API, chacun avec sa couleur : « programmé »
|
||||||
{ label: 'En attente', value: MOCK_STATS.gainsEnAttente },
|
// ne doit pas être confondu avec « retraits uniquement ».
|
||||||
{ label: 'Taux participation', value: MOCK_STATS.tauxParticipation },
|
const PHASE_BADGES = {
|
||||||
].map((stat) => (
|
NOT_CONFIGURED: { label: 'Non lancé', className: 'bg-amber-100 text-amber-700' },
|
||||||
<div key={stat.label} className="bg-white rounded-xl shadow p-4 text-center">
|
SCHEDULED: { label: 'Programmé', className: 'bg-indigo-100 text-indigo-700' },
|
||||||
<p className="text-2xl font-bold text-green-700">{stat.value}</p>
|
OPEN: { label: 'Participations ouvertes', className: 'bg-green-100 text-green-700' },
|
||||||
<p className="text-sm text-gray-500 mt-1">{stat.label}</p>
|
CLAIM_ONLY: { label: 'Retraits uniquement', className: 'bg-blue-100 text-blue-700' },
|
||||||
</div>
|
CLOSED: { label: 'Terminé', className: 'bg-red-100 text-red-700' },
|
||||||
))}
|
}
|
||||||
</div>
|
|
||||||
|
const PHASE_PANELS = {
|
||||||
{/* Tirage au sort */}
|
NOT_CONFIGURED: 'bg-amber-50 border border-amber-200',
|
||||||
<div className="bg-white rounded-xl shadow p-6 mb-6">
|
SCHEDULED: 'bg-indigo-50 border border-indigo-200',
|
||||||
<h2 className="text-lg font-bold text-gray-800 mb-3">Grand tirage au sort</h2>
|
OPEN: 'bg-white',
|
||||||
<button
|
CLAIM_ONLY: 'bg-white',
|
||||||
onClick={() => alert('TODO: connecter au backend')}
|
CLOSED: 'bg-red-50 border border-red-200',
|
||||||
className="bg-green-700 hover:bg-green-800 text-white font-semibold px-6 py-2.5 rounded-lg transition-colors"
|
}
|
||||||
>
|
|
||||||
Lancer le tirage
|
const PHASE_TITLES = {
|
||||||
</button>
|
NOT_CONFIGURED: 'Jeu-concours non lancé',
|
||||||
</div>
|
SCHEDULED: 'Jeu-concours programmé',
|
||||||
|
OPEN: 'Jeu-concours en cours',
|
||||||
{/* Liste participants */}
|
CLAIM_ONLY: 'Participations closes, retraits en cours',
|
||||||
<div className="bg-white rounded-xl shadow p-6">
|
CLOSED: 'Jeu-concours terminé',
|
||||||
<h2 className="text-lg font-bold text-gray-800 mb-4">Participants</h2>
|
}
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
function formatDate(dateStr) {
|
||||||
<tr className="text-left text-gray-500 border-b">
|
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||||
<th className="pb-2">Nom</th>
|
day: '2-digit', month: 'long', year: 'numeric',
|
||||||
<th className="pb-2">Email</th>
|
hour: '2-digit', minute: '2-digit',
|
||||||
<th className="pb-2 text-right">Gains</th>
|
})
|
||||||
</tr>
|
}
|
||||||
</thead>
|
|
||||||
<tbody>
|
export default function Admin() {
|
||||||
{MOCK_USERS.map((user) => (
|
const handleAuthError = useHandleAuthError()
|
||||||
<tr key={user.id} className="border-b last:border-0">
|
const [stats, setStats] = useState(null)
|
||||||
<td className="py-3 font-medium text-gray-800">{user.name}</td>
|
const [users, setUsers] = useState([])
|
||||||
<td className="py-3 text-gray-500">{user.email}</td>
|
const [winner, setWinner] = useState(undefined)
|
||||||
<td className="py-3 text-right text-green-700 font-semibold">{user.gains}</td>
|
const [drawing, setDrawing] = useState(false)
|
||||||
</tr>
|
const [drawError, setDrawError] = useState('')
|
||||||
))}
|
const [loading, setLoading] = useState(true)
|
||||||
</tbody>
|
const [error, setError] = useState('')
|
||||||
</table>
|
const [showConfirm, setShowConfirm] = useState(false)
|
||||||
</div>
|
|
||||||
</div>
|
// Lancement du jeu-concours
|
||||||
|
const [startDateInput, setStartDateInput] = useState('')
|
||||||
|
const [settingsSaving, setSettingsSaving] = useState(false)
|
||||||
|
const [settingsError, setSettingsError] = useState('')
|
||||||
|
const [settingsOk, setSettingsOk] = useState('')
|
||||||
|
|
||||||
|
// Remise de lot
|
||||||
|
const [ticketCode, setTicketCode] = useState('')
|
||||||
|
const [ticketResult, setTicketResult] = useState(null)
|
||||||
|
const [ticketSearching, setTicketSearching] = useState(false)
|
||||||
|
const [ticketDelivering, setTicketDelivering] = useState(false)
|
||||||
|
const [ticketSearchErr, setTicketSearchErr] = useState('')
|
||||||
|
const [ticketDeliverErr, setTicketDeliverErr] = useState('')
|
||||||
|
const [ticketSuccess, setTicketSuccess] = useState(false)
|
||||||
|
|
||||||
|
// Contacts emailing
|
||||||
|
const [emailingContacts, setEmailingContacts] = useState(null)
|
||||||
|
const [emailingLoading, setEmailingLoading] = useState(false)
|
||||||
|
const [emailingError, setEmailingError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
|
||||||
|
.then(([s, u, w]) => {
|
||||||
|
setStats(s)
|
||||||
|
setUsers(u.users)
|
||||||
|
setWinner(w)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!handleAuthError(e)) setError(e.message)
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [handleAuthError])
|
||||||
|
|
||||||
|
const handleSaveGameSettings = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSettingsError('')
|
||||||
|
setSettingsOk('')
|
||||||
|
setSettingsSaving(true)
|
||||||
|
try {
|
||||||
|
// L'input datetime-local renvoie une heure locale sans fuseau : on la
|
||||||
|
// convertit en ISO pour que le serveur reçoive un instant sans ambiguïté.
|
||||||
|
const data = await setGameSettingsApi(new Date(startDateInput).toISOString())
|
||||||
|
setStats((prev) => (prev ? { ...prev, gameWindow: data.gameWindow } : prev))
|
||||||
|
setSettingsOk(data.message)
|
||||||
|
setStartDateInput('')
|
||||||
|
} catch (err) {
|
||||||
|
if (!handleAuthError(err)) setSettingsError(err.message)
|
||||||
|
} finally {
|
||||||
|
setSettingsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDraw = async () => {
|
||||||
|
setShowConfirm(false)
|
||||||
|
setDrawing(true)
|
||||||
|
setDrawError('')
|
||||||
|
try {
|
||||||
|
const data = await drawWinnerApi()
|
||||||
|
setWinner(data)
|
||||||
|
} catch (e) {
|
||||||
|
if (!handleAuthError(e)) setDrawError(e.message)
|
||||||
|
} finally {
|
||||||
|
setDrawing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadEmailingContacts = async () => {
|
||||||
|
setEmailingLoading(true)
|
||||||
|
setEmailingError('')
|
||||||
|
try {
|
||||||
|
const data = await getEmailingContactsApi()
|
||||||
|
setEmailingContacts(data.contacts)
|
||||||
|
} catch (e) {
|
||||||
|
if (!handleAuthError(e)) setEmailingError(e.message)
|
||||||
|
} finally {
|
||||||
|
setEmailingLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExportEmailingCsv = () => {
|
||||||
|
if (!emailingContacts?.length) return
|
||||||
|
const header = ['Prénom', 'Nom', 'Email', 'Inscrit le']
|
||||||
|
const rows = emailingContacts.map((c) => [
|
||||||
|
c.firstname, c.lastname, c.email, new Date(c.createdAt).toLocaleDateString('fr-FR'),
|
||||||
|
])
|
||||||
|
const csv = [header, ...rows]
|
||||||
|
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(';'))
|
||||||
|
.join('\n')
|
||||||
|
// Le BOM en tete du fichier est indispensable : sans lui, Excel ouvre le
|
||||||
|
// CSV en ANSI et casse tous les accents. Ne pas le supprimer.
|
||||||
|
// eslint-disable-next-line no-irregular-whitespace
|
||||||
|
const blob = new Blob([`${csv}`], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `contacts-emailing-${new Date().toISOString().slice(0, 10)}.csv`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTicketSearch = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setTicketResult(null)
|
||||||
|
setTicketSearchErr('')
|
||||||
|
setTicketDeliverErr('')
|
||||||
|
setTicketSuccess(false)
|
||||||
|
setTicketSearching(true)
|
||||||
|
try {
|
||||||
|
const data = await searchTicketApi(ticketCode.trim())
|
||||||
|
setTicketResult(data)
|
||||||
|
} catch (err) {
|
||||||
|
if (!handleAuthError(err)) setTicketSearchErr(err.message)
|
||||||
|
} finally {
|
||||||
|
setTicketSearching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTicketDeliver = async () => {
|
||||||
|
if (!ticketResult?.claim?.id) return
|
||||||
|
setTicketDeliverErr('')
|
||||||
|
setTicketDelivering(true)
|
||||||
|
try {
|
||||||
|
await deliverClaimApi(ticketResult.claim.id)
|
||||||
|
setTicketResult((prev) => ({
|
||||||
|
...prev,
|
||||||
|
claim: { ...prev.claim, status: 'DELIVERED', claimedAt: new Date().toISOString() },
|
||||||
|
}))
|
||||||
|
setTicketSuccess(true)
|
||||||
|
setStats((prev) => prev ? {
|
||||||
|
...prev,
|
||||||
|
claimsPending: Math.max(0, (prev.claimsPending ?? 1) - 1),
|
||||||
|
claimsDelivered: (prev.claimsDelivered ?? 0) + 1,
|
||||||
|
} : prev)
|
||||||
|
} catch (err) {
|
||||||
|
if (!handleAuthError(err)) setTicketDeliverErr(err.message)
|
||||||
|
} finally {
|
||||||
|
setTicketDelivering(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<p className="text-gray-500">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||||
|
<p className="text-red-500 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const winnerUser = winner?.winner?.user
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
|
||||||
|
<div className="max-w-5xl mx-auto space-y-6">
|
||||||
|
|
||||||
|
<h1 className="text-xl sm:text-2xl font-bold text-gray-800">Tableau de bord Admin</h1>
|
||||||
|
|
||||||
|
{/* Fenêtre du jeu-concours */}
|
||||||
|
{stats?.gameWindow && (
|
||||||
|
<div className={`rounded-xl shadow p-4 sm:p-6 ${PHASE_PANELS[stats.gameWindow.phase] ?? 'bg-white'}`}>
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-800">
|
||||||
|
{PHASE_TITLES[stats.gameWindow.phase] ?? 'Jeu-concours'}
|
||||||
|
</p>
|
||||||
|
{stats.gameWindow.configured ? (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Début : {formatDate(stats.gameWindow.startDate)}
|
||||||
|
<br />
|
||||||
|
Fin des participations : {formatDate(stats.gameWindow.participationDeadline)}
|
||||||
|
<br />
|
||||||
|
Fin des retraits de lots : {formatDate(stats.gameWindow.claimDeadline)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-600 mt-1">
|
||||||
|
Choisissez une date de début pour ouvrir le jeu-concours.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${PHASE_BADGES[stats.gameWindow.phase]?.className ?? 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
{PHASE_BADGES[stats.gameWindow.phase]?.label ?? 'État inconnu'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveGameSettings} className="mt-4 pt-4 border-t border-gray-200 flex flex-wrap items-end gap-3">
|
||||||
|
<div className="flex-1 min-w-[220px]">
|
||||||
|
<label htmlFor="game-start" className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
{stats.gameWindow.configured ? 'Corriger la date de début' : 'Date de début du jeu-concours'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
id="game-start"
|
||||||
|
value={startDateInput}
|
||||||
|
onChange={(e) => setStartDateInput(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={settingsSaving || !startDateInput}
|
||||||
|
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
{settingsSaving ? 'Enregistrement…' : stats.gameWindow.configured ? 'Mettre à jour' : 'Lancer le jeu'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{settingsError && <p className="text-red-500 text-xs mt-2">{settingsError}</p>}
|
||||||
|
{settingsOk && <p className="text-green-600 text-xs mt-2">{settingsOk}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats principales */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
|
||||||
|
<StatCard label="Participants" value={stats?.users} />
|
||||||
|
<StatCard label="Participations" value={stats?.participations} />
|
||||||
|
<StatCard label="Gains en attente" value={stats?.claimsPending} />
|
||||||
|
<StatCard label="Gains remis" value={stats?.claimsDelivered} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats tickets */}
|
||||||
|
<div className="grid grid-cols-3 gap-3 sm:gap-4">
|
||||||
|
<StatCard label="Tickets total" value={stats?.ticketsTotal} />
|
||||||
|
<StatCard label="Tickets utilisés" value={stats?.ticketsUsed} />
|
||||||
|
<StatCard label="Tickets restants" value={stats?.ticketsRemaining} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats démographiques */}
|
||||||
|
{stats?.genderStats && stats?.ageStats && (
|
||||||
|
<div className="grid sm:grid-cols-2 gap-3 sm:gap-4">
|
||||||
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||||
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Genre des participants</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ label: 'Femmes', value: stats.genderStats.women },
|
||||||
|
{ label: 'Hommes', value: stats.genderStats.men },
|
||||||
|
{ label: 'Autre / non renseigné', value: stats.genderStats.other },
|
||||||
|
].map((row) => (
|
||||||
|
<div key={row.label} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500">{row.label}</span>
|
||||||
|
<span className="font-semibold text-gray-800">{row.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||||
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Âge des participants</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{['-18', '18-24', '25-34', '35-44', '45-54', '55-64', '65+', 'unknown'].map((bracket) => (
|
||||||
|
<div key={bracket} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500">{bracket === 'unknown' ? 'Non renseigné' : `${bracket} ans`}</span>
|
||||||
|
<span className="font-semibold text-gray-800">{stats.ageStats[bracket] ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Répartition par lot */}
|
||||||
|
{stats?.lotsStats?.length > 0 && (
|
||||||
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||||
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Répartition des lots</h2>
|
||||||
|
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||||
|
<table className="w-full text-sm min-w-[480px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Lot</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0 text-center">%</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0 text-center">Tickets</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0 text-center">Participations</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.lotsStats.map((lot) => (
|
||||||
|
<tr key={lot.id} className="border-b last:border-0">
|
||||||
|
<td className="py-2.5 px-4 sm:px-0 font-medium text-gray-800 text-xs sm:text-sm">{lot.name}</td>
|
||||||
|
<td className="py-2.5 px-4 sm:px-0 text-center text-gray-500 text-xs sm:text-sm">{lot.percentage}%</td>
|
||||||
|
<td className="py-2.5 px-4 sm:px-0 text-center text-gray-600 text-xs sm:text-sm">{lot._count.tickets.toLocaleString('fr-FR')}</td>
|
||||||
|
<td className="py-2.5 px-4 sm:px-0 text-center text-[#006B6B] font-semibold text-xs sm:text-sm">{lot._count.participations}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Contacts emailing ── */}
|
||||||
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||||
|
<h2 className="text-base sm:text-lg font-bold text-gray-800">Contacts emailing</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleLoadEmailingContacts}
|
||||||
|
disabled={emailingLoading}
|
||||||
|
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
{emailingLoading ? 'Chargement…' : emailingContacts ? 'Actualiser' : 'Charger les contacts'}
|
||||||
|
</button>
|
||||||
|
{emailingContacts?.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleExportEmailingCsv}
|
||||||
|
className="border border-gray-200 text-gray-600 hover:bg-gray-50 font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
Exporter en CSV
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{emailingError && <p className="text-red-500 text-sm mb-3">{emailingError}</p>}
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500 mb-3">
|
||||||
|
Clients ayant accepté d'être contactés à des fins marketing.
|
||||||
|
{emailingContacts && <> — <span className="font-semibold text-gray-800">{emailingContacts.length}</span> contact(s)</>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{emailingContacts && (
|
||||||
|
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||||
|
<table className="w-full text-sm min-w-[400px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Nom</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Email</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0 text-right">Inscrit le</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{emailingContacts.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="py-6 text-center text-gray-500 text-sm">
|
||||||
|
Aucun contact n'a accepté l'emailing pour le moment
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{emailingContacts.map((contact) => (
|
||||||
|
<tr key={contact.email} className="border-b last:border-0">
|
||||||
|
<td className="py-3 px-4 sm:px-0 font-medium text-gray-800 whitespace-nowrap text-xs sm:text-sm">
|
||||||
|
{contact.firstname} {contact.lastname}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">{contact.email}</td>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-right text-gray-500 text-xs">
|
||||||
|
{new Date(contact.createdAt).toLocaleDateString('fr-FR')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Remise de lot ── */}
|
||||||
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||||
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Remise de lot</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleTicketSearch} className="flex gap-2 mb-4">
|
||||||
|
<label htmlFor="admin-ticket-code" className="sr-only">Code ticket</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="admin-ticket-code"
|
||||||
|
value={ticketCode}
|
||||||
|
onChange={(e) => setTicketCode(e.target.value.toUpperCase())}
|
||||||
|
placeholder="Code ticket (10 caractères)"
|
||||||
|
maxLength={10}
|
||||||
|
className="flex-1 min-w-0 border border-gray-200 rounded-lg px-4 py-2.5 text-sm font-mono uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={ticketSearching || ticketCode.trim().length === 0}
|
||||||
|
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2.5 rounded-lg transition-colors text-sm whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{ticketSearching ? 'Recherche…' : 'Rechercher'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{ticketSearchErr && (
|
||||||
|
<p className="text-red-500 text-sm mb-3">{ticketSearchErr}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ticketResult && (
|
||||||
|
<div className="border border-gray-100 rounded-xl overflow-hidden">
|
||||||
|
{ticketSuccess && (
|
||||||
|
<div className="bg-green-500 px-4 py-2.5 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-white flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-white text-sm font-medium">Lot remis avec succès.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="px-4 py-3 flex items-start justify-between gap-3 border-b border-gray-100">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900 text-sm">{ticketResult.lot.name}</p>
|
||||||
|
<p className="text-gray-500 text-xs mt-0.5">{ticketResult.lot.description}</p>
|
||||||
|
{ticketResult.lot.value && <p className="text-[#006B6B] text-xs font-semibold mt-1">{ticketResult.lot.value} €</p>}
|
||||||
|
</div>
|
||||||
|
<span className={`flex-shrink-0 text-xs font-semibold px-2.5 py-1 rounded-full ${
|
||||||
|
ticketResult.claim?.status === 'DELIVERED' ? 'bg-green-100 text-green-700'
|
||||||
|
: ticketResult.claim?.status === 'CANCELLED' ? 'bg-gray-100 text-gray-500'
|
||||||
|
: 'bg-yellow-100 text-yellow-700'
|
||||||
|
}`}>
|
||||||
|
{ticketResult.claim?.status === 'DELIVERED' ? 'Remis'
|
||||||
|
: ticketResult.claim?.status === 'CANCELLED' ? 'Expiré'
|
||||||
|
: 'À remettre'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 flex items-center gap-3 border-b border-gray-100">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||||
|
{(ticketResult.client.firstname?.[0] ?? '?').toUpperCase()}{(ticketResult.client.lastname?.[0] ?? '?').toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-800">{ticketResult.client.firstname} {ticketResult.client.lastname}</p>
|
||||||
|
<p className="text-xs text-gray-500">{ticketResult.client.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 space-y-2">
|
||||||
|
{ticketDeliverErr && <p className="text-red-500 text-xs">{ticketDeliverErr}</p>}
|
||||||
|
|
||||||
|
{ticketResult.claim?.status === 'PENDING' && !ticketSuccess && (
|
||||||
|
<button
|
||||||
|
onClick={handleTicketDeliver}
|
||||||
|
disabled={ticketDelivering}
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold py-2.5 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
{ticketDelivering ? 'Confirmation…' : 'Confirmer la remise du lot'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => { setTicketCode(''); setTicketResult(null); setTicketSuccess(false); setTicketSearchErr(''); setTicketDeliverErr('') }}
|
||||||
|
className="w-full border border-gray-200 text-gray-500 hover:bg-gray-50 py-2 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Rechercher un autre code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Tirage au sort ── */}
|
||||||
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||||
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Grand tirage au sort</h2>
|
||||||
|
|
||||||
|
{winnerUser ? (
|
||||||
|
/* Gagnant déjà tiré */
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4 p-4 bg-[#006B6B]/5 rounded-xl border border-[#006B6B]/20">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-lg font-bold flex-shrink-0">
|
||||||
|
{(winnerUser.firstname?.[0] ?? '?').toUpperCase()}{(winnerUser.lastname?.[0] ?? '?').toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs text-[#006B6B] font-semibold uppercase tracking-wider mb-0.5">Gagnant du grand tirage</p>
|
||||||
|
<p className="font-bold text-gray-900 text-base">{winnerUser.firstname} {winnerUser.lastname}</p>
|
||||||
|
<p className="text-gray-500 text-sm truncate">{winnerUser.email}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="w-8 h-8 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{winner?.winner?.createdAt && (
|
||||||
|
<p className="text-xs text-gray-500 text-right">
|
||||||
|
Tirage effectué le {formatDate(winner.winner.createdAt)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{/* Signalé uniquement au retour du tirage : le GET du gagnant ne
|
||||||
|
renvoie pas emailSent, et l'absence de la clé ne vaut pas échec. */}
|
||||||
|
{winner?.emailSent === false && (
|
||||||
|
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||||
|
L'email de notification n'a pas pu être envoyé au gagnant. Merci de le contacter directement à l'adresse ci-dessus.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Pas encore de tirage */
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="p-4 bg-gray-50 rounded-xl border border-gray-200">
|
||||||
|
<p className="text-sm text-gray-600 mb-1">
|
||||||
|
<span className="font-semibold text-gray-800">{stats?.participations ?? 0}</span> participations enregistrées
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
<span className="font-semibold text-gray-800">{stats?.users ?? 0}</span> participants éligibles
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{drawError && (
|
||||||
|
<p className="text-red-500 text-sm px-1">{drawError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm(true)}
|
||||||
|
disabled={drawing || (stats?.users ?? 0) === 0}
|
||||||
|
className="flex items-center gap-2 bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-5 py-3 rounded-xl transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
||||||
|
</svg>
|
||||||
|
{drawing ? 'Tirage en cours…' : 'Lancer le tirage au sort'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste participants */}
|
||||||
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||||
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">
|
||||||
|
Participants <span className="text-gray-500 font-normal text-sm">({users.length})</span>
|
||||||
|
</h2>
|
||||||
|
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||||
|
<table className="w-full text-sm min-w-[400px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Nom</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Email</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0 text-right">Participations</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="py-6 text-center text-gray-500 text-sm">
|
||||||
|
Aucun participant pour le moment
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.id} className="border-b last:border-0">
|
||||||
|
<td className="py-3 px-4 sm:px-0 font-medium text-gray-800 whitespace-nowrap text-xs sm:text-sm">
|
||||||
|
{user.firstname} {user.lastname}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">{user.email}</td>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-right text-[#006B6B] font-semibold text-xs sm:text-sm">
|
||||||
|
{user._count.participations}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Modal de confirmation du tirage ── */}
|
||||||
|
{showConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 px-4">
|
||||||
|
<div className="bg-white rounded-2xl w-full max-w-sm shadow-xl p-6">
|
||||||
|
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-yellow-100 flex items-center justify-center">
|
||||||
|
<svg className="w-7 h-7 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 text-center mb-1">Lancer le tirage au sort ?</h3>
|
||||||
|
<p className="text-sm text-gray-500 text-center mb-4">
|
||||||
|
Un gagnant sera sélectionné aléatoirement parmi les <strong>{stats?.users}</strong> participants.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 mb-5 flex gap-2 items-start">
|
||||||
|
<svg className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-xs text-red-600">Cette action est <strong>irréversible</strong>. Le tirage ne peut être effectué qu'une seule fois.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm(false)}
|
||||||
|
className="flex-1 border border-gray-200 text-gray-600 hover:bg-gray-50 font-medium py-2.5 rounded-xl transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDraw}
|
||||||
|
className="flex-1 bg-[#006B6B] hover:bg-[#005959] text-white font-semibold py-2.5 rounded-xl transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Confirmer le tirage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
76
frontend/src/pages/AuthCallback.jsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
// Point d'arrivée après un aller-retour OAuth : le backend nous renvoie ici
|
||||||
|
// avec le JWT et le profil encodés dans l'URL.
|
||||||
|
export default function AuthCallback() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const { login } = useAuth()
|
||||||
|
|
||||||
|
const errorParam = searchParams.get('error')
|
||||||
|
const [error, setError] = useState(errorParam || '')
|
||||||
|
|
||||||
|
// StrictMode monte deux fois en dev : on ne traite le token qu'une seule fois.
|
||||||
|
const handled = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (handled.current) return
|
||||||
|
handled.current = true
|
||||||
|
|
||||||
|
if (errorParam) return
|
||||||
|
|
||||||
|
const token = searchParams.get('token')
|
||||||
|
const encodedUser = searchParams.get('user')
|
||||||
|
|
||||||
|
if (!token || !encodedUser) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setError('Réponse de connexion incomplète.')
|
||||||
|
}, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(atob(encodedUser))
|
||||||
|
login(user, token)
|
||||||
|
|
||||||
|
if (user.role === 'ADMIN') {
|
||||||
|
navigate('/admin', { replace: true })
|
||||||
|
} else if (user.role === 'EMPLOYEE') {
|
||||||
|
navigate('/employe', { replace: true })
|
||||||
|
} else {
|
||||||
|
navigate('/mes-gains', { replace: true })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setTimeout(() => {
|
||||||
|
setError('Réponse de connexion illisible.')
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}, [searchParams, errorParam, login, navigate])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex flex-col justify-center items-center px-8 max-w-sm mx-auto text-center">
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/connexion"
|
||||||
|
className="text-sm text-[#006B6B] font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Retour à la connexion
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex flex-col justify-center items-center px-8 max-w-sm mx-auto text-center">
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
Connexion en cours…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
244
frontend/src/pages/Cgu.jsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
export default function Cgu() {
|
||||||
|
return (
|
||||||
|
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||||
|
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||||
|
|
||||||
|
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
Conditions générales d'utilisation
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mb-6 sm:mb-8 px-4 py-3 sm:px-5 sm:py-4 rounded-xl bg-[#006B6B]">
|
||||||
|
<p className="text-white text-sm sm:text-base font-medium leading-relaxed">
|
||||||
|
Projet étudiant fictif — Ce site a été conçu dans un cadre pédagogique. La société
|
||||||
|
Thé Tip Top, l'agence Furious Ducks et l'ensemble des conditions présentées
|
||||||
|
ci-dessous sont fictives. Aucun achat, paiement ou réservation réel ne peut être
|
||||||
|
effectué sur ce site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-600 mb-8 sm:mb-10 text-sm sm:text-base">
|
||||||
|
Les présentes Conditions Générales d'Utilisation (CGU) précisent les modalités d'accès et
|
||||||
|
d'utilisation du site internet du jeu-concours « 100% Gagnant » de la société Thé Tip Top.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
title: 'Article 1 – Objet des CGU',
|
||||||
|
content: (
|
||||||
|
<div className="text-gray-700 space-y-2 text-sm sm:text-base leading-relaxed">
|
||||||
|
<p>
|
||||||
|
Les présentes Conditions Générales d'Utilisation (CGU) ont pour objet de définir
|
||||||
|
les modalités et conditions dans lesquelles la société Thé Tip Top met à
|
||||||
|
disposition des utilisateurs le site internet du jeu-concours « 100% Gagnant »,
|
||||||
|
accessible à l'adresse https://www.thetiptop-concours.fr.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Toute utilisation du Site implique l'acceptation pleine et entière des présentes
|
||||||
|
CGU. En cas de non-acceptation, l'utilisateur est invité à ne pas accéder au Site
|
||||||
|
ni à utiliser ses services.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Article 2 – Conditions d\'accès au Site',
|
||||||
|
content: (
|
||||||
|
<div className="text-gray-700 space-y-2 text-sm sm:text-base leading-relaxed">
|
||||||
|
<p><strong>Conditions d'âge —</strong> La participation au jeu-concours est strictement
|
||||||
|
réservée aux personnes physiques majeures, c'est-à-dire âgées d'au moins 18 ans
|
||||||
|
révolus à la date de leur inscription. Toute inscription d'un mineur est nulle et
|
||||||
|
non avenue. La société Thé Tip Top se réserve le droit de demander la fourniture
|
||||||
|
d'un justificatif d'identité pour vérifier l'âge déclaré.
|
||||||
|
</p>
|
||||||
|
<p><strong>Conditions géographiques —</strong> Le jeu-concours « 100% Gagnant » est
|
||||||
|
ouvert exclusivement aux résidents de France métropolitaine et des Départements et
|
||||||
|
Régions d'Outre-Mer (DROM). Sont exclus les ressortissants ou résidents de toute
|
||||||
|
autre zone géographique.
|
||||||
|
</p>
|
||||||
|
<p><strong>Personnes exclues —</strong> Sont exclus de toute participation les membres
|
||||||
|
du personnel de la société Thé Tip Top et de l'agence Furious Ducks, leurs
|
||||||
|
sous-traitants, ainsi que les membres de leur famille en ligne directe (conjoints,
|
||||||
|
ascendants, descendants). Est également exclu le personnel de l'étude du
|
||||||
|
Commissaire de Justice dépositaire du règlement.
|
||||||
|
</p>
|
||||||
|
<p><strong>Conditions techniques —</strong> L'accès au Site nécessite une connexion
|
||||||
|
internet active et un navigateur web à jour. La société Thé Tip Top ne saurait
|
||||||
|
être tenue responsable des difficultés d'accès liées à l'environnement technique
|
||||||
|
de l'utilisateur.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Article 3 – Services proposés et fonctionnalités',
|
||||||
|
content: (
|
||||||
|
<div className="text-gray-700 space-y-4 text-sm sm:text-base leading-relaxed">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-1">3.1 – Services proposés</h3>
|
||||||
|
<p>
|
||||||
|
Le site du jeu-concours « 100% Gagnant » est un site internet dédié permettant
|
||||||
|
aux clients de la société Thé Tip Top de participer à un jeu-concours à
|
||||||
|
l'occasion de l'ouverture de la 10ᵉ boutique de l'enseigne à Nice. Les services
|
||||||
|
proposés incluent :
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2 mt-2">
|
||||||
|
<li>La présentation du jeu-concours, de ses conditions et des lots à gagner</li>
|
||||||
|
<li>La création d'un espace personnel pour chaque participant</li>
|
||||||
|
<li>La saisie et la validation des codes à 10 caractères figurant sur les tickets de caisse ou factures d'un montant supérieur à 49 €</li>
|
||||||
|
<li>La consultation de l'historique des participations et gains obtenus</li>
|
||||||
|
<li>La réclamation des lots gagnés, en ligne ou en boutique</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-1">3.2 – Fonctionnalités principales</h3>
|
||||||
|
<p><strong>S'inscrire —</strong> L'utilisateur peut créer un compte personnel en
|
||||||
|
renseignant un formulaire d'inscription classique ou en utilisant les
|
||||||
|
fonctionnalités d'authentification via Google. La création d'un compte est
|
||||||
|
obligatoire pour participer au jeu-concours.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2"><strong>Jouer (participer au jeu-concours) —</strong> Une fois
|
||||||
|
connecté, l'utilisateur saisit le code à 10 caractères figurant sur son ticket de
|
||||||
|
caisse ou sa facture. Le Site valide le code et attribue immédiatement le lot
|
||||||
|
correspondant selon la répartition préétablie :
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 mt-2">
|
||||||
|
<li>60 % des tickets : infuseur à thé</li>
|
||||||
|
<li>20 % des tickets : boîte de 100 g de thé détox ou d'infusion</li>
|
||||||
|
<li>10 % des tickets : boîte de 100 g de thé signature</li>
|
||||||
|
<li>6 % des tickets : coffret découverte d'une valeur de 39 €</li>
|
||||||
|
<li>4 % des tickets : coffret découverte d'une valeur de 69 €</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-2">
|
||||||
|
Un même utilisateur peut soumettre plusieurs codes distincts. La participation
|
||||||
|
est possible pendant toute la durée du jeu-concours, soit 30 jours à compter de
|
||||||
|
son ouverture. Les lots gagnés peuvent être réclamés pendant cette période ainsi
|
||||||
|
que dans les 30 jours calendaires suivant la date de clôture du jeu-concours.
|
||||||
|
Passé ce délai, les lots non réclamés sont définitivement perdus.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2"><strong>Remise des lots —</strong> Les lots peuvent être réclamés
|
||||||
|
en boutique, sur présentation du compte utilisateur validé et de la confirmation
|
||||||
|
de gain.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
À l'issue du jeu-concours, un tirage au sort est effectué parmi l'ensemble des
|
||||||
|
participants pour désigner le gagnant d'un an de thé d'une valeur de 360 €. Ce
|
||||||
|
tirage a lieu sous le contrôle de Maître Arnaud Rick, Commissaire de Justice. Le
|
||||||
|
nombre de participations n'augmente pas les chances de remporter ce lot.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Article 4 – Conditions d\'inscription et de création de compte',
|
||||||
|
content: (
|
||||||
|
<div className="text-gray-700 space-y-4 text-sm sm:text-base leading-relaxed">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-1">4.1 – Processus d'inscription</h3>
|
||||||
|
<p>
|
||||||
|
Pour accéder aux services du Site, l'utilisateur doit créer un compte personnel.
|
||||||
|
L'inscription peut s'effectuer de deux manières :
|
||||||
|
</p>
|
||||||
|
<p className="mt-2"><strong>Via le formulaire classique —</strong> L'utilisateur
|
||||||
|
renseigne les champs obligatoires suivants : nom, prénom, adresse e-mail valide,
|
||||||
|
mot de passe sécurisé. L'utilisateur reçoit ensuite un e-mail de confirmation
|
||||||
|
contenant un lien d'activation. Le compte n'est activé qu'après validation de ce
|
||||||
|
lien.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2"><strong>Via l'authentification Google —</strong> L'utilisateur
|
||||||
|
peut s'inscrire en utilisant son compte Google. Dans ce cas, les données
|
||||||
|
transmises par la plateforme tierce (nom, prénom, adresse e-mail) sont récupérées
|
||||||
|
avec le consentement explicite de l'utilisateur.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-1">4.2 – Unicité du compte</h3>
|
||||||
|
<p>
|
||||||
|
Chaque utilisateur ne peut posséder qu'un seul compte. Toute tentative de
|
||||||
|
création de comptes multiples par un même utilisateur est prohibée et peut
|
||||||
|
entraîner la disqualification de l'ensemble des participations concernées.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-1">4.3 – Véracité des informations</h3>
|
||||||
|
<p>
|
||||||
|
L'utilisateur s'engage à fournir des informations exactes, complètes et à jour
|
||||||
|
lors de son inscription et tout au long de l'utilisation du Site. Toute
|
||||||
|
inscription effectuée avec une fausse identité ou des données inexactes sera
|
||||||
|
annulée, ainsi que les participations et gains associés.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Article 5 – Responsabilité de l\'utilisateur',
|
||||||
|
content: (
|
||||||
|
<div className="text-gray-700 space-y-3 text-sm sm:text-base leading-relaxed">
|
||||||
|
<p>
|
||||||
|
L'utilisateur est seul responsable des informations communiquées lors de la
|
||||||
|
création et de l'utilisation de son compte. À ce titre, il s'engage à :
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2">
|
||||||
|
<li>Fournir des données personnelles exactes, sincères et à jour</li>
|
||||||
|
<li>Ne pas usurper l'identité d'un tiers</li>
|
||||||
|
<li>Ne pas créer de compte au nom d'une autre personne sans son autorisation</li>
|
||||||
|
<li>Signaler sans délai à la société Thé Tip Top toute erreur, inexactitude ou modification de ses informations personnelles via les fonctionnalités de gestion du compte</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
En cas de préjudice subi par la société Thé Tip Top ou par des tiers en raison de
|
||||||
|
fausses informations fournies par l'utilisateur, ce dernier en assume l'entière
|
||||||
|
responsabilité et peut se voir réclamer réparation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Article 6 – Gestion du compte utilisateur',
|
||||||
|
content: (
|
||||||
|
<div className="text-gray-700 space-y-4 text-sm sm:text-base leading-relaxed">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-1">6.1 – Modification des informations</h3>
|
||||||
|
<p>
|
||||||
|
L'utilisateur peut à tout moment accéder à son espace personnel et modifier ses
|
||||||
|
informations (nom, prénom, mot de passe).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-1">6.2 – Suppression du compte</h3>
|
||||||
|
<p>
|
||||||
|
L'utilisateur peut demander la suppression de son compte à tout moment en
|
||||||
|
contactant directement l'équipe via contact@thetiptop.fr. La suppression entraîne
|
||||||
|
la perte définitive des participations et gains non encore réclamés. Les données
|
||||||
|
personnelles sont supprimées conformément aux dispositions de la Politique de
|
||||||
|
Confidentialité, sous réserve des obligations légales de conservation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-1">6.3 – Sécurité des identifiants</h3>
|
||||||
|
<p>
|
||||||
|
L'utilisateur est seul responsable de la confidentialité de ses identifiants de
|
||||||
|
connexion (adresse e-mail et mot de passe). Il s'engage à ne les communiquer à
|
||||||
|
personne et à prendre toutes les précautions nécessaires pour en assurer la
|
||||||
|
protection. En cas de perte, de vol ou d'utilisation frauduleuse de ses
|
||||||
|
identifiants, l'utilisateur doit en informer immédiatement la société Thé Tip
|
||||||
|
Top. La société Thé Tip Top ne saurait être tenue responsable des dommages
|
||||||
|
résultant de l'utilisation des identifiants par un tiers, sauf faute de sa part.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
].map((article) => (
|
||||||
|
<section key={article.title} className="mb-6 sm:mb-8">
|
||||||
|
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||||
|
{article.title}
|
||||||
|
</h2>
|
||||||
|
{article.content}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
99
frontend/src/pages/Confidentialite.jsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
export default function Confidentialite() {
|
||||||
|
return (
|
||||||
|
<main className="bg-[#F0F0F0] min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||||
|
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||||
|
|
||||||
|
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
Politique de confidentialité
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-gray-600 mb-8 sm:mb-10 leading-relaxed text-sm sm:text-base">
|
||||||
|
Thé Tip Top accorde une grande importance à la protection de vos données personnelles.
|
||||||
|
Cette politique explique quelles informations sont collectées, comment elles sont utilisées
|
||||||
|
et quels sont vos droits.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section className="mb-6 sm:mb-8">
|
||||||
|
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||||
|
Article 1 – Responsable du traitement
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||||
|
Le responsable du traitement des données collectées via le site du jeu-concours
|
||||||
|
« 100% Gagnant » est la société Thé Tip Top, située au 18 rue Léon Frot, 75011 Paris.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-6 sm:mb-8">
|
||||||
|
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||||
|
Article 2 – Données collectées
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700 mb-3 text-sm sm:text-base">Dans le cadre de votre participation, nous pouvons collecter :</p>
|
||||||
|
<ul className="list-disc list-inside text-gray-700 space-y-2 text-sm sm:text-base">
|
||||||
|
<li>Nom et prénom</li>
|
||||||
|
<li>Adresse email</li>
|
||||||
|
<li>Informations du compte utilisateur</li>
|
||||||
|
<li>Code de participation au jeu</li>
|
||||||
|
<li>Informations liées aux gains obtenus</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-6 sm:mb-8">
|
||||||
|
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||||
|
Article 3 – Utilisation des données
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||||
|
Les données collectées sont utilisées uniquement pour : gérer votre participation au
|
||||||
|
jeu-concours, vérifier les codes, attribuer les lots, vous contacter en cas de gain
|
||||||
|
et améliorer l'expérience utilisateur.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-6 sm:mb-8">
|
||||||
|
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||||
|
Article 4 – Conservation des données
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||||
|
Vos données sont conservées uniquement pendant la durée nécessaire au fonctionnement
|
||||||
|
du jeu-concours et au respect des obligations légales de Thé Tip Top.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-6 sm:mb-8">
|
||||||
|
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||||
|
Article 5 – Cookies
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||||
|
Le site utilise des cookies nécessaires au fonctionnement ainsi que des cookies
|
||||||
|
statistiques permettant d'améliorer les services proposés. Vous pouvez gérer vos
|
||||||
|
préférences à tout moment.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-6 sm:mb-8">
|
||||||
|
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||||
|
Article 6 – Vos droits
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700 mb-3 text-sm sm:text-base">Conformément au RGPD, vous disposez des droits suivants :</p>
|
||||||
|
<ul className="list-disc list-inside text-gray-700 space-y-2 text-sm sm:text-base">
|
||||||
|
<li>Droit d'accès à vos données</li>
|
||||||
|
<li>Droit de rectification</li>
|
||||||
|
<li>Droit de suppression</li>
|
||||||
|
<li>Droit d'opposition</li>
|
||||||
|
<li>Droit à la limitation du traitement</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||||
|
Article 7 – Contact
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700 text-sm sm:text-base">
|
||||||
|
Pour toute question concernant vos données personnelles :<br />
|
||||||
|
<strong>Email :</strong> contact@thetiptop.fr
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,73 +1,167 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { loginApi } from '../services/auth'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
export default function Connexion() {
|
export default function Connexion() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const { login } = useAuth()
|
||||||
|
const justRegistered = location.state?.registered === true
|
||||||
const [form, setForm] = useState({ email: '', password: '' })
|
const [form, setForm] = useState({ email: '', password: '' })
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
// Navigation pleine page (et non fetch) : le flux OAuth passe par des
|
||||||
|
// redirections vers Google puis retour sur /auth/callback.
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||||
|
|
||||||
|
const handleGoogleLogin = () => {
|
||||||
|
window.location.href = `${API_URL}/api/auth/google`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
// TODO: connecter au backend
|
setError('')
|
||||||
alert(`Connexion avec ${form.email}`)
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const { token, user } = await loginApi(form)
|
||||||
|
login(user, token)
|
||||||
|
if (user.role === 'ADMIN') navigate('/admin')
|
||||||
|
else if (user.role === 'EMPLOYEE') navigate('/employe')
|
||||||
|
else navigate('/mes-gains')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-green-50 flex items-center justify-center p-4">
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
||||||
<div className="bg-white rounded-2xl shadow-lg w-full max-w-md p-8">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-green-800">Thé Tip Top</h1>
|
|
||||||
<p className="text-gray-500 mt-1">Con
|
|
||||||
|
|
||||||
nexion à votre espace</p>
|
<div className="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Se connecter</h1>
|
||||||
|
<p className="text-gray-500 mt-1 text-sm">Connexion à votre espace</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{justRegistered && (
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm">
|
||||||
|
Compte créé avec succès ! Connectez-vous.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-800 mb-1">
|
||||||
|
Adresse email <span className="text-gray-800">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
placeholder="Votre email"
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<div>
|
||||||
<div>
|
<label htmlFor="password" className="block text-sm font-medium text-gray-800 mb-1">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
Mot de passe <span className="text-gray-800">*</span>
|
||||||
Email
|
</label>
|
||||||
</label>
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="email"
|
type={showPassword ? 'text' : 'password'}
|
||||||
name="email"
|
id="password"
|
||||||
value={form.email}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
placeholder="exemple@email.com"
|
|
||||||
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Mot de passe
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="password"
|
name="password"
|
||||||
value={form.password}
|
value={form.password}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
placeholder="••••••••"
|
placeholder="Votre mot de passe"
|
||||||
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600"
|
||||||
|
aria-label="Afficher/masquer le mot de passe"
|
||||||
|
>
|
||||||
|
<div className="w-6 h-6 rounded-full border-2 border-gray-400 flex items-center justify-center">
|
||||||
|
{showPassword && (
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-right mt-1">
|
||||||
|
<button type="button" className="text-xs text-gray-500 hover:text-[#006B6B] hover:underline">
|
||||||
|
Mot de passe oublié ?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-green-700 hover:bg-green-800 text-white font-semibold py-2.5 rounded-lg transition-colors"
|
disabled={loading}
|
||||||
>
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
||||||
Se connecter
|
>
|
||||||
</button>
|
{loading ? 'Connexion...' : 'Se connecter'}
|
||||||
</form>
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-sm text-gray-500 mt-6">
|
<div className="flex items-center gap-3 my-6">
|
||||||
Pas encore de compte ?{' '}
|
<div className="flex-1 h-px bg-gray-200" />
|
||||||
<Link to="/inscription" className="text-green-700 font-medium hover:underline">
|
<span className="text-sm text-gray-500">Ou</span>
|
||||||
S'inscrire
|
<div className="flex-1 h-px bg-gray-200" />
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGoogleLogin}
|
||||||
|
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
|
||||||
|
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||||
|
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
|
||||||
|
<path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"></path>
|
||||||
|
<path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||||
|
</svg>
|
||||||
|
Google
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
title="Connexion Facebook bientôt disponible"
|
||||||
|
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-500 cursor-not-allowed opacity-60"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
||||||
|
<path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" />
|
||||||
|
</svg>
|
||||||
|
Facebook
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-8">
|
||||||
|
Pas encore de compte ?{' '}
|
||||||
|
<Link to="/inscription" className="text-gray-700 font-medium hover:underline">
|
||||||
|
S'inscrire
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
45
frontend/src/pages/Contact.jsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import imgContact from '../assets/img/img-contact.jpg'
|
||||||
|
|
||||||
|
export default function Contact() {
|
||||||
|
return (
|
||||||
|
<main className="bg-[#F0F0F0] px-4 sm:px-6 py-10 sm:py-16">
|
||||||
|
<div className="max-w-6xl mx-auto grid md:grid-cols-2 gap-6 sm:gap-8">
|
||||||
|
|
||||||
|
{/* Informations */}
|
||||||
|
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||||
|
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
Contact
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 leading-relaxed mb-6 sm:mb-8 text-sm sm:text-base">
|
||||||
|
Une question concernant le jeu-concours, votre participation ou votre lot ?
|
||||||
|
Notre équipe ThéTipTop est à votre écoute.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-5 text-gray-700">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-[#006B6B] mb-1">Adresse</h2>
|
||||||
|
<p className="text-sm sm:text-base">ThéTipTop<br />18 rue Léon Frot<br />75011 Paris, France</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-[#006B6B] mb-1">Email</h2>
|
||||||
|
<a href="mailto:contact@thetiptop.fr" className="text-sm sm:text-base hover:text-[#006B6B]">contact@thetiptop.fr</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-[#006B6B] mb-1">Téléphone</h2>
|
||||||
|
<a href="tel:+33184802034" className="text-sm sm:text-base hover:text-[#006B6B]">01 84 80 20 34</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Image */}
|
||||||
|
<section className="rounded-2xl shadow overflow-hidden h-64 sm:h-80 md:h-[468px] md:self-start">
|
||||||
|
<img
|
||||||
|
src={imgContact}
|
||||||
|
alt="Une personne savourant un thé"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
133
frontend/src/pages/DefinirMotDePasse.jsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { resetPasswordApi } from '../services/auth'
|
||||||
|
|
||||||
|
// Sert les deux parcours qui passent par un jeton envoyé par email :
|
||||||
|
// la définition initiale du mot de passe après une inscription Google,
|
||||||
|
// et la réinitialisation classique depuis « mot de passe oublié ».
|
||||||
|
export default function DefinirMotDePasse() {
|
||||||
|
const { token } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [form, setForm] = useState({ password: '', confirmPassword: '' })
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (form.password.length < 8) {
|
||||||
|
setError('Le mot de passe doit contenir au moins 8 caractères.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.password !== form.confirmPassword) {
|
||||||
|
setError('Les deux mots de passe ne correspondent pas.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await resetPasswordApi({ token, ...form })
|
||||||
|
setSuccess('Mot de passe enregistré. Vous pouvez maintenant vous connecter avec votre email.')
|
||||||
|
setTimeout(() => navigate('/connexion'), 2500)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto text-center">
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
<Link to="/connexion" className="text-sm text-[#006B6B] font-medium hover:underline">
|
||||||
|
Aller à la connexion
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Mot de passe</h1>
|
||||||
|
<p className="text-gray-500 mt-1 text-sm">
|
||||||
|
Choisissez un mot de passe pour accéder à votre compte sans passer par Google.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-800 mb-1">
|
||||||
|
Nouveau mot de passe <span className="text-gray-800">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
placeholder="8 caractères minimum"
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-800 mb-1">
|
||||||
|
Confirmer le mot de passe <span className="text-gray-800">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
value={form.confirmPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
placeholder="Saisissez-le à nouveau"
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showPassword}
|
||||||
|
onChange={(e) => setShowPassword(e.target.checked)}
|
||||||
|
className="w-5 h-5 accent-[#006B6B]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500">Afficher les mots de passe</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Enregistrement…' : 'Enregistrer mon mot de passe'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-8">
|
||||||
|
<Link to="/connexion" className="text-gray-700 font-medium hover:underline">
|
||||||
|
Retour à la connexion
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,76 +1,213 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { searchTicketApi, deliverClaimApi } from '../services/employee'
|
||||||
|
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
PENDING: { label: 'À remettre', classes: 'bg-yellow-100 text-yellow-800' },
|
||||||
|
DELIVERED: { label: 'Déjà remis', classes: 'bg-green-100 text-green-800' },
|
||||||
|
CANCELLED: { label: 'Expiré', classes: 'bg-gray-100 text-gray-500' },
|
||||||
|
}
|
||||||
|
|
||||||
export default function Employe() {
|
export default function Employe() {
|
||||||
const [code, setCode] = useState('')
|
const handleAuthError = useHandleAuthError()
|
||||||
const [gain, setGain] = useState(null)
|
const [code, setCode] = useState('')
|
||||||
|
const [result, setResult] = useState(null)
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [delivering, setDelivering] = useState(false)
|
||||||
|
const [searchError, setSearchError] = useState('')
|
||||||
|
const [deliverError, setDeliverError] = useState('')
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
const handleSearch = (e) => {
|
const handleSearch = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
// TODO: connecter au backend
|
setResult(null)
|
||||||
setGain({
|
setSuccess(false)
|
||||||
code,
|
setDeliverError('')
|
||||||
lot: 'Thé signature (100g)',
|
setSearchError('')
|
||||||
client: 'Jean Dupont',
|
setSearching(true)
|
||||||
email: 'jean.dupont@email.com',
|
try {
|
||||||
status: 'PENDING',
|
const data = await searchTicketApi(code.trim())
|
||||||
})
|
setResult(data)
|
||||||
|
} catch (err) {
|
||||||
|
if (!handleAuthError(err)) setSearchError(err.message)
|
||||||
|
} finally {
|
||||||
|
setSearching(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeliver = () => {
|
const handleDeliver = async () => {
|
||||||
// TODO: connecter au backend
|
if (!result?.claim?.id) return
|
||||||
setGain({ ...gain, status: 'DELIVERED' })
|
setDeliverError('')
|
||||||
|
setDelivering(true)
|
||||||
|
try {
|
||||||
|
await deliverClaimApi(result.claim.id)
|
||||||
|
setResult((prev) => ({
|
||||||
|
...prev,
|
||||||
|
claim: { ...prev.claim, status: 'DELIVERED', claimedAt: new Date().toISOString() },
|
||||||
|
}))
|
||||||
|
setSuccess(true)
|
||||||
|
} catch (err) {
|
||||||
|
if (!handleAuthError(err)) setDeliverError(err.message)
|
||||||
|
} finally {
|
||||||
|
setDelivering(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setCode('')
|
||||||
|
setResult(null)
|
||||||
|
setSuccess(false)
|
||||||
|
setSearchError('')
|
||||||
|
setDeliverError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCfg = result ? (STATUS_CONFIG[result.claim?.status] ?? STATUS_CONFIG.PENDING) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-6">
|
<div className="min-h-screen bg-gray-50 px-4 py-8 sm:py-12">
|
||||||
<div className="max-w-lg mx-auto">
|
<div className="max-w-lg mx-auto">
|
||||||
<h1 className="text-2xl font-bold text-gray-800 mb-1">Espace Employé</h1>
|
|
||||||
<p className="text-gray-500 mb-6">Vérifier et remettre un lot</p>
|
|
||||||
|
|
||||||
|
{/* En-tête */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Espace Employé</h1>
|
||||||
|
<p className="text-gray-500 mt-1 text-sm">Recherchez un code ticket pour remettre le lot au client.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formulaire de recherche */}
|
||||||
<form onSubmit={handleSearch} className="flex gap-2 mb-6">
|
<form onSubmit={handleSearch} className="flex gap-2 mb-6">
|
||||||
|
<label htmlFor="employee-ticket-code" className="sr-only">Code ticket</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
id="employee-ticket-code"
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||||
required
|
required
|
||||||
placeholder="Code ticket (TTT-XXXXX)"
|
placeholder="Code ticket (10 caractères)"
|
||||||
className="flex-1 border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500 font-mono"
|
maxLength={10}
|
||||||
|
className="flex-1 min-w-0 border border-gray-300 rounded-lg px-4 py-3 text-sm font-mono uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="bg-green-700 hover:bg-green-800 text-white font-semibold px-5 py-2.5 rounded-lg transition-colors"
|
disabled={searching || code.trim().length === 0}
|
||||||
|
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-5 py-3 rounded-lg transition-colors text-sm whitespace-nowrap"
|
||||||
>
|
>
|
||||||
Rechercher
|
{searching ? 'Recherche…' : 'Rechercher'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{gain && (
|
{/* Erreur de recherche */}
|
||||||
<div className="bg-white rounded-xl shadow p-6 space-y-3">
|
{searchError && (
|
||||||
<div className="flex justify-between items-start">
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||||
<div>
|
{searchError}
|
||||||
<p className="font-bold text-gray-800 text-lg">{gain.lot}</p>
|
</div>
|
||||||
<p className="text-sm text-gray-500 font-mono">{gain.code}</p>
|
)}
|
||||||
|
|
||||||
|
{/* Résultat */}
|
||||||
|
{result && (
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
|
||||||
|
{/* Bandeau succès remise */}
|
||||||
|
{success && (
|
||||||
|
<div className="bg-green-500 px-5 py-3 flex items-center gap-3">
|
||||||
|
<svg className="w-5 h-5 text-white flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-white text-sm font-semibold">Lot remis avec succès — espace client mis à jour.</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs font-semibold px-3 py-1 rounded-full ${gain.status === 'DELIVERED' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}`}>
|
)}
|
||||||
{gain.status === 'DELIVERED' ? 'Remis' : 'À remettre'}
|
|
||||||
|
{/* En-tête carte : lot + statut */}
|
||||||
|
<div className="px-5 py-4 flex items-start justify-between gap-3 border-b border-gray-100">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-gray-900 text-base">{result.lot.name}</p>
|
||||||
|
<p className="text-gray-500 text-xs mt-0.5">{result.lot.description}</p>
|
||||||
|
{result.lot.value && (
|
||||||
|
<p className="text-[#006B6B] text-xs font-semibold mt-1">Valeur : {result.lot.value} €</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`flex-shrink-0 text-xs font-semibold px-3 py-1 rounded-full ${statusCfg.classes}`}>
|
||||||
|
{statusCfg.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-3">
|
{/* Infos client */}
|
||||||
<p className="text-sm text-gray-600"><span className="font-medium">Client :</span> {gain.client}</p>
|
<div className="px-5 py-4 space-y-2 border-b border-gray-100">
|
||||||
<p className="text-sm text-gray-600"><span className="font-medium">Email :</span> {gain.email}</p>
|
<p className="text-xs text-gray-500 font-medium uppercase tracking-wider mb-2">Client</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-sm font-bold flex-shrink-0">
|
||||||
|
{(result.client.firstname?.[0] ?? '?').toUpperCase()}{(result.client.lastname?.[0] ?? '?').toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-800">
|
||||||
|
{result.client.firstname} {result.client.lastname}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">{result.client.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{gain.status === 'PENDING' && (
|
{/* Infos ticket */}
|
||||||
|
<div className="px-5 py-4 space-y-1 border-b border-gray-100">
|
||||||
|
<p className="text-xs text-gray-500 font-medium uppercase tracking-wider mb-2">Ticket</p>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
<span className="font-medium">Code :</span>{' '}
|
||||||
|
<span className="font-mono">{result.ticket.code}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
<span className="font-medium">Utilisé le :</span> {formatDate(result.ticket.usedAt)}
|
||||||
|
</p>
|
||||||
|
{result.claim.claimedAt && (
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
<span className="font-medium">Remis le :</span> {formatDate(result.claim.claimedAt)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="px-5 py-4 space-y-3">
|
||||||
|
{deliverError && (
|
||||||
|
<div className="px-4 py-2 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||||
|
{deliverError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.claim.status === 'PENDING' && !success && (
|
||||||
|
<button
|
||||||
|
onClick={handleDeliver}
|
||||||
|
disabled={delivering}
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold py-3 rounded-xl transition-colors text-sm"
|
||||||
|
>
|
||||||
|
{delivering ? 'Confirmation en cours…' : 'Confirmer la remise du lot'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.claim.status === 'CANCELLED' && (
|
||||||
|
<div className="px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 text-gray-500 text-sm text-center">
|
||||||
|
Ce gain est expiré et ne peut plus être remis.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleDeliver}
|
onClick={handleReset}
|
||||||
className="w-full bg-green-700 hover:bg-green-800 text-white font-semibold py-2.5 rounded-lg transition-colors mt-2"
|
className="w-full border border-gray-200 text-gray-600 hover:bg-gray-50 font-medium py-2.5 rounded-xl transition-colors text-sm"
|
||||||
>
|
>
|
||||||
Confirmer la remise du lot
|
Rechercher un autre code
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,116 +1,295 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { registerApi, verifyCodeApi } from '../services/auth'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
// Une naissance ne peut pas être dans le futur : borne haute du sélecteur.
|
||||||
|
const AUJOURD_HUI = new Date().toISOString().split('T')[0]
|
||||||
|
|
||||||
|
// Valeurs alignées sur le tableau de bord admin, qui compte exactement
|
||||||
|
// "Homme" et "Femme" ; toute autre valeur retombe dans la catégorie "autre".
|
||||||
|
const GENRES = ['Femme', 'Homme', 'Autre']
|
||||||
|
|
||||||
export default function Inscription() {
|
export default function Inscription() {
|
||||||
const [form, setForm] = useState({
|
const navigate = useNavigate()
|
||||||
firstName: '',
|
const { login } = useAuth()
|
||||||
lastName: '',
|
|
||||||
|
// Étape : 'form' | 'verify'
|
||||||
|
const [step, setStep] = useState('form')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [form, setForm] = useState({
|
||||||
email: '',
|
email: '',
|
||||||
|
lastName: '',
|
||||||
|
firstName: '',
|
||||||
password: '',
|
password: '',
|
||||||
acceptMarketing: false,
|
birthdate: '',
|
||||||
|
gender: '',
|
||||||
})
|
})
|
||||||
|
const [acceptedTerms, setAcceptedTerms] = useState(false)
|
||||||
|
// Consentement marketing : distinct des CGU et décoché par défaut, il doit
|
||||||
|
// rester un choix libre et non une condition d'inscription.
|
||||||
|
const [marketingOptIn, setMarketingOptIn] = useState(false)
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
const handleChange = (e) => {
|
// Code à 4 chiffres
|
||||||
const { name, value, type, checked } = e.target
|
const [digits, setDigits] = useState(['', '', '', ''])
|
||||||
setForm({ ...form, [name]: type === 'checkbox' ? checked : value })
|
const [codeError, setCodeError] = useState('')
|
||||||
|
const [codeLoading, setCodeLoading] = useState(false)
|
||||||
|
const inputRefs = [useRef(), useRef(), useRef(), useRef()]
|
||||||
|
|
||||||
|
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
||||||
|
|
||||||
|
// Même route que la connexion : Google crée le compte à la première venue.
|
||||||
|
const handleGoogleSignup = () => {
|
||||||
|
window.location.href = '/api/auth/google'
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
// Étape 1 — Inscription
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
// TODO: connecter au backend
|
setError('')
|
||||||
alert(`Inscription de ${form.firstName} ${form.lastName}`)
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await registerApi({
|
||||||
|
email: form.email,
|
||||||
|
firstname: form.firstName,
|
||||||
|
lastname: form.lastName,
|
||||||
|
password: form.password,
|
||||||
|
birthdate: form.birthdate,
|
||||||
|
gender: form.gender,
|
||||||
|
marketingOptIn,
|
||||||
|
})
|
||||||
|
setEmail(form.email)
|
||||||
|
setStep('verify')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Étape 2 — Saisie du code
|
||||||
<div className="min-h-screen bg-green-50 flex items-center justify-center p-4">
|
const handleDigit = (index, value) => {
|
||||||
<div className="bg-white rounded-2xl shadow-lg w-full max-w-md p-8">
|
if (!/^\d?$/.test(value)) return
|
||||||
<div className="text-center mb-8">
|
const next = [...digits]
|
||||||
<h1 className="text-3xl font-bold text-green-800">Thé Tip Top</h1>
|
next[index] = value
|
||||||
<p className="text-gray-500 mt-1">Créer votre compte</p>
|
setDigits(next)
|
||||||
|
if (value && index < 3) inputRefs[index + 1].current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDigitKeyDown = (index, e) => {
|
||||||
|
if (e.key === 'Backspace' && !digits[index] && index > 0) {
|
||||||
|
inputRefs[index - 1].current?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVerify = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const code = digits.join('')
|
||||||
|
if (code.length !== 4) return
|
||||||
|
setCodeError('')
|
||||||
|
setCodeLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await verifyCodeApi({ email, code })
|
||||||
|
login(data.user, data.token)
|
||||||
|
navigate('/saisir-code-unique')
|
||||||
|
} catch (err) {
|
||||||
|
setCodeError(err.message)
|
||||||
|
setDigits(['', '', '', ''])
|
||||||
|
inputRefs[0].current?.focus()
|
||||||
|
} finally {
|
||||||
|
setCodeLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Étape 2 : Vérification du code ──
|
||||||
|
if (step === 'verify') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-[#006B6B]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-7 h-7 text-[#006B6B]" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold italic font-serif text-[#006B6B]">Vérifiez votre email</h1>
|
||||||
|
<p className="text-gray-500 text-sm mt-2">
|
||||||
|
Un code à 4 chiffres a été envoyé à<br />
|
||||||
|
<span className="font-medium text-gray-700">{email}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
{codeError && (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm text-center">
|
||||||
<div>
|
{codeError}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Prénom</label>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleVerify} className="space-y-6">
|
||||||
|
<div className="flex justify-center gap-3">
|
||||||
|
{digits.map((d, i) => (
|
||||||
<input
|
<input
|
||||||
|
key={i}
|
||||||
|
ref={inputRefs[i]}
|
||||||
type="text"
|
type="text"
|
||||||
name="firstName"
|
inputMode="numeric"
|
||||||
value={form.firstName}
|
maxLength={1}
|
||||||
onChange={handleChange}
|
value={d}
|
||||||
required
|
onChange={(e) => handleDigit(i, e.target.value)}
|
||||||
placeholder="Jean"
|
onKeyDown={(e) => handleDigitKeyDown(i, e)}
|
||||||
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
aria-label={`Chiffre ${i + 1} du code de vérification`}
|
||||||
|
className="w-14 h-14 text-center text-2xl font-bold border-2 border-gray-200 rounded-xl focus:outline-none focus:border-[#006B6B] focus:ring-1 focus:ring-[#006B6B] transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
))}
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nom</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="lastName"
|
|
||||||
value={form.lastName}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
placeholder="Dupont"
|
|
||||||
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
value={form.email}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
placeholder="exemple@email.com"
|
|
||||||
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mot de passe</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
value={form.password}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
placeholder="••••••••"
|
|
||||||
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="acceptMarketing"
|
|
||||||
checked={form.acceptMarketing}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="mt-1 accent-green-700"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-gray-600">
|
|
||||||
J'accepte de recevoir des communications marketing de Thé Tip Top
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-green-700 hover:bg-green-800 text-white font-semibold py-2.5 rounded-lg transition-colors"
|
disabled={codeLoading || digits.join('').length !== 4}
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Créer mon compte
|
{codeLoading ? 'Vérification…' : 'Valider le code'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-sm text-gray-500 mt-6">
|
<p className="text-center text-xs text-gray-500 mt-6">
|
||||||
Déjà un compte ?{' '}
|
Le code expire dans 15 minutes.{' '}
|
||||||
<Link to="/connexion" className="text-green-700 font-medium hover:underline">
|
<button
|
||||||
Se connecter
|
type="button"
|
||||||
</Link>
|
onClick={() => { setStep('form'); setDigits(['', '', '', '']); setCodeError('') }}
|
||||||
|
className="text-[#006B6B] hover:underline"
|
||||||
|
>
|
||||||
|
Recommencer
|
||||||
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Étape 1 : Formulaire d'inscription ──
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">S'inscrire</h1>
|
||||||
|
<p className="text-gray-500 mt-1 text-sm">Créer votre compte</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="reg-email" className="block text-sm font-medium text-gray-800 mb-1">Adresse email <span className="text-gray-800">*</span></label>
|
||||||
|
<input type="email" id="reg-email" name="email" value={form.email} onChange={handleChange} required placeholder="Votre email"
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="reg-lastName" className="block text-sm font-medium text-gray-800 mb-1">Nom <span className="text-gray-800">*</span></label>
|
||||||
|
<input type="text" id="reg-lastName" name="lastName" value={form.lastName} onChange={handleChange} required placeholder="Votre nom"
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="reg-firstName" className="block text-sm font-medium text-gray-800 mb-1">Prénom <span className="text-gray-800">*</span></label>
|
||||||
|
<input type="text" id="reg-firstName" name="firstName" value={form.firstName} onChange={handleChange} required placeholder="Votre prénom"
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="reg-birthdate" className="block text-sm font-medium text-gray-800 mb-1">Date de naissance</label>
|
||||||
|
<input type="date" id="reg-birthdate" name="birthdate" value={form.birthdate} onChange={handleChange} max={AUJOURD_HUI}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="reg-gender" className="block text-sm font-medium text-gray-800 mb-1">Genre</label>
|
||||||
|
<select id="reg-gender" name="gender" value={form.gender} onChange={handleChange}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]">
|
||||||
|
<option value="">Non précisé</option>
|
||||||
|
{GENRES.map((genre) => (
|
||||||
|
<option key={genre} value={genre}>{genre}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="reg-password" className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label>
|
||||||
|
<div className="relative">
|
||||||
|
<input type={showPassword ? 'text' : 'password'} id="reg-password" name="password" value={form.password} onChange={handleChange} required placeholder="Votre mot de passe"
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||||
|
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600" aria-label="Afficher/masquer">
|
||||||
|
<div className="w-6 h-6 rounded-full border-2 border-gray-400 flex items-center justify-center">
|
||||||
|
{showPassword && <div className="w-1.5 h-1.5 rounded-full bg-gray-400" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={acceptedTerms} onChange={(e) => setAcceptedTerms(e.target.checked)} required
|
||||||
|
className="mt-0.5 w-5 h-5 accent-[#006B6B] flex-shrink-0" />
|
||||||
|
<span className="text-xs text-gray-500 leading-relaxed">
|
||||||
|
J'accepte les{' '}
|
||||||
|
<Link to="/cgu" className="text-[#006B6B] hover:underline font-medium">conditions d'utilisation</Link>{' '}
|
||||||
|
et la{' '}
|
||||||
|
<Link to="/confidentialite" className="text-[#006B6B] hover:underline font-medium">politique de confidentialité</Link>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox" name="marketingOptIn" checked={marketingOptIn}
|
||||||
|
onChange={(e) => setMarketingOptIn(e.target.checked)}
|
||||||
|
className="mt-0.5 w-5 h-5 accent-[#006B6B] flex-shrink-0" />
|
||||||
|
<span className="text-xs text-gray-500 leading-relaxed">
|
||||||
|
J'accepte de recevoir les offres et actualités de Thé Tip Top par email.
|
||||||
|
<span className="block text-gray-500 mt-0.5">
|
||||||
|
Facultatif — vous pouvez vous désinscrire à tout moment.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading || !acceptedTerms}
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors">
|
||||||
|
{loading ? 'Inscription…' : "S'inscrire"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 my-6">
|
||||||
|
<div className="flex-1 h-px bg-gray-200" />
|
||||||
|
<span className="text-sm text-gray-500">Ou</span>
|
||||||
|
<div className="flex-1 h-px bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<button type="button" onClick={handleGoogleSignup} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
|
||||||
|
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||||
|
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
|
||||||
|
<path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"></path>
|
||||||
|
<path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||||
|
</svg>
|
||||||
|
Google
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled title="Inscription Facebook bientôt disponible" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-500 cursor-not-allowed opacity-60">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
||||||
|
<path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" />
|
||||||
|
</svg>
|
||||||
|
Facebook
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-8">
|
||||||
|
Vous avez déjà un compte ?{' '}
|
||||||
|
<Link to="/connexion" className="text-gray-700 font-medium hover:underline">Se connecter</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
255
frontend/src/pages/MentionsLegales.jsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
export default function MentionsLegales() {
|
||||||
|
return (
|
||||||
|
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||||
|
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||||
|
|
||||||
|
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
Mentions légales
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mb-6 sm:mb-8 px-4 py-3 sm:px-5 sm:py-4 rounded-xl bg-[#006B6B]">
|
||||||
|
<p className="text-white text-sm sm:text-base font-medium leading-relaxed">
|
||||||
|
Projet étudiant fictif — Ce site a été conçu dans un cadre pédagogique. La société
|
||||||
|
Thé Tip Top, l'agence Furious Ducks et l'ensemble des informations légales présentées
|
||||||
|
ci-dessous sont fictives. Aucun achat, paiement ou réservation réel ne peut être
|
||||||
|
effectué sur ce site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-600 mb-8 sm:mb-10 text-sm sm:text-base">
|
||||||
|
Conformément à la loi n°2004-575 du 21 juin 2004 pour la Confiance dans l'Économie
|
||||||
|
Numérique (LCEN), les présentes mentions légales précisent l'identité des différents
|
||||||
|
intervenants dans la conception et le suivi du site internet dédié au jeu-concours
|
||||||
|
« 100% Gagnant » de la société Thé Tip Top.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
title: 'Article 1 – Société éditrice du site',
|
||||||
|
content: (
|
||||||
|
<div className="text-gray-700 space-y-2 text-sm sm:text-base">
|
||||||
|
<p><strong>Dénomination sociale :</strong> Thé Tip Top</p>
|
||||||
|
<p><strong>Forme juridique :</strong> Société à Responsabilité Limitée (SARL)</p>
|
||||||
|
<p><strong>Capital social :</strong> 150 000 €</p>
|
||||||
|
<p><strong>Siège social :</strong> 18 rue Léon Frot, 75011 Paris, France</p>
|
||||||
|
<p><strong>Représentant légal :</strong> M. Eric Bourdon, Gérant</p>
|
||||||
|
<p><strong>Directeur de la publication :</strong> M. Eric Bourdon, en sa qualité de Gérant</p>
|
||||||
|
<p><strong>SIRET :</strong> 362 521 879 00054</p>
|
||||||
|
<p><strong>TVA intracommunautaire :</strong> FR83362521879</p>
|
||||||
|
<p><strong>Email :</strong> contact@thetiptop.fr</p>
|
||||||
|
<p><strong>Téléphone :</strong> 01 84 80 20 34</p>
|
||||||
|
<p className="pt-2">
|
||||||
|
Le règlement du jeu-concours a été déposé auprès de Maître Arnaud Rick,
|
||||||
|
Commissaire de Justice. Il est consultable gratuitement sur simple demande
|
||||||
|
à l'adresse contact@thetiptop.fr.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Article 2 – Agence de développement',
|
||||||
|
content: (
|
||||||
|
<div className="text-gray-700 space-y-2 text-sm sm:text-base">
|
||||||
|
<p><strong>Dénomination sociale :</strong> Furious Ducks</p>
|
||||||
|
<p><strong>Statut juridique :</strong> Société par Actions Simplifiée (SAS)</p>
|
||||||
|
<p><strong>Capital social :</strong> 200 000 €</p>
|
||||||
|
<p><strong>Siège social :</strong> 13 Avenue Jean Moulin, 93240 Stains</p>
|
||||||
|
<p><strong>Directeur Général :</strong> M. Guido Brasletti</p>
|
||||||
|
<p><strong>Effectif :</strong> 45 employés</p>
|
||||||
|
<p><strong>RCS :</strong> Bobigny 500 234 567</p>
|
||||||
|
<p><strong>Code APE / NAF :</strong> 6201Z – Développement de logiciels</p>
|
||||||
|
<p><strong>Année de création :</strong> 2007</p>
|
||||||
|
<p><strong>Spécialité :</strong> Technologies open source</p>
|
||||||
|
<p><strong>Téléphone :</strong> 01 35 52 40 49</p>
|
||||||
|
<p className="pt-2">
|
||||||
|
L'agence Furious Ducks est responsable de la conception technique du site, de
|
||||||
|
l'API, de la webApp et de l'infrastructure de déploiement. Cependant, elle n'est
|
||||||
|
pas responsable des contenus éditoriaux publiés par la société Thé Tip Top.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Article 3 – Hébergeur du site',
|
||||||
|
content: (
|
||||||
|
<div className="text-gray-700 space-y-2 text-sm sm:text-base">
|
||||||
|
<p><strong>Raison sociale :</strong> Vercel Inc.</p>
|
||||||
|
<p><strong>Adresse :</strong> 440 N Barranca Avenue #4133, Covina, CA 91723, États-Unis</p>
|
||||||
|
<p><strong>Contact :</strong> privacy@vercel.com</p>
|
||||||
|
<p><strong>Site internet :</strong> https://www.vercel.com</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Article 4 – Propriété intellectuelle',
|
||||||
|
content: (
|
||||||
|
<div className="text-gray-700 space-y-4 text-sm sm:text-base leading-relaxed">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-1">4.1 – Droits de la société Thé Tip Top</h3>
|
||||||
|
<p>
|
||||||
|
L'ensemble des éléments constituant le présent site internet (textes, graphiques,
|
||||||
|
logiciels, photographies, images, sons, vidéos, noms, logos, marques, créations et
|
||||||
|
œuvres protégeables) est la propriété exclusive de la société Thé Tip Top ou de ses
|
||||||
|
partenaires, et est protégé par les lois françaises et internationales relatives à
|
||||||
|
la propriété intellectuelle.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
La marque « Thé Tip Top », ses logos, son identité visuelle refondue dans le cadre
|
||||||
|
du présent projet et ses mélanges signatures sont des éléments protégés. Toute
|
||||||
|
utilisation non autorisée constitue une contrefaçon sanctionnée par les articles
|
||||||
|
L.713-1 et suivants du Code de la propriété intellectuelle.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
Toute reproduction, représentation, modification, publication ou adaptation de
|
||||||
|
tout ou partie des éléments du Site, quel que soit le moyen ou le procédé utilisé,
|
||||||
|
est strictement interdite sans autorisation écrite préalable de la société Thé Tip
|
||||||
|
Top, sous peine de poursuites judiciaires.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-1">4.2 – Droits de l'agence Furious Ducks</h3>
|
||||||
|
<p>
|
||||||
|
Les développements techniques réalisés par l'agence Furious Ducks (API, webApp,
|
||||||
|
infrastructure de déploiement, workflow CI/CD) font l'objet de droits distincts,
|
||||||
|
définis contractuellement dans le contrat de prestation signé entre les parties.
|
||||||
|
L'agence Furious Ducks cède à la société Thé Tip Top les droits d'exploitation
|
||||||
|
nécessaires à l'utilisation du site dans les conditions prévues au contrat, sans
|
||||||
|
que cela n'implique un transfert de propriété des outils et technologies
|
||||||
|
propriétaires de l'agence.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-1">4.3 – Contenu soumis par les utilisateurs</h3>
|
||||||
|
<p>
|
||||||
|
L'utilisateur garantit qu'il dispose de tous les droits nécessaires sur les
|
||||||
|
informations qu'il communique sur le Site. Il accorde à la société Thé Tip Top
|
||||||
|
une licence non exclusive d'utilisation de ces données dans le seul cadre de la
|
||||||
|
fourniture des services du jeu-concours.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Article 5 – Cookies et traceurs',
|
||||||
|
content: (
|
||||||
|
<div className="text-gray-700 space-y-3 text-sm sm:text-base leading-relaxed">
|
||||||
|
<p>
|
||||||
|
Le site est susceptible d'utiliser des cookies et traceurs afin d'améliorer
|
||||||
|
l'expérience utilisateur, d'analyser le trafic et de permettre les fonctionnalités
|
||||||
|
d'authentification via Google.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Lors de votre première visite, un bandeau de consentement conforme au RGPD et aux
|
||||||
|
recommandations de la CNIL (Commission Nationale de l'Informatique et des Libertés)
|
||||||
|
vous informe de la présence de cookies et recueille votre accord. Vous pouvez à
|
||||||
|
tout moment modifier vos préférences via le gestionnaire de cookies disponible sur
|
||||||
|
le Site ou les paramètres de votre navigateur.
|
||||||
|
</p>
|
||||||
|
<p>Les cookies utilisés sont les suivants :</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2">
|
||||||
|
<li>
|
||||||
|
<strong>Cookies de session :</strong> nécessaires au bon fonctionnement du site
|
||||||
|
(authentification, suivi de participation, sécurité). Non soumis à consentement.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Cookies analytiques (Google Analytics 4) :</strong> permettent de mesurer
|
||||||
|
l'audience du Site (visiteurs uniques, sources de trafic, comportements de
|
||||||
|
navigation, tunnel de conversion inscription → participation → réclamation de
|
||||||
|
lot). Soumis à consentement.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Cookies publicitaires et sociaux (Meta Business Suite – Instagram/Facebook) :</strong>{' '}
|
||||||
|
utilisés dans le cadre des campagnes Meta Ads et du suivi des performances
|
||||||
|
sociales. Soumis à consentement.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Cookies Google :</strong> liés aux fonctionnalités d'authentification via
|
||||||
|
compte Google. Soumis aux politiques de confidentialité respectives de ces
|
||||||
|
plateformes.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Article 6 – Responsabilité',
|
||||||
|
content: (
|
||||||
|
<div className="text-gray-700 space-y-3 text-sm sm:text-base leading-relaxed">
|
||||||
|
<p>
|
||||||
|
La société Thé Tip Top s'efforce d'assurer l'exactitude et la mise à jour des
|
||||||
|
informations diffusées sur le Site. Toutefois, elle ne peut garantir l'exactitude,
|
||||||
|
la complétude ou l'actualité de l'ensemble des informations publiées et décline
|
||||||
|
toute responsabilité pour :
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2">
|
||||||
|
<li>Toute inexactitude ou omission portant sur des informations disponibles sur le Site</li>
|
||||||
|
<li>Tout dommage résultant d'une intrusion frauduleuse d'un tiers ayant entraîné une modification des informations</li>
|
||||||
|
<li>Tout dommage lié à l'utilisation d'Internet (rupture de service, virus, intrusion extérieure)</li>
|
||||||
|
<li>Tout dysfonctionnement lié à l'infrastructure technique gérée par l'agence Furious Ducks ou son hébergeur</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Article 7 – Droit applicable',
|
||||||
|
content: (
|
||||||
|
<p className="text-gray-700 text-sm sm:text-base">
|
||||||
|
Le présent Site et ses contenus sont soumis au droit français. En cas de litige
|
||||||
|
relatif à l'interprétation ou à l'exécution des présentes mentions légales, et à
|
||||||
|
défaut de résolution amiable, les tribunaux français seront seuls compétents.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Article 8 – Accessibilité',
|
||||||
|
content: (
|
||||||
|
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||||
|
Le présent site internet a été développé en conformité avec les normes
|
||||||
|
d'accessibilité internationales WCAG 2.1 niveau AA et le Référentiel Général
|
||||||
|
d'Amélioration de l'Accessibilité (RGAA 4.1) afin de garantir une accessibilité
|
||||||
|
maximale aux utilisateurs en situation de handicap. Une déclaration d'accessibilité
|
||||||
|
complète est disponible sur la page{' '}
|
||||||
|
<a href="/accessibilite" className="text-[#006B6B] hover:underline font-medium">
|
||||||
|
Accessibilité
|
||||||
|
</a>.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Article 9 – Engagement RSE et éco-responsabilité',
|
||||||
|
content: (
|
||||||
|
<div className="text-gray-700 space-y-3 text-sm sm:text-base leading-relaxed">
|
||||||
|
<p>
|
||||||
|
La société Thé Tip Top et l'agence Furious Ducks s'engagent dans une démarche de
|
||||||
|
Responsabilité Sociale et Environnementale (RSE) pour la conception et
|
||||||
|
l'exploitation du site du jeu-concours 100% Gagnant. Cet engagement comprend
|
||||||
|
notamment :
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2">
|
||||||
|
<li>L'utilisation de technologies et d'infrastructures respectueuses de l'environnement</li>
|
||||||
|
<li>L'optimisation de la consommation d'énergie et de l'empreinte carbone du site</li>
|
||||||
|
<li>L'hébergement auprès de fournisseurs certifiés pour leurs pratiques durables</li>
|
||||||
|
<li>La limitation de la collecte de données personnelles aux seules données nécessaires</li>
|
||||||
|
<li>L'utilisation de technologies open source réduisant les dépendances propriétaires</li>
|
||||||
|
<li>La sensibilisation des utilisateurs aux bonnes pratiques numériques</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Toute question relative à ces engagements peut être adressée à : dpo@thetiptop.fr
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
].map((article) => (
|
||||||
|
<section key={article.title} className="mb-6 sm:mb-8">
|
||||||
|
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||||
|
{article.title}
|
||||||
|
</h2>
|
||||||
|
{article.content}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,34 +1,141 @@
|
|||||||
const MOCK_GAINS = [
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
{ id: 1, code: 'TTT-00123', lot: 'Infusion découverte (30g)', status: 'PENDING' },
|
import { myGainsApi } from '../services/game'
|
||||||
{ id: 2, code: 'TTT-00456', lot: 'Thé signature (100g)', status: 'DELIVERED' },
|
import UserNav from '../components/UserNav'
|
||||||
]
|
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||||
|
|
||||||
const statusLabel = {
|
function formatDate(dateStr) {
|
||||||
PENDING: { label: 'À récupérer', color: 'bg-yellow-100 text-yellow-800' },
|
const date = new Date(dateStr)
|
||||||
DELIVERED: { label: 'Récupéré', color: 'bg-green-100 text-green-800' },
|
const dateLabel = date.toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||||
CANCELLED: { label: 'Annulé', color: 'bg-red-100 text-red-800' },
|
const timeLabel = date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
return `${dateLabel} à ${timeLabel}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(dateStr) {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime()
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
const hours = Math.floor(diff / 3600000)
|
||||||
|
const days = Math.floor(diff / 86400000)
|
||||||
|
if (minutes < 1) return "À l'instant"
|
||||||
|
if (minutes < 60) return `Il y a ${minutes} min`
|
||||||
|
if (hours < 24) return `Il y a ${hours}h`
|
||||||
|
if (days < 7) return `Il y a ${days}j`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLAIM_STATUS = {
|
||||||
|
PENDING: { label: 'En attente de remise', classes: 'bg-yellow-100 text-yellow-700' },
|
||||||
|
DELIVERED: { label: 'Lot remis', classes: 'bg-green-100 text-green-700' },
|
||||||
|
CANCELLED: { label: 'Expiré', classes: 'bg-gray-100 text-gray-500' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MesGains() {
|
export default function MesGains() {
|
||||||
return (
|
const handleAuthError = useHandleAuthError()
|
||||||
<div className="min-h-screen bg-green-50 p-6">
|
const [gains, setGains] = useState([])
|
||||||
<div className="max-w-2xl mx-auto">
|
const [loading, setLoading] = useState(true)
|
||||||
<h1 className="text-3xl font-bold text-green-800 mb-2">Mes gains</h1>
|
const [error, setError] = useState('')
|
||||||
<p className="text-gray-500 mb-6">Historique de vos participations</p>
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
<div className="space-y-4">
|
const fetchGains = useCallback(async (isRefresh = false) => {
|
||||||
{MOCK_GAINS.map((gain) => (
|
if (isRefresh) setRefreshing(true)
|
||||||
<div key={gain.id} className="bg-white rounded-xl shadow p-5 flex items-center justify-between">
|
else setLoading(true)
|
||||||
<div>
|
setError('')
|
||||||
<p className="font-semibold text-gray-800">{gain.lot}</p>
|
try {
|
||||||
<p className="text-sm text-gray-400 font-mono mt-1">{gain.code}</p>
|
const data = await myGainsApi()
|
||||||
</div>
|
setGains(data.gains)
|
||||||
<span className={`text-xs font-semibold px-3 py-1 rounded-full ${statusLabel[gain.status].color}`}>
|
} catch (err) {
|
||||||
{statusLabel[gain.status].label}
|
if (!handleAuthError(err)) setError(err.message)
|
||||||
</span>
|
} finally {
|
||||||
</div>
|
setLoading(false)
|
||||||
))}
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}, [handleAuthError])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadGains = async () => {
|
||||||
|
try {
|
||||||
|
const data = await myGainsApi()
|
||||||
|
setGains(data.gains)
|
||||||
|
} catch (err) {
|
||||||
|
if (!handleAuthError(err)) setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadGains()
|
||||||
|
}, [handleAuthError])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto">
|
||||||
|
<UserNav active="gains" />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700">Historique de mes gains</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => fetchGains(true)}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="text-xs text-[#006B6B] hover:underline disabled:opacity-50 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<svg className={`w-3.5 h-3.5 ${refreshing ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582M20 20v-5h-.581M4.582 9A8 8 0 0120 15M19.418 15A8 8 0 014 9" />
|
||||||
|
</svg>
|
||||||
|
{refreshing ? 'Actualisation…' : 'Actualiser'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="text-center text-gray-500 text-sm mt-8">Chargement…</p>}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||||
|
{error}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && gains.length === 0 && (
|
||||||
|
<div className="text-center mt-12">
|
||||||
|
<p className="text-gray-500 text-sm">Aucun gain pour le moment.</p>
|
||||||
|
<p className="text-gray-500 text-xs mt-1">Saisissez un code pour participer !</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{gains.map((gain, index) => {
|
||||||
|
const statusCfg = CLAIM_STATUS[gain.claimStatus] ?? CLAIM_STATUS.PENDING
|
||||||
|
return (
|
||||||
|
<div key={gain.participationId} className="flex gap-4">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className={`w-3 h-3 mt-1.5 rounded-full flex-shrink-0 border-2 ${
|
||||||
|
gain.claimStatus === 'DELIVERED' ? 'border-[#006B6B] bg-[#006B6B]' : 'border-gray-300'
|
||||||
|
}`} />
|
||||||
|
{index < gains.length - 1 && <div className="w-px flex-1 bg-gray-200 my-1" />}
|
||||||
|
</div>
|
||||||
|
<div className={`flex-1 pb-6 ${index === 0 ? 'bg-gray-50 rounded-lg px-3 pt-2 -ml-1' : 'pt-1'}`}>
|
||||||
|
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||||
|
<p className="font-semibold text-gray-800 text-sm">{gain.lot}</p>
|
||||||
|
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full whitespace-nowrap ${statusCfg.classes}`}>
|
||||||
|
{statusCfg.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 text-xs mt-1 leading-relaxed">{gain.description}</p>
|
||||||
|
{gain.value && (
|
||||||
|
<p className="text-gray-500 text-xs mt-0.5">Valeur : {gain.value} €</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
|
<p className="text-[#006B6B] text-xs">Gagné le {formatDate(gain.wonAt)}</p>
|
||||||
|
{timeAgo(gain.wonAt) && (
|
||||||
|
<span className="text-[10px] font-medium text-white bg-[#006B6B]/70 px-1.5 py-0.5 rounded-full">
|
||||||
|
{timeAgo(gain.wonAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 text-[10px] mt-1">
|
||||||
|
Code utilisé :{' '}
|
||||||
|
<span className="font-mono tracking-widest text-gray-600">{gain.ticketCode}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
26
frontend/src/pages/NotFound.jsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex flex-col items-center justify-center px-6 text-center">
|
||||||
|
|
||||||
|
<p className="text-8xl sm:text-9xl font-bold text-[#006B6B] leading-none">404</p>
|
||||||
|
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold italic font-serif text-gray-800 mt-4 mb-2">
|
||||||
|
Page introuvable
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-gray-500 text-sm sm:text-base max-w-xs mb-8">
|
||||||
|
La page que vous recherchez n'existe pas ou a été déplacée.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/accueil"
|
||||||
|
className="bg-[#006B6B] hover:bg-[#005959] text-white font-medium px-8 py-3 rounded-lg transition-colors text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Retour à l'accueil
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
|
|
||||||
export default function Participation() {
|
|
||||||
const [code, setCode] = useState('')
|
|
||||||
const [result, setResult] = useState(null)
|
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
// TODO: connecter au backend
|
|
||||||
setResult({
|
|
||||||
lot: 'Infusion découverte (30g)',
|
|
||||||
description: 'Un assortiment de nos meilleures infusions',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-green-50 flex items-center justify-center p-4">
|
|
||||||
<div className="bg-white rounded-2xl shadow-lg w-full max-w-md p-8">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-green-800">Thé Tip Top</h1>
|
|
||||||
<p className="text-gray-500 mt-1">Participer au jeu-concours</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Code ticket
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => setCode(e.target.value)}
|
|
||||||
required
|
|
||||||
placeholder="Ex: TTT-XXXXX"
|
|
||||||
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500 font-mono tracking-widest"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full bg-green-700 hover:bg-green-800 text-white font-semibold py-2.5 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Valider mon ticket
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{result && (
|
|
||||||
<div className="mt-6 bg-green-100 border border-green-300 rounded-xl p-5 text-center">
|
|
||||||
<p className="text-green-800 font-bold text-lg">Félicitations !</p>
|
|
||||||
<p className="text-green-700 font-medium mt-1">{result.lot}</p>
|
|
||||||
<p className="text-gray-600 text-sm mt-1">{result.description}</p>
|
|
||||||
<Link to="/mes-gains" className="inline-block mt-4 text-sm text-green-700 font-medium hover:underline">
|
|
||||||
Voir tous mes gains →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
37
frontend/src/pages/Perdu.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function Perdu() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const message = location.state?.message
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center px-8 max-w-sm mx-auto text-center">
|
||||||
|
|
||||||
|
<div className="w-20 h-20 bg-gray-200 rounded-full flex items-center justify-center mb-6">
|
||||||
|
<svg className="w-10 h-10 text-gray-500" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold italic font-serif text-[#006B6B] mb-2">
|
||||||
|
Réessayer la prochaine fois
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-gray-500 text-sm mb-2">
|
||||||
|
{message || 'Ce code est invalide ou a déjà été utilisé.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-gray-500 text-xs mb-8">
|
||||||
|
Vérifiez le code présent sur votre ticket et réessayez.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/saisir-code-unique')}
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005959] text-white font-medium py-3 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
210
frontend/src/pages/Profile.jsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import UserNav from '../components/UserNav'
|
||||||
|
import { changePasswordApi, meApi, forgotPasswordApi } from '../services/auth'
|
||||||
|
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||||
|
|
||||||
|
export default function Profile() {
|
||||||
|
const user = JSON.parse(localStorage.getItem('user') || '{}')
|
||||||
|
const initials = `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase()
|
||||||
|
const handleAuthError = useHandleAuthError()
|
||||||
|
|
||||||
|
const [pwForm, setPwForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||||
|
const [showPw, setShowPw] = useState({ current: false, new: false, confirm: false })
|
||||||
|
const [pwLoading, setPwLoading] = useState(false)
|
||||||
|
const [pwError, setPwError] = useState('')
|
||||||
|
const [pwSuccess, setPwSuccess] = useState(false)
|
||||||
|
|
||||||
|
// Un compte créé via Google peut ne pas avoir de mot de passe : le
|
||||||
|
// formulaire de modification n'aurait alors aucun sens. L'information vient
|
||||||
|
// de l'API, car le profil stocké en localStorage ne la contient pas.
|
||||||
|
const [hasPassword, setHasPassword] = useState(null)
|
||||||
|
const [linkSending, setLinkSending] = useState(false)
|
||||||
|
const [linkSent, setLinkSent] = useState(false)
|
||||||
|
const [linkError, setLinkError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
meApi()
|
||||||
|
.then((data) => setHasPassword(data.user.hasPassword))
|
||||||
|
.catch((e) => {
|
||||||
|
if (!handleAuthError(e)) setHasPassword(true)
|
||||||
|
})
|
||||||
|
}, [handleAuthError])
|
||||||
|
|
||||||
|
const handleSendSetupLink = async () => {
|
||||||
|
setLinkError('')
|
||||||
|
setLinkSending(true)
|
||||||
|
try {
|
||||||
|
await forgotPasswordApi(user.email)
|
||||||
|
setLinkSent(true)
|
||||||
|
} catch (e) {
|
||||||
|
if (!handleAuthError(e)) setLinkError(e.message)
|
||||||
|
} finally {
|
||||||
|
setLinkSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePwChange = (e) => setPwForm({ ...pwForm, [e.target.name]: e.target.value })
|
||||||
|
|
||||||
|
const handlePwSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setPwError('')
|
||||||
|
setPwSuccess(false)
|
||||||
|
|
||||||
|
if (pwForm.newPassword !== pwForm.confirmPassword) {
|
||||||
|
setPwError('Les nouveaux mots de passe ne correspondent pas.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pwForm.newPassword.length < 8) {
|
||||||
|
setPwError('Le nouveau mot de passe doit faire au moins 8 caractères.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPwLoading(true)
|
||||||
|
try {
|
||||||
|
await changePasswordApi({ currentPassword: pwForm.currentPassword, newPassword: pwForm.newPassword })
|
||||||
|
setPwSuccess(true)
|
||||||
|
setPwForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||||
|
} catch (err) {
|
||||||
|
if (!handleAuthError(err)) setPwError(err.message)
|
||||||
|
} finally {
|
||||||
|
setPwLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EyeIcon = ({ visible }) => (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
|
||||||
|
{visible
|
||||||
|
? <><path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></>
|
||||||
|
: <path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto">
|
||||||
|
<UserNav active="profile" />
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-lg font-bold">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Infos du compte (lecture seule) */}
|
||||||
|
<div className="space-y-4 mb-8">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">Adresse email</label>
|
||||||
|
<div className="w-full border border-gray-100 rounded-lg px-4 py-3 text-sm text-gray-500 bg-gray-50 cursor-not-allowed select-none">
|
||||||
|
{user.email || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">Nom</label>
|
||||||
|
<div className="w-full border border-gray-100 rounded-lg px-4 py-3 text-sm text-gray-500 bg-gray-50">
|
||||||
|
{user.lastname || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">Prénom</label>
|
||||||
|
<div className="w-full border border-gray-100 rounded-lg px-4 py-3 text-sm text-gray-500 bg-gray-50">
|
||||||
|
{user.firstname || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Séparateur */}
|
||||||
|
<div className="border-t border-gray-100 mb-6" />
|
||||||
|
|
||||||
|
{/* Section changement de mot de passe */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-800 mb-4">
|
||||||
|
{hasPassword === false ? 'Définir un mot de passe' : 'Changer mon mot de passe'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{hasPassword === false && (
|
||||||
|
<div className="px-4 py-4 rounded-lg bg-gray-50 border border-gray-200">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Votre compte a été créé avec Google et n'a pas encore de mot de passe.
|
||||||
|
Définissez-en un pour pouvoir aussi vous connecter avec votre email.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{linkSent ? (
|
||||||
|
<p className="mt-3 text-sm text-green-700">
|
||||||
|
Lien envoyé à {user.email}. Consultez votre boîte mail.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSendSetupLink}
|
||||||
|
disabled={linkSending}
|
||||||
|
className="mt-3 bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-medium px-4 py-2.5 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
{linkSending ? 'Envoi…' : 'Recevoir un lien par email'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{linkError && <p className="mt-2 text-sm text-red-600">{linkError}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasPassword !== false && pwSuccess && (
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
Mot de passe modifié avec succès.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasPassword !== false && pwError && (
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||||
|
{pwError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasPassword !== false && (
|
||||||
|
<form onSubmit={handlePwSubmit} className="space-y-4">
|
||||||
|
{[
|
||||||
|
{ name: 'currentPassword', label: 'Mot de passe actuel', key: 'current' },
|
||||||
|
{ name: 'newPassword', label: 'Nouveau mot de passe', key: 'new' },
|
||||||
|
{ name: 'confirmPassword', label: 'Confirmer le nouveau mot de passe', key: 'confirm' },
|
||||||
|
].map(({ name, label, key }) => (
|
||||||
|
<div key={name}>
|
||||||
|
<label htmlFor={name} className="block text-sm font-medium text-gray-800 mb-1">{label}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPw[key] ? 'text' : 'password'}
|
||||||
|
id={name}
|
||||||
|
name={name}
|
||||||
|
value={pwForm[name]}
|
||||||
|
onChange={handlePwChange}
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-11 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPw((s) => ({ ...s, [key]: !s[key] }))}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600"
|
||||||
|
aria-label="Afficher/masquer"
|
||||||
|
>
|
||||||
|
<EyeIcon visible={showPw[key]} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pwLoading}
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-medium py-3 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
{pwLoading ? 'Modification…' : 'Modifier le mot de passe'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
106
frontend/src/pages/ReglesJeu.jsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
export default function ReglesJeu() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const isLoggedIn = !!user
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 min-h-screen">
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="max-w-7xl mx-auto px-4 sm:px-6 py-12 sm:py-16 md:py-20">
|
||||||
|
<div className="text-center max-w-3xl mx-auto">
|
||||||
|
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
Règles du jeu
|
||||||
|
</h1>
|
||||||
|
<p className="text-base sm:text-xl text-gray-600">
|
||||||
|
Participez au jeu-concours ThéTipTop et découvrez immédiatement
|
||||||
|
le lot que vous avez gagné.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Comment participer */}
|
||||||
|
<section className="max-w-6xl mx-auto px-4 sm:px-6 pb-12 sm:pb-16">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] text-center mb-8 sm:mb-10">
|
||||||
|
Comment participer ?
|
||||||
|
</h2>
|
||||||
|
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-4 sm:gap-6">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
num: '1',
|
||||||
|
title: 'Obtenez un code',
|
||||||
|
text: "Achetez un produit ThéTipTop d'une valeur supérieure à 49€ en ligne ou en magasin pour recevoir un code unique à 10 chiffres.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: '2',
|
||||||
|
title: 'Créez votre compte',
|
||||||
|
text: "Inscrivez-vous sur le site du jeu-concours afin d'accéder à votre espace participant.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: '3',
|
||||||
|
title: 'Découvrez votre lot',
|
||||||
|
text: 'Entrez votre code dans votre espace client et découvrez immédiatement votre récompense.',
|
||||||
|
},
|
||||||
|
].map((step) => (
|
||||||
|
<div key={step.num} className="bg-white rounded-2xl shadow p-6 sm:p-8 text-center">
|
||||||
|
<div className="text-3xl sm:text-4xl font-bold text-[#006B6B] mb-3 sm:mb-4">{step.num}</div>
|
||||||
|
<h3 className="text-lg sm:text-xl font-semibold mb-2 sm:mb-3">{step.title}</h3>
|
||||||
|
<p className="text-gray-600 text-sm sm:text-base">{step.text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Lots */}
|
||||||
|
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-16">
|
||||||
|
<div className="bg-white rounded-2xl shadow p-6 sm:p-10">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6 text-center">
|
||||||
|
Que pouvez-vous gagner ?
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 text-base sm:text-lg text-center leading-relaxed mb-6 sm:mb-8">
|
||||||
|
Tous les billets sont gagnants ! Les récompenses peuvent aller
|
||||||
|
d'un infuseur à thé à un coffret découverte d'une valeur de 69€,
|
||||||
|
en passant par des thés détox et des thés signatures.
|
||||||
|
</p>
|
||||||
|
<div className="bg-gray-100 rounded-xl p-4 sm:p-6 text-center">
|
||||||
|
<h3 className="text-xl sm:text-2xl font-bold text-[#006B6B] mb-2">Grand gagnant</h3>
|
||||||
|
<p className="text-gray-700 text-sm sm:text-base">
|
||||||
|
Le participant tiré au sort remportera une année de thé d'une valeur de 360€ !
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-20">
|
||||||
|
<div className="bg-[#006B6B] rounded-2xl p-6 sm:p-10 text-center text-white">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold mb-3 sm:mb-4">
|
||||||
|
Ne manquez pas votre chance !
|
||||||
|
</h2>
|
||||||
|
<p className="mb-6 sm:mb-8 text-base sm:text-lg">
|
||||||
|
Inscrivez-vous, entrez votre code de ticket et retirez votre lot en magasin ou en ligne.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
|
||||||
|
{!isLoggedIn && (
|
||||||
|
<Link
|
||||||
|
to="/inscription"
|
||||||
|
className="bg-white text-[#006B6B] px-6 sm:px-8 py-3 rounded-xl font-semibold hover:bg-[#006B6B] hover:text-white text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Inscrivez-vous
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
to="/saisir-code-unique"
|
||||||
|
className="border border-white px-6 sm:px-8 py-3 rounded-xl font-semibold hover:bg-[#006B6B] hover:text-white text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Entrer mon code de ticket
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
206
frontend/src/pages/SaisirCodeUnique.jsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { participateApi } from '../services/game'
|
||||||
|
import UserNav from '../components/UserNav'
|
||||||
|
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||||
|
|
||||||
|
function formatDateTime(date) {
|
||||||
|
return date.toLocaleDateString('fr-FR', {
|
||||||
|
day: '2-digit', month: 'long', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SaisirCodeUnique() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const handleAuthError = useHandleAuthError()
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [gain, setGain] = useState(null)
|
||||||
|
const [wonAt, setWonAt] = useState(null)
|
||||||
|
const [showDetail, setShowDetail] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await participateApi(code.trim().toUpperCase())
|
||||||
|
setGain(data.gain)
|
||||||
|
setWonAt(new Date())
|
||||||
|
} catch (err) {
|
||||||
|
if (!handleAuthError(err)) {
|
||||||
|
navigate('/perdu', { state: { message: err.message } })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setGain(null)
|
||||||
|
setWonAt(null)
|
||||||
|
setShowDetail(false)
|
||||||
|
setCode('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto">
|
||||||
|
<UserNav active="code" />
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold italic font-serif text-[#006B6B]">Saisir mon code gagnant</h1>
|
||||||
|
<p className="text-gray-500 mt-2 text-sm">Saisissez le code présent sur votre ticket</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<label htmlFor="ticket-code" className="sr-only">Code de votre ticket</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="ticket-code"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||||
|
placeholder="Votre code"
|
||||||
|
maxLength={10}
|
||||||
|
required
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || code.length !== 10}
|
||||||
|
className="w-full bg-[#00786F] hover:bg-[#1A6B65] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Validation...' : 'Valider'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* ── Overlay Félicitation ── */}
|
||||||
|
{gain && !showDetail && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex flex-col items-center justify-between z-50 px-6 py-12">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<h2 className="text-4xl font-bold italic font-serif text-white mb-1">Félicitation !</h2>
|
||||||
|
<p className="text-white/80 text-sm">Vous avez obtenu une récompense</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="bg-white/10 rounded-lg px-6 py-3 text-white font-mono tracking-widest text-lg">
|
||||||
|
{code}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<svg className="w-24 h-24 text-yellow-200" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<p className="text-white text-center font-semibold mb-1">{gain.name}</p>
|
||||||
|
<p className="text-white/70 text-center text-sm mb-6">{gain.description}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDetail(true)}
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005959] text-white font-medium py-3 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Découvrir mon lot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Modal détail du lot ── */}
|
||||||
|
{gain && showDetail && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 px-4">
|
||||||
|
<div className="bg-white rounded-2xl w-full max-w-sm shadow-xl overflow-hidden">
|
||||||
|
|
||||||
|
{/* En-tête coloré */}
|
||||||
|
<div className="bg-[#006B6B] px-6 py-5 relative">
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="absolute top-4 right-4 text-white/70 hover:text-white transition-colors"
|
||||||
|
aria-label="Fermer"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-yellow-200" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white/70 text-xs">Votre lot</p>
|
||||||
|
<p className="text-white font-bold text-base leading-tight">{gain.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Corps */}
|
||||||
|
<div className="px-6 py-5 space-y-4">
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-gray-600 text-sm leading-relaxed">{gain.description}</p>
|
||||||
|
|
||||||
|
{/* Valeur */}
|
||||||
|
{gain.value && (
|
||||||
|
<div className="flex items-center justify-between py-2 border-b border-gray-100">
|
||||||
|
<span className="text-xs text-gray-500 uppercase tracking-wider">Valeur estimée</span>
|
||||||
|
<span className="text-sm font-semibold text-[#006B6B]">{gain.value} €</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Code utilisé */}
|
||||||
|
<div className="flex items-center justify-between py-2 border-b border-gray-100">
|
||||||
|
<span className="text-xs text-gray-500 uppercase tracking-wider">Code utilisé</span>
|
||||||
|
<span className="font-mono text-sm tracking-widest text-gray-700">{code}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date & heure */}
|
||||||
|
{wonAt && (
|
||||||
|
<div className="flex items-center justify-between py-2 border-b border-gray-100">
|
||||||
|
<span className="text-xs text-gray-500 uppercase tracking-wider">Obtenu le</span>
|
||||||
|
<span className="text-sm text-gray-700">{formatDateTime(wonAt)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Statut */}
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<span className="text-xs text-gray-500 uppercase tracking-wider">Statut</span>
|
||||||
|
<span className="text-xs font-semibold px-2.5 py-1 rounded-full bg-yellow-100 text-yellow-700">
|
||||||
|
En attente de remise
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info remise */}
|
||||||
|
<div className="bg-[#006B6B]/5 rounded-xl px-4 py-3 flex gap-3 items-start">
|
||||||
|
<svg className="w-4 h-4 text-[#006B6B] flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-xs text-[#006B6B] leading-relaxed">
|
||||||
|
Présentez ce code en boutique Thé Tip Top pour récupérer votre lot.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="px-6 pb-6 space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/mes-gains')}
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005959] text-white font-medium py-3 rounded-xl transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Voir tout mon historique
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="w-full border border-gray-200 text-gray-500 hover:bg-gray-50 font-medium py-2.5 rounded-xl transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
104
frontend/src/pages/Sitemap.jsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useAuth} from '../context/AuthContext'
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
title: 'Navigation principale',
|
||||||
|
links: [
|
||||||
|
{ label: 'Accueil', to: '/accueil', desc: "Page d'accueil du jeu-concours" },
|
||||||
|
{ label: 'Règles du jeu', to: '/regles-jeu', desc: 'Comment participer et que gagner' },
|
||||||
|
{ label: 'Contact', to: '/contact', desc: 'Nous contacter' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Compte utilisateur',
|
||||||
|
links: [
|
||||||
|
{ label: 'Connexion', to: '/connexion', desc: 'Se connecter à son espace' },
|
||||||
|
{ label: 'Inscription', to: '/inscription', desc: 'Créer un compte participant' },
|
||||||
|
{ label: 'Mon profil', to: '/profile', desc: 'Voir et modifier ses informations' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Jeu-concours',
|
||||||
|
links: [
|
||||||
|
{ label: 'Saisir mon code', to: '/saisir-code-unique', desc: 'Entrer son code ticket' },
|
||||||
|
{ label: 'Mes gains', to: '/mes-gains', desc: 'Historique de ses participations et lots' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Espaces professionnels',
|
||||||
|
links: [
|
||||||
|
{ label: 'Espace employé', to: '/employe', desc: 'Vérifier et remettre un lot' },
|
||||||
|
{ label: 'Tableau de bord admin', to: '/admin', desc: 'Statistiques et gestion du jeu' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Informations légales',
|
||||||
|
links: [
|
||||||
|
{ label: 'Mentions légales', to: '/mentions-legales', desc: 'Informations légales du site' },
|
||||||
|
{ label: 'CGU', to: '/cgu', desc: "Conditions générales d'utilisation" },
|
||||||
|
{ label: 'Politique de confidentialité', to: '/confidentialite', desc: 'Gestion des données personnelles' },
|
||||||
|
{ label: 'Accessibilité', to: '/accessibilite', desc: 'Déclaration de conformité RGAA' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Sitemap() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const isAdmin = user?.role === 'ADMIN'
|
||||||
|
const visibleSections = sections.filter(
|
||||||
|
(section) => section.title !== 'Espaces professionnels' || isAdmin
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
|
||||||
|
<div className="mb-8 sm:mb-10">
|
||||||
|
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-2">
|
||||||
|
Plan du site
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 text-sm sm:text-base">
|
||||||
|
Retrouvez ici l'ensemble des pages disponibles sur le site Thé Tip Top.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-6">
|
||||||
|
{visibleSections.map((section) => (
|
||||||
|
<div key={section.title} className="bg-white rounded-2xl shadow p-5 sm:p-6">
|
||||||
|
<h2 className="text-base sm:text-lg font-bold text-[#006B6B] mb-4 pb-2 border-b border-gray-100">
|
||||||
|
{section.title}
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{section.links.map((link) => (
|
||||||
|
<li key={link.to}>
|
||||||
|
<Link
|
||||||
|
to={link.to}
|
||||||
|
className="group flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mt-0.5 text-[#006B6B] flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-800 group-hover:text-[#006B6B] transition-colors">
|
||||||
|
{link.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">{link.desc}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
frontend/src/services/admin.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||||
|
const BASE = `${API_URL}/api/admin`
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiError(message, status) {
|
||||||
|
const err = new Error(message)
|
||||||
|
err.status = status
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStatsApi() {
|
||||||
|
const res = await fetch(`${BASE}/stats`, { headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur stats.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUsersApi() {
|
||||||
|
const res = await fetch(`${BASE}/users`, { headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur utilisateurs.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function drawWinnerApi() {
|
||||||
|
const res = await fetch(`${BASE}/draw`, { method: 'POST', headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur tirage.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEmailingContactsApi() {
|
||||||
|
const res = await fetch(`${BASE}/emailing`, { headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur contacts emailing.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWinnerApi() {
|
||||||
|
const res = await fetch(`${BASE}/draw`, { headers: authHeaders() })
|
||||||
|
if (res.status === 404) return null
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur gagnant.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
export async function setGameSettingsApi(startDate) {
|
||||||
|
const res = await fetch(`${BASE}/game-settings`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify({ startDate }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur configuration du jeu.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
33
frontend/src/services/analytics.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
const GA_ID = import.meta.env.VITE_GA_ID
|
||||||
|
|
||||||
|
export function initAnalytics() {
|
||||||
|
if (!GA_ID || window.gtag) return
|
||||||
|
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.async = true
|
||||||
|
script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ID}`
|
||||||
|
document.head.appendChild(script)
|
||||||
|
|
||||||
|
window.dataLayer = window.dataLayer || []
|
||||||
|
|
||||||
|
window.gtag = function gtag() {
|
||||||
|
window.dataLayer.push(arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.gtag('js', new Date())
|
||||||
|
|
||||||
|
// Les page_view sont envoyées explicitement lors des changements de route React.
|
||||||
|
window.gtag('config', GA_ID, {
|
||||||
|
send_page_view: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackPageView(path) {
|
||||||
|
if (!GA_ID || !window.gtag) return
|
||||||
|
|
||||||
|
window.gtag('event', 'page_view', {
|
||||||
|
page_path: path,
|
||||||
|
page_location: window.location.href,
|
||||||
|
page_title: document.title,
|
||||||
|
})
|
||||||
|
}
|
||||||
117
frontend/src/services/auth.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||||
|
const BASE = `${API_URL}/api/auth`
|
||||||
|
|
||||||
|
function apiError(message, status) {
|
||||||
|
const err = new Error(message)
|
||||||
|
err.status = status
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginApi({ email, password }) {
|
||||||
|
const res = await fetch(`${BASE}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur de connexion.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerApi({
|
||||||
|
email,
|
||||||
|
firstname,
|
||||||
|
lastname,
|
||||||
|
password,
|
||||||
|
birthdate,
|
||||||
|
gender,
|
||||||
|
marketingOptIn,
|
||||||
|
}) {
|
||||||
|
const res = await fetch(`${BASE}/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
// birthdate et gender sont facultatifs : on omet la clé plutôt que
|
||||||
|
// d'envoyer une chaîne vide, que l'API stockerait telle quelle.
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
firstname,
|
||||||
|
lastname,
|
||||||
|
password,
|
||||||
|
birthdate: birthdate || undefined,
|
||||||
|
gender: gender || undefined,
|
||||||
|
marketingOptIn: marketingOptIn ?? undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || "Erreur lors de l'inscription.", res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyCodeApi({ email, code }) {
|
||||||
|
const res = await fetch(`${BASE}/verify-code`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, code }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Code invalide.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forgotPasswordApi(email) {
|
||||||
|
const res = await fetch(`${BASE}/forgot-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetPasswordApi({ token, password, confirmPassword }) {
|
||||||
|
const res = await fetch(`${BASE}/reset-password/${token}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password, confirmPassword }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur lors de la définition du mot de passe.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function meApi() {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const res = await fetch(`${BASE}/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur lors de la récupération du profil.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAccountApi() {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const res = await fetch(`${BASE}/account`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur lors de la suppression.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changePasswordApi({ currentPassword, newPassword }) {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const res = await fetch(`${BASE}/change-password`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur lors du changement de mot de passe.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
32
frontend/src/services/employee.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||||
|
const BASE = `${API_URL}/api/employee`
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
return { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiError(message, status) {
|
||||||
|
const err = new Error(message)
|
||||||
|
err.status = status
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchTicketApi(code) {
|
||||||
|
const res = await fetch(`${BASE}/ticket/${encodeURIComponent(code.toUpperCase())}`, {
|
||||||
|
headers: authHeaders(),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur lors de la recherche.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deliverClaimApi(claimId) {
|
||||||
|
const res = await fetch(`${BASE}/claim/${claimId}/deliver`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: authHeaders(),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur lors de la remise.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
34
frontend/src/services/game.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||||
|
const BASE = `${API_URL}/api/game`
|
||||||
|
|
||||||
|
|
||||||
|
function apiError(message, status) {
|
||||||
|
const err = new Error(message)
|
||||||
|
err.status = status
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function myGainsApi() {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const res = await fetch(`${BASE}/my-gains`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur lors de la récupération des gains.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function participateApi(code) {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const res = await fetch(`${BASE}/participate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur lors de la participation.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
8
frontend/src/setupTests.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import { afterEach } from 'vitest'
|
||||||
|
import { cleanup } from '@testing-library/react'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
84
frontend/src/test/fonctionnel/Connexion.test.jsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import Connexion from '../../pages/Connexion'
|
||||||
|
import { renderWithProviders } from '../renderWithProviders'
|
||||||
|
import { loginApi } from '../../services/auth'
|
||||||
|
|
||||||
|
// On mocke le service d'API : les tests de composant ne font pas de vraies requêtes HTTP.
|
||||||
|
vi.mock('../../services/auth', () => ({
|
||||||
|
loginApi: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('Connexion', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche le formulaire de connexion', () => {
|
||||||
|
renderWithProviders(<Connexion />, { route: '/connexion' })
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: /se connecter/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/adresse email/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/^mot de passe/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: /se connecter/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche le message de succès après une inscription', () => {
|
||||||
|
renderWithProviders(<Connexion />, {
|
||||||
|
route: { pathname: '/connexion', state: { registered: true } },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText(/compte créé avec succès/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("affiche l'erreur renvoyée par l'API en cas d'échec", async () => {
|
||||||
|
loginApi.mockRejectedValue(new Error('Email ou mot de passe incorrect.'))
|
||||||
|
|
||||||
|
renderWithProviders(<Connexion />, { route: '/connexion' })
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText(/adresse email/i), 'test@example.com')
|
||||||
|
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'mauvais-mdp')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
|
||||||
|
|
||||||
|
expect(await screen.findByText('Email ou mot de passe incorrect.')).toBeInTheDocument()
|
||||||
|
expect(loginApi).toHaveBeenCalledWith({ email: 'test@example.com', password: 'mauvais-mdp' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('connecte un client et redirige vers /mes-gains', async () => {
|
||||||
|
loginApi.mockResolvedValue({
|
||||||
|
token: 'jwt-token',
|
||||||
|
user: { id: 1, email: 'client@example.com', role: 'CLIENT' },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<Connexion />, {
|
||||||
|
route: '/connexion',
|
||||||
|
routes: { '/mes-gains': <p>Page Mes Gains</p> },
|
||||||
|
})
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText(/adresse email/i), 'client@example.com')
|
||||||
|
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
|
||||||
|
|
||||||
|
expect(await screen.findByText('Page Mes Gains')).toBeInTheDocument()
|
||||||
|
expect(localStorage.getItem('token')).toBe('jwt-token')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("connecte un admin et redirige vers /admin", async () => {
|
||||||
|
loginApi.mockResolvedValue({
|
||||||
|
token: 'jwt-token',
|
||||||
|
user: { id: 2, email: 'admin@example.com', role: 'ADMIN' },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<Connexion />, {
|
||||||
|
route: '/connexion',
|
||||||
|
routes: { '/admin': <p>Page Admin</p> },
|
||||||
|
})
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText(/adresse email/i), 'admin@example.com')
|
||||||
|
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
|
||||||
|
|
||||||
|
expect(await screen.findByText('Page Admin')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
39
frontend/src/test/fonctionnel/CookieBanner.test.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import CookieBanner from '../../components/CookieBanner'
|
||||||
|
import { renderWithProviders } from '../renderWithProviders'
|
||||||
|
|
||||||
|
describe('CookieBanner', () => {
|
||||||
|
it("s'affiche quand aucun choix n'a été fait", () => {
|
||||||
|
renderWithProviders(<CookieBanner />)
|
||||||
|
|
||||||
|
expect(screen.getByText(/gestion des cookies/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enregistre le consentement et disparaît quand on accepte', async () => {
|
||||||
|
renderWithProviders(<CookieBanner />)
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /accepter/i }))
|
||||||
|
|
||||||
|
expect(screen.queryByText(/ce site utilise des cookies/i)).not.toBeInTheDocument()
|
||||||
|
expect(localStorage.getItem('cookieConsent')).toBe('accepted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enregistre le refus et disparaît quand on refuse', async () => {
|
||||||
|
renderWithProviders(<CookieBanner />)
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /refuser/i }))
|
||||||
|
|
||||||
|
expect(screen.queryByText(/ce site utilise des cookies/i)).not.toBeInTheDocument()
|
||||||
|
expect(localStorage.getItem('cookieConsent')).toBe('refused')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("ne s'affiche pas si un choix a déjà été fait", () => {
|
||||||
|
localStorage.setItem('cookieConsent', 'accepted')
|
||||||
|
|
||||||
|
renderWithProviders(<CookieBanner />)
|
||||||
|
|
||||||
|
expect(screen.queryByText(/ce site utilise des cookies/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
173
frontend/src/test/fonctionnel/Inscription.test.jsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import Inscription from '../../pages/Inscription'
|
||||||
|
import { renderWithProviders } from '../renderWithProviders'
|
||||||
|
import { registerApi, verifyCodeApi } from '../../services/auth'
|
||||||
|
|
||||||
|
vi.mock('../../services/auth', () => ({
|
||||||
|
registerApi: vi.fn(),
|
||||||
|
verifyCodeApi: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Remplit et soumet le formulaire d'inscription (étape 1).
|
||||||
|
async function remplirEtSoumettre() {
|
||||||
|
await userEvent.type(screen.getByLabelText(/adresse email/i), 'jean@example.com')
|
||||||
|
await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont')
|
||||||
|
await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean')
|
||||||
|
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
|
||||||
|
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saisit le code à 4 chiffres de l'étape de vérification.
|
||||||
|
async function saisirCode(code) {
|
||||||
|
const inputs = screen.getAllByRole('textbox')
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
await userEvent.type(inputs[i], code[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Inscription', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("affiche le formulaire d'inscription", () => {
|
||||||
|
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: /s'inscrire/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/adresse email/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/^nom/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/prénom/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/^mot de passe/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("désactive le bouton tant que les conditions ne sont pas acceptées", async () => {
|
||||||
|
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||||
|
|
||||||
|
const bouton = screen.getByRole('button', { name: /s'inscrire/i })
|
||||||
|
expect(bouton).toBeDisabled()
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
|
||||||
|
expect(bouton).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("passe à l'étape de vérification après une inscription réussie", async () => {
|
||||||
|
registerApi.mockResolvedValue({ message: 'ok' })
|
||||||
|
|
||||||
|
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||||
|
await remplirEtSoumettre()
|
||||||
|
|
||||||
|
expect(registerApi).toHaveBeenCalledWith({
|
||||||
|
email: 'jean@example.com',
|
||||||
|
firstname: 'Jean',
|
||||||
|
lastname: 'Dupont',
|
||||||
|
password: 'MotDePasse123!',
|
||||||
|
birthdate: '',
|
||||||
|
gender: '',
|
||||||
|
marketingOptIn: false,
|
||||||
|
})
|
||||||
|
expect(await screen.findByRole('heading', { name: /vérifiez votre email/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('jean@example.com')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("transmet la date de naissance et le genre saisis", async () => {
|
||||||
|
registerApi.mockResolvedValue({ message: 'ok' })
|
||||||
|
|
||||||
|
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText(/adresse email/i), 'jean@example.com')
|
||||||
|
await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont')
|
||||||
|
await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean')
|
||||||
|
await userEvent.type(screen.getByLabelText(/date de naissance/i), '1990-05-14')
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText(/genre/i), 'Femme')
|
||||||
|
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
|
||||||
|
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i }))
|
||||||
|
|
||||||
|
expect(registerApi).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ birthdate: '1990-05-14', gender: 'Femme' })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("transmet le consentement marketing quand il est coché", async () => {
|
||||||
|
registerApi.mockResolvedValue({ message: 'ok' })
|
||||||
|
|
||||||
|
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||||
|
|
||||||
|
const marketing = screen.getByRole('checkbox', { name: /offres et actualités/i })
|
||||||
|
expect(marketing).not.toBeChecked()
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText(/adresse email/i), 'jean@example.com')
|
||||||
|
await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont')
|
||||||
|
await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean')
|
||||||
|
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
|
||||||
|
await userEvent.click(marketing)
|
||||||
|
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i }))
|
||||||
|
|
||||||
|
expect(registerApi).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ marketingOptIn: true })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("n'exige pas le consentement marketing pour s'inscrire", async () => {
|
||||||
|
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||||
|
|
||||||
|
const bouton = screen.getByRole('button', { name: /s'inscrire/i })
|
||||||
|
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
|
||||||
|
|
||||||
|
expect(screen.getByRole('checkbox', { name: /offres et actualités/i })).not.toBeChecked()
|
||||||
|
expect(bouton).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("affiche l'erreur de l'API si l'inscription échoue", async () => {
|
||||||
|
registerApi.mockRejectedValue(new Error('Cet email est déjà utilisé.'))
|
||||||
|
|
||||||
|
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||||
|
await remplirEtSoumettre()
|
||||||
|
|
||||||
|
expect(await screen.findByText('Cet email est déjà utilisé.')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('heading', { name: /vérifiez votre email/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('connecte et redirige vers /saisir-code-unique quand le code est valide', async () => {
|
||||||
|
registerApi.mockResolvedValue({ message: 'ok' })
|
||||||
|
verifyCodeApi.mockResolvedValue({
|
||||||
|
token: 'jwt-token',
|
||||||
|
user: { id: 1, email: 'jean@example.com', role: 'CLIENT' },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<Inscription />, {
|
||||||
|
route: '/inscription',
|
||||||
|
routes: { '/saisir-code-unique': <p>Page Saisir Code</p> },
|
||||||
|
})
|
||||||
|
await remplirEtSoumettre()
|
||||||
|
await screen.findByRole('heading', { name: /vérifiez votre email/i })
|
||||||
|
|
||||||
|
await saisirCode('1234')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /valider le code/i }))
|
||||||
|
|
||||||
|
expect(verifyCodeApi).toHaveBeenCalledWith({ email: 'jean@example.com', code: '1234' })
|
||||||
|
expect(await screen.findByText('Page Saisir Code')).toBeInTheDocument()
|
||||||
|
expect(localStorage.getItem('token')).toBe('jwt-token')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("affiche l'erreur et vide les champs quand le code est invalide", async () => {
|
||||||
|
registerApi.mockResolvedValue({ message: 'ok' })
|
||||||
|
verifyCodeApi.mockRejectedValue(new Error('Code invalide.'))
|
||||||
|
|
||||||
|
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||||
|
await remplirEtSoumettre()
|
||||||
|
await screen.findByRole('heading', { name: /vérifiez votre email/i })
|
||||||
|
|
||||||
|
await saisirCode('0000')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /valider le code/i }))
|
||||||
|
|
||||||
|
expect(await screen.findByText('Code invalide.')).toBeInTheDocument()
|
||||||
|
for (const input of screen.getAllByRole('textbox')) {
|
||||||
|
expect(input).toHaveValue('')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
80
frontend/src/test/fonctionnel/ProtectedRoute.test.jsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { screen } from '@testing-library/react'
|
||||||
|
import ProtectedRoute from '../../components/ProtectedRoute'
|
||||||
|
import { renderWithProviders } from '../renderWithProviders'
|
||||||
|
|
||||||
|
describe('ProtectedRoute', () => {
|
||||||
|
it("redirige vers /connexion si l'utilisateur n'est pas connecté", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<ProtectedRoute>
|
||||||
|
<p>Contenu protégé</p>
|
||||||
|
</ProtectedRoute>,
|
||||||
|
{
|
||||||
|
route: '/mes-gains',
|
||||||
|
routes: { '/connexion': <p>Page Connexion</p> },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Page Connexion')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Contenu protégé')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("affiche le contenu si l'utilisateur est connecté", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<ProtectedRoute>
|
||||||
|
<p>Contenu protégé</p>
|
||||||
|
</ProtectedRoute>,
|
||||||
|
{
|
||||||
|
route: '/mes-gains',
|
||||||
|
user: { id: 1, email: 'client@example.com', role: 'CLIENT' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Contenu protégé')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche le contenu si le rôle est autorisé', () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<ProtectedRoute roles={['ADMIN']}>
|
||||||
|
<p>Espace admin</p>
|
||||||
|
</ProtectedRoute>,
|
||||||
|
{
|
||||||
|
route: '/admin',
|
||||||
|
user: { id: 2, email: 'admin@example.com', role: 'ADMIN' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Espace admin')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("redirige un client vers son espace s'il tente d'accéder à l'admin", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<ProtectedRoute roles={['ADMIN']}>
|
||||||
|
<p>Espace admin</p>
|
||||||
|
</ProtectedRoute>,
|
||||||
|
{
|
||||||
|
route: '/admin',
|
||||||
|
user: { id: 1, email: 'client@example.com', role: 'CLIENT' },
|
||||||
|
routes: { '/mes-gains': <p>Page Mes Gains</p> },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Page Mes Gains')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Espace admin')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("redirige un employé vers son espace s'il tente d'accéder à l'admin", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<ProtectedRoute roles={['ADMIN']}>
|
||||||
|
<p>Espace admin</p>
|
||||||
|
</ProtectedRoute>,
|
||||||
|
{
|
||||||
|
route: '/admin',
|
||||||
|
user: { id: 3, email: 'employe@example.com', role: 'EMPLOYEE' },
|
||||||
|
routes: { '/employe': <p>Page Employé</p> },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Page Employé')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
93
frontend/src/test/fonctionnel/SaisirCodeUnique.test.jsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import SaisirCodeUnique from '../../pages/SaisirCodeUnique'
|
||||||
|
import Perdu from '../../pages/Perdu'
|
||||||
|
import { renderWithProviders } from '../renderWithProviders'
|
||||||
|
import { participateApi } from '../../services/game'
|
||||||
|
|
||||||
|
vi.mock('../../services/game', () => ({
|
||||||
|
participateApi: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const client = { id: 1, email: 'client@example.com', role: 'CLIENT' }
|
||||||
|
|
||||||
|
async function saisirEtValider(code) {
|
||||||
|
await userEvent.type(screen.getByPlaceholderText(/votre code/i), code)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /valider/i }))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SaisirCodeUnique', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('désactive le bouton tant que le code ne fait pas 10 caractères', async () => {
|
||||||
|
renderWithProviders(<SaisirCodeUnique />, { route: '/saisir-code-unique', user: client })
|
||||||
|
|
||||||
|
const bouton = screen.getByRole('button', { name: /valider/i })
|
||||||
|
expect(bouton).toBeDisabled()
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByPlaceholderText(/votre code/i), 'ABCDE12345')
|
||||||
|
expect(bouton).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('met le code en majuscules et l\'envoie à l\'API', async () => {
|
||||||
|
participateApi.mockResolvedValue({ gain: { name: 'Thé détox' }, claim: { id: 1 } })
|
||||||
|
|
||||||
|
renderWithProviders(<SaisirCodeUnique />, { route: '/saisir-code-unique', user: client })
|
||||||
|
await saisirEtValider('abcde12345')
|
||||||
|
|
||||||
|
expect(participateApi).toHaveBeenCalledWith('ABCDE12345')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("affiche l'écran Félicitation puis le détail du lot quand le code est gagnant", async () => {
|
||||||
|
participateApi.mockResolvedValue({
|
||||||
|
gain: { name: 'Thé signature', description: 'Un thé d\'exception', value: 39 },
|
||||||
|
claim: { id: 1 },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<SaisirCodeUnique />, { route: '/saisir-code-unique', user: client })
|
||||||
|
await saisirEtValider('ABCDE12345')
|
||||||
|
|
||||||
|
// Overlay de félicitation
|
||||||
|
expect(await screen.findByText(/félicitation/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Thé signature')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Détail du lot
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /découvrir mon lot/i }))
|
||||||
|
expect(screen.getByText(/valeur estimée/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('39 €')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/en attente de remise/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("redirige vers /perdu avec le message de l'API quand le code est invalide", async () => {
|
||||||
|
participateApi.mockRejectedValue(new Error('Ce code a déjà été utilisé.'))
|
||||||
|
|
||||||
|
renderWithProviders(<SaisirCodeUnique />, {
|
||||||
|
route: '/saisir-code-unique',
|
||||||
|
user: client,
|
||||||
|
routes: { '/perdu': <Perdu /> },
|
||||||
|
})
|
||||||
|
await saisirEtValider('ABCDE12345')
|
||||||
|
|
||||||
|
expect(await screen.findByText(/réessayer la prochaine fois/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Ce code a déjà été utilisé.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('déconnecte et redirige vers /connexion si la session a expiré (401)', async () => {
|
||||||
|
participateApi.mockRejectedValue(
|
||||||
|
Object.assign(new Error('Session expirée'), { status: 401 })
|
||||||
|
)
|
||||||
|
|
||||||
|
renderWithProviders(<SaisirCodeUnique />, {
|
||||||
|
route: '/saisir-code-unique',
|
||||||
|
user: client,
|
||||||
|
routes: { '/connexion': <p>Page Connexion</p> },
|
||||||
|
})
|
||||||
|
await saisirEtValider('ABCDE12345')
|
||||||
|
|
||||||
|
expect(await screen.findByText('Page Connexion')).toBeInTheDocument()
|
||||||
|
expect(localStorage.getItem('token')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
29
frontend/src/test/renderWithProviders.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from '../context/AuthContext'
|
||||||
|
|
||||||
|
// Rend un composant avec les mêmes providers que l'appli (AuthContext + Router).
|
||||||
|
// - `route` : URL initiale (peut être un objet { pathname, state })
|
||||||
|
// - `user` : utilisateur pré-connecté (écrit dans localStorage avant le rendu,
|
||||||
|
// comme le ferait une vraie session)
|
||||||
|
// - `routes`: routes supplémentaires pour vérifier les redirections,
|
||||||
|
// ex: { '/mes-gains': <p>Page Mes Gains</p> }
|
||||||
|
export function renderWithProviders(ui, { route = '/', user = null, routes = {} } = {}) {
|
||||||
|
if (user) {
|
||||||
|
localStorage.setItem('user', JSON.stringify(user))
|
||||||
|
localStorage.setItem('token', 'fake-token')
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<AuthProvider>
|
||||||
|
<MemoryRouter initialEntries={[route]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path={typeof route === 'string' ? route : route.pathname} element={ui} />
|
||||||
|
{Object.entries(routes).map(([path, element]) => (
|
||||||
|
<Route key={path} path={path} element={element} />
|
||||||
|
))}
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
frontend/src/test/unit/AuthContext.test.jsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { AuthProvider, useAuth } from '../../context/AuthContext'
|
||||||
|
|
||||||
|
// Petit composant de test pour manipuler le contexte comme le ferait une page.
|
||||||
|
function Consumer() {
|
||||||
|
const { user, login, logout } = useAuth()
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>{user ? `Connecté : ${user.email}` : 'Déconnecté'}</p>
|
||||||
|
<button onClick={() => login({ email: 'a@b.fr', role: 'CLIENT' }, 'mon-jwt')}>
|
||||||
|
se connecter
|
||||||
|
</button>
|
||||||
|
<button onClick={logout}>se déconnecter</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AuthContext', () => {
|
||||||
|
it("n'a pas d'utilisateur par défaut", () => {
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<Consumer />
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Déconnecté')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restaure la session depuis le localStorage', () => {
|
||||||
|
localStorage.setItem('user', JSON.stringify({ email: 'deja@la.fr', role: 'CLIENT' }))
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<Consumer />
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Connecté : deja@la.fr')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('login met à jour le user et écrit token + user dans le localStorage', async () => {
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<Consumer />
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'se connecter' }))
|
||||||
|
|
||||||
|
expect(screen.getByText('Connecté : a@b.fr')).toBeInTheDocument()
|
||||||
|
expect(localStorage.getItem('token')).toBe('mon-jwt')
|
||||||
|
expect(JSON.parse(localStorage.getItem('user'))).toEqual({ email: 'a@b.fr', role: 'CLIENT' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logout vide le user et le localStorage', async () => {
|
||||||
|
localStorage.setItem('token', 'mon-jwt')
|
||||||
|
localStorage.setItem('user', JSON.stringify({ email: 'a@b.fr', role: 'CLIENT' }))
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<Consumer />
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'se déconnecter' }))
|
||||||
|
|
||||||
|
expect(screen.getByText('Déconnecté')).toBeInTheDocument()
|
||||||
|
expect(localStorage.getItem('token')).toBeNull()
|
||||||
|
expect(localStorage.getItem('user')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
102
frontend/src/test/unit/auth.service.test.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { loginApi, registerApi, verifyCodeApi, changePasswordApi } from '../../services/auth'
|
||||||
|
|
||||||
|
// Tests unitaires du service auth : on mocke fetch, aucune vraie requête HTTP.
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
|
||||||
|
function jsonResponse(body, { ok = true, status = 200 } = {}) {
|
||||||
|
return Promise.resolve({ ok, status, json: () => Promise.resolve(body) })
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', mockFetch)
|
||||||
|
mockFetch.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loginApi', () => {
|
||||||
|
it('envoie email et mot de passe et renvoie les données', async () => {
|
||||||
|
const payload = { token: 'jwt', user: { email: 'a@b.fr', role: 'CLIENT' } }
|
||||||
|
mockFetch.mockReturnValue(jsonResponse(payload))
|
||||||
|
|
||||||
|
const data = await loginApi({ email: 'a@b.fr', password: 'secret' })
|
||||||
|
|
||||||
|
expect(data).toEqual(payload)
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: 'a@b.fr', password: 'secret' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("lève une erreur avec le message et le status de l'API en cas d'échec", async () => {
|
||||||
|
mockFetch.mockReturnValue(
|
||||||
|
jsonResponse({ message: 'Identifiants invalides.' }, { ok: false, status: 401 })
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(loginApi({ email: 'a@b.fr', password: 'faux' })).rejects.toMatchObject({
|
||||||
|
message: 'Identifiants invalides.',
|
||||||
|
status: 401,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("utilise un message par défaut si l'API n'en renvoie pas", async () => {
|
||||||
|
mockFetch.mockReturnValue(jsonResponse({}, { ok: false, status: 500 }))
|
||||||
|
|
||||||
|
await expect(loginApi({ email: 'a@b.fr', password: 'x' })).rejects.toThrow(
|
||||||
|
'Erreur de connexion.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('registerApi', () => {
|
||||||
|
it("envoie les informations d'inscription", async () => {
|
||||||
|
mockFetch.mockReturnValue(jsonResponse({ message: 'ok' }))
|
||||||
|
|
||||||
|
await registerApi({ email: 'a@b.fr', firstname: 'Jean', lastname: 'Dupont', password: 'secret' })
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: 'a@b.fr', firstname: 'Jean', lastname: 'Dupont', password: 'secret' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('verifyCodeApi', () => {
|
||||||
|
it('renvoie le token et le user quand le code est valide', async () => {
|
||||||
|
const payload = { token: 'jwt', user: { email: 'a@b.fr' } }
|
||||||
|
mockFetch.mockReturnValue(jsonResponse(payload))
|
||||||
|
|
||||||
|
const data = await verifyCodeApi({ email: 'a@b.fr', code: '1234' })
|
||||||
|
|
||||||
|
expect(data).toEqual(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lève "Code invalide." par défaut en cas d\'échec', async () => {
|
||||||
|
mockFetch.mockReturnValue(jsonResponse({}, { ok: false, status: 400 }))
|
||||||
|
|
||||||
|
await expect(verifyCodeApi({ email: 'a@b.fr', code: '0000' })).rejects.toThrow('Code invalide.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('changePasswordApi', () => {
|
||||||
|
it('envoie le token du localStorage dans le header Authorization', async () => {
|
||||||
|
localStorage.setItem('token', 'mon-jwt')
|
||||||
|
mockFetch.mockReturnValue(jsonResponse({ message: 'ok' }))
|
||||||
|
|
||||||
|
await changePasswordApi({ currentPassword: 'ancien', newPassword: 'nouveau' })
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/auth/change-password', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer mon-jwt',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ currentPassword: 'ancien', newPassword: 'nouveau' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
68
frontend/src/test/unit/game.service.test.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { myGainsApi, participateApi } from '../../services/game'
|
||||||
|
|
||||||
|
// Tests unitaires du service game : on mocke fetch, aucune vraie requête HTTP.
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
|
||||||
|
function jsonResponse(body, { ok = true, status = 200 } = {}) {
|
||||||
|
return Promise.resolve({ ok, status, json: () => Promise.resolve(body) })
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', mockFetch)
|
||||||
|
mockFetch.mockReset()
|
||||||
|
localStorage.setItem('token', 'mon-jwt')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('myGainsApi', () => {
|
||||||
|
it('appelle /api/game/my-gains avec le token et renvoie les gains', async () => {
|
||||||
|
const payload = { gains: [{ id: 1, name: 'Thé détox' }] }
|
||||||
|
mockFetch.mockReturnValue(jsonResponse(payload))
|
||||||
|
|
||||||
|
const data = await myGainsApi()
|
||||||
|
|
||||||
|
expect(data).toEqual(payload)
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/game/my-gains', {
|
||||||
|
headers: { Authorization: 'Bearer mon-jwt' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("lève une erreur avec le status en cas d'échec (ex: session expirée)", async () => {
|
||||||
|
mockFetch.mockReturnValue(
|
||||||
|
jsonResponse({ message: 'Non autorisé.' }, { ok: false, status: 401 })
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(myGainsApi()).rejects.toMatchObject({ message: 'Non autorisé.', status: 401 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('participateApi', () => {
|
||||||
|
it('envoie le code en POST avec le token', async () => {
|
||||||
|
const payload = { gain: { name: 'Thé signature' }, claim: { id: 1 } }
|
||||||
|
mockFetch.mockReturnValue(jsonResponse(payload))
|
||||||
|
|
||||||
|
const data = await participateApi('ABCDE12345')
|
||||||
|
|
||||||
|
expect(data).toEqual(payload)
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/game/participate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer mon-jwt',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ code: 'ABCDE12345' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("transmet le message d'erreur de l'API (code déjà utilisé, invalide...)", async () => {
|
||||||
|
mockFetch.mockReturnValue(
|
||||||
|
jsonResponse({ message: 'Ce code a déjà été utilisé.' }, { ok: false, status: 400 })
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(participateApi('ABCDE12345')).rejects.toThrow('Ce code a déjà été utilisé.')
|
||||||
|
})
|
||||||
|
})
|
||||||
51
frontend/src/test/unit/useHandleAuthError.test.jsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { useHandleAuthError } from '../../hooks/useHandleAuthError'
|
||||||
|
import { renderWithProviders } from '../renderWithProviders'
|
||||||
|
|
||||||
|
// Composant de test : déclenche le hook avec l'erreur passée en prop.
|
||||||
|
function TestComponent({ error }) {
|
||||||
|
const handleAuthError = useHandleAuthError()
|
||||||
|
const [handled, setHandled] = useState(null)
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => setHandled(handleAuthError(error))}>déclencher</button>
|
||||||
|
{handled !== null && <p>géré : {String(handled)}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useHandleAuthError', () => {
|
||||||
|
it('déconnecte et redirige vers /connexion sur une erreur 401', async () => {
|
||||||
|
const error = Object.assign(new Error('Session expirée'), { status: 401 })
|
||||||
|
|
||||||
|
renderWithProviders(<TestComponent error={error} />, {
|
||||||
|
route: '/mes-gains',
|
||||||
|
user: { email: 'a@b.fr', role: 'CLIENT' },
|
||||||
|
routes: { '/connexion': <p>Page Connexion</p> },
|
||||||
|
})
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'déclencher' }))
|
||||||
|
|
||||||
|
expect(screen.getByText('Page Connexion')).toBeInTheDocument()
|
||||||
|
expect(localStorage.getItem('token')).toBeNull()
|
||||||
|
expect(localStorage.getItem('user')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne fait rien et renvoie false pour une autre erreur', async () => {
|
||||||
|
const error = Object.assign(new Error('Erreur serveur'), { status: 500 })
|
||||||
|
|
||||||
|
renderWithProviders(<TestComponent error={error} />, {
|
||||||
|
route: '/mes-gains',
|
||||||
|
user: { email: 'a@b.fr', role: 'CLIENT' },
|
||||||
|
routes: { '/connexion': <p>Page Connexion</p> },
|
||||||
|
})
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'déclencher' }))
|
||||||
|
|
||||||
|
expect(screen.getByText('géré : false')).toBeInTheDocument()
|
||||||
|
expect(localStorage.getItem('token')).toBe('fake-token')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,4 +4,14 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:5000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: './src/setupTests.js',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||