<div x-data class="fixed right-0 z-40 inset-y-0 max-w-full">
<div x-cloak x-transition.opacity x-show="$store.asideBlocs.asides.find(aside => aside.name === 'storeLocatorAppointment')?.open" class="fixed inset-0 w-full h-full bg-dark-40 backdrop-blur-xl"></div>
<div x-cloak x-transition:enter="transition ease-out duration-300" x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0" x-transition:leave="transition ease-in duration-300" x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full" x-show="$store.asideBlocs.asides.find(aside => aside.name === 'storeLocatorAppointment')?.open" class="h-full relative bg-light-white overflow-hidden w-screen md:max-w-screen-sm flex flex-col" @click.outside="$store.asideBlocs.closeAside('storeLocatorAppointment')">
<div class="p-4 md:px-10 md:py-6 font-medium text-2xl flex justify-between items-center">
Livraison et retours
<button type="button" @click="$store.asideBlocs.closeAside('storeLocatorAppointment')" 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="M8.99469 7.9047C8.70179 7.6118 8.22692 7.6118 7.93403 7.9047C7.64113 8.19759 7.64113 8.67246 7.93403 8.96536L10.9392 11.9706L7.93403 14.9758C7.64114 15.2687 7.64114 15.7435 7.93403 16.0364C8.22693 16.3293 8.7018 16.3293 8.99469 16.0364L11.9999 13.0312L15.0051 16.0364C15.298 16.3293 15.7729 16.3293 16.0658 16.0364C16.3586 15.7435 16.3586 15.2687 16.0658 14.9758L13.0606 11.9706L16.0658 8.96536C16.3587 8.67246 16.3587 8.19759 16.0658 7.9047C15.7729 7.6118 15.298 7.6118 15.0051 7.9047L11.9999 10.9099L8.99469 7.9047Z" fill="currentColor" />
</svg>
</button>
</div>
<div class="px-4 md:px-10 overflow-auto flex-1 border-t border-b border-neutral-200">
<div x-data="stepInit()" x-init="init">
<template x-if="!isInitialized">
<div>Chargement...</div>
</template>
<template x-if="isInitialized">
<div class="w-full max-w-3xl mx-auto py-8">
<div x-show="!stepperData.completed">
<div class="relative">
<div class="absolute top-4 w-full h-[1px] bg-gray-200">
<div class="absolute top-0 left-0 h-full transition-all duration-500" :style="'width: ' + (((stepperData.currentStep - 1) / (stepperData.steps.length - 1)) * 100) + '%; background-color: #B8A369;'">
</div>
</div>
<div class="relative flex justify-between">
<template x-for="(step, index) in stepperData.steps" :key="step.id">
<div class="flex flex-col items-center" :class="{ 'pointer-events-none': step.id === 1 }">
<div class="w-8 h-8 rounded-full border-2 flex items-center justify-center relative bg-white" :class="{
'border-brand-400 bg-brand-400 text-white': step.completed,
'border-neutral-300 text-neutral-500': !step.completed && step.id !== stepperData.currentStep,
'border-neutral-300 text-neutral-700': !step.completed && step.id === stepperData.currentStep
}">
<template x-if="step.completed">
<svg class="text-brand-400 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 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22ZM16.5303 9.86366L11.197 15.197C10.9041 15.4899 10.4292 15.4899 10.1363 15.197L7.46967 12.5303C7.17678 12.2374 7.17678 11.7626 7.46967 11.4697C7.76256 11.1768 8.23744 11.1768 8.53033 11.4697L10.6667 13.606L15.4697 8.803C15.7626 8.51011 16.2374 8.51011 16.5303 8.803C16.8232 9.0959 16.8232 9.57077 16.5303 9.86366Z" fill="currentColor" />
</svg>
</template>
<template x-if="!step.completed">
<span x-text="step.id"></span>
</template>
</div>
<span class="mt-2 text-sm font-medium" :class="{
'text-brand-400': step.completed || step.id === stepperData.currentStep,
'text-neutral-500': !step.completed && step.id !== stepperData.currentStep
}" x-text="step.title"></span>
</div>
</template>
</div>
</div>
<div class="space-y-6">
<template x-for="(item, index) in stepperData.data" :key="index">
<div class="border-b border-gray-200 pb-4 last:border-b-0">
<p class="text-sm font-normal text-gray-500 mb-1" x-text="item.title"></p>
<div class="flex justify-between items-center">
<p class="text-base text-gray-900" x-text="item.value"></p>
<button class="text-sm text-gray-900 underline hover:no-underline focus:outline-none" @click="goToStep(index + 1)">
Modifier
</button>
</div>
</div>
</template>
<template x-if="stepperData.data.length <= 1">
<p class="text-sm text-gray-500 italic">
Complétez les étapes suivantes pour voir le récapitulatif
</p>
</template>
</div>
<div class="mt-8 p-6">
<template x-for="step in stepperData.steps" :key="step.id">
<div x-show="stepperData.currentStep === step.id" class="space-y-4">
<div x-show="step.id === 2">
<div x-data="servicesInit()" class="w-full max-w-3xl mx-auto space-y-6">
<!-- Pour chaque catégorie -->
<template x-for="(code, index) in Object.keys(categories)" :key="code">
<div class="border-b border-gray-200 last:border-b-0" x-show="services[code]">
<div class="py-4">
<h2 class="text-gray-600 text-sm font-medium mb-4" x-text="categories[code].title"></h2>
<!-- Liste des services -->
<div class="space-y-4">
<template x-for="service in services[code]" :key="service.serviceCode">
<div x-data="serviceCardInit()" class="bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer p-6" @click="selectService($event, service)">
<div class="flex items-start gap-4">
<!-- Icône du service -->
<div class="flex-shrink-0">
<div class="w-8 h-8 text-[#B8A369]">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 3H17V5H19V8C19 9.9 17.7 11.4 16 11.9V19C16 20.7 14.7 22 13 22H11C9.3 22 8 20.7 8 19V11.9C6.3 11.4 5 9.9 5 8V5H7V3ZM9 3V5H15V3H9ZM7 7V8C7 9.7 8.3 11 10 11H14C15.7 11 17 9.7 17 8V7H7Z" />
</svg>
</div>
</div>
<!-- Contenu principal -->
<div class="flex-1">
<div class="flex flex-col">
<h3 class="text-lg text-neutral-900 font-medium mb-1" x-text="service.service"></h3>
<div data-expand-trigger @click.stop="isExpanded = !isExpanded">
<button class="text-neutral-500 text-sm hover:text-neutral-700 transition-colors w-fit" :class="{ 'underline': !isExpanded }">
<span x-text="isExpanded ? 'Masquer' : 'En savoir plus'"></span>
<span class="inline-block ml-1 transition-transform duration-200" :class="{ 'rotate-180': isExpanded }">↓</span>
</button>
</div>
</div>
<!-- Contenu dépliable -->
<div x-show="isExpanded" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 transform -translate-y-2" x-transition:enter-end="opacity-100 transform translate-y-0" class="mt-4 space-y-4" @click.stop>
<p class="text-sm text-neutral-600">
Durée approximative : <span x-text="service.duration + ' minutes'"></span>
</p>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
</div>
<script>
function servicesInit() {
return {
services: [],
categories: {
'AR-001': {
title: 'SANTÉ DES YEUX',
icon: 'library--vision-test'
},
'AR-002': {
title: 'LENTILLES',
icon: 'library--contact-lenses'
},
'AR-004': {
title: 'VISAGISME',
icon: 'library--glasses'
}
},
async init() {
try {
const response = await fetch(`${window.location.origin}/js/json/getStoreServices.json`);
const data = await response.json();
// Grouper les services par areaCode
this.services = data.reduce((acc, service) => {
if (!acc[service.areaCode]) {
acc[service.areaCode] = [];
}
acc[service.areaCode].push(service);
return acc;
}, {});
} catch (error) {
console.error('Erreur lors du chargement des services:', error);
this.services = {};
}
}
}
}
function serviceCardInit() {
return {
isExpanded: false,
selectService(event, currentService) {
// Ignorer si le clic est sur le bouton 'En savoir plus' ou son conteneur
if (event.target.closest('[data-expand-trigger]')) {
return;
}
// Utiliser le store locator pour mettre à jour les données
this.$store.locator.updateStepperData(
'SUJET DU RENDEZ-VOUS',
currentService.service,
this.$store.locator.stepperData.currentStep + 1
);
}
}
}
</script>
</div>
<div x-show="step.id === 3">
<div x-data="datePicker()" x-init="init().then()" class="w-full max-w-3xl mx-auto space-y-8">
<h1 class="text-3xl font-normal mb-8">Date et heure du rendez-vous</h1>
<div class="bg-white rounded-3xl p-8">
<div class="flex justify-center items-center mb-8 relative">
<button @click="handlePreviousClick().then()" class="absolute left-0 w-10 h-10 flex items-center justify-center rounded-full bg-neutral-100 hover:bg-neutral-200 transition-colors">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" class="text-neutral-600">
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<span x-text="displayRange" class="text-xl font-normal"></span>
<button @click="handleNextClick().then()" class="absolute right-0 w-10 h-10 flex items-center justify-center rounded-full bg-neutral-100 hover:bg-neutral-200 transition-colors">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" class="text-neutral-600">
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
<div class="grid grid-cols-7 gap-0">
<template x-for="day in ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']">
<div class="text-center py-2 text-neutral-900 font-normal text-lg" x-text="day"></div>
</template>
<template x-for="day in days">
<div class="relative border-[0.5px] border-neutral-200">
<button @click="selectDate(day)" class="w-full aspect-square flex items-center justify-center relative" :class="{
'bg-green-50': day.isAvailable,
'cursor-pointer hover:bg-green-100': day.isAvailable,
'cursor-not-allowed bg-neutral-50': !day.isAvailable
}" :disabled="!day.isAvailable">
<div x-show="!day.isAvailable" class="absolute inset-0 bg-[linear-gradient(to_top_right,transparent_calc(50%_-_1px),#e5e7eb,transparent_calc(50%_+_1px))]">
</div>
<div class="relative flex flex-col items-center" :class="{
'text-green-700': day.isAvailable,
'text-neutral-400': !day.isAvailable
}">
<span class="text-lg" x-text="day.dayOfMonth"></span>
<div x-show="day.isAvailable" class="w-1.5 h-1.5 rounded-full bg-green-700 mt-1">
</div>
</div>
<div x-show="selectedDate && day.date.getTime() === selectedDate.getTime()" class="absolute inset-0 border-2 border-green-700 pointer-events-none">
</div>
</button>
</div>
</template>
</div>
<div class="mt-4 flex items-center gap-2 text-green-700">
<div class="w-1.5 h-1.5 rounded-full bg-green-700"></div>
<span class="text-sm">Jours avec des horaires disponibles</span>
</div>
</div>
<div x-show="selectedDate" class="space-y-4">
<h2 class="text-xl">Sélectionner l'horaire</h2>
<div class="grid grid-cols-4 gap-4">
<template x-for="slot in availableHours" :key="slot.initialHour">
<button @click="selectTime(slot.initialHour)" class="py-3 px-6 rounded-full border border-gray-200 hover:border-brand-400 transition-colors" :class="{
'bg-brand-50 border-brand-400': selectedTime === slot.initialHour,
'bg-white': selectedTime !== slot.initialHour
}">
<span x-text="slot.initialHour"></span>
</button>
</template>
</div>
</div>
</div>
<script>
function datePicker() {
return {
// États initiaux explicitement définis
startDate: new Date(), // On initialise directement avec une date par défaut
selectedDate: null,
selectedTime: null,
days: [],
availabilities: {},
availableHours: [],
isLoading: false,
displayRange: '',
initialized: false,
// Getter pour accéder aux données du stepper
get stepperData() {
return this.$store.locator.stepperData;
},
// Nouvelle méthode d'initialisation
async init() {
if (this.initialized) return;
try {
this.isLoading = true;
// Initialisation avec la date du jour
const today = new Date();
// Calcul du début de la semaine
let startOfWeek = new Date(today);
startOfWeek.setHours(0, 0, 0, 0);
// Ajustement pour commencer au lundi
const dayOfWeek = today.getDay();
const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
startOfWeek.setDate(today.getDate() + diff);
// Définition explicite de la date de début
this.startDate = startOfWeek;
// Initialisation du calendrier
await this.updateDateRange(this.startDate);
this.initialized = true;
} catch (error) {
console.error('Erreur lors de l\'initialisation:', error);
} finally {
this.isLoading = false;
}
},
// Le reste des méthodes reste identique à votre code original
async fetchAvailabilities(startDate, endDate) {
try {
const url = new URL(`${window.location.origin}/js/json/getStoreServiceAvailabilities.json`);
url.searchParams.append('date_start', this.formatDateISO(startDate));
url.searchParams.append('date_end', this.formatDateISO(endDate));
const response = await fetch(url);
const data = await response.json();
this.availabilities = data.reduce((acc, item) => {
acc[item.disponibilityDay] = item.disponibilityHours;
return acc;
}, {});
} catch (error) {
console.error('Erreur lors de la récupération des disponibilités:', error);
this.availabilities = {};
}
},
// Mise à jour améliorée du calendrier
async updateDateRange(newStartDate) {
if (!newStartDate) return;
try {
this.isLoading = true;
// Création d'une copie de la date de début
const startDate = new Date(newStartDate);
startDate.setHours(0, 0, 0, 0);
// Calcul de la date de fin
const endDate = new Date(startDate);
endDate.setDate(startDate.getDate() + 13);
// Récupération des disponibilités
await this.fetchAvailabilities(startDate, endDate);
// Mise à jour de l'état
this.startDate = startDate;
// Mise à jour de l'affichage
this.updateCalendarDisplay();
} catch (error) {
console.error('Erreur lors de la mise à jour:', error);
} finally {
this.isLoading = false;
}
},
// Navigation améliorée
async handlePreviousClick() {
if (this.isLoading) return;
const newStartDate = new Date(this.startDate);
newStartDate.setDate(newStartDate.getDate() - 14);
await this.updateDateRange(newStartDate);
},
async handleNextClick() {
if (this.isLoading) return;
const newStartDate = new Date(this.startDate);
newStartDate.setDate(newStartDate.getDate() + 14);
await this.updateDateRange(newStartDate);
},
// Mise à jour robuste de l'affichage du calendrier
updateCalendarDisplay() {
if (!this.startDate) return;
const newDays = [];
const endDate = new Date(this.startDate);
endDate.setDate(endDate.getDate() + 13);
// Itération sur les dates
for (let currentDate = new Date(this.startDate); currentDate <= endDate; currentDate.setDate(currentDate.getDate() + 1)) {
const dateStr = this.formatDateISO(new Date(currentDate));
const isAvailable = Boolean(
this.availabilities &&
this.availabilities[dateStr] &&
this.availabilities[dateStr].length > 0
);
newDays.push({
date: new Date(currentDate),
dayOfMonth: currentDate.getDate(),
isAvailable,
dateStr
});
}
this.days = newDays;
this.updateDisplayRange();
},
// Formatage amélioré des dates
formatDateISO(date) {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
},
updateDisplayRange() {
if (!this.startDate) return;
const endDate = new Date(this.startDate);
endDate.setDate(endDate.getDate() + 13);
const formatOptions = {
day: 'numeric',
month: 'short',
year: 'numeric'
};
this.displayRange = `du ${this.startDate.toLocaleDateString('fr-FR', formatOptions)} au ${endDate.toLocaleDateString('fr-FR', formatOptions)}`;
},
// Sélection d'une date
selectDate(day) {
if (!day.isAvailable) return;
this.selectedDate = day.date;
this.selectedTime = null;
this.availableHours = this.availabilities[day.dateStr] || [];
},
// Formatage d'une date selon le format demandé
formatDate(date, format = 'long') {
const options = format === 'long' ?
{
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
} :
{
day: 'numeric',
month: 'short',
year: 'numeric'
};
return date.toLocaleDateString('fr-FR', options);
},
// Sélection d'un créneau horaire
selectTime(time) {
if (!this.selectedDate) return;
// Formatage de la date et l'heure pour l'affichage
const dateStr = this.selectedDate.toLocaleDateString('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
});
// Mise à jour des données du stepper et passage à l'étape suivante
this.$store.locator.updateStepperData(
'DATE ET HEURE DU RENDEZ-VOUS',
`${dateStr} à ${time}`,
this.stepperData.currentStep + 1
);
}
};
}
</script>
</div>
<div x-show="step.id === 4">
<div x-data="formAppointment()" class="w-full max-w-3xl mx-auto space-y-8">
<h1 class="text-3xl font-normal">Information du contact</h1>
<div class="bg-neutral-50 rounded-lg p-6 flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-6 h-6">
<svg class="text-neutral-900 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.75C14.3472 3.75 16.25 5.65279 16.25 8C16.25 10.3472 14.3472 12.25 12 12.25C9.65279 12.25 7.75 10.3472 7.75 8C7.75 5.65279 9.65279 3.75 12 3.75ZM15.1254 12.8272C16.7051 11.8024 17.75 10.0232 17.75 8C17.75 4.82436 15.1756 2.25 12 2.25C8.82436 2.25 6.25 4.82436 6.25 8C6.25 10.0232 7.29494 11.8024 8.87458 12.8272C7.73658 13.2624 6.69098 13.9347 5.81282 14.8128C4.17187 16.4538 3.25 18.6794 3.25 21C3.25 21.4142 3.58579 21.75 4 21.75C4.41421 21.75 4.75 21.4142 4.75 21C4.75 19.0772 5.51384 17.2331 6.87348 15.8735C8.23311 14.5138 10.0772 13.75 12 13.75C13.9228 13.75 15.7669 14.5138 17.1265 15.8735C18.4862 17.2331 19.25 19.0772 19.25 21C19.25 21.4142 19.5858 21.75 20 21.75C20.4142 21.75 20.75 21.4142 20.75 21C20.75 18.6794 19.8281 16.4538 18.1872 14.8128C17.309 13.9347 16.2634 13.2624 15.1254 12.8272Z" fill="currentColor" />
</svg>
</div>
<p class="text-neutral-900">
Vous avez déjà un compte ?
<a href="#" class="text-neutral-900 underline hover:no-underline">Connectez-vous</a>
pour retrouver vos informations et gérer vos rendez-vous.
</p>
</div>
<div class="w-6 h-6">
<svg class="text-neutral-900 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>
</div>
</div>
<form @submit.prevent="validateForm" class="space-y-6">
<div>
<label class="block text-neutral-900 mb-2">
Prénom <span class="text-red-500">*</span>
</label>
<input type="text" x-model="formData.firstName" class="w-full px-4 py-3 rounded-lg border border-neutral-200 focus:border-brand-400 focus:ring-1 focus:ring-brand-400" :class="{ 'border-red-500': errors.firstName }">
<p x-show="errors.firstName" x-text="errors.firstName" class="mt-1 text-red-500 text-sm"></p>
</div>
<div>
<label class="block text-neutral-900 mb-2">
Nom <span class="text-red-500">*</span>
</label>
<input type="text" x-model="formData.lastName" class="w-full px-4 py-3 rounded-lg border border-neutral-200 focus:border-brand-400 focus:ring-1 focus:ring-brand-400" :class="{ 'border-red-500': errors.lastName }">
<p x-show="errors.lastName" x-text="errors.lastName" class="mt-1 text-red-500 text-sm"></p>
</div>
<div>
<label class="block text-neutral-900 mb-2">
E-mail <span class="text-red-500">*</span>
</label>
<input type="email" x-model="formData.email" class="w-full px-4 py-3 rounded-lg border border-neutral-200 focus:border-brand-400 focus:ring-1 focus:ring-brand-400" :class="{ 'border-red-500': errors.email }">
<p x-show="errors.email" x-text="errors.email" class="mt-1 text-red-500 text-sm"></p>
</div>
<div>
<label class="block text-neutral-900 mb-2">
Téléphone <span class="text-red-500">*</span>
</label>
<input type="tel" x-model="formData.phone" class="w-full px-4 py-3 rounded-lg border border-neutral-200 focus:border-brand-400 focus:ring-1 focus:ring-brand-400" :class="{ 'border-red-500': errors.phone }">
<p x-show="errors.phone" x-text="errors.phone" class="mt-1 text-red-500 text-sm"></p>
</div>
<div>
<button type="button" @click="showComment = !showComment" class="flex items-center gap-2 text-neutral-900 hover:underline">
<span class="w-6 h-6">+</span>
Ajouter un commentaire (facultatif)
</button>
<div x-show="showComment" x-transition class="mt-4">
<textarea x-model="formData.comment" class="w-full px-4 py-3 rounded-lg border border-neutral-200 focus:border-brand-400 focus:ring-1 focus:ring-brand-400" rows="4"></textarea>
</div>
</div>
<div class="space-y-4">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" x-model="formData.acceptPolicy" class="mt-1 rounded border-neutral-300 text-brand-400 focus:ring-brand-400" :class="{ 'border-red-500': errors.policy }">
<span class="text-sm">
ALAIN AFFLELOU, en tant que responsable de traitement, collecte vos données
personnelles afin de créer votre compte, vous faire parvenir vos informations de
rendez-vous et nos offres commerciales lorsque vous l'acceptez. Pour en savoir plus
sur vos droits et la gestion de vos données personnelles, consultez notre
<a href="#" class="underline hover:no-underline">Politique de confidentialité</a>.
</span>
</label>
<p x-show="errors.policy" x-text="errors.policy" class="text-red-500 text-sm"></p>
</div>
<button type="submit" class="w-full py-4 bg-neutral-500 text-white rounded-lg hover:bg-neutral-600 transition-colors">
Confirmer mon rendez-vous
</button>
</form>
</div>
<script>
function formAppointment() {
return {
formData: {
firstName: '',
lastName: '',
email: '',
phone: '',
comment: '',
acceptPolicy: false
},
showComment: false,
errors: {},
isInitialized: false,
isSubmitting: false, // Nouveau flag pour gérer l'état de soumission
get stepperData() {
return this.$store.locator.stepperData;
},
init() {
// S'il y a déjà des données à l'étape 4, charger les données immédiatement
if (this.stepperData?.currentStep === 4) {
this.loadSavedData();
}
},
// Le reste des méthodes de votre composant restent inchangées
loadSavedData() {
const contactInfo = this.stepperData.data.find(item => item.title === 'INFORMATION DU CONTACT');
if (contactInfo && contactInfo.details) {
this.formData = {
...this.formData,
...contactInfo.details
};
}
},
validateForm() {
this.errors = {};
if (!this.formData.firstName) this.errors.firstName = 'Le prénom est requis';
if (!this.formData.lastName) this.errors.lastName = 'Le nom est requis';
if (!this.formData.email) this.errors.email = 'L\'email est requis';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.formData.email)) {
this.errors.email = 'L\'email n\'est pas valide';
}
if (!this.formData.phone) this.errors.phone = 'Le téléphone est requis';
if (!this.formData.acceptPolicy) this.errors.policy = 'Vous devez accepter la politique de confidentialité';
if (Object.keys(this.errors).length === 0) {
this.submitForm();
}
},
async submitForm() {
if (!this.isInitialized || this.isSubmitting) return;
this.isSubmitting = true;
try {
const appointmentData = {
store: this.stepperData.data.find(item => item.title === 'MAGASIN')?.value,
service: this.stepperData.data.find(item => item.title === 'SUJET DU RENDEZ-VOUS')?.value,
datetime: this.stepperData.data.find(item => item.title === 'DATE ET HEURE DU RENDEZ-VOUS')?.value,
contact: {
firstName: this.formData.firstName,
lastName: this.formData.lastName,
email: this.formData.email,
phone: this.formData.phone,
comment: this.formData.comment,
},
metadata: {
code_mur: this.stepperData.code_mur,
type: this.stepperData.type
}
};
// Simulation d'un délai réseau
await new Promise(resolve => setTimeout(resolve, 1000));
// Choisir aléatoirement entre succès et erreur
const mockApiUrl = Math.random() < 0.7 ?
'/js/json/appointment-success.json' // 70% de chance de succès
:
'/js/json/appointment-error.json'; // 30% de chance d'erreur
const response = await fetch(mockApiUrl);
// Exemple d'appel api
// const response = await fetch('/api/appointments', {
// method: 'POST',
// headers: {'Content-Type': 'application/json'},
// body: JSON.stringify(appointmentData)
// });
const data = await response.json();
if (!data.success) {
throw new Error(data.error.message);
}
// Si succès, mettre à jour le stepperData
this.$store.locator.updateStepperData(
'INFORMATION DU CONTACT',
`${this.formData.firstName} ${this.formData.lastName}`,
null, {
details: {
firstName: this.formData.firstName,
lastName: this.formData.lastName,
email: this.formData.email,
phone: this.formData.phone,
comment: this.formData.comment,
acceptPolicy: this.formData.acceptPolicy,
appointmentDetails: data.data // Stocker les détails du rendez-vous
}
}
);
// Marquer comme complété
this.$store.locator.completeStepperData(data.appointmentId);
// Message de succès
this.$dispatch('show-toast', {
type: 'success',
message: data.message
});
} catch (error) {
console.error('Erreur lors de la soumission:', error);
// Message d'erreur
this.$dispatch('show-toast', {
type: 'error',
message: error.message || 'Une erreur est survenue lors de la prise de rendez-vous. Veuillez réessayer.'
});
} finally {
this.isSubmitting = false;
}
}
}
}
</script>
</div>
</div>
</template>
</div>
</div>
<div x-show="stepperData.completed">
<div x-data="appointmentConfirmation()" x-init="init()" class="w-full max-w-3xl mx-auto space-y-12">
<div class="bg-green-50 rounded-2xl p-8 space-y-8">
<div class="flex items-center gap-4">
<div class="w-8 h-8 text-green-600">
<svg class="text-green-600 w-8 h-8 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="M2.75 12C2.75 6.89137 6.89137 2.75 12 2.75C17.1086 2.75 21.25 6.89137 21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12ZM12 1.25C6.06294 1.25 1.25 6.06294 1.25 12C1.25 17.9371 6.06294 22.75 12 22.75C17.9371 22.75 22.75 17.9371 22.75 12C22.75 6.06294 17.9371 1.25 12 1.25ZM16.5303 9.86366C16.8232 9.57077 16.8232 9.0959 16.5303 8.803C16.2374 8.51011 15.7626 8.51011 15.4697 8.803L10.6667 13.606L8.53033 11.4697C8.23744 11.1768 7.76256 11.1768 7.46967 11.4697C7.17678 11.7626 7.17678 12.2374 7.46967 12.5303L10.1363 15.197C10.4292 15.4899 10.9041 15.4899 11.197 15.197L16.5303 9.86366Z" fill="currentColor" />
</svg>
</div>
<h1 class="text-3xl font-normal">Rendez-vous confirmé</h1>
</div>
<p class="text-neutral-700">
Vous trouverez le récapitulatif de votre rendez-vous ci-dessous. Nous vous l'envoyons également par e-mail (vérifiez vos spams) et par SMS si vous avez activé cette option.
</p>
<div class="bg-white rounded-xl p-6 space-y-4">
<template x-for="item in stepperData.data" :key="item.title">
<div>
<p class="text-neutral-600" x-text="item.title + ' : '"></p>
<p class="font-medium" x-text="item.value"></p>
<template x-if="item.details">
<div class="mt-2 text-sm text-neutral-500">
<template x-if="item.title === 'INFORMATION DU CONTACT'">
<div>
<p x-text="'Email : ' + item.details.email"></p>
<p x-text="'Téléphone : ' + item.details.phone"></p>
<template x-if="item.details.comment">
<p x-text="'Commentaire : ' + item.details.comment"></p>
</template>
</div>
</template>
</div>
</template>
</div>
</template>
</div>
<div class="space-y-4">
<p class="text-neutral-700">Ne manquez pas votre rendez-vous en l'ajoutant à votre agenda en ligne :</p>
<div class="flex gap-4 flex-wrap">
<a href="#" target="_blank" rel="noopenner noreferer" :href="getGoogleCalendarUrl()" class=" btn btn-dark-subtle ">
Ajouter à Google Agenda
</a>
<a href="#" target="_blank" rel="noopenner noreferer" :href="getOutlookCalendarUrl()" class=" btn btn-dark-subtle ">
Ajouter à l'agenda Outlook
</a>
</div>
</div>
</div>
<div class="space-y-6">
<h2 class="text-3xl font-normal">Votre pré-bilan pour gagner du temps</h2>
<p class="text-neutral-700">
Avant votre rendez-vous, remplissez ce questionnaire qui aidera votre opticien à vous recommander les solutions les plus adaptées à votre quotidien.
</p>
<a href="#" target="_blank" rel="noopenner noreferer" class=" btn btn-dark-ghost ">
Répondre au questionnaire
</a>
</div>
</div>
<script>
function appointmentConfirmation() {
return {
get stepperData() {
return this.$store.locator.stepperData;
},
init() {
this.$watch('stepperData.completed', (value) => {
if (value) {
console.log('Composant confirmation visible, initialisation...');
}
});
},
getCalendarData() {
if (!this.stepperData?.data) return null;
const location = this.stepperData.data.find(item => item.title === 'MAGASIN')?.value || '';
const subject = this.stepperData.data.find(item => item.title === 'SUJET DU RENDEZ-VOUS')?.value || '';
const datetime = this.stepperData.data.find(item => item.title === 'DATE ET HEURE DU RENDEZ-VOUS')?.value || '';
const contact = this.stepperData.data.find(item => item.title === 'INFORMATION DU CONTACT');
return {
location,
subject,
datetime,
contact: contact?.value || '',
email: contact?.details?.email || '',
phone: contact?.details?.phone || ''
};
},
getGoogleCalendarUrl() {
const data = this.getCalendarData();
if (!data) return '#';
const params = new URLSearchParams({
action: 'TEMPLATE',
text: `${data.subject} - Afflelou`,
details: `Rendez-vous chez Afflelou\n${data.contact}\nEmail: ${data.email}\nTél: ${data.phone}`,
location: data.location,
dates: this.formatDateForCalendar(data.datetime)
});
return `https://calendar.google.com/calendar/render?${params.toString()}`;
},
getOutlookCalendarUrl() {
const data = this.getCalendarData();
if (!data) return '#';
const params = new URLSearchParams({
path: '/calendar/action/compose',
rru: 'addevent',
subject: `${data.subject} - Afflelou`,
body: `Rendez-vous chez Afflelou\n${data.contact}\nEmail: ${data.email}\nTél: ${data.phone}`,
location: data.location,
startdt: this.formatDateForCalendar(data.datetime)
});
return `https://outlook.live.com/calendar/0/${params.toString()}`;
},
formatDateForCalendar(datetimeStr) {
// Exemple de format d'entrée : "Lundi 15 janvier 2024 à 14:30"
const dateRegex = /(\d{1,2})\s+(\w+)\s+(\d{4})\s+à\s+(\d{1,2}):(\d{2})/;
const matches = datetimeStr.match(dateRegex);
if (!matches) return '';
const [, day, month, year, hour, minute] = matches;
const monthMap = {
'janvier': '01',
'février': '02',
'mars': '03',
'avril': '04',
'mai': '05',
'juin': '06',
'juillet': '07',
'août': '08',
'septembre': '09',
'octobre': '10',
'novembre': '11',
'décembre': '12'
};
const monthNum = monthMap[month.toLowerCase()];
const dateObj = new Date(`${year}-${monthNum}-${day}T${hour}:${minute}:00`);
return dateObj.toISOString().replace(/[:-]/g, '').split('.')[0] + 'Z';
}
}
}
</script>
</div>
</div>
</template>
</div>
<script>
function stepInit() {
return {
isInitialized: false,
init() {
this.isInitialized = true;
},
get stepperData() {
return this.$store.locator.stepperData || this.$store.locator.defaultStepperState;
},
nextStep() {
if (!this.isInitialized) return;
if (this.stepperData.currentStep < 4) {
this.$store.locator.updateStepperSteps(this.stepperData.currentStep + 1);
} else {
this.$store.locator.stepperData.completed = true;
}
},
previousStep() {
if (!this.isInitialized) return;
if (this.stepperData.currentStep > 2) {
this.$store.locator.updateStepperSteps(this.stepperData.currentStep - 1);
}
},
goToStep(targetStep) {
if (!this.isInitialized) return;
this.$store.locator.goToStepperStep(targetStep);
},
updateStepData(title, value, targetStep) {
if (!this.isInitialized) return;
this.$store.locator.updateStepperData(title, value, targetStep);
}
}
}
</script>
</div>
</div>
</div>
<div x-data class="fixed right-0 z-40 inset-y-0 max-w-full">
<div x-cloak x-transition.opacity x-show="$store.asideBlocs.asides.find(aside => aside.name === 'storeLocatorFilter')?.open" class="fixed inset-0 w-full h-full bg-dark-40 backdrop-blur-xl"></div>
<div x-cloak x-transition:enter="transition ease-out duration-300" x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0" x-transition:leave="transition ease-in duration-300" x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full" x-show="$store.asideBlocs.asides.find(aside => aside.name === 'storeLocatorFilter')?.open" class="h-full relative bg-light-white overflow-hidden w-screen md:max-w-screen-sm flex flex-col" @click.outside="$store.asideBlocs.closeAside('storeLocatorFilter')">
<div class="p-4 md:px-10 md:py-6 font-medium text-2xl flex justify-between items-center">
Filtrer
<button type="button" @click="$store.asideBlocs.closeAside('storeLocatorFilter')" 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="M8.99469 7.9047C8.70179 7.6118 8.22692 7.6118 7.93403 7.9047C7.64113 8.19759 7.64113 8.67246 7.93403 8.96536L10.9392 11.9706L7.93403 14.9758C7.64114 15.2687 7.64114 15.7435 7.93403 16.0364C8.22693 16.3293 8.7018 16.3293 8.99469 16.0364L11.9999 13.0312L15.0051 16.0364C15.298 16.3293 15.7729 16.3293 16.0658 16.0364C16.3586 15.7435 16.3586 15.2687 16.0658 14.9758L13.0606 11.9706L16.0658 8.96536C16.3587 8.67246 16.3587 8.19759 16.0658 7.9047C15.7729 7.6118 15.298 7.6118 15.0051 7.9047L11.9999 10.9099L8.99469 7.9047Z" fill="currentColor" />
</svg>
</button>
</div>
<div class="px-4 md:px-10 overflow-auto flex-1 border-t border-b border-neutral-200">
<div x-cloak class="text-neutral-800">
<div class="flex flex-col gap-2 flex-wrap">
<p class="font-medium py-4 md:pt-6 md:pb-3 text-base md:text-xl">Spécialités du magasin</p>
<div x-data="storeFilters('types')" class="flex flex-col gap-4 md:gap-6 w-full">
<div x-show="items.length > 0" class="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6 pb-4 md:pb-3">
<template x-for="item in filteredItems" :key="item.label">
<label class="selection-control-label selection-control-size-md">
<input :checked="$store.locator.currentSelectedFilters[category].includes(item.label)" @click="$store.locator.updateFilter(category, item.label)" type="checkbox">
<span x-text="item.label + ' (' + item.count + ')'"></span>
</label>
</template>
</div>
</div>
<script>
function storeFilters(category) {
return {
search: '',
category: category,
items: [],
init() {
// Observer les changements dans les filtres du type actuel
this.$watch('$store.locator.currentFilterList', (value) => {
if (value && value[this.category]) {
this.items = value[this.category];
}
}, {
deep: true
});
// Initialisation immédiate si les données sont déjà disponibles
if (this.$store.locator.currentFilterList?.[this.category]) {
this.items = this.$store.locator.currentFilterList[this.category];
}
},
}
}
</script>
<div x-data="{ expanded: false }" class="border-b border-neutral-200 md:pb-3 text-neutral-800 text-neutral-800">
<h2 class="font-medium py-4 md:pt-6 md:pb-3 text-base md:text-xl">
<button type="button" @click="expanded = !expanded " class="flex justify-between items-center w-full ">
<span class="flex flex-wrap gap-3">
Mutuelles
</span>
<span :class="expanded ? 'rotate-180' : ''" class="transform transition-transform duration-300">
<svg class=" shrink-0" width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.5303 14.5303C12.2374 14.8232 11.7626 14.8232 11.4697 14.5303L7.46967 10.5303C7.17678 10.2374 7.17678 9.76256 7.46967 9.46967C7.76256 9.17678 8.23744 9.17678 8.53033 9.46967L12 12.9393L15.4697 9.46967C15.7626 9.17678 16.2374 9.17678 16.5303 9.46967C16.8232 9.76256 16.8232 10.2374 16.5303 10.5303L12.5303 14.5303Z" fill="currentColor" />
</svg>
</span>
</button>
</h2>
<div x-cloak x-show="expanded" x-collapse class=" ">
<div x-data="storeFilters('mutuals')" class="flex flex-col gap-4 md:gap-6 w-full">
<label class="relative">
<input x-model="search" type="text" placeholder="Rechercher" class="w-full leading-icon">
<svg class=" shrink-0" width="16" height="16" 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>
</label>
<div x-show="items.length > 0" class="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6 pb-4 md:pb-3">
<template x-for="item in filteredItems" :key="item.label">
<label class="selection-control-label selection-control-size-md">
<input :checked="$store.locator.currentSelectedFilters[category].includes(item.label)" @click="$store.locator.updateFilter(category, item.label)" type="checkbox">
<span x-text="item.label + ' (' + item.count + ')'"></span>
</label>
</template>
</div>
</div>
<script>
function storeFilters(category) {
return {
search: '',
category: category,
items: [],
init() {
// Observer les changements dans les filtres du type actuel
this.$watch('$store.locator.currentFilterList', (value) => {
if (value && value[this.category]) {
this.items = value[this.category];
}
}, {
deep: true
});
// Initialisation immédiate si les données sont déjà disponibles
if (this.$store.locator.currentFilterList?.[this.category]) {
this.items = this.$store.locator.currentFilterList[this.category];
}
},
get filteredItems() {
if (this.search === '') {
return this.items;
}
return this.items.filter(item =>
item.toLowerCase()
.replace(/ /g, '')
.includes(this.search.toLowerCase().replace(/ /g, ''))
);
}
}
}
</script>
</div>
</div>
<div x-data="{ expanded: false }" class="border-b border-neutral-200 md:pb-3 text-neutral-800 text-neutral-800">
<h2 class="font-medium py-4 md:pt-6 md:pb-3 text-base md:text-xl">
<button type="button" @click="expanded = !expanded " class="flex justify-between items-center w-full ">
<span class="flex flex-wrap gap-3">
Marques
</span>
<span :class="expanded ? 'rotate-180' : ''" class="transform transition-transform duration-300">
<svg class=" shrink-0" width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.5303 14.5303C12.2374 14.8232 11.7626 14.8232 11.4697 14.5303L7.46967 10.5303C7.17678 10.2374 7.17678 9.76256 7.46967 9.46967C7.76256 9.17678 8.23744 9.17678 8.53033 9.46967L12 12.9393L15.4697 9.46967C15.7626 9.17678 16.2374 9.17678 16.5303 9.46967C16.8232 9.76256 16.8232 10.2374 16.5303 10.5303L12.5303 14.5303Z" fill="currentColor" />
</svg>
</span>
</button>
</h2>
<div x-cloak x-show="expanded" x-collapse class=" ">
<div x-data="storeFilters('brands')" class="flex flex-col gap-4 md:gap-6 w-full">
<label class="relative">
<input x-model="search" type="text" placeholder="Rechercher" class="w-full leading-icon">
<svg class=" shrink-0" width="16" height="16" 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>
</label>
<div x-show="items.length > 0" class="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6 pb-4 md:pb-3">
<template x-for="item in filteredItems" :key="item.label">
<label class="selection-control-label selection-control-size-md">
<input :checked="$store.locator.currentSelectedFilters[category].includes(item.label)" @click="$store.locator.updateFilter(category, item.label)" type="checkbox">
<span x-text="item.label + ' (' + item.count + ')'"></span>
</label>
</template>
</div>
</div>
<script>
function storeFilters(category) {
return {
search: '',
category: category,
items: [],
init() {
// Observer les changements dans les filtres du type actuel
this.$watch('$store.locator.currentFilterList', (value) => {
if (value && value[this.category]) {
this.items = value[this.category];
}
}, {
deep: true
});
// Initialisation immédiate si les données sont déjà disponibles
if (this.$store.locator.currentFilterList?.[this.category]) {
this.items = this.$store.locator.currentFilterList[this.category];
}
},
get filteredItems() {
if (this.search === '') {
return this.items;
}
return this.items.filter(item =>
item.toLowerCase()
.replace(/ /g, '')
.includes(this.search.toLowerCase().replace(/ /g, ''))
);
}
}
}
</script>
</div>
</div>
</div>
</div>
</div>
<div class="p-2 md:px-10 md:py-6">
<div class="flex items-center justify-between">
<a href="#" x-data="{ buttonLabel() { return `Voir les ${$store.locator.countStore} magasins` } }" @click="$store.locator.applyFilters(); $store.asideBlocs.closeAside('storeLocatorFilter')" :class="$store.locator.isAudio ? 'btn-audio' : 'btn-dark'" x-text="buttonLabel()" class="w-full btn btn-audio btn-size-lg">
Voir les magasins
</a>
</div>
</div>
</div>
</div>
<div class="relative overflow-hidden">
<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 x-data="seoContent" class="container font-medium flex flex-col gap-5 md:gap-10 mx-auto my-16 md:my-32 px-4">
<h1 class="text-center text-2xl mb-8" :class="$store.locator.isAudio ? 'text-audio-700' : ''" x-text="title"></h1>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<template x-for="(item, index) in currentData?.listSEO" :key="index">
<div :class="{ 'md:col-span-2': item.col === 2, 'md:col-span-3': item.col === 3 }">
<h2 class="text-xl pb-4 mb-4 border-b border-neutral-200" x-text="item.title"></h2>
<ul :class="{
'space-y-2': true,
'md:space-y-0 md:grid md:grid-cols-2 md:gap-2': item.col === 2,
'md:space-y-0 md:grid md:grid-cols-3 md:gap-2': item.col === 3
}">
<template x-for="(location, locationIndex) in item.items" :key="locationIndex">
<li>
<a href="#" class="text-neutral-800" x-text="location"></a>
</li>
</template>
</ul>
</div>
</template>
</div>
<div x-show="currentData?.departementsSEO">
<h2 class="text-xl pb-4 mb-4 border-b border-neutral-200" x-text="currentData.departementsSEO.title"></h2>
<div class="grid grid-rows-[repeat(25,minmax(0,1fr))] grid-flow-col gap-3 auto-cols-[minmax(0,1fr)]">
<template x-for="(departement, index) in currentData.departementsSEO.departements" :key="index">
<div>
<a href="#" class="text-neutral-800" x-text="`${departement.code} - ${departement.name}`"></a>
</div>
</template>
</div>
</div>
<div x-show="currentData?.textSEO">
<template x-for="(section, index) in currentData.textSEO.sections" :key="index">
<div class="text-sm text-neutral-600 mb-8">
<h2 class="mb-4" x-text="section.title"></h2>
<div class="font-normal">
<p x-text="section.content"></p>
</div>
</div>
</template>
</div>
</div>
<script>
function seoContent() {
return {
title: '',
seoData: {
"opticiens": {
"listSEO": [{
"title": "France métropolitaine",
"col": 2,
"items": ["Opticiens à Paris", "Opticiens à Marseille", "Opticiens à Bordeaux", "Opticiens à Nice", "Opticiens à Strasbourg", "Opticiens à Lyon", "Opticiens à Toulouse", "Opticiens à Lille", "Opticiens à Nantes", "Opticiens à Rennes"]
}, {
"title": "France d'outre-mer",
"col": 1,
"items": ["Opticiens à la Guadeloupe", "Opticiens en Martinique", "Opticiens en Guyane", "Opticiens à La Réunion", "Opticiens à Mayotte", "Opticiens en Nouvelle Calédonie", "Opticiens en Polynésie française"]
}, {
"title": "Monde",
"col": 1,
"items": ["Opticiens en Espagne", "Opticiens en Belgique", "Opticiens en Suisse", "Opticiens au Maroc", "Opticiens au Portugal", "Opticiens en Colombie", "Opticiens dans le reste du monde"]
}],
"departementsSEO": {
"title": "Départements",
"departements": [{
"code": "01",
"name": "Ain"
}, {
"code": "02",
"name": "Aisne"
}, {
"code": "03",
"name": "Allier"
}, {
"code": "04",
"name": "Alpes-de-haute-provence"
}, {
"code": "05",
"name": "Hautes-alpes"
}, {
"code": "06",
"name": "Alpes-maritimes"
}, {
"code": "07",
"name": "Ardèche"
}, {
"code": "08",
"name": "Ardennes"
}, {
"code": "09",
"name": "Ariège"
}, {
"code": "10",
"name": "Aube"
}, {
"code": "11",
"name": "Aude"
}, {
"code": "12",
"name": "Aveyron"
}, {
"code": "13",
"name": "Bouches-du-rhône"
}, {
"code": "14",
"name": "Calvados"
}, {
"code": "15",
"name": "Cantal"
}, {
"code": "16",
"name": "Charente"
}, {
"code": "17",
"name": "Charente-maritime"
}, {
"code": "18",
"name": "Cher"
}, {
"code": "19",
"name": "Corrèze"
}, {
"code": "21",
"name": "Côte-d'or"
}, {
"code": "22",
"name": "Côtes d'armor"
}, {
"code": "23",
"name": "Creuse"
}, {
"code": "24",
"name": "Dordogne"
}, {
"code": "25",
"name": "Doubs"
}, {
"code": "26",
"name": "Drôme"
}, {
"code": "27",
"name": "Eure"
}, {
"code": "28",
"name": "Eure-et-loir"
}, {
"code": "29",
"name": "Finistère"
}, {
"code": "2A",
"name": "Corse-du-sud"
}, {
"code": "2B",
"name": "Haute-corse"
}, {
"code": "30",
"name": "Gard"
}, {
"code": "31",
"name": "Haute-garonne"
}, {
"code": "32",
"name": "Gers"
}, {
"code": "33",
"name": "Gironde"
}, {
"code": "34",
"name": "Hérault"
}, {
"code": "35",
"name": "Ille-et-vilaine"
}, {
"code": "36",
"name": "Indre"
}, {
"code": "37",
"name": "Indre-et-loire"
}, {
"code": "38",
"name": "Isère"
}, {
"code": "39",
"name": "Jura"
}, {
"code": "40",
"name": "Landes"
}, {
"code": "41",
"name": "Loir-et-cher"
}, {
"code": "42",
"name": "Loire"
}, {
"code": "43",
"name": "Haute-loire"
}, {
"code": "44",
"name": "Loire-atlantique"
}, {
"code": "45",
"name": "Loiret"
}, {
"code": "46",
"name": "Lot"
}, {
"code": "47",
"name": "Lot-et-garonne"
}, {
"code": "49",
"name": "Maine-et-loire"
}, {
"code": "50",
"name": "Manche"
}, {
"code": "51",
"name": "Marne"
}, {
"code": "52",
"name": "Haute-marne"
}, {
"code": "53",
"name": "Mayenne"
}, {
"code": "54",
"name": "Meurthe-et-moselle"
}, {
"code": "55",
"name": "Meuse"
}, {
"code": "56",
"name": "Morbihan"
}, {
"code": "57",
"name": "Moselle"
}, {
"code": "58",
"name": "Nièvre"
}, {
"code": "59",
"name": "Nord"
}, {
"code": "60",
"name": "Oise"
}, {
"code": "61",
"name": "Orne"
}, {
"code": "62",
"name": "Pas-de-calais"
}, {
"code": "63",
"name": "Puy-de-dôme"
}, {
"code": "64",
"name": "Pyrénées-atlantiques"
}, {
"code": "65",
"name": "Hautes-pyrénées"
}, {
"code": "66",
"name": "Pyrénées-orientales"
}, {
"code": "67",
"name": "Bas-rhin"
}, {
"code": "68",
"name": "Haut-rhin"
}, {
"code": "69",
"name": "Rhône"
}, {
"code": "70",
"name": "Haute-saône"
}, {
"code": "71",
"name": "Saône-et-loire"
}, {
"code": "72",
"name": "Sarthe"
}, {
"code": "73",
"name": "Savoie"
}, {
"code": "74",
"name": "Haute-savoie"
}, {
"code": "75",
"name": "Paris"
}, {
"code": "76",
"name": "Seine-maritime"
}, {
"code": "77",
"name": "Seine-et-marne"
}, {
"code": "78",
"name": "Yvelines"
}, {
"code": "79",
"name": "Deux-sèvres"
}, {
"code": "80",
"name": "Somme"
}, {
"code": "81",
"name": "Tarn"
}, {
"code": "82",
"name": "Tarn-et-garonne"
}, {
"code": "83",
"name": "Var"
}, {
"code": "84",
"name": "Vaucluse"
}, {
"code": "85",
"name": "Vendée"
}, {
"code": "86",
"name": "Vienne"
}, {
"code": "87",
"name": "Haute-vienne"
}, {
"code": "88",
"name": "Vosges"
}, {
"code": "89",
"name": "Yonne"
}, {
"code": "90",
"name": "Terr. de belfort"
}, {
"code": "91",
"name": "Essonne"
}, {
"code": "93",
"name": "Seine-st-denis"
}, {
"code": "94",
"name": "Val-de-marne"
}, {
"code": "95",
"name": "Val-d'oise"
}]
},
"textSEO": {
"sections": [{
"title": "Opticiens ALAIN AFFLELOU",
"content": "Lunetier depuis plus de 45 ans, nous vous proposons l'expertise ALAIN AFFLELOU dans nos magasins et sur notre site Internet afflelou.com. Nous sommes présents dans plus de 15 pays dans le monde entier. En France, vous retrouvez nos opticiens partout en France métropolitaine, mais aussi dans les DOM-TOM comme en Guadeloupe, en Martinique, à La Réunion ou encore en Guyane. Retrouvez facilement les magasins les plus proches de chez vous grâce à notre Store Locator. Entrez une ville ou un code postal dans le moteur de recherche, ou utilisez directement le bouton de géolocalisation. Venez ainsi rencontrer nos opticiens pour profiter des services ALAIN AFFLELOU !"
}, {
"title": "Nos opticiens au cœur des tendances",
"content": "Les opticiens ALAIN AFFLELOU se tiennent au courant des nouvelles tendances de la mode en matière d'optique. De quoi vous aider dans votre choix de nouvelles lunettes de vue ou de soleil ! Facile de se perdre parmi notre large gamme de lunettes de vue et de soleil. Il y a des couleurs, des formes et des matières de montures pour tous les goûts. Profitez des conseils de nos opticiens pour trouver les plus adaptées à la morphologie de votre visage, votre style et votre mode de vie ! Retrouvez des lunettes rondes au métal de couleur dorée ou noir, pour un style à la fois chic et tendance. Elles donner du peps à vos tenues et un look plus rétro, des montures rectangulaires ou œil de chat de couleur rouge sont parfaites. Vous ne savez pas quelle monture irait le mieux avec la forme de votre visage ? Quelles lunettes de soleil choisir lorsque vous faites des sports extrêmes ? Quelles lentilles de contact de couleur porter occasionnellement ? Nos opticiens sont là pour vous donner les meilleurs conseils pour votre santé visuelle."
}, {
"title": "Retrouvez les meilleurs conseils de nos opticiens en matière d'optique",
"content": "Nos opticiens mettent à votre disposition leur expertise dans le domaine de la santé visuelle. Lentilles de contact, lunettes de vue ou lunettes de soleil : ils sauront répondre à toutes vos questions en la matière. Ils peuvent vous conseiller sur les lentilles de contact journalières ou mensuelles, qu'elles soient souples ou rigides. Découvrez celles qui sont les plus adaptées à votre mode de vie. Vous devez porter des lunettes de vue équipées de verres progressifs ? Saviez-vous que toutes les montures ne sont pas adaptées à ce type de verres de correction ? Nos opticiens ALAIN AFFLELOU sont là pour vous guider tout au long de votre choix de lunettes de vue. Les lunettes de soleil peuvent être adaptées à votre vision en magasin pro nos équipes d'experts. Découvrez aussi une large gamme de produits d'entretien pour les dispositifs visuels en vente dans nos magasins."
}, {
"title": "Profitez des services ALAIN AFFLELOU directement en magasin",
"content": "Chez ALAIN AFFLELOU, votre satisfaction est notre priorité. C'est pour cela que de nombreux services vous attendent en magasin. Nos opticiens s'occupent de tout : de l'entretien de vos lunettes de vue et de soleil, de l'ajustage gratuit de vos lunettes et de la prise de mesure digitale pour un confort optimal ! En magasin, les opticiens ALAIN AFFLELOU procèdent à des vérifications de la vue (avec ordonnance) gratuitement… Grâce à votre ordonnance, nos opticiens peuvent vérifier votre vue si vous avez besoin de renouveler vos lunettes ou vos lentilles de contact. Attention cependant, car cela ne remplace pas une consultation avec un ophtalmologue. Nos opticiens sont aussi là pour apprendre les bons gestes aux enfants... et aux plus grands. C'est le moment de faire le plein de conseils pour l'entretien de vos lunettes et de vos lentilles de contact. Si vous portez des lentilles de contact mensuelles, il est important de bien les entretenir avec des solutions de nettoyage adaptées."
}, {
"title": "Venez rencontrer les opticiens proches de chez vous",
"content": "Vous pouvez prendre rendez-vous avec un opticien sur afflelou.com. Cela vous permet de ne pas attendre notamment pour l'entretien de vos lunettes ou pour obtenir un conseil. Pour cela, rendez-vous en ligne sur notre site Internet et choisissez le magasin et l'horaire qui vous conviennent le mieux. Faites votre choix parmi un grand nombre de magasins partout en France. Vous recevrez ensuite une confirmation de votre rendez-vous dans votre boîte mail. Vous avez un empêchement de dernière minute ? Aucun souci, vous pouvez annuler ou déplacer votre rendez-vous directement depuis votre compte. Pratique ! C'est un véritable gain de temps. Et, pour préparer au mieux votre venue en magasin, chacun d'entre eux dispose d'une fiche où vous pouvez retrouver différentes informations utiles. Sur chaque fiche magasin, retrouvez ses horaires d'ouverture, ses coordonnées (comme son adresse et son numéro de téléphone) ainsi que les aménagements mis à disposition pour son accessibilité aux personnes à mobilité réduite."
}]
}
},
"acousticiens": {
"listSEO": [{
"title": "France métropolitaine",
"col": 3,
"items": ["Acousticiens à Paris", "Acousticiens à Lyon", "Acousticiens à Marseille", "Acousticiens à Toulouse", "Acousticiens à Bordeaux", "Acousticiens à Lille", "Acousticiens à Nice", "Acousticiens à Nantes", "Acousticiens à Strasbourg", "Acousticiens à Rennes"]
}, {
"title": "France d'outre-mer",
"col": 1,
"items": ["Acousticiens à la Guadeloupe", "Acousticiens en Martinique", "Acousticiens en Nouvelle Calédonie", "Acousticiens en Polynésie française"]
}],
"departementsSEO": {
"title": "Départements",
"departements": [{
"code": "01",
"name": "Ain"
}, {
"code": "02",
"name": "Aisne"
}, {
"code": "03",
"name": "Allier"
}, {
"code": "04",
"name": "Alpes-de-haute-provence"
}, {
"code": "05",
"name": "Hautes-alpes"
}, {
"code": "06",
"name": "Alpes-maritimes"
}, {
"code": "07",
"name": "Ardèche"
}, {
"code": "08",
"name": "Ardennes"
}, {
"code": "09",
"name": "Ariège"
}, {
"code": "10",
"name": "Aube"
}, {
"code": "11",
"name": "Aude"
}, {
"code": "12",
"name": "Aveyron"
}, {
"code": "13",
"name": "Bouches-du-rhône"
}, {
"code": "14",
"name": "Calvados"
}, {
"code": "15",
"name": "Cantal"
}, {
"code": "16",
"name": "Charente"
}, {
"code": "17",
"name": "Charente-maritime"
}, {
"code": "18",
"name": "Cher"
}, {
"code": "19",
"name": "Corrèze"
}, {
"code": "21",
"name": "Côte-d'or"
}, {
"code": "22",
"name": "Côtes d'armor"
}, {
"code": "23",
"name": "Creuse"
}, {
"code": "24",
"name": "Dordogne"
}, {
"code": "25",
"name": "Doubs"
}, {
"code": "26",
"name": "Drôme"
}, {
"code": "27",
"name": "Eure"
}, {
"code": "28",
"name": "Eure-et-loir"
}, {
"code": "29",
"name": "Finistère"
}, {
"code": "2A",
"name": "Corse-du-sud"
}, {
"code": "2B",
"name": "Haute-corse"
}, {
"code": "30",
"name": "Gard"
}, {
"code": "31",
"name": "Haute-garonne"
}, {
"code": "32",
"name": "Gers"
}, {
"code": "33",
"name": "Gironde"
}, {
"code": "34",
"name": "Hérault"
}, {
"code": "35",
"name": "Ille-et-vilaine"
}, {
"code": "36",
"name": "Indre"
}, {
"code": "37",
"name": "Indre-et-loire"
}, {
"code": "38",
"name": "Isère"
}, {
"code": "39",
"name": "Jura"
}, {
"code": "40",
"name": "Landes"
}, {
"code": "41",
"name": "Loir-et-cher"
}, {
"code": "42",
"name": "Loire"
}, {
"code": "43",
"name": "Haute-loire"
}, {
"code": "44",
"name": "Loire-atlantique"
}, {
"code": "45",
"name": "Loiret"
}, {
"code": "46",
"name": "Lot"
}, {
"code": "47",
"name": "Lot-et-garonne"
}, {
"code": "49",
"name": "Maine-et-loire"
}, {
"code": "50",
"name": "Manche"
}, {
"code": "51",
"name": "Marne"
}, {
"code": "52",
"name": "Haute-marne"
}, {
"code": "53",
"name": "Mayenne"
}, {
"code": "54",
"name": "Meurthe-et-moselle"
}, {
"code": "55",
"name": "Meuse"
}, {
"code": "56",
"name": "Morbihan"
}, {
"code": "57",
"name": "Moselle"
}, {
"code": "58",
"name": "Nièvre"
}, {
"code": "59",
"name": "Nord"
}, {
"code": "60",
"name": "Oise"
}, {
"code": "61",
"name": "Orne"
}, {
"code": "62",
"name": "Pas-de-calais"
}, {
"code": "63",
"name": "Puy-de-dôme"
}, {
"code": "64",
"name": "Pyrénées-atlantiques"
}, {
"code": "65",
"name": "Hautes-pyrénées"
}, {
"code": "66",
"name": "Pyrénées-orientales"
}, {
"code": "67",
"name": "Bas-rhin"
}, {
"code": "68",
"name": "Haut-rhin"
}, {
"code": "69",
"name": "Rhône"
}, {
"code": "70",
"name": "Haute-saône"
}, {
"code": "71",
"name": "Saône-et-loire"
}, {
"code": "72",
"name": "Sarthe"
}, {
"code": "73",
"name": "Savoie"
}, {
"code": "74",
"name": "Haute-savoie"
}, {
"code": "75",
"name": "Paris"
}, {
"code": "76",
"name": "Seine-maritime"
}, {
"code": "77",
"name": "Seine-et-marne"
}, {
"code": "78",
"name": "Yvelines"
}, {
"code": "79",
"name": "Deux-sèvres"
}, {
"code": "80",
"name": "Somme"
}, {
"code": "81",
"name": "Tarn"
}, {
"code": "82",
"name": "Tarn-et-garonne"
}, {
"code": "83",
"name": "Var"
}, {
"code": "84",
"name": "Vaucluse"
}, {
"code": "85",
"name": "Vendée"
}, {
"code": "86",
"name": "Vienne"
}, {
"code": "87",
"name": "Haute-vienne"
}, {
"code": "88",
"name": "Vosges"
}, {
"code": "89",
"name": "Yonne"
}, {
"code": "90",
"name": "Terr. de belfort"
}, {
"code": "91",
"name": "Essonne"
}, {
"code": "93",
"name": "Seine-st-denis"
}, {
"code": "94",
"name": "Val-de-marne"
}, {
"code": "95",
"name": "Val-d'oise"
}]
},
"textSEO": {
"sections": [{
"title": "Acousticiens ALAIN AFFLELOU",
"content": "Speed monday explore three ditching calculator resources. Stands take be stakeholder hours pretend baseline working conversation. Land while done asserts launch next lean individual would. Like quick commitment I a managing pollination awareness meeting. Are field power loss anyway incompetent. Marketing hear supervisor rundown team event hours base any read. Day closer roll minimize manage competitors or closest place files. Please old goto money solutionize 30,000ft nobody back busy. When procrastinating bells you hammer player-coach good strategy. Product focus reinvent land live back stands eat best moments."
}, {
"title": "Retrouvez les meilleurs conseils de nos acousticiens en matière d’audition",
"content": "Building member bake space could attached marginalised by shoot. Recap launch data invested first-order backwards long. Food hard knowledge wiggle closer moments. Scope clean without downloaded asserts recap wheel crack team attached. Bells eow three should got hours harvest scraps cadence switch. Game giant highlights wheel what's dangerous low-hanging. Group ask hiring speed before. Great event idea creep chime meat. First-order algorithm event able devil group nor manage minimize. Rundown can cob feature illustration. Box ground regroup ui anyway items optimize quick-win 4-blocker harvest. Break first-order engagement dangerous is client."
}, {
"title": "Profitez des services ALAIN AFFLELOU directement en magasin",
"content": "Out masking pants let that turn finish. With deploy focus every follow high-level make eod email driving. Calculator ladder so got move economy net lean hit when. Team last level both speed believe helicopter deliverables ourselves. Savvy developing hours jumping territories streamline who's brainstorming. Masking individual helicopter alarming sop ask baked race effects. Job synergize pin social people. Eye prioritize scope leverage usabiltiy. Centric blue comms ocean this slipstream up cta plan teeth. Involved data or recap buy-in baseline. To protocol don't synchronise standup. But half say cross invested optimal scraps. Pulling pretend field we've this flesh offline. Options barn boys club emails principles native t-shaped kimono. Three go this sky closing thought."
}, {
"title": "Venez rencontrer les acousticiens proches de chez vous",
"content": "Users important calculator lean before ideal. Today stand this feelers contribution intersection intersection guys don't. On we conversation good data place finish do recap effects. Decisions performance when heads-up vendor ping tent walk going brainstorming. If wiggle dangerous businesses base you buy-in. Resources moments dangerous closing protocol overflow could horse. Moments long closest reference it let's hiring dunder eye. Version door any cc event."
}]
}
}
},
currentData: null,
init() {
// Initialisation immédiate avec la valeur actuelle
this.updateContent(this.$store.locator.isAudio);
// Puis on met en place le watcher
this.$watch('$store.locator.isAudio', value => {
this.updateContent(value);
});
},
updateContent(isAudio) {
if (isAudio) {
this.title = "Trouvez votre acousticien ALAIN AFFLELOU";
this.currentData = this.seoData.acousticiens;
} else {
this.title = "Trouvez votre opticien ALAIN AFFLELOU";
this.currentData = this.seoData.opticiens;
}
}
}
}
</script>
</div>
{% block page_header %}
{% render "@storelocator-appointment" %}
{% render "@storelocator-filter" %}
{% endblock %}
{% block page_content %}
<div class="relative overflow-hidden">
{% render "@store-locator" %}
{% render "@store-locator-seo-list" %}
</div>
{% endblock %}
/* No context defined. */
No notes defined.