Ajouter SSR prerending, espace employe, formulaire inscription employee depuis admin
This commit is contained in:
@@ -36,7 +36,7 @@ const router = express.Router();
|
||||
router.get(
|
||||
"/ticket/:code",
|
||||
authMiddleware,
|
||||
roleMiddleware("EMPLOYEE", "ADMIN"),
|
||||
roleMiddleware("EMPLOYEE"),
|
||||
getGainByTicketCode
|
||||
);
|
||||
|
||||
@@ -69,7 +69,7 @@ router.get(
|
||||
router.patch(
|
||||
"/claim/:id/deliver",
|
||||
authMiddleware,
|
||||
roleMiddleware("EMPLOYEE", "ADMIN"),
|
||||
roleMiddleware("EMPLOYEE"),
|
||||
deliverClaim
|
||||
);
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
FROM node:22-alpine AS build
|
||||
FROM node:22-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
RUN npx playwright install --with-deps chromium
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
ignores: ['scripts/**'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
@@ -18,4 +19,13 @@ export default defineConfig([
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Scripts Node exécutés à la compilation (ex. prerender.js), jamais
|
||||
// dans le navigateur : globals Node, pas de règles React.
|
||||
files: ['scripts/**/*.js'],
|
||||
extends: [js.configs.recommended],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@@ -6,6 +6,12 @@ server {
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
# $uri/index.html (et non $uri/) : sert directement le fichier des
|
||||
# pages pré-rendues (ex. /accueil -> dist/accueil/index.html) sans
|
||||
# passer par la redirection 301 vers l'URL à slash final que nginx
|
||||
# déclenche pour une résolution de type répertoire ($uri/). Les
|
||||
# canonical/sitemap pointent vers les URLs sans slash : elles
|
||||
# doivent répondre 200 directement, pas via un redirect.
|
||||
try_files $uri $uri/index.html /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
97
frontend/package-lock.json
generated
97
frontend/package-lock.json
generated
@@ -26,6 +26,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"playwright": "^1.62.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "^4.1.10"
|
||||
@@ -120,7 +121,6 @@
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -441,7 +441,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
@@ -490,11 +489,33 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"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,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.3",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"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,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
||||
@@ -1442,7 +1463,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
@@ -1489,7 +1511,6 @@
|
||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1500,7 +1521,6 @@
|
||||
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -1650,7 +1670,6 @@
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1691,6 +1710,7 @@
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -1701,6 +1721,7 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -1794,7 +1815,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
@@ -1974,7 +1994,8 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.408",
|
||||
@@ -2046,7 +2067,6 @@
|
||||
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -2923,6 +2943,7 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -3130,7 +3151,6 @@
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3138,6 +3158,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
@@ -3183,6 +3250,7 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -3207,7 +3275,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -3217,7 +3284,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -3230,7 +3296,8 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.18.2",
|
||||
@@ -3625,7 +3692,6 @@
|
||||
"integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.5",
|
||||
@@ -4183,7 +4249,6 @@
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "vite build && node scripts/prerender.js",
|
||||
"prerender": "node scripts/prerender.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
@@ -30,6 +31,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"playwright": "^1.62.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "^4.1.10"
|
||||
|
||||
54
frontend/scripts/prerender.js
Normal file
54
frontend/scripts/prerender.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { preview } from 'vite'
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const root = join(__dirname, '..')
|
||||
const distDir = join(root, 'dist')
|
||||
|
||||
// Pages publiques, indexables (voir public/robots.txt) : ce sont les seules
|
||||
// qui ont besoin d'un HTML pré-rendu pour le SEO et les aperçus sociaux. Les
|
||||
// pages privées (profil, mes gains, admin...) restent en rendu 100% client,
|
||||
// servies par le index.html générique via le fallback SPA de nginx.
|
||||
const ROUTES = [
|
||||
'/accueil',
|
||||
'/notre-concept',
|
||||
'/regles-jeu',
|
||||
'/contact',
|
||||
'/mentions-legales',
|
||||
'/confidentialite',
|
||||
'/accessibilite',
|
||||
'/cgu',
|
||||
'/sitemap',
|
||||
'/inscription',
|
||||
'/connexion',
|
||||
]
|
||||
|
||||
async function main() {
|
||||
const server = await preview({ root, preview: { port: 4173, strictPort: false } })
|
||||
const base = server.resolvedUrls.local[0].replace(/\/$/, '')
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
|
||||
for (const route of ROUTES) {
|
||||
await page.goto(`${base}${route}`, { waitUntil: 'networkidle' })
|
||||
await page.waitForSelector('h1')
|
||||
|
||||
const html = await page.content()
|
||||
const outDir = join(distDir, route.replace(/^\//, ''))
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
writeFileSync(join(outDir, 'index.html'), `<!doctype html>\n${html}`)
|
||||
console.log(`✓ pré-rendu ${route} -> dist${route}/index.html`)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
await server.close()
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Erreur de pré-rendu :', error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi } from '../services/admin'
|
||||
import { searchTicketApi, deliverClaimApi } from '../services/employee'
|
||||
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi, createEmployeeApi } from '../services/admin'
|
||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||
|
||||
function StatCard({ label, value, sub }) {
|
||||
@@ -63,20 +62,17 @@ export default function Admin() {
|
||||
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('')
|
||||
|
||||
// Création d'un compte employé
|
||||
const [employeeForm, setEmployeeForm] = useState({ firstname: '', lastname: '', email: '', password: '' })
|
||||
const [employeeSaving, setEmployeeSaving] = useState(false)
|
||||
const [employeeError, setEmployeeError] = useState('')
|
||||
const [employeeOk, setEmployeeOk] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
|
||||
.then(([s, u, w]) => {
|
||||
@@ -154,43 +150,19 @@ export default function Admin() {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleTicketSearch = async (e) => {
|
||||
const handleCreateEmployee = async (e) => {
|
||||
e.preventDefault()
|
||||
setTicketResult(null)
|
||||
setTicketSearchErr('')
|
||||
setTicketDeliverErr('')
|
||||
setTicketSuccess(false)
|
||||
setTicketSearching(true)
|
||||
setEmployeeError('')
|
||||
setEmployeeOk('')
|
||||
setEmployeeSaving(true)
|
||||
try {
|
||||
const data = await searchTicketApi(ticketCode.trim())
|
||||
setTicketResult(data)
|
||||
const data = await createEmployeeApi(employeeForm)
|
||||
setEmployeeOk(`Compte employé créé pour ${data.employee.firstname} ${data.employee.lastname} (${data.employee.email}).`)
|
||||
setEmployeeForm({ firstname: '', lastname: '', email: '', password: '' })
|
||||
} catch (err) {
|
||||
if (!handleAuthError(err)) setTicketSearchErr(err.message)
|
||||
if (!handleAuthError(err)) setEmployeeError(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)
|
||||
setEmployeeSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,93 +387,73 @@ export default function Admin() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Remise de lot ── */}
|
||||
{/* ── Création d'un compte employé ── */}
|
||||
<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>
|
||||
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-1">Créer un compte employé</h2>
|
||||
<p className="text-xs sm:text-sm text-gray-500 mb-4">
|
||||
Donne accès à l'espace employé (remise des lots en boutique). L'admin ne gère pas les remises lui-même.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleTicketSearch} className="flex gap-2 mb-4">
|
||||
<label htmlFor="admin-ticket-code" className="sr-only">Code ticket</label>
|
||||
<form onSubmit={handleCreateEmployee} className="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="employee-firstname" className="block text-xs font-medium text-gray-700 mb-1">Prénom</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]"
|
||||
id="employee-firstname"
|
||||
value={employeeForm.firstname}
|
||||
onChange={(e) => setEmployeeForm({ ...employeeForm, firstname: e.target.value })}
|
||||
required
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="employee-lastname" className="block text-xs font-medium text-gray-700 mb-1">Nom</label>
|
||||
<input
|
||||
type="text"
|
||||
id="employee-lastname"
|
||||
value={employeeForm.lastname}
|
||||
onChange={(e) => setEmployeeForm({ ...employeeForm, lastname: e.target.value })}
|
||||
required
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="employee-email" className="block text-xs font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="employee-email"
|
||||
value={employeeForm.email}
|
||||
onChange={(e) => setEmployeeForm({ ...employeeForm, email: e.target.value })}
|
||||
required
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="employee-password" className="block text-xs font-medium text-gray-700 mb-1">Mot de passe</label>
|
||||
<input
|
||||
type="password"
|
||||
id="employee-password"
|
||||
value={employeeForm.password}
|
||||
onChange={(e) => setEmployeeForm({ ...employeeForm, password: e.target.value })}
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="8 caractères minimum"
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2 flex items-center gap-3 pt-1">
|
||||
<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"
|
||||
disabled={employeeSaving}
|
||||
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"
|
||||
>
|
||||
{ticketSearching ? 'Recherche…' : 'Rechercher'}
|
||||
{employeeSaving ? 'Création…' : 'Créer le compte'}
|
||||
</button>
|
||||
{employeeError && <p className="text-red-500 text-xs sm:text-sm">{employeeError}</p>}
|
||||
{employeeOk && <p className="text-green-600 text-xs sm:text-sm">{employeeOk}</p>}
|
||||
</div>
|
||||
</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 ── */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
@@ -8,39 +8,40 @@ export default function AuthCallback() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const { login } = useAuth()
|
||||
const [error, setError] = useState('')
|
||||
// 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
|
||||
|
||||
// L'erreur (s'il y en a une) est entièrement dérivée de l'URL : pas besoin
|
||||
// d'un state ni d'un effet pour la calculer, juste pour l'action à usage
|
||||
// unique (login + redirection) une fois l'utilisateur décodé avec succès.
|
||||
const errorParam = searchParams.get('error')
|
||||
if (errorParam) {
|
||||
setError(errorParam)
|
||||
return
|
||||
}
|
||||
|
||||
const token = searchParams.get('token')
|
||||
const encodedUser = searchParams.get('user')
|
||||
|
||||
if (!token || !encodedUser) {
|
||||
setError('Réponse de connexion incomplète.')
|
||||
return
|
||||
let error = ''
|
||||
let user = null
|
||||
if (errorParam) {
|
||||
error = errorParam
|
||||
} else if (!token || !encodedUser) {
|
||||
error = 'Réponse de connexion incomplète.'
|
||||
} else {
|
||||
try {
|
||||
user = JSON.parse(atob(encodedUser))
|
||||
} catch {
|
||||
error = 'Réponse de connexion illisible.'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const user = JSON.parse(atob(encodedUser))
|
||||
useEffect(() => {
|
||||
if (handled.current || !user) return
|
||||
handled.current = true
|
||||
|
||||
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 {
|
||||
setError('Réponse de connexion illisible.')
|
||||
}
|
||||
}, [searchParams, login, navigate])
|
||||
}, [user, token, login, navigate])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function Profile() {
|
||||
.catch((e) => {
|
||||
if (!handleAuthError(e)) setHasPassword(true)
|
||||
})
|
||||
}, [])
|
||||
}, [handleAuthError])
|
||||
|
||||
const handleSendSetupLink = async () => {
|
||||
setLinkError('')
|
||||
|
||||
@@ -60,3 +60,14 @@ export async function getWinnerApi() {
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur gagnant.', res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createEmployeeApi({ firstname, lastname, email, password }) {
|
||||
const res = await fetch(`${BASE}/employees`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ firstname, lastname, email, password }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || "Erreur lors de la création de l'employé.", res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user