Add search and PARCOURSMOB integration
All checks were successful
Publish To Prod / deploy_and_publish (push) Successful in 40s
All checks were successful
Publish To Prod / deploy_and_publish (push) Successful in 40s
This commit is contained in:
@@ -1,3 +1,13 @@
|
||||
<!-- MapLibre GL JS + PMTiles + Polyline -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/maplibre-gl@^5.2.0/dist/maplibre-gl.css" rel="stylesheet" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@^5.2.0/dist/maplibre-gl.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/pmtiles@^4.3.0/dist/pmtiles.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/polyline@0.2.0/src/polyline.js"></script>
|
||||
|
||||
<!-- AlpineJS + Collapse plugin -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
{{- with resources.Get "js/main.js" }}
|
||||
{{- $opts := dict
|
||||
"minify" (not hugo.IsDevelopment)
|
||||
|
||||
214
themes/mms43/layouts/_partials/search-block.html
Normal file
214
themes/mms43/layouts/_partials/search-block.html
Normal file
@@ -0,0 +1,214 @@
|
||||
{{/*
|
||||
Partial: search-block
|
||||
|
||||
Paramètres:
|
||||
- showTitle: bool (afficher le titre, défaut: true)
|
||||
- action: string (URL de soumission du formulaire)
|
||||
- method: string (méthode du formulaire, défaut: GET)
|
||||
*/}}
|
||||
|
||||
{{ $showTitle := default true .showTitle }}
|
||||
{{ $action := .action }}
|
||||
{{ $method := default "GET" .method }}
|
||||
|
||||
{{ $iconLocation := resources.Get "images/picto/location_on_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg" }}
|
||||
{{ $iconCalendar := resources.Get "images/picto/calendar_today_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg" }}
|
||||
{{ $iconSchedule := resources.Get "images/picto/schedule_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg" }}
|
||||
{{ $iconSearch := resources.Get "images/picto/search_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg" }}
|
||||
{{ $iconSwap := resources.Get "images/picto/sync_alt_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg" }}
|
||||
|
||||
<section class="search-block" x-data="searchBlock()">
|
||||
{{ if $showTitle }}
|
||||
<h2 class="search-title">{{ site.Params.search.title }}</h2>
|
||||
{{ end }}
|
||||
|
||||
<form class="search-form"{{ with $action }} action="{{ . }}"{{ end }}{{ with $method }} method="{{ . }}"{{ end }} @submit="validateForm($event)">
|
||||
<div class="form-group-locations">
|
||||
<div class="form-group autocomplete-container">
|
||||
<label for="departure-input">
|
||||
{{ if $iconLocation }}<img src="{{ $iconLocation.RelPermalink }}" alt="" class="label-icon" />{{ end }}
|
||||
{{ site.Params.search.labelDepart }}
|
||||
</label>
|
||||
<input type="hidden" name="departure" x-model="departure.address" />
|
||||
<input type="text" id="departure-input" x-ref="departure"
|
||||
x-model="departure.input"
|
||||
@input.debounce.300ms="autocomplete('departure')"
|
||||
@focus="departure.showResults = departure.results.length > 0"
|
||||
@click.outside="departure.showResults = false"
|
||||
autocomplete="off"
|
||||
placeholder=""
|
||||
:class="{ 'input-error': errors.departure }"
|
||||
required />
|
||||
<ul class="autocomplete-results" x-show="departure.showResults && departure.results.length > 0" x-cloak>
|
||||
<template x-for="item in departure.results" :key="item.properties.id">
|
||||
<li @click="selectAddress('departure', item)" x-text="item.properties.label"></li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button type="button" class="swap-btn" @click="swapLocations()" aria-label="Inverser départ et destination">
|
||||
{{ if $iconSwap }}<img src="{{ $iconSwap.RelPermalink }}" alt="" />{{ end }}
|
||||
</button>
|
||||
|
||||
<div class="form-group autocomplete-container">
|
||||
<label for="destination-input">
|
||||
{{ if $iconLocation }}<img src="{{ $iconLocation.RelPermalink }}" alt="" class="label-icon" />{{ end }}
|
||||
{{ site.Params.search.labelDestination }}
|
||||
</label>
|
||||
<input type="hidden" name="destination" x-model="destination.address" />
|
||||
<input type="text" id="destination-input" x-ref="destination"
|
||||
x-model="destination.input"
|
||||
@input.debounce.300ms="autocomplete('destination')"
|
||||
@focus="destination.showResults = destination.results.length > 0"
|
||||
@click.outside="destination.showResults = false"
|
||||
autocomplete="off"
|
||||
placeholder=""
|
||||
:class="{ 'input-error': errors.destination }"
|
||||
required />
|
||||
<ul class="autocomplete-results" x-show="destination.showResults && destination.results.length > 0" x-cloak>
|
||||
<template x-for="item in destination.results" :key="item.properties.id">
|
||||
<li @click="selectAddress('destination', item)" x-text="item.properties.label"></li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group-row">
|
||||
<div class="form-group">
|
||||
<label for="departuredate">
|
||||
{{ if $iconCalendar }}<img src="{{ $iconCalendar.RelPermalink }}" alt="" class="label-icon" />{{ end }}
|
||||
{{ site.Params.search.labelDate }}
|
||||
</label>
|
||||
<input type="date" id="departuredate" name="departuredate" x-model="departuredate"
|
||||
:class="{ 'input-error': errors.departuredate }"
|
||||
required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="departuretime">
|
||||
{{ if $iconSchedule }}<img src="{{ $iconSchedule.RelPermalink }}" alt="" class="label-icon" />{{ end }}
|
||||
{{ site.Params.search.labelHeure }}
|
||||
</label>
|
||||
<input type="time" id="departuretime" name="departuretime" x-model="departuretime"
|
||||
:class="{ 'input-error': errors.departuretime }"
|
||||
required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="search-btn">
|
||||
{{ if $iconSearch }}<img src="{{ $iconSearch.RelPermalink }}" alt="" class="btn-icon" />{{ end }}
|
||||
{{ site.Params.search.buttonText }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
function searchBlock() {
|
||||
const data = window.__PARCOURSMOB_DATA__ || {};
|
||||
|
||||
return {
|
||||
departure: {
|
||||
input: data.departure?.properties?.label || '',
|
||||
address: data.departure ? JSON.stringify(data.departure) : '',
|
||||
results: [],
|
||||
showResults: false
|
||||
},
|
||||
destination: {
|
||||
input: data.destination?.properties?.label || '',
|
||||
address: data.destination ? JSON.stringify(data.destination) : '',
|
||||
results: [],
|
||||
showResults: false
|
||||
},
|
||||
departuredate: data.departure_date || '',
|
||||
departuretime: data.departure_time || '',
|
||||
errors: {
|
||||
departure: false,
|
||||
destination: false,
|
||||
departuredate: false,
|
||||
departuretime: false
|
||||
},
|
||||
|
||||
validateForm(event) {
|
||||
// Reset errors
|
||||
this.errors.departure = false;
|
||||
this.errors.destination = false;
|
||||
this.errors.departuredate = false;
|
||||
this.errors.departuretime = false;
|
||||
|
||||
let isValid = true;
|
||||
|
||||
// Vérifier que le départ a été sélectionné (pas juste du texte tapé)
|
||||
if (!this.departure.address) {
|
||||
this.errors.departure = true;
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Vérifier que la destination a été sélectionnée
|
||||
if (!this.destination.address) {
|
||||
this.errors.destination = true;
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Vérifier la date
|
||||
if (!this.departuredate) {
|
||||
this.errors.departuredate = true;
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Vérifier l'heure
|
||||
if (!this.departuretime) {
|
||||
this.errors.departuretime = true;
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
async autocomplete(field) {
|
||||
const fieldData = this[field];
|
||||
// Réinitialiser l'adresse si l'utilisateur modifie le texte
|
||||
fieldData.address = '';
|
||||
this.errors[field] = false;
|
||||
|
||||
if (!fieldData.input || fieldData.input.length < 3) {
|
||||
fieldData.results = [];
|
||||
fieldData.showResults = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://api-adresse.data.gouv.fr/search/?q=${encodeURIComponent(fieldData.input)}&limit=5`);
|
||||
const json = await response.json();
|
||||
fieldData.results = json.features || [];
|
||||
fieldData.showResults = fieldData.results.length > 0;
|
||||
} catch (e) {
|
||||
console.error('Erreur autocomplétion:', e);
|
||||
fieldData.results = [];
|
||||
fieldData.showResults = false;
|
||||
}
|
||||
},
|
||||
|
||||
selectAddress(field, item) {
|
||||
const fieldData = this[field];
|
||||
fieldData.input = item.properties.label;
|
||||
fieldData.address = JSON.stringify(item);
|
||||
fieldData.results = [];
|
||||
fieldData.showResults = false;
|
||||
this.errors[field] = false;
|
||||
},
|
||||
|
||||
swapLocations() {
|
||||
const tempInput = this.departure.input;
|
||||
const tempAddress = this.departure.address;
|
||||
|
||||
this.departure.input = this.destination.input;
|
||||
this.departure.address = this.destination.address;
|
||||
|
||||
this.destination.input = tempInput;
|
||||
this.destination.address = tempAddress;
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
765
themes/mms43/layouts/_partials/search-results.html
Normal file
765
themes/mms43/layouts/_partials/search-results.html
Normal file
@@ -0,0 +1,765 @@
|
||||
{{/*
|
||||
Partial: search-results
|
||||
Affiche les résultats de recherche en 2 colonnes :
|
||||
- Gauche : accordéons par catégorie
|
||||
- Droite : carte MapLibre
|
||||
*/}}
|
||||
|
||||
{{ $iconArrow := resources.Get "images/picto/keyboard_arrow_up_24dp_1F1F1F_FILL1_wght400_GRAD0_opsz24.svg" }}
|
||||
{{ $iconArrowRight := resources.Get "images/picto/arrow_right_alt_24dp_1F1F1F_FILL1_wght400_GRAD0_opsz24.svg" }}
|
||||
{{ $iconWalk := resources.Get "images/picto/directions_walk_24dp_1F1F1F_FILL1_wght400_GRAD0_opsz24.svg" }}
|
||||
{{ $iconBus := resources.Get "images/picto/directions_bus_24dp_1F1F1F_FILL1_wght400_GRAD0_opsz24.svg" }}
|
||||
|
||||
<div class="search-results-container" x-data="selectionManager()">
|
||||
<!-- Colonne gauche : Accordéons -->
|
||||
<div class="search-results-accordions">
|
||||
<!-- Transports en commun -->
|
||||
<div class="result-accordion" x-data="{ open: false }">
|
||||
<button type="button" class="accordion-header" @click="open = !open" :class="{ 'active': open }">
|
||||
<span class="accordion-title">Transports en commun</span>
|
||||
<span class="accordion-badge" x-text="results.public_transit?.number || 0"></span>
|
||||
{{ if $iconArrow }}
|
||||
<img src="{{ $iconArrow.RelPermalink }}" alt="" class="accordion-arrow" :class="{ 'open': open }" />
|
||||
{{ end }}
|
||||
</button>
|
||||
<div class="accordion-content" x-show="open" x-collapse x-cloak>
|
||||
<div class="accordion-inner">
|
||||
<template x-if="results.public_transit?.number > 0">
|
||||
<div class="transit-journeys">
|
||||
<template x-for="(journey, index) in results.public_transit.results" :key="index">
|
||||
<div class="transit-journey" @click="selectItem('transit', index, journey)" :class="{ 'active': isSelected('transit', index) }">
|
||||
<!-- En-tête du trajet -->
|
||||
<div class="journey-header">
|
||||
<div class="journey-date" x-text="formatJourneyDate(journey.startTime)"></div>
|
||||
<div class="journey-times">
|
||||
<span class="journey-time" x-text="formatTime(journey.startTime)"></span>
|
||||
<img src="{{ $iconArrowRight.RelPermalink }}" alt="→" class="journey-arrow" />
|
||||
<span class="journey-time" x-text="formatTime(journey.endTime)"></span>
|
||||
<span class="journey-duration" x-text="formatDuration(journey.duration)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Séparateur -->
|
||||
<div class="journey-separator"></div>
|
||||
<!-- Détail des legs -->
|
||||
<div class="journey-legs">
|
||||
<template x-for="(leg, legIndex) in journey.legs" :key="legIndex">
|
||||
<div class="journey-leg">
|
||||
<div class="leg-icon">
|
||||
<template x-if="leg.mode === 'WALK'">
|
||||
<img src="{{ $iconWalk.RelPermalink }}" alt="Marche" />
|
||||
</template>
|
||||
<template x-if="leg.mode === 'BUS' || leg.mode === 'COACH'">
|
||||
<img src="{{ $iconBus.RelPermalink }}" alt="Bus" />
|
||||
</template>
|
||||
<template x-if="leg.mode !== 'WALK' && leg.mode !== 'BUS' && leg.mode !== 'COACH'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
|
||||
<path d="M4 15.5C4 17.43 5.57 19 7.5 19L6 20.5v.5h12v-.5L16.5 19c1.93 0 3.5-1.57 3.5-3.5V5c0-3.5-3.58-4-8-4s-8 .5-8 4v10.5zm8-12.5c3.52 0 5.78.28 6 1h-12c.22-.72 2.48-1 6-1zM6 7h12v4H6V7zm6 10.5a1.5 1.5 0 110-3 1.5 1.5 0 010 3z"/>
|
||||
</svg>
|
||||
</template>
|
||||
</div>
|
||||
<div class="leg-details">
|
||||
<template x-if="leg.mode === 'WALK'">
|
||||
<div>
|
||||
<p class="leg-line">Marche à pieds</p>
|
||||
<p class="leg-text">
|
||||
<span x-text="formatDistance(leg.distance)"></span>
|
||||
<span class="leg-duration" x-text="'(' + formatDuration(leg.duration) + ')'"></span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="leg.mode !== 'WALK'">
|
||||
<div>
|
||||
<p class="leg-line" x-text="'Ligne ' + (leg.routeShortName || leg.route?.shortName || '')"></p>
|
||||
<p class="leg-operator" x-text="leg.agencyName"></p>
|
||||
<p class="leg-direction" x-show="leg.headsign">
|
||||
Direction <span x-text="leg.headsign"></span>
|
||||
</p>
|
||||
<div class="leg-timeline">
|
||||
<div class="leg-stop leg-stop-departure">
|
||||
<span class="leg-stop-dot"></span>
|
||||
<span class="leg-stop-time" x-text="formatTime(leg.startTime)"></span>
|
||||
<span class="leg-stop-name" x-text="leg.from.name"></span>
|
||||
</div>
|
||||
<img src="{{ $iconArrowRight.RelPermalink }}" alt="↓" class="leg-timeline-arrow" />
|
||||
<div class="leg-stop leg-stop-arrival">
|
||||
<span class="leg-stop-dot"></span>
|
||||
<span class="leg-stop-time" x-text="formatTime(leg.endTime)"></span>
|
||||
<span class="leg-stop-name" x-text="leg.to.name"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!results.public_transit?.number">
|
||||
<p class="no-result-text">Aucun transport en commun disponible pour ce trajet.</p>
|
||||
</template>
|
||||
{{ with .Params.contactCTA.transit }}
|
||||
<div class="accordion-cta">
|
||||
<p>{{ . }}</p>
|
||||
<a href="tel:{{ site.Params.phone | replaceRE " " "" }}" class="accordion-cta-phone">{{ site.Params.phone }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Covoiturage -->
|
||||
<div class="result-accordion" x-data="{ open: false }">
|
||||
<button type="button" class="accordion-header" @click="open = !open" :class="{ 'active': open }">
|
||||
<span class="accordion-title">Covoiturage</span>
|
||||
<span class="accordion-badge" x-text="results.carpools?.number || 0"></span>
|
||||
{{ if $iconArrow }}
|
||||
<img src="{{ $iconArrow.RelPermalink }}" alt="" class="accordion-arrow" :class="{ 'open': open }" />
|
||||
{{ end }}
|
||||
</button>
|
||||
<div class="accordion-content" x-show="open" x-collapse x-cloak>
|
||||
<div class="accordion-inner">
|
||||
<template x-if="results.carpools?.number > 0">
|
||||
<div class="carpool-list">
|
||||
<template x-for="(carpool, index) in results.carpools.results" :key="index">
|
||||
<div class="carpool-item" @click="selectItem('carpool', index, carpool)" :class="{ 'active': isSelected('carpool', index) }">
|
||||
<div class="carpool-preview">
|
||||
<div class="carpool-header">
|
||||
<span class="carpool-driver">Trajet avec <span x-text="carpool.ocss?.driver?.alias || 'un conducteur'"></span></span>
|
||||
<span class="carpool-price" x-show="carpool.ocss?.price?.amount" x-text="formatCarpoolPrice(carpool.ocss?.price?.amount)"></span>
|
||||
</div>
|
||||
<div class="carpool-date" x-show="carpool.ocss?.passengerPickupDate" x-text="'Départ ' + formatCarpoolDate(carpool.ocss?.passengerPickupDate)"></div>
|
||||
</div>
|
||||
<template x-if="carpool.ocss?.webUrl">
|
||||
<div class="carpool-link-container">
|
||||
<a :href="carpool.ocss.webUrl" target="_blank" rel="noopener" class="carpool-operator-link">
|
||||
Voir les détails sur <span x-text="carpool.ocss?.operator || 'le site'"></span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!results.carpools?.number">
|
||||
<p class="no-result-text">Aucun covoiturage disponible pour ce trajet.</p>
|
||||
</template>
|
||||
{{ with .Params.contactCTA.carpool }}
|
||||
<div class="accordion-cta">
|
||||
<p>{{ . }}</p>
|
||||
<a href="tel:{{ site.Params.phone | replaceRE " " "" }}" class="accordion-cta-phone">{{ site.Params.phone }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transport solidaire -->
|
||||
<div class="result-accordion" x-data="{ open: false, get totalDrivers() { return (results.solidarity_drivers?.number || 0) + (results.organized_carpools?.number || 0); } }">
|
||||
<button type="button" class="accordion-header" @click="open = !open" :class="{ 'active': open }">
|
||||
<span class="accordion-title">Transport solidaire</span>
|
||||
<span class="accordion-badge" x-text="totalDrivers"></span>
|
||||
{{ if $iconArrow }}
|
||||
<img src="{{ $iconArrow.RelPermalink }}" alt="" class="accordion-arrow" :class="{ 'open': open }" />
|
||||
{{ end }}
|
||||
</button>
|
||||
<div class="accordion-content" x-show="open" x-collapse x-cloak>
|
||||
<div class="accordion-inner">
|
||||
<template x-if="totalDrivers > 0">
|
||||
<div class="solidarity-result">
|
||||
<p>Nous avons trouvé</p>
|
||||
<p class="solidarity-count" x-text="totalDrivers + ' conducteur' + (totalDrivers > 1 ? 's' : '')"></p>
|
||||
<p>disponible<span x-text="totalDrivers > 1 ? 's' : ''"></span> pour faire le trajet que vous recherchez</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="totalDrivers === 0">
|
||||
<p class="no-result-text">Aucun transport solidaire disponible pour ce trajet.</p>
|
||||
</template>
|
||||
{{ with .Params.contactCTA.solidarity }}
|
||||
<div class="accordion-cta">
|
||||
<p>{{ . }}</p>
|
||||
<a href="tel:{{ site.Params.phone | replaceRE " " "" }}" class="accordion-cta-phone">{{ site.Params.phone }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location de véhicules -->
|
||||
<div class="result-accordion" x-data="{ open: false }">
|
||||
<button type="button" class="accordion-header" @click="open = !open" :class="{ 'active': open }">
|
||||
<span class="accordion-title">Location de véhicules</span>
|
||||
<span class="accordion-badge" x-text="results.vehicles?.number || 0"></span>
|
||||
{{ if $iconArrow }}
|
||||
<img src="{{ $iconArrow.RelPermalink }}" alt="" class="accordion-arrow" :class="{ 'open': open }" />
|
||||
{{ end }}
|
||||
</button>
|
||||
<div class="accordion-content" x-show="open" x-collapse x-cloak>
|
||||
<div class="accordion-inner">
|
||||
<template x-if="results.vehicles?.number > 0">
|
||||
<div class="solidarity-result">
|
||||
<p>Nous avons trouvé</p>
|
||||
<p class="solidarity-count" x-text="results.vehicles.number + ' véhicule' + (results.vehicles.number > 1 ? 's' : '')"></p>
|
||||
<p>disponible<span x-text="results.vehicles.number > 1 ? 's' : ''"></span></p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!results.vehicles?.number">
|
||||
<p class="no-result-text">Aucun véhicule disponible pour ce trajet.</p>
|
||||
</template>
|
||||
{{ with .Params.contactCTA.vehicles }}
|
||||
<div class="accordion-cta">
|
||||
<p>{{ . }}</p>
|
||||
<a href="tel:{{ site.Params.phone | replaceRE " " "" }}" class="accordion-cta-phone">{{ site.Params.phone }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Solutions complémentaires -->
|
||||
<div class="result-accordion" x-data="{ open: false }">
|
||||
<button type="button" class="accordion-header" @click="open = !open" :class="{ 'active': open }">
|
||||
<span class="accordion-title">Solutions complémentaires</span>
|
||||
<span class="accordion-badge" x-text="results.local_solutions?.number || 0"></span>
|
||||
{{ if $iconArrow }}
|
||||
<img src="{{ $iconArrow.RelPermalink }}" alt="" class="accordion-arrow" :class="{ 'open': open }" />
|
||||
{{ end }}
|
||||
</button>
|
||||
<div class="accordion-content" x-show="open" x-collapse x-cloak>
|
||||
<div class="accordion-inner">
|
||||
<template x-if="results.local_solutions?.number > 0">
|
||||
<div class="local-solutions-list">
|
||||
<template x-for="(solution, index) in results.local_solutions.results" :key="index">
|
||||
<div class="local-solution-item" @click="selectItem('solution', index, solution)" :class="{ 'active': isSelected('solution', index) }">
|
||||
<h4 class="local-solution-title" x-text="solution.title || solution.name"></h4>
|
||||
<p class="local-solution-description" x-text="solution.description" x-show="solution.description"></p>
|
||||
<template x-if="solution.url">
|
||||
<a :href="solution.url" target="_blank" rel="noopener" class="local-solution-link" @click.stop>En savoir plus</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!results.local_solutions?.number">
|
||||
<p class="no-result-text">Aucune solution complémentaire disponible pour ce trajet.</p>
|
||||
</template>
|
||||
{{ with .Params.localSolutionsText }}
|
||||
<p class="local-solutions-text">{{ . }}</p>
|
||||
{{ end }}
|
||||
{{ with .Params.contactCTA.localSolutions }}
|
||||
<div class="accordion-cta">
|
||||
<p>{{ . }}</p>
|
||||
<a href="tel:{{ site.Params.phone | replaceRE " " "" }}" class="accordion-cta-phone">{{ site.Params.phone }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colonne droite : Carte MapLibre -->
|
||||
<div class="search-results-map">
|
||||
<div id="map" x-ref="map" x-init="initMap()"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Gestionnaire de sélection Alpine.js
|
||||
function selectionManager() {
|
||||
return {
|
||||
selectedMode: null,
|
||||
selectedIndex: null,
|
||||
|
||||
selectItem(mode, index, item) {
|
||||
this.selectedMode = mode;
|
||||
this.selectedIndex = index;
|
||||
|
||||
// Appeler la fonction d'affichage appropriée
|
||||
if (mode === 'transit') {
|
||||
window.showJourneyRoute(item, index);
|
||||
} else if (mode === 'carpool') {
|
||||
window.showCarpoolRoute(item, index);
|
||||
} else if (mode === 'solution') {
|
||||
window.showSolutionZone(item, index);
|
||||
}
|
||||
},
|
||||
|
||||
isSelected(mode, index) {
|
||||
return this.selectedMode === mode && this.selectedIndex === index;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Décodeur polyline Google (implémentation native)
|
||||
function decodePolyline(encoded, precision) {
|
||||
precision = precision || 5;
|
||||
const factor = Math.pow(10, precision);
|
||||
const len = encoded.length;
|
||||
let index = 0;
|
||||
let lat = 0;
|
||||
let lng = 0;
|
||||
const coordinates = [];
|
||||
|
||||
while (index < len) {
|
||||
let b;
|
||||
let shift = 0;
|
||||
let result = 0;
|
||||
|
||||
do {
|
||||
b = encoded.charCodeAt(index++) - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
|
||||
const dlat = ((result & 1) ? ~(result >> 1) : (result >> 1));
|
||||
lat += dlat;
|
||||
|
||||
shift = 0;
|
||||
result = 0;
|
||||
|
||||
do {
|
||||
b = encoded.charCodeAt(index++) - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
|
||||
const dlng = ((result & 1) ? ~(result >> 1) : (result >> 1));
|
||||
lng += dlng;
|
||||
|
||||
coordinates.push([lat / factor, lng / factor]);
|
||||
}
|
||||
|
||||
return coordinates;
|
||||
}
|
||||
|
||||
// Compteur pour les layers de legs
|
||||
window.journeyLayerIds = [];
|
||||
|
||||
// Fonction globale pour afficher le tracé sur la carte
|
||||
window.showJourneyRoute = function(journey, index) {
|
||||
console.log('showJourneyRoute called', index, journey);
|
||||
|
||||
const map = window.parcoursmobMap;
|
||||
if (!map) {
|
||||
console.log('Map not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
// Supprimer le message d'info covoiturage s'il existe
|
||||
const existingInfo = document.querySelector('.carpool-route-info');
|
||||
if (existingInfo) existingInfo.remove();
|
||||
|
||||
// Supprimer les anciens tracés et zones
|
||||
window.journeyLayerIds.forEach(id => {
|
||||
if (map.getSource(id)) {
|
||||
if (map.getLayer(id + '-line')) map.removeLayer(id + '-line');
|
||||
if (map.getLayer(id + '-fill')) map.removeLayer(id + '-fill');
|
||||
map.removeSource(id);
|
||||
}
|
||||
});
|
||||
window.journeyLayerIds = [];
|
||||
|
||||
// Convertir le Proxy Alpine en objet simple
|
||||
const legs = JSON.parse(JSON.stringify(journey.legs));
|
||||
|
||||
// Bounds pour ajuster la vue
|
||||
const bounds = new maplibregl.LngLatBounds();
|
||||
|
||||
legs.forEach((leg, legIndex) => {
|
||||
console.log('Leg:', leg);
|
||||
|
||||
const coordinates = [];
|
||||
|
||||
if (leg.legGeometry && leg.legGeometry.points) {
|
||||
console.log('Decoding polyline:', leg.legGeometry.points, 'precision:', leg.legGeometry.precision);
|
||||
try {
|
||||
const decoded = decodePolyline(leg.legGeometry.points, leg.legGeometry.precision || 5);
|
||||
console.log('Decoded points:', decoded.length);
|
||||
decoded.forEach(point => {
|
||||
const lat = point[0];
|
||||
const lng = point[1];
|
||||
if (lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
||||
coordinates.push([lng, lat]);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Erreur décodage polyline:', e);
|
||||
}
|
||||
} else if (leg.from && leg.to) {
|
||||
console.log('Using from/to fallback');
|
||||
const fromLng = leg.from.lon || leg.from.lng;
|
||||
const fromLat = leg.from.lat;
|
||||
const toLng = leg.to.lon || leg.to.lng;
|
||||
const toLat = leg.to.lat;
|
||||
|
||||
if (fromLat && fromLng) coordinates.push([fromLng, fromLat]);
|
||||
if (toLat && toLng) coordinates.push([toLng, toLat]);
|
||||
}
|
||||
|
||||
if (coordinates.length < 2) {
|
||||
console.log('Not enough coordinates for leg', legIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ajouter les coordonnées aux bounds
|
||||
coordinates.forEach(coord => bounds.extend(coord));
|
||||
|
||||
const sourceId = 'journey-leg-' + legIndex;
|
||||
window.journeyLayerIds.push(sourceId);
|
||||
|
||||
// Ajouter la source
|
||||
map.addSource(sourceId, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: coordinates
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Déterminer la couleur et le style selon le type de leg
|
||||
const isWalk = leg.mode === 'WALK';
|
||||
let lineColor = '#888888'; // Gris par défaut pour la marche
|
||||
|
||||
if (!isWalk) {
|
||||
// Utiliser la couleur de la route si disponible
|
||||
lineColor = leg.routeColor ? '#' + leg.routeColor : '#283959';
|
||||
}
|
||||
|
||||
// Ajouter le layer
|
||||
map.addLayer({
|
||||
id: sourceId + '-line',
|
||||
type: 'line',
|
||||
source: sourceId,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
'line-color': lineColor,
|
||||
'line-width': isWalk ? 3 : 4,
|
||||
'line-dasharray': isWalk ? [2, 2] : [1, 0]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Ajuster la vue si on a des coordonnées
|
||||
if (!bounds.isEmpty()) {
|
||||
map.fitBounds(bounds, { padding: 50 });
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour afficher le trajet covoiturage sur la carte
|
||||
window.showCarpoolRoute = function(carpool, index) {
|
||||
console.log('showCarpoolRoute called', index, carpool);
|
||||
|
||||
const map = window.parcoursmobMap;
|
||||
if (!map) {
|
||||
console.log('Map not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
// Supprimer les anciens tracés et zones
|
||||
window.journeyLayerIds.forEach(id => {
|
||||
if (map.getSource(id)) {
|
||||
if (map.getLayer(id + '-line')) map.removeLayer(id + '-line');
|
||||
if (map.getLayer(id + '-fill')) map.removeLayer(id + '-fill');
|
||||
map.removeSource(id);
|
||||
}
|
||||
});
|
||||
window.journeyLayerIds = [];
|
||||
|
||||
// Supprimer l'ancien message d'info s'il existe
|
||||
const existingInfo = document.querySelector('.carpool-route-info');
|
||||
if (existingInfo) existingInfo.remove();
|
||||
|
||||
// Vérifier si une polyline est disponible
|
||||
const polylineData = carpool.ocss?.journeyPolyline;
|
||||
|
||||
if (!polylineData) {
|
||||
// Afficher un message d'info au lieu du tracé
|
||||
const isMobile = window.matchMedia('(max-width: 1024px)').matches;
|
||||
const mapContainer = document.getElementById(isMobile ? 'mobile-map' : 'map');
|
||||
if (mapContainer) {
|
||||
const infoDiv = document.createElement('div');
|
||||
infoDiv.className = 'carpool-route-info';
|
||||
infoDiv.textContent = "L'itinéraire exact n'est pas disponible";
|
||||
mapContainer.parentElement.appendChild(infoDiv);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Si polyline disponible, décoder et afficher
|
||||
const decoded = decodePolyline(polylineData, 5);
|
||||
const coordinates = decoded.map(point => [point[1], point[0]]);
|
||||
|
||||
const sourceId = 'carpool-route';
|
||||
window.journeyLayerIds.push(sourceId);
|
||||
|
||||
map.addSource(sourceId, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: coordinates
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: sourceId + '-line',
|
||||
type: 'line',
|
||||
source: sourceId,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#00A396',
|
||||
'line-width': 4
|
||||
}
|
||||
});
|
||||
|
||||
// Ajuster la vue pour montrer le trajet
|
||||
const bounds = new maplibregl.LngLatBounds();
|
||||
coordinates.forEach(coord => bounds.extend(coord));
|
||||
map.fitBounds(bounds, { padding: 50 });
|
||||
};
|
||||
|
||||
// Fonction pour afficher la zone géographique d'une solution locale
|
||||
window.showSolutionZone = function(solution, index) {
|
||||
console.log('showSolutionZone called', index, solution);
|
||||
|
||||
const map = window.parcoursmobMap;
|
||||
if (!map) {
|
||||
console.log('Map not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
// Supprimer le message d'info covoiturage s'il existe
|
||||
const existingInfo = document.querySelector('.carpool-route-info');
|
||||
if (existingInfo) existingInfo.remove();
|
||||
|
||||
// Supprimer les anciens tracés
|
||||
window.journeyLayerIds.forEach(id => {
|
||||
if (map.getSource(id)) {
|
||||
if (map.getLayer(id + '-line')) map.removeLayer(id + '-line');
|
||||
if (map.getLayer(id + '-fill')) map.removeLayer(id + '-fill');
|
||||
map.removeSource(id);
|
||||
}
|
||||
});
|
||||
window.journeyLayerIds = [];
|
||||
|
||||
// Chercher les données géographiques dans la solution
|
||||
const geographies = solution.geography || [];
|
||||
const bounds = new maplibregl.LngLatBounds();
|
||||
let hasGeometry = false;
|
||||
|
||||
geographies.forEach((geo, geoIndex) => {
|
||||
if (geo.geography && geo.geography.geometry) {
|
||||
hasGeometry = true;
|
||||
const sourceId = 'solution-zone-' + index + '-' + geoIndex;
|
||||
window.journeyLayerIds.push(sourceId);
|
||||
|
||||
map.addSource(sourceId, {
|
||||
type: 'geojson',
|
||||
data: geo.geography
|
||||
});
|
||||
|
||||
// Ajouter le remplissage
|
||||
map.addLayer({
|
||||
id: sourceId + '-fill',
|
||||
type: 'fill',
|
||||
source: sourceId,
|
||||
paint: {
|
||||
'fill-color': '#F39200',
|
||||
'fill-opacity': 0.2
|
||||
}
|
||||
});
|
||||
|
||||
// Ajouter le contour
|
||||
map.addLayer({
|
||||
id: sourceId + '-line',
|
||||
type: 'line',
|
||||
source: sourceId,
|
||||
paint: {
|
||||
'line-color': '#F39200',
|
||||
'line-width': 2
|
||||
}
|
||||
});
|
||||
|
||||
// Étendre les bounds avec les coordonnées
|
||||
const coords = geo.geography.geometry.coordinates;
|
||||
if (geo.geography.geometry.type === 'Polygon') {
|
||||
coords[0].forEach(coord => bounds.extend(coord));
|
||||
} else if (geo.geography.geometry.type === 'MultiPolygon') {
|
||||
coords.forEach(polygon => {
|
||||
polygon[0].forEach(coord => bounds.extend(coord));
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (hasGeometry && !bounds.isEmpty()) {
|
||||
map.fitBounds(bounds, { padding: 50 });
|
||||
}
|
||||
};
|
||||
|
||||
// Fonctions de formatage pour les trajets
|
||||
function formatTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatJourneyDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' });
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds) return '';
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (hours > 0) {
|
||||
return `${hours}h${minutes.toString().padStart(2, '0')}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function formatDistance(meters) {
|
||||
if (!meters) return '';
|
||||
if (meters >= 1000) {
|
||||
const km = (meters / 1000).toFixed(1).replace('.', ',');
|
||||
return `${km} kilomètres`;
|
||||
}
|
||||
return `${Math.round(meters)} mètres`;
|
||||
}
|
||||
|
||||
function formatCarpoolPrice(amount) {
|
||||
if (!amount && amount !== 0) return '';
|
||||
return amount.toFixed(2).replace('.', ',') + ' €';
|
||||
}
|
||||
|
||||
function formatCarpoolDate(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
// Le timestamp OCSS est en secondes, pas en millisecondes
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' }) +
|
||||
' à ' + date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
|
||||
function initMap() {
|
||||
const data = window.__PARCOURSMOB_DATA__ || {};
|
||||
const departure = data.departure;
|
||||
const destination = data.destination;
|
||||
|
||||
if (!departure || !destination) return;
|
||||
|
||||
const depCoords = departure.geometry.coordinates;
|
||||
const destCoords = destination.geometry.coordinates;
|
||||
|
||||
// Calculer les bounds
|
||||
const bounds = [
|
||||
[Math.min(depCoords[0], destCoords[0]), Math.min(depCoords[1], destCoords[1])],
|
||||
[Math.max(depCoords[0], destCoords[0]), Math.max(depCoords[1], destCoords[1])]
|
||||
];
|
||||
|
||||
// Initialiser le protocole PMTiles
|
||||
const protocol = new pmtiles.Protocol();
|
||||
maplibregl.addProtocol("pmtiles", protocol.tile);
|
||||
|
||||
// Fonction pour créer une carte dans un conteneur donné
|
||||
function createMapInContainer(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return null;
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: containerId,
|
||||
style: '/maps/protomaps-light.json',
|
||||
bounds: bounds,
|
||||
fitBoundsOptions: { padding: 50 }
|
||||
});
|
||||
|
||||
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||
|
||||
// Marqueur départ (secondary/vert)
|
||||
const departureEl = document.createElement('div');
|
||||
departureEl.className = 'map-marker map-marker-departure';
|
||||
departureEl.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18" height="18">
|
||||
<path fill-rule="evenodd" d="M11.54 22.351l.07.04.028.016a.76.76 0 00.723 0l.028-.015.071-.041a16.975 16.975 0 001.144-.742 19.58 19.58 0 002.683-2.282c1.944-1.99 3.963-4.98 3.963-8.827a8.25 8.25 0 00-16.5 0c0 3.846 2.02 6.837 3.963 8.827a19.58 19.58 0 002.682 2.282 16.975 16.975 0 001.145.742zM12 13.5a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
`;
|
||||
new maplibregl.Marker({ element: departureEl })
|
||||
.setLngLat(depCoords)
|
||||
.setPopup(new maplibregl.Popup().setHTML(`<strong>Départ</strong><br>${departure.properties.label}`))
|
||||
.addTo(map);
|
||||
|
||||
// Marqueur arrivée (highlight/orange)
|
||||
const arrivalEl = document.createElement('div');
|
||||
arrivalEl.className = 'map-marker map-marker-arrival';
|
||||
arrivalEl.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18" height="18">
|
||||
<path fill-rule="evenodd" d="M3 2.25a.75.75 0 01.75.75v.54l1.838-.46a9.75 9.75 0 016.725.738l.108.054a8.25 8.25 0 005.58.652l3.109-.732a.75.75 0 01.917.81 47.784 47.784 0 00.005 10.337.75.75 0 01-.574.812l-3.114.733a9.75 9.75 0 01-6.594-.77l-.108-.054a8.25 8.25 0 00-5.69-.625l-2.202.55V21a.75.75 0 01-1.5 0V3A.75.75 0 013 2.25z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
`;
|
||||
new maplibregl.Marker({ element: arrivalEl })
|
||||
.setLngLat(destCoords)
|
||||
.setPopup(new maplibregl.Popup().setHTML(`<strong>Arrivée</strong><br>${destination.properties.label}`))
|
||||
.addTo(map);
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
// Déterminer si on est en mobile
|
||||
const isMobile = window.matchMedia('(max-width: 1024px)').matches;
|
||||
|
||||
// Créer la carte dans le bon conteneur
|
||||
if (isMobile) {
|
||||
// En mobile, créer uniquement la carte mobile
|
||||
const mobileMap = createMapInContainer('mobile-map');
|
||||
if (mobileMap) {
|
||||
window.parcoursmobMap = mobileMap;
|
||||
}
|
||||
} else {
|
||||
// En desktop, créer uniquement la carte desktop
|
||||
const desktopMap = createMapInContainer('map');
|
||||
if (desktopMap) {
|
||||
window.parcoursmobMap = desktopMap;
|
||||
}
|
||||
}
|
||||
|
||||
// Écouter les changements de taille d'écran pour recréer la carte si nécessaire
|
||||
const mediaQuery = window.matchMedia('(max-width: 1024px)');
|
||||
mediaQuery.addEventListener('change', (e) => {
|
||||
// Supprimer l'ancienne carte
|
||||
if (window.parcoursmobMap) {
|
||||
window.parcoursmobMap.remove();
|
||||
window.parcoursmobMap = null;
|
||||
}
|
||||
|
||||
// Créer la nouvelle carte dans le bon conteneur
|
||||
if (e.matches) {
|
||||
const mobileMap = createMapInContainer('mobile-map');
|
||||
if (mobileMap) {
|
||||
window.parcoursmobMap = mobileMap;
|
||||
}
|
||||
} else {
|
||||
const desktopMap = createMapInContainer('map');
|
||||
if (desktopMap) {
|
||||
window.parcoursmobMap = desktopMap;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -7,62 +7,7 @@
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
<section class="search-block">
|
||||
<h2 class="search-title">{{ site.Params.search.title }}</h2>
|
||||
<form class="search-form">
|
||||
{{ $iconLocation := resources.Get "images/picto/location_on_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg" }}
|
||||
{{ $iconCalendar := resources.Get "images/picto/calendar_today_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg" }}
|
||||
{{ $iconSchedule := resources.Get "images/picto/schedule_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg" }}
|
||||
{{ $iconSearch := resources.Get "images/picto/search_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg" }}
|
||||
|
||||
{{ $iconSwap := resources.Get "images/picto/sync_alt_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg" }}
|
||||
|
||||
<div class="form-group-locations">
|
||||
<div class="form-group">
|
||||
<label for="depart">
|
||||
{{ if $iconLocation }}<img src="{{ $iconLocation.RelPermalink }}" alt="" class="label-icon" />{{ end }}
|
||||
{{ site.Params.search.labelDepart }}
|
||||
</label>
|
||||
<input type="text" id="depart" name="depart" placeholder="" />
|
||||
</div>
|
||||
|
||||
<button type="button" class="swap-btn" onclick="swapLocations()" aria-label="Inverser départ et destination">
|
||||
{{ if $iconSwap }}<img src="{{ $iconSwap.RelPermalink }}" alt="" />{{ end }}
|
||||
</button>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="destination">
|
||||
{{ if $iconLocation }}<img src="{{ $iconLocation.RelPermalink }}" alt="" class="label-icon" />{{ end }}
|
||||
{{ site.Params.search.labelDestination }}
|
||||
</label>
|
||||
<input type="text" id="destination" name="destination" placeholder="" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group-row">
|
||||
<div class="form-group">
|
||||
<label for="date">
|
||||
{{ if $iconCalendar }}<img src="{{ $iconCalendar.RelPermalink }}" alt="" class="label-icon" />{{ end }}
|
||||
{{ site.Params.search.labelDate }}
|
||||
</label>
|
||||
<input type="date" id="date" name="date" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="heure">
|
||||
{{ if $iconSchedule }}<img src="{{ $iconSchedule.RelPermalink }}" alt="" class="label-icon" />{{ end }}
|
||||
{{ site.Params.search.labelHeure }}
|
||||
</label>
|
||||
<input type="time" id="heure" name="heure" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="search-btn">
|
||||
{{ if $iconSearch }}<img src="{{ $iconSearch.RelPermalink }}" alt="" class="btn-icon" />{{ end }}
|
||||
{{ site.Params.search.buttonText }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
{{ partial "search-block.html" (dict "showTitle" true "action" "/recherche/") }}
|
||||
|
||||
<section class="video-block">
|
||||
<h2 class="video-title">{{ site.Params.video.title }}</h2>
|
||||
|
||||
161
themes/mms43/layouts/recherche.html
Normal file
161
themes/mms43/layouts/recherche.html
Normal file
@@ -0,0 +1,161 @@
|
||||
{{ define "main" }}
|
||||
<!-- Placeholder pour les données hydratées par Parcoursmob -->
|
||||
<script id="dynamic-data" type="application/json"></script>
|
||||
|
||||
{{ $iconSearch := resources.Get "images/picto/search_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg" }}
|
||||
{{ $iconArrow := resources.Get "images/picto/arrow_right_alt_24dp_1F1F1F_FILL1_wght400_GRAD0_opsz24.svg" }}
|
||||
|
||||
<section class="page-recherche" x-data="rechercheApp()" :class="{ 'has-results': searched }">
|
||||
<!-- Formulaire de recherche (caché en mobile si recherche effectuée) -->
|
||||
<div class="search-form-container" :class="{ 'hide-on-mobile-searched': searched }">
|
||||
{{ partial "search-block.html" (dict "showTitle" false "action" "/recherche/") }}
|
||||
</div>
|
||||
|
||||
<!-- Résultats de recherche -->
|
||||
<template x-if="searched">
|
||||
<div class="search-results-wrapper">
|
||||
<!-- Carte en premier sur mobile -->
|
||||
<div class="mobile-map-container">
|
||||
<div id="mobile-map"></div>
|
||||
</div>
|
||||
|
||||
<!-- Résumé compact de recherche (mobile uniquement) -->
|
||||
<div class="compact-search-summary">
|
||||
<a href="/recherche/" class="compact-search-content">
|
||||
<div class="compact-search-route">
|
||||
<span class="compact-search-place" x-text="departureLabel"></span>
|
||||
{{ if $iconArrow }}<img src="{{ $iconArrow.RelPermalink }}" alt="" class="compact-search-arrow" />{{ end }}
|
||||
<span class="compact-search-place" x-text="destinationLabel"></span>
|
||||
</div>
|
||||
<div class="compact-search-date" x-text="formatSearchDate()"></div>
|
||||
</a>
|
||||
<a href="/recherche/" class="compact-search-icon">
|
||||
{{ if $iconSearch }}<img src="{{ $iconSearch.RelPermalink }}" alt="Modifier la recherche" />{{ end }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{ partial "search-results.html" . }}
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
function rechercheApp() {
|
||||
const data = window.__PARCOURSMOB_DATA__ || {};
|
||||
|
||||
return {
|
||||
searched: data.searched || false,
|
||||
results: data.results || {},
|
||||
selectedJourney: null,
|
||||
departure: data.departure || null,
|
||||
destination: data.destination || null,
|
||||
departureDate: data.departure_date || '',
|
||||
departureTime: data.departure_time || '',
|
||||
|
||||
get departureLabel() {
|
||||
return this.departure?.properties?.label || this.departure?.properties?.name || 'Départ';
|
||||
},
|
||||
|
||||
get destinationLabel() {
|
||||
return this.destination?.properties?.label || this.destination?.properties?.name || 'Destination';
|
||||
},
|
||||
|
||||
formatSearchDate() {
|
||||
if (!this.departureDate) return '';
|
||||
const [year, month, day] = this.departureDate.split('-');
|
||||
const date = new Date(year, month - 1, day);
|
||||
const formatted = date.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' });
|
||||
return this.departureTime ? formatted + ' à ' + this.departureTime : formatted;
|
||||
},
|
||||
|
||||
get totalResults() {
|
||||
const r = this.results;
|
||||
return (r.solidarity_drivers?.number || 0) +
|
||||
(r.organized_carpools?.number || 0) +
|
||||
(r.public_transit?.number || 0) +
|
||||
(r.vehicles?.number || 0) +
|
||||
(r.local_solutions?.number || 0);
|
||||
},
|
||||
|
||||
showJourneyOnMap(journey, index) {
|
||||
console.log('showJourneyOnMap called', index, journey);
|
||||
this.selectedJourney = index;
|
||||
|
||||
if (!window.parcoursmobMap) {
|
||||
console.log('Map not found');
|
||||
return;
|
||||
}
|
||||
const map = window.parcoursmobMap;
|
||||
|
||||
// Supprimer l'ancien tracé s'il existe
|
||||
if (map.getSource('journey-route')) {
|
||||
map.removeLayer('journey-route-line');
|
||||
map.removeSource('journey-route');
|
||||
}
|
||||
|
||||
// Collecter toutes les coordonnées des legs
|
||||
const coordinates = [];
|
||||
|
||||
journey.legs.forEach(leg => {
|
||||
if (leg.legGeometry && leg.legGeometry.points) {
|
||||
try {
|
||||
const decoded = polyline.decode(leg.legGeometry.points);
|
||||
decoded.forEach(point => {
|
||||
const lng = point[1];
|
||||
const lat = point[0];
|
||||
if (lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
||||
coordinates.push([lng, lat]);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Erreur décodage polyline:', e);
|
||||
}
|
||||
} else if (leg.from && leg.to) {
|
||||
const fromLng = leg.from.lon || leg.from.lng;
|
||||
const fromLat = leg.from.lat;
|
||||
const toLng = leg.to.lon || leg.to.lng;
|
||||
const toLat = leg.to.lat;
|
||||
|
||||
if (fromLat && fromLng) coordinates.push([fromLng, fromLat]);
|
||||
if (toLat && toLng) coordinates.push([toLng, toLat]);
|
||||
}
|
||||
});
|
||||
|
||||
if (coordinates.length < 2) return;
|
||||
|
||||
// Ajouter le tracé sur la carte
|
||||
map.addSource('journey-route', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: coordinates
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: 'journey-route-line',
|
||||
type: 'line',
|
||||
source: 'journey-route',
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#283959',
|
||||
'line-width': 4
|
||||
}
|
||||
});
|
||||
|
||||
// Ajuster la vue pour montrer tout le tracé
|
||||
const bounds = new maplibregl.LngLatBounds();
|
||||
coordinates.forEach(coord => bounds.extend(coord));
|
||||
map.fitBounds(bounds, { padding: 50 });
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
@@ -13,64 +13,132 @@
|
||||
{{ $fields = .Page.Params.formFields }}
|
||||
{{ end }}
|
||||
|
||||
<form class="contact-form" action="{{ $action }}" method="POST">
|
||||
<div class="contact-form-row">
|
||||
<div class="contact-form-group">
|
||||
<label for="lastname">{{ $lastnameLabel }}</label>
|
||||
<input type="text" id="lastname" name="lastname" required />
|
||||
</div>
|
||||
<div class="contact-form-group">
|
||||
<label for="firstname">{{ $firstnameLabel }}</label>
|
||||
<input type="text" id="firstname" name="firstname" required />
|
||||
</div>
|
||||
<form class="contact-form" x-data="contactForm('{{ $action }}')">
|
||||
<!-- Message de succès -->
|
||||
<div x-show="success" x-cloak class="contact-form-message contact-form-success">
|
||||
Votre message a bien été envoyé. Nous vous répondrons dans les plus brefs délais.
|
||||
</div>
|
||||
|
||||
{{/* Champs dynamiques */}}
|
||||
{{ if $fields }}
|
||||
{{ range $index, $field := $fields }}
|
||||
{{ $fieldId := $field.name | default (printf "field_%d" $index) }}
|
||||
{{ $fieldRequired := $field.required | default false }}
|
||||
<!-- Message d'erreur -->
|
||||
<div x-show="error" x-cloak class="contact-form-message contact-form-error">
|
||||
<span x-text="errorMessage"></span>
|
||||
</div>
|
||||
|
||||
{{ if eq $field.type "multicheckboxes" }}
|
||||
<div class="contact-form-group contact-form-checkboxes">
|
||||
<span class="contact-form-checkbox-label">{{ $field.label }}</span>
|
||||
<div class="contact-form-checkbox-options">
|
||||
{{ range $optIndex, $option := $field.options }}
|
||||
<label class="contact-form-checkbox">
|
||||
<input type="checkbox" name="{{ $fieldId }}[]" value="{{ $option.value | default $option.label }}" />
|
||||
<span>{{ $option.label }}</span>
|
||||
</label>
|
||||
{{ end }}
|
||||
<div x-show="!success">
|
||||
<div class="contact-form-row">
|
||||
<div class="contact-form-group">
|
||||
<label for="lastname">{{ $lastnameLabel }}</label>
|
||||
<input type="text" id="lastname" name="lastname" required x-bind:disabled="loading" />
|
||||
</div>
|
||||
<div class="contact-form-group">
|
||||
<label for="firstname">{{ $firstnameLabel }}</label>
|
||||
<input type="text" id="firstname" name="firstname" required x-bind:disabled="loading" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Champs dynamiques */}}
|
||||
{{ if $fields }}
|
||||
{{ range $index, $field := $fields }}
|
||||
{{ $fieldId := $field.name | default (printf "field_%d" $index) }}
|
||||
{{ $fieldRequired := $field.required | default false }}
|
||||
|
||||
{{ if eq $field.type "multicheckboxes" }}
|
||||
<div class="contact-form-group contact-form-checkboxes">
|
||||
<span class="contact-form-checkbox-label">{{ $field.label }}</span>
|
||||
<div class="contact-form-checkbox-options">
|
||||
{{ range $optIndex, $option := $field.options }}
|
||||
<label class="contact-form-checkbox">
|
||||
<input type="checkbox" name="{{ $fieldId }}[]" value="{{ $option.value | default $option.label }}" x-bind:disabled="loading" />
|
||||
<span>{{ $option.label }}</span>
|
||||
</label>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ else if eq $field.type "textarea" }}
|
||||
<div class="contact-form-group">
|
||||
<label for="{{ $fieldId }}">{{ $field.label }}</label>
|
||||
<textarea id="{{ $fieldId }}" name="{{ $fieldId }}" rows="{{ $field.rows | default 6 }}" {{ if $fieldRequired }}required{{ end }}></textarea>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="contact-form-group">
|
||||
<label for="{{ $fieldId }}">{{ $field.label }}</label>
|
||||
<input type="{{ $field.type | default "text" }}" id="{{ $fieldId }}" name="{{ $fieldId }}" {{ if $fieldRequired }}required{{ end }} />
|
||||
</div>
|
||||
{{ else if eq $field.type "textarea" }}
|
||||
<div class="contact-form-group">
|
||||
<label for="{{ $fieldId }}">{{ $field.label }}</label>
|
||||
<textarea id="{{ $fieldId }}" name="{{ $fieldId }}" rows="{{ $field.rows | default 6 }}" {{ if $fieldRequired }}required{{ end }} x-bind:disabled="loading"></textarea>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="contact-form-group">
|
||||
<label for="{{ $fieldId }}">{{ $field.label }}</label>
|
||||
<input type="{{ $field.type | default "text" }}" id="{{ $fieldId }}" name="{{ $fieldId }}" {{ if $fieldRequired }}required{{ end }} x-bind:disabled="loading" />
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
{{/* Champs par défaut si aucun champ dynamique n'est défini */}}
|
||||
<div class="contact-form-group">
|
||||
<label for="email">Votre adresse mail</label>
|
||||
<input type="email" id="email" name="email" required x-bind:disabled="loading" />
|
||||
</div>
|
||||
<div class="contact-form-group">
|
||||
<label for="subject">Objet de votre demande</label>
|
||||
<input type="text" id="subject" name="subject" x-bind:disabled="loading" />
|
||||
</div>
|
||||
<div class="contact-form-group">
|
||||
<label for="message">Votre message</label>
|
||||
<textarea id="message" name="message" rows="6" required x-bind:disabled="loading"></textarea>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
{{/* Champs par défaut si aucun champ dynamique n'est défini */}}
|
||||
<div class="contact-form-group">
|
||||
<label for="email">Votre adresse mail</label>
|
||||
<input type="email" id="email" name="email" required />
|
||||
</div>
|
||||
<div class="contact-form-group">
|
||||
<label for="subject">Objet de votre demande</label>
|
||||
<input type="text" id="subject" name="subject" />
|
||||
</div>
|
||||
<div class="contact-form-group">
|
||||
<label for="message">Votre message</label>
|
||||
<textarea id="message" name="message" rows="6" required></textarea>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<button type="submit" class="contact-form-submit">{{ $buttonText }}</button>
|
||||
<p class="contact-form-privacy">{{ $privacyText }}</p>
|
||||
<button type="submit" class="contact-form-submit" x-bind:disabled="loading" @click.prevent="submit">
|
||||
<span x-show="!loading">{{ $buttonText }}</span>
|
||||
<span x-show="loading">Envoi en cours...</span>
|
||||
</button>
|
||||
<p class="contact-form-privacy">{{ $privacyText }}</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('contactForm', (action) => ({
|
||||
loading: false,
|
||||
success: false,
|
||||
error: false,
|
||||
errorMessage: '',
|
||||
|
||||
submit() {
|
||||
this.loading = true;
|
||||
this.success = false;
|
||||
this.error = false;
|
||||
this.errorMessage = '';
|
||||
|
||||
const form = this.$root;
|
||||
const formData = new FormData(form);
|
||||
const data = {};
|
||||
|
||||
formData.forEach((value, key) => {
|
||||
if (key.endsWith('[]')) {
|
||||
const cleanKey = key.slice(0, -2);
|
||||
if (!data[cleanKey]) data[cleanKey] = [];
|
||||
data[cleanKey].push(value);
|
||||
} else {
|
||||
data[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
fetch(action, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Erreur lors de l\'envoi');
|
||||
return response.json();
|
||||
})
|
||||
.then(() => {
|
||||
this.success = true;
|
||||
form.reset();
|
||||
})
|
||||
.catch(err => {
|
||||
this.error = true;
|
||||
this.errorMessage = err.message || 'Une erreur est survenue lors de l\'envoi. Veuillez réessayer.';
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user