feat(ReserverController): Calcule l'itinéraire et affiche sur une carte

Ajoute le calcul de l'itinéraire via l'API Geoplateforme et affiche le résultat sur une carte Leaflet. Met à jour la CSP.
This commit is contained in:
Serreau Jovann
2026-02-04 12:35:53 +01:00
parent 900b55c07b
commit c837095cc3
6 changed files with 241 additions and 13 deletions

View File

@@ -4,6 +4,7 @@ import { CookieBanner } from "./tools/CookieBanner.js";
import { FlowReserve } from "./tools/FlowReserve.js";
import { FlowDatePicker } from "./tools/FlowDatePicker.js";
import { FlowAddToCart } from "./tools/FlowAddToCart.js";
import { LeafletMap } from "./tools/LeafletMap.js";
import * as Turbo from "@hotwired/turbo";
import { onLCP, onINP, onCLS } from 'web-vitals';
import AOS from 'aos';
@@ -252,7 +253,7 @@ const initRegisterLogic = () => {
// --- INITIALISATION ---
const registerComponents = () => {
const comps = [['utm-event', UtmEvent], ['utm-account', UtmAccount], ['cookie-banner', CookieBanner]];
const comps = [['utm-event', UtmEvent], ['utm-account', UtmAccount], ['cookie-banner', CookieBanner], ['leaflet-map', LeafletMap]];
comps.forEach(([name, cl]) => { if (!customElements.get(name)) customElements.define(name, cl); });
if(!customElements.get('flow-reserve'))

View File

@@ -0,0 +1,53 @@
export class LeafletMap extends HTMLElement {
connectedCallback() {
if (this.map) return;
// Ensure Leaflet is loaded
if (typeof L === 'undefined') {
const checkLeaflet = setInterval(() => {
if (typeof L !== 'undefined') {
clearInterval(checkLeaflet);
this.initMap();
}
}, 100);
return;
}
this.initMap();
}
initMap() {
if (!this.dataset.geometry) return;
try {
const geometry = JSON.parse(this.dataset.geometry);
// 'this' refers to the custom element itself.
// Leaflet can attach to it if it has dimensions.
this.map = L.map(this);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(this.map);
const layer = L.geoJSON(geometry).addTo(this.map);
this.map.fitBounds(layer.getBounds(), {padding: [50, 50]});
// Fix for map not rendering correctly sometimes when container size changes or initial load
setTimeout(() => {
this.map.invalidateSize();
}, 100);
} catch (e) {
console.error('Error initializing Leaflet map:', e);
}
}
disconnectedCallback() {
if (this.map) {
this.map.remove();
this.map = null;
}
}
}

View File

@@ -36,6 +36,7 @@ nelmio_security:
- "https://auth.esy-web.dev"
- "https://static.cloudflareinsights.com"
- "https://challenges.cloudflare.com"
- "https://unpkg.com"
connect-src:
- "'self'"
- "https://sentry.esy-web.dev"
@@ -54,10 +55,13 @@ nelmio_security:
- "'self'"
- "'unsafe-inline'"
- "https://chat.esy-web.dev"
- "https://unpkg.com"
img-src:
- "'self'"
- "data:"
- "https://chat.esy-web.dev"
- "https://*.tile.openstreetmap.org"
- "https://unpkg.com"
font-src:
- "'self'"
- "data:"

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260204084418 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE order_session ADD options JSON DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE order_session DROP options');
}
}

View File

@@ -1168,18 +1168,22 @@ class ReserverController extends AbstractController
$startLat = 49.849;
$startLon = 3.286;
// Formule de Haversine
$earthRadius = 6371; // km
// Calcul itinéraire via API Geoplateforme
$itineraireResponse = $client->request('GET', 'https://data.geopf.fr/navigation/itineraire', [
'query' => [
'resource' => 'bdtopo-osrm',
'start' => $startLon . ',' . $startLat,
'end' => $lon . ',' . $lat,
'profile' => 'car',
'optimization' => 'fastest',
'distanceUnit' => 'kilometer',
'geometryFormat' => 'geojson'
]
]);
$dLat = deg2rad($lat - $startLat);
$dLon = deg2rad($lon - $startLon);
$a = sin($dLat / 2) * sin($dLat / 2) +
cos(deg2rad($startLat)) * cos(deg2rad($lat)) *
sin($dLon / 2) * sin($dLon / 2);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
$distance = $earthRadius * $c;
$itineraire = $itineraireResponse->toArray();
$distance = $itineraire['distance'];
$geometry = $itineraire['geometry'] ?? null;
$rate = 0.50;
$trips = 4;
@@ -1209,7 +1213,8 @@ class ReserverController extends AbstractController
return $this->render('revervation/estimate_delivery.twig', [
'form' => $form->createView(),
'estimation' => $estimation,
'details' => $details ?? null
'details' => $details ?? null,
'geometry' => $geometry ?? null
]);
}
}

View File

@@ -0,0 +1,134 @@
{% extends 'revervation/base.twig' %}
{% block title %}Estimer les frais de livraison | Ludik Event{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
{% endblock %}
{% block body %}
<div class="min-h-screen bg-white font-sans antialiased py-20 px-4">
<div class="max-w-3xl mx-auto">
<h1 class="text-4xl md:text-5xl font-black text-slate-900 uppercase italic tracking-tighter mb-8 text-center">
Estimer <span class="text-[#f39e36]">la livraison</span>
</h1>
<div class="bg-slate-50 p-8 rounded-[2.5rem] border border-slate-100 shadow-xl">
<p class="text-center text-slate-500 font-medium mb-8">
Renseignez l'adresse de votre événement pour obtenir une estimation des frais de livraison.
</p>
{{ form_start(form, {'attr': {'class': 'space-y-6', 'data-turbo': 'false'}}) }}
<div>
{{ form_label(form.address, 'Adresse complète', {'label_attr': {'class': 'text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-2 block'}}) }}
{{ form_widget(form.address, {'attr': {'class': 'w-full bg-white border border-slate-200 rounded-2xl px-5 py-4 text-slate-900 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 transition-all', 'placeholder': 'Ex: 10 rue de la Paix'}}) }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
{{ form_label(form.zipCode, 'Code Postal', {'label_attr': {'class': 'text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-2 block'}}) }}
{{ form_widget(form.zipCode, {'attr': {'class': 'w-full bg-white border border-slate-200 rounded-2xl px-5 py-4 text-slate-900 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 transition-all', 'placeholder': '02000'}}) }}
</div>
<div>
{{ form_label(form.city, 'Ville', {'label_attr': {'class': 'text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-2 block'}}) }}
{{ form_widget(form.city, {'attr': {'class': 'w-full bg-white border border-slate-200 rounded-2xl px-5 py-4 text-slate-900 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 transition-all', 'placeholder': 'Laon'}}) }}
</div>
</div>
<div class="pt-4">
<button type="submit" class="w-full py-5 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-[0.2em] hover:bg-[#f39e36] transition-all shadow-lg active:scale-95">
Calculer
</button>
</div>
{{ form_end(form) }}
{% if estimation is defined and estimation is not null %}
<div class="mt-10 bg-blue-50 border border-blue-100 rounded-2xl overflow-hidden animate-in fade-in slide-in-from-bottom-4">
<div class="p-6 border-b border-blue-100/50">
<span class="text-[10px] font-black text-blue-400 uppercase tracking-widest block mb-2">Résultat de l'estimation</span>
{% if details.isFree %}
<div class="flex items-center gap-3">
<span class="text-3xl font-black text-emerald-500 italic">Offert !</span>
<span class="px-3 py-1 bg-emerald-100 text-emerald-600 rounded-full text-[10px] font-bold uppercase tracking-wide">Zone gratuite</span>
</div>
<p class="text-xs text-slate-500 mt-2">Votre événement se trouve à moins de 10km de nos locaux.</p>
{% else %}
<p class="text-3xl font-black text-slate-900 italic">
{{ estimation|format_currency('EUR') }}
</p>
{% endif %}
</div>
{% if details is defined %}
<div class="bg-white/50 p-6 space-y-3">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-4">Détails du calcul</p>
<div class="flex justify-between text-xs">
<span class="text-slate-500">Distance réelle (Aller)</span>
<span class="font-bold text-slate-700">{{ details.distance|number_format(1, ',', ' ') }} km</span>
</div>
<div class="flex justify-between text-xs">
<span class="text-slate-500">Franchise kilométrique</span>
<span class="font-bold text-emerald-500">- 10.0 km (Offerts)</span>
</div>
<div class="border-t border-slate-200/60 my-2"></div>
<div class="flex justify-between text-xs">
<span class="text-slate-500">Distance facturée</span>
<span class="font-bold text-slate-700">{{ details.chargedDistance|number_format(1, ',', ' ') }} km</span>
</div>
<div class="flex justify-between text-xs">
<span class="text-slate-500">Nombre de trajets</span>
<span class="font-bold text-slate-700">{{ details.trips }} (2 A/R)</span>
</div>
<div class="flex justify-between text-xs">
<span class="text-slate-500">Tarif kilométrique</span>
<span class="font-bold text-slate-700">{{ details.rate }} € / km</span>
</div>
{% if not details.isFree %}
<div class="bg-blue-100/50 p-3 rounded-xl mt-4 text-center">
<code class="text-[10px] text-blue-600 font-mono">
({{ details.distance|number_format(1) }} - 10) x {{ details.trips }} x {{ details.rate }}€ = {{ estimation|number_format(2) }}
</code>
</div>
{% endif %}
</div>
{% endif %}
{% if geometry is defined and geometry is not null %}
<leaflet-map class="h-96 w-full block rounded-xl mt-6 z-0 border-t border-blue-100"
data-geometry="{{ geometry|json_encode|e('html_attr') }}">
</leaflet-map>
{% endif %}
<div class="p-4 bg-blue-100/30 text-center">
<p class="text-[10px] text-slate-500 italic">
Cette estimation est donnée à titre indicatif et sera confirmée lors de la validation de votre devis.
</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
{% if geometry is defined and geometry is not null %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
nonce="{{ csp_nonce('script') }}"></script>
{% endif %}
{% endblock %}