68 Commits

Author SHA1 Message Date
4c3e6cd384 ci: enforce SonarQube quality gate
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 60.55% (333/550) * Lines of Code: 550 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 45
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 86.14% (230/267) * Lines of Code: 267 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-28 09:08:06 +00:00
55fb17634d security: remove hardcoded admin credentials 2026-08-28 09:05:11 +00:00
4d7ca7945b ci: make SonarQube quality gate non-blocking
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 60.91% (335/550) * Lines of Code: 550 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 45
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 86.14% (230/267) * Lines of Code: 267 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-24 20:06:32 +00:00
c22c72e99c fix: provide Node.js to SonarQube analysis
Some checks failed
Tests / Backend - Tests skipped: 7, passed: 27
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 60.91% (335/550) * Lines of Code: 550 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 45
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 86.14% (230/267) * Lines of Code: 267 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-08-24 19:53:42 +00:00
efe5073aaa fix: use existing SonarQube project key
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 60.55% (333/550) * Lines of Code: 550 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 45
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 86.14% (230/267) * Lines of Code: 267 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-24 19:45:51 +00:00
39b7b7f8a4 fix: avoid duplicate SonarQube test indexing
Some checks failed
Tests / Backend - Tests skipped: 7, passed: 27
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 60.55% (333/550) * Lines of Code: 550 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 45
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 86.14% (230/267) * Lines of Code: 267 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-08-24 19:42:55 +00:00
f8c02b3cfc ci: integrate SonarQube analysis and quality gate
Some checks failed
Tests / Backend - Tests skipped: 7, passed: 27
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 60.55% (333/550) * Lines of Code: 550 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 45
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 86.14% (230/267) * Lines of Code: 267 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-08-24 19:38:44 +00:00
35bffc8a0b ci: publish coverage reports in Jenkins
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 60.55% (333/550) * Lines of Code: 550 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 45
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 86.14% (230/267) * Lines of Code: 267 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-24 18:51:03 +00:00
2d54a7384f merge: integrate quality reports
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
Tests / Frontend - Lint, Tests et Build passed: 45
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-24 18:30:32 +00:00
ab8ea038c0 ci: publish frontend test and coverage reports
Some checks failed
Tests / Backend - Tests No test results found
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-08-24 18:25:58 +00:00
79e10946b8 merge: integrate Google Analytics
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-23 10:35:40 +00:00
79f463d280 feat: integrate consent-based Google Analytics 2026-08-23 10:35:33 +00:00
a09e9f17b5 fix: load OAuth secrets from server env file
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-20 23:44:36 +00:00
a40a62d600 fix: use backend API URL for Google OAuth
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-20 23:36:54 +00:00
12685c1b22 fix: resolve deployment compose conflicts
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-20 23:27:51 +00:00
3a0dd25ed2 ci: seed test database before backend tests
Some checks failed
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-08-20 23:15:38 +00:00
a572fa1a15 merge: integrate Google OAuth and application fixes into develop
Some checks failed
Tests / Backend - Tests failed: 25, skipped: 7, passed: 6
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-08-20 23:09:19 +00:00
f9d72d45e2 merge: integrate Google OAuth and latest application fixes 2026-08-20 23:01:39 +00:00
Emma781227
2cf399d6d0 correction des bugue front end 2026-08-20 21:49:36 +02:00
Emma781227
e895325254 refont de la connexion par google et validation de lenvoie de mail a l'utilisater 2026-08-19 21:56:18 +02:00
Emma781227
fa21d9af2a ajout des champs date de naissance et gender sur l'inscription, modification de la permition de lancer le jeu concour par l'administrateur, envoie des mail a chaque lot remporter 2026-08-18 15:06:51 +02:00
6ee3dfdb87 release: promote 1.0.2 to production
All checks were successful
Tests / Backend - Tests passed: 8
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-17 15:59:13 +00:00
ecda263518 ci: retry deployment with Jenkins SMTP secret mount
All checks were successful
Tests / Backend - Tests passed: 8
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-17 15:30:55 +00:00
1f11a4e3dd ci: retry develop deployment after SMTP secret setup
Some checks failed
Tests / Backend - Tests passed: 8
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-08-17 15:24:50 +00:00
117ebac770 fix: configure SMTP and public API URLs for deployments
Some checks failed
Tests / Backend - Tests passed: 8
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-08-17 15:19:32 +00:00
d9d2eaba3d merge: promote validated develop to main 2026-08-17 14:55:36 +00:00
047d34f59b fix: resolve frontend lint issues
All checks were successful
Tests / Backend - Tests passed: 8
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-17 14:54:58 +00:00
0f1ad0b2ef merge: promote final team version to develop
Some checks failed
Tests / Backend - Tests passed: 8
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-08-17 14:29:00 +00:00
b57186ad6d Merge remote-tracking branch 'github/main' 2026-08-17 14:23:22 +00:00
a9afa48ec2 merge: integrate Alexis and Emmanuel latest changes with CI/CD 2026-08-17 14:14:34 +00:00
a1da7b9e17 fix: finalize API configuration and backend lockfile 2026-08-17 14:12:58 +00:00
3e12fa2d01 fix: configure frontend services for environment API URLs 2026-08-17 12:27:14 +00:00
09e6c86ca0 merge: integrate Emmanuel latest changes 2026-08-17 11:55:07 +00:00
87577cd14c merge: integrate Alexis latest changes 2026-08-17 11:46:41 +00:00
Emma781227
8adf780da2 Variables de connexion externe dans les environnements Docker
Ajoute FRONTEND_URL, absent des trois environnements alors que le callback
OAuth et la liste d'origines CORS en dépendent : sans lui le backend
retombait sur localhost:5173 et refusait le front déployé.

Les identifiants Google et Facebook passent par substitution depuis
l'environnement du pipeline, afin de ne pas committer de secret.

Ignore aussi test-results/, généré par le reporter jest-junit ajouté par la
chaîne CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:25:54 +02:00
Emma781227
bd46e0982e Merge remote-tracking branch 'origin/main' into Emmanuel
Intègre la chaîne CI/CD et la dockerisation venues de main.

Conflits sur backend/package-lock.json et frontend/package-lock.json :
résolus en régénérant les deux lockfiles depuis les package.json fusionnés,
qui réunissent les dépendances des deux branches (helmet, express-rate-limit,
passport, passport-google-oauth20 côté Emmanuel ; jest-junit côté main).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:24:34 +02:00
Emma781227
464d666343 Connexion via Google (OAuth 2.0)
Ajoute l'authentification Google en complément de la connexion par mot de
passe. Le callback OAuth émet le même JWT que la connexion classique, donc
le middleware d'authentification et ProtectedRoute restent inchangés.

Backend
- stratégie passport-google-oauth20 dans src/config/passport.js
- routes GET /api/auth/google et /google/callback, documentées dans Swagger
- un compte existant avec le même email est rattaché automatiquement au
  compte Google, et son email passe vérifié
- les routes répondent 503 si les identifiants ne sont pas configurés,
  plutôt que de faire échouer le démarrage du serveur
- login et changePassword ne comparent plus un hash inexistant sur les
  comptes sans mot de passe

Base de données
- password devient nullable
- colonnes provider, providerId et avatarUrl, index unique sur le couple
  provider/providerId

Frontend
- page /auth/callback qui récupère le token et redirige selon le rôle
- boutons Google de Connexion et Inscription branchés
- boutons Facebook désactivés en attendant les identifiants Meta

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:23:18 +02:00
Emma781227
20c865a071 Sécurisation de l'API et refonte des tests backend
- helmet et express-rate-limit sur les routes d'authentification
- CORS restreint aux origines de FRONTEND_URL
- CSP désactivée uniquement sur /api-docs pour Swagger UI
- ajout du test de tirage et simplification du test e2e
- suppression de tests/setup.js devenu inutile

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:23:02 +02:00
cdbacfebb5 ci: validate end-to-end deployment
All checks were successful
Tests / Backend - Tests passed: 8
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-15 08:39:14 +00:00
68dbf02c51 Merge branch 'release-1.0.1'
All checks were successful
Tests / Backend - Tests passed: 8
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-14 23:38:40 +00:00
deb847f26f Merge branch 'main' of https://github.com/Anilzer/THE-TIP-TOP 2026-08-10 19:29:48 +00:00
9bb9ab8e51 Merge branch 'develop'
All checks were successful
Tests / Backend - Tests passed: 8
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-10 19:26:39 +00:00
Andil ABAYOMI
2790fc89ff Merge pull request #2 from Anilzer/develop
Develop
2026-08-10 20:16:58 +01:00
d071f737bb Merge pull request 'develop' (#2) from develop into main
All checks were successful
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
Reviewed-on: http://gitea.furious.local/andil/THE-TIP-TOP/pulls/2
2026-08-04 21:38:40 +00:00
Alexis SANOGO
4d31b03116 Précision projet fictif sur les pages footer, mentions légales et CGU 2026-08-02 08:28:46 +02:00
Emma781227
a2543cf4ce Merge remote-tracking branch 'origin/Alexis' into Emmanuel
# Conflicts:
#	frontend/src/pages/Contact.jsx
2026-07-27 20:54:11 +02:00
Alexis SANOGO
3258f06ecb actualisation des données de mentions légales et ajout des pages CGU et accessibilite 2026-07-27 18:20:01 +02:00
Alexis SANOGO
6d96ea958a Correction des couleurs de textes et de fond 2026-07-27 17:59:40 +02:00
Emma781227
894ebdc000 Merge remote-tracking branch 'origin/Alexis' into Emmanuel 2026-07-23 15:12:15 +02:00
Alexis SANOGO
1c68b2bc0d condition d'affichage reservé à l'admin 2026-07-23 15:06:14 +02:00
Alexis SANOGO
2f7b2d2b64 Correction de redirection 2026-07-23 14:08:04 +02:00
Emma781227
902e304a5e changement 2026-07-23 13:45:53 +02:00
Emma781227
7f816c165b changement 2026-07-23 13:45:11 +02:00
Alexis SANOGO
4f0593bc38 Gestion de marges internes (padding) 2026-07-21 16:33:12 +02:00
Emma781227
b6506435d5 test 2026-07-20 10:35:35 +02:00
Emma781227
65d6b23233 mise a jour 2026-07-08 07:10:49 +02:00
Emma781227
68a9650223 mise a jour 2026-07-01 22:06:22 +02:00
Emma781227
efd10d6754 Merge remote-tracking branch 'origin/main' into Emmanuel 2026-07-01 21:17:39 +02:00
Alexis SANOGO
fba0a7133c modification favicon et balise title + disable champ email sur la page profile 2026-06-26 15:57:05 +02:00
Emma781227
2d25c91c55 changes 2026-06-26 11:26:45 +02:00
Emma781227
1cf2a24ede Ajout case à cocher CGU sur inscription et script création admin
- Inscription : checkbox obligatoire avec liens mentions légales et confidentialité
- Script createAdmin.js pour créer le compte administrateur en base

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 13:45:15 +02:00
Emma781227
4ea733440f Responsive : toutes les pages et composants Header/Footer
- Header : menu hamburger animé pour mobile, nav cachée sur petit écran
- Footer : grid 2 colonnes sur mobile, 4 colonnes sur desktop
- Accueil : titres et paddings adaptés mobile/tablette/desktop
- ReglesJeu : titres, cartes et CTA responsives
- MentionsLegales / Confidentialite : paddings et titres adaptés mobile
- Contact : padding et formulaire adaptés mobile
- Admin : tableau avec scroll horizontal sur mobile
- Employe : formulaire de recherche en colonne sur mobile
- Ajout composants Layout, Header, Footer et logo depuis branche Alexis

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 10:19:52 +02:00
Emma781227
4727a3a268 Merge Alexis : ajout Layout, Accueil, ReglesJeu et pages légales
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 10:06:45 +02:00
Emma781227
428bf2597a Refonte front : flux inscription/connexion, saisie code et historique gains
- Redesign Connexion, Inscription avec modal succès et compte à rebours
- Page SaisirCodeUnique connectée au backend avec overlay Félicitation
- Page Perdu pour les codes invalides/déjà utilisés
- MesGains redesigné en timeline avec appel API et bouton saisir nouveau code
- Profile redesigné avec onglets et données depuis localStorage
- Service game.js ajouté (participateApi, myGainsApi)
- Ajout des designs de référence dans frontend/Design/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 10:04:18 +02:00
Alexis SANOGO
e2d045c0ab Ajout de la page d'accueil, règles du jeu, confidentialité, mentions légales et contact 2026-06-19 16:05:11 +02:00
Emma781227
25be38027d incription , connection 2026-06-16 11:22:03 +02:00
Alexis SANOGO
72e7771d95 Ajout de la page SaisirCodeUnique 2026-06-12 16:48:19 +02:00
Alexis SANOGO
c1ca68da9a Ajout de la page Profile 2026-06-12 16:12:19 +02:00
95 changed files with 8406 additions and 542 deletions

109
Jenkinsfile vendored
View File

@@ -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
" "
''' '''
@@ -175,16 +180,35 @@ pipeline {
allowEmptyResults: true allowEmptyResults: true
) )
recordCoverage(
tools: [[
parser: 'LCOV',
pattern: 'backend/coverage/lcov.info'
]],
sourceCodeRetention: 'EVERY_BUILD',
id: 'backend-coverage',
name: 'Backend Coverage'
)
publishHTML([
allowMissing: true,
alwaysLinkToLastBuild: true,
keepAll: true,
reportDir: 'backend/coverage',
reportFiles: 'index.html',
reportName: 'Backend Coverage HTML'
])
archiveArtifacts( archiveArtifacts(
artifacts: 'backend/coverage/**', artifacts: 'backend/coverage/**',
fingerprint: true, fingerprint: true,
allowEmptyArchive: true allowEmptyArchive: true
) )
} }
} }
} }
stage('Frontend - Lint et Build') { stage('Frontend - Lint, Tests et Build') {
steps { steps {
sh ''' sh '''
docker run --rm \ docker run --rm \
@@ -208,12 +232,92 @@ pipeline {
sh -c " sh -c "
npm ci --prefer-offline --no-audit --no-fund && npm ci --prefer-offline --no-audit --no-fund &&
npm run lint && npm run lint &&
npm run test:coverage &&
npm run build npm run build
" "
''' '''
} }
post {
always {
junit(
testResults: 'frontend/test-results/*.xml',
allowEmptyResults: true
)
recordCoverage(
tools: [[
parser: 'LCOV',
pattern: 'frontend/coverage/lcov.info'
]],
sourceCodeRetention: 'EVERY_BUILD',
id: 'frontend-coverage',
name: 'Frontend Coverage'
)
publishHTML([
allowMissing: true,
alwaysLinkToLastBuild: true,
keepAll: true,
reportDir: 'frontend/coverage',
reportFiles: 'index.html',
reportName: 'Frontend Coverage HTML'
])
archiveArtifacts(
artifacts: 'frontend/coverage/**',
fingerprint: true,
allowEmptyArchive: true
)
}
}
} }
stage('SonarQube Analysis') {
when {
branch 'develop'
}
tools {
nodejs 'node22'
}
steps {
script {
def scannerHome = tool 'sonar-scanner'
withEnv(["SCANNER_HOME=${scannerHome}"]) {
withSonarQubeEnv('sonarqube') {
sh '''
echo "Node utilisé par Sonar :"
node -v
"${SCANNER_HOME}/bin/sonar-scanner" \
-Dsonar.projectKey=THE-TIP-TOP \
-Dsonar.projectName=THE-TIP-TOP \
-Dsonar.sources=backend/src,frontend/src \
-Dsonar.tests=backend/tests,frontend/src/test \
-Dsonar.exclusions=frontend/src/test/**,backend/tests/**,**/node_modules/**,**/coverage/**,**/dist/** \
-Dsonar.test.inclusions=backend/tests/**,frontend/src/test/** \
-Dsonar.javascript.lcov.reportPaths=backend/coverage/lcov.info,frontend/coverage/lcov.info \
-Dsonar.sourceEncoding=UTF-8
'''
}
}
}
}
}
stage('SonarQube Quality Gate') {
when {
branch 'develop'
}
steps {
timeout(time: 5, unit: 'MINUTES') {
waitForQualityGate abortPipeline: true
}
}
}
stage('Construction des images Docker') { stage('Construction des images Docker') {
when { when {
expression { expression {
@@ -229,6 +333,7 @@ pipeline {
docker build \ docker build \
--build-arg VITE_API_URL="${FRONTEND_API_URL}" \ --build-arg VITE_API_URL="${FRONTEND_API_URL}" \
--build-arg VITE_GA_ID="${FRONTEND_GA_ID}" \
-t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \ -t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \
frontend frontend
''' '''

View File

@@ -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
View File

@@ -3,3 +3,4 @@ node_modules/
.prisma .prisma
coverage/ coverage/
dist/ dist/
test-results/

View File

@@ -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",

View File

@@ -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",

View File

@@ -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")
);

View File

@@ -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");

View File

@@ -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())
}

View File

@@ -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()

View File

@@ -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) => {

View 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 };

View File

@@ -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,
}; };

View File

@@ -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
// ne pourrait ni se connecter (email non vérifié) ni se réinscrire (email
// déjà pris). On annule donc la création pour que la tentative reste
// rejouable.
try {
await transporter.sendMail({ await transporter.sendMail({
from: process.env.MAIL_FROM, from: process.env.MAIL_FROM,
to: email, to: email,
subject: "Validation de votre compte Thé Tip Top", subject: "Votre code de validation Thé Tip Top",
html: ` html: `
<h2>Bienvenue ${firstname}</h2> <div style="font-family:sans-serif;max-width:480px;margin:auto">
<p>Merci pour votre inscription.</p> <h2 style="color:#006B6B">Bienvenue ${firstname} !</h2>
<p>Veuillez cliquer sur le lien ci-dessous pour valider votre compte :</p> <p>Merci pour votre inscription sur Thé Tip Top.</p>
<a href="${verifyUrl}">Valider mon compte</a> <p>Voici votre code de validation à 4 chiffres :</p>
<p>Ce lien expire dans 1 heure.</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,
}; };

View File

@@ -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.",
}); });

View File

@@ -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,
})), })),
}); });

View File

@@ -10,7 +10,7 @@ const options = {
}, },
servers: [ servers: [
{ {
url: "http://localhost:5000", url: process.env.API_PUBLIC_URL || "http://localhost:5000",
}, },
], ],
components: { components: {

View File

@@ -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({

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,45 @@
require('dotenv').config()
const bcrypt = require('bcrypt')
const prisma = require('../config/prisma')
async function createAdmin() {
const email = process.env.ADMIN_EMAIL
const password = process.env.ADMIN_PASSWORD
if (!email || !password) {
throw new Error('ADMIN_EMAIL et ADMIN_PASSWORD doivent être définis')
}
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())

View 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,
};

View File

@@ -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,
}; };

View 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()
);
});

View 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);
});
});

View File

@@ -10,53 +10,58 @@ 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",
email: "test@test.com",
password: "Password123", 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({
email: "test@test.com",
password: "Password123", password: "Password123",
}); });
@@ -65,70 +70,28 @@ describe("THE TIP TOP - FULL E2E FLOW", () => {
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")

View 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();
});
});

View 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);
});
});

View 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");
});
});

View 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;

View 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");
});
});

View File

@@ -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();
});

View File

@@ -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 "

View File

@@ -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 "

View File

@@ -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 "

4
frontend/.gitignore vendored
View File

@@ -22,3 +22,7 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Tests / coverage
coverage/
test-results/

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
frontend/Design/profil .png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -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

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,10 @@
"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",
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"react": "^19.2.6", "react": "^19.2.6",
@@ -17,14 +20,20 @@
"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",
"@vitest/coverage-v8": "^4.1.11",
"eslint": "^10.3.0", "eslint": "^10.3.0",
"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"
} }
} }

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

View 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

View File

@@ -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>
) )
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}

View 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>
)
}

View 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)
}

View 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])
}

View 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>
)
}

View 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>
)
}

View File

@@ -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%',
function StatCard({ label, value, sub }) {
return (
<div className="bg-white rounded-xl shadow p-4 text-center">
<p className="text-2xl sm:text-3xl font-bold text-[#006B6B]">{value ?? '—'}</p>
<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>}
</div>
)
} }
const MOCK_USERS = [ // Les cinq états renvoyés par l'API, chacun avec sa couleur : « programmé »
{ id: 1, name: 'Jean Dupont', email: 'jean.dupont@email.com', gains: 2 }, // ne doit pas être confondu avec « retraits uniquement ».
{ id: 2, name: 'Marie Martin', email: 'marie.martin@email.com', gains: 1 }, const PHASE_BADGES = {
{ id: 3, name: 'Paul Bernard', email: 'paul.bernard@email.com', gains: 3 }, NOT_CONFIGURED: { label: 'Non lancé', className: 'bg-amber-100 text-amber-700' },
] SCHEDULED: { label: 'Programmé', className: 'bg-indigo-100 text-indigo-700' },
OPEN: { label: 'Participations ouvertes', className: 'bg-green-100 text-green-700' },
CLAIM_ONLY: { label: 'Retraits uniquement', className: 'bg-blue-100 text-blue-700' },
CLOSED: { label: 'Terminé', className: 'bg-red-100 text-red-700' },
}
const PHASE_PANELS = {
NOT_CONFIGURED: 'bg-amber-50 border border-amber-200',
SCHEDULED: 'bg-indigo-50 border border-indigo-200',
OPEN: 'bg-white',
CLAIM_ONLY: 'bg-white',
CLOSED: 'bg-red-50 border border-red-200',
}
const PHASE_TITLES = {
NOT_CONFIGURED: 'Jeu-concours non lancé',
SCHEDULED: 'Jeu-concours programmé',
OPEN: 'Jeu-concours en cours',
CLAIM_ONLY: 'Participations closes, retraits en cours',
CLOSED: 'Jeu-concours terminé',
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: '2-digit', month: 'long', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
export default function Admin() { export default function Admin() {
return ( const handleAuthError = useHandleAuthError()
<div className="min-h-screen bg-gray-50 p-6"> const [stats, setStats] = useState(null)
<div className="max-w-4xl mx-auto"> const [users, setUsers] = useState([])
<h1 className="text-2xl font-bold text-gray-800 mb-6">Tableau de bord Admin</h1> const [winner, setWinner] = useState(undefined)
const [drawing, setDrawing] = useState(false)
const [drawError, setDrawError] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showConfirm, setShowConfirm] = useState(false)
{/* Stats */} // Lancement du jeu-concours
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8"> 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: 'Participants', value: MOCK_STATS.totalParticipants }, { label: 'Femmes', value: stats.genderStats.women },
{ label: 'Gains remis', value: MOCK_STATS.gainsRemis }, { label: 'Hommes', value: stats.genderStats.men },
{ label: 'En attente', value: MOCK_STATS.gainsEnAttente }, { label: 'Autre / non renseigné', value: stats.genderStats.other },
{ label: 'Taux participation', value: MOCK_STATS.tauxParticipation }, ].map((row) => (
].map((stat) => ( <div key={row.label} className="flex items-center justify-between text-sm">
<div key={stat.label} className="bg-white rounded-xl shadow p-4 text-center"> <span className="text-gray-500">{row.label}</span>
<p className="text-2xl font-bold text-green-700">{stat.value}</p> <span className="font-semibold text-gray-800">{row.value}</span>
<p className="text-sm text-gray-500 mt-1">{stat.label}</p>
</div> </div>
))} ))}
</div> </div>
{/* Tirage au sort */}
<div className="bg-white rounded-xl shadow p-6 mb-6">
<h2 className="text-lg font-bold text-gray-800 mb-3">Grand tirage au sort</h2>
<button
onClick={() => alert('TODO: connecter au backend')}
className="bg-green-700 hover:bg-green-800 text-white font-semibold px-6 py-2.5 rounded-lg transition-colors"
>
Lancer le tirage
</button>
</div> </div>
{/* Liste participants */} <div className="bg-white rounded-xl shadow p-4 sm:p-6">
<div className="bg-white rounded-xl shadow p-6"> <h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Âge des participants</h2>
<h2 className="text-lg font-bold text-gray-800 mb-4">Participants</h2> <div className="space-y-2">
<table className="w-full text-sm"> {['-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> <thead>
<tr className="text-left text-gray-500 border-b"> <tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
<th className="pb-2">Nom</th> <th className="pb-2 px-4 sm:px-0">Lot</th>
<th className="pb-2">Email</th> <th className="pb-2 px-4 sm:px-0 text-center">%</th>
<th className="pb-2 text-right">Gains</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> </tr>
</thead> </thead>
<tbody> <tbody>
{MOCK_USERS.map((user) => ( {stats.lotsStats.map((lot) => (
<tr key={user.id} className="border-b last:border-0"> <tr key={lot.id} className="border-b last:border-0">
<td className="py-3 font-medium text-gray-800">{user.name}</td> <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-3 text-gray-500">{user.email}</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-3 text-right text-green-700 font-semibold">{user.gains}</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> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
</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&apos;email de notification n&apos;a pas pu être envoyé au gagnant. Merci de le contacter directement à l&apos;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>
) )
} }

View 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
View 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>
)
}

View 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>
)
}

View File

@@ -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> </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"> <form onSubmit={handleSubmit} className="space-y-5">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="email" className="block text-sm font-medium text-gray-800 mb-1">
Email Adresse email <span className="text-gray-800">*</span>
</label> </label>
<input <input
type="email" type="email"
id="email"
name="email" name="email"
value={form.email} value={form.email}
onChange={handleChange} onChange={handleChange}
required required
placeholder="exemple@email.com" placeholder="Votre email"
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 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="password" className="block text-sm font-medium text-gray-800 mb-1">
Mot de passe Mot de passe <span className="text-gray-800">*</span>
</label> </label>
<div className="relative">
<input <input
type="password" type={showPassword ? 'text' : 'password'}
id="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 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> </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 {loading ? 'Connexion...' : 'Se connecter'}
</button> </button>
</form> </form>
<p className="text-center text-sm text-gray-500 mt-6"> <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={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 ?{' '} Pas encore de compte ?{' '}
<Link to="/inscription" className="text-green-700 font-medium hover:underline"> <Link to="/inscription" className="text-gray-700 font-medium hover:underline">
S'inscrire S'inscrire
</Link> </Link>
</p> </p>
</div> </div>
</div>
) )
} }

View 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>
)
}

View 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>
)
}

View File

@@ -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 handleAuthError = useHandleAuthError()
const [code, setCode] = useState('') const [code, setCode] = useState('')
const [gain, setGain] = useState(null) 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>
<p className="text-sm text-gray-500 font-mono">{gain.code}</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'}
{/* 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>
)}
{/* 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 */}
<button <div className="px-5 py-4 space-y-1 border-b border-gray-100">
onClick={handleDeliver} <p className="text-xs text-gray-500 font-medium uppercase tracking-wider mb-2">Ticket</p>
className="w-full bg-green-700 hover:bg-green-800 text-white font-semibold py-2.5 rounded-lg transition-colors mt-2" <p className="text-sm text-gray-700">
> <span className="font-medium">Code :</span>{' '}
Confirmer la remise du lot <span className="font-mono">{result.ticket.code}</span>
</button> </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> </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
onClick={handleReset}
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"
>
Rechercher un autre code
</button>
</div>
</div>
)}
</div> </div>
</div> </div>
) )

View File

@@ -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 navigate = useNavigate()
const { login } = useAuth()
// Étape : 'form' | 'verify'
const [step, setStep] = useState('form')
const [email, setEmail] = useState('')
const [form, setForm] = useState({ const [form, setForm] = useState({
firstName: '',
lastName: '',
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)
}
} }
// Étape 2 — Saisie du code
const handleDigit = (index, value) => {
if (!/^\d?$/.test(value)) return
const next = [...digits]
next[index] = value
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 ( 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="mb-8 text-center">
<div className="text-center mb-8"> <div className="w-14 h-14 rounded-full bg-[#006B6B]/10 flex items-center justify-center mx-auto mb-4">
<h1 className="text-3xl font-bold text-green-800">Thé Tip Top</h1> <svg className="w-7 h-7 text-[#006B6B]" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<p className="text-gray-500 mt-1">Créer votre compte</p> <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>
<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>
<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>
) )
} }

View 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>
)
}

View File

@@ -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()
setGains(data.gains)
} catch (err) {
if (!handleAuthError(err)) setError(err.message)
} finally {
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> </div>
<span className={`text-xs font-semibold px-3 py-1 rounded-full ${statusLabel[gain.status].color}`}>
{statusLabel[gain.status].label} {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>
)}
{!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> </span>
</div> </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> </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>
) )

View 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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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&apos;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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}

View 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,
})
}

View 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
}

View 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
}

View 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
}

View File

@@ -0,0 +1,8 @@
import '@testing-library/jest-dom'
import { afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
afterEach(() => {
cleanup()
localStorage.clear()
})

View 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()
})
})

View 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()
})
})

View 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('')
}
})
})

View 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()
})
})

View 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()
})
})

View 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>
)
}

View 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()
})
})

View 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' }),
})
})
})

View 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é.')
})
})

View 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')
})
})

View File

@@ -4,4 +4,29 @@ 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',
// Jenkins pourra lire directement ce fichier.
reporters: ['default', 'junit'],
outputFile: {
junit: './test-results/junit.xml',
},
// Rapports utilisés ensuite par Jenkins et SonarQube.
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
reportsDirectory: './coverage',
},
},
}) })