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:
118
frontend/src/components/SpotPanel/SpotPanel.jsx
Normal file
118
frontend/src/components/SpotPanel/SpotPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user