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:
107
README.md
107
README.md
@@ -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).
|
||||
|
||||
12
content/recherche/index.md
Normal file
12
content/recherche/index.md
Normal 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."
|
||||
---
|
||||
11818
static/maps/protomaps-light.json
Normal file
11818
static/maps/protomaps-light.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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