From fd476f50c2c4ec9149ce105ef41dbd82c1ce618c Mon Sep 17 00:00:00 2001 From: Gwadaking Date: Tue, 14 Apr 2026 12:52:32 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20syst=C3=A8me=20de=20signalement=20bug/i?= =?UTF-8?q?d=C3=A9e=20+=20modal=20changelog=20avec=20limitations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppReport entity + migration (type, message, fingerprint SHA-256, created_at) - POST /api/report avec rate-limit 5/heure par fingerprint IP - ReportModal : formulaire type radio (bug/idée) + textarea 1 000 chars max - ChangelogModal : fonctionnalités en prod, roadmap, 5 limitations honnêtes - Deux boutons flottants bas-gauche (? info + ✉ signalement) - Légende remontée pour éviter le chevauchement Co-Authored-By: Claude Sonnet 4.6 --- backend/migrations/Version20260410000000.php | 35 +++++ backend/public/index.html | 4 +- backend/src/Controller/ReportController.php | 67 +++++++++ backend/src/Entity/AppReport.php | 47 +++++++ frontend/src/App.css | 33 +++++ frontend/src/App.jsx | 33 ++++- frontend/src/api/client.js | 3 + .../ChangelogModal/ChangelogModal.css | 124 +++++++++++++++++ .../ChangelogModal/ChangelogModal.jsx | 101 ++++++++++++++ frontend/src/components/Legend/Legend.css | 4 +- .../components/ReportModal/ReportModal.css | 128 ++++++++++++++++++ .../components/ReportModal/ReportModal.jsx | 71 ++++++++++ 12 files changed, 643 insertions(+), 7 deletions(-) create mode 100644 backend/migrations/Version20260410000000.php create mode 100644 backend/src/Controller/ReportController.php create mode 100644 backend/src/Entity/AppReport.php create mode 100644 frontend/src/components/ChangelogModal/ChangelogModal.css create mode 100644 frontend/src/components/ChangelogModal/ChangelogModal.jsx create mode 100644 frontend/src/components/ReportModal/ReportModal.css create mode 100644 frontend/src/components/ReportModal/ReportModal.jsx diff --git a/backend/migrations/Version20260410000000.php b/backend/migrations/Version20260410000000.php new file mode 100644 index 0000000..b91f20a --- /dev/null +++ b/backend/migrations/Version20260410000000.php @@ -0,0 +1,35 @@ +addSql("CREATE TABLE app_report ( + id UUID NOT NULL, + type VARCHAR(10) NOT NULL, + message TEXT NOT NULL, + fingerprint VARCHAR(64) DEFAULT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + PRIMARY KEY (id) + )"); + $this->addSql('CREATE INDEX idx_report_created_at ON app_report (created_at)'); + $this->addSql("COMMENT ON COLUMN app_report.id IS '(DC2Type:uuid)'"); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE app_report'); + } +} diff --git a/backend/public/index.html b/backend/public/index.html index a429c62..da10695 100644 --- a/backend/public/index.html +++ b/backend/public/index.html @@ -8,8 +8,8 @@ - - + +
diff --git a/backend/src/Controller/ReportController.php b/backend/src/Controller/ReportController.php new file mode 100644 index 0000000..d7149e7 --- /dev/null +++ b/backend/src/Controller/ReportController.php @@ -0,0 +1,67 @@ +getContent(), true); + + $type = trim((string) ($body['type'] ?? '')); + $message = trim((string) ($body['message'] ?? '')); + + if (!in_array($type, ['bug', 'idea'], true)) { + return $this->json(['error' => 'type must be "bug" or "idea"'], 400); + } + if (strlen($message) < 5) { + return $this->json(['error' => 'message too short (min 5 chars)'], 400); + } + if (strlen($message) > 1000) { + return $this->json(['error' => 'message too long (max 1000 chars)'], 400); + } + + $fingerprint = hash('sha256', $request->getClientIp() ?? 'unknown'); + + // Rate limit : 5 rapports par heure par fingerprint + $count = (int) $this->connection->fetchOne( + "SELECT COUNT(*) FROM app_report + WHERE fingerprint = :fp AND created_at >= NOW() - INTERVAL '1 hour'", + ['fp' => $fingerprint] + ); + if ($count >= 5) { + return $this->json(['error' => 'Rate limit exceeded. Try again later.'], 429); + } + + $report = new AppReport(); + $report->setType($type); + $report->setMessage($message); + $report->setFingerprint($fingerprint); + + $this->em->persist($report); + $this->em->flush(); + + return $this->json(['ok' => true], 201); + } +} diff --git a/backend/src/Entity/AppReport.php b/backend/src/Entity/AppReport.php new file mode 100644 index 0000000..801c883 --- /dev/null +++ b/backend/src/Entity/AppReport.php @@ -0,0 +1,47 @@ +createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?Uuid { return $this->id; } + public function getType(): string { return $this->type; } + public function setType(string $type): static { $this->type = $type; return $this; } + public function getMessage(): string { return $this->message; } + public function setMessage(string $message): static { $this->message = $message; return $this; } + public function getFingerprint(): ?string { return $this->fingerprint; } + public function setFingerprint(?string $fingerprint): static { $this->fingerprint = $fingerprint; return $this; } + public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } +} diff --git a/frontend/src/App.css b/frontend/src/App.css index e33cae1..37a58b8 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -13,6 +13,39 @@ body { height: 100vh; } +/* ── Boutons flottants (signalement + info) ── */ +.app__actions { + position: absolute; + bottom: 14px; + left: 16px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 15; +} + +.app__action-btn { + width: 36px; + height: 36px; + border: 2.5px solid #333; + border-radius: 50%; + font-size: 16px; + font-family: 'Fredoka', sans-serif; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 3px 3px 0px #333; + transition: transform .1s, box-shadow .1s; +} +.app__action-btn:hover { + transform: translate(-2px, -2px); + box-shadow: 5px 5px 0px #333; +} +.app__action-btn--info { background: #fffbf0; color: #1a1a2e; } +.app__action-btn--report { background: #2563eb; color: #fff; } + /* Vagues SVG en overlay sur la mer — très subtil, pointer-events none */ .app__wave-overlay { position: absolute; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a87f679..f5a8935 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,12 +3,16 @@ import SargassesMap from './components/Map/SargassesMap'; import SpotPanel from './components/SpotPanel/SpotPanel'; import Topbar from './components/Topbar/Topbar'; import Legend from './components/Legend/Legend'; +import ReportModal from './components/ReportModal/ReportModal'; +import ChangelogModal from './components/ChangelogModal/ChangelogModal'; import './App.css'; export default function App() { - const [selectedSpot, setSelectedSpot] = useState(null); - const [activeStep, setActiveStep] = useState('now'); - const [flyToTarget, setFlyToTarget] = useState(null); + const [selectedSpot, setSelectedSpot] = useState(null); + const [activeStep, setActiveStep] = useState('now'); + const [flyToTarget, setFlyToTarget] = useState(null); + const [showReport, setShowReport] = useState(false); + const [showChangelog, setShowChangelog] = useState(false); return (
@@ -29,11 +33,34 @@ export default function App() { + {/* Boutons flottants bas-gauche */} +
+ + +
+ setSelectedSpot(null)} /> + + {showReport && setShowReport(false)} />} + {showChangelog && setShowChangelog(false)} />}
); } diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 1bc8086..249d71c 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -43,6 +43,9 @@ export const api = { status: { get: () => get('/status'), }, + report: { + create: (data) => post('/report', data), + }, push: { vapidKey: () => get('/push/vapid-public-key'), subscribe: (data) => post('/push/subscribe', data), diff --git a/frontend/src/components/ChangelogModal/ChangelogModal.css b/frontend/src/components/ChangelogModal/ChangelogModal.css new file mode 100644 index 0000000..63ed47c --- /dev/null +++ b/frontend/src/components/ChangelogModal/ChangelogModal.css @@ -0,0 +1,124 @@ +.chlog-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 200; + display: flex; + align-items: center; + justify-content: center; +} + +.chlog { + position: relative; + background: #fffbf0; + border: 3px solid #333; + border-radius: 20px; + box-shadow: 6px 6px 0px #333; + padding: 28px 28px 20px; + width: min(520px, 94vw); + max-height: 86vh; + overflow-y: auto; + font-family: 'Fredoka', sans-serif; + scrollbar-width: thin; +} + +.chlog__close { + position: absolute; + top: 12px; + right: 14px; + background: none; + border: none; + font-size: 18px; + cursor: pointer; + color: #555; + line-height: 1; +} +.chlog__close:hover { color: #111; } + +.chlog__title { + font-family: 'Fredoka One', cursive; + font-size: 22px; + color: #1a1a2e; + margin-bottom: 2px; +} + +.chlog__subtitle { + font-size: 13px; + color: #666; + margin-bottom: 20px; +} + +.chlog__section { + margin-bottom: 20px; +} + +.chlog__section-title { + font-family: 'Fredoka One', cursive; + font-size: 14px; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 3px 10px; + border-radius: 8px; + display: inline-block; + margin-bottom: 10px; +} +.chlog__section-title--green { background: #dcfce7; color: #16a34a; } +.chlog__section-title--blue { background: #dbeafe; color: #1d4ed8; } +.chlog__section-title--orange { background: #fff7ed; color: #c2410c; border: 1px solid #fed7aa; } + +.chlog__list { + list-style: none; + display: flex; + flex-direction: column; + gap: 6px; +} + +.chlog__item { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 14px; + color: #333; + line-height: 1.4; +} +.chlog__item--muted { color: #666; } + +.chlog__item-icon { + flex-shrink: 0; + width: 20px; + text-align: center; +} + +.chlog__limitation { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 10px; + font-size: 13px; +} + +.chlog__limitation-header { + display: flex; + align-items: center; + gap: 6px; + color: #1a1a2e; + font-weight: 600; +} + +.chlog__limitation-text { + color: #555; + line-height: 1.5; + padding-left: 26px; +} + +.chlog__footer { + font-size: 11px; + color: #aaa; + text-align: center; + margin-top: 8px; + padding-top: 12px; + border-top: 1px solid #e5e7eb; +} diff --git a/frontend/src/components/ChangelogModal/ChangelogModal.jsx b/frontend/src/components/ChangelogModal/ChangelogModal.jsx new file mode 100644 index 0000000..f1e2483 --- /dev/null +++ b/frontend/src/components/ChangelogModal/ChangelogModal.jsx @@ -0,0 +1,101 @@ +import './ChangelogModal.css'; + +const PRODUCTION = [ + { icon: '🛰️', text: 'Détection satellite Sentinel-2 (10 m, confirmé AFAI + SWIR)' }, + { icon: '📡', text: 'Signal satellite Sentinel-3 (300 m, indicatif — peut inclure du bruit)' }, + { icon: '🌊', text: 'Simulation de dérive J+2 (modèle courant + vent)' }, + { icon: '📍', text: 'Score de risque par spot côtier (distance + densité + tendance)' }, + { icon: '🔔', text: 'Alertes push navigateur par spot' }, + { icon: '🗓️', text: 'Historique 30 jours en mode ligne du temps' }, +]; + +const ROADMAP = [ + { icon: '🏝️', text: 'Distance maritime réelle (évite les faux raccourcis à travers les terres)' }, + { icon: '🤖', text: 'Amélioration de la qualité du signal Sentinel-3 (filtrage brume, aérosols sahariens)' }, + { icon: '📊', text: 'Historique long terme et tendances saisonnières' }, + { icon: '📱', text: 'Application mobile native' }, +]; + +const LIMITATIONS = [ + { + icon: '☁️', + title: 'Couverture nuageuse', + text: 'Sentinel-2 ne voit pas à travers les nuages. En saison des pluies, les données peuvent être absentes plusieurs jours d\'affilée.', + }, + { + icon: '📡', + title: 'Signal Sentinel-3 bruité', + text: 'Les polygones jaune pâle (S3) peuvent inclure du soleil réfléchi ou des aérosols sahariens — pas toujours des sargasses. Fiez-vous en priorité à l\'orange vif (S2 confirmé).', + }, + { + icon: '📏', + title: 'Distance à vol d\'oiseau', + text: 'Les distances affichées sont calculées en ligne droite. Un banc à 19 km peut être séparé de vous par une île — la menace réelle dépend des courants.', + }, + { + icon: '🕐', + title: 'Mise à jour ~quotidienne', + text: 'L\'ingestion satellite est automatique mais dépend de la disponibilité des images (1 à 2 passages par jour selon l\'orbite).', + }, + { + icon: '🗺️', + title: 'Zone couverte', + text: 'L\'outil couvre actuellement les Petites Antilles (Saint-Martin à la Barbade). Aucune donnée pour la Martinique nord ou la Caraïbe profonde.', + }, +]; + +export default function ChangelogModal({ onClose }) { + return ( +
+
e.stopPropagation()}> + + +

À propos

+

Radar Sargasses Caraïbes — open & gratuit

+ +
+

En production

+
    + {PRODUCTION.map((item, i) => ( +
  • + {item.icon} + {item.text} +
  • + ))} +
+
+ +
+

Prochainement

+
    + {ROADMAP.map((item, i) => ( +
  • + {item.icon} + {item.text} +
  • + ))} +
+
+ +
+

Limitations connues

+
    + {LIMITATIONS.map((item, i) => ( +
  • + + {item.icon} + {item.title} + + {item.text} +
  • + ))} +
+
+ +

+ Données : ESA Copernicus · Modèle de dérive : courants CMEMS + ERA5 +

+
+
+ ); +} diff --git a/frontend/src/components/Legend/Legend.css b/frontend/src/components/Legend/Legend.css index 4877dcf..df9b1e1 100644 --- a/frontend/src/components/Legend/Legend.css +++ b/frontend/src/components/Legend/Legend.css @@ -1,6 +1,6 @@ .legend { position: absolute; - bottom: 48px; + bottom: 110px; left: 16px; background: #fffbf0; border: 3px solid #333; @@ -63,5 +63,5 @@ .legend__dot--high { background: #ef4444; } @media (max-width: 480px) { - .legend { bottom: auto; top: 60px; left: 10px; } + .legend { bottom: auto; top: 68px; left: 10px; } } diff --git a/frontend/src/components/ReportModal/ReportModal.css b/frontend/src/components/ReportModal/ReportModal.css new file mode 100644 index 0000000..33f2424 --- /dev/null +++ b/frontend/src/components/ReportModal/ReportModal.css @@ -0,0 +1,128 @@ +.rmodal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 200; + display: flex; + align-items: center; + justify-content: center; +} + +.rmodal { + position: relative; + background: #fffbf0; + border: 3px solid #333; + border-radius: 20px; + box-shadow: 6px 6px 0px #333; + padding: 28px 28px 24px; + width: min(440px, 92vw); + font-family: 'Fredoka', sans-serif; +} + +.rmodal__close { + position: absolute; + top: 12px; + right: 14px; + background: none; + border: none; + font-size: 18px; + cursor: pointer; + color: #555; + line-height: 1; +} +.rmodal__close:hover { color: #111; } + +.rmodal__title { + font-family: 'Fredoka One', cursive; + font-size: 20px; + color: #1a1a2e; + margin-bottom: 16px; +} + +.rmodal__type-row { + display: flex; + gap: 10px; + margin-bottom: 14px; +} + +.rmodal__type-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 12px; + border: 2px solid #333; + border-radius: 12px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + background: #fff; + transition: background .1s; +} +.rmodal__type-btn input { display: none; } +.rmodal__type-btn--active { + background: #2563eb; + color: #fff; + border-color: #1d4ed8; +} +.rmodal__type-btn:hover:not(.rmodal__type-btn--active) { background: #f0f4ff; } + +.rmodal__textarea { + width: 100%; + padding: 10px 12px; + border: 2px solid #333; + border-radius: 12px; + font-family: 'Fredoka', sans-serif; + font-size: 14px; + resize: vertical; + background: #fff; + outline: none; +} +.rmodal__textarea:focus { border-color: #2563eb; } + +.rmodal__counter { + text-align: right; + font-size: 11px; + color: #888; + margin-top: 4px; + margin-bottom: 12px; +} + +.rmodal__error { + color: #ef4444; + font-size: 13px; + margin-bottom: 10px; +} + +.rmodal__submit { + width: 100%; + padding: 10px; + border: 2px solid #333; + border-radius: 12px; + background: #2563eb; + color: #fff; + font-family: 'Fredoka', sans-serif; + font-size: 15px; + font-weight: 600; + cursor: pointer; + box-shadow: 3px 3px 0px #333; + transition: transform .1s, box-shadow .1s; +} +.rmodal__submit:hover:not(:disabled) { + transform: translate(-1px, -1px); + box-shadow: 4px 4px 0px #333; +} +.rmodal__submit:disabled { opacity: 0.6; cursor: not-allowed; } + +.rmodal__thanks { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 16px 0 8px; + text-align: center; + font-size: 16px; + color: #1a1a2e; +} +.rmodal__thanks-icon { font-size: 40px; } diff --git a/frontend/src/components/ReportModal/ReportModal.jsx b/frontend/src/components/ReportModal/ReportModal.jsx new file mode 100644 index 0000000..a1fcbbb --- /dev/null +++ b/frontend/src/components/ReportModal/ReportModal.jsx @@ -0,0 +1,71 @@ +import { useState } from 'react'; +import { api } from '../../api/client'; +import './ReportModal.css'; + +export default function ReportModal({ onClose }) { + const [type, setType] = useState('bug'); + const [message, setMessage] = useState(''); + const [status, setStatus] = useState('idle'); // idle | loading | ok | error + + const submit = async (e) => { + e.preventDefault(); + if (status === 'loading' || status === 'ok') return; + setStatus('loading'); + try { + await api.report.create({ type, message }); + setStatus('ok'); + } catch { + setStatus('error'); + } + }; + + return ( +
+
e.stopPropagation()}> + + + {status === 'ok' ? ( +
+ 🙏 +

Merci, votre retour a bien été envoyé !

+
+ ) : ( +
+

Votre retour

+ +
+ + +
+ +