Phase 1 : seed CoastalPoints + frontend React complet

Backend :
- SeedCoastalPointsCommand : 29 points Antilles (idempotent)
  Martinique, Guadeloupe, Sainte-Lucie, Barbade, Saint-Martin

Frontend :
- api/client.js : wrapper fetch pour spots, observations, feedback
- SargassesMap : MapLibre GL JS, marqueurs spots, couche observations
  GeoJSON avec rechargement bbox dynamique à chaque mouvement carte
- SpotPanel : Spot Mode (score/niveau/distance/trend), bouton feedback
  terrain anonyme, gestion état loading/error/null
- App.jsx : composition map + panel, état selectedSpot
- vite.config.js : proxy /api → Symfony dev, build → backend/public/spa

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-01 03:17:43 -04:00
parent 4af5e62465
commit 0c1deb93cb
15 changed files with 1369 additions and 306 deletions

View File

@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client';
import './SpotPanel.css';
const LEVEL_LABEL = { low: 'OK', medium: 'Risque', high: 'Impact' };
const LEVEL_CLASS = { low: 'level--ok', medium: 'level--risk', high: 'level--impact' };
const TYPE_ICON = { beach: '🏖', port: '⚓', surf: '🏄', fishing: '🎣' };
export default function SpotPanel({ spot, onClose }) {
const [score, setScore] = useState(undefined); // undefined = loading
const [error, setError] = useState(null);
useEffect(() => {
if (!spot) return;
setScore(undefined);
setError(null);
api.spots.score(spot.id)
.then(data => setScore(data))
.catch(() => setError('Impossible de charger le score.'));
}, [spot?.id]);
if (!spot) return null;
const icon = TYPE_ICON[spot.type] ?? '📍';
const lvl = score?.score?.level;
return (
<div className="spot-panel">
<button className="spot-panel__close" onClick={onClose} aria-label="Fermer"></button>
<div className="spot-panel__header">
<span className="spot-panel__icon">{icon}</span>
<div>
<h2 className="spot-panel__name">{spot.name}</h2>
<p className="spot-panel__region">{spot.region}</p>
</div>
</div>
<div className="spot-panel__score">
{score === undefined && <p className="spot-panel__loading">Chargement</p>}
{error && <p className="spot-panel__error">{error}</p>}
{score !== undefined && !error && (
score.score === null
? <p className="spot-panel__no-data">Aucune donnée disponible pour ce spot.</p>
: (
<>
<div className={`spot-panel__level ${LEVEL_CLASS[lvl] ?? ''}`}>
<span className="spot-panel__level-label">{LEVEL_LABEL[lvl] ?? lvl}</span>
<span className="spot-panel__level-value">{score.score.value} / 100</span>
</div>
<dl className="spot-panel__details">
{score.score.distance != null && (
<>
<dt>Distance sargasses</dt>
<dd>{score.score.distance.toFixed(1)} km</dd>
</>
)}
{score.score.trend && (
<>
<dt>Tendance</dt>
<dd>{score.score.trend}</dd>
</>
)}
{score.score.computedAt && (
<>
<dt>Calculé le</dt>
<dd>{new Date(score.score.computedAt).toLocaleString('fr-FR')}</dd>
</>
)}
</dl>
</>
)
)}
</div>
<FeedbackButton spot={spot} />
</div>
);
}
function FeedbackButton({ spot }) {
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
const submit = async (hasSeaweed) => {
if (loading || sent) return;
setLoading(true);
const coords = spot.geometry?.coordinates;
if (!coords) return;
await api.feedback.create({
lat: coords[1],
lng: coords[0],
hasSeaweed,
coastalPointId: spot.id,
}).catch(console.error);
setLoading(false);
setSent(true);
};
if (sent) return <p className="spot-panel__feedback-thanks">Merci pour votre retour !</p>;
return (
<div className="spot-panel__feedback">
<p>Vous êtes sur place ?</p>
<div className="spot-panel__feedback-btns">
<button onClick={() => submit(true)} disabled={loading}> Je vois des sargasses</button>
<button onClick={() => submit(false)} disabled={loading}> Pas de sargasses</button>
</div>
</div>
);
}