Merge remote-tracking branch 'origin/Alexis' into Emmanuel

Récupère la FAQ de la page d'accueil et la refonte de la page Contact, ainsi
que la chaîne CI/CD et l'infrastructure VPS HTTPS venues de main.

Résolution des 13 conflits :

- Footer : les deux corrections sont conservées. FooterLink sort du composant
  (Alexis), la grille passe en une colonne sous 640 px et l'adresse email peut
  se couper (audit responsive).
- Admin : le BOM du CSV adopte la séquence échappée d'Alexis, plus lisible que
  le caractère invisible et sans règle de lint à désactiver.
- Contact : version d'Alexis retenue, plus complète, avec son formulaire.
- Connexion, Inscription : logos SVG conservés dans leur version nettoyée,
  avec aria-hidden ; les gestionnaires OAuth et les champs d'inscription
  restent en place.
- UserNav, Profile, services/admin : versions locales conservées.
- docker-compose : structure Traefik et domaines HTTPS d'Alexis, sur lesquels
  sont regreffés FRONTEND_URL et les identifiants OAuth par substitution.
- Les deux package-lock régénérés depuis les package.json fusionnés.

Aligne au passage les derniers chemins d'API restés relatifs sur VITE_API_URL,
sans quoi le bouton Google et la configuration du jeu visaient le domaine du
front en déploiement.

Fiabilise trois tests de tirage au sort : la table GrandPrizeWinner est unique
pour tout le jeu et le vivier contient de vrais comptes, donc les suites
mettent désormais un éventuel tirage réel de côté et n'attendent plus de
connaître le gagnant à l'avance.
This commit is contained in:
Emma781227
2026-08-27 11:02:39 +02:00
24 changed files with 1108 additions and 561 deletions

70
Jenkinsfile vendored
View File

@@ -8,8 +8,7 @@ pipeline {
} }
environment { environment {
REGISTRY = "localhost:5000" REGISTRY = "registry.wk-archi-o24a-4-5-g4.fr"
SERVER_IP = "192.168.1.95"
BACKEND_IMAGE = "${REGISTRY}/the-tip-top/backend" BACKEND_IMAGE = "${REGISTRY}/the-tip-top/backend"
FRONTEND_IMAGE = "${REGISTRY}/the-tip-top/frontend" FRONTEND_IMAGE = "${REGISTRY}/the-tip-top/frontend"
@@ -49,7 +48,7 @@ pipeline {
env.COMPOSE_PROJECT = 'tiptop-dev' env.COMPOSE_PROJECT = 'tiptop-dev'
env.DEPLOY_NETWORK = 'tiptop-dev' env.DEPLOY_NETWORK = 'tiptop-dev'
env.FRONTEND_API_URL = "http://${SERVER_IP}:5001" env.FRONTEND_API_URL = "https://api-dev.dsp5-archi-o24a-4-5-g4.fr"
} else if (env.BRANCH_NAME.startsWith('release-')) { } else if (env.BRANCH_NAME.startsWith('release-')) {
env.DEPLOY_ENABLED = 'true' env.DEPLOY_ENABLED = 'true'
@@ -59,7 +58,7 @@ pipeline {
env.COMPOSE_PROJECT = 'tiptop-preprod' env.COMPOSE_PROJECT = 'tiptop-preprod'
env.DEPLOY_NETWORK = 'tiptop-preprod' env.DEPLOY_NETWORK = 'tiptop-preprod'
env.FRONTEND_API_URL = "http://${SERVER_IP}:5002" env.FRONTEND_API_URL = "https://api-preprod.dsp5-archi-o24a-4-5-g4.fr"
} else if (env.BRANCH_NAME == 'main') { } else if (env.BRANCH_NAME == 'main') {
env.DEPLOY_ENABLED = 'true' env.DEPLOY_ENABLED = 'true'
@@ -69,7 +68,7 @@ pipeline {
env.COMPOSE_PROJECT = 'tiptop-prod' env.COMPOSE_PROJECT = 'tiptop-prod'
env.DEPLOY_NETWORK = 'tiptop-prod' env.DEPLOY_NETWORK = 'tiptop-prod'
env.FRONTEND_API_URL = "http://${SERVER_IP}:5003" env.FRONTEND_API_URL = "https://api.dsp5-archi-o24a-4-5-g4.fr"
} else { } else {
env.DEPLOY_ENABLED = 'false' env.DEPLOY_ENABLED = 'false'
@@ -85,7 +84,6 @@ pipeline {
echo "Tag Docker : ${env.IMAGE_TAG}" echo "Tag Docker : ${env.IMAGE_TAG}"
echo "Environnement : ${env.DEPLOY_ENV}" echo "Environnement : ${env.DEPLOY_ENV}"
echo "Déploiement activé : ${env.DEPLOY_ENABLED}" echo "Déploiement activé : ${env.DEPLOY_ENABLED}"
echo "Serveur : ${env.SERVER_IP}"
echo "API frontend : ${env.FRONTEND_API_URL}" echo "API frontend : ${env.FRONTEND_API_URL}"
} }
} }
@@ -139,6 +137,10 @@ pipeline {
sleep 2 sleep 2
done done
docker run --rm \
-v tiptop-npm-cache-backend:/npm-cache \
alpine \
sh -c 'chown -R 1000:1000 /npm-cache'
docker run --rm \ docker run --rm \
--network "${CI_NETWORK}" \ --network "${CI_NETWORK}" \
@@ -186,6 +188,11 @@ pipeline {
steps { steps {
sh ''' sh '''
docker run --rm \ docker run --rm \
-v tiptop-npm-cache-frontend:/npm-cache \
alpine \
sh -c 'chown -R 1000:1000 /npm-cache'
docker run --rm \
--volumes-from furious-jenkins \ --volumes-from furious-jenkins \
-v tiptop-npm-cache-frontend:/npm-cache \ -v tiptop-npm-cache-frontend:/npm-cache \
--user "$(id -u):$(id -g)" \ --user "$(id -u):$(id -g)" \
@@ -229,19 +236,33 @@ pipeline {
} }
stage('Publication dans le Registry') { stage('Publication dans le Registry') {
when { when {
expression { expression {
env.DEPLOY_ENABLED == 'true' env.DEPLOY_ENABLED == 'true'
}
}
steps {
sh '''
docker push "${BACKEND_IMAGE}:${IMAGE_TAG}"
docker push "${FRONTEND_IMAGE}:${IMAGE_TAG}"
'''
}
} }
}
steps {
withCredentials([
usernamePassword(
credentialsId: 'registry-credentials',
usernameVariable: 'REGISTRY_USER',
passwordVariable: 'REGISTRY_PASSWORD'
)
]) {
sh '''
echo "${REGISTRY_PASSWORD}" | \
docker login "${REGISTRY}" \
-u "${REGISTRY_USER}" \
--password-stdin
docker push "${BACKEND_IMAGE}:${IMAGE_TAG}"
docker push "${FRONTEND_IMAGE}:${IMAGE_TAG}"
'''
}
}
}
stage('Validation production') { stage('Validation production') {
when { when {
@@ -333,12 +354,13 @@ pipeline {
} }
post { post {
always { always {
sh ''' sh '''
docker rm -f "${CI_DATABASE}" 2>/dev/null || true docker logout "${REGISTRY}" 2>/dev/null || true
docker network rm "${CI_NETWORK}" 2>/dev/null || true docker rm -f "${CI_DATABASE}" 2>/dev/null || true
''' docker network rm "${CI_NETWORK}" 2>/dev/null || true
} '''
}
success { success {
echo """ echo """

View File

@@ -5518,9 +5518,9 @@
} }
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "9.0.5", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==", "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"

View File

@@ -10,7 +10,7 @@ const options = {
}, },
servers: [ servers: [
{ {
url: "http://localhost:5000", url: process.env.API_PUBLIC_URL || "http://localhost:5000",
}, },
], ],
components: { components: {
@@ -26,4 +26,4 @@ const options = {
apis: ["./src/routes/*.js"], apis: ["./src/routes/*.js"],
}; };
module.exports = swaggerJsdoc(options); module.exports = swaggerJsdoc(options);

View File

@@ -30,7 +30,30 @@ const jourJ = (d) =>
data: { startDate: new Date(Date.now() - d * DAY) }, data: { startDate: new Date(Date.now() - d * DAY) },
}); });
// Un tirage reel peut exister en base : la table GrandPrizeWinner est unique
// pour tout le jeu. On la met de cote le temps du test, puis on la restaure.
async function mettreDeCoteLeGagnant() {
const existant = await prisma.grandPrizeWinner.findFirst();
if (existant) await prisma.grandPrizeWinner.delete({ where: { id: existant.id } });
return existant;
}
async function restaurerLeGagnant(sauvegarde) {
if (!sauvegarde) return;
await prisma.grandPrizeWinner.deleteMany({});
await prisma.grandPrizeWinner.create({
data: {
id: sauvegarde.id,
userId: sauvegarde.userId,
prize: sauvegarde.prize,
drawnAt: sauvegarde.drawnAt,
},
});
}
beforeAll(async () => { beforeAll(async () => {
ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant();
await cleanup(); await cleanup();
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
ctx.settingsId = row.id; ctx.settingsId = row.id;
@@ -51,6 +74,7 @@ beforeAll(async () => {
}); });
afterAll(async () => { afterAll(async () => {
await restaurerLeGagnant(ctx.gagnantSauvegarde);
await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } }); await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } });
await cleanup(); await cleanup();
await prisma.$disconnect(); await prisma.$disconnect();
@@ -67,13 +91,16 @@ describe("Notification du grand gagnant", () => {
expect(res.body.emailSent).toBe(true); expect(res.body.emailSent).toBe(true);
expect(mailer.sendMail).toHaveBeenCalledTimes(1); expect(mailer.sendMail).toHaveBeenCalledTimes(1);
// Le tirage est aleatoire parmi tous les participants : on verifie que
// l email part bien au gagnant annonce, quel qu il soit.
const gagnant = res.body.winner.user;
const envoi = mailer.sendMail.mock.calls[0][0]; const envoi = mailer.sendMail.mock.calls[0][0];
expect(envoi.to).toBe(ctx.client.email); expect(envoi.to).toBe(gagnant.email);
expect(envoi.subject).toMatch(/gros lot/i); expect(envoi.subject).toMatch(/gros lot/i);
expect(envoi.html).toContain("Chloe"); expect(envoi.html).toContain(gagnant.firstname);
expect(envoi.html).toContain("360"); expect(envoi.html).toContain("360");
await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } }); await prisma.grandPrizeWinner.deleteMany({});
}); });
test("un SMTP en panne n invalide pas le tirage", async () => { test("un SMTP en panne n invalide pas le tirage", async () => {
@@ -86,10 +113,11 @@ describe("Notification du grand gagnant", () => {
expect(res.body.message).toMatch(/contacter directement/); expect(res.body.message).toMatch(/contacter directement/);
// Le tirage reste enregistre : c est un acte irreversible. // Le tirage reste enregistre : c est un acte irreversible.
const enBase = await prisma.grandPrizeWinner.findFirst({ where: { userId: ctx.client.id } }); const enBase = await prisma.grandPrizeWinner.findFirst();
expect(enBase).not.toBeNull(); expect(enBase).not.toBeNull();
expect(enBase.userId).toBe(res.body.winner.user.id);
await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } }); await prisma.grandPrizeWinner.deleteMany({});
}); });
test("aucun email n est envoye si le tirage est refuse", async () => { test("aucun email n est envoye si le tirage est refuse", async () => {

View File

@@ -26,7 +26,30 @@ async function cleanup() {
const setStart = (d) => const setStart = (d) =>
prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: new Date(Date.now() - d * DAY) } }); prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: new Date(Date.now() - d * DAY) } });
// Un tirage reel peut exister en base : la table GrandPrizeWinner est unique
// pour tout le jeu. On la met de cote le temps du test, puis on la restaure.
async function mettreDeCoteLeGagnant() {
const existant = await prisma.grandPrizeWinner.findFirst();
if (existant) await prisma.grandPrizeWinner.delete({ where: { id: existant.id } });
return existant;
}
async function restaurerLeGagnant(sauvegarde) {
if (!sauvegarde) return;
await prisma.grandPrizeWinner.deleteMany({});
await prisma.grandPrizeWinner.create({
data: {
id: sauvegarde.id,
userId: sauvegarde.userId,
prize: sauvegarde.prize,
drawnAt: sauvegarde.drawnAt,
},
});
}
beforeAll(async () => { beforeAll(async () => {
ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant();
await cleanup(); await cleanup();
ctx.settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); ctx.settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
ctx.orig = ctx.settings.startDate; ctx.orig = ctx.settings.startDate;
@@ -46,6 +69,7 @@ beforeAll(async () => {
}); });
afterAll(async () => { afterAll(async () => {
await restaurerLeGagnant(ctx.gagnantSauvegarde);
await prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: ctx.orig } }); await prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: ctx.orig } });
await cleanup(); await cleanup();
await prisma.$disconnect(); await prisma.$disconnect();

View File

@@ -33,7 +33,25 @@ async function jourJ(offsetJours) {
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "2h" }); const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "2h" });
// La table GrandPrizeWinner est unique pour tout le jeu : on met de cote un
// eventuel tirage reel le temps du test, puis on le restaure.
async function mettreDeCoteLeGagnant() {
const existant = await prisma.grandPrizeWinner.findFirst();
if (existant) await prisma.grandPrizeWinner.delete({ where: { id: existant.id } });
return existant;
}
async function restaurerLeGagnant(sauvegarde) {
if (!sauvegarde) return;
await prisma.grandPrizeWinner.deleteMany({});
await prisma.grandPrizeWinner.create({
data: { id: sauvegarde.id, userId: sauvegarde.userId, prize: sauvegarde.prize, drawnAt: sauvegarde.drawnAt },
});
}
beforeAll(async () => { beforeAll(async () => {
ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant();
await cleanup(); await cleanup();
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
ctx.startDateOriginale = row ? row.startDate : null; ctx.startDateOriginale = row ? row.startDate : null;
@@ -49,6 +67,7 @@ beforeAll(async () => {
}); });
afterAll(async () => { afterAll(async () => {
await restaurerLeGagnant(ctx.gagnantSauvegarde);
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
if (ctx.startDateOriginale) { if (ctx.startDateOriginale) {
if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: ctx.startDateOriginale } }); if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: ctx.startDateOriginale } });

View File

@@ -9,7 +9,7 @@ services:
POSTGRES_DB: the_tip_top_db POSTGRES_DB: the_tip_top_db
volumes: volumes:
- tiptop_postgres_dev_data:/var/lib/postgresql/data - tiptop_dev_postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U tiptop -d the_tip_top_db"] test: ["CMD-SHELL", "pg_isready -U tiptop -d the_tip_top_db"]
@@ -24,21 +24,21 @@ services:
image: ${REGISTRY}/the-tip-top/backend:${IMAGE_TAG} image: ${REGISTRY}/the-tip-top/backend:${IMAGE_TAG}
restart: unless-stopped restart: unless-stopped
env_file:
- /opt/furious-ducks/secrets/tiptop-mail.env
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
environment: environment:
PORT: 5000 PORT: 5000
NODE_ENV: development
DATABASE_URL: postgresql://tiptop:tiptop_dev_password@postgres:5432/the_tip_top_db DATABASE_URL: postgresql://tiptop:tiptop_dev_password@postgres:5432/the_tip_top_db
JWT_SECRET: dev_secret_change_me JWT_SECRET: dev_secret_change_me
APP_URL: http://192.168.1.23:5001 APP_URL: https://api-dev.dsp5-archi-o24a-4-5-g4.fr
FRONTEND_URL: http://192.168.1.23:5173 API_PUBLIC_URL: https://api-dev.dsp5-archi-o24a-4-5-g4.fr
MAIL_HOST: smtp.gmail.com FRONTEND_URL: https://dev.dsp5-archi-o24a-4-5-g4.fr
MAIL_PORT: 587
MAIL_USER: ""
MAIL_PASS: ""
MAIL_FROM: "The Tip Top <no-reply@tiptop.local>"
# Injectés par le pipeline, jamais commités. Vides, les routes de # Injectés par le pipeline, jamais commités. Vides, les routes de
# connexion externe répondent 503 sans bloquer le démarrage. # connexion externe répondent 503 sans bloquer le démarrage.
@@ -54,11 +54,18 @@ services:
npm start npm start
" "
ports:
- "5001:5000"
networks: networks:
- tiptop-dev - tiptop-dev
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.tiptop-dev-api.rule=Host(`api-dev.dsp5-archi-o24a-4-5-g4.fr`)"
- "traefik.http.routers.tiptop-dev-api.entrypoints=websecure"
- "traefik.http.routers.tiptop-dev-api.tls=true"
- "traefik.http.routers.tiptop-dev-api.tls.certresolver=letsencrypt"
- "traefik.http.services.tiptop-dev-api.loadbalancer.server.port=5000"
frontend: frontend:
image: ${REGISTRY}/the-tip-top/frontend:${IMAGE_TAG} image: ${REGISTRY}/the-tip-top/frontend:${IMAGE_TAG}
@@ -67,17 +74,26 @@ services:
depends_on: depends_on:
- backend - backend
ports:
- "5173:80"
networks: networks:
- tiptop-dev - tiptop-dev
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.tiptop-dev.rule=Host(`dev.dsp5-archi-o24a-4-5-g4.fr`)"
- "traefik.http.routers.tiptop-dev.entrypoints=websecure"
- "traefik.http.routers.tiptop-dev.tls=true"
- "traefik.http.routers.tiptop-dev.tls.certresolver=letsencrypt"
- "traefik.http.services.tiptop-dev.loadbalancer.server.port=80"
volumes: volumes:
tiptop_postgres_dev_data: tiptop_dev_postgres_data:
external: true name: tiptop_dev_postgres_data
name: the-tip-top_tiptop_postgres_dev_data
networks: networks:
tiptop-dev: tiptop-dev:
name: tiptop-dev name: tiptop-dev
proxy:
external: true

View File

@@ -12,11 +12,7 @@ services:
- tiptop_preprod_postgres_data:/var/lib/postgresql/data - tiptop_preprod_postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: test: ["CMD-SHELL", "pg_isready -U tiptop_preprod -d the_tip_top_preprod"]
[
"CMD-SHELL",
"pg_isready -U tiptop_preprod -d the_tip_top_preprod"
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -28,6 +24,9 @@ services:
image: ${REGISTRY}/the-tip-top/backend:${IMAGE_TAG} image: ${REGISTRY}/the-tip-top/backend:${IMAGE_TAG}
restart: unless-stopped restart: unless-stopped
env_file:
- /opt/furious-ducks/secrets/tiptop-mail.env
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -37,13 +36,9 @@ services:
NODE_ENV: preproduction NODE_ENV: preproduction
DATABASE_URL: postgresql://tiptop_preprod:tiptop_preprod_password@postgres:5432/the_tip_top_preprod DATABASE_URL: postgresql://tiptop_preprod:tiptop_preprod_password@postgres:5432/the_tip_top_preprod
JWT_SECRET: preprod_secret_change_me JWT_SECRET: preprod_secret_change_me
APP_URL: http://192.168.1.23:5002 APP_URL: https://api-preprod.dsp5-archi-o24a-4-5-g4.fr
FRONTEND_URL: http://192.168.1.23:5174 API_PUBLIC_URL: https://api-preprod.dsp5-archi-o24a-4-5-g4.fr
MAIL_HOST: smtp.gmail.com FRONTEND_URL: https://preprod.dsp5-archi-o24a-4-5-g4.fr
MAIL_PORT: 587
MAIL_USER: ""
MAIL_PASS: ""
MAIL_FROM: "The Tip Top Preprod <no-reply@tiptop.local>"
# Injectés par le pipeline, jamais commités. Vides, les routes de # Injectés par le pipeline, jamais commités. Vides, les routes de
# connexion externe répondent 503 sans bloquer le démarrage. # connexion externe répondent 503 sans bloquer le démarrage.
@@ -59,11 +54,18 @@ services:
npm start npm start
" "
ports:
- "5002:5000"
networks: networks:
- tiptop-preprod - tiptop-preprod
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.tiptop-preprod-api.rule=Host(`api-preprod.dsp5-archi-o24a-4-5-g4.fr`)"
- "traefik.http.routers.tiptop-preprod-api.entrypoints=websecure"
- "traefik.http.routers.tiptop-preprod-api.tls=true"
- "traefik.http.routers.tiptop-preprod-api.tls.certresolver=letsencrypt"
- "traefik.http.services.tiptop-preprod-api.loadbalancer.server.port=5000"
frontend: frontend:
image: ${REGISTRY}/the-tip-top/frontend:${IMAGE_TAG} image: ${REGISTRY}/the-tip-top/frontend:${IMAGE_TAG}
@@ -72,11 +74,18 @@ services:
depends_on: depends_on:
- backend - backend
ports:
- "5174:80"
networks: networks:
- tiptop-preprod - tiptop-preprod
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.tiptop-preprod.rule=Host(`preprod.dsp5-archi-o24a-4-5-g4.fr`)"
- "traefik.http.routers.tiptop-preprod.entrypoints=websecure"
- "traefik.http.routers.tiptop-preprod.tls=true"
- "traefik.http.routers.tiptop-preprod.tls.certresolver=letsencrypt"
- "traefik.http.services.tiptop-preprod.loadbalancer.server.port=80"
volumes: volumes:
tiptop_preprod_postgres_data: tiptop_preprod_postgres_data:
@@ -85,3 +94,6 @@ volumes:
networks: networks:
tiptop-preprod: tiptop-preprod:
name: tiptop-preprod name: tiptop-preprod
proxy:
external: true

View File

@@ -12,11 +12,7 @@ services:
- tiptop_prod_postgres_data:/var/lib/postgresql/data - tiptop_prod_postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: test: ["CMD-SHELL", "pg_isready -U tiptop_prod -d the_tip_top_prod"]
[
"CMD-SHELL",
"pg_isready -U tiptop_prod -d the_tip_top_prod"
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -28,6 +24,9 @@ services:
image: ${REGISTRY}/the-tip-top/backend:${IMAGE_TAG} image: ${REGISTRY}/the-tip-top/backend:${IMAGE_TAG}
restart: unless-stopped restart: unless-stopped
env_file:
- /opt/furious-ducks/secrets/tiptop-mail.env
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -37,14 +36,9 @@ services:
NODE_ENV: production NODE_ENV: production
DATABASE_URL: postgresql://tiptop_prod:tiptop_prod_password@postgres:5432/the_tip_top_prod DATABASE_URL: postgresql://tiptop_prod:tiptop_prod_password@postgres:5432/the_tip_top_prod
JWT_SECRET: prod_secret_change_me JWT_SECRET: prod_secret_change_me
APP_URL: http://192.168.1.23:5003 APP_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
FRONTEND_URL: http://192.168.1.23:5175 API_PUBLIC_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
FRONTEND_URL: https://dsp5-archi-o24a-4-5-g4.fr,https://www.dsp5-archi-o24a-4-5-g4.fr
MAIL_HOST: smtp.gmail.com
MAIL_PORT: 587
MAIL_USER: ""
MAIL_PASS: ""
MAIL_FROM: "The Tip Top <no-reply@tiptop.local>"
# Injectés par le pipeline, jamais commités. Vides, les routes de # Injectés par le pipeline, jamais commités. Vides, les routes de
# connexion externe répondent 503 sans bloquer le démarrage. # connexion externe répondent 503 sans bloquer le démarrage.
@@ -60,11 +54,18 @@ services:
npm start npm start
" "
ports:
- "5003:5000"
networks: networks:
- tiptop-prod - tiptop-prod
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.tiptop-prod-api.rule=Host(`api.dsp5-archi-o24a-4-5-g4.fr`)"
- "traefik.http.routers.tiptop-prod-api.entrypoints=websecure"
- "traefik.http.routers.tiptop-prod-api.tls=true"
- "traefik.http.routers.tiptop-prod-api.tls.certresolver=letsencrypt"
- "traefik.http.services.tiptop-prod-api.loadbalancer.server.port=5000"
frontend: frontend:
image: ${REGISTRY}/the-tip-top/frontend:${IMAGE_TAG} image: ${REGISTRY}/the-tip-top/frontend:${IMAGE_TAG}
@@ -73,11 +74,18 @@ services:
depends_on: depends_on:
- backend - backend
ports:
- "5175:80"
networks: networks:
- tiptop-prod - tiptop-prod
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.tiptop-prod.rule=Host(`dsp5-archi-o24a-4-5-g4.fr`) || Host(`www.dsp5-archi-o24a-4-5-g4.fr`)"
- "traefik.http.routers.tiptop-prod.entrypoints=websecure"
- "traefik.http.routers.tiptop-prod.tls=true"
- "traefik.http.routers.tiptop-prod.tls.certresolver=letsencrypt"
- "traefik.http.services.tiptop-prod.loadbalancer.server.port=80"
volumes: volumes:
tiptop_prod_postgres_data: tiptop_prod_postgres_data:
@@ -86,3 +94,6 @@ volumes:
networks: networks:
tiptop-prod: tiptop-prod:
name: tiptop-prod name: tiptop-prod
proxy:
external: true

File diff suppressed because it is too large Load Diff

View File

@@ -1,125 +1,120 @@
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
function FooterLink({ to, children, className = '', pathname }) {
return pathname === to ? (
<span className={className}>{children}</span>
) : (
<Link to={to} className={className}>
{children}
</Link>
)
}
export default function Footer() { export default function Footer() {
const { pathname } = useLocation() const { pathname } = useLocation()
const { user } = useAuth() const { user } = useAuth()
const isLoggedIn = !!user const isLoggedIn = !!user
function FooterLink({ to, children, className=''}) {
return pathname === to ? (
<span className={className}>{children}</span>
) : (
<Link to={to} className={className}>
{children}
</Link>
)
}
return ( return (
<footer className="bg-[#006B6B] text-white"> <footer className="bg-[#006B6B] text-white">
{/* Partie principale */} {/* Partie principale */}
<div className="max-w-7xl mx-auto px-6 py-10 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8"> <div className="max-w-7xl mx-auto px-6 py-10 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8">
{/* Logo */} {/* Logo */}
<div> <div>
<FooterLink to='/accueil'> <FooterLink
to="/accueil"
pathname={pathname}
>
<h3 className="text-xl font-bold mb-3"> <h3 className="text-xl font-bold mb-3">
Thé Tip Top Thé Tip Top
</h3> </h3>
</FooterLink> </FooterLink>
<p className="text-teal-100 text-sm leading-relaxed"> <p className="text-teal-100 text-sm leading-relaxed">
Participez au jeu-concours Thé Tip Top et tentez de gagner Participez au jeu-concours Thé Tip Top et tentez de gagner
des lots gourmands autour du thé. des lots gourmands autour du thé.
</p> </p>
</div> </div>
{/* Navigation */} {/* Navigation */}
<div> <div>
<h3 className="text-lg font-semibold mb-5"> <h3 className="text-lg font-semibold mb-5">
Navigation Navigation
</h3> </h3>
<ul className="space-y-3 text-teal-100"> <ul className="space-y-3 text-teal-100">
<li> <li>
<FooterLink <FooterLink
to="/accueil" to="/accueil"
pathname={pathname}
className="hover:text-white transition" className="hover:text-white transition"
> >
Accueil Accueil
</FooterLink> </FooterLink>
</li> </li>
<li> <li>
<FooterLink <FooterLink
to="/regles-jeu" to="/regles-jeu"
pathname={pathname}
className="hover:text-white transition" className="hover:text-white transition"
> >
Règles du jeu Règles du jeu
</FooterLink> </FooterLink>
</li> </li>
<li> <li>
<FooterLink <FooterLink
to="/saisir-code-unique" to="/saisir-code-unique"
pathname={pathname}
className="hover:text-white transition" className="hover:text-white transition"
> >
Tester mon code Tester mon code
</FooterLink> </FooterLink>
</li> </li>
{ !isLoggedIn && ( {!isLoggedIn && (
<li> <li>
<FooterLink <FooterLink
to="/inscription" to="/inscription"
pathname={pathname}
className="hover:text-white transition" className="hover:text-white transition"
> >
Créer un compte Créer un compte
</FooterLink> </FooterLink>
</li> </li>
)} )}
</ul> </ul>
</div> </div>
{/* Informations légales */} {/* Informations légales */}
<div> <div>
<h3 className="text-lg font-semibold mb-5"> <h3 className="text-lg font-semibold mb-5">
Informations Informations
</h3> </h3>
<ul className="space-y-3 text-teal-100"> <ul className="space-y-3 text-teal-100">
<li> <li>
<FooterLink <FooterLink
to="/mentions-legales" to="/mentions-legales"
pathname={pathname}
className="hover:text-white transition" className="hover:text-white transition"
> >
Mentions légales Mentions légales
</FooterLink> </FooterLink>
</li> </li>
<li> <li>
<FooterLink <FooterLink
to="/confidentialite" to="/confidentialite"
pathname={pathname}
className="hover:text-white transition" className="hover:text-white transition"
> >
Politique de confidentialité Politique de confidentialité
@@ -129,6 +124,7 @@ export default function Footer() {
<li> <li>
<FooterLink <FooterLink
to="/accessibilite" to="/accessibilite"
pathname={pathname}
className="hover:text-white transition" className="hover:text-white transition"
> >
Accessibilité Accessibilité
@@ -138,83 +134,80 @@ export default function Footer() {
<li> <li>
<FooterLink <FooterLink
to="/cgu" to="/cgu"
pathname={pathname}
className="hover:text-white transition" className="hover:text-white transition"
> >
CGU CGU
</FooterLink> </FooterLink>
</li> </li>
<li> <li>
<FooterLink <FooterLink
to="/contact" to="/contact"
pathname={pathname}
className="hover:text-white transition" className="hover:text-white transition"
> >
Contact Contact
</FooterLink> </FooterLink>
</li> </li>
<li> <li>
<FooterLink <FooterLink
to="/sitemap" to="/sitemap"
pathname={pathname}
className="hover:text-white transition" className="hover:text-white transition"
> >
Plan du site Plan du site
</FooterLink> </FooterLink>
</li> </li>
</ul> </ul>
</div> </div>
{/* Contact */} {/* Contact */}
<div> <div>
<h3 className="text-lg font-semibold mb-5"> <h3 className="text-lg font-semibold mb-5">
Contact Contact
</h3> </h3>
<p className="text-teal-100 text-sm mb-3"> <p className="text-teal-100 text-sm mb-3">
Une question sur le jeu ? Une question sur le jeu ?
</p> </p>
<a href="tel:+33184802034" className="block mt-2 font-medium hover:text-teal-300"> <a
href="tel:+33184802034"
className="block mt-2 font-medium hover:text-teal-300"
>
01 84 80 20 34 01 84 80 20 34
</a> </a>
<a href="mailto:contact@thetiptop.fr" className="inline-block mt-3 font-medium break-all hover:text-teal-300"> <a href="mailto:contact@thetiptop.fr" className="inline-block mt-3 font-medium break-all hover:text-teal-300">
contact@thetiptop.fr contact@thetiptop.fr
</a> </a>
<p className="text-teal-100 text-sm mt-2"> <p className="text-teal-100 text-sm mt-2">
18 rue Léon Frot 18 rue Léon Frot
<br /> <br />
75011 Paris, France 75011 Paris, France
</p> </p>
</div> </div>
</div> </div>
{/* Barre basse */} {/* Barre basse */}
<div className="border-t border-teal-800"> <div className="border-t border-teal-800">
<div className="max-w-7xl mx-auto px-6 py-5 flex flex-col md:flex-row justify-between items-center gap-3"> <div className="max-w-7xl mx-auto px-6 py-5 flex flex-col md:flex-row justify-between items-center gap-3">
<p className="text-teal-200 text-sm text-center md:text-left">
<p className="text-teal-200 text-sm">
© {new Date().getFullYear()} Thé Tip Top. Tous droits réservés. © {new Date().getFullYear()} Thé Tip Top. Tous droits réservés.
<br className="md:hidden" />
<span className="md:before:content-['_—_']">
Projet étudiant fictif — aucun achat ni réservation réel ne peut être effectué sur ce site.
</span>
</p> </p>
<div className="flex gap-5 text-sm text-teal-200"> <div className="flex gap-5 text-sm text-teal-200">
<a <a
href="https://www.facebook.com/profile.php?id=61590449280854" href="https://www.facebook.com/profile.php?id=61590449280854"
target="_blank" target="_blank"
@@ -223,8 +216,7 @@ export default function Footer() {
> >
Facebook Facebook
</a> </a>
<a <a
href="https://www.instagram.com/thetiptop.gp4?igsh=MXZ3YzZ0d3dkazBpeg%3D%3D&utm_source=qr" href="https://www.instagram.com/thetiptop.gp4?igsh=MXZ3YzZ0d3dkazBpeg%3D%3D&utm_source=qr"
target="_blank" target="_blank"
@@ -233,8 +225,7 @@ export default function Footer() {
> >
Instagram Instagram
</a> </a>
<a <a
href="https://www.tiktok.com/@thetiptop4" href="https://www.tiktok.com/@thetiptop4"
target="_blank" target="_blank"
@@ -243,15 +234,11 @@ export default function Footer() {
> >
TikTok TikTok
</a> </a>
</div> </div>
</div> </div>
</div> </div>
</footer> </footer>
) )
} }

View File

@@ -52,6 +52,67 @@ export default function Accueil() {
</div> </div>
</section> </section>
{/* Questions fréquentes */}
<section className="max-w-3xl mx-auto px-4 sm:px-6 pb-16 sm:pb-24">
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-6 sm:mb-8 text-center">
Questions fréquentes
</h2>
<div className="space-y-3">
{[
{
question: 'Comment obtenir un code ?',
answer: "Achetez un produit Thé Tip Top d'une valeur supérieure à 49 € en boutique ou en ligne : un code unique à 10 caractères figure sur votre ticket de caisse ou votre facture.",
},
{
question: 'Quels sont les lots à gagner ?',
answer: (
<>
Tous les tickets sont gagnants ! Selon votre ticket, vous pouvez remporter :
<ul className="list-disc list-inside space-y-1 mt-2">
<li>Un infuseur à thé</li>
<li>Une boîte de 100 g de thé détox ou d'infusion</li>
<li>Une boîte de 100 g de thé signature</li>
<li>Un coffret découverte d'une valeur de 39 €</li>
<li>Un coffret découverte d'une valeur de 69 €</li>
</ul>
Un tirage au sort désigne en plus un grand gagnant qui remporte une année de thé d'une valeur de 360 €.
</>
),
},
{
question: 'Jusqu\'à quand puis-je jouer ?',
answer: 'La participation est possible pendant toute la durée du jeu-concours, soit 30 jours à compter de son ouverture. Consultez la page Règles du jeu pour le détail des dates.',
},
{
question: 'Que faire si mon code n\'est pas reconnu ?',
answer: "Vérifiez que le code a été saisi sans espace ni erreur. Si le problème persiste, contactez-nous depuis la page Contact.",
},
{
question: 'Où retirer mon lot ?',
answer: 'En boutique, sur présentation de votre compte utilisateur validé et de la confirmation de gain affichée dans votre espace « Mes gains ».',
},
].map((faq) => (
<details key={faq.question} className="group bg-white rounded-2xl shadow p-4 sm:p-6">
<summary className="flex items-center justify-between gap-3 cursor-pointer list-none font-semibold text-gray-800 text-sm sm:text-base">
{faq.question}
<svg
className="w-4 h-4 text-[#006B6B] flex-shrink-0 transition-transform group-open:rotate-180"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="text-gray-600 text-sm sm:text-base mt-3 leading-relaxed">
{faq.answer}
</div>
</details>
))}
</div>
</section>
</div> </div>
) )
} }

View File

@@ -88,7 +88,7 @@ export default function Admin() {
if (!handleAuthError(e)) setError(e.message) if (!handleAuthError(e)) setError(e.message)
}) })
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [handleAuthError])
const handleSaveGameSettings = async (e) => { const handleSaveGameSettings = async (e) => {
e.preventDefault() e.preventDefault()
@@ -145,10 +145,7 @@ export default function Admin() {
const csv = [header, ...rows] const csv = [header, ...rows]
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(';')) .map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(';'))
.join('\n') .join('\n')
// Le BOM en tete du fichier est indispensable : sans lui, Excel ouvre le const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' })
// CSV en ANSI et casse tous les accents. Ne pas le supprimer.
// eslint-disable-next-line no-irregular-whitespace
const blob = new Blob([`${csv}`], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url

View File

@@ -7,6 +7,15 @@ export default function Cgu() {
Conditions générales d'utilisation Conditions générales d'utilisation
</h1> </h1>
<div className="mb-6 sm:mb-8 px-4 py-3 sm:px-5 sm:py-4 rounded-xl bg-[#006B6B]">
<p className="text-white text-sm sm:text-base font-medium leading-relaxed">
Projet étudiant fictif — Ce site a été conçu dans un cadre pédagogique. La société
Thé Tip Top, l'agence Furious Ducks et l'ensemble des conditions présentées
ci-dessous sont fictives. Aucun achat, paiement ou réservation réel ne peut être
effectué sur ce site.
</p>
</div>
<p className="text-gray-600 mb-8 sm:mb-10 text-sm sm:text-base"> <p className="text-gray-600 mb-8 sm:mb-10 text-sm sm:text-base">
Les présentes Conditions Générales d'Utilisation (CGU) précisent les modalités d'accès et 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. d'utilisation du site internet du jeu-concours « 100% Gagnant » de la société Thé Tip Top.

View File

@@ -18,7 +18,8 @@ export default function Connexion() {
// Navigation pleine page (et non fetch) : le flux OAuth passe par des // Navigation pleine page (et non fetch) : le flux OAuth passe par des
// redirections vers Google puis retour sur /auth/callback. // redirections vers Google puis retour sur /auth/callback.
const handleGoogleLogin = () => { const handleGoogleLogin = () => {
window.location.href = '/api/auth/google' const apiUrl = import.meta.env.VITE_API_URL || ''
window.location.href = `${apiUrl}/api/auth/google`
} }
const handleSubmit = async (e) => { const handleSubmit = async (e) => {

View File

@@ -3,43 +3,152 @@ 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">
{/* Informations */} <div className="grid md:grid-cols-2 gap-6 sm:gap-8 mb-8">
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
Contact
</h1>
<p className="text-gray-600 leading-relaxed mb-6 sm:mb-8 text-sm sm:text-base">
Une question concernant le jeu-concours, votre participation ou votre lot ?
Notre équipe ThéTipTop est à votre écoute.
</p>
<div className="space-y-5 text-gray-700">
<div>
<h2 className="font-semibold text-[#006B6B] mb-1">Adresse</h2>
<p className="text-sm sm:text-base">ThéTipTop<br />18 rue Léon Frot<br />75011 Paris, France</p>
</div>
<div>
<h2 className="font-semibold text-[#006B6B] mb-1">Email</h2>
<a href="mailto:contact@thetiptop.fr" className="text-sm sm:text-base hover:text-[#006B6B]">contact@thetiptop.fr</a>
</div>
<div>
<h2 className="font-semibold text-[#006B6B] mb-1">Téléphone</h2>
<a href="tel:+33184802034" className="text-sm sm:text-base hover:text-[#006B6B]">01 84 80 20 34</a>
</div>
</div>
</section>
{/* Image */} {/* Informations */}
<section className="rounded-2xl shadow overflow-hidden h-64 sm:h-80 md:h-[468px] md:self-start"> <section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
<img <h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
src={imgContact} Contact
alt="Une personne savourant un thé" </h1>
className="w-full h-full object-cover"
/> <p className="text-gray-600 leading-relaxed mb-6 sm:mb-8 text-sm sm:text-base">
Une question concernant le jeu-concours, votre participation ou votre lot ?
Notre équipe ThéTipTop est à votre écoute.
</p>
<div className="space-y-5 text-gray-700">
<div>
<h2 className="font-semibold text-[#006B6B] mb-1">
Adresse
</h2>
<p className="text-sm sm:text-base">
ThéTipTop
<br />
18 rue Léon Frot
<br />
75011 Paris, France
</p>
</div>
<div>
<h2 className="font-semibold text-[#006B6B] mb-1">
Email
</h2>
<a
href="mailto:contact@thetiptop.fr"
className="text-sm sm:text-base hover:text-[#006B6B]"
>
contact@thetiptop.fr
</a>
</div>
<div>
<h2 className="font-semibold text-[#006B6B] mb-1">
Téléphone
</h2>
<a
href="tel:+33184802034"
className="text-sm sm:text-base hover:text-[#006B6B]"
>
01 84 80 20 34
</a>
</div>
</div>
</section>
{/* Image */}
<section className="rounded-2xl shadow overflow-hidden min-h-80">
<img
src={imgContact}
alt="Une personne savourant un thé"
className="w-full h-full object-cover"
/>
</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> </section>
</div> </div>
</main> </main>
) )
} }

View File

@@ -43,7 +43,8 @@ export default function Inscription() {
// Même route que la connexion : Google crée le compte à la première venue. // Même route que la connexion : Google crée le compte à la première venue.
const handleGoogleSignup = () => { const handleGoogleSignup = () => {
window.location.href = '/api/auth/google' const apiUrl = import.meta.env.VITE_API_URL || ''
window.location.href = `${apiUrl}/api/auth/google`
} }
// Étape 1 — Inscription // Étape 1 — Inscription

View File

@@ -7,6 +7,15 @@ export default function MentionsLegales() {
Mentions légales Mentions légales
</h1> </h1>
<div className="mb-6 sm:mb-8 px-4 py-3 sm:px-5 sm:py-4 rounded-xl bg-[#006B6B]">
<p className="text-white text-sm sm:text-base font-medium leading-relaxed">
Projet étudiant fictif — Ce site a été conçu dans un cadre pédagogique. La société
Thé Tip Top, l'agence Furious Ducks et l'ensemble des informations légales présentées
ci-dessous sont fictives. Aucun achat, paiement ou réservation réel ne peut être
effectué sur ce site.
</p>
</div>
<p className="text-gray-600 mb-8 sm:mb-10 text-sm sm:text-base"> <p className="text-gray-600 mb-8 sm:mb-10 text-sm sm:text-base">
Conformément à la loi n°2004-575 du 21 juin 2004 pour la Confiance dans l'Économie 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 Numérique (LCEN), les présentes mentions légales précisent l'identité des différents

View File

@@ -50,7 +50,20 @@ export default function MesGains() {
} }
}, [handleAuthError]) }, [handleAuthError])
useEffect(() => { fetchGains() }, [fetchGains]) useEffect(() => {
const loadGains = async () => {
try {
const data = await myGainsApi()
setGains(data.gains)
} catch (err) {
if (!handleAuthError(err)) setError(err.message)
} finally {
setLoading(false)
}
}
loadGains()
}, [handleAuthError])
return ( return (
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto"> <div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto">

View File

@@ -3,18 +3,11 @@ import { useNavigate } from 'react-router-dom'
import UserNav from '../components/UserNav' import UserNav from '../components/UserNav'
import { changePasswordApi, deleteAccountApi, meApi, forgotPasswordApi } from '../services/auth' import { changePasswordApi, deleteAccountApi, meApi, forgotPasswordApi } from '../services/auth'
import { useHandleAuthError } from '../hooks/useHandleAuthError' import { useHandleAuthError } from '../hooks/useHandleAuthError'
import { useAuth } from '../context/AuthContext'
export default function Profile() { export default function Profile() {
const user = JSON.parse(localStorage.getItem('user') || '{}') const user = JSON.parse(localStorage.getItem('user') || '{}')
const initials = `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase() const initials = `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase()
const handleAuthError = useHandleAuthError() 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 [pwForm, setPwForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
const [showPw, setShowPw] = useState({ current: false, new: false, confirm: false }) const [showPw, setShowPw] = useState({ current: false, new: false, confirm: false })

View File

@@ -1,3 +1,6 @@
const API_URL = import.meta.env.VITE_API_URL || ''
const BASE = `${API_URL}/api/admin`
function authHeaders() { function authHeaders() {
return { return {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -12,35 +15,35 @@ function apiError(message, status) {
} }
export async function getStatsApi() { export async function getStatsApi() {
const res = await fetch('/api/admin/stats', { headers: authHeaders() }) const res = await fetch(`${BASE}/stats`, { headers: authHeaders() })
const data = await res.json() const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur stats.', res.status) if (!res.ok) throw apiError(data.message || 'Erreur stats.', res.status)
return data return data
} }
export async function getUsersApi() { export async function getUsersApi() {
const res = await fetch('/api/admin/users', { headers: authHeaders() }) const res = await fetch(`${BASE}/users`, { headers: authHeaders() })
const data = await res.json() const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur utilisateurs.', res.status) if (!res.ok) throw apiError(data.message || 'Erreur utilisateurs.', res.status)
return data return data
} }
export async function drawWinnerApi() { export async function drawWinnerApi() {
const res = await fetch('/api/admin/draw', { method: 'POST', headers: authHeaders() }) const res = await fetch(`${BASE}/draw`, { method: 'POST', headers: authHeaders() })
const data = await res.json() const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur tirage.', res.status) if (!res.ok) throw apiError(data.message || 'Erreur tirage.', res.status)
return data return data
} }
export async function getEmailingContactsApi() { export async function getEmailingContactsApi() {
const res = await fetch('/api/admin/emailing', { headers: authHeaders() }) const res = await fetch(`${BASE}/emailing`, { headers: authHeaders() })
const data = await res.json() const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur contacts emailing.', res.status) if (!res.ok) throw apiError(data.message || 'Erreur contacts emailing.', res.status)
return data return data
} }
export async function setGameSettingsApi(startDate) { export async function setGameSettingsApi(startDate) {
const res = await fetch('/api/admin/game-settings', { const res = await fetch(`${BASE}/game-settings`, {
method: 'PUT', method: 'PUT',
headers: authHeaders(), headers: authHeaders(),
body: JSON.stringify({ startDate }), body: JSON.stringify({ startDate }),
@@ -51,7 +54,8 @@ export async function setGameSettingsApi(startDate) {
} }
export async function getWinnerApi() { export async function getWinnerApi() {
const res = await fetch('/api/admin/draw', { headers: authHeaders() }) const res = await fetch(`${BASE}/draw`, { headers: authHeaders() })
if (res.status === 404) return null
const data = await res.json() const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur gagnant.', res.status) if (!res.ok) throw apiError(data.message || 'Erreur gagnant.', res.status)
return data return data

View File

@@ -1,4 +1,5 @@
const BASE = '/api/auth' const API_URL = import.meta.env.VITE_API_URL || ''
const BASE = `${API_URL}/api/auth`
function apiError(message, status) { function apiError(message, status) {
const err = new Error(message) const err = new Error(message)

View File

@@ -1,4 +1,5 @@
const BASE = '/api/employee' const API_URL = import.meta.env.VITE_API_URL || ''
const BASE = `${API_URL}/api/employee`
function authHeaders() { function authHeaders() {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')

View File

@@ -1,3 +1,7 @@
const API_URL = import.meta.env.VITE_API_URL || ''
const BASE = `${API_URL}/api/game`
function apiError(message, status) { function apiError(message, status) {
const err = new Error(message) const err = new Error(message)
err.status = status err.status = status
@@ -6,7 +10,7 @@ function apiError(message, status) {
export async function myGainsApi() { export async function myGainsApi() {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
const res = await fetch('/api/game/my-gains', { const res = await fetch(`${BASE}/my-gains`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) })
const data = await res.json() const data = await res.json()
@@ -16,7 +20,7 @@ export async function myGainsApi() {
export async function participateApi(code) { export async function participateApi(code) {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
const res = await fetch('/api/game/participate', { const res = await fetch(`${BASE}/participate`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',