<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Preview Layout</title>
<link media="all" rel="stylesheet" href="../../css/plyr.css">
<link media="all" rel="stylesheet" href="../../css/swiper-bundle.min.css">
<link media="all" rel="stylesheet" href="../../css/styles.css">
<script>
window.APP = {
modules: {},
addModule: function(name, config) {
this.modules[name] = this.modules[name] || [];
this.modules[name].push(config);
},
DEBUG: 0,
CONFIG: {},
};
</script>
<style>
body {
margin: 25px !important;
}
</style>
</head>
<body style="background-color: " class="antialiased text-base font-sans ">
<script>
'use strict';
(function(hyva, undefined) {
function lifetimeToExpires(options, defaults) {
const lifetime = options.lifetime || defaults.lifetime;
if (lifetime) {
const date = new Date;
date.setTime(date.getTime() + lifetime * 1000);
return date;
}
return null;
}
function generateRandomString() {
const allowedCharacters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
length = 16;
let formKey = '',
charactersLength = allowedCharacters.length;
for (let i = 0; i < length; i++) {
formKey += allowedCharacters[Math.round(Math.random() * (charactersLength - 1))]
}
return formKey;
}
const sessionCookieMarker = {
noLifetime: true
}
const cookieTempStorage = {};
const internalCookie = {
get(name) {
const v = document.cookie.match('(^|;) ?' + name + '=([^;]*)(;|$)');
return v ? v[2] : null;
},
set(name, value, days, skipSetDomain) {
let expires,
path,
domain,
secure,
samesite;
const defaultCookieConfig = {
expires: null,
path: '/',
domain: null,
secure: false,
lifetime: null,
samesite: 'lax'
};
const cookieConfig = window.COOKIE_CONFIG || {};
expires = days && days !== sessionCookieMarker ?
lifetimeToExpires({
lifetime: 24 * 60 * 60 * days,
expires: null
}, defaultCookieConfig) :
lifetimeToExpires(window.COOKIE_CONFIG, defaultCookieConfig) || defaultCookieConfig.expires;
path = cookieConfig.path || defaultCookieConfig.path;
domain = !skipSetDomain && (cookieConfig.domain || defaultCookieConfig.domain);
secure = cookieConfig.secure || defaultCookieConfig.secure;
samesite = cookieConfig.samesite || defaultCookieConfig.samesite;
document.cookie = name + "=" + encodeURIComponent(value) +
(expires && days !== sessionCookieMarker ? '; expires=' + expires.toGMTString() : '') +
(path ? '; path=' + path : '') +
(domain ? '; domain=' + domain : '') +
(secure ? '; secure' : '') +
(samesite ? '; samesite=' + samesite : 'lax');
},
isWebsiteAllowedToSaveCookie() {
const allowedCookies = this.get('user_allowed_save_cookie');
if (allowedCookies) {
const allowedWebsites = JSON.parse(unescape(allowedCookies));
return allowedWebsites[CURRENT_WEBSITE_ID] === 1;
}
return false;
},
getGroupByCookieName(name) {
const cookieConsentConfig = window.cookie_consent_config || {};
let group = null;
for (let prop in cookieConsentConfig) {
if (!cookieConsentConfig.hasOwnProperty(prop)) continue;
if (cookieConsentConfig[prop].includes(name)) {
group = prop;
break;
}
}
return group;
},
isCookieAllowed(name) {
const cookieGroup = this.getGroupByCookieName(name);
return cookieGroup ?
window.cookie_consent_groups[cookieGroup] :
this.isWebsiteAllowedToSaveCookie();
},
saveTempStorageCookies() {
for (const [name, data] of Object.entries(cookieTempStorage)) {
if (this.isCookieAllowed(name)) {
this.set(name, data['value'], data['days'], data['skipSetDomain']);
delete cookieTempStorage[name];
}
}
}
};
hyva.getCookie = (name) => {
const cookieConfig = window.COOKIE_CONFIG || {};
if (cookieConfig.cookie_restriction_enabled && !internalCookie.isCookieAllowed(name)) {
return cookieTempStorage[name] ? cookieTempStorage[name]['value'] : null;
}
return internalCookie.get(name);
}
hyva.setCookie = (name, value, days, skipSetDomain) => {
const cookieConfig = window.COOKIE_CONFIG || {};
if (cookieConfig.cookie_restriction_enabled && !internalCookie.isCookieAllowed(name)) {
cookieTempStorage[name] = {
value,
days,
skipSetDomain
};
return;
}
return internalCookie.set(name, value, days, skipSetDomain);
}
hyva.setSessionCookie = (name, value, skipSetDomain) => {
return hyva.setCookie(name, value, sessionCookieMarker, skipSetDomain)
}
hyva.getBrowserStorage = () => {
const browserStorage = window.localStorage || window.sessionStorage;
if (!browserStorage) {
console.warn('Browser Storage is unavailable');
return false;
}
try {
browserStorage.setItem('storage_test', '1');
browserStorage.removeItem('storage_test');
} catch (error) {
console.warn('Browser Storage is not accessible', error);
return false;
}
return browserStorage;
}
hyva.postForm = (postParams) => {
const form = document.createElement("form");
let data = postParams.data;
if (!postParams.skipUenc && !data.uenc) {
data.uenc = btoa(window.location.href);
}
form.method = "POST";
form.action = postParams.action;
Object.keys(postParams.data).map(key => {
const field = document.createElement("input");
field.type = 'hidden'
field.value = postParams.data[key];
field.name = key;
form.appendChild(field);
});
const form_key = document.createElement("input");
form_key.type = 'hidden';
form_key.value = hyva.getFormKey();
form_key.name = "form_key";
form.appendChild(form_key);
document.body.appendChild(form);
form.submit();
}
hyva.getFormKey = function() {
let formKey = hyva.getCookie('form_key');
if (!formKey) {
formKey = generateRandomString();
hyva.setCookie('form_key', formKey);
}
return formKey;
}
hyva.formatPrice = (value, showSign, options = {}) => {
const formatter = new Intl.NumberFormat(
'en-US',
Object.assign({
style: 'currency',
currency: 'EUR',
signDisplay: showSign ? 'always' : 'auto'
}, options)
);
return (typeof Intl.NumberFormat.prototype.formatToParts === 'function') ?
formatter.formatToParts(value).map(({
type,
value
}) => {
switch (type) {
case 'currency':
return '€' || value;
case 'minusSign':
return '- ';
case 'plusSign':
return '+ ';
default:
return value;
}
}).reduce((string, part) => string + part) :
formatter.format(value);
}
/**
* Internal string replacement function implementation, see hyva.str() for usage details.
*
* @param string str Template string with optional placeholders
* @param int nStart Offset for placeholders, 0 means %0 is replaced with args[0], 1 means %1 is replaced with args[0]
* @param array ...args Positional replacement arguments. Rest arguments support isn't at 97% yet, so Array.from(arguments).slice() is used instead.
*/
const formatStr = function(str, nStart) {
const args = Array.from(arguments).slice(2);
return str.replace(/(%+)([0-9]+)/g, (m, p, n) => {
const idx = parseInt(n) - nStart;
if (args[idx] === null || args[idx] === void 0) {
return m;
}
return p.length % 2 ?
p.slice(0, -1).replace('%%', '%') + args[idx] :
p.replace('%%', '%') + n;
})
}
/**
* Replace positional parameters like %1 in string with the rest argument in the matching position.
* The first rest argument replaces %1, the second %2 and so on.
*
* Example: hyva.str('%3 %2 %1', 'a', 'b', 'c') => "c b a"
*
* To insert a literal % symbol followed by a number, duplicate the %, for example %%2 is returned as %2.
*/
hyva.str = function(string) {
const args = Array.from(arguments);
args.splice(1, 0, 1);
return formatStr.apply(undefined, args);
}
/**
* Zero based version of hyva.str(): the first rest argument replaces %0, the second %1 and so on.
*
* Example: hyva.strf('%2 %1 %0', 'a', 'b', 'c') => "c b a"
*
* If in doubt whether to use hyva.str() or hyva.strf(), prefer hyva.str() because it is more similar to __()
* and it might be possible to reuse existing phrase translations with placeholders.
*/
hyva.strf = function() {
const args = Array.from(arguments);
args.splice(1, 0, 0);
return formatStr.apply(undefined, args);
}
/**
* Take a html string as `content` parameter and
* extract an element from the DOM to replace in
* the current page under the same selector,
* defined by `targetSelector`
*/
hyva.replaceDomElement = (targetSelector, content) => {
// Parse the content and extract the DOM node using the `targetSelector`
const parser = new DOMParser();
const doc = parser.parseFromString(content, 'text/html');
const contentNode = doc.querySelector(targetSelector);
// Bail if content can't be found
if (!contentNode) {
return;
}
hyva.activateScripts(contentNode)
// Replace the old DOM node with the new content
document.querySelector(targetSelector).replaceWith(contentNode);
// Reload customerSectionData and display cookie-messages if present
window.dispatchEvent(new CustomEvent("reload-customer-section-data"));
hyva.initMessages();
}
hyva.activateScripts = (contentNode) => {
// Extract all the script tags from the content.
// Script tags won't execute when inserted into a dom-element directly,
// therefore we need to inject them to the head of the document.
const tmpScripts = contentNode.getElementsByTagName('script');
if (tmpScripts.length > 0) {
// Push all script tags into an array
// (to prevent dom manipulation while iterating over dom nodes)
const scripts = [];
for (let i = 0; i < tmpScripts.length; i++) {
scripts.push(tmpScripts[i]);
}
// Iterate over all script tags and duplicate+inject each into the head
for (let i = 0; i < scripts.length; i++) {
let script = document.createElement('script');
script.innerHTML = scripts[i].innerHTML;
document.head.appendChild(script);
// Remove the original (non-executing) node from the content
scripts[i].parentNode.removeChild(scripts[i]);
}
}
return contentNode;
}
/**
* Return base64 encoded current URL that can be used by Magento to redirect the visitor back to the current page.
* The func hyva.getUenc handles additional encoding of +, / and = like \Magento\Framework\Url\Encoder::encode().
*/
const replace = {
['+']: '-',
['/']: '_',
['=']: ','
};
hyva.getUenc = () => btoa(window.location.href).replace(/[+/=]/g, match => replace[match]);
let currentTrap;
const focusableElements = (rootElement) => {
const selector = 'button, [href], input, select, textarea, details, [tabindex]:not([tabindex="-1"]';
return Array.from(rootElement.querySelectorAll(selector))
.filter(el => {
return el.style.display !== 'none' &&
!el.disabled &&
el.tabIndex !== -1 &&
(el.offsetWidth || el.offsetHeight || el.getClientRects().length)
})
}
const focusTrap = (e) => {
const isTabPressed = e.key === 'Tab' || e.keyCode === 9;
if (!isTabPressed) return;
const focusable = focusableElements(currentTrap)
const firstFocusableElement = focusable[0]
const lastFocusableElement = focusable[focusable.length - 1]
e.shiftKey ?
document.activeElement === firstFocusableElement && (lastFocusableElement.focus(), e.preventDefault()) :
document.activeElement === lastFocusableElement && (firstFocusableElement.focus(), e.preventDefault())
};
hyva.releaseFocus = (rootElement) => {
if (currentTrap && (!rootElement || rootElement === currentTrap)) {
currentTrap.removeEventListener('keydown', focusTrap)
currentTrap = null
}
}
hyva.trapFocus = (rootElement) => {
if (!rootElement) return;
hyva.releaseFocus()
currentTrap = rootElement
rootElement.addEventListener('keydown', focusTrap)
const firstElement = focusableElements(rootElement)[0]
firstElement && firstElement.focus()
}
hyva.alpineInitialized = (fn) => window.addEventListener('alpine:initialized', fn, {
once: true
})
window.addEventListener('alpine:initialized', () => {
console.log('Alpine.js initialized')
})
window.addEventListener('user-allowed-save-cookie', () => internalCookie.saveTempStorageCookies())
}(window.hyva = window.hyva || {}));
</script>
<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>
<script src="../../js/gsap.min.js" defer crossorigin></script>
<script src="../../js/scrollTrigger.min.js" defer crossorigin></script>
<script src="../../js/swiper-bundle.min.js" defer crossorigin></script>
<script type="module" src="../../js/anchor.min.js" defer crossorigin></script>
<script type="module" src="../../js/persist.min.js" defer crossorigin></script>
<script type="module" src="../../js/intersect.min.js" defer crossorigin></script>
<script type="module" src="../../js/plyr.min.js" defer crossorigin></script>
<script type="module" src="../../js/collapse.min.js" defer crossorigin></script>
<script type="module" src="../../js/alpine3.min.js" defer crossorigin></script>
<script>
(g => {
var h, a, k, p = "The Google Maps JavaScript API",
c = "google",
l = "importLibrary",
q = "__ib__",
m = document,
b = window;
b = b[c] || (b[c] = {});
var d = b.maps || (b.maps = {}),
r = new Set,
e = new URLSearchParams,
u = () => h || (h = new Promise(async (f, n) => {
await (a = m.createElement("script"));
e.set("libraries", [...r] + "");
for (k in g) e.set(k.replace(/[A-Z]/g, t => "_" + t[0].toLowerCase()), g[k]);
e.set("callback", c + ".maps." + q);
a.src = `https://maps.${c}apis.com/maps/api/js?` + e;
d[q] = f;
a.onerror = () => h = n(Error(p + " could not load."));
a.nonce = m.querySelector("script[nonce]")?.nonce || "";
m.head.append(a)
}));
d[l] ? console.warn(p + " only loads once. Ignoring:", g) : d[l] = (f, ...n) => r.add(f) && u().then(() => d[l](f, ...n))
})({
key: "AIzaSyAmlBHNWuFlAL7elylRqvhRn4MD7ko0sWs",
v: "weekly",
// Use the 'v' parameter to indicate the version to use (weekly, beta, alpha, etc.).
// Add other bootstrap parameters as needed, using camel case.
});
</script>
<script src="../../js/markerclusterer.min.js" defer crossorigin></script>
<script>
window.addEventListener('alpine:init', () => {
console.log('Alpine.js has been initialized');
Alpine.store('screen', {
isMobile: window.matchMedia('(max-width: 768px)').matches,
isTablet: window.matchMedia('(max-width: 1024px)').matches,
// Méthode d'initialisation pour mettre à jour `isMobile` en fonction de la taille de l'écran
init() {
const mobileMedia = window.matchMedia('(max-width: 768px)');
const tabletMedia = window.matchMedia('(max-width: 1024px)');
this.isMobile = mobileMedia.matches;
this.isTablet = tabletMedia.matches;
const updateScreen = (event, type) => {
if (type === 'mobile') this.isMobile = event.matches;
if (type === 'tablet') this.isTablet = event.matches;
};
[{
media: mobileMedia,
type: 'mobile'
},
{
media: tabletMedia,
type: 'tablet'
}
].forEach(({
media,
type
}) => {
if (typeof media.onchange !== 'object') {
media.addListener((e) => updateScreen(e, type));
} else {
media.addEventListener('change', (e) => updateScreen(e, type));
}
});
}
});
Alpine.store('filter', {
filters: [],
getFilter(type, value) {
return this.filters.some((filter) => filter.type === type && filter.value === value);
},
toggleFilter(type, value) {
const filterArray = this.filters.selected[type];
const index = filterArray.indexOf(value);
if (index === -1) {
filterArray.push(value);
} else {
filterArray.splice(index, 1);
}
this.applyFilters();
},
clearFilters() {
this.filters = [];
}
});
Alpine.store('asideBlocs', {
asides: [],
// Ajouter un nouvel aside au tableau
addAside(name, customProperties = {}) {
if (!this.asides.find(aside => aside.name === name)) {
this.asides.push({
name,
open: false,
...customProperties, // Ajoute des propriétés spécifiques
});
}
},
// Supprimer un aside par son nom
removeAside(name) {
this.asides = this.asides.filter(aside => aside.name !== name);
},
// Basculer l'état d'ouverture d'un aside
toggleAside(name) {
const aside = this.asides.find(aside => aside.name === name);
if (aside) {
aside.open = !aside.open;
document.body.classList.toggle('overflow-hidden', aside.open);
}
},
// Fermer un aside spécifique
closeAside(name) {
const aside = this.asides.find(aside => aside.name === name);
if (aside) {
aside.open = false;
document.body.classList.remove('overflow-hidden');
}
},
// Ouvrir un aside spécifique
openAside(name) {
const aside = this.asides.find(aside => aside.name === name);
if (aside) {
aside.open = true;
document.body.classList.add('overflow-hidden');
}
}
});
Alpine.store('locator', {
allStores: [], // Liste complète des magasins
countStore: "",
filteredStores: [], // Liste des magasins filtrés
filteredDistanceStores: null, //Liste des magasins trié par distance
selectedStore: null, // Magasin sélectionné
isAudio: false, // Type de magasin affiché (true = audio, false = optique)
loading: true, // État de chargement
mapCenter: null, // Centre lat et lng de la google map
mapInstance: null, // Instance de la google map. ( /!\ Toutes les fonctions ne sont pas disponible )
// Listes des filtres disponibles (extraites des données)
filterLists: {
audio: {
mutuals: [], // Liste des mutuelles optique disponibles
brands: [], // Liste des marques optique disponibles
types: [] // Contiendra ['optic', 'teleophtalmologie'] selon disponibilité
},
optic: {
mutuals: [], // Liste des mutuelles optique disponibles
brands: [], // Liste des marques optique disponibles
types: [] // Contiendra ['audio', 'teleophtalmologie'] selon disponibilité
}
},
// Modification des selectedFilters pour inclure les types
selectedFilters: {
audio: {
mutuals: [], // Mutuelles audio sélectionnées
brands: [], // Marques audio sélectionnées
types: [] // Les types sélectionnés
},
optic: {
mutuals: [], // Mutuelles audio sélectionnées
brands: [], // Marques audio sélectionnées
types: [] // Les types sélectionnés
},
search: ''
},
// Défault donnée des prises de rdv
defaultStepperState: {
steps: [{
id: 1,
title: 'Où',
completed: true
},
{
id: 2,
title: 'Quoi',
completed: false
},
{
id: 3,
title: 'Quand',
completed: false
},
{
id: 4,
title: 'Qui',
completed: false
}
],
completed: false,
currentStep: 2,
code_mur: null,
type: null,
data: []
},
// Initialisation avec persist
stepperData: Alpine.$persist(function() {
return {
...this.defaultStepperState
}
}).as('stepperData'),
// Getters
get currentFilterList() {
return this.filterLists[this.currentType];
},
get currentType() {
return this.isAudio ? 'audio' : 'optic';
},
get currentSelectedFilters() {
return this.selectedFilters[this.currentType];
},
async init() {
try {
// Détection du type depuis l'URL
try {
const pathname = window.location.pathname;
this.isAudio = pathname.includes('/acousticien');
} catch (error) {
console.error('Erreur lors de la détection du type depuis l\'URL:', error);
this.isAudio = false; // Défault value
}
await this.loadLibraries();
await this.loadStores();
this.extractFiltersFromStores();
this.applyFilters();
} finally {
this.loading = false;
}
},
async loadLibraries() {
const [geometry] = await Promise.all([
google.maps.importLibrary("geometry"),
]);
},
async loadStores() {
try {
const response = await fetch(`${window.location.origin}/js/json/getListv2.json`);
const data = await response.json();
if (data.success) {
this.countStore = data.data.totalCount ?? data.data.items.length;
this.allStores = data.data.items;
}
} catch (error) {
console.error('Erreur lors du chargement des magasins:', error);
this.allStores = [];
}
},
extractFiltersFromStores() {
const storesArray = Object.values(this.allStores);
// Pour les magasins Audio
const audioStores = storesArray.filter(store =>
store.locations.some(location => location.is_audio)
);
// Comptage pour les mutuelles audio
const audioMutualCounts = new Map();
audioStores.forEach(store => {
store.locations
.filter(location => location.is_audio)
.forEach(location => {
(location.mutuelles || []).forEach(mutual => {
audioMutualCounts.set(mutual, (audioMutualCounts.get(mutual) || 0) + 1);
});
});
});
// Comptage pour les marques audio
const audioBrandCounts = new Map();
audioStores.forEach(store => {
store.locations
.filter(location => location.is_audio)
.forEach(location => {
(location.brands || []).forEach(brand => {
audioBrandCounts.set(brand, (audioBrandCounts.get(brand) || 0) + 1);
});
});
});
// Pour les magasins Optique
const opticStores = storesArray.filter(store =>
store.locations.some(location => !location.is_audio)
);
// Comptage pour les mutuelles optique
const opticMutualCounts = new Map();
opticStores.forEach(store => {
store.locations
.filter(location => !location.is_audio)
.forEach(location => {
(location.mutuelles || []).forEach(mutual => {
opticMutualCounts.set(mutual, (opticMutualCounts.get(mutual) || 0) + 1);
});
});
});
// Comptage pour les marques optique
const opticBrandCounts = new Map();
opticStores.forEach(store => {
store.locations
.filter(location => !location.is_audio)
.forEach(location => {
(location.brands || []).forEach(brand => {
opticBrandCounts.set(brand, (opticBrandCounts.get(brand) || 0) + 1);
});
});
});
this.filterLists.audio = {
mutuals: Array.from(audioMutualCounts.entries())
.map(([id, count]) => ({
id,
label: id,
count
}))
.sort((a, b) => a.label.localeCompare(b.label)),
brands: Array.from(audioBrandCounts.entries())
.map(([id, count]) => ({
id,
label: id,
count
}))
.sort((a, b) => a.label.localeCompare(b.label)),
types: this.extractAvailableTypes(audioStores, true)
};
this.filterLists.optic = {
mutuals: Array.from(opticMutualCounts.entries())
.map(([id, count]) => ({
id,
label: id,
count
}))
.sort((a, b) => a.label.localeCompare(b.label)),
brands: Array.from(opticBrandCounts.entries())
.map(([id, count]) => ({
id,
label: id,
count
}))
.sort((a, b) => a.label.localeCompare(b.label)),
types: this.extractAvailableTypes(opticStores, false)
};
},
// Méthode pour extraire les types disponibles
extractAvailableTypes(stores, isAudio) {
const types = new Set();
const counts = {
[isAudio ? 'optic' : 'audio']: 0,
teleophtalmologie: 0
};
stores.forEach(store => {
// Vérifie si le magasin a l'autre type de service
const hasOtherService = store.locations.some(location =>
isAudio ? !location.is_audio : location.is_audio
);
if (hasOtherService) {
types.add(isAudio ? 'optic' : 'audio');
counts[isAudio ? 'optic' : 'audio']++;
}
// Vérifie si le magasin a la téléophtalmologie
const hasTelephtalmology = store.locations.some(location =>
location.attributes.teleophtalmologie?.value === "1"
);
if (hasTelephtalmology) {
types.add('teleophtalmologie');
counts.teleophtalmologie++;
}
});
// Retourne un tableau d'objets avec le type et son count
return Array.from(types).sort().map(type => ({
id: type,
label: type,
count: counts[type]
}));
},
// Dans toggleStoreType du store locator
toggleStoreType(isAudio) {
if (isAudio !== undefined) {
this.isAudio = isAudio;
} else {
this.isAudio = !this.isAudio;
}
// Mise à jour de l'URL
try {
const currentUrl = new URL(window.location.href);
const params = new URLSearchParams(currentUrl.search);
// Changer le pathname en gardant la même base
const newPathname = currentUrl.pathname.replace(
/(\/opticien|\/acousticien)/,
this.isAudio ? '/acousticien' : '/opticien'
);
// Construire la nouvelle URL avec les paramètres existants
const newUrl = `${currentUrl.origin}${newPathname}${params.toString() ? '?' + params.toString() : ''}`;
// Mettre à jour l'URL sans recharger la page
window.history.pushState({}, '', newUrl);
} catch (error) {
console.error('Erreur lors de la mise à jour de l\'URL:', error);
}
this.selectedStore = null;
this.clearFilters();
this.applyFilters();
},
updateFilter(category, value) {
const filters = this.selectedFilters[this.currentType][category];
const index = filters.indexOf(value.id || value);
if (index === -1) {
filters.push(value.id || value);
} else {
filters.splice(index, 1);
}
},
updateSearchTerm(term) {
this.filters.selected.search = term;
this.applyFilters();
},
clearFilters() {
this.selectedFilters[this.currentType].mutuals = [];
this.selectedFilters[this.currentType].brands = [];
this.selectedFilters[this.currentType].types = [];
this.selectedFilters.search = '';
this.applyFilters();
},
// Application des filtres modifiée
applyFilters() {
const searchTerm = this.selectedFilters.search?.toLowerCase() || '';
const selectedMutuals = this.selectedFilters[this.currentType].mutuals || [];
const selectedBrands = this.selectedFilters[this.currentType].brands || [];
const selectedTypes = this.selectedFilters[this.currentType].types || [];
const filteredStores = Object.values(this.allStores).filter(store => {
// 1. Vérification du service principal (toujours requis)
const hasMainService = store.locations.some(location =>
this.isAudio ? location.is_audio : !location.is_audio
);
if (!hasMainService) return false;
// 2. Vérification des types additionnels si sélectionnés
if (selectedTypes.length > 0) {
// Vérifie l'autre service si sélectionné
const otherServiceType = this.isAudio ? 'optic' : 'audio';
if (selectedTypes.includes(otherServiceType)) {
const hasOtherService = store.locations.some(location =>
this.isAudio ? !location.is_audio : location.is_audio
);
if (!hasOtherService) return false;
}
// Vérifie la téléophtalmologie si sélectionnée
if (selectedTypes.includes('teleophtalmologie')) {
const hasTelephtalmology = store.locations.some(location =>
location.attributes.teleophtalmologie?.value === "1"
);
if (!hasTelephtalmology) return false;
}
}
// Filtres existants inchangés
if (searchTerm && !this.matchesSearch(store, searchTerm)) return false;
// Filtre par mutuelles
if (selectedMutuals.length > 0) {
const storeMutuals = store.locations
.filter(location => this.isAudio ? location.is_audio : !location.is_audio)
.flatMap(location => location.mutuelles || []);
if (!selectedMutuals.some(mutualId => storeMutuals.includes(mutualId))) {
return false;
}
}
if (selectedBrands.length > 0) {
const storeBrands = store.locations
.filter(location => this.isAudio ? location.is_audio : !location.is_audio)
.flatMap(location => location.brands || []);
if (!selectedBrands.some(brandId => storeBrands.includes(brandId))) {
return false;
}
}
return true;
});
this.filteredStores = filteredStores;
this.updateDistances(filteredStores);
},
// Mise à jour de la méthode matchesSearch également si nécessaire
matchesSearch(store, searchTerm) {
return store.locations.some(location =>
location.name?.toLowerCase().includes(searchTerm) ||
store.city?.toLowerCase().includes(searchTerm) ||
store.zip?.toLowerCase().includes(searchTerm)
);
},
selectStore(store) {
this.selectedStore = store;
},
isFilterSelected(category, value) {
return this.selectedFilters[category].includes(value);
},
updateDistances(filteredStores) {
if (!this.mapInstance) return;
const center = this.mapInstance.getCenter();
const stores = Object.values(filteredStores || {});
// Créer de nouveaux objets avec les distances au lieu de modifier les existants
this.filteredDistanceStores = stores.map(store => {
const distance = google.maps.geometry.spherical.computeDistanceBetween(
center, {
lat: parseFloat(store.lat),
lng: parseFloat(store.lng)
}
);
// Retourner un nouvel objet au lieu de modifier l'original
return {
...store,
distance: (distance / 1000).toFixed(1) + ' km'
};
}).sort((a, b) => {
const distA = parseFloat(a.distance);
const distB = parseFloat(b.distance);
return distA - distB;
});
},
setMapCenter(lat, lng, zoom) {
if (!isNaN(lat) && !isNaN(lng)) {
this.mapInstance.panTo({
lat,
lng
})
}
if (zoom) {
this.mapInstance.setZoom(zoom)
}
},
goToStore(store) {
if (!this.mapInstance || !store) return;
const lat = parseFloat(store.lat);
const lng = parseFloat(store.lng);
if (isNaN(lat) || isNaN(lng)) return;
this.setMapCenter(lat, lng, 15)
this.selectStore(store);
},
// PARTIE: PRISE DE RENDEZ-VOUS
initStepper(store) {
this.stepperData = {
...this.defaultStepperState,
code_mur: store.mur_code,
type: this.isAudio,
data: [{
title: 'MAGASIN',
value: `${store.address}, ${store.zip} ${store.city}`
}]
};
},
updateStepperData(title, value, targetStep) {
if (!this.stepperData) return;
// Trouver l'index de la donnée existante
const currentIndex = this.stepperData.data.findIndex(item => item.title === title);
// Mise à jour ou ajout des données
if (currentIndex === -1) {
// Ajout d'une nouvelle entrée
this.stepperData.data.push({
title,
value
});
} else if (this.stepperData.data[currentIndex].value !== value) {
// Si la valeur change, on supprime les données suivantes
this.stepperData.data = this.stepperData.data.slice(0, currentIndex + 1);
this.stepperData.data[currentIndex] = {
title,
value
};
}
// Mise à jour du step et des états des étapes si nécessaire
if (targetStep) {
this.stepperData.currentStep = targetStep;
this.stepperData.steps = this.stepperData.steps.map(step => ({
...step,
completed: step.id < targetStep
}));
}
// Alpine.persist se charge automatiquement de la persistance
return this.stepperData;
},
completeStepperData(appointmentId) {
if (!this.stepperData) return;
// Marquer toutes les étapes comme complétées
this.stepperData.steps = this.stepperData.steps.map(step => ({
...step,
completed: true
}));
// Marquer le stepper comme complété et ajouter l'ID du rendez-vous
this.stepperData.completed = true;
this.stepperData.appointmentId = appointmentId;
},
goToStepperStep(targetStep) {
// Vérifier que l'étape cible est valide
if (!this.stepperData || targetStep < 1 || targetStep > 4) return;
// Si on retourne à l'étape 1, réinitialiser complètement
if (targetStep === 1) {
this.resetStepperData();
this.$store.asideBlocs.closeAside('storeLocatorAppointment');
return;
}
// Mettre à jour l'étape courante et l'état des étapes
this.stepperData.currentStep = targetStep;
this.stepperData.steps = this.stepperData.steps.map(step => ({
...step,
completed: step.id < targetStep
}));
},
resetStepperData() {
// Réinitialiser avec les valeurs par défaut
this.stepperData = Alpine.persist({
...this.defaultStepperState
}, 'stepperData');
}
});
});
</script>
</body>
</html>
{% extends '@layout' %}
{% block yield %}
{% render "@storelocator-appointment" %}
{% render "@storelocator-filter" %}
{{ yield }}
{% endblock %}
/* No context defined. */
No notes defined.