Compare commits
72 Commits
d071f737bb
...
release-1.
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d7ca7945b | |||
| c22c72e99c | |||
| efe5073aaa | |||
| 39b7b7f8a4 | |||
| f8c02b3cfc | |||
| 35bffc8a0b | |||
| 2d54a7384f | |||
| ab8ea038c0 | |||
| 79e10946b8 | |||
| 79f463d280 | |||
| a09e9f17b5 | |||
| a40a62d600 | |||
| 12685c1b22 | |||
| 3a0dd25ed2 | |||
| a572fa1a15 | |||
| f9d72d45e2 | |||
|
|
2cf399d6d0 | ||
|
|
e895325254 | ||
|
|
fa21d9af2a | ||
| 6ee3dfdb87 | |||
| ecda263518 | |||
| 1f11a4e3dd | |||
| 117ebac770 | |||
| d9d2eaba3d | |||
| 047d34f59b | |||
| 0f1ad0b2ef | |||
| b57186ad6d | |||
| a9afa48ec2 | |||
| a1da7b9e17 | |||
| 3e12fa2d01 | |||
| 09e6c86ca0 | |||
| 87577cd14c | |||
|
|
8adf780da2 | ||
|
|
bd46e0982e | ||
|
|
464d666343 | ||
|
|
20c865a071 | ||
| cdbacfebb5 | |||
| 68dbf02c51 | |||
| 02cf98f6ca | |||
| 825fedf118 | |||
| 61296dda0c | |||
| 2bbd2d16e4 | |||
| deb847f26f | |||
| 9bb9ab8e51 | |||
| b15d8458c8 | |||
|
|
2790fc89ff | ||
| 100b75a05a | |||
| d57b26c8c8 | |||
|
|
4d31b03116 | ||
|
|
a2543cf4ce | ||
|
|
3258f06ecb | ||
|
|
6d96ea958a | ||
|
|
894ebdc000 | ||
|
|
1c68b2bc0d | ||
|
|
2f7b2d2b64 | ||
|
|
902e304a5e | ||
|
|
7f816c165b | ||
|
|
4f0593bc38 | ||
|
|
b6506435d5 | ||
|
|
65d6b23233 | ||
|
|
68a9650223 | ||
|
|
efd10d6754 | ||
|
|
fba0a7133c | ||
|
|
2d25c91c55 | ||
|
|
1cf2a24ede | ||
|
|
4ea733440f | ||
|
|
4727a3a268 | ||
|
|
428bf2597a | ||
|
|
e2d045c0ab | ||
|
|
25be38027d | ||
|
|
72e7771d95 | ||
|
|
c1ca68da9a |
155
Jenkinsfile
vendored
@@ -8,7 +8,7 @@ pipeline {
|
||||
}
|
||||
|
||||
environment {
|
||||
REGISTRY = "localhost:5000"
|
||||
REGISTRY = "registry.wk-archi-o24a-4-5-g4.fr"
|
||||
|
||||
BACKEND_IMAGE = "${REGISTRY}/the-tip-top/backend"
|
||||
FRONTEND_IMAGE = "${REGISTRY}/the-tip-top/frontend"
|
||||
@@ -48,7 +48,8 @@ pipeline {
|
||||
env.COMPOSE_PROJECT = 'tiptop-dev'
|
||||
env.DEPLOY_NETWORK = 'tiptop-dev'
|
||||
|
||||
env.FRONTEND_API_URL = 'http://192.168.1.23:5001'
|
||||
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-')) {
|
||||
env.DEPLOY_ENABLED = 'true'
|
||||
@@ -58,7 +59,8 @@ pipeline {
|
||||
env.COMPOSE_PROJECT = 'tiptop-preprod'
|
||||
env.DEPLOY_NETWORK = 'tiptop-preprod'
|
||||
|
||||
env.FRONTEND_API_URL = 'http://192.168.1.23:5002'
|
||||
env.FRONTEND_API_URL = "https://api-preprod.dsp5-archi-o24a-4-5-g4.fr"
|
||||
env.FRONTEND_GA_ID = ""
|
||||
|
||||
} else if (env.BRANCH_NAME == 'main') {
|
||||
env.DEPLOY_ENABLED = 'true'
|
||||
@@ -68,7 +70,8 @@ pipeline {
|
||||
env.COMPOSE_PROJECT = 'tiptop-prod'
|
||||
env.DEPLOY_NETWORK = 'tiptop-prod'
|
||||
|
||||
env.FRONTEND_API_URL = 'http://192.168.1.23:5003'
|
||||
env.FRONTEND_API_URL = "https://api.dsp5-archi-o24a-4-5-g4.fr"
|
||||
env.FRONTEND_GA_ID = "G-4FTNTPJDRJ"
|
||||
|
||||
} else {
|
||||
env.DEPLOY_ENABLED = 'false'
|
||||
@@ -78,12 +81,14 @@ pipeline {
|
||||
env.COMPOSE_PROJECT = ''
|
||||
env.DEPLOY_NETWORK = ''
|
||||
env.FRONTEND_API_URL = ''
|
||||
env.FRONTEND_GA_ID = ""
|
||||
}
|
||||
|
||||
echo "Branche : ${env.BRANCH_NAME}"
|
||||
echo "Tag Docker : ${env.IMAGE_TAG}"
|
||||
echo "Environnement : ${env.DEPLOY_ENV}"
|
||||
echo "Déploiement activé : ${env.DEPLOY_ENABLED}"
|
||||
echo "API frontend : ${env.FRONTEND_API_URL}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,6 +141,10 @@ pipeline {
|
||||
|
||||
sleep 2
|
||||
done
|
||||
docker run --rm \
|
||||
-v tiptop-npm-cache-backend:/npm-cache \
|
||||
alpine \
|
||||
sh -c 'chown -R 1000:1000 /npm-cache'
|
||||
|
||||
docker run --rm \
|
||||
--network "${CI_NETWORK}" \
|
||||
@@ -158,16 +167,56 @@ pipeline {
|
||||
npm ci --prefer-offline --no-audit --no-fund &&
|
||||
npx prisma generate &&
|
||||
npx prisma migrate deploy &&
|
||||
npx prisma db seed &&
|
||||
npm test
|
||||
"
|
||||
'''
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
junit(
|
||||
testResults: 'backend/test-results/*.xml',
|
||||
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(
|
||||
artifacts: 'backend/coverage/**',
|
||||
fingerprint: true,
|
||||
allowEmptyArchive: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Frontend - Lint et Build') {
|
||||
stage('Frontend - Lint, Tests et Build') {
|
||||
steps {
|
||||
sh '''
|
||||
docker run --rm \
|
||||
-v tiptop-npm-cache-frontend:/npm-cache \
|
||||
alpine \
|
||||
sh -c 'chown -R 1000:1000 /npm-cache'
|
||||
|
||||
docker run --rm \
|
||||
--volumes-from furious-jenkins \
|
||||
-v tiptop-npm-cache-frontend:/npm-cache \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
@@ -183,12 +232,92 @@ pipeline {
|
||||
sh -c "
|
||||
npm ci --prefer-offline --no-audit --no-fund &&
|
||||
npm run lint &&
|
||||
npm run test:coverage &&
|
||||
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: false
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Construction des images Docker') {
|
||||
when {
|
||||
expression {
|
||||
@@ -204,6 +333,7 @@ pipeline {
|
||||
|
||||
docker build \
|
||||
--build-arg VITE_API_URL="${FRONTEND_API_URL}" \
|
||||
--build-arg VITE_GA_ID="${FRONTEND_GA_ID}" \
|
||||
-t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \
|
||||
frontend
|
||||
'''
|
||||
@@ -218,12 +348,26 @@ pipeline {
|
||||
}
|
||||
|
||||
steps {
|
||||
withCredentials([
|
||||
usernamePassword(
|
||||
credentialsId: 'registry-credentials',
|
||||
usernameVariable: 'REGISTRY_USER',
|
||||
passwordVariable: 'REGISTRY_PASSWORD'
|
||||
)
|
||||
]) {
|
||||
sh '''
|
||||
echo "${REGISTRY_PASSWORD}" | \
|
||||
docker login "${REGISTRY}" \
|
||||
-u "${REGISTRY_USER}" \
|
||||
--password-stdin
|
||||
|
||||
docker push "${BACKEND_IMAGE}:${IMAGE_TAG}"
|
||||
docker push "${FRONTEND_IMAGE}:${IMAGE_TAG}"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
stage('Validation production') {
|
||||
when {
|
||||
@@ -317,6 +461,7 @@ pipeline {
|
||||
post {
|
||||
always {
|
||||
sh '''
|
||||
docker logout "${REGISTRY}" 2>/dev/null || true
|
||||
docker rm -f "${CI_DATABASE}" 2>/dev/null || true
|
||||
docker network rm "${CI_NETWORK}" 2>/dev/null || true
|
||||
'''
|
||||
|
||||
@@ -4,9 +4,18 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/the_tip_top_db"
|
||||
|
||||
JWT_SECRET="your_secret_key"
|
||||
|
||||
APP_URL=http://localhost:5000
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USER=yourmail_adress@gmail.com
|
||||
MAIL_PASS=_______________________
|
||||
MAIL_FROM="Thé Tip Top <yourmail_adress@gmail.com>"
|
||||
APP_URL="http://localhost:5000"
|
||||
FRONTEND_URL="http://localhost:5173"
|
||||
MAIL_HOST="smtp.gmail.com"
|
||||
MAIL_PORT="587"
|
||||
MAIL_USER=abayomiandil62@gmail.com
|
||||
MAIL_PASS=dumqamoupdrliygf
|
||||
MAIL_FROM="Thé Tip Top <abayomiandil62@gmail.com>"
|
||||
|
||||
# Connexion externe (OAuth 2.0). Laisser vide désactive proprement le bouton
|
||||
# correspondant : la route répond 503 au lieu de faire planter le serveur.
|
||||
# URI de redirection à déclarer côté provider : APP_URL + /api/auth/google/callback
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
FACEBOOK_APP_ID=""
|
||||
FACEBOOK_APP_SECRET=""
|
||||
|
||||
1
backend/.gitignore
vendored
@@ -3,3 +3,4 @@ node_modules/
|
||||
.prisma
|
||||
coverage/
|
||||
dist/
|
||||
test-results/
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
module.exports = {
|
||||
testEnvironment: "node",
|
||||
|
||||
// Dossier des tests
|
||||
testMatch: ["**/tests/**/*.test.js"],
|
||||
|
||||
// Nettoyage des mocks entre les tests
|
||||
clearMocks: true,
|
||||
|
||||
// Timeout (utile pour Prisma / DB)
|
||||
testTimeout: 10000,
|
||||
|
||||
// Affichage des logs de test
|
||||
verbose: true,
|
||||
|
||||
// Couverture de code (optionnel mais bon pour soutenance)
|
||||
collectCoverage: true,
|
||||
coverageDirectory: "coverage",
|
||||
|
||||
coverageReporters: [
|
||||
"text",
|
||||
"lcov",
|
||||
"html"
|
||||
],
|
||||
|
||||
// Fichiers à ignorer
|
||||
testPathIgnorePatterns: ["/node_modules/"],
|
||||
reporters: [
|
||||
"default",
|
||||
[
|
||||
"jest-junit",
|
||||
{
|
||||
outputDirectory: "./test-results",
|
||||
outputName: "junit.xml"
|
||||
}
|
||||
]
|
||||
],
|
||||
|
||||
// Configuration pour Jest avec Supertest
|
||||
//setupFilesAfterEnv: ["<rootDir>/tests/setup.js"]
|
||||
testPathIgnorePatterns: ["/node_modules/"]
|
||||
};
|
||||
216
backend/package-lock.json
generated
@@ -13,8 +13,12 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"cors": "^2.8.6",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.6.1",
|
||||
"helmet": "^8.3.0",
|
||||
"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",
|
||||
"swagger-jsdoc": "^6.3.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
@@ -25,6 +29,7 @@
|
||||
"cross-env": "^10.1.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"jest": "^30.4.2",
|
||||
"jest-junit": "^17.0.0",
|
||||
"nodemon": "^3.1.14",
|
||||
"supertest": "^7.2.2"
|
||||
}
|
||||
@@ -604,22 +609,22 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
|
||||
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"@emnapi/wasi-threads": "1.2.3",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -629,9 +634,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
||||
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -2196,6 +2201,15 @@
|
||||
"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": {
|
||||
"version": "2.10.40",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
||||
@@ -3209,6 +3223,25 @@
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||
@@ -3657,6 +3690,18 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
@@ -3765,6 +3810,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"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": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -4457,6 +4511,22 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-junit": {
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-junit/-/jest-junit-17.0.0.tgz",
|
||||
"integrity": "sha512-RYWCkq4j59gUXj5DsgbIE7xFBZzu1gtibPhyjSjMmGaOTLnqlXhg7x9zuGCwgbCuMAyoyvk0Mi8wSrRR5uOeLA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"mkdirp": "^1.0.4",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"uuid": "^14.0.0",
|
||||
"xml": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-leak-detector": {
|
||||
"version": "30.4.1",
|
||||
"resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz",
|
||||
@@ -5375,6 +5445,19 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
||||
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -5457,9 +5540,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
||||
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
|
||||
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -5578,6 +5661,12 @@
|
||||
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
||||
"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": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -5738,6 +5827,64 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -5799,6 +5946,11 @@
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
@@ -6744,6 +6896,12 @@
|
||||
"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": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
@@ -6836,6 +6994,29 @@
|
||||
"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": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
|
||||
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||
@@ -6942,6 +7123,13 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xml": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -20,8 +20,12 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"cors": "^2.8.6",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.6.1",
|
||||
"helmet": "^8.3.0",
|
||||
"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",
|
||||
"swagger-jsdoc": "^6.3.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
@@ -32,6 +36,7 @@
|
||||
"cross-env": "^10.1.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"jest": "^30.4.2",
|
||||
"jest-junit": "^17.0.0",
|
||||
"nodemon": "^3.1.14",
|
||||
"supertest": "^7.2.2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "GameSettings" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"startDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "GameSettings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AuthProvider" AS ENUM ('LOCAL', 'GOOGLE', 'FACEBOOK');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "avatarUrl" TEXT,
|
||||
ADD COLUMN "provider" "AuthProvider" NOT NULL DEFAULT 'LOCAL',
|
||||
ADD COLUMN "providerId" TEXT,
|
||||
ALTER COLUMN "password" DROP NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_provider_providerId_key" ON "User"("provider", "providerId");
|
||||
@@ -24,12 +24,20 @@ enum ClaimStatus {
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum AuthProvider {
|
||||
LOCAL
|
||||
GOOGLE
|
||||
FACEBOOK
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
firstname String
|
||||
lastname String
|
||||
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)
|
||||
birthdate DateTime?
|
||||
gender String?
|
||||
@@ -43,10 +51,16 @@ model User {
|
||||
resetPasswordToken String?
|
||||
resetPasswordExpires DateTime?
|
||||
|
||||
provider AuthProvider @default(LOCAL)
|
||||
providerId String?
|
||||
avatarUrl String?
|
||||
|
||||
tickets Ticket[]
|
||||
participations Participation[]
|
||||
grandPrizeWinner GrandPrizeWinner?
|
||||
claimsValidated Claim[] @relation("EmployeeClaims")
|
||||
|
||||
@@unique([provider, providerId])
|
||||
}
|
||||
|
||||
model Lot {
|
||||
@@ -116,3 +130,12 @@ model GrandPrizeWinner {
|
||||
prize String @default("Un an de thé d'une valeur de 360€")
|
||||
drawnAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// Ligne unique de configuration : date de début du jeu-concours.
|
||||
// Participation et réclamation des lots sont autorisées pendant 60 jours
|
||||
// (30 jours de jeu + 30 jours supplémentaires) à compter de startDate.
|
||||
model GameSettings {
|
||||
id Int @id @default(autoincrement())
|
||||
startDate DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
@@ -39,6 +39,14 @@ async function main() {
|
||||
});
|
||||
|
||||
console.log("Lots créés avec succès");
|
||||
|
||||
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()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const express = require("express");
|
||||
const cors = require("cors");
|
||||
const helmet = require("helmet");
|
||||
const rateLimit = require("express-rate-limit");
|
||||
require("dotenv").config();
|
||||
|
||||
const authRoutes = require("./routes/auth.routes");
|
||||
@@ -9,11 +11,37 @@ const adminRoutes = require("./routes/admin.routes");
|
||||
|
||||
const swaggerUi = require("swagger-ui-express");
|
||||
const swaggerSpec = require("./docs/swagger");
|
||||
const { passport } = require("./config/passport");
|
||||
|
||||
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());
|
||||
// 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
|
||||
app.use("/api/auth", authRoutes);
|
||||
@@ -22,7 +50,7 @@ app.use("/api/employee", employeeRoutes);
|
||||
app.use("/api/admin", adminRoutes);
|
||||
|
||||
// swagger
|
||||
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||
app.use("/api-docs", helmet({ contentSecurityPolicy: false }), swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||
|
||||
// test route
|
||||
app.get("/", (req, res) => {
|
||||
|
||||
136
backend/src/config/passport.js
Normal file
@@ -0,0 +1,136 @@
|
||||
const passport = require("passport");
|
||||
const GoogleStrategy = require("passport-google-oauth20").Strategy;
|
||||
const crypto = require("crypto");
|
||||
const prisma = require("./prisma");
|
||||
const transporter = require("./mailer");
|
||||
|
||||
// Un compte créé via un provider externe n'a pas de mot de passe. On lui en
|
||||
// propose un dès l'inscription, via le même mécanisme de jeton que la
|
||||
// réinitialisation : sans mot de passe, l'utilisateur dépend entièrement de
|
||||
// Google pour se connecter, et le formulaire de changement de son profil lui
|
||||
// reste inaccessible.
|
||||
const PASSWORD_SETUP_EXPIRY_MS = 1000 * 60 * 60 * 24; // 24 h
|
||||
|
||||
async function sendPasswordSetupEmail(user) {
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
resetPasswordToken: token,
|
||||
resetPasswordExpires: new Date(Date.now() + PASSWORD_SETUP_EXPIRY_MS),
|
||||
},
|
||||
});
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
||||
const setupUrl = `${frontendUrl}/definir-mot-de-passe/${token}`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.MAIL_FROM,
|
||||
to: user.email,
|
||||
subject: "Définissez votre mot de passe Thé Tip Top",
|
||||
html: `
|
||||
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||
<h2 style="color:#006B6B">Bienvenue ${user.firstname} !</h2>
|
||||
<p>Votre compte a été créé avec Google. Vous pouvez continuer à vous
|
||||
connecter de cette façon, ou définir un mot de passe pour accéder
|
||||
également à votre compte sans passer par Google.</p>
|
||||
<p style="text-align:center;padding:20px 0">
|
||||
<a href="${setupUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block">
|
||||
Définir mon mot de passe
|
||||
</a>
|
||||
</p>
|
||||
<p style="color:#888;font-size:13px">Ce lien expire dans 24 heures. Si vous
|
||||
ne souhaitez pas de mot de passe, ignorez simplement cet email : votre
|
||||
connexion Google continuera de fonctionner.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
// Authentification stateless : le callback OAuth émet directement notre JWT,
|
||||
// donc pas de session Express ni de serialize/deserialize à brancher.
|
||||
|
||||
const googleEnabled =
|
||||
Boolean(process.env.GOOGLE_CLIENT_ID) && Boolean(process.env.GOOGLE_CLIENT_SECRET);
|
||||
|
||||
if (googleEnabled) {
|
||||
passport.use(
|
||||
new GoogleStrategy(
|
||||
{
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: `${process.env.APP_URL || "http://localhost:5000"}/api/auth/google/callback`,
|
||||
scope: ["profile", "email"],
|
||||
},
|
||||
async (accessToken, refreshToken, profile, done) => {
|
||||
try {
|
||||
const email = profile.emails?.[0]?.value;
|
||||
|
||||
if (!email) {
|
||||
return done(null, false, {
|
||||
message: "Aucune adresse email n'est associée à ce compte Google.",
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Compte déjà lié à ce compte Google : connexion directe.
|
||||
const byProvider = await prisma.user.findUnique({
|
||||
where: {
|
||||
provider_providerId: { provider: "GOOGLE", providerId: profile.id },
|
||||
},
|
||||
});
|
||||
|
||||
if (byProvider) return done(null, byProvider);
|
||||
|
||||
// 2. Un compte existe avec le même email : on le rattache à Google.
|
||||
// L'email étant déjà validé par Google, on en profite pour lever
|
||||
// la vérification si elle était en attente.
|
||||
const byEmail = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
if (byEmail) {
|
||||
const linked = await prisma.user.update({
|
||||
where: { id: byEmail.id },
|
||||
data: {
|
||||
provider: "GOOGLE",
|
||||
providerId: profile.id,
|
||||
isEmailVerified: true,
|
||||
emailVerifyToken: null,
|
||||
emailVerifyExpires: null,
|
||||
avatarUrl: byEmail.avatarUrl || profile.photos?.[0]?.value || null,
|
||||
},
|
||||
});
|
||||
return done(null, linked);
|
||||
}
|
||||
|
||||
// 3. Première connexion : création du compte.
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
firstname: profile.name?.givenName || profile.displayName || "Utilisateur",
|
||||
lastname: profile.name?.familyName || "",
|
||||
email,
|
||||
password: null,
|
||||
provider: "GOOGLE",
|
||||
providerId: profile.id,
|
||||
avatarUrl: profile.photos?.[0]?.value || null,
|
||||
isEmailVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Un envoi qui échoue ne doit pas bloquer la connexion : le compte
|
||||
// est valide, et « mot de passe oublié » permet de rattraper.
|
||||
try {
|
||||
await sendPasswordSetupEmail(created);
|
||||
} catch (mailError) {
|
||||
console.error("Erreur envoi email de definition du mot de passe:", mailError);
|
||||
}
|
||||
|
||||
return done(null, created);
|
||||
} catch (error) {
|
||||
return done(error);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { passport, googleEnabled };
|
||||
@@ -1,8 +1,42 @@
|
||||
const bcrypt = require("bcrypt");
|
||||
const crypto = require("crypto");
|
||||
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) => {
|
||||
try {
|
||||
const gameWindow = await getGameWindow();
|
||||
|
||||
const [
|
||||
ticketsTotal,
|
||||
ticketsUsed,
|
||||
@@ -13,6 +47,7 @@ const getStats = async (req, res) => {
|
||||
users,
|
||||
men,
|
||||
women,
|
||||
clientsWithBirthdate,
|
||||
] = await Promise.all([
|
||||
prisma.ticket.count(),
|
||||
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", gender: "Homme" } }),
|
||||
prisma.user.count({ where: { role: "CLIENT", gender: "Femme" } }),
|
||||
prisma.user.findMany({ where: { role: "CLIENT" }, select: { birthdate: true } }),
|
||||
]);
|
||||
|
||||
const lotsStats = await prisma.lot.findMany({
|
||||
@@ -44,10 +80,14 @@ const getStats = async (req, res) => {
|
||||
ticketsUsed,
|
||||
ticketsRemaining: ticketsTotal - ticketsUsed,
|
||||
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,
|
||||
claimsCancelled,
|
||||
claimsCancelled: gameWindow.isOpen ? claimsCancelled : claimsCancelled + claimsPending,
|
||||
users,
|
||||
gameWindow,
|
||||
ageStats: computeAgeStats(clientsWithBirthdate),
|
||||
genderStats: {
|
||||
men,
|
||||
women,
|
||||
@@ -177,6 +217,31 @@ const createEmployee = async (req, res) => {
|
||||
|
||||
const drawWinner = async (req, res) => {
|
||||
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({
|
||||
include: {
|
||||
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({
|
||||
where: {
|
||||
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 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({
|
||||
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,
|
||||
emailSent,
|
||||
winner: savedWinner,
|
||||
});
|
||||
} 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) => {
|
||||
try {
|
||||
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({
|
||||
winner,
|
||||
winner: winner ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erreur récupération gagnant:", error);
|
||||
@@ -289,4 +434,6 @@ module.exports = {
|
||||
createEmployee,
|
||||
drawWinner,
|
||||
getGrandPrizeWinner,
|
||||
setGameSettings,
|
||||
getGameSettings,
|
||||
};
|
||||
@@ -34,8 +34,8 @@ const register = async (req, res) => {
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const emailVerifyToken = crypto.randomBytes(32).toString("hex");
|
||||
const emailVerifyExpires = new Date(Date.now() + 1000 * 60 * 60); // 1h
|
||||
const emailVerifyToken = String(Math.floor(1000 + Math.random() * 9000));
|
||||
const emailVerifyExpires = new Date(Date.now() + 1000 * 60 * 15); // 15 min
|
||||
|
||||
const user = await prisma.user.create({
|
||||
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({
|
||||
from: process.env.MAIL_FROM,
|
||||
to: email,
|
||||
subject: "Validation de votre compte Thé Tip Top",
|
||||
subject: "Votre code de validation Thé Tip Top",
|
||||
html: `
|
||||
<h2>Bienvenue ${firstname}</h2>
|
||||
<p>Merci pour votre inscription.</p>
|
||||
<p>Veuillez cliquer sur le lien ci-dessous pour valider votre compte :</p>
|
||||
<a href="${verifyUrl}">Valider mon compte</a>
|
||||
<p>Ce lien expire dans 1 heure.</p>
|
||||
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||
<h2 style="color:#006B6B">Bienvenue ${firstname} !</h2>
|
||||
<p>Merci pour votre inscription sur Thé Tip Top.</p>
|
||||
<p>Voici votre code de validation à 4 chiffres :</p>
|
||||
<div style="font-size:36px;font-weight:bold;letter-spacing:12px;color:#006B6B;text-align:center;padding:20px 0">
|
||||
${emailVerifyToken}
|
||||
</div>
|
||||
<p style="color:#888;font-size:13px">Ce code expire dans <strong>15 minutes</strong>.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (mailError) {
|
||||
console.error("Erreur envoi du code de validation:", mailError);
|
||||
|
||||
try {
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
} catch (rollbackError) {
|
||||
console.error("Echec de l'annulation de l'inscription:", rollbackError);
|
||||
}
|
||||
|
||||
return res.status(502).json({
|
||||
message:
|
||||
"Votre compte n'a pas pu être créé : l'email de validation n'a pas pu être envoyé. Merci de réessayer dans quelques instants.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
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,
|
||||
});
|
||||
} 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({
|
||||
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);
|
||||
|
||||
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({
|
||||
from: process.env.MAIL_FROM,
|
||||
@@ -253,7 +286,7 @@ const forgotPassword = async (req, res) => {
|
||||
const resetPassword = async (req, res) => {
|
||||
try {
|
||||
const { token } = req.params;
|
||||
const { password } = req.body;
|
||||
const { password, confirmPassword } = req.body;
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
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({
|
||||
where: {
|
||||
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) => {
|
||||
return res.json({
|
||||
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 = {
|
||||
register,
|
||||
login,
|
||||
me,
|
||||
verifyEmail,
|
||||
verifyCode,
|
||||
resetPassword,
|
||||
forgotPassword
|
||||
forgotPassword,
|
||||
changePassword,
|
||||
deleteAccount,
|
||||
oauthCallback,
|
||||
};
|
||||
@@ -1,9 +1,12 @@
|
||||
const prisma = require("../config/prisma");
|
||||
const { getGameWindow, effectiveClaimStatus } = require("../utils/gameWindow");
|
||||
|
||||
const getGainByTicketCode = async (req, res) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
|
||||
const gameWindow = await getGameWindow();
|
||||
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { code: code.toUpperCase() },
|
||||
include: {
|
||||
@@ -49,7 +52,7 @@ const getGainByTicketCode = async (req, res) => {
|
||||
},
|
||||
claim: {
|
||||
id: ticket.participation.claim.id,
|
||||
status: ticket.participation.claim.status,
|
||||
status: effectiveClaimStatus(ticket.participation.claim.status, gameWindow),
|
||||
claimedAt: ticket.participation.claim.claimedAt,
|
||||
},
|
||||
});
|
||||
@@ -66,6 +69,8 @@ const deliverClaim = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const employeeId = req.user.id;
|
||||
|
||||
const gameWindow = await getGameWindow();
|
||||
|
||||
const claim = await prisma.claim.findUnique({
|
||||
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({
|
||||
message: "Ce gain est expiré et ne peut plus être remis.",
|
||||
});
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
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) => {
|
||||
try {
|
||||
const { code } = req.body;
|
||||
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({
|
||||
where: { code },
|
||||
include: {
|
||||
@@ -25,16 +45,24 @@ const participate = async (req, res) => {
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const updatedTicket = await tx.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
// Le contrôle isUsed ci-dessus ne suffit pas : deux requêtes simultanées
|
||||
// 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: {
|
||||
isUsed: true,
|
||||
usedAt: new Date(),
|
||||
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({
|
||||
@@ -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({
|
||||
message: "Participation enregistrée avec succès.",
|
||||
emailSent,
|
||||
gain: {
|
||||
name: result.ticket.lot.name,
|
||||
description: result.ticket.lot.description,
|
||||
@@ -72,6 +133,12 @@ const participate = async (req, res) => {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TicketDejaUtiliseError) {
|
||||
return res.status(409).json({
|
||||
message: "Ce code a déjà été utilisé.",
|
||||
});
|
||||
}
|
||||
|
||||
console.error("Erreur participation:", error);
|
||||
return res.status(500).json({
|
||||
message: "Erreur serveur lors de la participation.",
|
||||
@@ -83,6 +150,8 @@ const myGains = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
const gameWindow = await getGameWindow();
|
||||
|
||||
const participations = await prisma.participation.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
@@ -102,7 +171,9 @@ const myGains = async (req, res) => {
|
||||
lot: participation.lot.name,
|
||||
description: participation.lot.description,
|
||||
value: participation.lot.value,
|
||||
claimStatus: participation.claim?.status,
|
||||
claimStatus: participation.claim
|
||||
? effectiveClaimStatus(participation.claim.status, gameWindow)
|
||||
: undefined,
|
||||
wonAt: participation.createdAt,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ const options = {
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: "http://localhost:5000",
|
||||
url: process.env.API_PUBLIC_URL || "http://localhost:5000",
|
||||
},
|
||||
],
|
||||
components: {
|
||||
|
||||
@@ -23,6 +23,8 @@ const authMiddleware = async (req, res, next) => {
|
||||
lastname: true,
|
||||
email: 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();
|
||||
} catch (error) {
|
||||
return res.status(401).json({
|
||||
|
||||
@@ -6,12 +6,14 @@ const {
|
||||
createEmployee,
|
||||
drawWinner,
|
||||
getGrandPrizeWinner,
|
||||
setGameSettings,
|
||||
getGameSettings,
|
||||
} = require("../controllers/admin.controller");
|
||||
|
||||
const authMiddleware = require("../middlewares/auth.middleware");
|
||||
const roleMiddleware = require("../middlewares/role.middleware");
|
||||
const validate = require("../middlewares/validate.middleware");
|
||||
const { createEmployeeSchema } = require("../validations/admin.validation");
|
||||
const { createEmployeeSchema, gameSettingsSchema } = require("../validations/admin.validation");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -105,10 +107,48 @@ router.post("/draw", drawWinner);
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Gagnant du gros lot
|
||||
* 404:
|
||||
* description: Aucun gagnant encore tiré
|
||||
* description: Gagnant du gros lot, ou { winner - null } si aucun tirage n'a encore eu lieu
|
||||
*/
|
||||
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;
|
||||
@@ -1,8 +1,9 @@
|
||||
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 validate = require("../middlewares/validate.middleware");
|
||||
const { registerSchema, loginSchema } = require("../validations/auth.validation");
|
||||
const { passport, googleEnabled } = require("../config/passport");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -157,5 +158,59 @@ router.post("/forgot-password", forgotPassword);
|
||||
*/
|
||||
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;
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require("express");
|
||||
const { participate, myGains } = require("../controllers/game.controller");
|
||||
const authMiddleware = require("../middlewares/auth.middleware");
|
||||
const roleMiddleware = require("../middlewares/role.middleware");
|
||||
const validate = require("../middlewares/validate.middleware");
|
||||
const { participateSchema } = require("../validations/game.validation");
|
||||
|
||||
@@ -20,7 +21,9 @@ const router = express.Router();
|
||||
* 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
|
||||
@@ -36,6 +39,6 @@ router.post("/participate", authMiddleware, validate(participateSchema), partici
|
||||
* description: Liste des gains
|
||||
*/
|
||||
|
||||
router.get("/my-gains", authMiddleware, myGains);
|
||||
router.get("/my-gains", authMiddleware, roleMiddleware("CLIENT"), myGains);
|
||||
|
||||
module.exports = router;
|
||||
39
backend/src/scripts/createAdmin.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const bcrypt = require('bcrypt')
|
||||
const prisma = require('../config/prisma')
|
||||
|
||||
async function createAdmin() {
|
||||
const email = 'admin@thetiptop.fr'
|
||||
const password = 'Admin@TipTop2026!'
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } })
|
||||
|
||||
if (existing) {
|
||||
await prisma.user.update({
|
||||
where: { email },
|
||||
data: { isEmailVerified: true, role: 'ADMIN' },
|
||||
})
|
||||
console.log('Admin mis à jour :', email)
|
||||
return
|
||||
}
|
||||
|
||||
const hashed = await bcrypt.hash(password, 10)
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
firstname: 'Admin',
|
||||
lastname: 'TipTop',
|
||||
email,
|
||||
password: hashed,
|
||||
role: 'ADMIN',
|
||||
isEmailVerified: true,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Admin créé avec succès')
|
||||
console.log(' Email :', email)
|
||||
console.log(' Mot de passe:', password)
|
||||
}
|
||||
|
||||
createAdmin()
|
||||
.catch((e) => { console.error('Erreur :', e.message); process.exit(1) })
|
||||
.finally(() => prisma.$disconnect())
|
||||
100
backend/src/utils/gameWindow.js
Normal file
@@ -0,0 +1,100 @@
|
||||
const prisma = require("../config/prisma");
|
||||
|
||||
// Le concours traverse cinq états, et non deux : confondre « pas encore
|
||||
// ouvert » avec « terminé » produit des messages faux côté joueur et laisse
|
||||
// passer le tirage au sort avant le début du jeu.
|
||||
const PHASES = {
|
||||
NOT_CONFIGURED: "NOT_CONFIGURED",
|
||||
SCHEDULED: "SCHEDULED",
|
||||
OPEN: "OPEN",
|
||||
CLAIM_ONLY: "CLAIM_ONLY",
|
||||
CLOSED: "CLOSED",
|
||||
};
|
||||
|
||||
const GAME_DURATION_DAYS = 30;
|
||||
const CLAIM_EXTENSION_DAYS = 30;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Deux échéances distinctes à partir de startDate :
|
||||
// - participationDeadline (30 j) : dernier moment pour tester un code ;
|
||||
// - claimDeadline (60 j) : dernier moment pour retirer un lot déjà gagné.
|
||||
// Les 30 jours supplémentaires servent uniquement au retrait, pas au jeu.
|
||||
// Tant qu'aucune GameSettings n'existe, le jeu est considéré comme non
|
||||
// configuré et donc ouvert (comportement de dev par défaut).
|
||||
async function getGameWindow(now = new Date()) {
|
||||
const settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||
|
||||
// Tant que l'administrateur n'a pas fixé de date, le concours n'existe pas :
|
||||
// rien n'est jouable et aucun tirage n'est possible. Ouvrir par défaut
|
||||
// reviendrait à faire gagner des lots dans un jeu qui n'a jamais été lancé.
|
||||
if (!settings) {
|
||||
return {
|
||||
configured: false,
|
||||
phase: PHASES.NOT_CONFIGURED,
|
||||
startDate: null,
|
||||
participationDeadline: null,
|
||||
claimDeadline: null,
|
||||
isParticipationOpen: false,
|
||||
isOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
const participationDeadline = new Date(settings.startDate.getTime() + GAME_DURATION_DAYS * DAY_MS);
|
||||
const claimDeadline = new Date(
|
||||
settings.startDate.getTime() + (GAME_DURATION_DAYS + CLAIM_EXTENSION_DAYS) * DAY_MS
|
||||
);
|
||||
|
||||
const hasStarted = now >= settings.startDate;
|
||||
const isParticipationOpen = hasStarted && now <= participationDeadline;
|
||||
const isOpen = hasStarted && now <= claimDeadline;
|
||||
|
||||
let phase;
|
||||
if (!hasStarted) phase = PHASES.SCHEDULED;
|
||||
else if (isParticipationOpen) phase = PHASES.OPEN;
|
||||
else if (isOpen) phase = PHASES.CLAIM_ONLY;
|
||||
else phase = PHASES.CLOSED;
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
phase,
|
||||
startDate: settings.startDate,
|
||||
participationDeadline,
|
||||
claimDeadline,
|
||||
hasStarted,
|
||||
isParticipationOpen,
|
||||
isOpen,
|
||||
};
|
||||
}
|
||||
|
||||
// L'expiration d'un gain est *dérivée* de la fenêtre de jeu, jamais écrite en
|
||||
// base : un PENDING dont le délai de retrait est passé est présenté comme
|
||||
// CANCELLED, mais la ligne reste intacte. Corriger une startDate erronée
|
||||
// suffit donc à rétablir les gains, et aucune lecture ne modifie la base.
|
||||
function effectiveClaimStatus(status, gameWindow) {
|
||||
if (status === "PENDING" && !gameWindow.isOpen) return "CANCELLED";
|
||||
return status;
|
||||
}
|
||||
|
||||
// Message destiné au joueur qui tente de jouer hors de la fenêtre : chaque
|
||||
// phase a sa propre explication, avec la date utile quand elle existe.
|
||||
function participationClosedMessage(gameWindow) {
|
||||
switch (gameWindow.phase) {
|
||||
case PHASES.NOT_CONFIGURED:
|
||||
return "Le jeu-concours n'est pas encore ouvert. Revenez prochainement.";
|
||||
case PHASES.SCHEDULED:
|
||||
return `Le jeu-concours ouvrira le ${gameWindow.startDate.toLocaleDateString("fr-FR")}.`;
|
||||
case PHASES.CLAIM_ONLY:
|
||||
return "La période de participation est terminée. Vous pouvez encore retirer les lots déjà gagnés.";
|
||||
default:
|
||||
return "Le jeu-concours est terminé. Vous ne pouvez plus tester de code ni réclamer de lot.";
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getGameWindow,
|
||||
effectiveClaimStatus,
|
||||
participationClosedMessage,
|
||||
PHASES,
|
||||
GAME_DURATION_DAYS,
|
||||
CLAIM_EXTENSION_DAYS,
|
||||
};
|
||||
@@ -9,6 +9,13 @@ const createEmployeeSchema = z.object({
|
||||
gender: z.string().optional(),
|
||||
});
|
||||
|
||||
const gameSettingsSchema = z.object({
|
||||
startDate: z
|
||||
.string()
|
||||
.refine((value) => !Number.isNaN(new Date(value).getTime()), "Date de début invalide"),
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
createEmployeeSchema,
|
||||
gameSettingsSchema,
|
||||
};
|
||||
87
backend/tests/draw.test.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const bcrypt = require("bcrypt");
|
||||
const prisma = require("../src/config/prisma");
|
||||
|
||||
// ------------------------
|
||||
// Vérifie que le pool de participants au tirage du gros lot ne compte
|
||||
// chaque client qu'une seule fois, quel que soit son nombre de
|
||||
// participations. On rejoue ici exactement la requête utilisée par
|
||||
// admin.controller.js::drawWinner, restreinte à des utilisateurs dédiés
|
||||
// pour ne pas dépendre des autres données déjà présentes en base
|
||||
// (et surtout ne jamais toucher aux vrais tickets du jeu-concours).
|
||||
// ------------------------
|
||||
|
||||
let userManyParticipations;
|
||||
let userOneParticipation;
|
||||
let ticketIds = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const lot = await prisma.lot.findFirst();
|
||||
if (!lot) throw new Error("Aucun lot en base : lancez `npx prisma db seed` avant ce test.");
|
||||
|
||||
const hashed = await bcrypt.hash("Password123", 10);
|
||||
|
||||
userManyParticipations = await prisma.user.create({
|
||||
data: {
|
||||
firstname: "Draw",
|
||||
lastname: "MultiParticip",
|
||||
email: "draw-test-multi@test.com",
|
||||
password: hashed,
|
||||
role: "CLIENT",
|
||||
isEmailVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
userOneParticipation = await prisma.user.create({
|
||||
data: {
|
||||
firstname: "Draw",
|
||||
lastname: "SingleParticip",
|
||||
email: "draw-test-single@test.com",
|
||||
password: hashed,
|
||||
role: "CLIENT",
|
||||
isEmailVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
const codes = ["ZZDRAWTEST1", "ZZDRAWTEST2", "ZZDRAWTEST3", "ZZDRAWTEST4"];
|
||||
const owners = [
|
||||
userManyParticipations,
|
||||
userManyParticipations,
|
||||
userManyParticipations,
|
||||
userOneParticipation,
|
||||
];
|
||||
|
||||
for (let i = 0; i < codes.length; i++) {
|
||||
const ticket = await prisma.ticket.create({
|
||||
data: { code: codes[i], lotId: lot.id, isUsed: true, usedAt: new Date(), userId: owners[i].id },
|
||||
});
|
||||
ticketIds.push(ticket.id);
|
||||
await prisma.participation.create({
|
||||
data: { userId: owners[i].id, ticketId: ticket.id, lotId: lot.id },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.participation.deleteMany({ where: { ticketId: { in: ticketIds } } });
|
||||
await prisma.ticket.deleteMany({ where: { id: { in: ticketIds } } });
|
||||
await prisma.user.deleteMany({
|
||||
where: { id: { in: [userManyParticipations.id, userOneParticipation.id] } },
|
||||
});
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
test("un client avec plusieurs participations ne compte qu'une fois dans le pool du tirage", async () => {
|
||||
const eligible = await prisma.user.findMany({
|
||||
where: {
|
||||
role: "CLIENT",
|
||||
id: { in: [userManyParticipations.id, userOneParticipation.id] },
|
||||
participations: { some: {} },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
expect(eligible).toHaveLength(2);
|
||||
expect(eligible.map((u) => u.id).sort()).toEqual(
|
||||
[userManyParticipations.id, userOneParticipation.id].sort()
|
||||
);
|
||||
});
|
||||
106
backend/tests/drawNotification.test.js
Normal file
@@ -0,0 +1,106 @@
|
||||
const request = require("supertest");
|
||||
const app = require("../src/app");
|
||||
const prisma = require("../src/config/prisma");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const bcrypt = require("bcrypt");
|
||||
const mailer = require("../src/config/mailer");
|
||||
|
||||
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
|
||||
|
||||
const DAY = 86400000;
|
||||
const PREFIX = "notif-";
|
||||
const CODE = "NOTIFTICK1";
|
||||
let ctx = {};
|
||||
|
||||
async function cleanup() {
|
||||
const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } });
|
||||
const ids = us.map((u) => u.id);
|
||||
const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } });
|
||||
await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } });
|
||||
await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
|
||||
await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } });
|
||||
await prisma.ticket.deleteMany({ where: { code: CODE } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
|
||||
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||
const jourJ = (d) =>
|
||||
prisma.gameSettings.update({
|
||||
where: { id: ctx.settingsId },
|
||||
data: { startDate: new Date(Date.now() - d * DAY) },
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||
ctx.settingsId = row.id;
|
||||
ctx.startDateOriginale = row.startDate;
|
||||
|
||||
const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
|
||||
await prisma.ticket.create({ data: { code: CODE, lotId: lot.id } });
|
||||
|
||||
const h = await bcrypt.hash("Password123", 10);
|
||||
ctx.admin = await prisma.user.create({ data: { firstname: "Alice", lastname: "Admin", email: PREFIX + "admin@test.local", password: h, role: "ADMIN", isEmailVerified: true } });
|
||||
ctx.client = await prisma.user.create({ data: { firstname: "Chloe", lastname: "Martin", email: PREFIX + "chloe@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
|
||||
ctx.tAdmin = tk(ctx.admin);
|
||||
|
||||
await jourJ(1);
|
||||
mailer.sendMail.mockResolvedValue(true);
|
||||
await request(app).post("/api/game/participate").set("Authorization", "Bearer " + tk(ctx.client)).send({ code: CODE }).expect(201);
|
||||
await jourJ(35);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } });
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("Notification du grand gagnant", () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test("le gagnant recoit un email a son adresse", async () => {
|
||||
mailer.sendMail.mockResolvedValue(true);
|
||||
|
||||
const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin).expect(201);
|
||||
|
||||
expect(res.body.emailSent).toBe(true);
|
||||
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
||||
|
||||
const envoi = mailer.sendMail.mock.calls[0][0];
|
||||
expect(envoi.to).toBe(ctx.client.email);
|
||||
expect(envoi.subject).toMatch(/gros lot/i);
|
||||
expect(envoi.html).toContain("Chloe");
|
||||
expect(envoi.html).toContain("360");
|
||||
|
||||
await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } });
|
||||
});
|
||||
|
||||
test("un SMTP en panne n invalide pas le tirage", async () => {
|
||||
mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible"));
|
||||
|
||||
const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.emailSent).toBe(false);
|
||||
expect(res.body.message).toMatch(/contacter directement/);
|
||||
|
||||
// Le tirage reste enregistre : c est un acte irreversible.
|
||||
const enBase = await prisma.grandPrizeWinner.findFirst({ where: { userId: ctx.client.id } });
|
||||
expect(enBase).not.toBeNull();
|
||||
|
||||
await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } });
|
||||
});
|
||||
|
||||
test("aucun email n est envoye si le tirage est refuse", async () => {
|
||||
mailer.sendMail.mockResolvedValue(true);
|
||||
await jourJ(1);
|
||||
|
||||
const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(mailer.sendMail).not.toHaveBeenCalled();
|
||||
|
||||
await jourJ(35);
|
||||
});
|
||||
});
|
||||
@@ -10,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 () => {
|
||||
await prisma.claim.deleteMany();
|
||||
await prisma.participation.deleteMany();
|
||||
await prisma.ticket.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
await prisma.user.deleteMany({ where: { email: testEmail } });
|
||||
await prisma.ticket.deleteMany({ where: { code: ticketCode } });
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
describe("THE TIP TOP - FULL E2E FLOW", () => {
|
||||
let token;
|
||||
let ticketCode = "ABC123";
|
||||
|
||||
// ------------------------
|
||||
// 1. REGISTER
|
||||
// ------------------------
|
||||
test("Register user", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/auth/register")
|
||||
.send({
|
||||
firstname: "Test",
|
||||
lastname: "User",
|
||||
email: "test@test.com",
|
||||
const res = await request(app).post("/api/auth/register").send({
|
||||
firstname: "E2E",
|
||||
lastname: "Test",
|
||||
email: testEmail,
|
||||
password: "Password123",
|
||||
});
|
||||
|
||||
expect([201, 409]).toContain(res.statusCode);
|
||||
expect(res.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
// ------------------------
|
||||
// 2. LOGIN
|
||||
// ------------------------
|
||||
test("Login user", async () => {
|
||||
// Validation du user pour bypasser vérification email
|
||||
await prisma.user.update({
|
||||
where: { email: "test@test.com" },
|
||||
where: { email: testEmail },
|
||||
data: { isEmailVerified: true },
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/auth/login")
|
||||
.send({
|
||||
email: "test@test.com",
|
||||
const res = await request(app).post("/api/auth/login").send({
|
||||
email: testEmail,
|
||||
password: "Password123",
|
||||
});
|
||||
|
||||
@@ -65,70 +70,28 @@ describe("THE TIP TOP - FULL E2E FLOW", () => {
|
||||
token = res.body.token;
|
||||
});
|
||||
|
||||
// ------------------------
|
||||
// 3. PARTICIPATE GAME
|
||||
// ------------------------
|
||||
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
|
||||
},
|
||||
});
|
||||
test("Create a dedicated ticket for this test", async () => {
|
||||
const lot = await prisma.lot.findFirst();
|
||||
expect(lot).not.toBeNull();
|
||||
|
||||
const ticket = await prisma.ticket.create({
|
||||
data: {
|
||||
code: ticketCode,
|
||||
lotId: lot.id
|
||||
}
|
||||
data: { code: ticketCode, lotId: lot.id },
|
||||
});
|
||||
createdTicketId = ticket.id;
|
||||
|
||||
expect(ticket).toBeDefined();
|
||||
expect(ticket.code).toBe(ticketCode);
|
||||
});
|
||||
|
||||
// ------------------------
|
||||
// 4. PARTICIPATE GAME
|
||||
// ------------------------
|
||||
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);
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.body.gain).toBeDefined();
|
||||
});
|
||||
|
||||
// ------------------------
|
||||
// 5. MY GAINS
|
||||
// ------------------------
|
||||
test("Get my gains", async () => {
|
||||
const res = await request(app)
|
||||
.get("/api/game/my-gains")
|
||||
@@ -136,11 +99,9 @@ describe("THE TIP TOP - FULL E2E FLOW", () => {
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
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 () => {
|
||||
const res = await request(app)
|
||||
.get("/api/admin/stats")
|
||||
|
||||
113
backend/tests/gainNotification.test.js
Normal file
@@ -0,0 +1,113 @@
|
||||
const request = require("supertest");
|
||||
const app = require("../src/app");
|
||||
const prisma = require("../src/config/prisma");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const bcrypt = require("bcrypt");
|
||||
const mailer = require("../src/config/mailer");
|
||||
|
||||
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
|
||||
|
||||
const DAY = 86400000;
|
||||
const PREFIX = "gain-";
|
||||
const CODES = ["GAINTICK01", "GAINTICK02", "GAINTICK03"];
|
||||
let ctx = {};
|
||||
|
||||
async function cleanup() {
|
||||
const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } });
|
||||
const ids = us.map((u) => u.id);
|
||||
const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } });
|
||||
await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } });
|
||||
await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
|
||||
await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } });
|
||||
await prisma.ticket.deleteMany({ where: { code: { in: CODES } } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
|
||||
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||
const jourJ = (d) =>
|
||||
prisma.gameSettings.update({
|
||||
where: { id: ctx.settingsId },
|
||||
data: { startDate: new Date(Date.now() - d * DAY) },
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||
ctx.settingsId = row.id;
|
||||
ctx.startDateOriginale = row.startDate;
|
||||
|
||||
ctx.lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
|
||||
for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: ctx.lot.id } });
|
||||
|
||||
const h = await bcrypt.hash("Password123", 10);
|
||||
ctx.client = await prisma.user.create({ data: { firstname: "Chloe", lastname: "Martin", email: PREFIX + "chloe@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
|
||||
ctx.tClient = tk(ctx.client);
|
||||
|
||||
await jourJ(1);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } });
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("Notification du gain a chaque participation", () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test("le joueur recoit un email decrivant son lot", async () => {
|
||||
mailer.sendMail.mockResolvedValue(true);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/game/participate")
|
||||
.set("Authorization", "Bearer " + ctx.tClient)
|
||||
.send({ code: CODES[0] })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.emailSent).toBe(true);
|
||||
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
||||
|
||||
const envoi = mailer.sendMail.mock.calls[0][0];
|
||||
expect(envoi.to).toBe(ctx.client.email);
|
||||
expect(envoi.subject).toContain(ctx.lot.name);
|
||||
expect(envoi.html).toContain("Chloe");
|
||||
expect(envoi.html).toContain(CODES[0]);
|
||||
expect(envoi.html).toContain(ctx.lot.name);
|
||||
});
|
||||
|
||||
test("un SMTP en panne n annule pas la participation", async () => {
|
||||
mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible"));
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/game/participate")
|
||||
.set("Authorization", "Bearer " + ctx.tClient)
|
||||
.send({ code: CODES[1] });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.emailSent).toBe(false);
|
||||
|
||||
// Le gain reste acquis et consultable.
|
||||
const t = await prisma.ticket.findUnique({ where: { code: CODES[1] } });
|
||||
expect(t.isUsed).toBe(true);
|
||||
|
||||
const gains = await request(app).get("/api/game/my-gains").set("Authorization", "Bearer " + ctx.tClient).expect(200);
|
||||
expect(gains.body.gains.some((g) => g.ticketCode === CODES[1])).toBe(true);
|
||||
});
|
||||
|
||||
test("aucun email si le code est refuse", async () => {
|
||||
mailer.sendMail.mockResolvedValue(true);
|
||||
|
||||
const rejoue = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
|
||||
expect(rejoue.status).toBe(409);
|
||||
|
||||
const inconnu = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: "INEXISTAN1" });
|
||||
expect(inconnu.status).toBe(404);
|
||||
|
||||
await jourJ(45);
|
||||
const horsDelai = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[2] });
|
||||
expect(horsDelai.status).toBe(410);
|
||||
await jourJ(1);
|
||||
|
||||
expect(mailer.sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
163
backend/tests/gameFlow.test.js
Normal file
@@ -0,0 +1,163 @@
|
||||
const request = require("supertest");
|
||||
const app = require("../src/app");
|
||||
const prisma = require("../src/config/prisma");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const bcrypt = require("bcrypt");
|
||||
|
||||
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) }));
|
||||
|
||||
const emails = { adm: "v-adm@test.local", emp: "v-emp@test.local", a: "v-a@test.local", b: "v-b@test.local" };
|
||||
const CODES = ["VERIFTICK1", "VERIFTICK2", "VERIFTICK3", "VERIFTICK4", "VERIFTICK5"];
|
||||
const DAY = 86400000;
|
||||
let ctx = {};
|
||||
const log = (...a) => console.log(" ", ...a);
|
||||
|
||||
async function cleanup() {
|
||||
const us = await prisma.user.findMany({ where: { email: { in: [...Object.values(emails), "v-r1@test.local", "v-r2@test.local"] } } });
|
||||
const ids = us.map((u) => u.id);
|
||||
const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } });
|
||||
await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } });
|
||||
await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
|
||||
await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } });
|
||||
await prisma.ticket.deleteMany({ where: { code: { in: CODES } } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
|
||||
const setStart = (d) =>
|
||||
prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: new Date(Date.now() - d * DAY) } });
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
ctx.settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||
ctx.orig = ctx.settings.startDate;
|
||||
const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
|
||||
for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: lot.id } });
|
||||
const h = await bcrypt.hash("Password123", 10);
|
||||
const mk = (e, r) => prisma.user.create({ data: { firstname: "V", lastname: r, email: e, password: h, role: r, isEmailVerified: true } });
|
||||
ctx.adm = await mk(emails.adm, "ADMIN");
|
||||
ctx.emp = await mk(emails.emp, "EMPLOYEE");
|
||||
ctx.a = await mk(emails.a, "CLIENT");
|
||||
ctx.b = await mk(emails.b, "CLIENT");
|
||||
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||
ctx.tadm = tk(ctx.adm);
|
||||
ctx.temp = tk(ctx.emp);
|
||||
ctx.ta = tk(ctx.a);
|
||||
ctx.tb = tk(ctx.b);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: ctx.orig } });
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("VERIFICATION DES 7 CORRECTIFS", () => {
|
||||
test("#1 ADMIN peut lancer le jeu", async () => {
|
||||
const futur = new Date(Date.now() + 5 * DAY).toISOString();
|
||||
const r = await request(app).put("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.tadm}`).send({ startDate: futur });
|
||||
log("PUT game-settings ->", r.status, r.body.message);
|
||||
expect(r.status).toBe(200);
|
||||
|
||||
const g = await request(app).get("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.tadm}`).expect(200);
|
||||
log("GET game-settings -> participation ouverte :", g.body.gameWindow.isParticipationOpen, "(jeu pas encore commence)");
|
||||
|
||||
const bad = await request(app).put("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.tadm}`).send({ startDate: "pas-une-date" });
|
||||
log("date invalide ->", bad.status, bad.body.message);
|
||||
expect(bad.status).toBe(400);
|
||||
|
||||
const nonAdmin = await request(app).put("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.ta}`).send({ startDate: futur });
|
||||
log("client tente de lancer le jeu ->", nonAdmin.status);
|
||||
expect(nonAdmin.status).toBe(403);
|
||||
});
|
||||
|
||||
test("#2 participation refusee apres 30 jours", async () => {
|
||||
await setStart(45);
|
||||
const r = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.ta}`).send({ code: CODES[0] });
|
||||
log("jour 45 ->", r.status, r.body.message);
|
||||
expect(r.status).toBe(410);
|
||||
|
||||
await setStart(1);
|
||||
const ok = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.ta}`).send({ code: CODES[0] });
|
||||
log("jour 1 ->", ok.status, ok.body.message || "accepte");
|
||||
expect(ok.status).toBe(201);
|
||||
ctx.claimId = ok.body.claim.id;
|
||||
});
|
||||
|
||||
test("#3 expiration derivee et reversible", async () => {
|
||||
await setStart(70);
|
||||
const g1 = await request(app).get("/api/game/my-gains").set("Authorization", `Bearer ${ctx.ta}`).expect(200);
|
||||
log("jour 70, statut affiche :", g1.body.gains.map((g) => g.claimStatus).join(","));
|
||||
expect(g1.body.gains[0].claimStatus).toBe("CANCELLED");
|
||||
|
||||
const enBase = await prisma.claim.findUnique({ where: { id: ctx.claimId } });
|
||||
log("statut REEL en base :", enBase.status, "(non ecrase)");
|
||||
expect(enBase.status).toBe("PENDING");
|
||||
|
||||
await setStart(1);
|
||||
const g2 = await request(app).get("/api/game/my-gains").set("Authorization", `Bearer ${ctx.ta}`).expect(200);
|
||||
log("retour jour 1, statut :", g2.body.gains.map((g) => g.claimStatus).join(","), "(reversible)");
|
||||
expect(g2.body.gains[0].claimStatus).toBe("PENDING");
|
||||
});
|
||||
|
||||
test("#4 tirage refuse pendant le jeu", async () => {
|
||||
await setStart(1);
|
||||
const r = await request(app).post("/api/admin/draw").set("Authorization", `Bearer ${ctx.tadm}`);
|
||||
log("tirage pendant le jeu ->", r.status, r.body.message);
|
||||
expect(r.status).toBe(409);
|
||||
|
||||
await setStart(35);
|
||||
const r2 = await request(app).post("/api/admin/draw").set("Authorization", `Bearer ${ctx.tadm}`);
|
||||
log("tirage apres cloture ->", r2.status, r2.body.message, r2.body.totalEligibleParticipants ? `(${r2.body.totalEligibleParticipants} eligibles)` : "");
|
||||
expect(r2.status).toBe(201);
|
||||
|
||||
await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: [ctx.a.id, ctx.b.id] } } });
|
||||
await setStart(1);
|
||||
});
|
||||
|
||||
test("#5 admin et employe ne peuvent plus jouer", async () => {
|
||||
const r1 = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.tadm}`).send({ code: CODES[1] });
|
||||
const r2 = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.temp}`).send({ code: CODES[1] });
|
||||
log("ADMIN ->", r1.status, r1.body.message);
|
||||
log("EMPLOYE ->", r2.status, r2.body.message);
|
||||
expect(r1.status).toBe(403);
|
||||
expect(r2.status).toBe(403);
|
||||
|
||||
const t = await prisma.ticket.findUnique({ where: { code: CODES[1] } });
|
||||
log("le ticket reste vierge :", t.isUsed === false);
|
||||
expect(t.isUsed).toBe(false);
|
||||
});
|
||||
|
||||
test("#6 suppression de compte ne remet pas le ticket en jeu", async () => {
|
||||
await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.tb}`).send({ code: CODES[2] }).expect(201);
|
||||
await request(app).delete("/api/auth/account").set("Authorization", `Bearer ${ctx.tb}`).expect(200);
|
||||
|
||||
const t = await prisma.ticket.findUnique({ where: { code: CODES[2] } });
|
||||
log("apres suppression : isUsed =", t.isUsed, "| userId =", t.userId);
|
||||
expect(t.isUsed).toBe(true);
|
||||
|
||||
const rejoue = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.ta}`).send({ code: CODES[2] });
|
||||
log("tentative de rejouer le code ->", rejoue.status, rejoue.body.message);
|
||||
expect(rejoue.status).toBe(409);
|
||||
});
|
||||
|
||||
test("#7 course sur un meme code", async () => {
|
||||
const h = await bcrypt.hash("Password123", 10);
|
||||
const c1 = await prisma.user.create({ data: { firstname: "R", lastname: "1", email: "v-r1@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
|
||||
const c2 = await prisma.user.create({ data: { firstname: "R", lastname: "2", email: "v-r2@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
|
||||
const t1 = jwt.sign({ id: c1.id, role: "CLIENT" }, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||
const t2 = jwt.sign({ id: c2.id, role: "CLIENT" }, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
request(app).post("/api/game/participate").set("Authorization", `Bearer ${t1}`).send({ code: CODES[3] }),
|
||||
request(app).post("/api/game/participate").set("Authorization", `Bearer ${t2}`).send({ code: CODES[3] }),
|
||||
]);
|
||||
|
||||
const codes = [r1.status, r2.status].sort();
|
||||
log("statuts obtenus :", codes.join(" + "), "(201 + 409 attendu, aucun 500)");
|
||||
expect(codes).toEqual([201, 409]);
|
||||
|
||||
const n = await prisma.participation.count({ where: { ticket: { code: CODES[3] } } });
|
||||
log("participations pour ce ticket :", n);
|
||||
expect(n).toBe(1);
|
||||
});
|
||||
});
|
||||
145
backend/tests/gamePhases.test.js
Normal file
@@ -0,0 +1,145 @@
|
||||
const request = require("supertest");
|
||||
const app = require("../src/app");
|
||||
const prisma = require("../src/config/prisma");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const bcrypt = require("bcrypt");
|
||||
|
||||
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) }));
|
||||
|
||||
const DAY = 86400000;
|
||||
const PREFIX = "r2-";
|
||||
const CODES = ["R2TICKET01", "R2TICKET02", "R2TICKET03", "R2TICKET04"];
|
||||
let ctx = {};
|
||||
const log = (...a) => console.log(" ", ...a);
|
||||
const titre = (t) => console.log("\n===== " + t + " =====");
|
||||
|
||||
async function cleanup() {
|
||||
const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } });
|
||||
const ids = us.map((u) => u.id);
|
||||
const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } });
|
||||
await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } });
|
||||
await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
|
||||
await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } });
|
||||
await prisma.ticket.deleteMany({ where: { code: { in: CODES } } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
|
||||
async function jourJ(offsetJours) {
|
||||
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||
const debut = new Date(Date.now() - offsetJours * DAY);
|
||||
if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: debut } });
|
||||
else await prisma.gameSettings.create({ data: { startDate: debut } });
|
||||
}
|
||||
|
||||
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "2h" });
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||
ctx.startDateOriginale = row ? row.startDate : null;
|
||||
|
||||
const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
|
||||
for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: lot.id } });
|
||||
|
||||
const h = await bcrypt.hash("Password123", 10);
|
||||
ctx.admin = await prisma.user.create({ data: { firstname: "Alice", lastname: "Admin", email: PREFIX + "admin@test.local", password: h, role: "ADMIN", isEmailVerified: true } });
|
||||
ctx.client = await prisma.user.create({ data: { firstname: "Zoe", lastname: "Cliente", email: PREFIX + "zoe@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
|
||||
ctx.tAdmin = tk(ctx.admin);
|
||||
ctx.tClient = tk(ctx.client);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||
if (ctx.startDateOriginale) {
|
||||
if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: ctx.startDateOriginale } });
|
||||
else await prisma.gameSettings.create({ data: { startDate: ctx.startDateOriginale } });
|
||||
}
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("VERIFICATION DES 3 CORRECTIFS DE PHASE", () => {
|
||||
|
||||
test("A. rien nest jouable avant tout lancement", async () => {
|
||||
titre("A : aucune GameSettings en base");
|
||||
await prisma.gameSettings.deleteMany({});
|
||||
|
||||
const vue = await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin).expect(200);
|
||||
log("phase :", vue.body.gameWindow.phase, "| participations ouvertes :", vue.body.gameWindow.isParticipationOpen);
|
||||
expect(vue.body.gameWindow.phase).toBe("NOT_CONFIGURED");
|
||||
|
||||
const essai = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
|
||||
log("un client tente de jouer ->", essai.status, essai.body.message);
|
||||
expect(essai.status).toBe(410);
|
||||
|
||||
const t = await prisma.ticket.findUnique({ where: { code: CODES[0] } });
|
||||
log("le ticket reste vierge :", t.isUsed === false);
|
||||
expect(t.isUsed).toBe(false);
|
||||
|
||||
const tirage = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
|
||||
log("admin tente le tirage ->", tirage.status, tirage.body.message);
|
||||
expect(tirage.status).toBe(409);
|
||||
expect(await prisma.grandPrizeWinner.count()).toBe(0);
|
||||
});
|
||||
|
||||
test("B. jeu programme : message juste et tirage bloque", async () => {
|
||||
titre("B : lancement programme a J+7");
|
||||
const futur = new Date(Date.now() + 7 * DAY).toISOString();
|
||||
const r = await request(app).put("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin).send({ startDate: futur });
|
||||
log("PUT game-settings ->", r.status, "| phase :", r.body.gameWindow.phase);
|
||||
expect(r.body.gameWindow.phase).toBe("SCHEDULED");
|
||||
|
||||
const anticipe = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
|
||||
log("client joue avant ouverture ->", anticipe.status);
|
||||
log("message :", JSON.stringify(anticipe.body.message));
|
||||
expect(anticipe.body.message).toMatch(/ouvrira le/);
|
||||
expect(anticipe.body.message).not.toMatch(/terminée/);
|
||||
|
||||
const tirage = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
|
||||
log("tirage avant ouverture ->", tirage.status, tirage.body.message);
|
||||
expect(tirage.status).toBe(409);
|
||||
expect(tirage.body.message).toMatch(/pas encore commencé/);
|
||||
});
|
||||
|
||||
test("C. les quatre phases dun jeu configure", async () => {
|
||||
titre("C : parcours des phases");
|
||||
|
||||
await jourJ(1);
|
||||
let w = (await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin)).body.gameWindow;
|
||||
const ok = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
|
||||
log("jour 1 | phase", w.phase, "| participate ->", ok.status);
|
||||
expect(w.phase).toBe("OPEN");
|
||||
expect(ok.status).toBe(201);
|
||||
ctx.claimId = ok.body.claim.id;
|
||||
|
||||
await jourJ(35);
|
||||
w = (await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin)).body.gameWindow;
|
||||
const tard = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[1] });
|
||||
log("jour 35 | phase", w.phase, "| participate ->", tard.status, JSON.stringify(tard.body.message));
|
||||
expect(w.phase).toBe("CLAIM_ONLY");
|
||||
expect(tard.body.message).toMatch(/participation est terminée/);
|
||||
|
||||
const tirage = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
|
||||
log("tirage apres cloture ->", tirage.status, tirage.body.message);
|
||||
expect(tirage.status).toBe(201);
|
||||
|
||||
await jourJ(70);
|
||||
w = (await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin)).body.gameWindow;
|
||||
const fini = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[2] });
|
||||
log("jour 70 | phase", w.phase, "| participate ->", fini.status, JSON.stringify(fini.body.message));
|
||||
expect(w.phase).toBe("CLOSED");
|
||||
expect(fini.body.message).toMatch(/jeu-concours est terminé/);
|
||||
});
|
||||
|
||||
test("D. non-regression : le parcours nominal fonctionne toujours", async () => {
|
||||
titre("D : verification du parcours de base");
|
||||
await jourJ(2);
|
||||
const g = await request(app).get("/api/game/my-gains").set("Authorization", "Bearer " + ctx.tClient).expect(200);
|
||||
log("gains du client :", JSON.stringify(g.body.gains.map((x) => x.claimStatus)));
|
||||
expect(g.body.gains[0].claimStatus).toBe("PENDING");
|
||||
|
||||
const stats = await request(app).get("/api/admin/stats").set("Authorization", "Bearer " + ctx.tAdmin).expect(200);
|
||||
log("stats : phase =", stats.body.gameWindow.phase, "| participations =", stats.body.participations);
|
||||
expect(stats.body.gameWindow.phase).toBe("OPEN");
|
||||
});
|
||||
});
|
||||
142
backend/tests/googlePassword.test.js
Normal file
@@ -0,0 +1,142 @@
|
||||
const request = require("supertest");
|
||||
const app = require("../src/app");
|
||||
const prisma = require("../src/config/prisma");
|
||||
const mailer = require("../src/config/mailer");
|
||||
const { passport, googleEnabled } = require("../src/config/passport");
|
||||
|
||||
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
|
||||
|
||||
const EMAIL = "google-pwd-test@test.local";
|
||||
const PROFILE = {
|
||||
id: "sim-google-id-123456",
|
||||
displayName: "Chloe Martin",
|
||||
name: { givenName: "Chloe", familyName: "Martin" },
|
||||
emails: [{ value: EMAIL }],
|
||||
photos: [{ value: "https://example.test/avatar.png" }],
|
||||
};
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.user.deleteMany({ where: { email: EMAIL } });
|
||||
}
|
||||
|
||||
// Rejoue le callback de la strategie Google sans sortir sur le reseau.
|
||||
function verifierProfilGoogle() {
|
||||
const strategy = passport._strategy("google");
|
||||
return new Promise((resolve, reject) => {
|
||||
strategy._verify("access-token", "refresh-token", PROFILE, (err, user) => {
|
||||
if (err) reject(err);
|
||||
else resolve(user);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(cleanup);
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
const decrire = googleEnabled ? describe : describe.skip;
|
||||
|
||||
decrire("Inscription Google et definition du mot de passe", () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test("un compte Google est cree sans mot de passe et recoit un lien", async () => {
|
||||
mailer.sendMail.mockResolvedValue(true);
|
||||
|
||||
const user = await verifierProfilGoogle();
|
||||
|
||||
expect(user.provider).toBe("GOOGLE");
|
||||
expect(user.password).toBeNull();
|
||||
expect(user.isEmailVerified).toBe(true);
|
||||
|
||||
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
||||
const envoi = mailer.sendMail.mock.calls[0][0];
|
||||
expect(envoi.to).toBe(EMAIL);
|
||||
expect(envoi.subject).toMatch(/mot de passe/i);
|
||||
|
||||
// Le lien doit viser le formulaire du front, pas la route POST de l API.
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
||||
expect(envoi.html).toContain(`${frontendUrl}/definir-mot-de-passe/`);
|
||||
|
||||
const enBase = await prisma.user.findUnique({ where: { email: EMAIL } });
|
||||
expect(enBase.resetPasswordToken).toHaveLength(64);
|
||||
expect(enBase.resetPasswordExpires.getTime()).toBeGreaterThan(Date.now());
|
||||
ctxToken = enBase.resetPasswordToken;
|
||||
});
|
||||
|
||||
test("le profil signale que le compte n a pas de mot de passe", async () => {
|
||||
const jwt = require("jsonwebtoken");
|
||||
const u = await prisma.user.findUnique({ where: { email: EMAIL } });
|
||||
const token = jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||
|
||||
const res = await request(app).get("/api/auth/me").set("Authorization", "Bearer " + token).expect(200);
|
||||
|
||||
expect(res.body.user.hasPassword).toBe(false);
|
||||
expect(res.body.user.provider).toBe("GOOGLE");
|
||||
// Le hash ne doit jamais sortir de l API.
|
||||
expect(res.body.user.password).toBeUndefined();
|
||||
});
|
||||
|
||||
test("la connexion par mot de passe est refusee tant qu il n en existe pas", async () => {
|
||||
const res = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "PeuImporte123" });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.message).toMatch(/connexion Google/);
|
||||
});
|
||||
|
||||
test("une confirmation qui ne correspond pas est refusee", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/auth/reset-password/" + ctxToken)
|
||||
.send({ password: "MonMotDePasse1", confirmPassword: "AutreMotDePasse2" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.message).toMatch(/ne correspondent pas/);
|
||||
|
||||
const u = await prisma.user.findUnique({ where: { email: EMAIL } });
|
||||
expect(u.password).toBeNull();
|
||||
});
|
||||
|
||||
test("un mot de passe trop court est refuse", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/auth/reset-password/" + ctxToken)
|
||||
.send({ password: "court", confirmPassword: "court" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.message).toMatch(/8 caractères/);
|
||||
});
|
||||
|
||||
test("le mot de passe defini permet ensuite de se connecter et de le changer", async () => {
|
||||
const ok = await request(app)
|
||||
.post("/api/auth/reset-password/" + ctxToken)
|
||||
.send({ password: "MonMotDePasse1", confirmPassword: "MonMotDePasse1" })
|
||||
.expect(200);
|
||||
expect(ok.body.message).toMatch(/réinitialisé/);
|
||||
|
||||
const login = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "MonMotDePasse1" }).expect(200);
|
||||
expect(login.body.token).toBeDefined();
|
||||
|
||||
// Le jeton a bien ete consomme.
|
||||
const u = await prisma.user.findUnique({ where: { email: EMAIL } });
|
||||
expect(u.resetPasswordToken).toBeNull();
|
||||
|
||||
const me = await request(app).get("/api/auth/me").set("Authorization", "Bearer " + login.body.token).expect(200);
|
||||
expect(me.body.user.hasPassword).toBe(true);
|
||||
|
||||
const change = await request(app)
|
||||
.patch("/api/auth/change-password")
|
||||
.set("Authorization", "Bearer " + login.body.token)
|
||||
.send({ currentPassword: "MonMotDePasse1", newPassword: "EncoreUnAutre2" });
|
||||
expect(change.status).toBe(200);
|
||||
});
|
||||
|
||||
test("une seconde connexion Google ne renvoie pas de nouveau lien", async () => {
|
||||
mailer.sendMail.mockResolvedValue(true);
|
||||
|
||||
const user = await verifierProfilGoogle();
|
||||
|
||||
expect(user.email).toBe(EMAIL);
|
||||
expect(mailer.sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
let ctxToken;
|
||||
66
backend/tests/register.mail.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const request = require("supertest");
|
||||
const app = require("../src/app");
|
||||
const prisma = require("../src/config/prisma");
|
||||
const mailer = require("../src/config/mailer");
|
||||
|
||||
jest.mock("../src/config/mailer", () => ({
|
||||
sendMail: jest.fn(),
|
||||
}));
|
||||
|
||||
// Comme le reste de la suite, ce fichier tourne sur la base de dev : il ne
|
||||
// touche qu'au compte qu'il crée lui-même, identifié par cet email.
|
||||
const testEmail = "rollback-mail-test@test.com";
|
||||
|
||||
const payload = {
|
||||
firstname: "Camille",
|
||||
lastname: "Martin",
|
||||
email: testEmail,
|
||||
password: "Password123",
|
||||
birthdate: "1990-05-14",
|
||||
gender: "Femme",
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
await prisma.user.deleteMany({ where: { email: testEmail } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.user.deleteMany({ where: { email: testEmail } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("Inscription - echec d'envoi du code de validation", () => {
|
||||
test("repond 502 et ne laisse aucun compte orphelin", async () => {
|
||||
mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible"));
|
||||
|
||||
const res = await request(app).post("/api/auth/register").send(payload);
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email: testEmail } });
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
|
||||
test("l'email reste reutilisable apres un echec d'envoi", async () => {
|
||||
mailer.sendMail.mockRejectedValueOnce(new Error("SMTP indisponible"));
|
||||
const echec = await request(app).post("/api/auth/register").send(payload);
|
||||
expect(echec.statusCode).toBe(502);
|
||||
|
||||
mailer.sendMail.mockResolvedValue(true);
|
||||
const succes = await request(app).post("/api/auth/register").send(payload);
|
||||
|
||||
expect(succes.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
test("enregistre la date de naissance et le genre", async () => {
|
||||
mailer.sendMail.mockResolvedValue(true);
|
||||
|
||||
const res = await request(app).post("/api/auth/register").send(payload);
|
||||
expect(res.statusCode).toBe(201);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email: testEmail } });
|
||||
expect(user.gender).toBe("Femme");
|
||||
expect(user.birthdate.toISOString()).toBe("1990-05-14T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
const prisma = require("../src/config/prisma");
|
||||
|
||||
beforeAll(async () => {
|
||||
await prisma.claim.deleteMany();
|
||||
await prisma.participation.deleteMany();
|
||||
await prisma.ticket.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
|
||||
// Création de lots
|
||||
await prisma.lot.createMany({
|
||||
data: [
|
||||
{ name: "Infuseur", value: 10, percentage: 60, description: "Infuseur à thé" },
|
||||
{ name: "Thé Détox", value: 15, percentage: 20, description: "Thé détox bio" },
|
||||
{ name: "Thé Signature", value: 20, percentage: 10, description: "Thé signature" },
|
||||
{ name: "Coffret 39€", value: 39, percentage: 6, description: "Coffret découverte" },
|
||||
{ name: "Coffret 69€", value: 69, percentage: 4, description: "Coffret premium" },
|
||||
]
|
||||
});
|
||||
|
||||
// Création d'un ticket
|
||||
await prisma.ticket.create({
|
||||
data: {
|
||||
code: "ABC123",
|
||||
lotId: 1
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
POSTGRES_DB: the_tip_top_db
|
||||
|
||||
volumes:
|
||||
- tiptop_postgres_dev_data:/var/lib/postgresql/data
|
||||
- tiptop_dev_postgres_data:/var/lib/postgresql/data
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U tiptop -d the_tip_top_db"]
|
||||
@@ -24,20 +24,21 @@ services:
|
||||
image: ${REGISTRY}/the-tip-top/backend:${IMAGE_TAG}
|
||||
restart: unless-stopped
|
||||
|
||||
env_file:
|
||||
- /opt/furious-ducks/secrets/tiptop-mail.env
|
||||
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
environment:
|
||||
PORT: 5000
|
||||
NODE_ENV: development
|
||||
DATABASE_URL: postgresql://tiptop:tiptop_dev_password@postgres:5432/the_tip_top_db
|
||||
JWT_SECRET: dev_secret_change_me
|
||||
APP_URL: http://192.168.1.23:5001
|
||||
MAIL_HOST: smtp.gmail.com
|
||||
MAIL_PORT: 587
|
||||
MAIL_USER: ""
|
||||
MAIL_PASS: ""
|
||||
MAIL_FROM: "The Tip Top <no-reply@tiptop.local>"
|
||||
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: >
|
||||
sh -c "
|
||||
@@ -46,11 +47,18 @@ services:
|
||||
npm start
|
||||
"
|
||||
|
||||
ports:
|
||||
- "5001:5000"
|
||||
|
||||
networks:
|
||||
- tiptop-dev
|
||||
- proxy
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=proxy"
|
||||
- "traefik.http.routers.tiptop-dev-api.rule=Host(`api-dev.dsp5-archi-o24a-4-5-g4.fr`)"
|
||||
- "traefik.http.routers.tiptop-dev-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.tiptop-dev-api.tls=true"
|
||||
- "traefik.http.routers.tiptop-dev-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.tiptop-dev-api.loadbalancer.server.port=5000"
|
||||
|
||||
frontend:
|
||||
image: ${REGISTRY}/the-tip-top/frontend:${IMAGE_TAG}
|
||||
@@ -59,17 +67,26 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
ports:
|
||||
- "5173:80"
|
||||
|
||||
networks:
|
||||
- tiptop-dev
|
||||
- proxy
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=proxy"
|
||||
- "traefik.http.routers.tiptop-dev.rule=Host(`dev.dsp5-archi-o24a-4-5-g4.fr`)"
|
||||
- "traefik.http.routers.tiptop-dev.entrypoints=websecure"
|
||||
- "traefik.http.routers.tiptop-dev.tls=true"
|
||||
- "traefik.http.routers.tiptop-dev.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.tiptop-dev.loadbalancer.server.port=80"
|
||||
|
||||
volumes:
|
||||
tiptop_postgres_dev_data:
|
||||
external: true
|
||||
name: the-tip-top_tiptop_postgres_dev_data
|
||||
tiptop_dev_postgres_data:
|
||||
name: tiptop_dev_postgres_data
|
||||
|
||||
networks:
|
||||
tiptop-dev:
|
||||
name: tiptop-dev
|
||||
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
@@ -12,11 +12,7 @@ services:
|
||||
- tiptop_preprod_postgres_data:/var/lib/postgresql/data
|
||||
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U tiptop_preprod -d the_tip_top_preprod"
|
||||
]
|
||||
test: ["CMD-SHELL", "pg_isready -U tiptop_preprod -d the_tip_top_preprod"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -28,6 +24,9 @@ services:
|
||||
image: ${REGISTRY}/the-tip-top/backend:${IMAGE_TAG}
|
||||
restart: unless-stopped
|
||||
|
||||
env_file:
|
||||
- /opt/furious-ducks/secrets/tiptop-mail.env
|
||||
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -37,12 +36,9 @@ services:
|
||||
NODE_ENV: preproduction
|
||||
DATABASE_URL: postgresql://tiptop_preprod:tiptop_preprod_password@postgres:5432/the_tip_top_preprod
|
||||
JWT_SECRET: preprod_secret_change_me
|
||||
APP_URL: http://192.168.1.23:5002
|
||||
MAIL_HOST: smtp.gmail.com
|
||||
MAIL_PORT: 587
|
||||
MAIL_USER: ""
|
||||
MAIL_PASS: ""
|
||||
MAIL_FROM: "The Tip Top Preprod <no-reply@tiptop.local>"
|
||||
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: >
|
||||
sh -c "
|
||||
@@ -51,11 +47,18 @@ services:
|
||||
npm start
|
||||
"
|
||||
|
||||
ports:
|
||||
- "5002:5000"
|
||||
|
||||
networks:
|
||||
- tiptop-preprod
|
||||
- proxy
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=proxy"
|
||||
- "traefik.http.routers.tiptop-preprod-api.rule=Host(`api-preprod.dsp5-archi-o24a-4-5-g4.fr`)"
|
||||
- "traefik.http.routers.tiptop-preprod-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.tiptop-preprod-api.tls=true"
|
||||
- "traefik.http.routers.tiptop-preprod-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.tiptop-preprod-api.loadbalancer.server.port=5000"
|
||||
|
||||
frontend:
|
||||
image: ${REGISTRY}/the-tip-top/frontend:${IMAGE_TAG}
|
||||
@@ -64,11 +67,18 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
ports:
|
||||
- "5174:80"
|
||||
|
||||
networks:
|
||||
- tiptop-preprod
|
||||
- proxy
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=proxy"
|
||||
- "traefik.http.routers.tiptop-preprod.rule=Host(`preprod.dsp5-archi-o24a-4-5-g4.fr`)"
|
||||
- "traefik.http.routers.tiptop-preprod.entrypoints=websecure"
|
||||
- "traefik.http.routers.tiptop-preprod.tls=true"
|
||||
- "traefik.http.routers.tiptop-preprod.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.tiptop-preprod.loadbalancer.server.port=80"
|
||||
|
||||
volumes:
|
||||
tiptop_preprod_postgres_data:
|
||||
@@ -77,3 +87,6 @@ volumes:
|
||||
networks:
|
||||
tiptop-preprod:
|
||||
name: tiptop-preprod
|
||||
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
@@ -12,11 +12,7 @@ services:
|
||||
- tiptop_prod_postgres_data:/var/lib/postgresql/data
|
||||
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U tiptop_prod -d the_tip_top_prod"
|
||||
]
|
||||
test: ["CMD-SHELL", "pg_isready -U tiptop_prod -d the_tip_top_prod"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -28,6 +24,9 @@ services:
|
||||
image: ${REGISTRY}/the-tip-top/backend:${IMAGE_TAG}
|
||||
restart: unless-stopped
|
||||
|
||||
env_file:
|
||||
- /opt/furious-ducks/secrets/tiptop-mail.env
|
||||
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -37,13 +36,9 @@ services:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://tiptop_prod:tiptop_prod_password@postgres:5432/the_tip_top_prod
|
||||
JWT_SECRET: prod_secret_change_me
|
||||
APP_URL: http://192.168.1.23:5003
|
||||
|
||||
MAIL_HOST: smtp.gmail.com
|
||||
MAIL_PORT: 587
|
||||
MAIL_USER: ""
|
||||
MAIL_PASS: ""
|
||||
MAIL_FROM: "The Tip Top <no-reply@tiptop.local>"
|
||||
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: >
|
||||
sh -c "
|
||||
@@ -52,11 +47,18 @@ services:
|
||||
npm start
|
||||
"
|
||||
|
||||
ports:
|
||||
- "5003:5000"
|
||||
|
||||
networks:
|
||||
- tiptop-prod
|
||||
- proxy
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=proxy"
|
||||
- "traefik.http.routers.tiptop-prod-api.rule=Host(`api.dsp5-archi-o24a-4-5-g4.fr`)"
|
||||
- "traefik.http.routers.tiptop-prod-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.tiptop-prod-api.tls=true"
|
||||
- "traefik.http.routers.tiptop-prod-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.tiptop-prod-api.loadbalancer.server.port=5000"
|
||||
|
||||
frontend:
|
||||
image: ${REGISTRY}/the-tip-top/frontend:${IMAGE_TAG}
|
||||
@@ -65,11 +67,18 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
ports:
|
||||
- "5175:80"
|
||||
|
||||
networks:
|
||||
- tiptop-prod
|
||||
- proxy
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=proxy"
|
||||
- "traefik.http.routers.tiptop-prod.rule=Host(`dsp5-archi-o24a-4-5-g4.fr`) || Host(`www.dsp5-archi-o24a-4-5-g4.fr`)"
|
||||
- "traefik.http.routers.tiptop-prod.entrypoints=websecure"
|
||||
- "traefik.http.routers.tiptop-prod.tls=true"
|
||||
- "traefik.http.routers.tiptop-prod.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.tiptop-prod.loadbalancer.server.port=80"
|
||||
|
||||
volumes:
|
||||
tiptop_prod_postgres_data:
|
||||
@@ -78,3 +87,6 @@ volumes:
|
||||
networks:
|
||||
tiptop-prod:
|
||||
name: tiptop-prod
|
||||
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
4
frontend/.gitignore
vendored
@@ -22,3 +22,7 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Tests / coverage
|
||||
coverage/
|
||||
test-results/
|
||||
|
||||
BIN
frontend/Design/Code gagnang.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
frontend/Design/Inscription reussit.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/Design/Saisir_code.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/Design/historique des gain .png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
frontend/Design/profil .png
Normal file
|
After Width: | Height: | Size: 13 KiB |
@@ -8,7 +8,10 @@ RUN npm ci
|
||||
COPY . .
|
||||
|
||||
ARG VITE_API_URL
|
||||
ARG VITE_GA_ID
|
||||
|
||||
ENV VITE_API_URL=${VITE_API_URL}
|
||||
ENV VITE_GA_ID=${VITE_GA_ID}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
1387
frontend/package-lock.json
generated
@@ -7,7 +7,10 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.6",
|
||||
@@ -17,14 +20,20 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@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-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"vite": "^8.0.12"
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.6 KiB |
23
frontend/public/robots.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
User-agent: *
|
||||
|
||||
# Pages publiques autorisées
|
||||
Allow: /
|
||||
Allow: /accueil
|
||||
Allow: /regles-jeu
|
||||
Allow: /contact
|
||||
Allow: /mentions-legales
|
||||
Allow: /confidentialite
|
||||
Allow: /sitemap
|
||||
Allow: /inscription
|
||||
Allow: /connexion
|
||||
|
||||
# Pages privées interdites aux robots
|
||||
Disallow: /admin
|
||||
Disallow: /employe
|
||||
Disallow: /profile
|
||||
Disallow: /mes-gains
|
||||
Disallow: /saisir-code-unique
|
||||
Disallow: /perdu
|
||||
|
||||
# Sitemap
|
||||
Sitemap: https://thetiptop.fr/sitemap
|
||||
@@ -1,23 +1,89 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { 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 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 Employe from './pages/Employe'
|
||||
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() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/connexion" replace />} />
|
||||
<Route path="/" element={<Navigate to="/accueil" replace />} />
|
||||
<Route path="/connexion" element={<Connexion />} />
|
||||
<Route path="/inscription" element={<Inscription />} />
|
||||
<Route path="/participation" element={<Participation />} />
|
||||
<Route path="/mes-gains" element={<MesGains />} />
|
||||
<Route path="/employe" element={<Employe />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/definir-mot-de-passe/:token" element={<DefinirMotDePasse />} />
|
||||
|
||||
{/* 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>
|
||||
<CookieBanner />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
BIN
frontend/src/assets/img/img-contact.jpg
Normal file
|
After Width: | Height: | Size: 7.2 MiB |
BIN
frontend/src/assets/img/logo.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
79
frontend/src/components/CookieBanner.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { initAnalytics, trackPageView } from '../services/analytics'
|
||||
|
||||
export default function CookieBanner() {
|
||||
const location = useLocation()
|
||||
|
||||
const [consent, setConsent] = useState(() =>
|
||||
localStorage.getItem('cookieConsent')
|
||||
)
|
||||
|
||||
const visible = consent === null
|
||||
|
||||
// Si le consentement avait déjà été donné lors d'une précédente visite,
|
||||
// Analytics est initialisé au chargement de l'application.
|
||||
useEffect(() => {
|
||||
if (consent !== 'accepted') return
|
||||
|
||||
initAnalytics()
|
||||
trackPageView(location.pathname + location.search)
|
||||
}, [consent, location.pathname, location.search])
|
||||
|
||||
const handleChoice = (choice) => {
|
||||
localStorage.setItem('cookieConsent', choice)
|
||||
setConsent(choice)
|
||||
|
||||
if (choice === 'accepted') {
|
||||
initAnalytics()
|
||||
trackPageView(location.pathname + location.search)
|
||||
}
|
||||
}
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 px-4 py-4 bg-white border-t border-gray-200 shadow-lg">
|
||||
<div className="max-w-5xl mx-auto flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-gray-800 mb-1">
|
||||
Gestion des cookies
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-gray-500 leading-relaxed">
|
||||
Les cookies strictement nécessaires au fonctionnement du site sont
|
||||
utilisés automatiquement. Avec votre accord, nous utilisons également
|
||||
Google Analytics afin de mesurer l'audience et améliorer le site. Vous
|
||||
pouvez accepter ou refuser ces cookies statistiques. Consultez notre{' '}
|
||||
<Link
|
||||
to="/confidentialite"
|
||||
className="text-[#006B6B] hover:underline"
|
||||
>
|
||||
politique de confidentialité
|
||||
</Link>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-shrink-0 w-full sm:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChoice('refused')}
|
||||
className="flex-1 sm:flex-none border border-gray-300 text-gray-600 hover:bg-gray-50 text-sm font-medium px-4 py-2.5 rounded-lg transition-colors"
|
||||
>
|
||||
Refuser
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChoice('accepted')}
|
||||
className="flex-1 sm:flex-none bg-[#006B6B] hover:bg-[#005959] text-white text-sm font-medium px-4 py-2.5 rounded-lg transition-colors"
|
||||
>
|
||||
Accepter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
246
frontend/src/components/Footer.jsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
function FooterLink({ to, children, className = '', pathname }) {
|
||||
return pathname === to ? (
|
||||
<span className={className}>{children}</span>
|
||||
) : (
|
||||
<Link to={to} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Footer() {
|
||||
const { pathname } = useLocation()
|
||||
const { user } = useAuth()
|
||||
const isLoggedIn = !!user
|
||||
|
||||
return (
|
||||
<footer className="bg-[#006B6B] text-white">
|
||||
|
||||
{/* Partie principale */}
|
||||
<div className="max-w-7xl mx-auto px-6 py-10 grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||
|
||||
{/* Logo */}
|
||||
<div>
|
||||
<FooterLink
|
||||
to="/accueil"
|
||||
pathname={pathname}
|
||||
>
|
||||
<h3 className="text-xl font-bold mb-3">
|
||||
Thé Tip Top
|
||||
</h3>
|
||||
</FooterLink>
|
||||
|
||||
<p className="text-teal-100 text-sm leading-relaxed">
|
||||
Participez au jeu-concours Thé Tip Top et tentez de gagner
|
||||
des lots gourmands autour du thé.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-5">
|
||||
Navigation
|
||||
</h3>
|
||||
|
||||
<ul className="space-y-3 text-teal-100">
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/accueil"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Accueil
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/regles-jeu"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Règles du jeu
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/saisir-code-unique"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Tester mon code
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
{!isLoggedIn && (
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/inscription"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Créer un compte
|
||||
</FooterLink>
|
||||
</li>
|
||||
)}
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Informations légales */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-5">
|
||||
Informations
|
||||
</h3>
|
||||
|
||||
<ul className="space-y-3 text-teal-100">
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/mentions-legales"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Mentions légales
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/confidentialite"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Politique de confidentialité
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/accessibilite"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Accessibilité
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/cgu"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
CGU
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/contact"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Contact
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/sitemap"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Plan du site
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-5">
|
||||
Contact
|
||||
</h3>
|
||||
|
||||
<p className="text-teal-100 text-sm mb-3">
|
||||
Une question sur le jeu ?
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="tel:+33184802034"
|
||||
className="block mt-2 font-medium hover:text-teal-300"
|
||||
>
|
||||
01 84 80 20 34
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="mailto:contact@thetiptop.fr"
|
||||
className="inline-block mt-3 font-medium hover:text-teal-300"
|
||||
>
|
||||
contact@thetiptop.fr
|
||||
</a>
|
||||
|
||||
<p className="text-teal-100 text-sm mt-2">
|
||||
18 rue Léon Frot
|
||||
<br />
|
||||
75011 Paris, France
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Barre basse */}
|
||||
<div className="border-t border-teal-800">
|
||||
<div className="max-w-7xl mx-auto px-6 py-5 flex flex-col md:flex-row justify-between items-center gap-3">
|
||||
|
||||
<p className="text-teal-200 text-sm text-center md:text-left">
|
||||
© {new Date().getFullYear()} Thé Tip Top. Tous droits réservés.
|
||||
<br className="md:hidden" />
|
||||
<span className="md:before:content-['_—_']">
|
||||
Projet étudiant fictif — aucun achat ni réservation réel ne peut être effectué sur ce site.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="flex gap-5 text-sm text-teal-200">
|
||||
|
||||
<a
|
||||
href="https://www.facebook.com/profile.php?id=61590449280854"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Facebook
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://www.instagram.com/thetiptop.gp4?igsh=MXZ3YzZ0d3dkazBpeg%3D%3D&utm_source=qr"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Instagram
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://www.tiktok.com/@thetiptop4"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
TikTok
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
151
frontend/src/components/Header.jsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import logo from '../assets/img/logo.png'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function Header() {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuth()
|
||||
const isLoggedIn = !!user
|
||||
const logoContent = <img src={logo} alt="Logo de Thé Tip Top" className="h-7 sm:h-8 md:h-9 lg:h-12 w-auto" />
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
setMenuOpen(false)
|
||||
navigate('/connexion')
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/accueil', label: 'Accueil' },
|
||||
{ to: '/regles-jeu', label: 'Règles du jeu' },
|
||||
]
|
||||
|
||||
const initials = user
|
||||
? `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase()
|
||||
: null
|
||||
|
||||
return (
|
||||
<header className="bg-white shadow-sm relative z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
|
||||
{pathname === '/accueil' ? (
|
||||
<span className="flex items-center flex-shrink-0">
|
||||
{logoContent}
|
||||
</span>
|
||||
) : (
|
||||
<Link to ={'/accueil'} className='flex items-center flex-shrink-0'>
|
||||
{logoContent}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Nav desktop */}
|
||||
<nav className="hidden md:flex items-center gap-4 lg:gap-6">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={`font-medium whitespace-nowrap transition-colors ${
|
||||
pathname === link.to ? 'text-[#006B6B]' : 'text-gray-700 hover:text-[#006B6B]'
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{isLoggedIn ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/profile" className="flex items-center gap-2 text-sm text-gray-700 hover:text-[#006B6B] font-medium transition-colors">
|
||||
<div className="w-8 h-8 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-xs font-bold">
|
||||
{initials}
|
||||
</div>
|
||||
<span className="whitespace-nowrap">{user.firstname}</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-sm text-gray-500 hover:text-red-500 transition-colors font-medium whitespace-nowrap"
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/connexion" className="bg-[#006B6B] border border-transparent text-white px-4 py-2 rounded-lg hover:bg-[#005a5a] transition-colors text-sm whitespace-nowrap">
|
||||
Connexion
|
||||
</Link>
|
||||
<Link to="/inscription" className="border border-[#006B6B] text-[#006B6B] px-4 py-2 rounded-lg hover:bg-[#006B6B] hover:text-white transition-colors text-sm whitespace-nowrap">
|
||||
Inscription
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Hamburger mobile */}
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="md:hidden flex flex-col justify-center items-center w-9 h-9 gap-1.5"
|
||||
aria-label="Ouvrir le menu"
|
||||
>
|
||||
<span className={`block w-6 h-0.5 bg-gray-700 transition-all duration-300 origin-center ${menuOpen ? 'rotate-45 translate-y-2' : ''}`} />
|
||||
<span className={`block w-6 h-0.5 bg-gray-700 transition-all duration-300 ${menuOpen ? 'opacity-0' : ''}`} />
|
||||
<span className={`block w-6 h-0.5 bg-gray-700 transition-all duration-300 origin-center ${menuOpen ? '-rotate-45 -translate-y-2' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Menu mobile déroulant */}
|
||||
{menuOpen && (
|
||||
<div className="md:hidden bg-white border-t border-gray-100 px-4 pb-4">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className={`block py-3 font-medium border-b border-gray-50 ${
|
||||
pathname === link.to ? 'text-[#006B6B]' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<div className="flex flex-col gap-2 pt-3">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Link
|
||||
to="/mes-gains"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="w-full text-center bg-[#006B6B] text-white px-4 py-2.5 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Mon espace ({user.firstname})
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-center border border-red-300 text-red-500 px-4 py-2.5 rounded-lg text-sm font-medium hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
to="/connexion"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="w-full text-center bg-[#006B6B] text-white px-4 py-2.5 rounded-lg hover:bg-[#005a5a] transition-colors text-sm font-medium"
|
||||
>
|
||||
Connexion
|
||||
</Link>
|
||||
<Link
|
||||
to="/inscription"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="w-full text-center border border-[#006B6B] text-[#006B6B] px-4 py-2.5 rounded-lg hover:bg-[#006B6B] hover:text-white transition-colors text-sm font-medium"
|
||||
>
|
||||
Inscription
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
16
frontend/src/components/Layout.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Header from './Header'
|
||||
import Footer from './Footer'
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
frontend/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function ProtectedRoute({ children, roles }) {
|
||||
const { user } = useAuth()
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/connexion" replace />
|
||||
}
|
||||
|
||||
if (roles && !roles.includes(user.role)) {
|
||||
const redirects = { ADMIN: '/admin', EMPLOYEE: '/employe', CLIENT: '/mes-gains' }
|
||||
return <Navigate to={redirects[user.role] || '/connexion'} replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
59
frontend/src/components/UserNav.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function UserNav({ active }) {
|
||||
const tabs = [
|
||||
{
|
||||
key: 'code',
|
||||
to: '/saisir-code-unique',
|
||||
label: 'Saisir un code',
|
||||
shortLabel: 'Code',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'gains',
|
||||
to: '/mes-gains',
|
||||
label: 'Mes gains',
|
||||
shortLabel: 'Mes gains',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20 12V22H4V12M22 7H2v5h20V7zM12 22V7M12 7H7.5a2.5 2.5 0 010-5C11 2 12 7 12 7zM12 7h4.5a2.5 2.5 0 000-5C13 2 12 7 12 7z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'profile',
|
||||
to: '/profile',
|
||||
label: 'Profil',
|
||||
shortLabel: 'Profil',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex border-b border-gray-200 mb-6">
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.key}
|
||||
to={tab.to}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-3 text-xs sm:text-sm font-medium transition-colors border-b-2 ${
|
||||
active === tab.key
|
||||
? 'text-[#006B6B] border-[#006B6B]'
|
||||
: 'text-gray-500 border-transparent hover:text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
<span className="sm:hidden whitespace-nowrap">{tab.shortLabel}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
frontend/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(() =>
|
||||
JSON.parse(localStorage.getItem('user') || 'null')
|
||||
)
|
||||
|
||||
function login(userData, token) {
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
setUser(userData)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext)
|
||||
}
|
||||
17
frontend/src/hooks/useHandleAuthError.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export function useHandleAuthError() {
|
||||
const { logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return useCallback((err) => {
|
||||
if (err.status === 401) {
|
||||
logout()
|
||||
navigate('/connexion')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [logout, navigate])
|
||||
}
|
||||
41
frontend/src/pages/Accessibilite.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
export default function Accessibilite() {
|
||||
return (
|
||||
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||
Déclaration d'accessibilité
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-600 mb-8 sm:mb-10 leading-relaxed text-sm sm:text-base">
|
||||
La société Thé Tip Top s'engage à rendre le site de son jeu-concours « 100% Gagnant »
|
||||
accessible conformément à l'article 47 de la loi n° 2005-102 du 11 février 2005.
|
||||
</p>
|
||||
|
||||
<section className="mb-6 sm:mb-8">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
État de conformité
|
||||
</h2>
|
||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||
Le site a été développé en conformité avec les normes d'accessibilité internationales
|
||||
WCAG 2.1 niveau AA et le Référentiel Général d'Amélioration de l'Accessibilité
|
||||
(RGAA 4.1) afin de garantir une accessibilité maximale aux utilisateurs en situation
|
||||
de handicap.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
Signaler un problème d'accessibilité
|
||||
</h2>
|
||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||
Si vous rencontrez un obstacle à l'accessibilité sur le site, vous pouvez nous en faire
|
||||
part afin que nous puissions y remédier :<br />
|
||||
<strong>Email :</strong> contact@thetiptop.fr
|
||||
</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
57
frontend/src/pages/Accueil.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function Accueil() {
|
||||
return (
|
||||
<div className="bg-gray-50 min-h-screen">
|
||||
|
||||
{/* Hero */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 py-12 sm:py-16 md:py-24">
|
||||
<div className="text-center max-w-3xl mx-auto">
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-[#006B6B] mb-4 sm:mb-6 leading-tight">
|
||||
Bienvenue au jeu-concours Thé Tip Top
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg md:text-xl text-gray-600 mb-8 sm:mb-10">
|
||||
À l'occasion de l'ouverture de notre 10ᵉ boutique à Nice, participez à notre jeu-concours
|
||||
et tentez de gagner des lots exceptionnels ! Tous les tickets sont gagnants, alors n'attendez
|
||||
plus pour découvrir votre lot.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
|
||||
<Link
|
||||
to="/saisir-code-unique"
|
||||
className="bg-[#006B6B] hover:bg-[#005555] text-white font-semibold px-6 sm:px-8 py-3 sm:py-4 rounded-xl transition-colors text-sm sm:text-base"
|
||||
>
|
||||
Tester mon code
|
||||
</Link>
|
||||
<Link
|
||||
to="/regles-jeu"
|
||||
className="border border-[#006B6B] text-[#006B6B] hover:bg-[#005555] hover:text-white font-semibold px-6 sm:px-8 py-3 sm:py-4 rounded-xl transition-colors text-sm sm:text-base"
|
||||
>
|
||||
Découvrir les règles
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Comment participer */}
|
||||
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-20">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-6 sm:mb-8 text-center">
|
||||
Comment participer ?
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ num: '1', text: 'Achat de 49 € minimum' },
|
||||
{ num: '2', text: 'Recevez votre code unique' },
|
||||
{ num: '3', text: 'Saisissez le code sur le site' },
|
||||
{ num: '4', text: 'Découvrez votre lot' },
|
||||
].map((step) => (
|
||||
<div key={step.num} className="bg-white rounded-2xl shadow p-4 sm:p-6 text-center">
|
||||
<div className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-2 sm:mb-3">{step.num}</div>
|
||||
<p className="text-sm sm:text-base text-gray-700">{step.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,71 +1,661 @@
|
||||
const MOCK_STATS = {
|
||||
totalParticipants: 1248,
|
||||
gainsRemis: 342,
|
||||
gainsEnAttente: 906,
|
||||
tauxParticipation: '78%',
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi } from '../services/admin'
|
||||
import { searchTicketApi, deliverClaimApi } from '../services/employee'
|
||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||
|
||||
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 = [
|
||||
{ id: 1, name: 'Jean Dupont', email: 'jean.dupont@email.com', gains: 2 },
|
||||
{ id: 2, name: 'Marie Martin', email: 'marie.martin@email.com', gains: 1 },
|
||||
{ id: 3, name: 'Paul Bernard', email: 'paul.bernard@email.com', gains: 3 },
|
||||
]
|
||||
// Les cinq états renvoyés par l'API, chacun avec sa couleur : « programmé »
|
||||
// ne doit pas être confondu avec « retraits uniquement ».
|
||||
const PHASE_BADGES = {
|
||||
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() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">Tableau de bord Admin</h1>
|
||||
const handleAuthError = useHandleAuthError()
|
||||
const [stats, setStats] = useState(null)
|
||||
const [users, setUsers] = useState([])
|
||||
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 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
// Lancement du jeu-concours
|
||||
const [startDateInput, setStartDateInput] = useState('')
|
||||
const [settingsSaving, setSettingsSaving] = useState(false)
|
||||
const [settingsError, setSettingsError] = useState('')
|
||||
const [settingsOk, setSettingsOk] = useState('')
|
||||
|
||||
// Remise de lot
|
||||
const [ticketCode, setTicketCode] = useState('')
|
||||
const [ticketResult, setTicketResult] = useState(null)
|
||||
const [ticketSearching, setTicketSearching] = useState(false)
|
||||
const [ticketDelivering, setTicketDelivering] = useState(false)
|
||||
const [ticketSearchErr, setTicketSearchErr] = useState('')
|
||||
const [ticketDeliverErr, setTicketDeliverErr] = useState('')
|
||||
const [ticketSuccess, setTicketSuccess] = useState(false)
|
||||
|
||||
// Contacts emailing
|
||||
const [emailingContacts, setEmailingContacts] = useState(null)
|
||||
const [emailingLoading, setEmailingLoading] = useState(false)
|
||||
const [emailingError, setEmailingError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
|
||||
.then(([s, u, w]) => {
|
||||
setStats(s)
|
||||
setUsers(u.users)
|
||||
setWinner(w)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!handleAuthError(e)) setError(e.message)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [handleAuthError])
|
||||
|
||||
const handleSaveGameSettings = async (e) => {
|
||||
e.preventDefault()
|
||||
setSettingsError('')
|
||||
setSettingsOk('')
|
||||
setSettingsSaving(true)
|
||||
try {
|
||||
// L'input datetime-local renvoie une heure locale sans fuseau : on la
|
||||
// convertit en ISO pour que le serveur reçoive un instant sans ambiguïté.
|
||||
const data = await setGameSettingsApi(new Date(startDateInput).toISOString())
|
||||
setStats((prev) => (prev ? { ...prev, gameWindow: data.gameWindow } : prev))
|
||||
setSettingsOk(data.message)
|
||||
setStartDateInput('')
|
||||
} catch (err) {
|
||||
if (!handleAuthError(err)) setSettingsError(err.message)
|
||||
} finally {
|
||||
setSettingsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDraw = async () => {
|
||||
setShowConfirm(false)
|
||||
setDrawing(true)
|
||||
setDrawError('')
|
||||
try {
|
||||
const data = await drawWinnerApi()
|
||||
setWinner(data)
|
||||
} catch (e) {
|
||||
if (!handleAuthError(e)) setDrawError(e.message)
|
||||
} finally {
|
||||
setDrawing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadEmailingContacts = async () => {
|
||||
setEmailingLoading(true)
|
||||
setEmailingError('')
|
||||
try {
|
||||
const data = await getEmailingContactsApi()
|
||||
setEmailingContacts(data.contacts)
|
||||
} catch (e) {
|
||||
if (!handleAuthError(e)) setEmailingError(e.message)
|
||||
} finally {
|
||||
setEmailingLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportEmailingCsv = () => {
|
||||
if (!emailingContacts?.length) return
|
||||
const header = ['Prénom', 'Nom', 'Email', 'Inscrit le']
|
||||
const rows = emailingContacts.map((c) => [
|
||||
c.firstname, c.lastname, c.email, new Date(c.createdAt).toLocaleDateString('fr-FR'),
|
||||
])
|
||||
const csv = [header, ...rows]
|
||||
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(';'))
|
||||
.join('\n')
|
||||
// Le BOM en tete du fichier est indispensable : sans lui, Excel ouvre le
|
||||
// CSV en ANSI et casse tous les accents. Ne pas le supprimer.
|
||||
// eslint-disable-next-line no-irregular-whitespace
|
||||
const blob = new Blob([`${csv}`], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `contacts-emailing-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleTicketSearch = async (e) => {
|
||||
e.preventDefault()
|
||||
setTicketResult(null)
|
||||
setTicketSearchErr('')
|
||||
setTicketDeliverErr('')
|
||||
setTicketSuccess(false)
|
||||
setTicketSearching(true)
|
||||
try {
|
||||
const data = await searchTicketApi(ticketCode.trim())
|
||||
setTicketResult(data)
|
||||
} catch (err) {
|
||||
if (!handleAuthError(err)) setTicketSearchErr(err.message)
|
||||
} finally {
|
||||
setTicketSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTicketDeliver = async () => {
|
||||
if (!ticketResult?.claim?.id) return
|
||||
setTicketDeliverErr('')
|
||||
setTicketDelivering(true)
|
||||
try {
|
||||
await deliverClaimApi(ticketResult.claim.id)
|
||||
setTicketResult((prev) => ({
|
||||
...prev,
|
||||
claim: { ...prev.claim, status: 'DELIVERED', claimedAt: new Date().toISOString() },
|
||||
}))
|
||||
setTicketSuccess(true)
|
||||
setStats((prev) => prev ? {
|
||||
...prev,
|
||||
claimsPending: Math.max(0, (prev.claimsPending ?? 1) - 1),
|
||||
claimsDelivered: (prev.claimsDelivered ?? 0) + 1,
|
||||
} : prev)
|
||||
} catch (err) {
|
||||
if (!handleAuthError(err)) setTicketDeliverErr(err.message)
|
||||
} finally {
|
||||
setTicketDelivering(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<p className="text-gray-500">Chargement...</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (error) return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<p className="text-red-500 text-sm">{error}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
const winnerUser = winner?.winner?.user
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-800">Tableau de bord Admin</h1>
|
||||
|
||||
{/* Fenêtre du jeu-concours */}
|
||||
{stats?.gameWindow && (
|
||||
<div className={`rounded-xl shadow p-4 sm:p-6 ${PHASE_PANELS[stats.gameWindow.phase] ?? 'bg-white'}`}>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-800">
|
||||
{PHASE_TITLES[stats.gameWindow.phase] ?? 'Jeu-concours'}
|
||||
</p>
|
||||
{stats.gameWindow.configured ? (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Début : {formatDate(stats.gameWindow.startDate)}
|
||||
<br />
|
||||
Fin des participations : {formatDate(stats.gameWindow.participationDeadline)}
|
||||
<br />
|
||||
Fin des retraits de lots : {formatDate(stats.gameWindow.claimDeadline)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
Choisissez une date de début pour ouvrir le jeu-concours.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${PHASE_BADGES[stats.gameWindow.phase]?.className ?? 'bg-gray-100 text-gray-700'}`}>
|
||||
{PHASE_BADGES[stats.gameWindow.phase]?.label ?? 'État inconnu'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveGameSettings} className="mt-4 pt-4 border-t border-gray-200 flex flex-wrap items-end gap-3">
|
||||
<div className="flex-1 min-w-[220px]">
|
||||
<label htmlFor="game-start" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
{stats.gameWindow.configured ? 'Corriger la date de début' : 'Date de début du jeu-concours'}
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="game-start"
|
||||
value={startDateInput}
|
||||
onChange={(e) => setStartDateInput(e.target.value)}
|
||||
required
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={settingsSaving || !startDateInput}
|
||||
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
|
||||
>
|
||||
{settingsSaving ? 'Enregistrement…' : stats.gameWindow.configured ? 'Mettre à jour' : 'Lancer le jeu'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots.
|
||||
</p>
|
||||
|
||||
{settingsError && <p className="text-red-500 text-xs mt-2">{settingsError}</p>}
|
||||
{settingsOk && <p className="text-green-600 text-xs mt-2">{settingsOk}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats principales */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
|
||||
<StatCard label="Participants" value={stats?.users} />
|
||||
<StatCard label="Participations" value={stats?.participations} />
|
||||
<StatCard label="Gains en attente" value={stats?.claimsPending} />
|
||||
<StatCard label="Gains remis" value={stats?.claimsDelivered} />
|
||||
</div>
|
||||
|
||||
{/* Stats tickets */}
|
||||
<div className="grid grid-cols-3 gap-3 sm:gap-4">
|
||||
<StatCard label="Tickets total" value={stats?.ticketsTotal} />
|
||||
<StatCard label="Tickets utilisés" value={stats?.ticketsUsed} />
|
||||
<StatCard label="Tickets restants" value={stats?.ticketsRemaining} />
|
||||
</div>
|
||||
|
||||
{/* Stats démographiques */}
|
||||
{stats?.genderStats && stats?.ageStats && (
|
||||
<div className="grid sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Genre des participants</h2>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ label: 'Participants', value: MOCK_STATS.totalParticipants },
|
||||
{ label: 'Gains remis', value: MOCK_STATS.gainsRemis },
|
||||
{ label: 'En attente', value: MOCK_STATS.gainsEnAttente },
|
||||
{ label: 'Taux participation', value: MOCK_STATS.tauxParticipation },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="bg-white rounded-xl shadow p-4 text-center">
|
||||
<p className="text-2xl font-bold text-green-700">{stat.value}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">{stat.label}</p>
|
||||
{ label: 'Femmes', value: stats.genderStats.women },
|
||||
{ label: 'Hommes', value: stats.genderStats.men },
|
||||
{ label: 'Autre / non renseigné', value: stats.genderStats.other },
|
||||
].map((row) => (
|
||||
<div key={row.label} className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">{row.label}</span>
|
||||
<span className="font-semibold text-gray-800">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Liste participants */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-4">Participants</h2>
|
||||
<table className="w-full text-sm">
|
||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Âge des participants</h2>
|
||||
<div className="space-y-2">
|
||||
{['-18', '18-24', '25-34', '35-44', '45-54', '55-64', '65+', 'unknown'].map((bracket) => (
|
||||
<div key={bracket} className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">{bracket === 'unknown' ? 'Non renseigné' : `${bracket} ans`}</span>
|
||||
<span className="font-semibold text-gray-800">{stats.ageStats[bracket] ?? 0}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Répartition par lot */}
|
||||
{stats?.lotsStats?.length > 0 && (
|
||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Répartition des lots</h2>
|
||||
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||
<table className="w-full text-sm min-w-[480px]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b">
|
||||
<th className="pb-2">Nom</th>
|
||||
<th className="pb-2">Email</th>
|
||||
<th className="pb-2 text-right">Gains</th>
|
||||
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
||||
<th className="pb-2 px-4 sm:px-0">Lot</th>
|
||||
<th className="pb-2 px-4 sm:px-0 text-center">%</th>
|
||||
<th className="pb-2 px-4 sm:px-0 text-center">Tickets</th>
|
||||
<th className="pb-2 px-4 sm:px-0 text-center">Participations</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MOCK_USERS.map((user) => (
|
||||
<tr key={user.id} className="border-b last:border-0">
|
||||
<td className="py-3 font-medium text-gray-800">{user.name}</td>
|
||||
<td className="py-3 text-gray-500">{user.email}</td>
|
||||
<td className="py-3 text-right text-green-700 font-semibold">{user.gains}</td>
|
||||
{stats.lotsStats.map((lot) => (
|
||||
<tr key={lot.id} className="border-b last:border-0">
|
||||
<td className="py-2.5 px-4 sm:px-0 font-medium text-gray-800 text-xs sm:text-sm">{lot.name}</td>
|
||||
<td className="py-2.5 px-4 sm:px-0 text-center text-gray-500 text-xs sm:text-sm">{lot.percentage}%</td>
|
||||
<td className="py-2.5 px-4 sm:px-0 text-center text-gray-600 text-xs sm:text-sm">{lot._count.tickets.toLocaleString('fr-FR')}</td>
|
||||
<td className="py-2.5 px-4 sm:px-0 text-center text-[#006B6B] font-semibold text-xs sm:text-sm">{lot._count.participations}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Contacts emailing ── */}
|
||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
<h2 className="text-base sm:text-lg font-bold text-gray-800">Contacts emailing</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleLoadEmailingContacts}
|
||||
disabled={emailingLoading}
|
||||
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
|
||||
>
|
||||
{emailingLoading ? 'Chargement…' : emailingContacts ? 'Actualiser' : 'Charger les contacts'}
|
||||
</button>
|
||||
{emailingContacts?.length > 0 && (
|
||||
<button
|
||||
onClick={handleExportEmailingCsv}
|
||||
className="border border-gray-200 text-gray-600 hover:bg-gray-50 font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
|
||||
>
|
||||
Exporter en CSV
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{emailingError && <p className="text-red-500 text-sm mb-3">{emailingError}</p>}
|
||||
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Clients ayant accepté d'être contactés à des fins marketing.
|
||||
{emailingContacts && <> — <span className="font-semibold text-gray-800">{emailingContacts.length}</span> contact(s)</>}
|
||||
</p>
|
||||
|
||||
{emailingContacts && (
|
||||
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||
<table className="w-full text-sm min-w-[400px]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
||||
<th className="pb-2 px-4 sm:px-0">Nom</th>
|
||||
<th className="pb-2 px-4 sm:px-0">Email</th>
|
||||
<th className="pb-2 px-4 sm:px-0 text-right">Inscrit le</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{emailingContacts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="py-6 text-center text-gray-500 text-sm">
|
||||
Aucun contact n'a accepté l'emailing pour le moment
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{emailingContacts.map((contact) => (
|
||||
<tr key={contact.email} className="border-b last:border-0">
|
||||
<td className="py-3 px-4 sm:px-0 font-medium text-gray-800 whitespace-nowrap text-xs sm:text-sm">
|
||||
{contact.firstname} {contact.lastname}
|
||||
</td>
|
||||
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">{contact.email}</td>
|
||||
<td className="py-3 px-4 sm:px-0 text-right text-gray-500 text-xs">
|
||||
{new Date(contact.createdAt).toLocaleDateString('fr-FR')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Remise de lot ── */}
|
||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Remise de lot</h2>
|
||||
|
||||
<form onSubmit={handleTicketSearch} className="flex gap-2 mb-4">
|
||||
<label htmlFor="admin-ticket-code" className="sr-only">Code ticket</label>
|
||||
<input
|
||||
type="text"
|
||||
id="admin-ticket-code"
|
||||
value={ticketCode}
|
||||
onChange={(e) => setTicketCode(e.target.value.toUpperCase())}
|
||||
placeholder="Code ticket (10 caractères)"
|
||||
maxLength={10}
|
||||
className="flex-1 min-w-0 border border-gray-200 rounded-lg px-4 py-2.5 text-sm font-mono uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={ticketSearching || ticketCode.trim().length === 0}
|
||||
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2.5 rounded-lg transition-colors text-sm whitespace-nowrap"
|
||||
>
|
||||
{ticketSearching ? 'Recherche…' : 'Rechercher'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{ticketSearchErr && (
|
||||
<p className="text-red-500 text-sm mb-3">{ticketSearchErr}</p>
|
||||
)}
|
||||
|
||||
{ticketResult && (
|
||||
<div className="border border-gray-100 rounded-xl overflow-hidden">
|
||||
{ticketSuccess && (
|
||||
<div className="bg-green-500 px-4 py-2.5 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-white flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<p className="text-white text-sm font-medium">Lot remis avec succès.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 py-3 flex items-start justify-between gap-3 border-b border-gray-100">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 text-sm">{ticketResult.lot.name}</p>
|
||||
<p className="text-gray-500 text-xs mt-0.5">{ticketResult.lot.description}</p>
|
||||
{ticketResult.lot.value && <p className="text-[#006B6B] text-xs font-semibold mt-1">{ticketResult.lot.value} €</p>}
|
||||
</div>
|
||||
<span className={`flex-shrink-0 text-xs font-semibold px-2.5 py-1 rounded-full ${
|
||||
ticketResult.claim?.status === 'DELIVERED' ? 'bg-green-100 text-green-700'
|
||||
: ticketResult.claim?.status === 'CANCELLED' ? 'bg-gray-100 text-gray-500'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{ticketResult.claim?.status === 'DELIVERED' ? 'Remis'
|
||||
: ticketResult.claim?.status === 'CANCELLED' ? 'Expiré'
|
||||
: 'À remettre'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 flex items-center gap-3 border-b border-gray-100">
|
||||
<div className="w-8 h-8 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||
{(ticketResult.client.firstname?.[0] ?? '?').toUpperCase()}{(ticketResult.client.lastname?.[0] ?? '?').toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">{ticketResult.client.firstname} {ticketResult.client.lastname}</p>
|
||||
<p className="text-xs text-gray-500">{ticketResult.client.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
{ticketDeliverErr && <p className="text-red-500 text-xs">{ticketDeliverErr}</p>}
|
||||
|
||||
{ticketResult.claim?.status === 'PENDING' && !ticketSuccess && (
|
||||
<button
|
||||
onClick={handleTicketDeliver}
|
||||
disabled={ticketDelivering}
|
||||
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold py-2.5 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
{ticketDelivering ? 'Confirmation…' : 'Confirmer la remise du lot'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setTicketCode(''); setTicketResult(null); setTicketSuccess(false); setTicketSearchErr(''); setTicketDeliverErr('') }}
|
||||
className="w-full border border-gray-200 text-gray-500 hover:bg-gray-50 py-2 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
Rechercher un autre code
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Tirage au sort ── */}
|
||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Grand tirage au sort</h2>
|
||||
|
||||
{winnerUser ? (
|
||||
/* Gagnant déjà tiré */
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4 p-4 bg-[#006B6B]/5 rounded-xl border border-[#006B6B]/20">
|
||||
<div className="w-14 h-14 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-lg font-bold flex-shrink-0">
|
||||
{(winnerUser.firstname?.[0] ?? '?').toUpperCase()}{(winnerUser.lastname?.[0] ?? '?').toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-[#006B6B] font-semibold uppercase tracking-wider mb-0.5">Gagnant du grand tirage</p>
|
||||
<p className="font-bold text-gray-900 text-base">{winnerUser.firstname} {winnerUser.lastname}</p>
|
||||
<p className="text-gray-500 text-sm truncate">{winnerUser.email}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="w-8 h-8 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{winner?.winner?.createdAt && (
|
||||
<p className="text-xs text-gray-500 text-right">
|
||||
Tirage effectué le {formatDate(winner.winner.createdAt)}
|
||||
</p>
|
||||
)}
|
||||
{/* Signalé uniquement au retour du tirage : le GET du gagnant ne
|
||||
renvoie pas emailSent, et l'absence de la clé ne vaut pas échec. */}
|
||||
{winner?.emailSent === false && (
|
||||
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||
L'email de notification n'a pas pu être envoyé au gagnant. Merci de le contacter directement à l'adresse ci-dessus.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Pas encore de tirage */
|
||||
<div className="space-y-3">
|
||||
<div className="p-4 bg-gray-50 rounded-xl border border-gray-200">
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
<span className="font-semibold text-gray-800">{stats?.participations ?? 0}</span> participations enregistrées
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-semibold text-gray-800">{stats?.users ?? 0}</span> participants éligibles
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{drawError && (
|
||||
<p className="text-red-500 text-sm px-1">{drawError}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={drawing || (stats?.users ?? 0) === 0}
|
||||
className="flex items-center gap-2 bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-5 py-3 rounded-xl transition-colors text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
||||
</svg>
|
||||
{drawing ? 'Tirage en cours…' : 'Lancer le tirage au sort'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Liste participants */}
|
||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">
|
||||
Participants <span className="text-gray-500 font-normal text-sm">({users.length})</span>
|
||||
</h2>
|
||||
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||
<table className="w-full text-sm min-w-[400px]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
||||
<th className="pb-2 px-4 sm:px-0">Nom</th>
|
||||
<th className="pb-2 px-4 sm:px-0">Email</th>
|
||||
<th className="pb-2 px-4 sm:px-0 text-right">Participations</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="py-6 text-center text-gray-500 text-sm">
|
||||
Aucun participant pour le moment
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b last:border-0">
|
||||
<td className="py-3 px-4 sm:px-0 font-medium text-gray-800 whitespace-nowrap text-xs sm:text-sm">
|
||||
{user.firstname} {user.lastname}
|
||||
</td>
|
||||
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">{user.email}</td>
|
||||
<td className="py-3 px-4 sm:px-0 text-right text-[#006B6B] font-semibold text-xs sm:text-sm">
|
||||
{user._count.participations}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Modal de confirmation du tirage ── */}
|
||||
{showConfirm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 px-4">
|
||||
<div className="bg-white rounded-2xl w-full max-w-sm shadow-xl p-6">
|
||||
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-14 h-14 rounded-full bg-yellow-100 flex items-center justify-center">
|
||||
<svg className="w-7 h-7 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-gray-900 text-center mb-1">Lancer le tirage au sort ?</h3>
|
||||
<p className="text-sm text-gray-500 text-center mb-4">
|
||||
Un gagnant sera sélectionné aléatoirement parmi les <strong>{stats?.users}</strong> participants.
|
||||
</p>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 mb-5 flex gap-2 items-start">
|
||||
<svg className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
<p className="text-xs text-red-600">Cette action est <strong>irréversible</strong>. Le tirage ne peut être effectué qu'une seule fois.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowConfirm(false)}
|
||||
className="flex-1 border border-gray-200 text-gray-600 hover:bg-gray-50 font-medium py-2.5 rounded-xl transition-colors text-sm"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDraw}
|
||||
className="flex-1 bg-[#006B6B] hover:bg-[#005959] text-white font-semibold py-2.5 rounded-xl transition-colors text-sm"
|
||||
>
|
||||
Confirmer le tirage
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
76
frontend/src/pages/AuthCallback.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
// Point d'arrivée après un aller-retour OAuth : le backend nous renvoie ici
|
||||
// avec le JWT et le profil encodés dans l'URL.
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const { login } = useAuth()
|
||||
|
||||
const errorParam = searchParams.get('error')
|
||||
const [error, setError] = useState(errorParam || '')
|
||||
|
||||
// StrictMode monte deux fois en dev : on ne traite le token qu'une seule fois.
|
||||
const handled = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (handled.current) return
|
||||
handled.current = true
|
||||
|
||||
if (errorParam) return
|
||||
|
||||
const token = searchParams.get('token')
|
||||
const encodedUser = searchParams.get('user')
|
||||
|
||||
if (!token || !encodedUser) {
|
||||
setTimeout(() => {
|
||||
setError('Réponse de connexion incomplète.')
|
||||
}, 0)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const user = JSON.parse(atob(encodedUser))
|
||||
login(user, token)
|
||||
|
||||
if (user.role === 'ADMIN') {
|
||||
navigate('/admin', { replace: true })
|
||||
} else if (user.role === 'EMPLOYEE') {
|
||||
navigate('/employe', { replace: true })
|
||||
} else {
|
||||
navigate('/mes-gains', { replace: true })
|
||||
}
|
||||
} catch {
|
||||
setTimeout(() => {
|
||||
setError('Réponse de connexion illisible.')
|
||||
}, 0)
|
||||
}
|
||||
}, [searchParams, errorParam, login, navigate])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col justify-center items-center px-8 max-w-sm mx-auto text-center">
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/connexion"
|
||||
className="text-sm text-[#006B6B] font-medium hover:underline"
|
||||
>
|
||||
Retour à la connexion
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col justify-center items-center px-8 max-w-sm mx-auto text-center">
|
||||
<p className="text-gray-500 text-sm">
|
||||
Connexion en cours…
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
244
frontend/src/pages/Cgu.jsx
Normal file
@@ -0,0 +1,244 @@
|
||||
export default function Cgu() {
|
||||
return (
|
||||
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||
Conditions générales d'utilisation
|
||||
</h1>
|
||||
|
||||
<div className="mb-6 sm:mb-8 px-4 py-3 sm:px-5 sm:py-4 rounded-xl bg-[#006B6B]">
|
||||
<p className="text-white text-sm sm:text-base font-medium leading-relaxed">
|
||||
Projet étudiant fictif — Ce site a été conçu dans un cadre pédagogique. La société
|
||||
Thé Tip Top, l'agence Furious Ducks et l'ensemble des conditions présentées
|
||||
ci-dessous sont fictives. Aucun achat, paiement ou réservation réel ne peut être
|
||||
effectué sur ce site.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-8 sm:mb-10 text-sm sm:text-base">
|
||||
Les présentes Conditions Générales d'Utilisation (CGU) précisent les modalités d'accès et
|
||||
d'utilisation du site internet du jeu-concours « 100% Gagnant » de la société Thé Tip Top.
|
||||
</p>
|
||||
|
||||
{[
|
||||
{
|
||||
title: 'Article 1 – Objet des CGU',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-2 text-sm sm:text-base leading-relaxed">
|
||||
<p>
|
||||
Les présentes Conditions Générales d'Utilisation (CGU) ont pour objet de définir
|
||||
les modalités et conditions dans lesquelles la société Thé Tip Top met à
|
||||
disposition des utilisateurs le site internet du jeu-concours « 100% Gagnant »,
|
||||
accessible à l'adresse https://www.thetiptop-concours.fr.
|
||||
</p>
|
||||
<p>
|
||||
Toute utilisation du Site implique l'acceptation pleine et entière des présentes
|
||||
CGU. En cas de non-acceptation, l'utilisateur est invité à ne pas accéder au Site
|
||||
ni à utiliser ses services.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Article 2 – Conditions d\'accès au Site',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-2 text-sm sm:text-base leading-relaxed">
|
||||
<p><strong>Conditions d'âge —</strong> La participation au jeu-concours est strictement
|
||||
réservée aux personnes physiques majeures, c'est-à-dire âgées d'au moins 18 ans
|
||||
révolus à la date de leur inscription. Toute inscription d'un mineur est nulle et
|
||||
non avenue. La société Thé Tip Top se réserve le droit de demander la fourniture
|
||||
d'un justificatif d'identité pour vérifier l'âge déclaré.
|
||||
</p>
|
||||
<p><strong>Conditions géographiques —</strong> Le jeu-concours « 100% Gagnant » est
|
||||
ouvert exclusivement aux résidents de France métropolitaine et des Départements et
|
||||
Régions d'Outre-Mer (DROM). Sont exclus les ressortissants ou résidents de toute
|
||||
autre zone géographique.
|
||||
</p>
|
||||
<p><strong>Personnes exclues —</strong> Sont exclus de toute participation les membres
|
||||
du personnel de la société Thé Tip Top et de l'agence Furious Ducks, leurs
|
||||
sous-traitants, ainsi que les membres de leur famille en ligne directe (conjoints,
|
||||
ascendants, descendants). Est également exclu le personnel de l'étude du
|
||||
Commissaire de Justice dépositaire du règlement.
|
||||
</p>
|
||||
<p><strong>Conditions techniques —</strong> L'accès au Site nécessite une connexion
|
||||
internet active et un navigateur web à jour. La société Thé Tip Top ne saurait
|
||||
être tenue responsable des difficultés d'accès liées à l'environnement technique
|
||||
de l'utilisateur.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Article 3 – Services proposés et fonctionnalités',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-4 text-sm sm:text-base leading-relaxed">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">3.1 – Services proposés</h3>
|
||||
<p>
|
||||
Le site du jeu-concours « 100% Gagnant » est un site internet dédié permettant
|
||||
aux clients de la société Thé Tip Top de participer à un jeu-concours à
|
||||
l'occasion de l'ouverture de la 10ᵉ boutique de l'enseigne à Nice. Les services
|
||||
proposés incluent :
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 mt-2">
|
||||
<li>La présentation du jeu-concours, de ses conditions et des lots à gagner</li>
|
||||
<li>La création d'un espace personnel pour chaque participant</li>
|
||||
<li>La saisie et la validation des codes à 10 caractères figurant sur les tickets de caisse ou factures d'un montant supérieur à 49 €</li>
|
||||
<li>La consultation de l'historique des participations et gains obtenus</li>
|
||||
<li>La réclamation des lots gagnés, en ligne ou en boutique</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">3.2 – Fonctionnalités principales</h3>
|
||||
<p><strong>S'inscrire —</strong> L'utilisateur peut créer un compte personnel en
|
||||
renseignant un formulaire d'inscription classique ou en utilisant les
|
||||
fonctionnalités d'authentification via Google. La création d'un compte est
|
||||
obligatoire pour participer au jeu-concours.
|
||||
</p>
|
||||
<p className="mt-2"><strong>Jouer (participer au jeu-concours) —</strong> Une fois
|
||||
connecté, l'utilisateur saisit le code à 10 caractères figurant sur son ticket de
|
||||
caisse ou sa facture. Le Site valide le code et attribue immédiatement le lot
|
||||
correspondant selon la répartition préétablie :
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 mt-2">
|
||||
<li>60 % des tickets : infuseur à thé</li>
|
||||
<li>20 % des tickets : boîte de 100 g de thé détox ou d'infusion</li>
|
||||
<li>10 % des tickets : boîte de 100 g de thé signature</li>
|
||||
<li>6 % des tickets : coffret découverte d'une valeur de 39 €</li>
|
||||
<li>4 % des tickets : coffret découverte d'une valeur de 69 €</li>
|
||||
</ul>
|
||||
<p className="mt-2">
|
||||
Un même utilisateur peut soumettre plusieurs codes distincts. La participation
|
||||
est possible pendant toute la durée du jeu-concours, soit 30 jours à compter de
|
||||
son ouverture. Les lots gagnés peuvent être réclamés pendant cette période ainsi
|
||||
que dans les 30 jours calendaires suivant la date de clôture du jeu-concours.
|
||||
Passé ce délai, les lots non réclamés sont définitivement perdus.
|
||||
</p>
|
||||
<p className="mt-2"><strong>Remise des lots —</strong> Les lots peuvent être réclamés
|
||||
en boutique, sur présentation du compte utilisateur validé et de la confirmation
|
||||
de gain.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
À l'issue du jeu-concours, un tirage au sort est effectué parmi l'ensemble des
|
||||
participants pour désigner le gagnant d'un an de thé d'une valeur de 360 €. Ce
|
||||
tirage a lieu sous le contrôle de Maître Arnaud Rick, Commissaire de Justice. Le
|
||||
nombre de participations n'augmente pas les chances de remporter ce lot.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Article 4 – Conditions d\'inscription et de création de compte',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-4 text-sm sm:text-base leading-relaxed">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">4.1 – Processus d'inscription</h3>
|
||||
<p>
|
||||
Pour accéder aux services du Site, l'utilisateur doit créer un compte personnel.
|
||||
L'inscription peut s'effectuer de deux manières :
|
||||
</p>
|
||||
<p className="mt-2"><strong>Via le formulaire classique —</strong> L'utilisateur
|
||||
renseigne les champs obligatoires suivants : nom, prénom, adresse e-mail valide,
|
||||
mot de passe sécurisé. L'utilisateur reçoit ensuite un e-mail de confirmation
|
||||
contenant un lien d'activation. Le compte n'est activé qu'après validation de ce
|
||||
lien.
|
||||
</p>
|
||||
<p className="mt-2"><strong>Via l'authentification Google —</strong> L'utilisateur
|
||||
peut s'inscrire en utilisant son compte Google. Dans ce cas, les données
|
||||
transmises par la plateforme tierce (nom, prénom, adresse e-mail) sont récupérées
|
||||
avec le consentement explicite de l'utilisateur.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">4.2 – Unicité du compte</h3>
|
||||
<p>
|
||||
Chaque utilisateur ne peut posséder qu'un seul compte. Toute tentative de
|
||||
création de comptes multiples par un même utilisateur est prohibée et peut
|
||||
entraîner la disqualification de l'ensemble des participations concernées.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">4.3 – Véracité des informations</h3>
|
||||
<p>
|
||||
L'utilisateur s'engage à fournir des informations exactes, complètes et à jour
|
||||
lors de son inscription et tout au long de l'utilisation du Site. Toute
|
||||
inscription effectuée avec une fausse identité ou des données inexactes sera
|
||||
annulée, ainsi que les participations et gains associés.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Article 5 – Responsabilité de l\'utilisateur',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-3 text-sm sm:text-base leading-relaxed">
|
||||
<p>
|
||||
L'utilisateur est seul responsable des informations communiquées lors de la
|
||||
création et de l'utilisation de son compte. À ce titre, il s'engage à :
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2">
|
||||
<li>Fournir des données personnelles exactes, sincères et à jour</li>
|
||||
<li>Ne pas usurper l'identité d'un tiers</li>
|
||||
<li>Ne pas créer de compte au nom d'une autre personne sans son autorisation</li>
|
||||
<li>Signaler sans délai à la société Thé Tip Top toute erreur, inexactitude ou modification de ses informations personnelles via les fonctionnalités de gestion du compte</li>
|
||||
</ul>
|
||||
<p>
|
||||
En cas de préjudice subi par la société Thé Tip Top ou par des tiers en raison de
|
||||
fausses informations fournies par l'utilisateur, ce dernier en assume l'entière
|
||||
responsabilité et peut se voir réclamer réparation.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Article 6 – Gestion du compte utilisateur',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-4 text-sm sm:text-base leading-relaxed">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">6.1 – Modification des informations</h3>
|
||||
<p>
|
||||
L'utilisateur peut à tout moment accéder à son espace personnel et modifier ses
|
||||
informations (nom, prénom, mot de passe).
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">6.2 – Suppression du compte</h3>
|
||||
<p>
|
||||
L'utilisateur peut demander la suppression de son compte à tout moment en
|
||||
contactant directement l'équipe via contact@thetiptop.fr. La suppression entraîne
|
||||
la perte définitive des participations et gains non encore réclamés. Les données
|
||||
personnelles sont supprimées conformément aux dispositions de la Politique de
|
||||
Confidentialité, sous réserve des obligations légales de conservation.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">6.3 – Sécurité des identifiants</h3>
|
||||
<p>
|
||||
L'utilisateur est seul responsable de la confidentialité de ses identifiants de
|
||||
connexion (adresse e-mail et mot de passe). Il s'engage à ne les communiquer à
|
||||
personne et à prendre toutes les précautions nécessaires pour en assurer la
|
||||
protection. En cas de perte, de vol ou d'utilisation frauduleuse de ses
|
||||
identifiants, l'utilisateur doit en informer immédiatement la société Thé Tip
|
||||
Top. La société Thé Tip Top ne saurait être tenue responsable des dommages
|
||||
résultant de l'utilisation des identifiants par un tiers, sauf faute de sa part.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
].map((article) => (
|
||||
<section key={article.title} className="mb-6 sm:mb-8">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
{article.title}
|
||||
</h2>
|
||||
{article.content}
|
||||
</section>
|
||||
))}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
99
frontend/src/pages/Confidentialite.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
export default function Confidentialite() {
|
||||
return (
|
||||
<main className="bg-[#F0F0F0] min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||
Politique de confidentialité
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-600 mb-8 sm:mb-10 leading-relaxed text-sm sm:text-base">
|
||||
Thé Tip Top accorde une grande importance à la protection de vos données personnelles.
|
||||
Cette politique explique quelles informations sont collectées, comment elles sont utilisées
|
||||
et quels sont vos droits.
|
||||
</p>
|
||||
|
||||
<section className="mb-6 sm:mb-8">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
Article 1 – Responsable du traitement
|
||||
</h2>
|
||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||
Le responsable du traitement des données collectées via le site du jeu-concours
|
||||
« 100% Gagnant » est la société Thé Tip Top, située au 18 rue Léon Frot, 75011 Paris.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-6 sm:mb-8">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
Article 2 – Données collectées
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3 text-sm sm:text-base">Dans le cadre de votre participation, nous pouvons collecter :</p>
|
||||
<ul className="list-disc list-inside text-gray-700 space-y-2 text-sm sm:text-base">
|
||||
<li>Nom et prénom</li>
|
||||
<li>Adresse email</li>
|
||||
<li>Informations du compte utilisateur</li>
|
||||
<li>Code de participation au jeu</li>
|
||||
<li>Informations liées aux gains obtenus</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-6 sm:mb-8">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
Article 3 – Utilisation des données
|
||||
</h2>
|
||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||
Les données collectées sont utilisées uniquement pour : gérer votre participation au
|
||||
jeu-concours, vérifier les codes, attribuer les lots, vous contacter en cas de gain
|
||||
et améliorer l'expérience utilisateur.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-6 sm:mb-8">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
Article 4 – Conservation des données
|
||||
</h2>
|
||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||
Vos données sont conservées uniquement pendant la durée nécessaire au fonctionnement
|
||||
du jeu-concours et au respect des obligations légales de Thé Tip Top.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-6 sm:mb-8">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
Article 5 – Cookies
|
||||
</h2>
|
||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||
Le site utilise des cookies nécessaires au fonctionnement ainsi que des cookies
|
||||
statistiques permettant d'améliorer les services proposés. Vous pouvez gérer vos
|
||||
préférences à tout moment.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-6 sm:mb-8">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
Article 6 – Vos droits
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3 text-sm sm:text-base">Conformément au RGPD, vous disposez des droits suivants :</p>
|
||||
<ul className="list-disc list-inside text-gray-700 space-y-2 text-sm sm:text-base">
|
||||
<li>Droit d'accès à vos données</li>
|
||||
<li>Droit de rectification</li>
|
||||
<li>Droit de suppression</li>
|
||||
<li>Droit d'opposition</li>
|
||||
<li>Droit à la limitation du traitement</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
Article 7 – Contact
|
||||
</h2>
|
||||
<p className="text-gray-700 text-sm sm:text-base">
|
||||
Pour toute question concernant vos données personnelles :<br />
|
||||
<strong>Email :</strong> contact@thetiptop.fr
|
||||
</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +1,167 @@
|
||||
import { useState } from 'react'
|
||||
import { 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() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { login } = useAuth()
|
||||
const justRegistered = location.state?.registered === true
|
||||
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 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()
|
||||
// TODO: connecter au backend
|
||||
alert(`Connexion avec ${form.email}`)
|
||||
setError('')
|
||||
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 (
|
||||
<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">Con
|
||||
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
||||
|
||||
nexion à votre espace</p>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Se connecter</h1>
|
||||
<p className="text-gray-500 mt-1 text-sm">Connexion à votre espace</p>
|
||||
</div>
|
||||
|
||||
{justRegistered && (
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm">
|
||||
Compte créé avec succès ! Connectez-vous.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-800 mb-1">
|
||||
Adresse email <span className="text-gray-800">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="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"
|
||||
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 className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Mot de passe
|
||||
<label htmlFor="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="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="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"
|
||||
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 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>
|
||||
|
||||
<button
|
||||
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>
|
||||
</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 ?{' '}
|
||||
<Link to="/inscription" className="text-green-700 font-medium hover:underline">
|
||||
<Link to="/inscription" className="text-gray-700 font-medium hover:underline">
|
||||
S'inscrire
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
45
frontend/src/pages/Contact.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import imgContact from '../assets/img/img-contact.jpg'
|
||||
|
||||
export default function Contact() {
|
||||
return (
|
||||
<main className="bg-[#F0F0F0] px-4 sm:px-6 py-10 sm:py-16">
|
||||
<div className="max-w-6xl mx-auto grid md:grid-cols-2 gap-6 sm:gap-8">
|
||||
|
||||
{/* Informations */}
|
||||
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||
Contact
|
||||
</h1>
|
||||
<p className="text-gray-600 leading-relaxed mb-6 sm:mb-8 text-sm sm:text-base">
|
||||
Une question concernant le jeu-concours, votre participation ou votre lot ?
|
||||
Notre équipe ThéTipTop est à votre écoute.
|
||||
</p>
|
||||
<div className="space-y-5 text-gray-700">
|
||||
<div>
|
||||
<h2 className="font-semibold text-[#006B6B] mb-1">Adresse</h2>
|
||||
<p className="text-sm sm:text-base">ThéTipTop<br />18 rue Léon Frot<br />75011 Paris, France</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-[#006B6B] mb-1">Email</h2>
|
||||
<a href="mailto:contact@thetiptop.fr" className="text-sm sm:text-base hover:text-[#006B6B]">contact@thetiptop.fr</a>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-[#006B6B] mb-1">Téléphone</h2>
|
||||
<a href="tel:+33184802034" className="text-sm sm:text-base hover:text-[#006B6B]">01 84 80 20 34</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Image */}
|
||||
<section className="rounded-2xl shadow overflow-hidden h-64 sm:h-80 md:h-[468px] md:self-start">
|
||||
<img
|
||||
src={imgContact}
|
||||
alt="Une personne savourant un thé"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
133
frontend/src/pages/DefinirMotDePasse.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { resetPasswordApi } from '../services/auth'
|
||||
|
||||
// Sert les deux parcours qui passent par un jeton envoyé par email :
|
||||
// la définition initiale du mot de passe après une inscription Google,
|
||||
// et la réinitialisation classique depuis « mot de passe oublié ».
|
||||
export default function DefinirMotDePasse() {
|
||||
const { token } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [form, setForm] = useState({ password: '', confirmPassword: '' })
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (form.password.length < 8) {
|
||||
setError('Le mot de passe doit contenir au moins 8 caractères.')
|
||||
return
|
||||
}
|
||||
if (form.password !== form.confirmPassword) {
|
||||
setError('Les deux mots de passe ne correspondent pas.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await resetPasswordApi({ token, ...form })
|
||||
setSuccess('Mot de passe enregistré. Vous pouvez maintenant vous connecter avec votre email.')
|
||||
setTimeout(() => navigate('/connexion'), 2500)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto text-center">
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm">
|
||||
{success}
|
||||
</div>
|
||||
<Link to="/connexion" className="text-sm text-[#006B6B] font-medium hover:underline">
|
||||
Aller à la connexion
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Mot de passe</h1>
|
||||
<p className="text-gray-500 mt-1 text-sm">
|
||||
Choisissez un mot de passe pour accéder à votre compte sans passer par Google.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-800 mb-1">
|
||||
Nouveau mot de passe <span className="text-gray-800">*</span>
|
||||
</label>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
name="password"
|
||||
value={form.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="8 caractères minimum"
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-800 mb-1">
|
||||
Confirmer le mot de passe <span className="text-gray-800">*</span>
|
||||
</label>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
value={form.confirmPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Saisissez-le à nouveau"
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showPassword}
|
||||
onChange={(e) => setShowPassword(e.target.checked)}
|
||||
className="w-5 h-5 accent-[#006B6B]"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">Afficher les mots de passe</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? 'Enregistrement…' : 'Enregistrer mon mot de passe'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-8">
|
||||
<Link to="/connexion" className="text-gray-700 font-medium hover:underline">
|
||||
Retour à la connexion
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +1,213 @@
|
||||
import { useState } from 'react'
|
||||
import { searchTicketApi, deliverClaimApi } from '../services/employee'
|
||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||
|
||||
export default function Employe() {
|
||||
const [code, setCode] = useState('')
|
||||
const [gain, setGain] = useState(null)
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault()
|
||||
// TODO: connecter au backend
|
||||
setGain({
|
||||
code,
|
||||
lot: 'Thé signature (100g)',
|
||||
client: 'Jean Dupont',
|
||||
email: 'jean.dupont@email.com',
|
||||
status: 'PENDING',
|
||||
function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeliver = () => {
|
||||
// TODO: connecter au backend
|
||||
setGain({ ...gain, status: 'DELIVERED' })
|
||||
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' },
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<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>
|
||||
export default function Employe() {
|
||||
const handleAuthError = useHandleAuthError()
|
||||
const [code, setCode] = useState('')
|
||||
const [result, setResult] = useState(null)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [delivering, setDelivering] = useState(false)
|
||||
const [searchError, setSearchError] = useState('')
|
||||
const [deliverError, setDeliverError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const handleSearch = async (e) => {
|
||||
e.preventDefault()
|
||||
setResult(null)
|
||||
setSuccess(false)
|
||||
setDeliverError('')
|
||||
setSearchError('')
|
||||
setSearching(true)
|
||||
try {
|
||||
const data = await searchTicketApi(code.trim())
|
||||
setResult(data)
|
||||
} catch (err) {
|
||||
if (!handleAuthError(err)) setSearchError(err.message)
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeliver = async () => {
|
||||
if (!result?.claim?.id) return
|
||||
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 (
|
||||
<div className="min-h-screen bg-gray-50 px-4 py-8 sm:py-12">
|
||||
<div className="max-w-lg mx-auto">
|
||||
|
||||
{/* 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">
|
||||
<label htmlFor="employee-ticket-code" className="sr-only">Code ticket</label>
|
||||
<input
|
||||
type="text"
|
||||
id="employee-ticket-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
required
|
||||
placeholder="Code ticket (TTT-XXXXX)"
|
||||
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"
|
||||
placeholder="Code ticket (10 caractères)"
|
||||
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
|
||||
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>
|
||||
</form>
|
||||
|
||||
{gain && (
|
||||
<div className="bg-white rounded-xl shadow p-6 space-y-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-bold text-gray-800 text-lg">{gain.lot}</p>
|
||||
<p className="text-sm text-gray-500 font-mono">{gain.code}</p>
|
||||
{/* Erreur de recherche */}
|
||||
{searchError && (
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||
{searchError}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-3">
|
||||
<p className="text-sm text-gray-600"><span className="font-medium">Client :</span> {gain.client}</p>
|
||||
<p className="text-sm text-gray-600"><span className="font-medium">Email :</span> {gain.email}</p>
|
||||
{/* Infos client */}
|
||||
<div className="px-5 py-4 space-y-2 border-b border-gray-100">
|
||||
<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>
|
||||
|
||||
{gain.status === 'PENDING' && (
|
||||
<button
|
||||
onClick={handleDeliver}
|
||||
className="w-full bg-green-700 hover:bg-green-800 text-white font-semibold py-2.5 rounded-lg transition-colors mt-2"
|
||||
>
|
||||
Confirmer la remise du lot
|
||||
</button>
|
||||
{/* Infos ticket */}
|
||||
<div className="px-5 py-4 space-y-1 border-b border-gray-100">
|
||||
<p className="text-xs text-gray-500 font-medium uppercase tracking-wider mb-2">Ticket</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
<span className="font-medium">Code :</span>{' '}
|
||||
<span className="font-mono">{result.ticket.code}</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
<span className="font-medium">Utilisé le :</span> {formatDate(result.ticket.usedAt)}
|
||||
</p>
|
||||
{result.claim.claimedAt && (
|
||||
<p className="text-sm text-gray-700">
|
||||
<span className="font-medium">Remis le :</span> {formatDate(result.claim.claimedAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
{deliverError && (
|
||||
<div className="px-4 py-2 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||
{deliverError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.claim.status === 'PENDING' && !success && (
|
||||
<button
|
||||
onClick={handleDeliver}
|
||||
disabled={delivering}
|
||||
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold py-3 rounded-xl transition-colors text-sm"
|
||||
>
|
||||
{delivering ? 'Confirmation en cours…' : 'Confirmer la remise du lot'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{result.claim.status === 'CANCELLED' && (
|
||||
<div className="px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 text-gray-500 text-sm text-center">
|
||||
Ce gain est expiré et ne peut plus être remis.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -1,116 +1,295 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useState, useRef } from 'react'
|
||||
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() {
|
||||
const navigate = useNavigate()
|
||||
const { login } = useAuth()
|
||||
|
||||
// Étape : 'form' | 'verify'
|
||||
const [step, setStep] = useState('form')
|
||||
const [email, setEmail] = useState('')
|
||||
const [form, setForm] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
lastName: '',
|
||||
firstName: '',
|
||||
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) => {
|
||||
const { name, value, type, checked } = e.target
|
||||
setForm({ ...form, [name]: type === 'checkbox' ? checked : value })
|
||||
// Code à 4 chiffres
|
||||
const [digits, setDigits] = useState(['', '', '', ''])
|
||||
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()
|
||||
// TODO: connecter au backend
|
||||
alert(`Inscription de ${form.firstName} ${form.lastName}`)
|
||||
setError('')
|
||||
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 (
|
||||
<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">Créer votre compte</p>
|
||||
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-[#006B6B]/10 flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-7 h-7 text-[#006B6B]" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold italic font-serif text-[#006B6B]">Vérifiez votre email</h1>
|
||||
<p className="text-gray-500 text-sm mt-2">
|
||||
Un code à 4 chiffres a été envoyé à<br />
|
||||
<span className="font-medium text-gray-700">{email}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Prénom</label>
|
||||
{codeError && (
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm text-center">
|
||||
{codeError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleVerify} className="space-y-6">
|
||||
<div className="flex justify-center gap-3">
|
||||
{digits.map((d, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={inputRefs[i]}
|
||||
type="text"
|
||||
name="firstName"
|
||||
value={form.firstName}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Jean"
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={d}
|
||||
onChange={(e) => handleDigit(i, e.target.value)}
|
||||
onKeyDown={(e) => handleDigitKeyDown(i, e)}
|
||||
aria-label={`Chiffre ${i + 1} du code de vérification`}
|
||||
className="w-14 h-14 text-center text-2xl font-bold border-2 border-gray-200 rounded-xl focus:outline-none focus:border-[#006B6B] focus:ring-1 focus:ring-[#006B6B] transition-colors"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nom</label>
|
||||
<input
|
||||
type="text"
|
||||
name="lastName"
|
||||
value={form.lastName}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Dupont"
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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
|
||||
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>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-6">
|
||||
Déjà un compte ?{' '}
|
||||
<Link to="/connexion" className="text-green-700 font-medium hover:underline">
|
||||
Se connecter
|
||||
</Link>
|
||||
<p className="text-center text-xs text-gray-500 mt-6">
|
||||
Le code expire dans 15 minutes.{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep('form'); setDigits(['', '', '', '']); setCodeError('') }}
|
||||
className="text-[#006B6B] hover:underline"
|
||||
>
|
||||
Recommencer
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Étape 1 : Formulaire d'inscription ──
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">S'inscrire</h1>
|
||||
<p className="text-gray-500 mt-1 text-sm">Créer votre compte</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="reg-email" className="block text-sm font-medium text-gray-800 mb-1">Adresse email <span className="text-gray-800">*</span></label>
|
||||
<input type="email" id="reg-email" name="email" value={form.email} onChange={handleChange} required placeholder="Votre email"
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="reg-lastName" className="block text-sm font-medium text-gray-800 mb-1">Nom <span className="text-gray-800">*</span></label>
|
||||
<input type="text" id="reg-lastName" name="lastName" value={form.lastName} onChange={handleChange} required placeholder="Votre nom"
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="reg-firstName" className="block text-sm font-medium text-gray-800 mb-1">Prénom <span className="text-gray-800">*</span></label>
|
||||
<input type="text" id="reg-firstName" name="firstName" value={form.firstName} onChange={handleChange} required placeholder="Votre prénom"
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="reg-birthdate" className="block text-sm font-medium text-gray-800 mb-1">Date de naissance</label>
|
||||
<input type="date" id="reg-birthdate" name="birthdate" value={form.birthdate} onChange={handleChange} max={AUJOURD_HUI}
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="reg-gender" className="block text-sm font-medium text-gray-800 mb-1">Genre</label>
|
||||
<select id="reg-gender" name="gender" value={form.gender} onChange={handleChange}
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]">
|
||||
<option value="">Non précisé</option>
|
||||
{GENRES.map((genre) => (
|
||||
<option key={genre} value={genre}>{genre}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="reg-password" className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label>
|
||||
<div className="relative">
|
||||
<input type={showPassword ? 'text' : 'password'} id="reg-password" name="password" value={form.password} onChange={handleChange} required placeholder="Votre mot de passe"
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600" aria-label="Afficher/masquer">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-gray-400 flex items-center justify-center">
|
||||
{showPassword && <div className="w-1.5 h-1.5 rounded-full bg-gray-400" />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" checked={acceptedTerms} onChange={(e) => setAcceptedTerms(e.target.checked)} required
|
||||
className="mt-0.5 w-5 h-5 accent-[#006B6B] flex-shrink-0" />
|
||||
<span className="text-xs text-gray-500 leading-relaxed">
|
||||
J'accepte les{' '}
|
||||
<Link to="/cgu" className="text-[#006B6B] hover:underline font-medium">conditions d'utilisation</Link>{' '}
|
||||
et la{' '}
|
||||
<Link to="/confidentialite" className="text-[#006B6B] hover:underline font-medium">politique de confidentialité</Link>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="marketingOptIn" checked={marketingOptIn}
|
||||
onChange={(e) => setMarketingOptIn(e.target.checked)}
|
||||
className="mt-0.5 w-5 h-5 accent-[#006B6B] flex-shrink-0" />
|
||||
<span className="text-xs text-gray-500 leading-relaxed">
|
||||
J'accepte de recevoir les offres et actualités de Thé Tip Top par email.
|
||||
<span className="block text-gray-500 mt-0.5">
|
||||
Facultatif — vous pouvez vous désinscrire à tout moment.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={loading || !acceptedTerms}
|
||||
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors">
|
||||
{loading ? 'Inscription…' : "S'inscrire"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-3 my-6">
|
||||
<div className="flex-1 h-px bg-gray-200" />
|
||||
<span className="text-sm text-gray-500">Ou</span>
|
||||
<div className="flex-1 h-px bg-gray-200" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button type="button" onClick={handleGoogleSignup} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
|
||||
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
|
||||
<path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"></path>
|
||||
<path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||
</svg>
|
||||
Google
|
||||
</button>
|
||||
<button type="button" disabled title="Inscription Facebook bientôt disponible" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-500 cursor-not-allowed opacity-60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
||||
<path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" />
|
||||
</svg>
|
||||
Facebook
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-8">
|
||||
Vous avez déjà un compte ?{' '}
|
||||
<Link to="/connexion" className="text-gray-700 font-medium hover:underline">Se connecter</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
255
frontend/src/pages/MentionsLegales.jsx
Normal file
@@ -0,0 +1,255 @@
|
||||
export default function MentionsLegales() {
|
||||
return (
|
||||
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||
Mentions légales
|
||||
</h1>
|
||||
|
||||
<div className="mb-6 sm:mb-8 px-4 py-3 sm:px-5 sm:py-4 rounded-xl bg-[#006B6B]">
|
||||
<p className="text-white text-sm sm:text-base font-medium leading-relaxed">
|
||||
Projet étudiant fictif — Ce site a été conçu dans un cadre pédagogique. La société
|
||||
Thé Tip Top, l'agence Furious Ducks et l'ensemble des informations légales présentées
|
||||
ci-dessous sont fictives. Aucun achat, paiement ou réservation réel ne peut être
|
||||
effectué sur ce site.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-8 sm:mb-10 text-sm sm:text-base">
|
||||
Conformément à la loi n°2004-575 du 21 juin 2004 pour la Confiance dans l'Économie
|
||||
Numérique (LCEN), les présentes mentions légales précisent l'identité des différents
|
||||
intervenants dans la conception et le suivi du site internet dédié au jeu-concours
|
||||
« 100% Gagnant » de la société Thé Tip Top.
|
||||
</p>
|
||||
|
||||
{[
|
||||
{
|
||||
title: 'Article 1 – Société éditrice du site',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-2 text-sm sm:text-base">
|
||||
<p><strong>Dénomination sociale :</strong> Thé Tip Top</p>
|
||||
<p><strong>Forme juridique :</strong> Société à Responsabilité Limitée (SARL)</p>
|
||||
<p><strong>Capital social :</strong> 150 000 €</p>
|
||||
<p><strong>Siège social :</strong> 18 rue Léon Frot, 75011 Paris, France</p>
|
||||
<p><strong>Représentant légal :</strong> M. Eric Bourdon, Gérant</p>
|
||||
<p><strong>Directeur de la publication :</strong> M. Eric Bourdon, en sa qualité de Gérant</p>
|
||||
<p><strong>SIRET :</strong> 362 521 879 00054</p>
|
||||
<p><strong>TVA intracommunautaire :</strong> FR83362521879</p>
|
||||
<p><strong>Email :</strong> contact@thetiptop.fr</p>
|
||||
<p><strong>Téléphone :</strong> 01 84 80 20 34</p>
|
||||
<p className="pt-2">
|
||||
Le règlement du jeu-concours a été déposé auprès de Maître Arnaud Rick,
|
||||
Commissaire de Justice. Il est consultable gratuitement sur simple demande
|
||||
à l'adresse contact@thetiptop.fr.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Article 2 – Agence de développement',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-2 text-sm sm:text-base">
|
||||
<p><strong>Dénomination sociale :</strong> Furious Ducks</p>
|
||||
<p><strong>Statut juridique :</strong> Société par Actions Simplifiée (SAS)</p>
|
||||
<p><strong>Capital social :</strong> 200 000 €</p>
|
||||
<p><strong>Siège social :</strong> 13 Avenue Jean Moulin, 93240 Stains</p>
|
||||
<p><strong>Directeur Général :</strong> M. Guido Brasletti</p>
|
||||
<p><strong>Effectif :</strong> 45 employés</p>
|
||||
<p><strong>RCS :</strong> Bobigny 500 234 567</p>
|
||||
<p><strong>Code APE / NAF :</strong> 6201Z – Développement de logiciels</p>
|
||||
<p><strong>Année de création :</strong> 2007</p>
|
||||
<p><strong>Spécialité :</strong> Technologies open source</p>
|
||||
<p><strong>Téléphone :</strong> 01 35 52 40 49</p>
|
||||
<p className="pt-2">
|
||||
L'agence Furious Ducks est responsable de la conception technique du site, de
|
||||
l'API, de la webApp et de l'infrastructure de déploiement. Cependant, elle n'est
|
||||
pas responsable des contenus éditoriaux publiés par la société Thé Tip Top.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Article 3 – Hébergeur du site',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-2 text-sm sm:text-base">
|
||||
<p><strong>Raison sociale :</strong> Vercel Inc.</p>
|
||||
<p><strong>Adresse :</strong> 440 N Barranca Avenue #4133, Covina, CA 91723, États-Unis</p>
|
||||
<p><strong>Contact :</strong> privacy@vercel.com</p>
|
||||
<p><strong>Site internet :</strong> https://www.vercel.com</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Article 4 – Propriété intellectuelle',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-4 text-sm sm:text-base leading-relaxed">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">4.1 – Droits de la société Thé Tip Top</h3>
|
||||
<p>
|
||||
L'ensemble des éléments constituant le présent site internet (textes, graphiques,
|
||||
logiciels, photographies, images, sons, vidéos, noms, logos, marques, créations et
|
||||
œuvres protégeables) est la propriété exclusive de la société Thé Tip Top ou de ses
|
||||
partenaires, et est protégé par les lois françaises et internationales relatives à
|
||||
la propriété intellectuelle.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
La marque « Thé Tip Top », ses logos, son identité visuelle refondue dans le cadre
|
||||
du présent projet et ses mélanges signatures sont des éléments protégés. Toute
|
||||
utilisation non autorisée constitue une contrefaçon sanctionnée par les articles
|
||||
L.713-1 et suivants du Code de la propriété intellectuelle.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Toute reproduction, représentation, modification, publication ou adaptation de
|
||||
tout ou partie des éléments du Site, quel que soit le moyen ou le procédé utilisé,
|
||||
est strictement interdite sans autorisation écrite préalable de la société Thé Tip
|
||||
Top, sous peine de poursuites judiciaires.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">4.2 – Droits de l'agence Furious Ducks</h3>
|
||||
<p>
|
||||
Les développements techniques réalisés par l'agence Furious Ducks (API, webApp,
|
||||
infrastructure de déploiement, workflow CI/CD) font l'objet de droits distincts,
|
||||
définis contractuellement dans le contrat de prestation signé entre les parties.
|
||||
L'agence Furious Ducks cède à la société Thé Tip Top les droits d'exploitation
|
||||
nécessaires à l'utilisation du site dans les conditions prévues au contrat, sans
|
||||
que cela n'implique un transfert de propriété des outils et technologies
|
||||
propriétaires de l'agence.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">4.3 – Contenu soumis par les utilisateurs</h3>
|
||||
<p>
|
||||
L'utilisateur garantit qu'il dispose de tous les droits nécessaires sur les
|
||||
informations qu'il communique sur le Site. Il accorde à la société Thé Tip Top
|
||||
une licence non exclusive d'utilisation de ces données dans le seul cadre de la
|
||||
fourniture des services du jeu-concours.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Article 5 – Cookies et traceurs',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-3 text-sm sm:text-base leading-relaxed">
|
||||
<p>
|
||||
Le site est susceptible d'utiliser des cookies et traceurs afin d'améliorer
|
||||
l'expérience utilisateur, d'analyser le trafic et de permettre les fonctionnalités
|
||||
d'authentification via Google.
|
||||
</p>
|
||||
<p>
|
||||
Lors de votre première visite, un bandeau de consentement conforme au RGPD et aux
|
||||
recommandations de la CNIL (Commission Nationale de l'Informatique et des Libertés)
|
||||
vous informe de la présence de cookies et recueille votre accord. Vous pouvez à
|
||||
tout moment modifier vos préférences via le gestionnaire de cookies disponible sur
|
||||
le Site ou les paramètres de votre navigateur.
|
||||
</p>
|
||||
<p>Les cookies utilisés sont les suivants :</p>
|
||||
<ul className="list-disc list-inside space-y-2">
|
||||
<li>
|
||||
<strong>Cookies de session :</strong> nécessaires au bon fonctionnement du site
|
||||
(authentification, suivi de participation, sécurité). Non soumis à consentement.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cookies analytiques (Google Analytics 4) :</strong> permettent de mesurer
|
||||
l'audience du Site (visiteurs uniques, sources de trafic, comportements de
|
||||
navigation, tunnel de conversion inscription → participation → réclamation de
|
||||
lot). Soumis à consentement.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cookies publicitaires et sociaux (Meta Business Suite – Instagram/Facebook) :</strong>{' '}
|
||||
utilisés dans le cadre des campagnes Meta Ads et du suivi des performances
|
||||
sociales. Soumis à consentement.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cookies Google :</strong> liés aux fonctionnalités d'authentification via
|
||||
compte Google. Soumis aux politiques de confidentialité respectives de ces
|
||||
plateformes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Article 6 – Responsabilité',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-3 text-sm sm:text-base leading-relaxed">
|
||||
<p>
|
||||
La société Thé Tip Top s'efforce d'assurer l'exactitude et la mise à jour des
|
||||
informations diffusées sur le Site. Toutefois, elle ne peut garantir l'exactitude,
|
||||
la complétude ou l'actualité de l'ensemble des informations publiées et décline
|
||||
toute responsabilité pour :
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2">
|
||||
<li>Toute inexactitude ou omission portant sur des informations disponibles sur le Site</li>
|
||||
<li>Tout dommage résultant d'une intrusion frauduleuse d'un tiers ayant entraîné une modification des informations</li>
|
||||
<li>Tout dommage lié à l'utilisation d'Internet (rupture de service, virus, intrusion extérieure)</li>
|
||||
<li>Tout dysfonctionnement lié à l'infrastructure technique gérée par l'agence Furious Ducks ou son hébergeur</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Article 7 – Droit applicable',
|
||||
content: (
|
||||
<p className="text-gray-700 text-sm sm:text-base">
|
||||
Le présent Site et ses contenus sont soumis au droit français. En cas de litige
|
||||
relatif à l'interprétation ou à l'exécution des présentes mentions légales, et à
|
||||
défaut de résolution amiable, les tribunaux français seront seuls compétents.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Article 8 – Accessibilité',
|
||||
content: (
|
||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||
Le présent site internet a été développé en conformité avec les normes
|
||||
d'accessibilité internationales WCAG 2.1 niveau AA et le Référentiel Général
|
||||
d'Amélioration de l'Accessibilité (RGAA 4.1) afin de garantir une accessibilité
|
||||
maximale aux utilisateurs en situation de handicap. Une déclaration d'accessibilité
|
||||
complète est disponible sur la page{' '}
|
||||
<a href="/accessibilite" className="text-[#006B6B] hover:underline font-medium">
|
||||
Accessibilité
|
||||
</a>.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Article 9 – Engagement RSE et éco-responsabilité',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-3 text-sm sm:text-base leading-relaxed">
|
||||
<p>
|
||||
La société Thé Tip Top et l'agence Furious Ducks s'engagent dans une démarche de
|
||||
Responsabilité Sociale et Environnementale (RSE) pour la conception et
|
||||
l'exploitation du site du jeu-concours 100% Gagnant. Cet engagement comprend
|
||||
notamment :
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2">
|
||||
<li>L'utilisation de technologies et d'infrastructures respectueuses de l'environnement</li>
|
||||
<li>L'optimisation de la consommation d'énergie et de l'empreinte carbone du site</li>
|
||||
<li>L'hébergement auprès de fournisseurs certifiés pour leurs pratiques durables</li>
|
||||
<li>La limitation de la collecte de données personnelles aux seules données nécessaires</li>
|
||||
<li>L'utilisation de technologies open source réduisant les dépendances propriétaires</li>
|
||||
<li>La sensibilisation des utilisateurs aux bonnes pratiques numériques</li>
|
||||
</ul>
|
||||
<p>
|
||||
Toute question relative à ces engagements peut être adressée à : dpo@thetiptop.fr
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
].map((article) => (
|
||||
<section key={article.title} className="mb-6 sm:mb-8">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
{article.title}
|
||||
</h2>
|
||||
{article.content}
|
||||
</section>
|
||||
))}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +1,141 @@
|
||||
const MOCK_GAINS = [
|
||||
{ id: 1, code: 'TTT-00123', lot: 'Infusion découverte (30g)', status: 'PENDING' },
|
||||
{ id: 2, code: 'TTT-00456', lot: 'Thé signature (100g)', status: 'DELIVERED' },
|
||||
]
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { myGainsApi } from '../services/game'
|
||||
import UserNav from '../components/UserNav'
|
||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||
|
||||
const statusLabel = {
|
||||
PENDING: { label: 'À récupérer', color: 'bg-yellow-100 text-yellow-800' },
|
||||
DELIVERED: { label: 'Récupéré', color: 'bg-green-100 text-green-800' },
|
||||
CANCELLED: { label: 'Annulé', color: 'bg-red-100 text-red-800' },
|
||||
function formatDate(dateStr) {
|
||||
const date = new Date(dateStr)
|
||||
const dateLabel = date.toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
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() {
|
||||
return (
|
||||
<div className="min-h-screen bg-green-50 p-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-green-800 mb-2">Mes gains</h1>
|
||||
<p className="text-gray-500 mb-6">Historique de vos participations</p>
|
||||
const handleAuthError = useHandleAuthError()
|
||||
const [gains, setGains] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
<div className="space-y-4">
|
||||
{MOCK_GAINS.map((gain) => (
|
||||
<div key={gain.id} className="bg-white rounded-xl shadow p-5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-800">{gain.lot}</p>
|
||||
<p className="text-sm text-gray-400 font-mono mt-1">{gain.code}</p>
|
||||
const fetchGains = useCallback(async (isRefresh = false) => {
|
||||
if (isRefresh) setRefreshing(true)
|
||||
else setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-gray-500 text-xs mt-1 leading-relaxed">{gain.description}</p>
|
||||
{gain.value && (
|
||||
<p className="text-gray-500 text-xs mt-0.5">Valeur : {gain.value} €</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
<p className="text-[#006B6B] text-xs">Gagné le {formatDate(gain.wonAt)}</p>
|
||||
{timeAgo(gain.wonAt) && (
|
||||
<span className="text-[10px] font-medium text-white bg-[#006B6B]/70 px-1.5 py-0.5 rounded-full">
|
||||
{timeAgo(gain.wonAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-500 text-[10px] mt-1">
|
||||
Code utilisé :{' '}
|
||||
<span className="font-mono tracking-widest text-gray-600">{gain.ticketCode}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
26
frontend/src/pages/NotFound.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col items-center justify-center px-6 text-center">
|
||||
|
||||
<p className="text-8xl sm:text-9xl font-bold text-[#006B6B] leading-none">404</p>
|
||||
|
||||
<h1 className="text-2xl sm:text-3xl font-bold italic font-serif text-gray-800 mt-4 mb-2">
|
||||
Page introuvable
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-500 text-sm sm:text-base max-w-xs mb-8">
|
||||
La page que vous recherchez n'existe pas ou a été déplacée.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
to="/accueil"
|
||||
className="bg-[#006B6B] hover:bg-[#005959] text-white font-medium px-8 py-3 rounded-lg transition-colors text-sm sm:text-base"
|
||||
>
|
||||
Retour à l'accueil
|
||||
</Link>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function Participation() {
|
||||
const [code, setCode] = useState('')
|
||||
const [result, setResult] = useState(null)
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
// TODO: connecter au backend
|
||||
setResult({
|
||||
lot: 'Infusion découverte (30g)',
|
||||
description: 'Un assortiment de nos meilleures infusions',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-green-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-lg w-full max-w-md p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-green-800">Thé Tip Top</h1>
|
||||
<p className="text-gray-500 mt-1">Participer au jeu-concours</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Code ticket
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
required
|
||||
placeholder="Ex: TTT-XXXXX"
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500 font-mono tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-green-700 hover:bg-green-800 text-white font-semibold py-2.5 rounded-lg transition-colors"
|
||||
>
|
||||
Valider mon ticket
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{result && (
|
||||
<div className="mt-6 bg-green-100 border border-green-300 rounded-xl p-5 text-center">
|
||||
<p className="text-green-800 font-bold text-lg">Félicitations !</p>
|
||||
<p className="text-green-700 font-medium mt-1">{result.lot}</p>
|
||||
<p className="text-gray-600 text-sm mt-1">{result.description}</p>
|
||||
<Link to="/mes-gains" className="inline-block mt-4 text-sm text-green-700 font-medium hover:underline">
|
||||
Voir tous mes gains →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
frontend/src/pages/Perdu.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
export default function Perdu() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const message = location.state?.message
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center px-8 max-w-sm mx-auto text-center">
|
||||
|
||||
<div className="w-20 h-20 bg-gray-200 rounded-full flex items-center justify-center mb-6">
|
||||
<svg className="w-10 h-10 text-gray-500" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold italic font-serif text-[#006B6B] mb-2">
|
||||
Réessayer la prochaine fois
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-500 text-sm mb-2">
|
||||
{message || 'Ce code est invalide ou a déjà été utilisé.'}
|
||||
</p>
|
||||
|
||||
<p className="text-gray-500 text-xs mb-8">
|
||||
Vérifiez le code présent sur votre ticket et réessayez.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/saisir-code-unique')}
|
||||
className="w-full bg-[#006B6B] hover:bg-[#005959] text-white font-medium py-3 rounded-lg transition-colors"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
210
frontend/src/pages/Profile.jsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import UserNav from '../components/UserNav'
|
||||
import { changePasswordApi, meApi, forgotPasswordApi } from '../services/auth'
|
||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||
|
||||
export default function Profile() {
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}')
|
||||
const initials = `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase()
|
||||
const handleAuthError = useHandleAuthError()
|
||||
|
||||
const [pwForm, setPwForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||
const [showPw, setShowPw] = useState({ current: false, new: false, confirm: false })
|
||||
const [pwLoading, setPwLoading] = useState(false)
|
||||
const [pwError, setPwError] = useState('')
|
||||
const [pwSuccess, setPwSuccess] = useState(false)
|
||||
|
||||
// Un compte créé via Google peut ne pas avoir de mot de passe : le
|
||||
// formulaire de modification n'aurait alors aucun sens. L'information vient
|
||||
// de l'API, car le profil stocké en localStorage ne la contient pas.
|
||||
const [hasPassword, setHasPassword] = useState(null)
|
||||
const [linkSending, setLinkSending] = useState(false)
|
||||
const [linkSent, setLinkSent] = useState(false)
|
||||
const [linkError, setLinkError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
meApi()
|
||||
.then((data) => setHasPassword(data.user.hasPassword))
|
||||
.catch((e) => {
|
||||
if (!handleAuthError(e)) setHasPassword(true)
|
||||
})
|
||||
}, [handleAuthError])
|
||||
|
||||
const handleSendSetupLink = async () => {
|
||||
setLinkError('')
|
||||
setLinkSending(true)
|
||||
try {
|
||||
await forgotPasswordApi(user.email)
|
||||
setLinkSent(true)
|
||||
} catch (e) {
|
||||
if (!handleAuthError(e)) setLinkError(e.message)
|
||||
} finally {
|
||||
setLinkSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePwChange = (e) => setPwForm({ ...pwForm, [e.target.name]: e.target.value })
|
||||
|
||||
const handlePwSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setPwError('')
|
||||
setPwSuccess(false)
|
||||
|
||||
if (pwForm.newPassword !== pwForm.confirmPassword) {
|
||||
setPwError('Les nouveaux mots de passe ne correspondent pas.')
|
||||
return
|
||||
}
|
||||
if (pwForm.newPassword.length < 8) {
|
||||
setPwError('Le nouveau mot de passe doit faire au moins 8 caractères.')
|
||||
return
|
||||
}
|
||||
|
||||
setPwLoading(true)
|
||||
try {
|
||||
await changePasswordApi({ currentPassword: pwForm.currentPassword, newPassword: pwForm.newPassword })
|
||||
setPwSuccess(true)
|
||||
setPwForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||
} catch (err) {
|
||||
if (!handleAuthError(err)) setPwError(err.message)
|
||||
} finally {
|
||||
setPwLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const EyeIcon = ({ visible }) => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
|
||||
{visible
|
||||
? <><path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></>
|
||||
: <path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
}
|
||||
</svg>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto">
|
||||
<UserNav active="profile" />
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="mb-6">
|
||||
<div className="w-14 h-14 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-lg font-bold">
|
||||
{initials}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Infos du compte (lecture seule) */}
|
||||
<div className="space-y-4 mb-8">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">Adresse email</label>
|
||||
<div className="w-full border border-gray-100 rounded-lg px-4 py-3 text-sm text-gray-500 bg-gray-50 cursor-not-allowed select-none">
|
||||
{user.email || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">Nom</label>
|
||||
<div className="w-full border border-gray-100 rounded-lg px-4 py-3 text-sm text-gray-500 bg-gray-50">
|
||||
{user.lastname || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">Prénom</label>
|
||||
<div className="w-full border border-gray-100 rounded-lg px-4 py-3 text-sm text-gray-500 bg-gray-50">
|
||||
{user.firstname || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Séparateur */}
|
||||
<div className="border-t border-gray-100 mb-6" />
|
||||
|
||||
{/* Section changement de mot de passe */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-4">
|
||||
{hasPassword === false ? 'Définir un mot de passe' : 'Changer mon mot de passe'}
|
||||
</h2>
|
||||
|
||||
{hasPassword === false && (
|
||||
<div className="px-4 py-4 rounded-lg bg-gray-50 border border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
Votre compte a été créé avec Google et n'a pas encore de mot de passe.
|
||||
Définissez-en un pour pouvoir aussi vous connecter avec votre email.
|
||||
</p>
|
||||
|
||||
{linkSent ? (
|
||||
<p className="mt-3 text-sm text-green-700">
|
||||
Lien envoyé à {user.email}. Consultez votre boîte mail.
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendSetupLink}
|
||||
disabled={linkSending}
|
||||
className="mt-3 bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-medium px-4 py-2.5 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
{linkSending ? 'Envoi…' : 'Recevoir un lien par email'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{linkError && <p className="mt-2 text-sm text-red-600">{linkError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPassword !== false && pwSuccess && (
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm flex items-center gap-2">
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Mot de passe modifié avec succès.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPassword !== false && pwError && (
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||
{pwError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPassword !== false && (
|
||||
<form onSubmit={handlePwSubmit} className="space-y-4">
|
||||
{[
|
||||
{ name: 'currentPassword', label: 'Mot de passe actuel', key: 'current' },
|
||||
{ name: 'newPassword', label: 'Nouveau mot de passe', key: 'new' },
|
||||
{ name: 'confirmPassword', label: 'Confirmer le nouveau mot de passe', key: 'confirm' },
|
||||
].map(({ name, label, key }) => (
|
||||
<div key={name}>
|
||||
<label htmlFor={name} className="block text-sm font-medium text-gray-800 mb-1">{label}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPw[key] ? 'text' : 'password'}
|
||||
id={name}
|
||||
name={name}
|
||||
value={pwForm[name]}
|
||||
onChange={handlePwChange}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-11 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPw((s) => ({ ...s, [key]: !s[key] }))}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600"
|
||||
aria-label="Afficher/masquer"
|
||||
>
|
||||
<EyeIcon visible={showPw[key]} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pwLoading}
|
||||
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-medium py-3 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
{pwLoading ? 'Modification…' : 'Modifier le mot de passe'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
frontend/src/pages/ReglesJeu.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function ReglesJeu() {
|
||||
const { user } = useAuth()
|
||||
const isLoggedIn = !!user
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 min-h-screen">
|
||||
|
||||
{/* Hero */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 py-12 sm:py-16 md:py-20">
|
||||
<div className="text-center max-w-3xl mx-auto">
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||
Règles du jeu
|
||||
</h1>
|
||||
<p className="text-base sm:text-xl text-gray-600">
|
||||
Participez au jeu-concours ThéTipTop et découvrez immédiatement
|
||||
le lot que vous avez gagné.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Comment participer */}
|
||||
<section className="max-w-6xl mx-auto px-4 sm:px-6 pb-12 sm:pb-16">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] text-center mb-8 sm:mb-10">
|
||||
Comment participer ?
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-4 sm:gap-6">
|
||||
{[
|
||||
{
|
||||
num: '1',
|
||||
title: 'Obtenez un code',
|
||||
text: "Achetez un produit ThéTipTop d'une valeur supérieure à 49€ en ligne ou en magasin pour recevoir un code unique à 10 chiffres.",
|
||||
},
|
||||
{
|
||||
num: '2',
|
||||
title: 'Créez votre compte',
|
||||
text: "Inscrivez-vous sur le site du jeu-concours afin d'accéder à votre espace participant.",
|
||||
},
|
||||
{
|
||||
num: '3',
|
||||
title: 'Découvrez votre lot',
|
||||
text: 'Entrez votre code dans votre espace client et découvrez immédiatement votre récompense.',
|
||||
},
|
||||
].map((step) => (
|
||||
<div key={step.num} className="bg-white rounded-2xl shadow p-6 sm:p-8 text-center">
|
||||
<div className="text-3xl sm:text-4xl font-bold text-[#006B6B] mb-3 sm:mb-4">{step.num}</div>
|
||||
<h3 className="text-lg sm:text-xl font-semibold mb-2 sm:mb-3">{step.title}</h3>
|
||||
<p className="text-gray-600 text-sm sm:text-base">{step.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Lots */}
|
||||
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-16">
|
||||
<div className="bg-white rounded-2xl shadow p-6 sm:p-10">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6 text-center">
|
||||
Que pouvez-vous gagner ?
|
||||
</h2>
|
||||
<p className="text-gray-600 text-base sm:text-lg text-center leading-relaxed mb-6 sm:mb-8">
|
||||
Tous les billets sont gagnants ! Les récompenses peuvent aller
|
||||
d'un infuseur à thé à un coffret découverte d'une valeur de 69€,
|
||||
en passant par des thés détox et des thés signatures.
|
||||
</p>
|
||||
<div className="bg-gray-100 rounded-xl p-4 sm:p-6 text-center">
|
||||
<h3 className="text-xl sm:text-2xl font-bold text-[#006B6B] mb-2">Grand gagnant</h3>
|
||||
<p className="text-gray-700 text-sm sm:text-base">
|
||||
Le participant tiré au sort remportera une année de thé d'une valeur de 360€ !
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-20">
|
||||
<div className="bg-[#006B6B] rounded-2xl p-6 sm:p-10 text-center text-white">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold mb-3 sm:mb-4">
|
||||
Ne manquez pas votre chance !
|
||||
</h2>
|
||||
<p className="mb-6 sm:mb-8 text-base sm:text-lg">
|
||||
Inscrivez-vous, entrez votre code de ticket et retirez votre lot en magasin ou en ligne.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
|
||||
{!isLoggedIn && (
|
||||
<Link
|
||||
to="/inscription"
|
||||
className="bg-white text-[#006B6B] px-6 sm:px-8 py-3 rounded-xl font-semibold hover:bg-[#006B6B] hover:text-white text-sm sm:text-base"
|
||||
>
|
||||
Inscrivez-vous
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to="/saisir-code-unique"
|
||||
className="border border-white px-6 sm:px-8 py-3 rounded-xl font-semibold hover:bg-[#006B6B] hover:text-white text-sm sm:text-base"
|
||||
>
|
||||
Entrer mon code de ticket
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
206
frontend/src/pages/SaisirCodeUnique.jsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { participateApi } from '../services/game'
|
||||
import UserNav from '../components/UserNav'
|
||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||
|
||||
function formatDateTime(date) {
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit', month: 'long', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export default function SaisirCodeUnique() {
|
||||
const navigate = useNavigate()
|
||||
const handleAuthError = useHandleAuthError()
|
||||
const [code, setCode] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [gain, setGain] = useState(null)
|
||||
const [wonAt, setWonAt] = useState(null)
|
||||
const [showDetail, setShowDetail] = useState(false)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await participateApi(code.trim().toUpperCase())
|
||||
setGain(data.gain)
|
||||
setWonAt(new Date())
|
||||
} catch (err) {
|
||||
if (!handleAuthError(err)) {
|
||||
navigate('/perdu', { state: { message: err.message } })
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setGain(null)
|
||||
setWonAt(null)
|
||||
setShowDetail(false)
|
||||
setCode('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto">
|
||||
<UserNav active="code" />
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold italic font-serif text-[#006B6B]">Saisir mon code gagnant</h1>
|
||||
<p className="text-gray-500 mt-2 text-sm">Saisissez le code présent sur votre ticket</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<label htmlFor="ticket-code" className="sr-only">Code de votre ticket</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ticket-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
placeholder="Votre code"
|
||||
maxLength={10}
|
||||
required
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || code.length !== 10}
|
||||
className="w-full bg-[#00786F] hover:bg-[#1A6B65] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? 'Validation...' : 'Valider'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* ── Overlay Félicitation ── */}
|
||||
{gain && !showDetail && (
|
||||
<div className="fixed inset-0 bg-black/60 flex flex-col items-center justify-between z-50 px-6 py-12">
|
||||
<div className="w-full max-w-sm">
|
||||
<h2 className="text-4xl font-bold italic font-serif text-white mb-1">Félicitation !</h2>
|
||||
<p className="text-white/80 text-sm">Vous avez obtenu une récompense</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="bg-white/10 rounded-lg px-6 py-3 text-white font-mono tracking-widest text-lg">
|
||||
{code}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<svg className="w-24 h-24 text-yellow-200" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm">
|
||||
<p className="text-white text-center font-semibold mb-1">{gain.name}</p>
|
||||
<p className="text-white/70 text-center text-sm mb-6">{gain.description}</p>
|
||||
<button
|
||||
onClick={() => setShowDetail(true)}
|
||||
className="w-full bg-[#006B6B] hover:bg-[#005959] text-white font-medium py-3 rounded-lg transition-colors"
|
||||
>
|
||||
Découvrir mon lot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Modal détail du lot ── */}
|
||||
{gain && showDetail && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 px-4">
|
||||
<div className="bg-white rounded-2xl w-full max-w-sm shadow-xl overflow-hidden">
|
||||
|
||||
{/* En-tête coloré */}
|
||||
<div className="bg-[#006B6B] px-6 py-5 relative">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 text-white/70 hover:text-white transition-colors"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-yellow-200" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/70 text-xs">Votre lot</p>
|
||||
<p className="text-white font-bold text-base leading-tight">{gain.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Corps */}
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-gray-600 text-sm leading-relaxed">{gain.description}</p>
|
||||
|
||||
{/* Valeur */}
|
||||
{gain.value && (
|
||||
<div className="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wider">Valeur estimée</span>
|
||||
<span className="text-sm font-semibold text-[#006B6B]">{gain.value} €</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Code utilisé */}
|
||||
<div className="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wider">Code utilisé</span>
|
||||
<span className="font-mono text-sm tracking-widest text-gray-700">{code}</span>
|
||||
</div>
|
||||
|
||||
{/* Date & heure */}
|
||||
{wonAt && (
|
||||
<div className="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wider">Obtenu le</span>
|
||||
<span className="text-sm text-gray-700">{formatDateTime(wonAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Statut */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wider">Statut</span>
|
||||
<span className="text-xs font-semibold px-2.5 py-1 rounded-full bg-yellow-100 text-yellow-700">
|
||||
En attente de remise
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Info remise */}
|
||||
<div className="bg-[#006B6B]/5 rounded-xl px-4 py-3 flex gap-3 items-start">
|
||||
<svg className="w-4 h-4 text-[#006B6B] flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-xs text-[#006B6B] leading-relaxed">
|
||||
Présentez ce code en boutique Thé Tip Top pour récupérer votre lot.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="px-6 pb-6 space-y-2">
|
||||
<button
|
||||
onClick={() => navigate('/mes-gains')}
|
||||
className="w-full bg-[#006B6B] hover:bg-[#005959] text-white font-medium py-3 rounded-xl transition-colors text-sm"
|
||||
>
|
||||
Voir tout mon historique
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="w-full border border-gray-200 text-gray-500 hover:bg-gray-50 font-medium py-2.5 rounded-xl transition-colors text-sm"
|
||||
>
|
||||
Retour
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
frontend/src/pages/Sitemap.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth} from '../context/AuthContext'
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Navigation principale',
|
||||
links: [
|
||||
{ label: 'Accueil', to: '/accueil', desc: "Page d'accueil du jeu-concours" },
|
||||
{ label: 'Règles du jeu', to: '/regles-jeu', desc: 'Comment participer et que gagner' },
|
||||
{ label: 'Contact', to: '/contact', desc: 'Nous contacter' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Compte utilisateur',
|
||||
links: [
|
||||
{ label: 'Connexion', to: '/connexion', desc: 'Se connecter à son espace' },
|
||||
{ label: 'Inscription', to: '/inscription', desc: 'Créer un compte participant' },
|
||||
{ label: 'Mon profil', to: '/profile', desc: 'Voir et modifier ses informations' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Jeu-concours',
|
||||
links: [
|
||||
{ label: 'Saisir mon code', to: '/saisir-code-unique', desc: 'Entrer son code ticket' },
|
||||
{ label: 'Mes gains', to: '/mes-gains', desc: 'Historique de ses participations et lots' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Espaces professionnels',
|
||||
links: [
|
||||
{ label: 'Espace employé', to: '/employe', desc: 'Vérifier et remettre un lot' },
|
||||
{ label: 'Tableau de bord admin', to: '/admin', desc: 'Statistiques et gestion du jeu' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Informations légales',
|
||||
links: [
|
||||
{ label: 'Mentions légales', to: '/mentions-legales', desc: 'Informations légales du site' },
|
||||
{ label: 'CGU', to: '/cgu', desc: "Conditions générales d'utilisation" },
|
||||
{ label: 'Politique de confidentialité', to: '/confidentialite', desc: 'Gestion des données personnelles' },
|
||||
{ label: 'Accessibilité', to: '/accessibilite', desc: 'Déclaration de conformité RGAA' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function Sitemap() {
|
||||
const { user } = useAuth()
|
||||
const isAdmin = user?.role === 'ADMIN'
|
||||
const visibleSections = sections.filter(
|
||||
(section) => section.title !== 'Espaces professionnels' || isAdmin
|
||||
)
|
||||
return (
|
||||
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
|
||||
<div className="mb-8 sm:mb-10">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-2">
|
||||
Plan du site
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm sm:text-base">
|
||||
Retrouvez ici l'ensemble des pages disponibles sur le site Thé Tip Top.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{visibleSections.map((section) => (
|
||||
<div key={section.title} className="bg-white rounded-2xl shadow p-5 sm:p-6">
|
||||
<h2 className="text-base sm:text-lg font-bold text-[#006B6B] mb-4 pb-2 border-b border-gray-100">
|
||||
{section.title}
|
||||
</h2>
|
||||
<ul className="space-y-3">
|
||||
{section.links.map((link) => (
|
||||
<li key={link.to}>
|
||||
<Link
|
||||
to={link.to}
|
||||
className="group flex items-start gap-3"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mt-0.5 text-[#006B6B] flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800 group-hover:text-[#006B6B] transition-colors">
|
||||
{link.label}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{link.desc}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
61
frontend/src/services/admin.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
const BASE = `${API_URL}/api/admin`
|
||||
|
||||
function authHeaders() {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
}
|
||||
}
|
||||
|
||||
function apiError(message, status) {
|
||||
const err = new Error(message)
|
||||
err.status = status
|
||||
return err
|
||||
}
|
||||
|
||||
export async function getStatsApi() {
|
||||
const res = await fetch(`${BASE}/stats`, { headers: authHeaders() })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur stats.', res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getUsersApi() {
|
||||
const res = await fetch(`${BASE}/users`, { headers: authHeaders() })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur utilisateurs.', res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function drawWinnerApi() {
|
||||
const res = await fetch(`${BASE}/draw`, { method: 'POST', headers: authHeaders() })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur tirage.', res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getEmailingContactsApi() {
|
||||
const res = await fetch(`${BASE}/emailing`, { headers: authHeaders() })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur contacts emailing.', res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getWinnerApi() {
|
||||
const res = await fetch(`${BASE}/draw`, { headers: authHeaders() })
|
||||
if (res.status === 404) return null
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur gagnant.', res.status)
|
||||
return data
|
||||
}
|
||||
export async function setGameSettingsApi(startDate) {
|
||||
const res = await fetch(`${BASE}/game-settings`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ startDate }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur configuration du jeu.', res.status)
|
||||
return data
|
||||
}
|
||||
33
frontend/src/services/analytics.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const GA_ID = import.meta.env.VITE_GA_ID
|
||||
|
||||
export function initAnalytics() {
|
||||
if (!GA_ID || window.gtag) return
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.async = true
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ID}`
|
||||
document.head.appendChild(script)
|
||||
|
||||
window.dataLayer = window.dataLayer || []
|
||||
|
||||
window.gtag = function gtag() {
|
||||
window.dataLayer.push(arguments)
|
||||
}
|
||||
|
||||
window.gtag('js', new Date())
|
||||
|
||||
// Les page_view sont envoyées explicitement lors des changements de route React.
|
||||
window.gtag('config', GA_ID, {
|
||||
send_page_view: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function trackPageView(path) {
|
||||
if (!GA_ID || !window.gtag) return
|
||||
|
||||
window.gtag('event', 'page_view', {
|
||||
page_path: path,
|
||||
page_location: window.location.href,
|
||||
page_title: document.title,
|
||||
})
|
||||
}
|
||||
117
frontend/src/services/auth.js
Normal file
@@ -0,0 +1,117 @@
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
const BASE = `${API_URL}/api/auth`
|
||||
|
||||
function apiError(message, status) {
|
||||
const err = new Error(message)
|
||||
err.status = status
|
||||
return err
|
||||
}
|
||||
|
||||
export async function loginApi({ email, password }) {
|
||||
const res = await fetch(`${BASE}/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur de connexion.', res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function registerApi({
|
||||
email,
|
||||
firstname,
|
||||
lastname,
|
||||
password,
|
||||
birthdate,
|
||||
gender,
|
||||
marketingOptIn,
|
||||
}) {
|
||||
const res = await fetch(`${BASE}/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// birthdate et gender sont facultatifs : on omet la clé plutôt que
|
||||
// d'envoyer une chaîne vide, que l'API stockerait telle quelle.
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
firstname,
|
||||
lastname,
|
||||
password,
|
||||
birthdate: birthdate || undefined,
|
||||
gender: gender || undefined,
|
||||
marketingOptIn: marketingOptIn ?? undefined,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || "Erreur lors de l'inscription.", res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function verifyCodeApi({ email, code }) {
|
||||
const res = await fetch(`${BASE}/verify-code`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, code }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Code invalide.', res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function forgotPasswordApi(email) {
|
||||
const res = await fetch(`${BASE}/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function resetPasswordApi({ token, password, confirmPassword }) {
|
||||
const res = await fetch(`${BASE}/reset-password/${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password, confirmPassword }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur lors de la définition du mot de passe.', res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function meApi() {
|
||||
const token = localStorage.getItem('token')
|
||||
const res = await fetch(`${BASE}/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur lors de la récupération du profil.', res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAccountApi() {
|
||||
const token = localStorage.getItem('token')
|
||||
const res = await fetch(`${BASE}/account`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur lors de la suppression.', res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function changePasswordApi({ currentPassword, newPassword }) {
|
||||
const token = localStorage.getItem('token')
|
||||
const res = await fetch(`${BASE}/change-password`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur lors du changement de mot de passe.', res.status)
|
||||
return data
|
||||
}
|
||||
32
frontend/src/services/employee.js
Normal file
@@ -0,0 +1,32 @@
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
const BASE = `${API_URL}/api/employee`
|
||||
|
||||
function authHeaders() {
|
||||
const token = localStorage.getItem('token')
|
||||
return { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }
|
||||
}
|
||||
|
||||
function apiError(message, status) {
|
||||
const err = new Error(message)
|
||||
err.status = status
|
||||
return err
|
||||
}
|
||||
|
||||
export async function searchTicketApi(code) {
|
||||
const res = await fetch(`${BASE}/ticket/${encodeURIComponent(code.toUpperCase())}`, {
|
||||
headers: authHeaders(),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur lors de la recherche.', res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deliverClaimApi(claimId) {
|
||||
const res = await fetch(`${BASE}/claim/${claimId}/deliver`, {
|
||||
method: 'PATCH',
|
||||
headers: authHeaders(),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur lors de la remise.', res.status)
|
||||
return data
|
||||
}
|
||||
34
frontend/src/services/game.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
const BASE = `${API_URL}/api/game`
|
||||
|
||||
|
||||
function apiError(message, status) {
|
||||
const err = new Error(message)
|
||||
err.status = status
|
||||
return err
|
||||
}
|
||||
|
||||
export async function myGainsApi() {
|
||||
const token = localStorage.getItem('token')
|
||||
const res = await fetch(`${BASE}/my-gains`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur lors de la récupération des gains.', res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function participateApi(code) {
|
||||
const token = localStorage.getItem('token')
|
||||
const res = await fetch(`${BASE}/participate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ code }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur lors de la participation.', res.status)
|
||||
return data
|
||||
}
|
||||
8
frontend/src/setupTests.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { afterEach } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
localStorage.clear()
|
||||
})
|
||||
84
frontend/src/test/fonctionnel/Connexion.test.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Connexion from '../../pages/Connexion'
|
||||
import { renderWithProviders } from '../renderWithProviders'
|
||||
import { loginApi } from '../../services/auth'
|
||||
|
||||
// On mocke le service d'API : les tests de composant ne font pas de vraies requêtes HTTP.
|
||||
vi.mock('../../services/auth', () => ({
|
||||
loginApi: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('Connexion', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('affiche le formulaire de connexion', () => {
|
||||
renderWithProviders(<Connexion />, { route: '/connexion' })
|
||||
|
||||
expect(screen.getByRole('heading', { name: /se connecter/i })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/adresse email/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/^mot de passe/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /se connecter/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('affiche le message de succès après une inscription', () => {
|
||||
renderWithProviders(<Connexion />, {
|
||||
route: { pathname: '/connexion', state: { registered: true } },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/compte créé avec succès/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("affiche l'erreur renvoyée par l'API en cas d'échec", async () => {
|
||||
loginApi.mockRejectedValue(new Error('Email ou mot de passe incorrect.'))
|
||||
|
||||
renderWithProviders(<Connexion />, { route: '/connexion' })
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/adresse email/i), 'test@example.com')
|
||||
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'mauvais-mdp')
|
||||
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
|
||||
|
||||
expect(await screen.findByText('Email ou mot de passe incorrect.')).toBeInTheDocument()
|
||||
expect(loginApi).toHaveBeenCalledWith({ email: 'test@example.com', password: 'mauvais-mdp' })
|
||||
})
|
||||
|
||||
it('connecte un client et redirige vers /mes-gains', async () => {
|
||||
loginApi.mockResolvedValue({
|
||||
token: 'jwt-token',
|
||||
user: { id: 1, email: 'client@example.com', role: 'CLIENT' },
|
||||
})
|
||||
|
||||
renderWithProviders(<Connexion />, {
|
||||
route: '/connexion',
|
||||
routes: { '/mes-gains': <p>Page Mes Gains</p> },
|
||||
})
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/adresse email/i), 'client@example.com')
|
||||
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
|
||||
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
|
||||
|
||||
expect(await screen.findByText('Page Mes Gains')).toBeInTheDocument()
|
||||
expect(localStorage.getItem('token')).toBe('jwt-token')
|
||||
})
|
||||
|
||||
it("connecte un admin et redirige vers /admin", async () => {
|
||||
loginApi.mockResolvedValue({
|
||||
token: 'jwt-token',
|
||||
user: { id: 2, email: 'admin@example.com', role: 'ADMIN' },
|
||||
})
|
||||
|
||||
renderWithProviders(<Connexion />, {
|
||||
route: '/connexion',
|
||||
routes: { '/admin': <p>Page Admin</p> },
|
||||
})
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/adresse email/i), 'admin@example.com')
|
||||
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
|
||||
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
|
||||
|
||||
expect(await screen.findByText('Page Admin')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
39
frontend/src/test/fonctionnel/CookieBanner.test.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import CookieBanner from '../../components/CookieBanner'
|
||||
import { renderWithProviders } from '../renderWithProviders'
|
||||
|
||||
describe('CookieBanner', () => {
|
||||
it("s'affiche quand aucun choix n'a été fait", () => {
|
||||
renderWithProviders(<CookieBanner />)
|
||||
|
||||
expect(screen.getByText(/gestion des cookies/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('enregistre le consentement et disparaît quand on accepte', async () => {
|
||||
renderWithProviders(<CookieBanner />)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /accepter/i }))
|
||||
|
||||
expect(screen.queryByText(/ce site utilise des cookies/i)).not.toBeInTheDocument()
|
||||
expect(localStorage.getItem('cookieConsent')).toBe('accepted')
|
||||
})
|
||||
|
||||
it('enregistre le refus et disparaît quand on refuse', async () => {
|
||||
renderWithProviders(<CookieBanner />)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /refuser/i }))
|
||||
|
||||
expect(screen.queryByText(/ce site utilise des cookies/i)).not.toBeInTheDocument()
|
||||
expect(localStorage.getItem('cookieConsent')).toBe('refused')
|
||||
})
|
||||
|
||||
it("ne s'affiche pas si un choix a déjà été fait", () => {
|
||||
localStorage.setItem('cookieConsent', 'accepted')
|
||||
|
||||
renderWithProviders(<CookieBanner />)
|
||||
|
||||
expect(screen.queryByText(/ce site utilise des cookies/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
173
frontend/src/test/fonctionnel/Inscription.test.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Inscription from '../../pages/Inscription'
|
||||
import { renderWithProviders } from '../renderWithProviders'
|
||||
import { registerApi, verifyCodeApi } from '../../services/auth'
|
||||
|
||||
vi.mock('../../services/auth', () => ({
|
||||
registerApi: vi.fn(),
|
||||
verifyCodeApi: vi.fn(),
|
||||
}))
|
||||
|
||||
// Remplit et soumet le formulaire d'inscription (étape 1).
|
||||
async function remplirEtSoumettre() {
|
||||
await userEvent.type(screen.getByLabelText(/adresse email/i), 'jean@example.com')
|
||||
await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont')
|
||||
await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean')
|
||||
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
|
||||
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i }))
|
||||
}
|
||||
|
||||
// Saisit le code à 4 chiffres de l'étape de vérification.
|
||||
async function saisirCode(code) {
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await userEvent.type(inputs[i], code[i])
|
||||
}
|
||||
}
|
||||
|
||||
describe('Inscription', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("affiche le formulaire d'inscription", () => {
|
||||
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||
|
||||
expect(screen.getByRole('heading', { name: /s'inscrire/i })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/adresse email/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/^nom/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/prénom/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/^mot de passe/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("désactive le bouton tant que les conditions ne sont pas acceptées", async () => {
|
||||
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||
|
||||
const bouton = screen.getByRole('button', { name: /s'inscrire/i })
|
||||
expect(bouton).toBeDisabled()
|
||||
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
|
||||
expect(bouton).toBeEnabled()
|
||||
})
|
||||
|
||||
it("passe à l'étape de vérification après une inscription réussie", async () => {
|
||||
registerApi.mockResolvedValue({ message: 'ok' })
|
||||
|
||||
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||
await remplirEtSoumettre()
|
||||
|
||||
expect(registerApi).toHaveBeenCalledWith({
|
||||
email: 'jean@example.com',
|
||||
firstname: 'Jean',
|
||||
lastname: 'Dupont',
|
||||
password: 'MotDePasse123!',
|
||||
birthdate: '',
|
||||
gender: '',
|
||||
marketingOptIn: false,
|
||||
})
|
||||
expect(await screen.findByRole('heading', { name: /vérifiez votre email/i })).toBeInTheDocument()
|
||||
expect(screen.getByText('jean@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("transmet la date de naissance et le genre saisis", async () => {
|
||||
registerApi.mockResolvedValue({ message: 'ok' })
|
||||
|
||||
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/adresse email/i), 'jean@example.com')
|
||||
await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont')
|
||||
await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean')
|
||||
await userEvent.type(screen.getByLabelText(/date de naissance/i), '1990-05-14')
|
||||
await userEvent.selectOptions(screen.getByLabelText(/genre/i), 'Femme')
|
||||
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
|
||||
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i }))
|
||||
|
||||
expect(registerApi).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ birthdate: '1990-05-14', gender: 'Femme' })
|
||||
)
|
||||
})
|
||||
|
||||
it("transmet le consentement marketing quand il est coché", async () => {
|
||||
registerApi.mockResolvedValue({ message: 'ok' })
|
||||
|
||||
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||
|
||||
const marketing = screen.getByRole('checkbox', { name: /offres et actualités/i })
|
||||
expect(marketing).not.toBeChecked()
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/adresse email/i), 'jean@example.com')
|
||||
await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont')
|
||||
await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean')
|
||||
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
|
||||
await userEvent.click(marketing)
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
|
||||
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i }))
|
||||
|
||||
expect(registerApi).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ marketingOptIn: true })
|
||||
)
|
||||
})
|
||||
|
||||
it("n'exige pas le consentement marketing pour s'inscrire", async () => {
|
||||
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||
|
||||
const bouton = screen.getByRole('button', { name: /s'inscrire/i })
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: /offres et actualités/i })).not.toBeChecked()
|
||||
expect(bouton).toBeEnabled()
|
||||
})
|
||||
|
||||
it("affiche l'erreur de l'API si l'inscription échoue", async () => {
|
||||
registerApi.mockRejectedValue(new Error('Cet email est déjà utilisé.'))
|
||||
|
||||
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||
await remplirEtSoumettre()
|
||||
|
||||
expect(await screen.findByText('Cet email est déjà utilisé.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: /vérifiez votre email/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('connecte et redirige vers /saisir-code-unique quand le code est valide', async () => {
|
||||
registerApi.mockResolvedValue({ message: 'ok' })
|
||||
verifyCodeApi.mockResolvedValue({
|
||||
token: 'jwt-token',
|
||||
user: { id: 1, email: 'jean@example.com', role: 'CLIENT' },
|
||||
})
|
||||
|
||||
renderWithProviders(<Inscription />, {
|
||||
route: '/inscription',
|
||||
routes: { '/saisir-code-unique': <p>Page Saisir Code</p> },
|
||||
})
|
||||
await remplirEtSoumettre()
|
||||
await screen.findByRole('heading', { name: /vérifiez votre email/i })
|
||||
|
||||
await saisirCode('1234')
|
||||
await userEvent.click(screen.getByRole('button', { name: /valider le code/i }))
|
||||
|
||||
expect(verifyCodeApi).toHaveBeenCalledWith({ email: 'jean@example.com', code: '1234' })
|
||||
expect(await screen.findByText('Page Saisir Code')).toBeInTheDocument()
|
||||
expect(localStorage.getItem('token')).toBe('jwt-token')
|
||||
})
|
||||
|
||||
it("affiche l'erreur et vide les champs quand le code est invalide", async () => {
|
||||
registerApi.mockResolvedValue({ message: 'ok' })
|
||||
verifyCodeApi.mockRejectedValue(new Error('Code invalide.'))
|
||||
|
||||
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||
await remplirEtSoumettre()
|
||||
await screen.findByRole('heading', { name: /vérifiez votre email/i })
|
||||
|
||||
await saisirCode('0000')
|
||||
await userEvent.click(screen.getByRole('button', { name: /valider le code/i }))
|
||||
|
||||
expect(await screen.findByText('Code invalide.')).toBeInTheDocument()
|
||||
for (const input of screen.getAllByRole('textbox')) {
|
||||
expect(input).toHaveValue('')
|
||||
}
|
||||
})
|
||||
})
|
||||
80
frontend/src/test/fonctionnel/ProtectedRoute.test.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { screen } from '@testing-library/react'
|
||||
import ProtectedRoute from '../../components/ProtectedRoute'
|
||||
import { renderWithProviders } from '../renderWithProviders'
|
||||
|
||||
describe('ProtectedRoute', () => {
|
||||
it("redirige vers /connexion si l'utilisateur n'est pas connecté", () => {
|
||||
renderWithProviders(
|
||||
<ProtectedRoute>
|
||||
<p>Contenu protégé</p>
|
||||
</ProtectedRoute>,
|
||||
{
|
||||
route: '/mes-gains',
|
||||
routes: { '/connexion': <p>Page Connexion</p> },
|
||||
}
|
||||
)
|
||||
|
||||
expect(screen.getByText('Page Connexion')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Contenu protégé')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("affiche le contenu si l'utilisateur est connecté", () => {
|
||||
renderWithProviders(
|
||||
<ProtectedRoute>
|
||||
<p>Contenu protégé</p>
|
||||
</ProtectedRoute>,
|
||||
{
|
||||
route: '/mes-gains',
|
||||
user: { id: 1, email: 'client@example.com', role: 'CLIENT' },
|
||||
}
|
||||
)
|
||||
|
||||
expect(screen.getByText('Contenu protégé')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('affiche le contenu si le rôle est autorisé', () => {
|
||||
renderWithProviders(
|
||||
<ProtectedRoute roles={['ADMIN']}>
|
||||
<p>Espace admin</p>
|
||||
</ProtectedRoute>,
|
||||
{
|
||||
route: '/admin',
|
||||
user: { id: 2, email: 'admin@example.com', role: 'ADMIN' },
|
||||
}
|
||||
)
|
||||
|
||||
expect(screen.getByText('Espace admin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("redirige un client vers son espace s'il tente d'accéder à l'admin", () => {
|
||||
renderWithProviders(
|
||||
<ProtectedRoute roles={['ADMIN']}>
|
||||
<p>Espace admin</p>
|
||||
</ProtectedRoute>,
|
||||
{
|
||||
route: '/admin',
|
||||
user: { id: 1, email: 'client@example.com', role: 'CLIENT' },
|
||||
routes: { '/mes-gains': <p>Page Mes Gains</p> },
|
||||
}
|
||||
)
|
||||
|
||||
expect(screen.getByText('Page Mes Gains')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Espace admin')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("redirige un employé vers son espace s'il tente d'accéder à l'admin", () => {
|
||||
renderWithProviders(
|
||||
<ProtectedRoute roles={['ADMIN']}>
|
||||
<p>Espace admin</p>
|
||||
</ProtectedRoute>,
|
||||
{
|
||||
route: '/admin',
|
||||
user: { id: 3, email: 'employe@example.com', role: 'EMPLOYEE' },
|
||||
routes: { '/employe': <p>Page Employé</p> },
|
||||
}
|
||||
)
|
||||
|
||||
expect(screen.getByText('Page Employé')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
93
frontend/src/test/fonctionnel/SaisirCodeUnique.test.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import SaisirCodeUnique from '../../pages/SaisirCodeUnique'
|
||||
import Perdu from '../../pages/Perdu'
|
||||
import { renderWithProviders } from '../renderWithProviders'
|
||||
import { participateApi } from '../../services/game'
|
||||
|
||||
vi.mock('../../services/game', () => ({
|
||||
participateApi: vi.fn(),
|
||||
}))
|
||||
|
||||
const client = { id: 1, email: 'client@example.com', role: 'CLIENT' }
|
||||
|
||||
async function saisirEtValider(code) {
|
||||
await userEvent.type(screen.getByPlaceholderText(/votre code/i), code)
|
||||
await userEvent.click(screen.getByRole('button', { name: /valider/i }))
|
||||
}
|
||||
|
||||
describe('SaisirCodeUnique', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('désactive le bouton tant que le code ne fait pas 10 caractères', async () => {
|
||||
renderWithProviders(<SaisirCodeUnique />, { route: '/saisir-code-unique', user: client })
|
||||
|
||||
const bouton = screen.getByRole('button', { name: /valider/i })
|
||||
expect(bouton).toBeDisabled()
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText(/votre code/i), 'ABCDE12345')
|
||||
expect(bouton).toBeEnabled()
|
||||
})
|
||||
|
||||
it('met le code en majuscules et l\'envoie à l\'API', async () => {
|
||||
participateApi.mockResolvedValue({ gain: { name: 'Thé détox' }, claim: { id: 1 } })
|
||||
|
||||
renderWithProviders(<SaisirCodeUnique />, { route: '/saisir-code-unique', user: client })
|
||||
await saisirEtValider('abcde12345')
|
||||
|
||||
expect(participateApi).toHaveBeenCalledWith('ABCDE12345')
|
||||
})
|
||||
|
||||
it("affiche l'écran Félicitation puis le détail du lot quand le code est gagnant", async () => {
|
||||
participateApi.mockResolvedValue({
|
||||
gain: { name: 'Thé signature', description: 'Un thé d\'exception', value: 39 },
|
||||
claim: { id: 1 },
|
||||
})
|
||||
|
||||
renderWithProviders(<SaisirCodeUnique />, { route: '/saisir-code-unique', user: client })
|
||||
await saisirEtValider('ABCDE12345')
|
||||
|
||||
// Overlay de félicitation
|
||||
expect(await screen.findByText(/félicitation/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Thé signature')).toBeInTheDocument()
|
||||
|
||||
// Détail du lot
|
||||
await userEvent.click(screen.getByRole('button', { name: /découvrir mon lot/i }))
|
||||
expect(screen.getByText(/valeur estimée/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('39 €')).toBeInTheDocument()
|
||||
expect(screen.getByText(/en attente de remise/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("redirige vers /perdu avec le message de l'API quand le code est invalide", async () => {
|
||||
participateApi.mockRejectedValue(new Error('Ce code a déjà été utilisé.'))
|
||||
|
||||
renderWithProviders(<SaisirCodeUnique />, {
|
||||
route: '/saisir-code-unique',
|
||||
user: client,
|
||||
routes: { '/perdu': <Perdu /> },
|
||||
})
|
||||
await saisirEtValider('ABCDE12345')
|
||||
|
||||
expect(await screen.findByText(/réessayer la prochaine fois/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Ce code a déjà été utilisé.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('déconnecte et redirige vers /connexion si la session a expiré (401)', async () => {
|
||||
participateApi.mockRejectedValue(
|
||||
Object.assign(new Error('Session expirée'), { status: 401 })
|
||||
)
|
||||
|
||||
renderWithProviders(<SaisirCodeUnique />, {
|
||||
route: '/saisir-code-unique',
|
||||
user: client,
|
||||
routes: { '/connexion': <p>Page Connexion</p> },
|
||||
})
|
||||
await saisirEtValider('ABCDE12345')
|
||||
|
||||
expect(await screen.findByText('Page Connexion')).toBeInTheDocument()
|
||||
expect(localStorage.getItem('token')).toBeNull()
|
||||
})
|
||||
})
|
||||
29
frontend/src/test/renderWithProviders.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
import { AuthProvider } from '../context/AuthContext'
|
||||
|
||||
// Rend un composant avec les mêmes providers que l'appli (AuthContext + Router).
|
||||
// - `route` : URL initiale (peut être un objet { pathname, state })
|
||||
// - `user` : utilisateur pré-connecté (écrit dans localStorage avant le rendu,
|
||||
// comme le ferait une vraie session)
|
||||
// - `routes`: routes supplémentaires pour vérifier les redirections,
|
||||
// ex: { '/mes-gains': <p>Page Mes Gains</p> }
|
||||
export function renderWithProviders(ui, { route = '/', user = null, routes = {} } = {}) {
|
||||
if (user) {
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
localStorage.setItem('token', 'fake-token')
|
||||
}
|
||||
|
||||
return render(
|
||||
<AuthProvider>
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
<Routes>
|
||||
<Route path={typeof route === 'string' ? route : route.pathname} element={ui} />
|
||||
{Object.entries(routes).map(([path, element]) => (
|
||||
<Route key={path} path={path} element={element} />
|
||||
))}
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
71
frontend/src/test/unit/AuthContext.test.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AuthProvider, useAuth } from '../../context/AuthContext'
|
||||
|
||||
// Petit composant de test pour manipuler le contexte comme le ferait une page.
|
||||
function Consumer() {
|
||||
const { user, login, logout } = useAuth()
|
||||
return (
|
||||
<div>
|
||||
<p>{user ? `Connecté : ${user.email}` : 'Déconnecté'}</p>
|
||||
<button onClick={() => login({ email: 'a@b.fr', role: 'CLIENT' }, 'mon-jwt')}>
|
||||
se connecter
|
||||
</button>
|
||||
<button onClick={logout}>se déconnecter</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('AuthContext', () => {
|
||||
it("n'a pas d'utilisateur par défaut", () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<Consumer />
|
||||
</AuthProvider>
|
||||
)
|
||||
expect(screen.getByText('Déconnecté')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('restaure la session depuis le localStorage', () => {
|
||||
localStorage.setItem('user', JSON.stringify({ email: 'deja@la.fr', role: 'CLIENT' }))
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<Consumer />
|
||||
</AuthProvider>
|
||||
)
|
||||
expect(screen.getByText('Connecté : deja@la.fr')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('login met à jour le user et écrit token + user dans le localStorage', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<Consumer />
|
||||
</AuthProvider>
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'se connecter' }))
|
||||
|
||||
expect(screen.getByText('Connecté : a@b.fr')).toBeInTheDocument()
|
||||
expect(localStorage.getItem('token')).toBe('mon-jwt')
|
||||
expect(JSON.parse(localStorage.getItem('user'))).toEqual({ email: 'a@b.fr', role: 'CLIENT' })
|
||||
})
|
||||
|
||||
it('logout vide le user et le localStorage', async () => {
|
||||
localStorage.setItem('token', 'mon-jwt')
|
||||
localStorage.setItem('user', JSON.stringify({ email: 'a@b.fr', role: 'CLIENT' }))
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<Consumer />
|
||||
</AuthProvider>
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'se déconnecter' }))
|
||||
|
||||
expect(screen.getByText('Déconnecté')).toBeInTheDocument()
|
||||
expect(localStorage.getItem('token')).toBeNull()
|
||||
expect(localStorage.getItem('user')).toBeNull()
|
||||
})
|
||||
})
|
||||
102
frontend/src/test/unit/auth.service.test.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { loginApi, registerApi, verifyCodeApi, changePasswordApi } from '../../services/auth'
|
||||
|
||||
// Tests unitaires du service auth : on mocke fetch, aucune vraie requête HTTP.
|
||||
const mockFetch = vi.fn()
|
||||
|
||||
function jsonResponse(body, { ok = true, status = 200 } = {}) {
|
||||
return Promise.resolve({ ok, status, json: () => Promise.resolve(body) })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
mockFetch.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('loginApi', () => {
|
||||
it('envoie email et mot de passe et renvoie les données', async () => {
|
||||
const payload = { token: 'jwt', user: { email: 'a@b.fr', role: 'CLIENT' } }
|
||||
mockFetch.mockReturnValue(jsonResponse(payload))
|
||||
|
||||
const data = await loginApi({ email: 'a@b.fr', password: 'secret' })
|
||||
|
||||
expect(data).toEqual(payload)
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'a@b.fr', password: 'secret' }),
|
||||
})
|
||||
})
|
||||
|
||||
it("lève une erreur avec le message et le status de l'API en cas d'échec", async () => {
|
||||
mockFetch.mockReturnValue(
|
||||
jsonResponse({ message: 'Identifiants invalides.' }, { ok: false, status: 401 })
|
||||
)
|
||||
|
||||
await expect(loginApi({ email: 'a@b.fr', password: 'faux' })).rejects.toMatchObject({
|
||||
message: 'Identifiants invalides.',
|
||||
status: 401,
|
||||
})
|
||||
})
|
||||
|
||||
it("utilise un message par défaut si l'API n'en renvoie pas", async () => {
|
||||
mockFetch.mockReturnValue(jsonResponse({}, { ok: false, status: 500 }))
|
||||
|
||||
await expect(loginApi({ email: 'a@b.fr', password: 'x' })).rejects.toThrow(
|
||||
'Erreur de connexion.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerApi', () => {
|
||||
it("envoie les informations d'inscription", async () => {
|
||||
mockFetch.mockReturnValue(jsonResponse({ message: 'ok' }))
|
||||
|
||||
await registerApi({ email: 'a@b.fr', firstname: 'Jean', lastname: 'Dupont', password: 'secret' })
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'a@b.fr', firstname: 'Jean', lastname: 'Dupont', password: 'secret' }),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyCodeApi', () => {
|
||||
it('renvoie le token et le user quand le code est valide', async () => {
|
||||
const payload = { token: 'jwt', user: { email: 'a@b.fr' } }
|
||||
mockFetch.mockReturnValue(jsonResponse(payload))
|
||||
|
||||
const data = await verifyCodeApi({ email: 'a@b.fr', code: '1234' })
|
||||
|
||||
expect(data).toEqual(payload)
|
||||
})
|
||||
|
||||
it('lève "Code invalide." par défaut en cas d\'échec', async () => {
|
||||
mockFetch.mockReturnValue(jsonResponse({}, { ok: false, status: 400 }))
|
||||
|
||||
await expect(verifyCodeApi({ email: 'a@b.fr', code: '0000' })).rejects.toThrow('Code invalide.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('changePasswordApi', () => {
|
||||
it('envoie le token du localStorage dans le header Authorization', async () => {
|
||||
localStorage.setItem('token', 'mon-jwt')
|
||||
mockFetch.mockReturnValue(jsonResponse({ message: 'ok' }))
|
||||
|
||||
await changePasswordApi({ currentPassword: 'ancien', newPassword: 'nouveau' })
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/auth/change-password', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer mon-jwt',
|
||||
},
|
||||
body: JSON.stringify({ currentPassword: 'ancien', newPassword: 'nouveau' }),
|
||||
})
|
||||
})
|
||||
})
|
||||
68
frontend/src/test/unit/game.service.test.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { myGainsApi, participateApi } from '../../services/game'
|
||||
|
||||
// Tests unitaires du service game : on mocke fetch, aucune vraie requête HTTP.
|
||||
const mockFetch = vi.fn()
|
||||
|
||||
function jsonResponse(body, { ok = true, status = 200 } = {}) {
|
||||
return Promise.resolve({ ok, status, json: () => Promise.resolve(body) })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
mockFetch.mockReset()
|
||||
localStorage.setItem('token', 'mon-jwt')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('myGainsApi', () => {
|
||||
it('appelle /api/game/my-gains avec le token et renvoie les gains', async () => {
|
||||
const payload = { gains: [{ id: 1, name: 'Thé détox' }] }
|
||||
mockFetch.mockReturnValue(jsonResponse(payload))
|
||||
|
||||
const data = await myGainsApi()
|
||||
|
||||
expect(data).toEqual(payload)
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/game/my-gains', {
|
||||
headers: { Authorization: 'Bearer mon-jwt' },
|
||||
})
|
||||
})
|
||||
|
||||
it("lève une erreur avec le status en cas d'échec (ex: session expirée)", async () => {
|
||||
mockFetch.mockReturnValue(
|
||||
jsonResponse({ message: 'Non autorisé.' }, { ok: false, status: 401 })
|
||||
)
|
||||
|
||||
await expect(myGainsApi()).rejects.toMatchObject({ message: 'Non autorisé.', status: 401 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('participateApi', () => {
|
||||
it('envoie le code en POST avec le token', async () => {
|
||||
const payload = { gain: { name: 'Thé signature' }, claim: { id: 1 } }
|
||||
mockFetch.mockReturnValue(jsonResponse(payload))
|
||||
|
||||
const data = await participateApi('ABCDE12345')
|
||||
|
||||
expect(data).toEqual(payload)
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/game/participate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer mon-jwt',
|
||||
},
|
||||
body: JSON.stringify({ code: 'ABCDE12345' }),
|
||||
})
|
||||
})
|
||||
|
||||
it("transmet le message d'erreur de l'API (code déjà utilisé, invalide...)", async () => {
|
||||
mockFetch.mockReturnValue(
|
||||
jsonResponse({ message: 'Ce code a déjà été utilisé.' }, { ok: false, status: 400 })
|
||||
)
|
||||
|
||||
await expect(participateApi('ABCDE12345')).rejects.toThrow('Ce code a déjà été utilisé.')
|
||||
})
|
||||
})
|
||||
51
frontend/src/test/unit/useHandleAuthError.test.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { useState } from 'react'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useHandleAuthError } from '../../hooks/useHandleAuthError'
|
||||
import { renderWithProviders } from '../renderWithProviders'
|
||||
|
||||
// Composant de test : déclenche le hook avec l'erreur passée en prop.
|
||||
function TestComponent({ error }) {
|
||||
const handleAuthError = useHandleAuthError()
|
||||
const [handled, setHandled] = useState(null)
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setHandled(handleAuthError(error))}>déclencher</button>
|
||||
{handled !== null && <p>géré : {String(handled)}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useHandleAuthError', () => {
|
||||
it('déconnecte et redirige vers /connexion sur une erreur 401', async () => {
|
||||
const error = Object.assign(new Error('Session expirée'), { status: 401 })
|
||||
|
||||
renderWithProviders(<TestComponent error={error} />, {
|
||||
route: '/mes-gains',
|
||||
user: { email: 'a@b.fr', role: 'CLIENT' },
|
||||
routes: { '/connexion': <p>Page Connexion</p> },
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'déclencher' }))
|
||||
|
||||
expect(screen.getByText('Page Connexion')).toBeInTheDocument()
|
||||
expect(localStorage.getItem('token')).toBeNull()
|
||||
expect(localStorage.getItem('user')).toBeNull()
|
||||
})
|
||||
|
||||
it('ne fait rien et renvoie false pour une autre erreur', async () => {
|
||||
const error = Object.assign(new Error('Erreur serveur'), { status: 500 })
|
||||
|
||||
renderWithProviders(<TestComponent error={error} />, {
|
||||
route: '/mes-gains',
|
||||
user: { email: 'a@b.fr', role: 'CLIENT' },
|
||||
routes: { '/connexion': <p>Page Connexion</p> },
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'déclencher' }))
|
||||
|
||||
expect(screen.getByText('géré : false')).toBeInTheDocument()
|
||||
expect(localStorage.getItem('token')).toBe('fake-token')
|
||||
})
|
||||
})
|
||||
@@ -4,4 +4,29 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
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',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||