feat: système de signalement bug/idée + modal changelog avec limitations

- 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 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-14 12:52:32 -04:00
parent 83690b94ab
commit fd476f50c2
12 changed files with 643 additions and 7 deletions

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260410000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Crée la table app_report pour les retours bug/idée';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View File

@@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Fredoka:wght@400;500;600&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-y0ONsdGe.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BcO1waH7.css">
<script type="module" crossorigin src="/assets/index-MV1p6Xs0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DZE-vnLk.css">
</head>
<body>
<div id="root"></div>

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Controller;
use App\Entity\AppReport;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/report', name: 'api_report_')]
class ReportController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em,
private Connection $connection,
) {}
/**
* POST /api/report
*
* Body : { "type": "bug"|"idea", "message": "..." }
* Rate limit : 5 reports/heure par fingerprint (hash IP).
*/
#[Route('', name: 'create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$body = json_decode($request->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);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'app_report')]
#[ORM\Index(columns: ['created_at'], name: 'idx_report_created_at')]
class AppReport
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
private ?Uuid $id = null;
/** 'bug' ou 'idea' */
#[ORM\Column(length: 10)]
private string $type;
#[ORM\Column(type: 'text')]
private string $message;
/** Hash SHA-256 de l'IP — jamais l'IP brute */
#[ORM\Column(length: 64, nullable: true)]
private ?string $fingerprint = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->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; }
}

View File

@@ -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;

View File

@@ -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 [showReport, setShowReport] = useState(false);
const [showChangelog, setShowChangelog] = useState(false);
return (
<div className="app">
@@ -29,11 +33,34 @@ export default function App() {
<Legend />
{/* Boutons flottants bas-gauche */}
<div className="app__actions">
<button
className="app__action-btn app__action-btn--info"
onClick={() => setShowChangelog(true)}
title="À propos / changelog"
aria-label="À propos"
>
?
</button>
<button
className="app__action-btn app__action-btn--report"
onClick={() => setShowReport(true)}
title="Signaler un bug ou soumettre une idée"
aria-label="Signalement"
>
</button>
</div>
<SpotPanel
spot={selectedSpot}
activeStep={activeStep}
onClose={() => setSelectedSpot(null)}
/>
{showReport && <ReportModal onClose={() => setShowReport(false)} />}
{showChangelog && <ChangelogModal onClose={() => setShowChangelog(false)} />}
</div>
);
}

View File

@@ -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),

View File

@@ -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;
}

View File

@@ -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 (
<div className="chlog-backdrop" onClick={onClose}>
<div className="chlog" role="dialog" aria-modal="true" aria-label="Changelog et limitations" onClick={e => e.stopPropagation()}>
<button className="chlog__close" onClick={onClose} aria-label="Fermer"></button>
<h2 className="chlog__title">À propos</h2>
<p className="chlog__subtitle">Radar Sargasses Caraïbes open &amp; gratuit</p>
<section className="chlog__section">
<h3 className="chlog__section-title chlog__section-title--green">En production</h3>
<ul className="chlog__list">
{PRODUCTION.map((item, i) => (
<li key={i} className="chlog__item">
<span className="chlog__item-icon">{item.icon}</span>
<span>{item.text}</span>
</li>
))}
</ul>
</section>
<section className="chlog__section">
<h3 className="chlog__section-title chlog__section-title--blue">Prochainement</h3>
<ul className="chlog__list">
{ROADMAP.map((item, i) => (
<li key={i} className="chlog__item chlog__item--muted">
<span className="chlog__item-icon">{item.icon}</span>
<span>{item.text}</span>
</li>
))}
</ul>
</section>
<section className="chlog__section">
<h3 className="chlog__section-title chlog__section-title--orange">Limitations connues</h3>
<ul className="chlog__list">
{LIMITATIONS.map((item, i) => (
<li key={i} className="chlog__limitation">
<span className="chlog__limitation-header">
<span className="chlog__item-icon">{item.icon}</span>
<strong>{item.title}</strong>
</span>
<span className="chlog__limitation-text">{item.text}</span>
</li>
))}
</ul>
</section>
<p className="chlog__footer">
Données : ESA Copernicus · Modèle de dérive : courants CMEMS + ERA5
</p>
</div>
</div>
);
}

View File

@@ -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; }
}

View File

@@ -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; }

View File

@@ -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 (
<div className="rmodal-backdrop" onClick={onClose}>
<div className="rmodal" role="dialog" aria-modal="true" aria-label="Signalement" onClick={e => e.stopPropagation()}>
<button className="rmodal__close" onClick={onClose} aria-label="Fermer"></button>
{status === 'ok' ? (
<div className="rmodal__thanks">
<span className="rmodal__thanks-icon">🙏</span>
<p>Merci, votre retour a bien été envoyé !</p>
</div>
) : (
<form className="rmodal__form" onSubmit={submit}>
<h2 className="rmodal__title">Votre retour</h2>
<div className="rmodal__type-row">
<label className={`rmodal__type-btn${type === 'bug' ? ' rmodal__type-btn--active' : ''}`}>
<input type="radio" name="type" value="bug" checked={type === 'bug'} onChange={() => setType('bug')} />
🐛 Signaler un bug
</label>
<label className={`rmodal__type-btn${type === 'idea' ? ' rmodal__type-btn--active' : ''}`}>
<input type="radio" name="type" value="idea" checked={type === 'idea'} onChange={() => setType('idea')} />
💡 Soumettre une idée
</label>
</div>
<textarea
className="rmodal__textarea"
value={message}
onChange={e => setMessage(e.target.value)}
placeholder={type === 'bug' ? 'Décrivez le problème observé…' : 'Votre idée ou suggestion…'}
maxLength={1000}
required
minLength={5}
rows={5}
/>
<p className="rmodal__counter">{message.length}/1000</p>
{status === 'error' && (
<p className="rmodal__error">Une erreur est survenue. Réessayez dans un instant.</p>
)}
<button type="submit" className="rmodal__submit" disabled={status === 'loading'}>
{status === 'loading' ? 'Envoi…' : 'Envoyer'}
</button>
</form>
)}
</div>
</div>
);
}