merge: integrate Emmanuel latest changes
This commit is contained in:
@@ -7,7 +7,6 @@ JWT_SECRET="your_secret_key"
|
|||||||
APP_URL="http://localhost:5000"
|
APP_URL="http://localhost:5000"
|
||||||
MAIL_HOST="smtp.gmail.com"
|
MAIL_HOST="smtp.gmail.com"
|
||||||
MAIL_PORT="587"
|
MAIL_PORT="587"
|
||||||
MAIL_USER="engongomo.emma27@gmail.com"
|
MAIL_USER="your_email@gmail.com"
|
||||||
MAIL_PASS="rczo zqps hsnm msdm"
|
MAIL_PASS="your_app_password"
|
||||||
MAIL_FROM="Thé Tip Top <engongomo.emma27@gmail.com>"
|
MAIL_FROM="Thé Tip Top <your_email@gmail.com>"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GameSettings" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"startDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "GameSettings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -116,3 +116,12 @@ model GrandPrizeWinner {
|
|||||||
prize String @default("Un an de thé d'une valeur de 360€")
|
prize String @default("Un an de thé d'une valeur de 360€")
|
||||||
drawnAt DateTime @default(now())
|
drawnAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ligne unique de configuration : date de début du jeu-concours.
|
||||||
|
// Participation et réclamation des lots sont autorisées pendant 60 jours
|
||||||
|
// (30 jours de jeu + 30 jours supplémentaires) à compter de startDate.
|
||||||
|
model GameSettings {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
startDate DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log("Lots créés avec succès");
|
console.log("Lots créés avec succès");
|
||||||
|
|
||||||
|
const existingSettings = await prisma.gameSettings.findFirst();
|
||||||
|
if (!existingSettings) {
|
||||||
|
await prisma.gameSettings.create({ data: {} });
|
||||||
|
console.log("Date de début du jeu-concours initialisée à aujourd'hui.");
|
||||||
|
} else {
|
||||||
|
console.log("Date de début du jeu-concours déjà définie, inchangée.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,8 +1,41 @@
|
|||||||
const bcrypt = require("bcrypt");
|
const bcrypt = require("bcrypt");
|
||||||
const prisma = require("../config/prisma");
|
const prisma = require("../config/prisma");
|
||||||
|
const { getGameWindow, expireOverdueClaims } = require("../utils/gameWindow");
|
||||||
|
|
||||||
|
const AGE_BRACKETS = ["-18", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"];
|
||||||
|
|
||||||
|
function getAgeBracket(birthdate) {
|
||||||
|
if (!birthdate) return "unknown";
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
let age = now.getFullYear() - birthdate.getFullYear();
|
||||||
|
const hasHadBirthdayThisYear =
|
||||||
|
now.getMonth() > birthdate.getMonth() ||
|
||||||
|
(now.getMonth() === birthdate.getMonth() && now.getDate() >= birthdate.getDate());
|
||||||
|
if (!hasHadBirthdayThisYear) age -= 1;
|
||||||
|
|
||||||
|
if (age < 18) return "-18";
|
||||||
|
if (age <= 24) return "18-24";
|
||||||
|
if (age <= 34) return "25-34";
|
||||||
|
if (age <= 44) return "35-44";
|
||||||
|
if (age <= 54) return "45-54";
|
||||||
|
if (age <= 64) return "55-64";
|
||||||
|
return "65+";
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeAgeStats(users) {
|
||||||
|
const counts = Object.fromEntries([...AGE_BRACKETS, "unknown"].map((b) => [b, 0]));
|
||||||
|
for (const user of users) {
|
||||||
|
counts[getAgeBracket(user.birthdate)] += 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
const getStats = async (req, res) => {
|
const getStats = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
await expireOverdueClaims();
|
||||||
|
const gameWindow = await getGameWindow();
|
||||||
|
|
||||||
const [
|
const [
|
||||||
ticketsTotal,
|
ticketsTotal,
|
||||||
ticketsUsed,
|
ticketsUsed,
|
||||||
@@ -13,6 +46,7 @@ const getStats = async (req, res) => {
|
|||||||
users,
|
users,
|
||||||
men,
|
men,
|
||||||
women,
|
women,
|
||||||
|
clientsWithBirthdate,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
prisma.ticket.count(),
|
prisma.ticket.count(),
|
||||||
prisma.ticket.count({ where: { isUsed: true } }),
|
prisma.ticket.count({ where: { isUsed: true } }),
|
||||||
@@ -23,6 +57,7 @@ const getStats = async (req, res) => {
|
|||||||
prisma.user.count({ where: { role: "CLIENT" } }),
|
prisma.user.count({ where: { role: "CLIENT" } }),
|
||||||
prisma.user.count({ where: { role: "CLIENT", gender: "Homme" } }),
|
prisma.user.count({ where: { role: "CLIENT", gender: "Homme" } }),
|
||||||
prisma.user.count({ where: { role: "CLIENT", gender: "Femme" } }),
|
prisma.user.count({ where: { role: "CLIENT", gender: "Femme" } }),
|
||||||
|
prisma.user.findMany({ where: { role: "CLIENT" }, select: { birthdate: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const lotsStats = await prisma.lot.findMany({
|
const lotsStats = await prisma.lot.findMany({
|
||||||
@@ -48,6 +83,8 @@ const getStats = async (req, res) => {
|
|||||||
claimsDelivered,
|
claimsDelivered,
|
||||||
claimsCancelled,
|
claimsCancelled,
|
||||||
users,
|
users,
|
||||||
|
gameWindow,
|
||||||
|
ageStats: computeAgeStats(clientsWithBirthdate),
|
||||||
genderStats: {
|
genderStats: {
|
||||||
men,
|
men,
|
||||||
women,
|
women,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
const prisma = require("../config/prisma");
|
const prisma = require("../config/prisma");
|
||||||
|
const { expireOverdueClaims } = require("../utils/gameWindow");
|
||||||
|
|
||||||
const getGainByTicketCode = async (req, res) => {
|
const getGainByTicketCode = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { code } = req.params;
|
const { code } = req.params;
|
||||||
|
|
||||||
|
await expireOverdueClaims();
|
||||||
|
|
||||||
const ticket = await prisma.ticket.findUnique({
|
const ticket = await prisma.ticket.findUnique({
|
||||||
where: { code: code.toUpperCase() },
|
where: { code: code.toUpperCase() },
|
||||||
include: {
|
include: {
|
||||||
@@ -66,6 +69,8 @@ const deliverClaim = async (req, res) => {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const employeeId = req.user.id;
|
const employeeId = req.user.id;
|
||||||
|
|
||||||
|
await expireOverdueClaims();
|
||||||
|
|
||||||
const claim = await prisma.claim.findUnique({
|
const claim = await prisma.claim.findUnique({
|
||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
const prisma = require("../config/prisma");
|
const prisma = require("../config/prisma");
|
||||||
|
const { getGameWindow, expireOverdueClaims } = require("../utils/gameWindow");
|
||||||
|
|
||||||
const participate = async (req, res) => {
|
const participate = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { code } = req.body;
|
const { code } = req.body;
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
const gameWindow = await getGameWindow();
|
||||||
|
if (!gameWindow.isOpen) {
|
||||||
|
return res.status(410).json({
|
||||||
|
message: "Le jeu-concours est terminé. Vous ne pouvez plus tester de code ni réclamer de lot.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const ticket = await prisma.ticket.findUnique({
|
const ticket = await prisma.ticket.findUnique({
|
||||||
where: { code },
|
where: { code },
|
||||||
include: {
|
include: {
|
||||||
@@ -83,6 +91,8 @@ const myGains = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
await expireOverdueClaims();
|
||||||
|
|
||||||
const participations = await prisma.participation.findMany({
|
const participations = await prisma.participation.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
51
backend/src/utils/gameWindow.js
Normal file
51
backend/src/utils/gameWindow.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
const prisma = require("../config/prisma");
|
||||||
|
|
||||||
|
const GAME_DURATION_DAYS = 30;
|
||||||
|
const CLAIM_EXTENSION_DAYS = 30;
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Fenêtre pendant laquelle un joueur peut à la fois tester son code et
|
||||||
|
// réclamer son lot : 30 jours de jeu + 30 jours supplémentaires à compter
|
||||||
|
// de startDate. Tant qu'aucune GameSettings n'existe, le jeu est considéré
|
||||||
|
// comme non démarré et donc ouvert (comportement de dev par défaut).
|
||||||
|
async function getGameWindow() {
|
||||||
|
const settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
return {
|
||||||
|
configured: false,
|
||||||
|
startDate: null,
|
||||||
|
participationDeadline: null,
|
||||||
|
claimDeadline: null,
|
||||||
|
isOpen: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const participationDeadline = new Date(settings.startDate.getTime() + GAME_DURATION_DAYS * DAY_MS);
|
||||||
|
const claimDeadline = new Date(
|
||||||
|
settings.startDate.getTime() + (GAME_DURATION_DAYS + CLAIM_EXTENSION_DAYS) * DAY_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
configured: true,
|
||||||
|
startDate: settings.startDate,
|
||||||
|
participationDeadline,
|
||||||
|
claimDeadline,
|
||||||
|
isOpen: new Date() <= claimDeadline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bascule en CANCELLED les gains PENDING non réclamés une fois la fenêtre
|
||||||
|
// fermée. Appelé au fil de l'eau (pas de cron) avant toute lecture/écriture
|
||||||
|
// de claims, ce qui suffit pour ce projet.
|
||||||
|
async function expireOverdueClaims() {
|
||||||
|
const window = await getGameWindow();
|
||||||
|
if (window.isOpen) return;
|
||||||
|
|
||||||
|
await prisma.claim.updateMany({
|
||||||
|
where: { status: "PENDING" },
|
||||||
|
data: { status: "CANCELLED" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getGameWindow, expireOverdueClaims };
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Participez au jeu-concours Thé Tip Top : saisissez le code de votre ticket et découvrez immédiatement votre lot." />
|
||||||
<title>Jeu concours - Thé Tip Top</title>
|
<title>Jeu concours - Thé Tip Top</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import Connexion from './pages/Connexion'
|
|||||||
import Inscription from './pages/Inscription'
|
import Inscription from './pages/Inscription'
|
||||||
import Accueil from './pages/Accueil'
|
import Accueil from './pages/Accueil'
|
||||||
import ReglesJeu from './pages/ReglesJeu'
|
import ReglesJeu from './pages/ReglesJeu'
|
||||||
import Participation from './pages/Participation'
|
|
||||||
import MesGains from './pages/MesGains'
|
import MesGains from './pages/MesGains'
|
||||||
import Employe from './pages/Employe'
|
import Employe from './pages/Employe'
|
||||||
import Admin from './pages/Admin'
|
import Admin from './pages/Admin'
|
||||||
@@ -34,7 +33,6 @@ export default function App() {
|
|||||||
{/* Pages publiques */}
|
{/* Pages publiques */}
|
||||||
<Route path="/accueil" element={<Layout><Accueil /></Layout>} />
|
<Route path="/accueil" element={<Layout><Accueil /></Layout>} />
|
||||||
<Route path="/regles-jeu" element={<Layout><ReglesJeu /></Layout>} />
|
<Route path="/regles-jeu" element={<Layout><ReglesJeu /></Layout>} />
|
||||||
<Route path="/participation" element={<Layout><Participation /></Layout>} />
|
|
||||||
<Route path="/mentions-legales" element={<Layout><MentionsLegales /></Layout>} />
|
<Route path="/mentions-legales" element={<Layout><MentionsLegales /></Layout>} />
|
||||||
<Route path="/confidentialite" element={<Layout><Confidentialite /></Layout>} />
|
<Route path="/confidentialite" element={<Layout><Confidentialite /></Layout>} />
|
||||||
<Route path="/accessibilite" element={<Layout><Accessibilite /></Layout>} />
|
<Route path="/accessibilite" element={<Layout><Accessibilite /></Layout>} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi } from '../services/admin'
|
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi } from '../services/admin'
|
||||||
import { searchTicketApi, deliverClaimApi } from '../services/employee'
|
import { searchTicketApi, deliverClaimApi } from '../services/employee'
|
||||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||||
|
|
||||||
@@ -40,6 +40,11 @@ export default function Admin() {
|
|||||||
const [ticketDeliverErr, setTicketDeliverErr] = useState('')
|
const [ticketDeliverErr, setTicketDeliverErr] = useState('')
|
||||||
const [ticketSuccess, setTicketSuccess] = useState(false)
|
const [ticketSuccess, setTicketSuccess] = useState(false)
|
||||||
|
|
||||||
|
// Contacts emailing
|
||||||
|
const [emailingContacts, setEmailingContacts] = useState(null)
|
||||||
|
const [emailingLoading, setEmailingLoading] = useState(false)
|
||||||
|
const [emailingError, setEmailingError] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
|
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
|
||||||
.then(([s, u, w]) => {
|
.then(([s, u, w]) => {
|
||||||
@@ -67,6 +72,37 @@ export default function Admin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLoadEmailingContacts = async () => {
|
||||||
|
setEmailingLoading(true)
|
||||||
|
setEmailingError('')
|
||||||
|
try {
|
||||||
|
const data = await getEmailingContactsApi()
|
||||||
|
setEmailingContacts(data.contacts)
|
||||||
|
} catch (e) {
|
||||||
|
if (!handleAuthError(e)) setEmailingError(e.message)
|
||||||
|
} finally {
|
||||||
|
setEmailingLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExportEmailingCsv = () => {
|
||||||
|
if (!emailingContacts?.length) return
|
||||||
|
const header = ['Prénom', 'Nom', 'Email', 'Inscrit le']
|
||||||
|
const rows = emailingContacts.map((c) => [
|
||||||
|
c.firstname, c.lastname, c.email, new Date(c.createdAt).toLocaleDateString('fr-FR'),
|
||||||
|
])
|
||||||
|
const csv = [header, ...rows]
|
||||||
|
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(';'))
|
||||||
|
.join('\n')
|
||||||
|
const blob = new Blob([`${csv}`], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `contacts-emailing-${new Date().toISOString().slice(0, 10)}.csv`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
const handleTicketSearch = async (e) => {
|
const handleTicketSearch = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setTicketResult(null)
|
setTicketResult(null)
|
||||||
@@ -127,6 +163,25 @@ export default function Admin() {
|
|||||||
|
|
||||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-800">Tableau de bord Admin</h1>
|
<h1 className="text-xl sm:text-2xl font-bold text-gray-800">Tableau de bord Admin</h1>
|
||||||
|
|
||||||
|
{/* Fenêtre du jeu-concours */}
|
||||||
|
{stats?.gameWindow?.configured && (
|
||||||
|
<div className={`rounded-xl shadow p-4 sm:p-6 ${stats.gameWindow.isOpen ? 'bg-white' : 'bg-red-50 border border-red-200'}`}>
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-800">
|
||||||
|
Jeu-concours {stats.gameWindow.isOpen ? 'en cours' : 'terminé'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Début : {formatDate(stats.gameWindow.startDate)} — Fin des participations et réclamations : {formatDate(stats.gameWindow.claimDeadline)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${stats.gameWindow.isOpen ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||||
|
{stats.gameWindow.isOpen ? 'Ouvert' : 'Fermé'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Stats principales */}
|
{/* Stats principales */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
|
||||||
<StatCard label="Participants" value={stats?.users} />
|
<StatCard label="Participants" value={stats?.users} />
|
||||||
@@ -142,6 +197,39 @@ export default function Admin() {
|
|||||||
<StatCard label="Tickets restants" value={stats?.ticketsRemaining} />
|
<StatCard label="Tickets restants" value={stats?.ticketsRemaining} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stats démographiques */}
|
||||||
|
{stats?.genderStats && stats?.ageStats && (
|
||||||
|
<div className="grid sm:grid-cols-2 gap-3 sm:gap-4">
|
||||||
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||||
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Genre des participants</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ label: 'Femmes', value: stats.genderStats.women },
|
||||||
|
{ label: 'Hommes', value: stats.genderStats.men },
|
||||||
|
{ label: 'Autre / non renseigné', value: stats.genderStats.other },
|
||||||
|
].map((row) => (
|
||||||
|
<div key={row.label} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500">{row.label}</span>
|
||||||
|
<span className="font-semibold text-gray-800">{row.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||||
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Âge des participants</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{['-18', '18-24', '25-34', '35-44', '45-54', '55-64', '65+', 'unknown'].map((bracket) => (
|
||||||
|
<div key={bracket} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500">{bracket === 'unknown' ? 'Non renseigné' : `${bracket} ans`}</span>
|
||||||
|
<span className="font-semibold text-gray-800">{stats.ageStats[bracket] ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Répartition par lot */}
|
{/* Répartition par lot */}
|
||||||
{stats?.lotsStats?.length > 0 && (
|
{stats?.lotsStats?.length > 0 && (
|
||||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||||
@@ -171,13 +259,80 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Contacts emailing ── */}
|
||||||
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||||
|
<h2 className="text-base sm:text-lg font-bold text-gray-800">Contacts emailing</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleLoadEmailingContacts}
|
||||||
|
disabled={emailingLoading}
|
||||||
|
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
{emailingLoading ? 'Chargement…' : emailingContacts ? 'Actualiser' : 'Charger les contacts'}
|
||||||
|
</button>
|
||||||
|
{emailingContacts?.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleExportEmailingCsv}
|
||||||
|
className="border border-gray-200 text-gray-600 hover:bg-gray-50 font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
Exporter en CSV
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{emailingError && <p className="text-red-500 text-sm mb-3">{emailingError}</p>}
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500 mb-3">
|
||||||
|
Clients ayant accepté d'être contactés à des fins marketing.
|
||||||
|
{emailingContacts && <> — <span className="font-semibold text-gray-800">{emailingContacts.length}</span> contact(s)</>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{emailingContacts && (
|
||||||
|
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||||
|
<table className="w-full text-sm min-w-[400px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Nom</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Email</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0 text-right">Inscrit le</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{emailingContacts.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="py-6 text-center text-gray-400 text-sm">
|
||||||
|
Aucun contact n'a accepté l'emailing pour le moment
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{emailingContacts.map((contact) => (
|
||||||
|
<tr key={contact.email} className="border-b last:border-0">
|
||||||
|
<td className="py-3 px-4 sm:px-0 font-medium text-gray-800 whitespace-nowrap text-xs sm:text-sm">
|
||||||
|
{contact.firstname} {contact.lastname}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">{contact.email}</td>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-right text-gray-500 text-xs">
|
||||||
|
{new Date(contact.createdAt).toLocaleDateString('fr-FR')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Remise de lot ── */}
|
{/* ── Remise de lot ── */}
|
||||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||||
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Remise de lot</h2>
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Remise de lot</h2>
|
||||||
|
|
||||||
<form onSubmit={handleTicketSearch} className="flex gap-2 mb-4">
|
<form onSubmit={handleTicketSearch} className="flex gap-2 mb-4">
|
||||||
|
<label htmlFor="admin-ticket-code" className="sr-only">Code ticket</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
id="admin-ticket-code"
|
||||||
value={ticketCode}
|
value={ticketCode}
|
||||||
onChange={(e) => setTicketCode(e.target.value.toUpperCase())}
|
onChange={(e) => setTicketCode(e.target.value.toUpperCase())}
|
||||||
placeholder="Code ticket (10 caractères)"
|
placeholder="Code ticket (10 caractères)"
|
||||||
|
|||||||
@@ -55,11 +55,12 @@ export default function Connexion() {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-800 mb-1">
|
<label htmlFor="email" className="block text-sm font-medium text-gray-800 mb-1">
|
||||||
Adresse email <span className="text-gray-800">*</span>
|
Adresse email <span className="text-gray-800">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
value={form.email}
|
value={form.email}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
@@ -70,12 +71,13 @@ export default function Connexion() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-800 mb-1">
|
<label htmlFor="password" className="block text-sm font-medium text-gray-800 mb-1">
|
||||||
Mot de passe <span className="text-gray-800">*</span>
|
Mot de passe <span className="text-gray-800">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
value={form.password}
|
value={form.password}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
|||||||
@@ -3,41 +3,150 @@ import imgContact from '../assets/img/img-contact.jpg'
|
|||||||
export default function Contact() {
|
export default function Contact() {
|
||||||
return (
|
return (
|
||||||
<main className="bg-[#F0F0F0] px-4 sm:px-6 py-10 sm:py-16">
|
<main className="bg-[#F0F0F0] px-4 sm:px-6 py-10 sm:py-16">
|
||||||
<div className="max-w-6xl mx-auto grid md:grid-cols-2 gap-6 sm:gap-8">
|
<div className="max-w-6xl mx-auto">
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6 sm:gap-8 mb-8">
|
||||||
|
|
||||||
{/* Informations */}
|
{/* Informations */}
|
||||||
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
Contact
|
Contact
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-gray-600 leading-relaxed mb-6 sm:mb-8 text-sm sm:text-base">
|
<p className="text-gray-600 leading-relaxed mb-6 sm:mb-8 text-sm sm:text-base">
|
||||||
Une question concernant le jeu-concours, votre participation ou votre lot ?
|
Une question concernant le jeu-concours, votre participation ou votre lot ?
|
||||||
Notre équipe ThéTipTop est à votre écoute.
|
Notre équipe ThéTipTop est à votre écoute.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-5 text-gray-700">
|
<div className="space-y-5 text-gray-700">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold text-[#006B6B] mb-1">Adresse</h2>
|
<h2 className="font-semibold text-[#006B6B] mb-1">
|
||||||
<p className="text-sm sm:text-base">ThéTipTop<br />18 rue Léon Frot<br />75011 Paris, France</p>
|
Adresse
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm sm:text-base">
|
||||||
|
ThéTipTop
|
||||||
|
<br />
|
||||||
|
18 rue Léon Frot
|
||||||
|
<br />
|
||||||
|
75011 Paris, France
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold text-[#006B6B] mb-1">Email</h2>
|
<h2 className="font-semibold text-[#006B6B] mb-1">
|
||||||
<a href="mailto:contact@thetiptop.fr" className="text-sm sm:text-base hover:text-[#006B6B]">contact@thetiptop.fr</a>
|
Email
|
||||||
|
</h2>
|
||||||
|
<a
|
||||||
|
href="mailto:contact@thetiptop.fr"
|
||||||
|
className="text-sm sm:text-base hover:text-[#006B6B]"
|
||||||
|
>
|
||||||
|
contact@thetiptop.fr
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold text-[#006B6B] mb-1">Téléphone</h2>
|
<h2 className="font-semibold text-[#006B6B] mb-1">
|
||||||
<a href="tel:+33184802034" className="text-sm sm:text-base hover:text-[#006B6B]">01 84 80 20 34</a>
|
Téléphone
|
||||||
|
</h2>
|
||||||
|
<a
|
||||||
|
href="tel:+33184802034"
|
||||||
|
className="text-sm sm:text-base hover:text-[#006B6B]"
|
||||||
|
>
|
||||||
|
01 84 80 20 34
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
<section className="rounded-2xl shadow overflow-hidden h-64 sm:h-80 md:h-117 md:self-start">
|
<section className="rounded-2xl shadow overflow-hidden min-h-80">
|
||||||
<img
|
<img
|
||||||
src={imgContact}
|
src={imgContact}
|
||||||
alt="Une personne savourant un thé"
|
alt="Une personne savourant un thé"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formulaire Emmanuel intégré au design Alexis */}
|
||||||
|
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10 max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
Envoyez-nous un message
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form className="space-y-4 sm:space-y-5">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="contact-name"
|
||||||
|
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Nom
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="contact-name"
|
||||||
|
placeholder="Votre nom"
|
||||||
|
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="contact-email"
|
||||||
|
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="contact-email"
|
||||||
|
placeholder="Votre email"
|
||||||
|
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="contact-subject"
|
||||||
|
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Sujet
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="contact-subject"
|
||||||
|
placeholder="Sujet de votre demande"
|
||||||
|
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="contact-message"
|
||||||
|
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Message
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
id="contact-message"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Votre message..."
|
||||||
|
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005858] text-white font-semibold py-2.5 sm:py-3 rounded-xl transition text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Envoyer le message
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -85,8 +85,10 @@ export default function Employe() {
|
|||||||
|
|
||||||
{/* Formulaire de recherche */}
|
{/* Formulaire de recherche */}
|
||||||
<form onSubmit={handleSearch} className="flex gap-2 mb-6">
|
<form onSubmit={handleSearch} className="flex gap-2 mb-6">
|
||||||
|
<label htmlFor="employee-ticket-code" className="sr-only">Code ticket</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
id="employee-ticket-code"
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||||
required
|
required
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export default function Inscription() {
|
|||||||
value={d}
|
value={d}
|
||||||
onChange={(e) => handleDigit(i, e.target.value)}
|
onChange={(e) => handleDigit(i, e.target.value)}
|
||||||
onKeyDown={(e) => handleDigitKeyDown(i, e)}
|
onKeyDown={(e) => handleDigitKeyDown(i, e)}
|
||||||
|
aria-label={`Chiffre ${i + 1} du code de vérification`}
|
||||||
className="w-14 h-14 text-center text-2xl font-bold border-2 border-gray-200 rounded-xl focus:outline-none focus:border-[#006B6B] focus:ring-1 focus:ring-[#006B6B] transition-colors"
|
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"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -158,27 +159,27 @@ export default function Inscription() {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-800 mb-1">Adresse email <span className="text-gray-800">*</span></label>
|
<label htmlFor="reg-email" className="block text-sm font-medium text-gray-800 mb-1">Adresse email <span className="text-gray-800">*</span></label>
|
||||||
<input type="email" name="email" value={form.email} onChange={handleChange} required placeholder="Votre email"
|
<input type="email" id="reg-email" name="email" value={form.email} onChange={handleChange} required placeholder="Votre email"
|
||||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-800 mb-1">Nom <span className="text-gray-800">*</span></label>
|
<label htmlFor="reg-lastName" className="block text-sm font-medium text-gray-800 mb-1">Nom <span className="text-gray-800">*</span></label>
|
||||||
<input type="text" name="lastName" value={form.lastName} onChange={handleChange} required placeholder="Votre nom"
|
<input type="text" id="reg-lastName" name="lastName" value={form.lastName} onChange={handleChange} required placeholder="Votre nom"
|
||||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-800 mb-1">Prénom <span className="text-gray-800">*</span></label>
|
<label htmlFor="reg-firstName" className="block text-sm font-medium text-gray-800 mb-1">Prénom <span className="text-gray-800">*</span></label>
|
||||||
<input type="text" name="firstName" value={form.firstName} onChange={handleChange} required placeholder="Votre prénom"
|
<input type="text" id="reg-firstName" name="firstName" value={form.firstName} onChange={handleChange} required placeholder="Votre prénom"
|
||||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label>
|
<label htmlFor="reg-password" className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input type={showPassword ? 'text' : 'password'} name="password" value={form.password} onChange={handleChange} required placeholder="Votre mot de passe"
|
<input type={showPassword ? 'text' : 'password'} id="reg-password" name="password" value={form.password} onChange={handleChange} required placeholder="Votre mot de passe"
|
||||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||||
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" aria-label="Afficher/masquer">
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" aria-label="Afficher/masquer">
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
|
|
||||||
export default function Participation() {
|
|
||||||
const [code, setCode] = useState('')
|
|
||||||
const [result, setResult] = useState(null)
|
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
// TODO: connecter au backend
|
|
||||||
setResult({
|
|
||||||
lot: 'Infusion découverte (30g)',
|
|
||||||
description: 'Un assortiment de nos meilleures infusions',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-green-50 flex items-center justify-center p-4">
|
|
||||||
<div className="bg-white rounded-2xl shadow-lg w-full max-w-md p-8">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-green-800">Thé Tip Top</h1>
|
|
||||||
<p className="text-gray-500 mt-1">Participer au jeu-concours</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Code ticket
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => setCode(e.target.value)}
|
|
||||||
required
|
|
||||||
placeholder="Ex: TTT-XXXXX"
|
|
||||||
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500 font-mono tracking-widest"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full bg-green-700 hover:bg-green-800 text-white font-semibold py-2.5 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Valider mon ticket
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{result && (
|
|
||||||
<div className="mt-6 bg-green-100 border border-green-300 rounded-xl p-5 text-center">
|
|
||||||
<p className="text-green-800 font-bold text-lg">Félicitations !</p>
|
|
||||||
<p className="text-green-700 font-medium mt-1">{result.lot}</p>
|
|
||||||
<p className="text-gray-600 text-sm mt-1">{result.description}</p>
|
|
||||||
<Link to="/mes-gains" className="inline-block mt-4 text-sm text-green-700 font-medium hover:underline">
|
|
||||||
Voir tous mes gains →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -121,10 +121,11 @@ export default function Profile() {
|
|||||||
{ name: 'confirmPassword', label: 'Confirmer le nouveau mot de passe', key: 'confirm' },
|
{ name: 'confirmPassword', label: 'Confirmer le nouveau mot de passe', key: 'confirm' },
|
||||||
].map(({ name, label, key }) => (
|
].map(({ name, label, key }) => (
|
||||||
<div key={name}>
|
<div key={name}>
|
||||||
<label className="block text-sm font-medium text-gray-800 mb-1">{label}</label>
|
<label htmlFor={name} className="block text-sm font-medium text-gray-800 mb-1">{label}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type={showPw[key] ? 'text' : 'password'}
|
type={showPw[key] ? 'text' : 'password'}
|
||||||
|
id={name}
|
||||||
name={name}
|
name={name}
|
||||||
value={pwForm[name]}
|
value={pwForm[name]}
|
||||||
onChange={handlePwChange}
|
onChange={handlePwChange}
|
||||||
|
|||||||
@@ -56,8 +56,10 @@ export default function SaisirCodeUnique() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<label htmlFor="ticket-code" className="sr-only">Code de votre ticket</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
id="ticket-code"
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||||
placeholder="Votre code"
|
placeholder="Votre code"
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ export async function drawWinnerApi() {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getEmailingContactsApi() {
|
||||||
|
const res = await fetch('/api/admin/emailing', { headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur contacts emailing.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export async function getWinnerApi() {
|
export async function getWinnerApi() {
|
||||||
const res = await fetch('/api/admin/draw', { headers: authHeaders() })
|
const res = await fetch('/api/admin/draw', { headers: authHeaders() })
|
||||||
if (res.status === 404) return null
|
if (res.status === 404) return null
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ describe('Connexion', () => {
|
|||||||
renderWithProviders(<Connexion />, { route: '/connexion' })
|
renderWithProviders(<Connexion />, { route: '/connexion' })
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: /se connecter/i })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { name: /se connecter/i })).toBeInTheDocument()
|
||||||
expect(screen.getByPlaceholderText(/votre email/i)).toBeInTheDocument()
|
expect(screen.getByLabelText(/adresse email/i)).toBeInTheDocument()
|
||||||
expect(screen.getByPlaceholderText(/votre mot de passe/i)).toBeInTheDocument()
|
expect(screen.getByLabelText(/^mot de passe/i)).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: /se connecter/i })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: /se connecter/i })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -37,8 +37,8 @@ describe('Connexion', () => {
|
|||||||
|
|
||||||
renderWithProviders(<Connexion />, { route: '/connexion' })
|
renderWithProviders(<Connexion />, { route: '/connexion' })
|
||||||
|
|
||||||
await userEvent.type(screen.getByPlaceholderText(/votre email/i), 'test@example.com')
|
await userEvent.type(screen.getByLabelText(/adresse email/i), 'test@example.com')
|
||||||
await userEvent.type(screen.getByPlaceholderText(/votre mot de passe/i), 'mauvais-mdp')
|
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'mauvais-mdp')
|
||||||
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
|
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
|
||||||
|
|
||||||
expect(await screen.findByText('Email ou mot de passe incorrect.')).toBeInTheDocument()
|
expect(await screen.findByText('Email ou mot de passe incorrect.')).toBeInTheDocument()
|
||||||
@@ -56,8 +56,8 @@ describe('Connexion', () => {
|
|||||||
routes: { '/mes-gains': <p>Page Mes Gains</p> },
|
routes: { '/mes-gains': <p>Page Mes Gains</p> },
|
||||||
})
|
})
|
||||||
|
|
||||||
await userEvent.type(screen.getByPlaceholderText(/votre email/i), 'client@example.com')
|
await userEvent.type(screen.getByLabelText(/adresse email/i), 'client@example.com')
|
||||||
await userEvent.type(screen.getByPlaceholderText(/votre mot de passe/i), 'MotDePasse123!')
|
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
|
||||||
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
|
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
|
||||||
|
|
||||||
expect(await screen.findByText('Page Mes Gains')).toBeInTheDocument()
|
expect(await screen.findByText('Page Mes Gains')).toBeInTheDocument()
|
||||||
@@ -75,8 +75,8 @@ describe('Connexion', () => {
|
|||||||
routes: { '/admin': <p>Page Admin</p> },
|
routes: { '/admin': <p>Page Admin</p> },
|
||||||
})
|
})
|
||||||
|
|
||||||
await userEvent.type(screen.getByPlaceholderText(/votre email/i), 'admin@example.com')
|
await userEvent.type(screen.getByLabelText(/adresse email/i), 'admin@example.com')
|
||||||
await userEvent.type(screen.getByPlaceholderText(/votre mot de passe/i), 'MotDePasse123!')
|
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
|
||||||
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
|
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
|
||||||
|
|
||||||
expect(await screen.findByText('Page Admin')).toBeInTheDocument()
|
expect(await screen.findByText('Page Admin')).toBeInTheDocument()
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ vi.mock('../../services/auth', () => ({
|
|||||||
|
|
||||||
// Remplit et soumet le formulaire d'inscription (étape 1).
|
// Remplit et soumet le formulaire d'inscription (étape 1).
|
||||||
async function remplirEtSoumettre() {
|
async function remplirEtSoumettre() {
|
||||||
await userEvent.type(screen.getByPlaceholderText(/votre email/i), 'jean@example.com')
|
await userEvent.type(screen.getByLabelText(/adresse email/i), 'jean@example.com')
|
||||||
await userEvent.type(screen.getByPlaceholderText(/votre nom/i), 'Dupont')
|
await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont')
|
||||||
await userEvent.type(screen.getByPlaceholderText(/votre prénom/i), 'Jean')
|
await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean')
|
||||||
await userEvent.type(screen.getByPlaceholderText(/votre mot de passe/i), 'MotDePasse123!')
|
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
|
||||||
await userEvent.click(screen.getByRole('checkbox'))
|
await userEvent.click(screen.getByRole('checkbox'))
|
||||||
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i }))
|
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i }))
|
||||||
}
|
}
|
||||||
@@ -37,10 +37,10 @@ describe('Inscription', () => {
|
|||||||
renderWithProviders(<Inscription />, { route: '/inscription' })
|
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: /s'inscrire/i })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { name: /s'inscrire/i })).toBeInTheDocument()
|
||||||
expect(screen.getByPlaceholderText(/votre email/i)).toBeInTheDocument()
|
expect(screen.getByLabelText(/adresse email/i)).toBeInTheDocument()
|
||||||
expect(screen.getByPlaceholderText(/votre nom/i)).toBeInTheDocument()
|
expect(screen.getByLabelText(/^nom/i)).toBeInTheDocument()
|
||||||
expect(screen.getByPlaceholderText(/votre prénom/i)).toBeInTheDocument()
|
expect(screen.getByLabelText(/prénom/i)).toBeInTheDocument()
|
||||||
expect(screen.getByPlaceholderText(/votre mot de passe/i)).toBeInTheDocument()
|
expect(screen.getByLabelText(/^mot de passe/i)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("désactive le bouton tant que les conditions ne sont pas acceptées", async () => {
|
it("désactive le bouton tant que les conditions ne sont pas acceptées", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user