Add search and PARCOURSMOB integration
All checks were successful
Publish To Prod / deploy_and_publish (push) Successful in 40s

This commit is contained in:
Arnaud Delcasse
2026-01-07 11:46:19 +01:00
parent ed5dade5c3
commit 36dd81e661
10 changed files with 14048 additions and 110 deletions

107
README.md
View File

@@ -61,6 +61,113 @@ hugo server -D
hugo --minify
```
## PARCOURSMOB Integration
This website is designed to be served by **PARCOURSMOB**, COOPGO's inclusive mobility platform. PARCOURSMOB acts as a reverse proxy that serves Hugo static files and dynamically hydrates journey search data.
### Architecture
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Browser │────▶│ PARCOURSMOB │────▶│ Hugo Build │
│ │◀────│ (Go app) │◀────│ (static) │
└─────────────┘ └──────────────┘ └─────────────┘
┌──────────────┐
│ APIs │
│ - Transit │
│ - Carpool │
│ - Vehicles │
│ - Knowledge │
└──────────────┘
```
### Data Hydration
PARCOURSMOB injects search data into the page via the global variable `window.__PARCOURSMOB_DATA__`. This variable contains:
```javascript
window.__PARCOURSMOB_DATA__ = {
searched: true, // Search was performed
departure: { /* GeoJSON Feature */ },
destination: { /* GeoJSON Feature */ },
departure_date: "2024-01-15",
departure_time: "08:30",
results: {
public_transit: {
number: 3,
results: [ /* OTP itineraries */ ]
},
carpools: {
number: 2,
results: [ /* RDEX/OCSS carpools */ ]
},
solidarity_drivers: {
number: 5
},
organized_carpools: {
number: 2
},
vehicles: {
number: 3,
results: [ /* Available vehicles */ ]
},
local_solutions: {
number: 4,
results: [ /* Knowledge base solutions */ ]
}
}
};
```
### Dynamic Pages
#### Search Page (`/recherche/`)
The search form sends the following parameters:
- `departure`: GeoJSON Feature of departure address (JSON stringified)
- `destination`: GeoJSON Feature of arrival address (JSON stringified)
- `departuredate`: Date in `YYYY-MM-DD` format
- `departuretime`: Time in `HH:MM` format
Address autocomplete uses the French government Address API (`api-adresse.data.gouv.fr`).
### Alpine.js Components
The site uses [Alpine.js](https://alpinejs.dev/) for client-side reactivity:
- `searchBlock()`: Search form management with autocomplete
- `rechercheApp()`: Results display and map interaction
### MapLibre Map
The map uses [MapLibre GL JS](https://maplibre.org/) with:
- Self-hosted PMTiles
- Custom markers for departure/arrival
- Journey display (decoded polylines)
- Geographic zones display (local solutions)
### Configuration
CTA (Call-to-Action) texts are configurable in the front matter of `/content/recherche/index.md`:
```yaml
contactCTA:
transit: "To organize your trip..."
carpool: "To organize your trip..."
solidarity: "To organize your trip..."
vehicles: "For more information..."
localSolutions: "For more information..."
localSolutionsText: "Mobi'Pouce, on-demand transport..."
```
### Deployment
1. Build the Hugo site: `hugo --minify`
2. Configure PARCOURSMOB to serve the `public/` folder
3. PARCOURSMOB intercepts requests to `/recherche/` and injects data
## License
This project is licensed under the **AGPLv3** (GNU Affero General Public License v3.0).

View File

@@ -0,0 +1,12 @@
---
title: "Rechercher un trajet"
headline: "Trouvez votre solution de mobilité"
layout: recherche
contactCTA:
transit: "Pour organiser votre déplacement, vous pouvez aussi appeler la Maison de la Mobilité Solidaire au"
carpool: "Pour organiser votre déplacement, vous pouvez aussi appeler la Maison de la Mobilité Solidaire au"
solidarity: "Pour organiser votre déplacement, appelez la Maison de la Mobilité Solidaire au"
vehicles: "Pour en savoir plus et réserver un véhicule, appelez la Maison de la Mobilité Solidaire au"
localSolutions: "Pour plus d'informations et pour organiser votre déplacement, appelez la Maison de la Mobilité Solidaire au"
localSolutionsText: "Mobi'Pouce, transport à la demande, location de vélos, service de taxi gratuit... De nombreuses solutions existent sur le territoire."
---

File diff suppressed because it is too large Load Diff

View File

@@ -403,6 +403,11 @@ main {
outline: 2px solid var(--color-primary);
}
.form-group input.input-error {
outline: 2px solid #e53935;
background-color: #ffebee;
}
.swap-btn {
display: flex;
align-items: center;
@@ -741,7 +746,7 @@ main {
.conducteur-hero {
height: 380px;
background-size: cover;
background-position: center;
background-position: top;
background-repeat: no-repeat;
border-radius: 0 0 35px 35px;
}
@@ -1358,6 +1363,43 @@ main {
line-height: 1.5;
}
/* Messages de formulaire */
.contact-form-message {
padding: 1rem 1.5rem;
border-radius: 10px;
margin-bottom: 1.5rem;
text-align: center;
font-size: 14px;
line-height: 1.5;
}
.contact-form-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.contact-form-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.contact-form-submit:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.contact-form input:disabled,
.contact-form textarea:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
[x-cloak] {
display: none !important;
}
/* Multicheckboxes */
.contact-form-checkboxes {
text-align: left;
@@ -2084,3 +2126,799 @@ a {
font-size: 16px;
}
}
/* Autocomplete */
.autocomplete-container {
position: relative;
}
.autocomplete-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--color-white);
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
max-height: 200px;
overflow-y: auto;
z-index: 100;
list-style: none;
margin-top: 4px;
}
.autocomplete-results li {
padding: 0.75rem 1rem;
cursor: pointer;
font-size: 14px;
color: var(--color-text);
border-bottom: 1px solid #eee;
}
.autocomplete-results li:last-child {
border-bottom: none;
}
.autocomplete-results li:hover {
background-color: #f5f5f5;
}
[x-cloak] {
display: none !important;
}
/* Page Recherche */
.page-recherche {
padding: 2rem;
}
.page-recherche .search-block {
margin-top: 0;
padding-top: 1rem;
padding-bottom: 1rem;
height: auto;
}
/* Search Results */
.search-results-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
max-width: 1328px;
margin: 2rem auto 0;
padding: 0 2rem;
align-items: start;
}
.search-results-accordions {
display: flex;
flex-direction: column;
gap: 1rem;
}
.result-accordion {
background-color: var(--color-secondary);
border-radius: 15px;
overflow: hidden;
}
.accordion-header {
display: flex;
align-items: center;
width: 100%;
padding: 1rem 1.25rem;
background: transparent;
border: none;
cursor: pointer;
font-family: var(--font-family);
}
.accordion-title {
flex: 1;
text-align: left;
font-size: 16px;
font-weight: 600;
color: var(--color-white);
}
.accordion-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 0.5rem;
background-color: var(--color-highlight);
color: var(--color-white);
font-size: 14px;
font-weight: 700;
border-radius: 14px;
margin-right: 0.75rem;
}
.accordion-arrow {
width: 24px;
height: 24px;
filter: brightness(0) invert(1);
transition: transform 0.3s ease;
transform: rotate(180deg);
}
.accordion-arrow.open {
transform: rotate(0deg);
}
.accordion-content {
background-color: var(--color-white);
}
.accordion-inner {
padding: 1rem 1.25rem;
}
.no-result-text {
color: var(--color-text);
font-size: 14px;
font-style: italic;
margin: 0;
text-align: center;
}
.accordion-cta {
text-align: center;
margin: 1.5rem 1.5rem 0 1.5rem;
}
.accordion-cta p {
font-size: 16px;
font-weight: 600;
color: #000;
margin: 0 0 0.5rem 0;
}
.accordion-cta-phone {
display: inline-block;
font-size: 22px;
font-weight: 700;
color: #000;
text-decoration: none;
}
.accordion-cta-phone:hover {
text-decoration: underline;
}
/* Transport solidaire result */
.solidarity-result {
text-align: center;
}
.solidarity-result p {
margin: 0;
font-size: 16px;
color: var(--color-text);
}
.solidarity-result .solidarity-count {
font-size: 24px;
font-weight: 700;
color: #000;
margin: 0.5rem 0;
}
/* Local Solutions */
.local-solutions-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.local-solution-item {
background-color: #f9fafb;
border-radius: 10px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.local-solution-item:hover {
background-color: #f3f4f6;
}
.local-solution-item.active {
border-color: var(--color-highlight);
background-color: #fef3e6;
}
.local-solution-title {
font-size: 16px;
font-weight: 700;
color: #000;
margin: 0 0 0.5rem 0;
}
.local-solution-description {
font-size: 14px;
color: var(--color-text);
margin: 0 0 0.75rem 0;
line-height: 1.4;
}
.local-solution-link {
display: inline-block;
font-size: 14px;
font-weight: 600;
color: var(--color-highlight);
text-decoration: underline;
}
.local-solution-link:hover {
opacity: 0.8;
}
.local-solutions-text {
font-size: 15px;
color: var(--color-text);
text-align: center;
margin: 1.5rem 0 0 0;
line-height: 1.5;
}
/* Search Results Map */
.search-results-map {
min-height: 400px;
max-height: 600px;
height: 600px;
border-radius: 15px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
position: sticky;
top: calc(var(--header-height) + 2rem);
}
.search-results-map #map {
width: 100%;
height: 100%;
}
.carpool-route-info {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.75);
color: #fff;
padding: 0.75rem 1.25rem;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
z-index: 10;
}
/* Map Markers */
.map-marker {
width: 36px;
height: 36px;
border-radius: 10px;
border: 2px solid var(--color-white);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s ease;
}
.map-marker:hover {
transform: scale(1.1);
}
.map-marker-departure {
background-color: var(--color-secondary);
}
.map-marker-arrival {
background-color: var(--color-highlight);
}
/* MapLibre Popup Styling */
.maplibregl-popup-content {
padding: 12px 16px;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-family: var(--font-family);
font-size: 14px;
}
.maplibregl-popup-close-button {
font-size: 18px;
padding: 4px 8px;
color: #666;
}
.maplibregl-popup-close-button:hover {
color: var(--color-text);
background: transparent;
}
/* Transit Journeys */
.transit-journeys {
display: flex;
flex-direction: column;
gap: 1rem;
}
.transit-journey {
background-color: #f9fafb;
border-radius: 10px;
padding: 1rem;
cursor: pointer;
transition: box-shadow 0.2s, border-color 0.2s;
border: 2px solid transparent;
}
.transit-journey:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.transit-journey.active {
border-color: var(--color-primary);
box-shadow: 0 2px 8px rgba(40, 57, 89, 0.2);
}
.journey-header {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.journey-date {
font-size: 14px;
color: var(--color-text);
text-transform: capitalize;
}
.journey-times {
display: flex;
align-items: center;
gap: 0.375rem;
}
.journey-time {
font-size: 22px;
font-weight: 700;
color: var(--color-text);
}
.journey-arrow {
width: 18px;
height: 18px;
}
.journey-duration {
margin-left: auto;
font-size: 12px;
font-weight: 600;
color: var(--color-white);
background-color: var(--color-secondary);
padding: 0.2rem 0.5rem;
border-radius: 12px;
}
.journey-separator {
height: 1px;
background-color: #e5e7eb;
margin: 0.75rem 0;
}
.journey-legs {
display: flex;
flex-direction: column;
gap: 0;
position: relative;
}
.journey-leg {
display: flex;
gap: 0.75rem;
align-items: flex-start;
position: relative;
padding-bottom: 0.75rem;
}
.journey-leg:last-child {
padding-bottom: 0;
}
/* Ligne de connexion verticale entre les icônes */
.journey-leg:not(:last-child)::after {
content: '';
position: absolute;
left: 18px;
top: 36px;
bottom: 0;
width: 2px;
background-color: var(--color-secondary);
transform: translateX(-50%);
}
.leg-icon {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-primary);
position: relative;
z-index: 1;
}
.leg-icon img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
}
.leg-icon svg {
color: white;
}
.leg-details {
flex: 1;
min-width: 0;
}
.leg-line {
font-size: 15px;
font-weight: 700;
color: var(--color-text);
margin: 0;
}
.leg-line strong {
color: var(--color-primary);
}
.leg-text {
font-size: 13px;
color: #6b7280;
margin: 0 0 0.125rem;
}
.leg-text strong {
color: var(--color-text);
font-weight: 500;
}
.leg-duration {
font-size: 12px;
color: #9ca3af;
}
.leg-operator {
font-size: 12px;
color: #6b7280;
margin: 0;
}
.leg-direction {
font-size: 14px;
font-weight: 600;
color: var(--color-text);
margin: 0.25rem 0 0.5rem;
}
.leg-timeline {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0;
margin-top: 0.5rem;
position: relative;
padding-left: 10px;
}
.leg-stop {
display: flex;
align-items: center;
gap: 0.5rem;
}
.leg-stop-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.leg-stop-departure .leg-stop-dot {
background-color: #79C970;
}
.leg-stop-arrival .leg-stop-dot {
background-color: #F70004;
}
.leg-stop-time {
font-size: 12px;
font-weight: 600;
color: var(--color-text);
min-width: 40px;
}
.leg-stop-name {
font-size: 12px;
color: var(--color-text);
}
.leg-timeline-arrow {
width: 14px;
height: 14px;
margin-left: -2px;
transform: rotate(90deg);
}
/* Carpool List */
.carpool-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.carpool-item {
background-color: #f9fafb;
border-radius: 10px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.carpool-item:hover {
background-color: #f3f4f6;
}
.carpool-item.active {
border-color: var(--color-secondary);
background-color: #e6f7f6;
}
.carpool-preview {
margin-bottom: 0.75rem;
}
.carpool-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.carpool-driver {
font-size: 15px;
font-weight: 700;
color: var(--color-text);
}
.carpool-price {
font-size: 16px;
font-weight: 700;
color: var(--color-primary);
}
.carpool-date {
font-size: 13px;
color: #6b7280;
margin-bottom: 0.75rem;
}
.carpool-route {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.carpool-place {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 14px;
color: var(--color-text);
margin: 0;
}
.carpool-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.carpool-dot-departure {
background-color: var(--color-secondary);
}
.carpool-dot-arrival {
background-color: var(--color-highlight);
}
.carpool-link-container {
text-align: center;
margin-top: 1rem;
}
.carpool-operator-link {
color: var(--color-highlight);
font-size: 14px;
font-weight: 600;
text-decoration: underline;
transition: opacity 0.2s;
}
.carpool-operator-link:hover {
opacity: 0.8;
}
/* Mobile map container - hidden by default on desktop */
.mobile-map-container {
display: none;
}
.mobile-map-container #mobile-map {
width: 100%;
height: 100%;
}
/* Compact search summary - hidden by default on desktop */
.compact-search-summary {
display: none;
}
/* Responsive */
@media (max-width: 1024px) {
/* Hide search form on mobile when search was performed */
.hide-on-mobile-searched {
display: none;
}
/* Page recherche with results - no padding */
.page-recherche.has-results {
padding: 0;
}
/* Mobile map - full width, no margin, after header */
.mobile-map-container {
display: block;
width: 100%;
height: 500px;
margin: 0;
position: relative;
}
.mobile-map-container #mobile-map {
width: 100%;
height: 100%;
}
/* Hide desktop map on mobile */
.search-results-map {
display: none;
}
/* Compact search summary */
.compact-search-summary {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--color-secondary);
padding: 1rem 1.25rem;
margin-top: -1.5rem;
border-radius: 20px 20px 0 0;
position: relative;
z-index: 10;
}
.compact-search-content {
flex: 1;
text-decoration: none;
color: #fff;
}
.compact-search-route {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 13px;
font-weight: 500;
margin-bottom: 0.25rem;
}
.compact-search-place {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
font-weight: 700;
}
.compact-search-arrow {
width: 16px;
height: 16px;
flex-shrink: 0;
filter: brightness(0) invert(1);
}
.compact-search-date {
font-size: 12px;
opacity: 0.9;
}
.compact-search-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: 1rem;
}
.compact-search-icon img {
width: 24px;
height: 24px;
filter: brightness(0) invert(1);
}
/* Search results container adjustments */
.search-results-container {
grid-template-columns: 1fr;
padding: 0 1rem;
margin-top: 0;
}
.search-results-accordions {
padding-top: 1rem;
}
}
@media (max-width: 600px) {
.search-results-container {
gap: 1rem;
padding: 0 0.75rem;
}
.accordion-header {
padding: 0.875rem 1rem;
}
.accordion-title {
font-size: 14px;
}
.accordion-badge {
min-width: 24px;
height: 24px;
font-size: 12px;
}
.compact-search-summary {
padding: 0.875rem 1rem;
}
.compact-search-route {
font-size: 12px;
}
.compact-search-place {
max-width: 100px;
}
.compact-search-date {
font-size: 12px;
}
.mobile-map-container {
height: 400px;
}
}

View File

@@ -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)

View 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>

View 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>

View File

@@ -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>

View 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 }}

View File

@@ -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>