<div class="md:grid md:grid-cols-3 h-[calc(100vh-125px)] md:h-[calc(100vh-141px)] overflow-auto" x-data="{ showStoreList: false }">
<div class="relative h-[calc(100vh-125px)] md:h-auto md:m-4 lg:m-6 overflow-auto" x-show="showStoreList || !$store.screen.isMobile">
<div class="md:sticky top-0 inset-x-6 bg-white z-10 flex flex-col gap-5">
<h1 class="text-2xl font-medium">Trouver un magasin</h1>
<div class="relative w-full">
<div class="relative w-full bg-white rounded-lg border border-neutral-300 px-4 py-3 flex items-center gap-3" x-data="SearchPlace()" @click.away="showPredictions = false">
<!-- Icône de recherche -->
<svg class=" shrink-0" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 3.75C6.99594 3.75 3.75 6.99594 3.75 11C3.75 15.0041 6.99594 18.25 11 18.25C15.0041 18.25 18.25 15.0041 18.25 11C18.25 6.99594 15.0041 3.75 11 3.75ZM2.25 11C2.25 6.16751 6.16751 2.25 11 2.25C15.8325 2.25 19.75 6.16751 19.75 11C19.75 13.1462 18.9773 15.112 17.6949 16.6342L21.5303 20.4697C21.8232 20.7626 21.8232 21.2374 21.5303 21.5303C21.2374 21.8232 20.7626 21.8232 20.4697 21.5303L16.6342 17.6949C15.112 18.9773 13.1462 19.75 11 19.75C6.16751 19.75 2.25 15.8325 2.25 11Z" fill="currentColor" />
</svg>
<!-- Champ de recherche -->
<input type="text" x-model="query" @focus="showPredictions = predictions.length > 0" placeholder="Rechercher une ville..." class="w-full p-0 border-none text-neutral-900 placeholder-neutral-900 focus:outline-none focus:ring-0">
<!-- Bouton de géolocalisation -->
<button @click="getLocation()" class="flex-shrink-0">
<svg class=" shrink-0" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.75 2C12.75 1.58579 12.4142 1.25 12 1.25C11.5858 1.25 11.25 1.58579 11.25 2V4.28582C7.5685 4.63935 4.63935 7.5685 4.28582 11.25H2C1.58579 11.25 1.25 11.5858 1.25 12C1.25 12.4142 1.58579 12.75 2 12.75H4.28582C4.63935 16.4315 7.5685 19.3607 11.25 19.7142V22C11.25 22.4142 11.5858 22.75 12 22.75C12.4142 22.75 12.75 22.4142 12.75 22V19.7142C16.4315 19.3607 19.3607 16.4315 19.7142 12.75H22C22.4142 12.75 22.75 12.4142 22.75 12C22.75 11.5858 22.4142 11.25 22 11.25H19.7142C19.3607 7.5685 16.4315 4.63935 12.75 4.28582V2ZM18.25 12C18.25 15.4518 15.4518 18.25 12 18.25C8.54822 18.25 5.75 15.4518 5.75 12C5.75 8.54822 8.54822 5.75 12 5.75C15.4518 5.75 18.25 8.54822 18.25 12Z" fill="currentColor" />
</svg>
</button>
<!-- Liste des prédictions -->
<div x-show="showPredictions" class="absolute z-10 top-14 left-0 right-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-300 overflow-hidden" x-transition>
<template x-for="prediction in predictions" :key="prediction.place_id">
<button @click="selectPlace(prediction)" class="w-full px-4 py-3 text-left hover:bg-neutral-50 text-base">
<span x-text="prediction.structured_formatting.main_text"></span>
<span x-text="prediction.structured_formatting.secondary_text" class="text-neutral-500"></span>
</button>
</template>
</div>
</div>
</div>
<script>
function SearchPlace() {
return {
query: '',
autocomplete: null,
predictions: [],
showPredictions: false,
selectedPlace: null,
isLocating: false,
async init() {
try {
const {
AutocompleteService
} = await google.maps.importLibrary("places");
this.autocomplete = new AutocompleteService();
this.geocoder = new google.maps.Geocoder();
// Vérifie le cookie de géolocalisation refusée
if (!document.cookie.includes('geolocation_denied=true')) {
this.getLocation(false);
}
this.$watch('query', (value) => {
if (value.length > 2) {
this.getPredictions(value);
} else {
this.predictions = [];
this.showPredictions = false;
}
});
} catch (error) {
console.error('Error initializing Google Places:', error);
}
},
async getPredictions(input) {
if (!input) return;
try {
const response = await this.autocomplete.getPlacePredictions({
input: input,
types: ['(cities)'],
fields: ['formatted_address', 'geometry', 'name']
});
this.predictions = response.predictions;
this.showPredictions = true;
} catch (error) {
console.error('Error fetching predictions:', error);
}
},
async selectPlace(prediction) {
try {
const response = await this.geocoder.geocode({
placeId: prediction.place_id
});
const location = response.results[0].geometry.location;
Alpine.store('locator').setMapCenter(location.lat(), location.lng(), 12);
this.query = prediction.structured_formatting.main_text;
this.showPredictions = false;
this.predictions = [];
} catch (error) {
console.error('Error getting coordinates:', error);
}
},
getLocation(showError = true) {
// Vérifie si la géolocalisation a été refusée précédemment
if (document.cookie.includes('geolocation_denied=true')) {
showError && alert('Vous avez précédemment refusé la géolocalisation. Pour la réactiver, veuillez effacer vos cookies pour ce site.');
return;
}
if (!navigator.geolocation) {
showError && alert('La géolocalisation n\'est pas supportée par ce navigateur.');
return;
}
this.isLocating = true;
navigator.geolocation.getCurrentPosition(
(position) => {
Alpine.store('locator').setMapCenter(
position.coords.latitude,
position.coords.longitude,
12
);
this.isLocating = false;
},
(error) => {
this.isLocating = false;
if (!showError) return;
if (error.code === error.PERMISSION_DENIED) {
// Cookie valide 30 jours
document.cookie = "geolocation_denied=true;max-age=2592000;path=/";
alert("Veuillez autoriser la géolocalisation dans les paramètres de votre navigateur. Cette préférence sera mémorisée pendant 30 jours.");
} else if (error.code === error.POSITION_UNAVAILABLE) {
alert("Votre position n'est pas disponible actuellement.");
} else if (error.code === error.TIMEOUT) {
alert("La demande de géolocalisation a pris trop de temps.");
}
}, {
enableHighAccuracy: true,
timeout: 3000,
maximumAge: 30000
}
);
},
}
}
</script>
<div class="flex rounded-full bg-dark-5 p-1 gap-1 " x-data="{
get isAudio() { return $store.locator.isAudio },
toggleType(isAudio) { $store.locator.toggleStoreType(isAudio) }
}">
<button type="button" @click="toggleType(false)" :class="!isAudio ? 'bg-white text-neutral-800' : 'bg-transparent text-neutral-500'" class="w-full btn btn-light ">
Opticien
</button>
<button type="button" @click="toggleType(true)" :class="isAudio ? 'bg-white text-audio-700' : 'bg-transparent text-neutral-500'" class="w-full hover:text-white btn btn-light ">
Acousticien
</button>
</div>
<p class="text-sm font-medium text-neutral-600 text-center">1200 magasins dans toute la France</p>
</div>
<div class="space-y-4 md:pb-0" x-data="listStoreLocator()">
<template x-for="store in paginatedStores" :key="store.mur_code">
<div class="flex flex-col p-5 gap-5 bg-white rounded-lg shadow-sm border border-neutral-200 font-medium">
<div class="flex justify-between items-start">
<div class="flex flex-wrap gap-2">
<template x-for="location in store.locations" :key="location.id">
<span class="px-3 py-1 text-xs rounded-md text-white" :class="location.is_audio ? 'bg-audio-700' : 'bg-neutral-800'">
<span x-text="location.is_audio ? 'Acousticien' : 'Opticien'"></span>
</span>
</template>
<template x-if="store.locations.some(location => location.attributes.teleophtalmologie?.value === '1')">
<span class="px-3 py-1 text-xs rounded-md text-gray-600 border border-neutral-200">
Télémédecine
</span>
</template>
</div>
</div>
<div class="text-sm text-neutral-800">
<div class="flex justify-between items-start">
<div>
<p class="mb-1" x-text="store.address"></p>
<p class="mb-2 uppercase">
<span x-text="store.city"></span>,
<span x-text="store.zip"></span>,
<span x-text="store.country"></span>
</p>
</div>
<div class="flex items-center justify-end gap-2 w-1/2">
<span class="text-sm" x-text="store.distance"></span>
<button type="button" x-data x-init="" @click="$store.locator.goToStore(store)" class="max-md:btn-size-sm btn btn-dark-ghost btn-only-icon">
<svg class=" shrink-0" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 3.75C8.55638 3.75 5.75 6.53739 5.75 9.92308C5.75 12.7655 7.35594 15.361 9.06325 17.3029C9.9074 18.263 10.7535 19.0373 11.3889 19.5713C11.6267 19.7713 11.8342 19.9369 12 20.0653C12.1658 19.9369 12.3733 19.7713 12.6111 19.5713C13.2465 19.0373 14.0926 18.263 14.9367 17.3029C16.6441 15.361 18.25 12.7655 18.25 9.92308C18.25 6.53739 15.4436 3.75 12 3.75ZM12 21C11.5731 21.6166 11.5729 21.6165 11.5726 21.6163L11.5704 21.6147L11.5651 21.6111L11.5471 21.5984C11.5318 21.5876 11.5101 21.5722 11.4824 21.5521C11.4269 21.5121 11.3473 21.4537 11.247 21.3779C11.0464 21.2263 10.7628 21.0046 10.4236 20.7195C9.74646 20.1502 8.8426 19.3236 7.93675 18.2933C6.14406 16.2544 4.25 13.3114 4.25 9.92308C4.25 5.69338 7.74362 2.25 12 2.25C16.2564 2.25 19.75 5.69338 19.75 9.92308C19.75 13.3114 17.8559 16.2544 16.0633 18.2933C15.1574 19.3236 14.2535 20.1502 13.5764 20.7195C13.2372 21.0046 12.9536 21.2263 12.753 21.3779C12.6527 21.4537 12.5731 21.5121 12.5176 21.5521C12.4899 21.5722 12.4682 21.5876 12.4529 21.5984L12.4349 21.6111L12.4296 21.6147L12.428 21.6159C12.4277 21.6161 12.4269 21.6166 12 21ZM12 21L12.4269 21.6166L12 21.9122L11.5726 21.6163L12 21ZM12 7.75C10.7574 7.75 9.75 8.75736 9.75 10C9.75 11.2426 10.7574 12.25 12 12.25C13.2426 12.25 14.25 11.2426 14.25 10C14.25 8.75736 13.2426 7.75 12 7.75ZM8.25 10C8.25 7.92893 9.92893 6.25 12 6.25C14.0711 6.25 15.75 7.92893 15.75 10C15.75 12.0711 14.0711 13.75 12 13.75C9.92893 13.75 8.25 12.0711 8.25 10Z" fill="currentColor" />
</svg>
</button>
</div>
</div>
<p x-data="{
status: null,
async checkOpenStatus(store) {
this.status = await isStoreOpen(store);
}
}" x-init="checkOpenStatus(store)">
<template x-if="!status">
<span class="text-gray-500">Chargement...</span>
</template>
<!-- État chargé -->
<template x-if="status">
<span>
<span :class="status.isOpen ? 'text-green-600' : 'text-red-700'" x-text="status.isOpen ? 'Ouvert' : 'Fermé'">
</span>
·
<span class="text-sm text-gray-800" x-text="status.text">
</span>
</span>
</template>
</p>
</div>
<div class="flex gap-3 justify-between">
<a href="#" class="max-md:btn-size-sm btn btn-dark-ghost ">
Voir le magasin
</a>
<button type="button" x-data="{}" x-init="$store.asideBlocs.addAside('storeLocatorAppointment')" @click="
(!$store.locator.stepperData ||
$store.locator.stepperData.code_mur !== store.mur_code ||
$store.locator.stepperData.type !== $store.locator.isAudio)
? $store.locator.initStepper(store)
: null;
$store.asideBlocs.toggleAside('storeLocatorAppointment')
" class="max-md:btn-size-sm btn btn-dark-ghost btn-icons">
<svg class=" shrink-0" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 1.25C8.41421 1.25 8.75 1.58579 8.75 2V3.25H15.25V2C15.25 1.58579 15.5858 1.25 16 1.25C16.4142 1.25 16.75 1.58579 16.75 2V3.25H20C21.5188 3.25 22.75 4.48122 22.75 6V20C22.75 21.5188 21.5188 22.75 20 22.75H4C2.48122 22.75 1.25 21.5188 1.25 20V6C1.25 4.48122 2.48122 3.25 4 3.25H7.25V2C7.25 1.58579 7.58579 1.25 8 1.25ZM7.25 4.75H4C3.30964 4.75 2.75 5.30964 2.75 6V8.25H21.25V6C21.25 5.30964 20.6904 4.75 20 4.75H16.75V6C16.75 6.41421 16.4142 6.75 16 6.75C15.5858 6.75 15.25 6.41421 15.25 6V4.75H8.75V6C8.75 6.41421 8.41421 6.75 8 6.75C7.58579 6.75 7.25 6.41421 7.25 6V4.75ZM21.25 9.75H2.75V20C2.75 20.6904 3.30964 21.25 4 21.25H20C20.6904 21.25 21.25 20.6904 21.25 20V9.75ZM12 14.5C12.5523 14.5 13 14.0523 13 13.5C13 12.9477 12.5523 12.5 12 12.5C11.4477 12.5 11 12.9477 11 13.5C11 14.0523 11.4477 14.5 12 14.5ZM8 17.5C8 18.0523 7.55228 18.5 7 18.5C6.44772 18.5 6 18.0523 6 17.5C6 16.9477 6.44772 16.5 7 16.5C7.55228 16.5 8 16.9477 8 17.5ZM12 18.5C12.5523 18.5 13 18.0523 13 17.5C13 16.9477 12.5523 16.5 12 16.5C11.4477 16.5 11 16.9477 11 17.5C11 18.0523 11.4477 18.5 12 18.5ZM17 14.5C17.5523 14.5 18 14.0523 18 13.5C18 12.9477 17.5523 12.5 17 12.5C16.4477 12.5 16 12.9477 16 13.5C16 14.0523 16.4477 14.5 17 14.5ZM17 18.5C17.5523 18.5 18 18.0523 18 17.5C18 16.9477 17.5523 16.5 17 16.5C16.4477 16.5 16 16.9477 16 17.5C16 18.0523 16.4477 18.5 17 18.5Z" fill="currentColor" />
</svg>
Prendre rendez-vous
</button>
</div>
</div>
</template>
<div class="py-4 border-t flex justify-around items-center">
<button type="button" @click="previousPage()" :disabled="currentPage === 1" class="disabled:opacity-50 btn btn-dark-ghost btn-only-icon">
<svg class=" shrink-0" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.46967 12.5303C9.17678 12.2374 9.17678 11.7626 9.46967 11.4697L13.4697 7.46967C13.7626 7.17678 14.2374 7.17678 14.5303 7.46967C14.8232 7.76256 14.8232 8.23744 14.5303 8.53033L11.0607 12L14.5303 15.4697C14.8232 15.7626 14.8232 16.2374 14.5303 16.5303C14.2374 16.8232 13.7626 16.8232 13.4697 16.5303L9.46967 12.5303Z" fill="currentColor" />
</svg>
</button>
<div class="flex items-center">
<template x-for="(item, index) in paginationItems" :key="index">
<div class="mt-4 flex gap-4 m-2 justify-center">
<span x-show="item.type === 'dots'" class="flex items-center justify-center font-semibold text-black/60">...</span>
<button x-show="item.type === 'page'" @click="goToPage(item.value)" class="flex items-center justify-center font-semibold" :class="currentPage === item.value ? 'px-2 border-b border-black/80' : 'text-black/60'" x-text="item.value">
</button>
</div>
</template>
</div>
<button type="button" @click="nextPage()" :disabled="currentPage === totalPages" class="disabled:opacity-50 btn btn-dark-ghost btn-only-icon">
<svg class=" shrink-0" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.5303 11.4697C14.8232 11.7626 14.8232 12.2374 14.5303 12.5303L10.5303 16.5303C10.2374 16.8232 9.76256 16.8232 9.46967 16.5303C9.17678 16.2374 9.17678 15.7626 9.46967 15.4697L12.9393 12L9.46967 8.53033C9.17678 8.23744 9.17678 7.76256 9.46967 7.46967C9.76256 7.17678 10.2374 7.17678 10.5303 7.46967L14.5303 11.4697Z" fill="currentColor" />
</svg>
</button>
</div>
</div>
<script>
function listStoreLocator() {
return {
currentPage: 1,
itemsPerPage: 10,
get stores() {
return Object.values(this.$store.locator.filteredDistanceStores || {});
},
get paginatedStores() {
const start = (this.currentPage - 1) * this.itemsPerPage;
const paginatedResults = this.stores.slice(start, start + this.itemsPerPage);
// If current page is empty, redirect into the first page
if (paginatedResults.length === 0 && this.stores.length > 0) {
this.currentPage = 1;
return this.stores.slice(0, this.itemsPerPage);
}
return paginatedResults;
},
get totalPages() {
return Math.ceil(this.stores.length / this.itemsPerPage);
},
get paginationItems() {
const items = [];
items.push({
type: 'page',
value: 1
});
if (this.currentPage > 3) {
items.push({
type: 'dots'
});
}
if (this.currentPage > 2) {
items.push({
type: 'page',
value: this.currentPage - 1
});
}
if (this.currentPage !== 1 && this.currentPage !== this.totalPages) {
items.push({
type: 'page',
value: this.currentPage
});
}
if (this.currentPage < this.totalPages - 1) {
items.push({
type: 'page',
value: this.currentPage + 1
});
}
if (this.currentPage < this.totalPages - 2) {
items.push({
type: 'dots'
});
}
if (this.totalPages > 1) {
items.push({
type: 'page',
value: this.totalPages
});
}
return items;
},
scrollToTopOfList() {},
goToPage(page) {
this.currentPage = page;
this.scrollToTopOfList();
},
nextPage() {
if (this.currentPage < this.totalPages) {
this.currentPage++;
this.scrollToTopOfList();
}
},
previousPage() {
if (this.currentPage > 1) {
this.currentPage--;
this.scrollToTopOfList();
}
},
async isStoreOpen(store) {
const DAYS_SHORT = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam'];
const DAYS_LONG = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'];
try {
if (!store?.locations?.length) {
return {
isOpen: false,
text: 'Horaires non disponibles'
};
}
const service = store.locations.find(s => !s.is_audio);
if (!service?.attributes?.schedule_opening_hours?.value) {
return {
isOpen: false,
text: 'Horaires non disponibles'
};
}
const timezone = service.attributes.timezone?.value || 'Europe/Paris';
const now = new Intl.DateTimeFormat('fr-FR', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(new Date());
const [date, time] = now.split(' ');
const [hours, minutes] = time.split(':').map(Number);
const storeDate = new Date();
const currentDay = DAYS_SHORT[storeDate.getDay()];
const currentHour = hours + minutes / 60;
const schedule = service.attributes.schedule_opening_hours.value
.split('|')
.reduce((acc, day) => {
const [key, hours] = day.split('=');
if (!key || !hours) return acc;
acc[key] = hours === 'null' ? 'Fermé' : hours;
return acc;
}, {});
// Check if close today
if (!schedule[currentDay] || schedule[currentDay] === 'Fermé' || schedule[currentDay] === 'null') {
let nextDay = (storeDate.getDay() + 1) % 7;
let daysChecked = 0;
while (daysChecked < 7) {
const daySchedule = schedule[DAYS_SHORT[nextDay]];
if (daySchedule && daySchedule !== 'Fermé' && daySchedule !== 'null') {
const [firstPeriod] = daySchedule.split(',');
if (firstPeriod && firstPeriod !== '-') {
const openTime = firstPeriod.split('-')[0];
return {
isOpen: false,
text: `Ouvre ${DAYS_LONG[nextDay]} à ${openTime}`
};
}
}
nextDay = (nextDay + 1) % 7;
daysChecked++;
}
return {
isOpen: false,
text: 'Fermé aujourd\'hui'
};
}
// Check openning periods
const periods = schedule[currentDay].split(',').filter(p => p && p !== '-');
for (const period of periods) {
const [start, end] = period.split('-');
if (!start || !end) continue;
const [startHour, startMin] = start.split(':').map(Number);
const [endHour, endMin] = end.split(':').map(Number);
if (isNaN(startHour) || isNaN(startMin) || isNaN(endHour) || isNaN(endMin)) continue;
const startTime = startHour + startMin / 60;
const endTime = endHour + endMin / 60;
if (currentHour >= startTime && currentHour < endTime) {
return {
isOpen: true,
text: `Ferme à ${end}`
};
}
if (currentHour < startTime) {
return {
isOpen: false,
text: `Ouvre à ${start}`
};
}
}
// Search next opening day
let nextDay = (storeDate.getDay() + 1) % 7;
let daysChecked = 0;
while (daysChecked < 7) {
const daySchedule = schedule[DAYS_SHORT[nextDay]];
if (daySchedule && daySchedule !== 'Fermé' && daySchedule !== 'null') {
const [firstPeriod] = daySchedule.split(',');
if (firstPeriod && firstPeriod !== '-') {
const openTime = firstPeriod.split('-')[0];
return {
isOpen: false,
text: `Ouvre ${DAYS_LONG[nextDay]} à ${openTime}`
};
}
}
nextDay = (nextDay + 1) % 7;
daysChecked++;
}
return {
isOpen: false,
text: 'Fermé'
};
} catch (error) {
console.error('Erreur dans isStoreOpen:', error);
return {
isOpen: false,
text: 'Horaires non disponibles'
};
}
}
}
}
</script> <button type="button" x-data x-init="$store.asideBlocs.addAside('storeLocatorFilter')" @click="$store.asideBlocs.toggleAside('storeLocatorFilter')" :class="$store.locator.isAudio ? 'btn-audio' : 'btn-dark'" class="hidden md:flex sticky bottom-0 inset-x-6 w-full z-10 btn btn-audio btn-size-lg btn-icons">
<svg class=" shrink-0" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.37143 7.42857C5.37143 6.29244 6.29244 5.37143 7.42857 5.37143C8.5647 5.37143 9.48571 6.29244 9.48571 7.42857C9.48571 8.5647 8.5647 9.48571 7.42857 9.48571C6.29244 9.48571 5.37143 8.5647 5.37143 7.42857ZM10.7886 6.74286C10.4709 5.17789 9.08729 4 7.42857 4C5.53502 4 4 5.53502 4 7.42857C4 9.32212 5.53502 10.8571 7.42857 10.8571C9.08729 10.8571 10.4709 9.67925 10.7886 8.11429H19.3143C19.693 8.11429 20 7.80728 20 7.42857C20 7.04986 19.693 6.74286 19.3143 6.74286H10.7886ZM4.68571 15.8857C4.307 15.8857 4 16.1927 4 16.5714C4 16.9501 4.307 17.2571 4.68571 17.2571H13.2114C13.5291 18.8221 14.9127 20 16.5714 20C18.465 20 20 18.465 20 16.5714C20 14.6779 18.465 13.1429 16.5714 13.1429C14.9127 13.1429 13.5291 14.3207 13.2114 15.8857H4.68571ZM16.5714 18.6286C15.4353 18.6286 14.5143 17.7076 14.5143 16.5714C14.5143 15.4353 15.4353 14.5143 16.5714 14.5143C17.7076 14.5143 18.6286 15.4353 18.6286 16.5714C18.6286 17.7076 17.7076 18.6286 16.5714 18.6286Z" fill="currentColor" />
</svg>
Filter les magasins
</button>
</div>
<div class="md:grid col-span-2 h-[calc(100vh-125px)] md:h-auto" x-show="!showStoreList || !$store.screen.isMobile">
<div x-data="googleMap()" x-init="init()" class="flex gap-4 h-full">
<div class="flex-1 relative">
<div class="flex rounded-full bg-dark-5 p-1 gap-1 relative flex md:hidden mx-auto m-4 max-w-96 bg-neutral-150 z-10" x-data="{
get isAudio() { return $store.locator.isAudio },
toggleType(isAudio) { $store.locator.toggleStoreType(isAudio) }
}">
<button type="button" @click="toggleType(false)" :class="!isAudio ? 'bg-white text-neutral-800' : 'bg-transparent text-neutral-500'" class="w-full btn btn-light ">
Opticien
</button>
<button type="button" @click="toggleType(true)" :class="isAudio ? 'bg-white text-audio-700' : 'bg-transparent text-neutral-500'" class="w-full hover:text-white btn btn-light ">
Acousticien
</button>
</div>
<div id="DEMO_MAP_ID" class="absolute inset-0"></div>
<template x-if="loading">
<div class="absolute inset-0 bg-white/80 flex items-center justify-center">
<div class="loading-spinner"></div>
</div>
</template>
</div>
</div>
<script>
function googleMap() {
let mapsLib = null;
let markerLib = null;
let mapInstance = null;
return {
// États et configurations
_initialized: false,
mapId: "DEMO_MAP_ID",
loading: true,
markers: [],
markerCluster: null,
selectedMarker: null,
markerConfig: {
size: 48,
colors: {
default: '#262626',
audio: '#C70C0F'
}
},
async init() {
if (this._initialized) return;
this._initialized = true;
try {
await this.loadLibraries();
await this.initMap();
if (this.$store.locator.loading) {
await new Promise(resolve => {
this.$watch('$store.locator.loading', (loading) => {
if (!loading) resolve();
});
});
}
this.updateMarkersFromStore();
this.$watch('$store.locator.filteredStores', () => {
console.log("this.$watch $store.locator.filteredStores")
this.updateMarkersFromStore();
}, {
deep: true
});
} catch (error) {
console.error('Erreur lors de l\'initialisation:', error);
} finally {
this.loading = false;
}
},
async loadLibraries() {
try {
const [maps, marker] = await Promise.all([
google.maps.importLibrary("maps"),
google.maps.importLibrary("marker"),
]);
mapsLib = maps;
markerLib = marker;
} catch (error) {
console.error('Erreur lors du chargement des bibliothèques:', error);
throw error;
}
},
async initMap() {
if (!mapsLib) {
throw new Error('Les bibliothèques Google Maps ne sont pas chargées');
}
try {
const urlParams = new URLSearchParams(window.location.search);
const lat = urlParams.get('lat') || 46.603354;
const lng = urlParams.get('lng') || 1.888334;
const validation = this.validateCoordinates(lat, lng);
const coordinates = validation.isValid ?
validation.coordinates : {
lat: 46.603354,
lng: 1.888334
};
mapInstance = new google.maps.Map(document.getElementById(this.mapId), {
center: coordinates,
zoom: 4,
mapId: this.mapId,
mapTypeControl: false,
fullscreenControl: false,
streetViewControl: false,
zoomControl: !this.$store.screen.isMobile
});
this.$store.locator.mapInstance = mapInstance;
mapInstance.addListener('idle', () => {
this.$store.locator.updateDistances(this.$store.locator.filteredStores);
this.updateUrlParams();
});
} catch (error) {
console.error('Erreur lors de l\'initialisation de la carte:', error);
throw error;
}
},
validateCoordinates(lat, lng) {
if (lat === null || lng === null || lat === undefined || lng === undefined) {
return {
isValid: false,
error: 'Les coordonnées ne peuvent pas être null ou undefined'
};
}
const latitude = parseFloat(lat);
const longitude = parseFloat(lng);
if (isNaN(latitude) || isNaN(longitude)) {
return {
isValid: false,
error: 'Les coordonnées doivent être des nombres valides'
};
}
if (lat === '' || lng === '') {
return {
isValid: false,
error: 'Les coordonnées ne peuvent pas être vides'
};
}
if (latitude < -90 || latitude > 90) {
return {
isValid: false,
error: 'La latitude doit être comprise entre -90 et 90'
};
}
if (longitude < -180 || longitude > 180) {
return {
isValid: false,
error: 'La longitude doit être comprise entre -180 et 180'
};
}
return {
isValid: true,
coordinates: {
lat: latitude,
lng: longitude
}
};
},
updateUrlParams() {
if (!mapInstance) return;
try {
const center = mapInstance.getCenter();
const newUrl = new URL(window.location.href);
const validation = this.validateCoordinates(
center.lat(),
center.lng()
);
if (validation.isValid) {
newUrl.searchParams.set('lat', validation.coordinates.lat.toFixed(6));
newUrl.searchParams.set('lng', validation.coordinates.lng.toFixed(6));
window.history.replaceState({}, '', newUrl.toString());
}
} catch (error) {
console.error('Erreur lors de la mise à jour des paramètres URL:', error);
}
},
createMarkerSVG(isMultiStore, isAudio) {
const leftColor = isMultiStore ? this.markerConfig.colors.default :
isAudio ? this.markerConfig.colors.audio :
this.markerConfig.colors.default;
const rightColor = isMultiStore ? this.markerConfig.colors.audio :
isAudio ? this.markerConfig.colors.audio :
this.markerConfig.colors.default;
return `
<path fill-rule="evenodd" clip-rule="evenodd"
d="M24 42C24 42 38 32.3076 38 19.8462C38 12.2308 31.7 6 24 6V42Z"
fill="${rightColor}"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M24 6C16.3 6 10 12.2308 10 19.8462C10 32.3076 24 42 24 42V6Z"
fill="${leftColor}"/>
`;
},
createMarkerElement(store, isSelected = false) {
const container = document.createElement('div');
const size = isSelected ? 64 : 48;
// Création du SVG principal
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', `0 0 ${this.markerConfig.size} ${this.markerConfig.size}`);
svg.setAttribute('width', size);
svg.setAttribute('height', size);
svg.innerHTML = this.createMarkerSVG(
store.locations.length > 1,
this.$store.locator.isAudio
);
// Si c'est sélectionné, ajouter le SVG overlay et l'infoWindow
if (isSelected) {
container.className = 'relative';
// Container du marker
const markerWrapper = document.createElement('div');
markerWrapper.appendChild(svg);
// SVG overlay
const overlaySvg = document.createElement('div');
overlaySvg.className = 'absolute inset-0 flex items-center justify-center pointer-events-none';
overlaySvg.innerHTML = `
<svg width="24" height="24" viewBox="0 0 20 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13.8392L7.04036 0.162109H11.435V8.26383L15.6054 0.162109H20V13.8392H17.13V10.7899H13.139L11.6592 13.8392H8.56503V10.7899H4.57399L3.09417 13.8392H0ZM8.56503 2.76301H8.4305L5.60538 8.68229H8.56503V2.76301ZM17.13 2.76301H16.9955L14.1704 8.68229H17.13V2.76301Z" fill="white"/>
</svg>
`;
markerWrapper.appendChild(overlaySvg);
// Conteneur pour l'infoWindow
const infoContainer = document.createElement('div');
infoContainer.className = 'absolute -translate-x-1/2 left-1/2 top-full mt-2 z-50';
infoContainer.id = 'info-container';
container.appendChild(markerWrapper);
container.appendChild(infoContainer);
} else {
container.appendChild(svg);
}
return container;
},
updateMarkersFromStore() {
if (!markerLib || !mapInstance) return;
try {
this.clearMarkers();
const validStores = this.$store.locator.filteredStores.filter(store => {
const validation = this.validateCoordinates(store.lat, store.lng);
if (!validation.isValid) {
console.warn(`Marqueur ignoré pour le store ${store.id || 'Unknown'}: ${validation.error}`);
return false;
}
return true;
});
this.markers = validStores.map(store => {
const validation = this.validateCoordinates(store.lat, store.lng);
const position = validation.coordinates;
const marker = new markerLib.AdvancedMarkerElement({
position,
content: this.createMarkerElement(store),
map: mapInstance
});
this.addMarkerEvents(marker, store);
return marker;
});
this.createMarkerCluster(this.markers);
} catch (error) {
console.error('Erreur lors de la mise à jour des marqueurs:', error);
}
},
addMarkerEvents(marker, store) {
marker.addListener('click', (e) => {
try {
console.log("Marker cliqué :", store);
// Si un nouveau marker est déjà sélectionné
if (this.selectedMarker) {
// Vérifier si le clic est sur le nouveau marker ou le marker d'origine
const isSameMarker =
this.selectedMarker.position.equals(marker.position) ||
this.selectedMarker.position.equals(marker.position);
if (isSameMarker) {
console.log("Marker déjà sélectionné détecté.");
if (this.currentInfoWindow) {
console.log("InfoWindow déjà ouverte. Pas d'action.");
return; // Ne rien faire si l'infoWindow est déjà ouverte
} else {
console.log("InfoWindow fermée. Réouverture...");
this.openInfoWindow(store); // Rouvrir l'infoWindow si elle est fermée
return;
}
}
}
// Nouveau marker cliqué : nettoyer la sélection précédente
console.log("Nouveau marker cliqué. Ancienne sélection supprimée.");
this.cleanupPreviousSelection(true);
// Création d'un nouveau marker pour la sélection
this.selectedMarker = new markerLib.AdvancedMarkerElement({
position: marker.position,
content: this.createMarkerElement(store, true),
map: mapInstance,
zIndex: 1000
});
console.log("Nouveau marker créé pour la sélection :", this.selectedMarker);
// Ajouter un gestionnaire d'événement pour le nouveau marker
this.selectedMarker.addListener('click', (e) => {
console.log("Clic sur le nouveau marker sélectionné.");
// Même logique de vérification que ci-dessus
if (this.currentInfoWindow) {
console.log("InfoWindow déjà ouverte. Pas d'action.");
return;
} else {
console.log("Réouverture de l'infoWindow pour le store :", store);
this.openInfoWindow(store);
}
});
// Met à jour le store sélectionné
this.$store.locator.selectStore(store);
// Recentre la carte sur le nouveau marker
mapInstance.panTo(marker.position);
console.log("Nouvelle sélection. Ouverture de l'infoWindow...");
this.openInfoWindow(store);
// Configurer le gestionnaire pour fermer en cliquant à l'extérieur
this.setupInfoWindowCloseHandler();
} catch (error) {
console.error('Erreur lors du clic sur un marqueur:', error);
}
});
},
openInfoWindow(store) {
console.log("Ouverture de l'infoWindow pour le store :", store);
// Créer et attacher l'infoWindow au marker sélectionné
const infoWindow = this.createInfoWindow(store);
this.currentInfoWindow = infoWindow;
const infoContainer = this.selectedMarker.content.querySelector('#info-container');
if (infoContainer) {
infoContainer.appendChild(infoWindow);
}
},
cleanupPreviousSelection(removeMarker = false) {
if (removeMarker && this.selectedMarker) {
// Supprime le marker sélectionné précédent, s'il existe
this.selectedMarker.setMap(null); // Retire le marker de la carte
this.selectedMarker = null; // Réinitialise l'état
console.log("Suppression du marker sélectionné précédent.");
}
if (this.currentInfoWindow) {
this.currentInfoWindow.remove(); // Ferme l'infoWindow
console.log("Suppression de l'infoWindow actuelle.");
this.currentInfoWindow = null; // Réinitialise l'état
}
},
setupInfoWindowCloseHandler() {
const closeHandler = (e) => {
console.log("Clic détecté en dehors. Vérification des éléments...");
// Vérifiez si le clic est sur le marker ou l'infoWindow sélectionnée
const isClickOnMarker = this.selectedMarker?.content && this.selectedMarker.content.contains(e.target);
const isClickOnInfoWindow = this.currentInfoWindow && this.currentInfoWindow.contains(e.target);
if (isClickOnMarker) {
console.log("Clic détecté sur le marker sélectionné. Pas d'action.");
}
if (isClickOnInfoWindow) {
console.log("Clic détecté sur l'infoWindow sélectionnée. Pas d'action.");
}
if (!isClickOnMarker && !isClickOnInfoWindow) {
console.log("Clic en dehors des éléments. Fermeture de la windows.");
this.cleanupPreviousSelection(false);
document.removeEventListener('click', closeHandler);
}
};
// Supprime tout gestionnaire existant
document.removeEventListener('click', closeHandler);
// Ajoute un nouveau gestionnaire global
setTimeout(() => document.addEventListener('click', closeHandler), 0);
},
clearMarkers() {
try {
if (this.currentInfoWindow) {
this.currentInfoWindow.remove();
}
if (this.selectedMarker) {
this.selectedMarker.setMap(null);
}
if (this.markerCluster) {
this.markerCluster.setMap(null);
}
this.markers.forEach(marker => marker.setMap(null));
this.markers = [];
} catch (error) {
console.error('Erreur lors du nettoyage des marqueurs:', error);
}
},
createMarkerCluster(markersToCluster) {
if (!mapInstance || !markersToCluster.length) return;
try {
this.markerCluster = new markerClusterer.MarkerClusterer({
markers: markersToCluster,
map: mapInstance,
ignoreHidden: true,
algorithm: new markerClusterer.SuperClusterAlgorithm({
radius: 250,
maxZoom: 12
}),
renderer: {
render: ({
count,
position
}) => {
const scale = count < 5 ? 30 : count < 50 ? 40 : 50;
const div = document.createElement('div');
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', `0 0 ${scale} ${scale}`);
svg.setAttribute('width', scale);
svg.setAttribute('height', scale);
const circle = `<circle cx="${scale / 2}" cy="${scale / 2}" r="${(scale / 2) - 2}" fill="${this.$store.locator.isAudio ? "#C70C0F" : "#262626"}" stroke="white" stroke-width="2"/>`;
const text = count >= 5 ?
`<text x="${scale / 2}" y="${scale / 2}" text-anchor="middle" dominant-baseline="central" fill="white" font-size="14px" font-weight="bold">${count < 50 ? count : '50+'}</text>` :
'';
svg.innerHTML = circle + text;
div.appendChild(svg);
return new markerLib.AdvancedMarkerElement({
position,
content: div
});
}
}
});
} catch (error) {
console.error('Erreur lors de la création du cluster de marqueurs:', error);
}
},
getGoogleMapsUrl(lat, lng, address) {
const encodedAddress = encodeURIComponent(address);
return `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}&destination_place_id=${encodedAddress}`;
},
createInfoWindow(store) {
const div = document.createElement('div');
div.className = 'font-sans bg-white rounded-lg border border-neutral-300 shadow-lg p-1 min-w-40';
const fullAddress = `${store.address}, ${store.zip} ${store.city}`.trim();
const googleMapsUrl = this.getGoogleMapsUrl(store.lat, store.lng, fullAddress);
div.innerHTML = `
<div class="flex flex-col gap-2">
${store.address && store.city ? `
<div class="text-xs text-neutral-800">
${store.address}, ${store.city}
</div>
` : ''}
<div class="flex flex-col gap-2 mt-2">
<a href="#" class=" btn btn-dark-ghost btn-size-sm">
Voir la fiche magasin
</a>
<a href="${googleMapsUrl}" class=" btn btn-dark-ghost btn-size-sm">
Itinéraire
</a>
</div>
</div>
`;
return div;
}
};
}
</script>
</div>
<div class="sticky bottom-0 inset-x-0 flex md:hidden flex-row justify-around w-full p-4 bg-gradient-to-b from-transparent to-white z-10">
<div class="flex rounded-full bg-neutral-150 p-1 gap-1">
<button type="button" @click="showStoreList = !showStoreList" :class="!showStoreList ? 'bg-white text-neutral-800' : 'bg-transparent text-neutral-500'" class="w-full !px-4 btn btn-light btn-only-icon">
<svg class=" shrink-0" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.5 6C4.5 5.17157 5.17157 4.5 6 4.5H6.1C6.92843 4.5 7.6 5.17157 7.6 6C7.6 6.82843 6.92843 7.5 6.1 7.5H6C5.17157 7.5 4.5 6.82843 4.5 6ZM10.25 6C10.25 5.58579 10.5858 5.25 11 5.25H19C19.4142 5.25 19.75 5.58579 19.75 6C19.75 6.41421 19.4142 6.75 19 6.75H11C10.5858 6.75 10.25 6.41421 10.25 6ZM4.5 12C4.5 11.1716 5.17157 10.5 6 10.5H6.1C6.92843 10.5 7.6 11.1716 7.6 12C7.6 12.8284 6.92843 13.5 6.1 13.5H6C5.17157 13.5 4.5 12.8284 4.5 12ZM10.25 12C10.25 11.5858 10.5858 11.25 11 11.25H19C19.4142 11.25 19.75 11.5858 19.75 12C19.75 12.4142 19.4142 12.75 19 12.75H11C10.5858 12.75 10.25 12.4142 10.25 12ZM4.5 18C4.5 17.1716 5.17157 16.5 6 16.5H6.1C6.92843 16.5 7.6 17.1716 7.6 18C7.6 18.8284 6.92843 19.5 6.1 19.5H6C5.17157 19.5 4.5 18.8284 4.5 18ZM10.25 18C10.25 17.5858 10.5858 17.25 11 17.25H19C19.4142 17.25 19.75 17.5858 19.75 18C19.75 18.4142 19.4142 18.75 19 18.75H11C10.5858 18.75 10.25 18.4142 10.25 18Z" fill="currentColor" />
</svg>
</button>
<button type="button" @click="showStoreList = !showStoreList" :class="showStoreList ? 'bg-white text-neutral-800' : 'bg-transparent text-neutral-500'" class="w-full !px-4 btn btn-light btn-only-icon">
<svg class=" shrink-0" width="24" height="24" viewBox="0 0 18 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.25 1.8379C17.25 0.744889 16.0765 0.0339971 15.0792 0.522807L12.1708 1.94822C11.7485 2.15518 11.2515 2.15518 10.8292 1.94822L7.17082 0.155226C6.74853 -0.0517419 6.25147 -0.0517422 5.82918 0.155226L1.57918 2.23818C1.071 2.48724 0.75 2.99636 0.75 3.55328V14.1621C0.75 15.2551 1.92347 15.966 2.92082 15.4772L5.82918 14.0518C6.25147 13.8448 6.74853 13.8448 7.17082 14.0518L10.8292 15.8448C11.2515 16.0517 11.7485 16.0517 12.1708 15.8448L16.4208 13.7618C16.929 13.5128 17.25 13.0036 17.25 12.4467V1.8379ZM2.25 4.46199C2.25 3.90507 2.571 3.39595 3.07918 3.14689L5.75 1.8379V12.4467L2.97361 13.8075C2.64116 13.9704 2.25 13.7334 2.25 13.3691V4.46199ZM7.25 12.4467L10.75 14.1621V3.55328L7.25 1.8379V12.4467ZM12.25 3.55328V14.1621L14.9208 12.8531C15.429 12.604 15.75 12.0949 15.75 11.538V2.63091C15.75 2.26658 15.3588 2.02961 15.0264 2.19255L12.25 3.55328Z" fill="currentColor" />
</svg>
</button>
</div> <button type="button" x-data x-init="$store.asideBlocs.addAside('storeLocatorFilter')" @click="$store.asideBlocs.toggleAside('storeLocatorFilter')" :class="$store.locator.isAudio ? 'btn-audio' : 'btn-dark'" class="flex md:hidden btn btn-audio btn-icons">
<svg class=" shrink-0" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.37143 7.42857C5.37143 6.29244 6.29244 5.37143 7.42857 5.37143C8.5647 5.37143 9.48571 6.29244 9.48571 7.42857C9.48571 8.5647 8.5647 9.48571 7.42857 9.48571C6.29244 9.48571 5.37143 8.5647 5.37143 7.42857ZM10.7886 6.74286C10.4709 5.17789 9.08729 4 7.42857 4C5.53502 4 4 5.53502 4 7.42857C4 9.32212 5.53502 10.8571 7.42857 10.8571C9.08729 10.8571 10.4709 9.67925 10.7886 8.11429H19.3143C19.693 8.11429 20 7.80728 20 7.42857C20 7.04986 19.693 6.74286 19.3143 6.74286H10.7886ZM4.68571 15.8857C4.307 15.8857 4 16.1927 4 16.5714C4 16.9501 4.307 17.2571 4.68571 17.2571H13.2114C13.5291 18.8221 14.9127 20 16.5714 20C18.465 20 20 18.465 20 16.5714C20 14.6779 18.465 13.1429 16.5714 13.1429C14.9127 13.1429 13.5291 14.3207 13.2114 15.8857H4.68571ZM16.5714 18.6286C15.4353 18.6286 14.5143 17.7076 14.5143 16.5714C14.5143 15.4353 15.4353 14.5143 16.5714 14.5143C17.7076 14.5143 18.6286 15.4353 18.6286 16.5714C18.6286 17.7076 17.7076 18.6286 16.5714 18.6286Z" fill="currentColor" />
</svg>
Filter les magasins
</button>
</div>
</div>
<div class="md:grid md:grid-cols-3 h-[calc(100vh-125px)] md:h-[calc(100vh-141px)] overflow-auto" x-data="{ showStoreList: false }">
<div class="relative h-[calc(100vh-125px)] md:h-auto md:m-4 lg:m-6 overflow-auto" x-show="showStoreList || !$store.screen.isMobile">
<div class="md:sticky top-0 inset-x-6 bg-white z-10 flex flex-col gap-5">
<h1 class="text-2xl font-medium">Trouver un magasin</h1>
{% render '@search-bar' %}
{% render '@store-switcher' %}
<p class="text-sm font-medium text-neutral-600 text-center">1200 magasins dans toute la France</p>
</div>
{% render '@store-list' %}
{% render "@template-button" with {
size: 'lg',
label: 'Filter les magasins',
color: 'audio',
icon: {name:'library--settings-1'},
type:"leading-icon",
button_class: "hidden md:flex sticky bottom-0 inset-x-6 w-full z-10",
button_attribute: ('x-data x-init="$store.asideBlocs.addAside(\'storeLocatorFilter\')" @click="$store.asideBlocs.toggleAside(\'storeLocatorFilter\')" :class="$store.locator.isAudio ? \'btn-audio\' : \'btn-dark\'"')|replace({"\'": "'"})
} %}
</div>
<div class="md:grid col-span-2 h-[calc(100vh-125px)] md:h-auto" x-show="!showStoreList || !$store.screen.isMobile">
{% render "@google-map" %}
</div>
<div class="sticky bottom-0 inset-x-0 flex md:hidden flex-row justify-around w-full p-4 bg-gradient-to-b from-transparent to-white z-10">
{% render "@store-switcher-mobile" with {
onListClick: 'showStoreList = !showStoreList',
onMapClick: 'showStoreList = !showStoreList'
} %}
{% render "@template-button" with {
label: 'Filter les magasins',
color: 'audio',
icon: {name:'library--settings-1'},
type:"leading-icon",
button_class: "flex md:hidden",
button_attribute: ('x-data x-init="$store.asideBlocs.addAside(\'storeLocatorFilter\')" @click="$store.asideBlocs.toggleAside(\'storeLocatorFilter\')" :class="$store.locator.isAudio ? \'btn-audio\' : \'btn-dark\'" ')|replace({"\'": "'"})
} %}
</div>
</div>
/* No context defined. */
No notes defined.