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:
35
backend/migrations/Version20260410000000.php
Normal file
35
backend/migrations/Version20260410000000.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
67
backend/src/Controller/ReportController.php
Normal file
67
backend/src/Controller/ReportController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
47
backend/src/Entity/AppReport.php
Normal file
47
backend/src/Entity/AppReport.php
Normal 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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
124
frontend/src/components/ChangelogModal/ChangelogModal.css
Normal file
124
frontend/src/components/ChangelogModal/ChangelogModal.css
Normal 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;
|
||||
}
|
||||
101
frontend/src/components/ChangelogModal/ChangelogModal.jsx
Normal file
101
frontend/src/components/ChangelogModal/ChangelogModal.jsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
128
frontend/src/components/ReportModal/ReportModal.css
Normal file
128
frontend/src/components/ReportModal/ReportModal.css
Normal 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; }
|
||||
71
frontend/src/components/ReportModal/ReportModal.jsx
Normal file
71
frontend/src/components/ReportModal/ReportModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user