feat: Phase 3 complete — push alerts, feedback scoring, S3 fallback, SPA routing, mobile CSS
- ImpactScoreService: implement getFeedbackBonus() (ST_Distance query on UserFeedback within 20km/6h) - ImpactScoreService: trigger PushNotificationService when score >= 70 - PushNotificationService: send VAPID WebPush to spot subscribers, clean expired subs - PushController: GET vapid-public-key, POST/DELETE subscribe with rate limiting - SentinelHubClient: add optional $collection param, add Sentinel-3 OLCI FAI evalscript (MCI) - IngestionService: add $collection param + HighCloudCoverageException for fallback logic - IngestionService: add ingestWithFallback() — tries S2, falls back to S3 on high cloud - IngestSentinelCommand: --source=auto (default) triggers ingestWithFallback - FeedbackController: rate limiting via apiFeedbackLimiter - Migration: push_subscription table - rate_limiter.yaml: api_read(120/min), api_feedback(10/min), api_push(5/min) - sw.js: service worker handling push events + notificationclick - usePushSubscription hook: subscribe/unsubscribe lifecycle with VAPID - SpotPanel: PushButton component integrated - SpotPanel.css + TimelineSlider.css: mobile responsive (bottom-sheet on small screens) - Caddyfile: SPA served at / with try_files fallback, sw.js served from root scope - vite.config.js: build outDir → backend/public (not /spa) - deploy/post-receive.sh: full deploy script (composer, npm build, migrations, cache, docker up) - docs/roadmap.md: all Phase 3 + transversal items marked done Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
42
frontend/public/sw.js
Normal file
42
frontend/public/sw.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// Service Worker — Sargasse-Sentry push notifications
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = event.data.json();
|
||||
} catch {
|
||||
payload = { title: 'Alerte sargasses', body: event.data.text() };
|
||||
}
|
||||
|
||||
const title = payload.title ?? 'Alerte sargasses';
|
||||
const options = {
|
||||
body: payload.body ?? '',
|
||||
icon: '/icons.svg',
|
||||
badge: '/icons.svg',
|
||||
tag: payload.spotId ? `sargasse-${payload.spotId}` : 'sargasse-alert',
|
||||
renotify: true,
|
||||
data: { url: payload.url ?? '/' },
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const targetUrl = event.notification.data?.url ?? '/';
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
||||
for (const client of windowClients) {
|
||||
if (client.url === targetUrl && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(targetUrl);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -40,4 +40,16 @@ export const api = {
|
||||
feedback: {
|
||||
create: (data) => post('/feedback', data),
|
||||
},
|
||||
push: {
|
||||
vapidKey: () => get('/push/vapid-public-key'),
|
||||
subscribe: (data) => post('/push/subscribe', data),
|
||||
unsubscribe: async (endpoint) => {
|
||||
const res = await fetch(BASE_URL + '/push/subscribe', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint }),
|
||||
});
|
||||
if (!res.ok && res.status !== 204) throw new Error(`API error ${res.status}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -121,3 +121,47 @@
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #334155;
|
||||
}
|
||||
|
||||
/* Push notifications */
|
||||
.spot-panel__push {
|
||||
border-top: 1px solid #334155;
|
||||
padding-top: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.spot-panel__push-btn {
|
||||
width: 100%;
|
||||
padding: 9px 16px;
|
||||
border: 1px solid #475569;
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.spot-panel__push-btn:hover:not(:disabled) { background: #1e293b; border-color: #64748b; color: #f1f5f9; }
|
||||
.spot-panel__push-btn--active { border-color: #0ea5e9; color: #38bdf8; }
|
||||
.spot-panel__push-btn:disabled { opacity: .5; cursor: default; }
|
||||
.spot-panel__push-error { color: #f87171; font-size: 12px; margin-top: 6px; }
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 480px) {
|
||||
.spot-panel {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transform: none;
|
||||
width: 100%;
|
||||
border-radius: 16px 16px 0 0;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.spot-panel__feedback-btns {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.spot-panel__details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import usePushSubscription from '../../hooks/usePushSubscription';
|
||||
import './SpotPanel.css';
|
||||
|
||||
const LEVEL_LABEL = { low: 'OK', medium: 'Risque', high: 'Impact' };
|
||||
@@ -89,6 +90,7 @@ export default function SpotPanel({ spot, activeStep, onClose }) {
|
||||
</div>
|
||||
|
||||
<FeedbackButton spot={spot} />
|
||||
<PushButton spotId={spot.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -123,3 +125,23 @@ function FeedbackButton({ spot }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PushButton({ spotId }) {
|
||||
const { supported, subscribed, loading, error, toggle } = usePushSubscription(spotId);
|
||||
|
||||
if (!supported) return null;
|
||||
|
||||
return (
|
||||
<div className="spot-panel__push">
|
||||
<button
|
||||
className={`spot-panel__push-btn ${subscribed ? 'spot-panel__push-btn--active' : ''}`}
|
||||
onClick={toggle}
|
||||
disabled={loading}
|
||||
aria-pressed={subscribed}
|
||||
>
|
||||
{loading ? '…' : subscribed ? '🔔 Alertes activées' : '🔕 Activer les alertes'}
|
||||
</button>
|
||||
{error && <p className="spot-panel__push-error">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,3 +29,16 @@
|
||||
background: #0ea5e9;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 480px) {
|
||||
.timeline {
|
||||
top: 8px;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
.timeline__step {
|
||||
padding: 5px 9px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
70
frontend/src/hooks/usePushSubscription.js
Normal file
70
frontend/src/hooks/usePushSubscription.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../api/client';
|
||||
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = window.atob(base64);
|
||||
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook managing the Web Push subscription lifecycle for a given coastalPointId.
|
||||
* Returns { supported, subscribed, loading, error, toggle }
|
||||
*/
|
||||
export default function usePushSubscription(coastalPointId) {
|
||||
const [supported, setSupported] = useState(false);
|
||||
const [subscribed, setSubscribed] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSupported('serviceWorker' in navigator && 'PushManager' in window);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supported) return;
|
||||
|
||||
navigator.serviceWorker.ready.then((reg) =>
|
||||
reg.pushManager.getSubscription()
|
||||
).then((sub) => {
|
||||
setSubscribed(sub !== null);
|
||||
}).catch(() => {});
|
||||
}, [supported]);
|
||||
|
||||
const toggle = async () => {
|
||||
if (!supported || loading) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const existingSub = await reg.pushManager.getSubscription();
|
||||
|
||||
if (existingSub) {
|
||||
await api.push.unsubscribe(existingSub.endpoint).catch(() => {});
|
||||
await existingSub.unsubscribe();
|
||||
setSubscribed(false);
|
||||
} else {
|
||||
const { publicKey } = await api.push.vapidKey();
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
const json = sub.toJSON();
|
||||
await api.push.subscribe({
|
||||
endpoint: json.endpoint,
|
||||
keys: json.keys,
|
||||
coastalPointId,
|
||||
});
|
||||
setSubscribed(true);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.message ?? 'Erreur abonnement push');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { supported, subscribed, loading, error, toggle };
|
||||
}
|
||||
@@ -3,6 +3,12 @@ import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -13,7 +13,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: '../backend/public/spa',
|
||||
emptyOutDir: true,
|
||||
outDir: '../backend/public',
|
||||
emptyOutDir: false, // ne pas supprimer index.php, .htaccess Symfony, etc.
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user