diff --git a/backend/.env.example b/backend/.env.example index 97c309f..2ca9d51 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -4,9 +4,10 @@ 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 " \ No newline at end of file +APP_URL="http://localhost:5000" +MAIL_HOST="smtp.gmail.com" +MAIL_PORT="587" +MAIL_USER="engongomo.emma27@gmail.com" +MAIL_PASS="rczo zqps hsnm msdm" +MAIL_FROM="Thé Tip Top " + diff --git a/backend/package-lock.json b/backend/package-lock.json index 3d2455a..d7d2e38 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,7 +14,7 @@ "cors": "^2.8.6", "express": "^5.2.1", "jsonwebtoken": "^9.0.3", - "nodemailer": "^9.0.1", + "nodemailer": "^9.0.3", "prisma": "^6.19.3", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", @@ -109,6 +109,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -604,31 +605,6 @@ "dev": true, "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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -636,7 +612,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1988,6 +1963,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2306,6 +2282,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -3172,6 +3149,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5487,9 +5465,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.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -5924,6 +5902,7 @@ "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" diff --git a/backend/package.json b/backend/package.json index 7d0fd04..43152fa 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,7 +21,7 @@ "cors": "^2.8.6", "express": "^5.2.1", "jsonwebtoken": "^9.0.3", - "nodemailer": "^9.0.1", + "nodemailer": "^9.0.3", "prisma": "^6.19.3", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 7da0d51..4810fd6 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -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,25 @@ const register = async (req, res) => { }, }); - const verifyUrl = `${process.env.APP_URL}/api/auth/verify-email/${emailVerifyToken}`; - 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: ` -

Bienvenue ${firstname}

-

Merci pour votre inscription.

-

Veuillez cliquer sur le lien ci-dessous pour valider votre compte :

- Valider mon compte -

Ce lien expire dans 1 heure.

+
+

Bienvenue ${firstname} !

+

Merci pour votre inscription sur Thé Tip Top.

+

Voici votre code de validation à 4 chiffres :

+
+ ${emailVerifyToken} +
+

Ce code expire dans 15 minutes.

+
`, }); 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,7 +150,7 @@ 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.", }); @@ -298,17 +300,144 @@ 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." }); + } +}; + 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. Libérer les tickets utilisés par ce user + await tx.ticket.updateMany({ + where: { userId }, + data: { userId: null, isUsed: false, usedAt: 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 } }); + + 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, }; \ No newline at end of file diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index 1c61ae4..6053cc4 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -1,5 +1,5 @@ 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 } = require("../controllers/auth.controller"); const authMiddleware = require("../middlewares/auth.middleware"); const validate = require("../middlewares/validate.middleware"); const { registerSchema, loginSchema } = require("../validations/auth.validation"); @@ -155,7 +155,10 @@ router.post("/forgot-password", forgotPassword); * 500: * description: Erreur serveur. */ -router.post("/reset-password/:token",resetPassword); +router.post("/reset-password/:token", resetPassword); +router.post("/verify-code", verifyCode); +router.patch("/change-password", authMiddleware, changePassword); +router.delete("/account", authMiddleware, deleteAccount); module.exports = router; \ No newline at end of file diff --git a/backend/src/scripts/createAdmin.js b/backend/src/scripts/createAdmin.js new file mode 100644 index 0000000..619d456 --- /dev/null +++ b/backend/src/scripts/createAdmin.js @@ -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()) diff --git a/frontend/Design/Code gagnang.png b/frontend/Design/Code gagnang.png new file mode 100644 index 0000000..29de31f Binary files /dev/null and b/frontend/Design/Code gagnang.png differ diff --git a/frontend/Design/Inscription reussit.png b/frontend/Design/Inscription reussit.png new file mode 100644 index 0000000..01c9dae Binary files /dev/null and b/frontend/Design/Inscription reussit.png differ diff --git a/frontend/Design/Saisir_code.png b/frontend/Design/Saisir_code.png new file mode 100644 index 0000000..01eba6f Binary files /dev/null and b/frontend/Design/Saisir_code.png differ diff --git a/frontend/Design/historique des gain .png b/frontend/Design/historique des gain .png new file mode 100644 index 0000000..371c09a Binary files /dev/null and b/frontend/Design/historique des gain .png differ diff --git a/frontend/Design/profil .png b/frontend/Design/profil .png new file mode 100644 index 0000000..c0fb4a0 Binary files /dev/null and b/frontend/Design/profil .png differ diff --git a/frontend/index.html b/frontend/index.html index b74cdf8..a9d49dd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - ttt-front + Jeu concours - Thé Tip Top
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2bf4f85..ccc4043 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,9 @@ "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", @@ -22,10 +25,70 @@ "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" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -83,14 +146,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -203,13 +266,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -218,6 +281,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -234,18 +307,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -253,9 +326,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -266,44 +339,163 @@ "node": ">=6.9.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==", + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.3", - "tslib": "^2.4.0" + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" } }, - "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==", + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", - "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { @@ -358,9 +550,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -428,6 +620,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -544,29 +754,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", "dev": true, "license": "MIT", "funding": { @@ -574,9 +765,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", "cpu": [ "arm64" ], @@ -591,9 +782,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", "cpu": [ "arm64" ], @@ -608,9 +799,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", "cpu": [ "x64" ], @@ -625,9 +816,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", "cpu": [ "x64" ], @@ -642,9 +833,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", "cpu": [ "arm" ], @@ -659,9 +850,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", "cpu": [ "arm64" ], @@ -676,9 +867,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", "cpu": [ "arm64" ], @@ -693,9 +884,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", "cpu": [ "ppc64" ], @@ -710,9 +901,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", "cpu": [ "s390x" ], @@ -727,9 +918,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", "cpu": [ "x64" ], @@ -744,9 +935,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", "cpu": [ "x64" ], @@ -761,9 +952,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", "cpu": [ "arm64" ], @@ -777,63 +968,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", "cpu": [ "arm64" ], @@ -848,9 +986,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", "cpu": [ "x64" ], @@ -871,50 +1009,57 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", - "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.21.0", - "jiti": "^2.6.1", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.0" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", - "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-x64": "4.3.0", - "@tailwindcss/oxide-freebsd-x64": "4.3.0", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-x64-musl": "4.3.0", - "@tailwindcss/oxide-wasm32-wasi": "4.3.0", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", - "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], @@ -929,9 +1074,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", - "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ "arm64" ], @@ -946,9 +1091,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", - "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -963,9 +1108,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", - "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], @@ -980,9 +1125,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", - "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ "arm" ], @@ -997,9 +1142,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", - "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ "arm64" ], @@ -1014,9 +1159,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", - "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], @@ -1031,9 +1176,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", - "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], @@ -1048,9 +1193,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", - "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], @@ -1065,9 +1210,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", - "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1083,21 +1228,87 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.1", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", - "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -1112,9 +1323,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", - "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -1129,31 +1340,136 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", - "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.0", - "@tailwindcss/oxide": "4.3.0", - "tailwindcss": "4.3.0" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "optional": true, + "peer": true, "dependencies": { - "tslib": "^2.4.0" + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" } }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.4", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.4.tgz", + "integrity": "sha512-QCGwP6QrjypBLwyj5cuyfVamkaIEy/XGY+1VDehbtbQqOggYmTFpFOdWR5mPz14vX8vXLMVjDHlRNBcClyO9ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1176,9 +1492,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "dev": true, "license": "MIT", "dependencies": { @@ -1186,9 +1502,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1196,13 +1512,13 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", - "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "^1.0.0" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1221,10 +1537,123 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -1261,6 +1690,51 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1272,9 +1746,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.35", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", - "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", + "version": "2.11.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", + "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1284,23 +1758,33 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -1318,11 +1802,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -1332,9 +1816,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001797", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", - "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", "dev": true, "funding": [ { @@ -1352,6 +1836,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1387,6 +1881,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1394,6 +1909,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1412,6 +1941,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1419,6 +1955,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1429,17 +1975,25 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { - "version": "1.5.371", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", - "integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==", + "version": "1.5.408", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.408.tgz", + "integrity": "sha512-SLoprcYpJ/OH2v2ps0+N5biv9H4/KBT3+YmmDew64TwK5y9j2wv7pMOFY7IorVkyMtEyLSCRlXKLsNlakeAlPw==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.23.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", - "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", "dependencies": { @@ -1450,6 +2004,26 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1474,16 +2048,19 @@ } }, "node_modules/eslint": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", - "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", + "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", + "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", @@ -1507,7 +2084,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -1550,9 +2127,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", - "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.4.tgz", + "integrity": "sha512-7bqTKz7T0r+HKWFarNXByDE9/5+73wI2ru+M3zuqGbR7s/b/5/pQJXZoufWlrngqGqoZto73ZkGumCdLxk+4rw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1645,6 +2222,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1655,6 +2242,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1739,9 +2336,9 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, @@ -1784,9 +2381,9 @@ } }, "node_modules/globals": { - "version": "17.6.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", - "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "version": "17.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", + "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", "dev": true, "license": "MIT", "engines": { @@ -1820,6 +2417,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1840,6 +2450,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1863,6 +2483,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1887,6 +2514,57 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2245,6 +2923,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2255,14 +2944,31 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -2279,9 +2985,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -2305,15 +3011,29 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", "dev": true, "license": "MIT", "engines": { "node": ">=18" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2364,6 +3084,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2384,6 +3117,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2392,9 +3132,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -2405,9 +3145,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -2425,7 +3165,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2443,6 +3183,22 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2454,30 +3210,38 @@ } }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.7" + "react": "^19.2.8" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-router": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", - "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -2497,12 +3261,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", - "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", "license": "MIT", "dependencies": { - "react-router": "7.17.0" + "react-router": "7.18.2" }, "engines": { "node": ">=20.0.0" @@ -2512,14 +3276,38 @@ "react-dom": ">=18" } }, - "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2529,21 +3317,33 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" } }, "node_modules/scheduler": { @@ -2591,6 +3391,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2601,10 +3408,44 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", - "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, "license": "MIT" }, @@ -2622,6 +3463,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2639,13 +3497,61 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, - "license": "0BSD", - "optional": true + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } }, "node_modules/type-check": { "version": "0.4.0", @@ -2660,10 +3566,20 @@ "node": ">= 0.8.0" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", "dev": true, "funding": [ { @@ -2702,16 +3618,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "bin": { @@ -2728,7 +3644,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -2779,6 +3695,405 @@ } } }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2795,6 +4110,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -2805,6 +4137,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 83f90e6..5fc8338 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "react": "^19.2.6", @@ -17,6 +19,9 @@ "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", @@ -24,7 +29,9 @@ "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" } } diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 6893eb1..7d2840d 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1 +1,32 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..86a7682 --- /dev/null +++ b/frontend/public/robots.txt @@ -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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5d0e670..e04d3ba 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,23 +1,87 @@ 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 Accueil from './pages/Accueil' +import ReglesJeu from './pages/ReglesJeu' import Participation from './pages/Participation' 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 ( + - } /> + } /> } /> } /> - } /> - } /> - } /> - } /> + + {/* Pages publiques */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Pages CLIENT */} + + + + } /> + + + + } /> + + + + } /> + + + + } /> + + {/* Page EMPLOYEE */} + + + + } /> + + {/* Page ADMIN */} + + + + } /> + + } /> + + ) } diff --git a/frontend/src/assets/img/img-contact.jpg b/frontend/src/assets/img/img-contact.jpg new file mode 100644 index 0000000..6c2ae69 Binary files /dev/null and b/frontend/src/assets/img/img-contact.jpg differ diff --git a/frontend/src/assets/img/logo.png b/frontend/src/assets/img/logo.png new file mode 100644 index 0000000..6cf9105 Binary files /dev/null and b/frontend/src/assets/img/logo.png differ diff --git a/frontend/src/components/CookieBanner.jsx b/frontend/src/components/CookieBanner.jsx new file mode 100644 index 0000000..e68ed36 --- /dev/null +++ b/frontend/src/components/CookieBanner.jsx @@ -0,0 +1,41 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' + +export default function CookieBanner() { + const [visible, setVisible] = useState(() => !localStorage.getItem('cookieConsent')) + + if (!visible) return null + + const handleChoice = (choice) => { + localStorage.setItem('cookieConsent', choice) + setVisible(false) + } + + return ( +
+
+
+

Ce site utilise des cookies

+

+ Nous utilisons des cookies essentiels au fonctionnement du site. En continuant votre navigation, vous acceptez leur utilisation conformément à notre{' '} + politique de confidentialité. +

+
+
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx new file mode 100644 index 0000000..1c673df --- /dev/null +++ b/frontend/src/components/Footer.jsx @@ -0,0 +1,261 @@ +import { Link, useLocation } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' + +export default function Footer() { + const { pathname } = useLocation() + const { user } = useAuth() + const isLoggedIn = !!user + + function FooterLink({ to, children, className=''}) { + return pathname === to ? ( + {children} + ) : ( + + {children} + + ) + } + return ( +
+ + + {/* Partie principale */} +
+ + + {/* Logo */} +
+ +

+ Thé Tip Top +

+
+ +

+ Participez au jeu-concours Thé Tip Top et tentez de gagner + des lots gourmands autour du thé. +

+ +
+ + + + {/* Navigation */} +
+ +

+ Navigation +

+ + +
    + +
  • + + Accueil + +
  • + + +
  • + + Règles du jeu + +
  • + + +
  • + + Tester mon code + +
  • + + { !isLoggedIn && ( +
  • + + Créer un compte + +
  • + )} + +
+ +
+ + + + + {/* Informations légales */} +
+ +

+ Informations +

+ + +
    + +
  • + + Mentions légales + +
  • + + +
  • + + Politique de confidentialité + +
  • + +
  • + + Accessibilité + +
  • + +
  • + + CGU + +
  • + + +
  • + + Contact + +
  • + +
  • + + Plan du site + +
  • + +
+ +
+ + + + + {/* Contact */} +
+ +

+ Contact +

+ +

+ Une question sur le jeu ? +

+ + + 01 84 80 20 34 + + + + contact@thetiptop.fr + + +

+ 18 rue Léon Frot +
+ 75011 Paris, France +

+ + +
+ + +
+ + + + {/* Barre basse */} + +
+ +
+ + +

+ © {new Date().getFullYear()} Thé Tip Top. Tous droits réservés. +
+ + Projet étudiant fictif — aucun achat ni réservation réel ne peut être effectué sur ce site. + +

+ + + + +
+ +
+ + +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx new file mode 100644 index 0000000..0862d4f --- /dev/null +++ b/frontend/src/components/Header.jsx @@ -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 = Logo de Thé Tip Top + + 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 ( +
+
+ {pathname === '/accueil' ? ( + + {logoContent} + + ) : ( + + {logoContent} + + )} + + {/* Nav desktop */} + + + {/* Hamburger mobile */} + +
+ + {/* Menu mobile déroulant */} + {menuOpen && ( +
+ {navLinks.map((link) => ( + setMenuOpen(false)} + className={`block py-3 font-medium border-b border-gray-50 ${ + pathname === link.to ? 'text-[#006B6B]' : 'text-gray-700' + }`} + > + {link.label} + + ))} + +
+ {isLoggedIn ? ( + <> + 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}) + + + + ) : ( + <> + 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 + + 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 + + + )} +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx new file mode 100644 index 0000000..5d4a61b --- /dev/null +++ b/frontend/src/components/Layout.jsx @@ -0,0 +1,16 @@ +import Header from './Header' +import Footer from './Footer' + +export default function Layout({ children }) { + return ( +
+
+ +
+ {children} +
+ +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..f78f05c --- /dev/null +++ b/frontend/src/components/ProtectedRoute.jsx @@ -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 + } + + if (roles && !roles.includes(user.role)) { + const redirects = { ADMIN: '/admin', EMPLOYEE: '/employe', CLIENT: '/mes-gains' } + return + } + + return children +} diff --git a/frontend/src/components/UserNav.jsx b/frontend/src/components/UserNav.jsx new file mode 100644 index 0000000..6582af3 --- /dev/null +++ b/frontend/src/components/UserNav.jsx @@ -0,0 +1,56 @@ +import { Link } from 'react-router-dom' + +export default function UserNav({ active }) { + const tabs = [ + { + key: 'code', + to: '/saisir-code-unique', + label: 'Saisir un code', + icon: ( + + + + ), + }, + { + key: 'gains', + to: '/mes-gains', + label: 'Mes gains', + icon: ( + + + + ), + }, + { + key: 'profile', + to: '/profile', + label: 'Profil', + icon: ( + + + + ), + }, + ] + + return ( +
+ {tabs.map((tab) => ( + + {tab.icon} + {tab.label} + {tab.label.split(' ')[0]} + + ))} +
+ ) +} diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000..552cb66 --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -0,0 +1,31 @@ +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 ( + + {children} + + ) +} + +export function useAuth() { + return useContext(AuthContext) +} diff --git a/frontend/src/hooks/useHandleAuthError.js b/frontend/src/hooks/useHandleAuthError.js new file mode 100644 index 0000000..4e9ddd0 --- /dev/null +++ b/frontend/src/hooks/useHandleAuthError.js @@ -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]) +} diff --git a/frontend/src/pages/Accessibilite.jsx b/frontend/src/pages/Accessibilite.jsx new file mode 100644 index 0000000..d11b030 --- /dev/null +++ b/frontend/src/pages/Accessibilite.jsx @@ -0,0 +1,41 @@ +export default function Accessibilite() { + return ( +
+
+ +

+ Déclaration d'accessibilité +

+ +

+ 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. +

+ +
+

+ État de conformité +

+

+ 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. +

+
+ +
+

+ Signaler un problème d'accessibilité +

+

+ Si vous rencontrez un obstacle à l'accessibilité sur le site, vous pouvez nous en faire + part afin que nous puissions y remédier :
+ Email : contact@thetiptop.fr +

+
+ +
+
+ ) +} diff --git a/frontend/src/pages/Accueil.jsx b/frontend/src/pages/Accueil.jsx new file mode 100644 index 0000000..e8f3a49 --- /dev/null +++ b/frontend/src/pages/Accueil.jsx @@ -0,0 +1,57 @@ +import { Link } from 'react-router-dom' + +export default function Accueil() { + return ( +
+ + {/* Hero */} +
+
+

+ Bienvenue au jeu-concours Thé Tip Top +

+

+ À 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. +

+
+ + Tester mon code + + + Découvrir les règles + +
+
+
+ + {/* Comment participer */} +
+

+ Comment participer ? +

+
+ {[ + { 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) => ( +
+
{step.num}
+

{step.text}

+
+ ))} +
+
+ +
+ ) +} diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index f2dee00..67fe444 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -1,71 +1,405 @@ -const MOCK_STATS = { - totalParticipants: 1248, - gainsRemis: 342, - gainsEnAttente: 906, - tauxParticipation: '78%', -} +import { useState, useEffect } from 'react' +import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi } from '../services/admin' +import { searchTicketApi, deliverClaimApi } from '../services/employee' +import { useHandleAuthError } from '../hooks/useHandleAuthError' -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 }, -] - -export default function Admin() { +function StatCard({ label, value, sub }) { return ( -
-
-

Tableau de bord Admin

- - {/* Stats */} -
- {[ - { 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) => ( -
-

{stat.value}

-

{stat.label}

-
- ))} -
- - {/* Tirage au sort */} -
-

Grand tirage au sort

- -
- - {/* Liste participants */} -
-

Participants

- - - - - - - - - - {MOCK_USERS.map((user) => ( - - - - - - ))} - -
NomEmailGains
{user.name}{user.email}{user.gains}
-
-
+
+

{value ?? '—'}

+

{label}

+ {sub &&

{sub}

} +
+ ) +} + +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() { + 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) + + // 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) + + 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)) + }, []) + + 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 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 ( +
+

Chargement...

+
+ ) + + if (error) return ( +
+

{error}

+
+ ) + + const winnerUser = winner?.winner?.user + + return ( +
+
+ +

Tableau de bord Admin

+ + {/* Stats principales */} +
+ + + + +
+ + {/* Stats tickets */} +
+ + + +
+ + {/* Répartition par lot */} + {stats?.lotsStats?.length > 0 && ( +
+

Répartition des lots

+
+ + + + + + + + + + + {stats.lotsStats.map((lot) => ( + + + + + + + ))} + +
Lot%TicketsParticipations
{lot.name}{lot.percentage}%{lot._count.tickets.toLocaleString('fr-FR')}{lot._count.participations}
+
+
+ )} + + {/* ── Remise de lot ── */} +
+

Remise de lot

+ +
+ setTicketCode(e.target.value.toUpperCase())} + placeholder="Code ticket (10 caractères)" + maxLength={10} + className="flex-1 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]" + /> + +
+ + {ticketSearchErr && ( +

{ticketSearchErr}

+ )} + + {ticketResult && ( +
+ {ticketSuccess && ( +
+ + + +

Lot remis avec succès.

+
+ )} + +
+
+

{ticketResult.lot.name}

+

{ticketResult.lot.description}

+ {ticketResult.lot.value &&

{ticketResult.lot.value} €

} +
+ + {ticketResult.claim?.status === 'DELIVERED' ? 'Remis' + : ticketResult.claim?.status === 'CANCELLED' ? 'Expiré' + : 'À remettre'} + +
+ +
+
+ {(ticketResult.client.firstname?.[0] ?? '?').toUpperCase()}{(ticketResult.client.lastname?.[0] ?? '?').toUpperCase()} +
+
+

{ticketResult.client.firstname} {ticketResult.client.lastname}

+

{ticketResult.client.email}

+
+
+ +
+ {ticketDeliverErr &&

{ticketDeliverErr}

} + + {ticketResult.claim?.status === 'PENDING' && !ticketSuccess && ( + + )} + +
+
+ )} +
+ + {/* ── Tirage au sort ── */} +
+

Grand tirage au sort

+ + {winnerUser ? ( + /* Gagnant déjà tiré */ +
+
+
+ {(winnerUser.firstname?.[0] ?? '?').toUpperCase()}{(winnerUser.lastname?.[0] ?? '?').toUpperCase()} +
+
+

Gagnant du grand tirage

+

{winnerUser.firstname} {winnerUser.lastname}

+

{winnerUser.email}

+
+
+ + + +
+
+ {winner?.winner?.createdAt && ( +

+ Tirage effectué le {formatDate(winner.winner.createdAt)} +

+ )} +
+ ) : ( + /* Pas encore de tirage */ +
+
+

+ {stats?.participations ?? 0} participations enregistrées +

+

+ {stats?.users ?? 0} participants éligibles +

+
+ + {drawError && ( +

{drawError}

+ )} + + +
+ )} +
+ + {/* Liste participants */} +
+

+ Participants ({users.length}) +

+
+ + + + + + + + + + {users.length === 0 && ( + + + + )} + {users.map((user) => ( + + + + + + ))} + +
NomEmailParticipations
+ Aucun participant pour le moment +
+ {user.firstname} {user.lastname} + {user.email} + {user._count.participations} +
+
+
+ +
+ + {/* ── Modal de confirmation du tirage ── */} + {showConfirm && ( +
+
+ +
+
+ + + +
+
+ +

Lancer le tirage au sort ?

+

+ Un gagnant sera sélectionné aléatoirement parmi les {stats?.users} participants. +

+ +
+ + + +

Cette action est irréversible. Le tirage ne peut être effectué qu'une seule fois.

+
+ +
+ + +
+ +
+
+ )} +
) } diff --git a/frontend/src/pages/Cgu.jsx b/frontend/src/pages/Cgu.jsx new file mode 100644 index 0000000..212870c --- /dev/null +++ b/frontend/src/pages/Cgu.jsx @@ -0,0 +1,244 @@ +export default function Cgu() { + return ( +
+
+ +

+ Conditions générales d'utilisation +

+ +
+

+ 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. +

+
+ +

+ 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. +

+ + {[ + { + title: 'Article 1 – Objet des CGU', + content: ( +
+

+ 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. +

+

+ 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. +

+
+ ), + }, + { + title: 'Article 2 – Conditions d\'accès au Site', + content: ( +
+

Conditions d'âge — 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é. +

+

Conditions géographiques — 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. +

+

Personnes exclues — 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. +

+

Conditions techniques — 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. +

+
+ ), + }, + { + title: 'Article 3 – Services proposés et fonctionnalités', + content: ( +
+
+

3.1 – Services proposés

+

+ 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 : +

+
    +
  • La présentation du jeu-concours, de ses conditions et des lots à gagner
  • +
  • La création d'un espace personnel pour chaque participant
  • +
  • La saisie et la validation des codes à 10 caractères figurant sur les tickets de caisse ou factures d'un montant supérieur à 49 €
  • +
  • La consultation de l'historique des participations et gains obtenus
  • +
  • La réclamation des lots gagnés, en ligne ou en boutique
  • +
+
+
+

3.2 – Fonctionnalités principales

+

S'inscrire — 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. +

+

Jouer (participer au jeu-concours) — 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 : +

+
    +
  • 60 % des tickets : infuseur à thé
  • +
  • 20 % des tickets : boîte de 100 g de thé détox ou d'infusion
  • +
  • 10 % des tickets : boîte de 100 g de thé signature
  • +
  • 6 % des tickets : coffret découverte d'une valeur de 39 €
  • +
  • 4 % des tickets : coffret découverte d'une valeur de 69 €
  • +
+

+ 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. +

+

Remise des lots — Les lots peuvent être réclamés + en boutique, sur présentation du compte utilisateur validé et de la confirmation + de gain. +

+

+ À 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. +

+
+
+ ), + }, + { + title: 'Article 4 – Conditions d\'inscription et de création de compte', + content: ( +
+
+

4.1 – Processus d'inscription

+

+ Pour accéder aux services du Site, l'utilisateur doit créer un compte personnel. + L'inscription peut s'effectuer de deux manières : +

+

Via le formulaire classique — 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. +

+

Via l'authentification Google — 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. +

+
+
+

4.2 – Unicité du compte

+

+ 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. +

+
+
+

4.3 – Véracité des informations

+

+ 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. +

+
+
+ ), + }, + { + title: 'Article 5 – Responsabilité de l\'utilisateur', + content: ( +
+

+ 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 à : +

+
    +
  • Fournir des données personnelles exactes, sincères et à jour
  • +
  • Ne pas usurper l'identité d'un tiers
  • +
  • Ne pas créer de compte au nom d'une autre personne sans son autorisation
  • +
  • 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
  • +
+

+ 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. +

+
+ ), + }, + { + title: 'Article 6 – Gestion du compte utilisateur', + content: ( +
+
+

6.1 – Modification des informations

+

+ L'utilisateur peut à tout moment accéder à son espace personnel et modifier ses + informations (nom, prénom, mot de passe). +

+
+
+

6.2 – Suppression du compte

+

+ 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. +

+
+
+

6.3 – Sécurité des identifiants

+

+ 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. +

+
+
+ ), + }, + ].map((article) => ( +
+

+ {article.title} +

+ {article.content} +
+ ))} + +
+
+ ) +} diff --git a/frontend/src/pages/Confidentialite.jsx b/frontend/src/pages/Confidentialite.jsx new file mode 100644 index 0000000..899f1db --- /dev/null +++ b/frontend/src/pages/Confidentialite.jsx @@ -0,0 +1,99 @@ +export default function Confidentialite() { + return ( +
+
+ +

+ Politique de confidentialité +

+ +

+ 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. +

+ +
+

+ Article 1 – Responsable du traitement +

+

+ 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. +

+
+ +
+

+ Article 2 – Données collectées +

+

Dans le cadre de votre participation, nous pouvons collecter :

+
    +
  • Nom et prénom
  • +
  • Adresse email
  • +
  • Informations du compte utilisateur
  • +
  • Code de participation au jeu
  • +
  • Informations liées aux gains obtenus
  • +
+
+ +
+

+ Article 3 – Utilisation des données +

+

+ 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. +

+
+ +
+

+ Article 4 – Conservation des données +

+

+ 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. +

+
+ +
+

+ Article 5 – Cookies +

+

+ 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. +

+
+ +
+

+ Article 6 – Vos droits +

+

Conformément au RGPD, vous disposez des droits suivants :

+
    +
  • Droit d'accès à vos données
  • +
  • Droit de rectification
  • +
  • Droit de suppression
  • +
  • Droit d'opposition
  • +
  • Droit à la limitation du traitement
  • +
+
+ +
+

+ Article 7 – Contact +

+

+ Pour toute question concernant vos données personnelles :
+ Email : contact@thetiptop.fr +

+
+ +
+
+ ) +} diff --git a/frontend/src/pages/Connexion.jsx b/frontend/src/pages/Connexion.jsx index b6a25a0..acf2a7f 100644 --- a/frontend/src/pages/Connexion.jsx +++ b/frontend/src/pages/Connexion.jsx @@ -1,73 +1,150 @@ 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) => { + 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 ( -
-
-
-

Thé Tip Top

-

Con - - nexion à votre espace

+
+ +
+

Se connecter

+

Connexion à votre espace

+
+ + {justRegistered && ( +
+ Compte créé avec succès ! Connectez-vous. +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ +
+ +
- -
- +
+ +
-
- -
- - +
+
+ +
+
- - + + -

- Pas encore de compte ?{' '} - - S'inscrire - -

+
+
+ Ou +
+ +
+ + +
+ +

+ Pas encore de compte ?{' '} + + S'inscrire + +

) } diff --git a/frontend/src/pages/Contact.jsx b/frontend/src/pages/Contact.jsx new file mode 100644 index 0000000..67dd601 --- /dev/null +++ b/frontend/src/pages/Contact.jsx @@ -0,0 +1,45 @@ +import imgContact from '../assets/img/img-contact.jpg' + +export default function Contact() { + return ( +
+
+ + {/* Informations */} +
+

+ Contact +

+

+ Une question concernant le jeu-concours, votre participation ou votre lot ? + Notre équipe ThéTipTop est à votre écoute. +

+
+
+

Adresse

+

ThéTipTop
18 rue Léon Frot
75011 Paris, France

+
+
+

Email

+ contact@thetiptop.fr +
+
+

Téléphone

+ 01 84 80 20 34 +
+
+
+ + {/* Image */} +
+ Une personne savourant un thé +
+ +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/Employe.jsx b/frontend/src/pages/Employe.jsx index c417257..263ec15 100644 --- a/frontend/src/pages/Employe.jsx +++ b/frontend/src/pages/Employe.jsx @@ -1,76 +1,211 @@ import { useState } from 'react' +import { searchTicketApi, deliverClaimApi } from '../services/employee' +import { useHandleAuthError } from '../hooks/useHandleAuthError' + +function formatDate(dateStr) { + return new Date(dateStr).toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +const STATUS_CONFIG = { + PENDING: { label: 'À remettre', classes: 'bg-yellow-100 text-yellow-800' }, + DELIVERED: { label: 'Déjà remis', classes: 'bg-green-100 text-green-800' }, + CANCELLED: { label: 'Expiré', classes: 'bg-gray-100 text-gray-500' }, +} export default function Employe() { - const [code, setCode] = useState('') - const [gain, setGain] = useState(null) + 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 = (e) => { + const handleSearch = async (e) => { e.preventDefault() - // TODO: connecter au backend - setGain({ - code, - lot: 'Thé signature (100g)', - client: 'Jean Dupont', - email: 'jean.dupont@email.com', - status: 'PENDING', - }) + 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 = () => { - // TODO: connecter au backend - setGain({ ...gain, status: 'DELIVERED' }) + 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 ( -
+
-

Espace Employé

-

Vérifier et remettre un lot

+ {/* En-tête */} +
+

Espace Employé

+

Recherchez un code ticket pour remettre le lot au client.

+
+ + {/* Formulaire de recherche */}
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 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]" />
- {gain && ( -
-
-
-

{gain.lot}

-

{gain.code}

+ {/* Erreur de recherche */} + {searchError && ( +
+ {searchError} +
+ )} + + {/* Résultat */} + {result && ( +
+ + {/* Bandeau succès remise */} + {success && ( +
+ + + +

Lot remis avec succès — espace client mis à jour.

- - {gain.status === 'DELIVERED' ? 'Remis' : 'À remettre'} + )} + + {/* En-tête carte : lot + statut */} +
+
+

{result.lot.name}

+

{result.lot.description}

+ {result.lot.value && ( +

Valeur : {result.lot.value} €

+ )} +
+ + {statusCfg.label}
-
-

Client : {gain.client}

-

Email : {gain.email}

+ {/* Infos client */} +
+

Client

+
+
+ {(result.client.firstname?.[0] ?? '?').toUpperCase()}{(result.client.lastname?.[0] ?? '?').toUpperCase()} +
+
+

+ {result.client.firstname} {result.client.lastname} +

+

{result.client.email}

+
+
- {gain.status === 'PENDING' && ( + {/* Infos ticket */} +
+

Ticket

+

+ Code :{' '} + {result.ticket.code} +

+

+ Utilisé le : {formatDate(result.ticket.usedAt)} +

+ {result.claim.claimedAt && ( +

+ Remis le : {formatDate(result.claim.claimedAt)} +

+ )} +
+ + {/* Actions */} +
+ {deliverError && ( +
+ {deliverError} +
+ )} + + {result.claim.status === 'PENDING' && !success && ( + + )} + + {result.claim.status === 'CANCELLED' && ( +
+ Ce gain est expiré et ne peut plus être remis. +
+ )} + - )} +
+
)} +
) diff --git a/frontend/src/pages/Inscription.jsx b/frontend/src/pages/Inscription.jsx index 59af900..5a13ec4 100644 --- a/frontend/src/pages/Inscription.jsx +++ b/frontend/src/pages/Inscription.jsx @@ -1,116 +1,232 @@ -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' export default function Inscription() { - const [form, setForm] = useState({ - firstName: '', - lastName: '', - email: '', - password: '', - acceptMarketing: false, - }) + const navigate = useNavigate() + const { login } = useAuth() - const handleChange = (e) => { - const { name, value, type, checked } = e.target - setForm({ ...form, [name]: type === 'checkbox' ? checked : value }) - } + // Étape : 'form' | 'verify' + const [step, setStep] = useState('form') + const [email, setEmail] = useState('') + const [form, setForm] = useState({ email: '', lastName: '', firstName: '', password: '' }) + const [acceptedTerms, setAcceptedTerms] = useState(false) + const [showPassword, setShowPassword] = useState(false) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) - const handleSubmit = (e) => { + // 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 }) + + // É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, + }) + setEmail(form.email) + setStep('verify') + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } } - return ( -
-
-
-

Thé Tip Top

-

Créer votre compte

+ // É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 ( +
+
+
+ + + +
+

Vérifiez votre email

+

+ Un code à 4 chiffres a été envoyé à
+ {email} +

-
-
-
- + {codeError && ( +
+ {codeError} +
+ )} + + +
+ {digits.map((d, i) => ( handleDigit(i, e.target.value)} + onKeyDown={(e) => handleDigitKeyDown(i, e)} + 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" /> -
-
- - -
+ ))}
-
- - -
- -
- - -
- - - -

- Déjà un compte ?{' '} - - Se connecter - +

+ Le code expire dans 15 minutes.{' '} +

+ ) + } + + // ── Étape 1 : Formulaire d'inscription ── + return ( +
+
+

S'inscrire

+

Créer votre compte

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ + + + +
+ +
+
+ Ou +
+
+ +
+ + +
+ +

+ Vous avez déjà un compte ?{' '} + Se connecter +

) } diff --git a/frontend/src/pages/MentionsLegales.jsx b/frontend/src/pages/MentionsLegales.jsx new file mode 100644 index 0000000..0a11183 --- /dev/null +++ b/frontend/src/pages/MentionsLegales.jsx @@ -0,0 +1,255 @@ +export default function MentionsLegales() { + return ( +
+
+ +

+ Mentions légales +

+ +
+

+ 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. +

+
+ +

+ 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. +

+ + {[ + { + title: 'Article 1 – Société éditrice du site', + content: ( +
+

Dénomination sociale : Thé Tip Top

+

Forme juridique : Société à Responsabilité Limitée (SARL)

+

Capital social : 150 000 €

+

Siège social : 18 rue Léon Frot, 75011 Paris, France

+

Représentant légal : M. Eric Bourdon, Gérant

+

Directeur de la publication : M. Eric Bourdon, en sa qualité de Gérant

+

SIRET : 362 521 879 00054

+

TVA intracommunautaire : FR83362521879

+

Email : contact@thetiptop.fr

+

Téléphone : 01 84 80 20 34

+

+ 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. +

+
+ ), + }, + { + title: 'Article 2 – Agence de développement', + content: ( +
+

Dénomination sociale : Furious Ducks

+

Statut juridique : Société par Actions Simplifiée (SAS)

+

Capital social : 200 000 €

+

Siège social : 13 Avenue Jean Moulin, 93240 Stains

+

Directeur Général : M. Guido Brasletti

+

Effectif : 45 employés

+

RCS : Bobigny 500 234 567

+

Code APE / NAF : 6201Z – Développement de logiciels

+

Année de création : 2007

+

Spécialité : Technologies open source

+

Téléphone : 01 35 52 40 49

+

+ 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. +

+
+ ), + }, + { + title: 'Article 3 – Hébergeur du site', + content: ( +
+

Raison sociale : Vercel Inc.

+

Adresse : 440 N Barranca Avenue #4133, Covina, CA 91723, États-Unis

+

Contact : privacy@vercel.com

+

Site internet : https://www.vercel.com

+
+ ), + }, + { + title: 'Article 4 – Propriété intellectuelle', + content: ( +
+
+

4.1 – Droits de la société Thé Tip Top

+

+ 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. +

+

+ 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. +

+

+ 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. +

+
+
+

4.2 – Droits de l'agence Furious Ducks

+

+ 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. +

+
+
+

4.3 – Contenu soumis par les utilisateurs

+

+ 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. +

+
+
+ ), + }, + { + title: 'Article 5 – Cookies et traceurs', + content: ( +
+

+ 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. +

+

+ 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. +

+

Les cookies utilisés sont les suivants :

+
    +
  • + Cookies de session : nécessaires au bon fonctionnement du site + (authentification, suivi de participation, sécurité). Non soumis à consentement. +
  • +
  • + Cookies analytiques (Google Analytics 4) : 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. +
  • +
  • + Cookies publicitaires et sociaux (Meta Business Suite – Instagram/Facebook) :{' '} + utilisés dans le cadre des campagnes Meta Ads et du suivi des performances + sociales. Soumis à consentement. +
  • +
  • + Cookies Google : liés aux fonctionnalités d'authentification via + compte Google. Soumis aux politiques de confidentialité respectives de ces + plateformes. +
  • +
+
+ ), + }, + { + title: 'Article 6 – Responsabilité', + content: ( +
+

+ 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 : +

+
    +
  • Toute inexactitude ou omission portant sur des informations disponibles sur le Site
  • +
  • Tout dommage résultant d'une intrusion frauduleuse d'un tiers ayant entraîné une modification des informations
  • +
  • Tout dommage lié à l'utilisation d'Internet (rupture de service, virus, intrusion extérieure)
  • +
  • Tout dysfonctionnement lié à l'infrastructure technique gérée par l'agence Furious Ducks ou son hébergeur
  • +
+
+ ), + }, + { + title: 'Article 7 – Droit applicable', + content: ( +

+ 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. +

+ ), + }, + { + title: 'Article 8 – Accessibilité', + content: ( +

+ 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{' '} + + Accessibilité + . +

+ ), + }, + { + title: 'Article 9 – Engagement RSE et éco-responsabilité', + content: ( +
+

+ 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 : +

+
    +
  • L'utilisation de technologies et d'infrastructures respectueuses de l'environnement
  • +
  • L'optimisation de la consommation d'énergie et de l'empreinte carbone du site
  • +
  • L'hébergement auprès de fournisseurs certifiés pour leurs pratiques durables
  • +
  • La limitation de la collecte de données personnelles aux seules données nécessaires
  • +
  • L'utilisation de technologies open source réduisant les dépendances propriétaires
  • +
  • La sensibilisation des utilisateurs aux bonnes pratiques numériques
  • +
+

+ Toute question relative à ces engagements peut être adressée à : dpo@thetiptop.fr +

+
+ ), + }, + ].map((article) => ( +
+

+ {article.title} +

+ {article.content} +
+ ))} + +
+
+ ) +} diff --git a/frontend/src/pages/MesGains.jsx b/frontend/src/pages/MesGains.jsx index a0ed8d6..dcb45f3 100644 --- a/frontend/src/pages/MesGains.jsx +++ b/frontend/src/pages/MesGains.jsx @@ -1,34 +1,128 @@ -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 ( -
-
-

Mes gains

-

Historique de vos participations

+ const handleAuthError = useHandleAuthError() + const [gains, setGains] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [refreshing, setRefreshing] = useState(false) -
- {MOCK_GAINS.map((gain) => ( -
-
-

{gain.lot}

-

{gain.code}

-
- - {statusLabel[gain.status].label} - -
- ))} + 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(() => { fetchGains() }, [fetchGains]) + + return ( +
+ + +
+

Historique de mes gains

+ +
+ + {loading &&

Chargement…

} + + {error && ( +
+ {error}
+ )} + + {!loading && !error && gains.length === 0 && ( +
+

Aucun gain pour le moment.

+

Saisissez un code pour participer !

+
+ )} + +
+ {gains.map((gain, index) => { + const statusCfg = CLAIM_STATUS[gain.claimStatus] ?? CLAIM_STATUS.PENDING + return ( +
+
+
+ {index < gains.length - 1 &&
} +
+
+
+

{gain.lot}

+ + {statusCfg.label} + +
+

{gain.description}

+ {gain.value && ( +

Valeur : {gain.value} €

+ )} +
+

Gagné le {formatDate(gain.wonAt)}

+ {timeAgo(gain.wonAt) && ( + + {timeAgo(gain.wonAt)} + + )} +
+

+ Code utilisé :{' '} + {gain.ticketCode} +

+
+
+ ) + })}
) diff --git a/frontend/src/pages/NotFound.jsx b/frontend/src/pages/NotFound.jsx new file mode 100644 index 0000000..1bca46e --- /dev/null +++ b/frontend/src/pages/NotFound.jsx @@ -0,0 +1,26 @@ +import { Link } from 'react-router-dom' + +export default function NotFound() { + return ( +
+ +

404

+ +

+ Page introuvable +

+ +

+ La page que vous recherchez n'existe pas ou a été déplacée. +

+ + + Retour à l'accueil + + +
+ ) +} diff --git a/frontend/src/pages/Perdu.jsx b/frontend/src/pages/Perdu.jsx new file mode 100644 index 0000000..ddd4b62 --- /dev/null +++ b/frontend/src/pages/Perdu.jsx @@ -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 ( +
+ +
+ + + +
+ +

+ Réessayer la prochaine fois +

+ +

+ {message || 'Ce code est invalide ou a déjà été utilisé.'} +

+ +

+ Vérifiez le code présent sur votre ticket et réessayez. +

+ + +
+ ) +} diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx new file mode 100644 index 0000000..7a84cb6 --- /dev/null +++ b/frontend/src/pages/Profile.jsx @@ -0,0 +1,158 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import UserNav from '../components/UserNav' +import { changePasswordApi, deleteAccountApi } from '../services/auth' +import { useHandleAuthError } from '../hooks/useHandleAuthError' +import { useAuth } from '../context/AuthContext' + +export default function Profile() { + const user = JSON.parse(localStorage.getItem('user') || '{}') + const initials = `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase() + const handleAuthError = useHandleAuthError() + const { logout } = useAuth() + const navigate = useNavigate() + + const [showDeleteModal, setShowDeleteModal] = useState(false) + const [deleting, setDeleting] = useState(false) + const [deleteError, setDeleteError] = useState('') + + 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) + + 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 }) => ( + + {visible + ? <> + : + } + + ) + + return ( +
+ + + {/* Avatar */} +
+
+ {initials} +
+
+ + {/* Infos du compte (lecture seule) */} +
+
+ +
+ {user.email || '—'} +
+
+
+ +
+ {user.lastname || '—'} +
+
+
+ +
+ {user.firstname || '—'} +
+
+
+ + {/* Séparateur */} +
+ + {/* Section changement de mot de passe */} +
+

Changer mon mot de passe

+ + {pwSuccess && ( +
+ + + + Mot de passe modifié avec succès. +
+ )} + + {pwError && ( +
+ {pwError} +
+ )} + +
+ {[ + { 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 }) => ( +
+ +
+ + +
+
+ ))} + + +
+
+
+ ) +} diff --git a/frontend/src/pages/ReglesJeu.jsx b/frontend/src/pages/ReglesJeu.jsx new file mode 100644 index 0000000..dba3432 --- /dev/null +++ b/frontend/src/pages/ReglesJeu.jsx @@ -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 ( +
+ + {/* Hero */} +
+
+

+ Règles du jeu +

+

+ Participez au jeu-concours ThéTipTop et découvrez immédiatement + le lot que vous avez gagné. +

+
+
+ + {/* Comment participer */} +
+

+ Comment participer ? +

+
+ {[ + { + 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) => ( +
+
{step.num}
+

{step.title}

+

{step.text}

+
+ ))} +
+
+ + {/* Lots */} +
+
+

+ Que pouvez-vous gagner ? +

+

+ 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. +

+
+

Grand gagnant

+

+ Le participant tiré au sort remportera une année de thé d'une valeur de 360€ ! +

+
+
+
+ + {/* CTA */} +
+
+

+ Ne manquez pas votre chance ! +

+

+ Inscrivez-vous, entrez votre code de ticket et retirez votre lot en magasin ou en ligne. +

+
+ {!isLoggedIn && ( + + Inscrivez-vous + + )} + + Entrer mon code de ticket + +
+
+
+ +
+ ) +} diff --git a/frontend/src/pages/SaisirCodeUnique.jsx b/frontend/src/pages/SaisirCodeUnique.jsx new file mode 100644 index 0000000..07fcb32 --- /dev/null +++ b/frontend/src/pages/SaisirCodeUnique.jsx @@ -0,0 +1,207 @@ +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 [claim, setClaim] = 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) + setClaim(data.claim) + setWonAt(new Date()) + } catch (err) { + if (!handleAuthError(err)) { + navigate('/perdu', { state: { message: err.message } }) + } + } finally { + setLoading(false) + } + } + + const handleClose = () => { + setGain(null) + setClaim(null) + setWonAt(null) + setShowDetail(false) + setCode('') + } + + return ( +
+ + +
+

Saisir mon code gagnant

+

Saisissez le code présent sur votre ticket

+
+ +
+ 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-300 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" + /> + +
+ + {/* ── Overlay Félicitation ── */} + {gain && !showDetail && ( +
+
+

Félicitation !

+

Vous avez obtenu une récompense

+
+ +
+
+ {code} +
+
+ +
+ + + +
+ +
+

{gain.name}

+

{gain.description}

+ +
+
+ )} + + {/* ── Modal détail du lot ── */} + {gain && showDetail && ( +
+
+ + {/* En-tête coloré */} +
+ +
+
+ + + +
+
+

Votre lot

+

{gain.name}

+
+
+
+ + {/* Corps */} +
+ + {/* Description */} +

{gain.description}

+ + {/* Valeur */} + {gain.value && ( +
+ Valeur estimée + {gain.value} € +
+ )} + + {/* Code utilisé */} +
+ Code utilisé + {code} +
+ + {/* Date & heure */} + {wonAt && ( +
+ Obtenu le + {formatDateTime(wonAt)} +
+ )} + + {/* Statut */} +
+ Statut + + En attente de remise + +
+ + {/* Info remise */} +
+ + + +

+ Présentez ce code en boutique Thé Tip Top pour récupérer votre lot. +

+
+
+ + {/* Actions */} +
+ + +
+ +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/Sitemap.jsx b/frontend/src/pages/Sitemap.jsx new file mode 100644 index 0000000..88bfd18 --- /dev/null +++ b/frontend/src/pages/Sitemap.jsx @@ -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 ( +
+
+ +
+

+ Plan du site +

+

+ Retrouvez ici l'ensemble des pages disponibles sur le site Thé Tip Top. +

+
+ +
+ {visibleSections.map((section) => ( +
+

+ {section.title} +

+
    + {section.links.map((link) => ( +
  • + + + + +
    +

    + {link.label} +

    +

    {link.desc}

    +
    + +
  • + ))} +
+
+ ))} +
+ +
+
+ ) +} diff --git a/frontend/src/services/admin.js b/frontend/src/services/admin.js new file mode 100644 index 0000000..09f2da3 --- /dev/null +++ b/frontend/src/services/admin.js @@ -0,0 +1,41 @@ +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('/api/admin/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('/api/admin/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('/api/admin/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 getWinnerApi() { + const res = await fetch('/api/admin/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 +} diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js new file mode 100644 index 0000000..eb55d8a --- /dev/null +++ b/frontend/src/services/auth.js @@ -0,0 +1,66 @@ +const BASE = '/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 }) { + const res = await fetch(`${BASE}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, firstname, lastname, password }), + }) + 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 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 +} diff --git a/frontend/src/services/employee.js b/frontend/src/services/employee.js new file mode 100644 index 0000000..a127fb0 --- /dev/null +++ b/frontend/src/services/employee.js @@ -0,0 +1,31 @@ +const BASE = '/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 +} diff --git a/frontend/src/services/game.js b/frontend/src/services/game.js new file mode 100644 index 0000000..1312a56 --- /dev/null +++ b/frontend/src/services/game.js @@ -0,0 +1,30 @@ +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('/api/game/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('/api/game/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 +} diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js new file mode 100644 index 0000000..d0879b7 --- /dev/null +++ b/frontend/src/setupTests.js @@ -0,0 +1,8 @@ +import '@testing-library/jest-dom' +import { afterEach } from 'vitest' +import { cleanup } from '@testing-library/react' + +afterEach(() => { + cleanup() + localStorage.clear() +}) diff --git a/frontend/src/test/fonctionnel/Connexion.test.jsx b/frontend/src/test/fonctionnel/Connexion.test.jsx new file mode 100644 index 0000000..76234a3 --- /dev/null +++ b/frontend/src/test/fonctionnel/Connexion.test.jsx @@ -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(, { route: '/connexion' }) + + expect(screen.getByRole('heading', { name: /se connecter/i })).toBeInTheDocument() + expect(screen.getByPlaceholderText(/votre email/i)).toBeInTheDocument() + expect(screen.getByPlaceholderText(/votre 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(, { + 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(, { route: '/connexion' }) + + await userEvent.type(screen.getByPlaceholderText(/votre email/i), 'test@example.com') + await userEvent.type(screen.getByPlaceholderText(/votre 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(, { + route: '/connexion', + routes: { '/mes-gains':

Page Mes Gains

}, + }) + + await userEvent.type(screen.getByPlaceholderText(/votre email/i), 'client@example.com') + await userEvent.type(screen.getByPlaceholderText(/votre 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(, { + route: '/connexion', + routes: { '/admin':

Page Admin

}, + }) + + await userEvent.type(screen.getByPlaceholderText(/votre email/i), 'admin@example.com') + await userEvent.type(screen.getByPlaceholderText(/votre mot de passe/i), 'MotDePasse123!') + await userEvent.click(screen.getByRole('button', { name: /se connecter/i })) + + expect(await screen.findByText('Page Admin')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/fonctionnel/CookieBanner.test.jsx b/frontend/src/test/fonctionnel/CookieBanner.test.jsx new file mode 100644 index 0000000..2e4a139 --- /dev/null +++ b/frontend/src/test/fonctionnel/CookieBanner.test.jsx @@ -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() + + expect(screen.getByText(/ce site utilise des cookies/i)).toBeInTheDocument() + }) + + it('enregistre le consentement et disparaît quand on accepte', async () => { + renderWithProviders() + + 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() + + 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() + + expect(screen.queryByText(/ce site utilise des cookies/i)).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/fonctionnel/Inscription.test.jsx b/frontend/src/test/fonctionnel/Inscription.test.jsx new file mode 100644 index 0000000..efac812 --- /dev/null +++ b/frontend/src/test/fonctionnel/Inscription.test.jsx @@ -0,0 +1,120 @@ +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.getByPlaceholderText(/votre email/i), 'jean@example.com') + await userEvent.type(screen.getByPlaceholderText(/votre nom/i), 'Dupont') + await userEvent.type(screen.getByPlaceholderText(/votre prénom/i), 'Jean') + await userEvent.type(screen.getByPlaceholderText(/votre mot de passe/i), 'MotDePasse123!') + await userEvent.click(screen.getByRole('checkbox')) + 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(, { route: '/inscription' }) + + expect(screen.getByRole('heading', { name: /s'inscrire/i })).toBeInTheDocument() + expect(screen.getByPlaceholderText(/votre email/i)).toBeInTheDocument() + expect(screen.getByPlaceholderText(/votre nom/i)).toBeInTheDocument() + expect(screen.getByPlaceholderText(/votre prénom/i)).toBeInTheDocument() + expect(screen.getByPlaceholderText(/votre mot de passe/i)).toBeInTheDocument() + }) + + it("désactive le bouton tant que les conditions ne sont pas acceptées", async () => { + renderWithProviders(, { route: '/inscription' }) + + const bouton = screen.getByRole('button', { name: /s'inscrire/i }) + expect(bouton).toBeDisabled() + + await userEvent.click(screen.getByRole('checkbox')) + expect(bouton).toBeEnabled() + }) + + it("passe à l'étape de vérification après une inscription réussie", async () => { + registerApi.mockResolvedValue({ message: 'ok' }) + + renderWithProviders(, { route: '/inscription' }) + await remplirEtSoumettre() + + expect(registerApi).toHaveBeenCalledWith({ + email: 'jean@example.com', + firstname: 'Jean', + lastname: 'Dupont', + password: 'MotDePasse123!', + }) + expect(await screen.findByRole('heading', { name: /vérifiez votre email/i })).toBeInTheDocument() + expect(screen.getByText('jean@example.com')).toBeInTheDocument() + }) + + it("affiche l'erreur de l'API si l'inscription échoue", async () => { + registerApi.mockRejectedValue(new Error('Cet email est déjà utilisé.')) + + renderWithProviders(, { 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(, { + route: '/inscription', + routes: { '/saisir-code-unique':

Page Saisir Code

}, + }) + 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(, { 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('') + } + }) +}) diff --git a/frontend/src/test/fonctionnel/ProtectedRoute.test.jsx b/frontend/src/test/fonctionnel/ProtectedRoute.test.jsx new file mode 100644 index 0000000..b33fc34 --- /dev/null +++ b/frontend/src/test/fonctionnel/ProtectedRoute.test.jsx @@ -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( + +

Contenu protégé

+
, + { + route: '/mes-gains', + routes: { '/connexion':

Page Connexion

}, + } + ) + + expect(screen.getByText('Page Connexion')).toBeInTheDocument() + expect(screen.queryByText('Contenu protégé')).not.toBeInTheDocument() + }) + + it("affiche le contenu si l'utilisateur est connecté", () => { + renderWithProviders( + +

Contenu protégé

+
, + { + 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( + +

Espace admin

+
, + { + 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( + +

Espace admin

+
, + { + route: '/admin', + user: { id: 1, email: 'client@example.com', role: 'CLIENT' }, + routes: { '/mes-gains':

Page Mes Gains

}, + } + ) + + 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( + +

Espace admin

+
, + { + route: '/admin', + user: { id: 3, email: 'employe@example.com', role: 'EMPLOYEE' }, + routes: { '/employe':

Page Employé

}, + } + ) + + expect(screen.getByText('Page Employé')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/fonctionnel/SaisirCodeUnique.test.jsx b/frontend/src/test/fonctionnel/SaisirCodeUnique.test.jsx new file mode 100644 index 0000000..8b6c5fe --- /dev/null +++ b/frontend/src/test/fonctionnel/SaisirCodeUnique.test.jsx @@ -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(, { 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(, { 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(, { 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(, { + route: '/saisir-code-unique', + user: client, + routes: { '/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(, { + route: '/saisir-code-unique', + user: client, + routes: { '/connexion':

Page Connexion

}, + }) + await saisirEtValider('ABCDE12345') + + expect(await screen.findByText('Page Connexion')).toBeInTheDocument() + expect(localStorage.getItem('token')).toBeNull() + }) +}) diff --git a/frontend/src/test/renderWithProviders.jsx b/frontend/src/test/renderWithProviders.jsx new file mode 100644 index 0000000..a5a2668 --- /dev/null +++ b/frontend/src/test/renderWithProviders.jsx @@ -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':

Page Mes Gains

} +export function renderWithProviders(ui, { route = '/', user = null, routes = {} } = {}) { + if (user) { + localStorage.setItem('user', JSON.stringify(user)) + localStorage.setItem('token', 'fake-token') + } + + return render( + + + + + {Object.entries(routes).map(([path, element]) => ( + + ))} + + + + ) +} diff --git a/frontend/src/test/unit/AuthContext.test.jsx b/frontend/src/test/unit/AuthContext.test.jsx new file mode 100644 index 0000000..ff8f84f --- /dev/null +++ b/frontend/src/test/unit/AuthContext.test.jsx @@ -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 ( +
+

{user ? `Connecté : ${user.email}` : 'Déconnecté'}

+ + +
+ ) +} + +describe('AuthContext', () => { + it("n'a pas d'utilisateur par défaut", () => { + render( + + + + ) + 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( + + + + ) + expect(screen.getByText('Connecté : deja@la.fr')).toBeInTheDocument() + }) + + it('login met à jour le user et écrit token + user dans le localStorage', async () => { + render( + + + + ) + + 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( + + + + ) + + 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() + }) +}) diff --git a/frontend/src/test/unit/auth.service.test.js b/frontend/src/test/unit/auth.service.test.js new file mode 100644 index 0000000..561e909 --- /dev/null +++ b/frontend/src/test/unit/auth.service.test.js @@ -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' }), + }) + }) +}) diff --git a/frontend/src/test/unit/game.service.test.js b/frontend/src/test/unit/game.service.test.js new file mode 100644 index 0000000..3904603 --- /dev/null +++ b/frontend/src/test/unit/game.service.test.js @@ -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é.') + }) +}) diff --git a/frontend/src/test/unit/useHandleAuthError.test.jsx b/frontend/src/test/unit/useHandleAuthError.test.jsx new file mode 100644 index 0000000..cd240dc --- /dev/null +++ b/frontend/src/test/unit/useHandleAuthError.test.jsx @@ -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 ( +
+ + {handled !== null &&

géré : {String(handled)}

} +
+ ) +} + +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(, { + route: '/mes-gains', + user: { email: 'a@b.fr', role: 'CLIENT' }, + routes: { '/connexion':

Page Connexion

}, + }) + + 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(, { + route: '/mes-gains', + user: { email: 'a@b.fr', role: 'CLIENT' }, + routes: { '/connexion':

Page Connexion

}, + }) + + await userEvent.click(screen.getByRole('button', { name: 'déclencher' })) + + expect(screen.getByText('géré : false')).toBeInTheDocument() + expect(localStorage.getItem('token')).toBe('fake-token') + }) +}) diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 0616e59..8ba6a01 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -4,4 +4,14 @@ 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', + }, })