| Current Path : /home/happyrenas/find.myreco.online/v2/js/ |
Linux webd005.cluster105.gra.hosting.ovh.net 5.15.206-ovh-vps-grsec-zfs-classid #1 SMP Fri May 15 02:41:25 UTC 2026 x86_64 |
| Current File : /home/happyrenas/find.myreco.online/v2/js/script_halt - Copie.js |
/* =============================================================================
Find.MyReco — Page Recherche (index2)
Objectif :
- Autocomplete villes (JSON prefix)
- Recherche (cache BDD)
- Affichage des cards + chips équipements + favoris
- Modale contact (demandes de disponibilité)
- Carte Leaflet : markers + interactions cards <-> markers
- Photo caching (ajax/photo_cache.php)
Organisation :
- Configuration / constantes
- Helpers (DOM, chaînes, formatage)
- Etat & filtres (UI home/results)
- Rendering (cards, mini-pills, erreurs)
- Autocomplete
- Photos (lazy cache)
- Carte Leaflet
- Home (top villes / sections)
- Recherche (API)
- Modale contact
- Events & init
============================================================================= */
/* ----------------------------------------------------------------------------
Config / Flags
---------------------------------------------------------------------------- */
const DEBUG_PHOTOS = false; // Debug box sur les cards (désactiver en prod)
const SITE_BASE_URL = 'https://www.myreco.online';
const PLACEHOLDER_IMG = 'https://find.myreco.online/img/placeholder.svg';
// Likes en mémoire (perdus si refresh)
const likedTokens = new Set(); // token likés
const likedAtByToken = new Map(); // token -> timestamp (pour ton tri "récemment liké")
/* ----------------------------------------------------------------------------
Helpers (string / DOM / formatting)
---------------------------------------------------------------------------- */
const $ = (s) => document.querySelector(s);
function normToken(t){
return String(t ?? '').trim();
}
function toAbsoluteUrl(url){
const u = String(url ?? '').trim();
if (!u) return '';
if (u.startsWith('http://') || u.startsWith('https://')) return u;
if (u.startsWith('//')) return 'https:' + u;
if (u.startsWith('/')) return SITE_BASE_URL + u;
return SITE_BASE_URL + '/' + u;
}
function normalizePhotosLocal(arr){
if (!Array.isArray(arr)) return [];
return arr
.map(x => String(x ?? '').trim())
.filter(Boolean);
}
function buildLocalPhotoUrl(relPath){
const rel = String(relPath ?? '').trim();
if (!rel) return '';
return SITE_BASE_URL + '/upload/hebergement_multiple/' + rel;
}
function escapeHtml(s){
return String(s ?? '').replace(/[&<>"']/g, m => ({
'&':'&','<':'<','>':'>','"':'"',"'":'''
}[m]));
}
function normalizeForPrefix2(str){
const city = (str.split(',')[0] || '').trim().toLowerCase();
if (city.length < 2) return '';
const deacc = city.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
const clean = deacc.replace(/[^a-z]/g, '');
return clean.slice(0,2);
}
function formatRating(rating){
return rating;
}
function formatReviews(reviews){
const n = parseInt(reviews, 10);
if (!isFinite(n) || n <= 0) return '';
return n.toLocaleString('fr-FR');
}
function parseCoord(v){
const n = parseFloat(String(v ?? '').replace(',', '.'));
return isFinite(n) ? n : null;
}
function initContactDates(root = document){
const debut = root.querySelector('#date_debut');
const fin = root.querySelector('#date_fin');
if (!debut || !fin) return;
const pad = (n) => String(n).padStart(2, '0');
const toYMD = (d) => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
const today = new Date();
const todayStr = toYMD(today);
const d3 = new Date(today);
d3.setDate(d3.getDate() + 3);
const plus3Str = toYMD(d3);
// valeurs par défaut (si vide)
if (!debut.value) debut.value = todayStr;
if (!fin.value) fin.value = plus3Str;
// min
debut.min = todayStr;
fin.min = todayStr;
function syncMinEnd(){
if (debut.value){
fin.min = debut.value;
if (fin.value && fin.value < debut.value){
fin.value = debut.value;
}
} else {
fin.min = todayStr;
}
}
debut.removeEventListener('change', syncMinEnd); // évite de cumuler si rappelé
debut.addEventListener('change', syncMinEnd);
syncMinEnd();
}
function normalizeSearch(str){
if (!str) return '';
let s = String(str).toLowerCase();
// enlever accents
s = s.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
// remplacer tirets / apostrophes par espace
s = s.replace(/[-'’]/g, ' ');
// enlever tout sauf lettres/chiffres/espace
s = s.replace(/[^a-z0-9 ]/g, '');
// compacter espaces
s = s.replace(/\s+/g, ' ').trim();
return s;
}
/* ----------------------------------------------------------------------------
Equipements (chips mini)
---------------------------------------------------------------------------- */
function normalizeEquipements(eqs){
if (!Array.isArray(eqs)) return [];
return eqs.map(x => String(x ?? '').trim()).filter(Boolean);
}
function renderEquipementsChips(eqs, max = 3){
const arr = normalizeEquipements(eqs);
if (!arr.length) return '';
const shown = arr.slice(0, max);
const more = arr.length - shown.length;
const chips = shown.map(t => `<span class="chip-mini">${escapeHtml(t)}</span>`).join('');
const moreHtml = more > 0 ? `<span class="chip-mini">+${more}</span>` : '';
return `<div class="d-flex flex-wrap gap-1 justify-content-end">${chips}${moreHtml}</div>`;
}
//16/12/2025
function fixBrokenUnicode(s){
// transforme "Pu00e9tanque" -> "Pétanque", "Flu00e9chette" -> "Fléchette"
return String(s ?? '').replace(/u([0-9a-fA-F]{4})/g, function(_, hex){
return String.fromCharCode(parseInt(hex, 16));
});
}
function normalizeList(arr){
if (!Array.isArray(arr)) return [];
return arr
.map(function(x){
return fixBrokenUnicode(String(x ?? '')).trim();
})
.filter(Boolean);
}
function syncContactButtons(){
const selected = new Set(contactSelected.map(x => String(x.token)));
document.querySelectorAll('.btnContact[data-token]').forEach(btn => {
const t = String(btn.getAttribute('data-token') || '');
const on = selected.has(t);
btn.classList.toggle('is-active', on);
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
});
}
/* --- Parking --- */
function buildParkingHtml(parking){
const p = String(parking ?? '').toLowerCase();
// adapte ici si tes valeurs sont différentes (ex: "ouvert", "ferme", "parking_ouvert", etc.)
let label = '—';
let icon = 'bi bi-p-circle';
let cls = 'mini-pill';
if (!p || p.includes('aucun')) {
label = 'Pas de parking';
icon = 'bi bi-ban';
cls += ' is-muted';
} else if (p.includes('ouvert')) {
label = 'Parking ouvert';
icon = 'bi bi-p-circle-fill';
} else if (p.includes('ferme') || p.includes('fermé')) {
label = 'Parking fermé';
icon = 'bi bi-p-square-fill';
} else {
label = parking; // fallback : affiche la valeur brute
icon = 'bi bi-p-circle';
}
return '<span class="' + cls + '"><i class="' + icon + '"></i><span class="lbl">' + escapeHtml(label) + '</span></span>';
}
/* --- Facts: capacité / chambres / sdb --- */
function buildFactsHtml(r){
const d = (r.donnees && typeof r.donnees === 'object') ? r.donnees : {};
const capacite = parseInt(d.capacite, 10);
const chambres = parseInt(d.nb_chambres, 10);
const sdb = parseInt(d.nb_sdb, 10);
const out = [];
out.push('<div class="facts-row">');
if (isFinite(capacite) && capacite > 0){
out.push('<span class="mini-pill"><i class="bi bi-people-fill"></i><span class="lbl">' + capacite + ' pers.</span></span>');
}
if (isFinite(chambres) && chambres > 0){
out.push('<span class="mini-pill"><i class="bi bi-door-closed-fill"></i><span class="lbl">' + chambres + ' ch.</span></span>');
}
if (isFinite(sdb) && sdb > 0){
out.push('<span class="mini-pill"><i class="bi bi-droplet-fill"></i><span class="lbl">' + sdb + ' sdb</span></span>');
}
// parking (toujours visible)
out.push(buildParkingHtml(r.parking));
out.push('</div>');
return out.join('');
}
/* --- Icônes pour équipements / activités --- */
function iconForItem(label){
const t = String(label || '').toLowerCase();
// Équipements
if (t.includes('piscine')) return 'bi bi-water';
if (t.includes('jacuzzi')) return 'bi bi-droplet';
if (t.includes('hammam')) return 'bi bi-cloud-haze2';
if (t.includes('barbecue'))return 'bi bi-fire';
if (t.includes('plancha')) return 'bi bi-fire';
// Activités
if (t.includes('ping')) return 'bi bi-circle';
if (t.includes('pétanque') || t.includes('petanque')) return 'bi bi-bullseye';
if (t.includes('babyfoot')) return 'bi bi-controller';
if (t.includes('billard')) return 'bi bi-record-circle';
if (t.includes('fléchette') || t.includes('flechette')) return 'bi bi-bullseye';
if (t.includes('raquette')) return 'bi bi-activity';
if (t.includes('toboggan')) return 'bi bi-water';
return 'bi bi-check2-circle';
}
function renderMiniPills(arr, kind){
const list = normalizeList(arr);
if (!list.length) return '';
// kind = "equip" ou "act" (juste pour un petit titre optionnel)
const title = (kind === 'act') ? 'Activités' : 'Équipements';
let out = [];
out.push('<div class="mini-line">');
out.push('<div class="mini-title">' + title + '</div>');
out.push('<div class="mini-wrap">');
for (let i=0; i<list.length; i++){
const lbl = list[i];
out.push(
'<span class="mini-pill">' +
'<i class="' + iconForItem(lbl) + '"></i>' +
'<span class="lbl">' + escapeHtml(lbl) + '</span>' +
'</span>'
);
}
out.push('</div></div>');
return out.join('');
}
/* ----------------------------------------------------------------------------
DOM refs
---------------------------------------------------------------------------- */
const input = $('#qCity');
const suggestBox = $('#suggestBox');
const grid = $('#grid');
const statusEl = $('#status');
const metaEl = $('#meta');
const errorBox = $('#errorBox');
const errorMsg = $('#errorMsg');
const btnClearCache= $('#btnClearCache');
const radiusSelect = $('#qRadius');
const guestsSelect = $('#qGuests');
const splitEl = document.querySelector('#resultsSplit');
const btnToggleMap = document.querySelector('#btnToggleMap');
const btnShuffle = document.querySelector('#btnShuffle');
const filtersBar = document.querySelector('#filtersBar');
const resultsBlock = document.getElementById('resultsBlock');
const homeSections = document.getElementById('homeSections');
/* ----------------------------------------------------------------------------
Filters state
- types: Set("hotel"|"location"|"camping")
* vide => pas de filtre => affiche tout
- piscine: boolean
---------------------------------------------------------------------------- */
const filters = {
types: new Set(),
piscine: false,
};
function getActiveTypes(){
return [...filters.types];
}
function setFilterBtnState(){
if (!filtersBar) return;
filtersBar.querySelectorAll('.js-filter[data-filter="type"]').forEach(btn => {
const t = btn.getAttribute('data-value');
btn.classList.toggle('active', filters.types.has(t));
});
const p = filtersBar.querySelector('.js-filter[data-filter="piscine"]');
if (p) p.classList.toggle('active', !!filters.piscine);
}
function resetFilters(){
filters.types.clear();
filters.piscine = false;
setFilterBtnState();
}
/* ----------------------------------------------------------------------------
UI states (Home / Results)
---------------------------------------------------------------------------- */
function setModeHome(){
if (resultsBlock) resultsBlock.style.display = 'none';
if (homeSections) homeSections.style.display = 'block';
// Reset UI "Résultats"
grid.innerHTML = '';
statusEl.textContent = '—';
metaEl.textContent = '—';
btnClearCache.style.display = 'none';
}
function setModeResults(){
if (homeSections) homeSections.style.display = 'none';
if (resultsBlock) resultsBlock.style.display = 'block';
// Carte ON par défaut en mode résultats
if (splitEl) splitEl.classList.add('is-map');
if (btnToggleMap) btnToggleMap.innerHTML = '<i class="bi bi-list me-1"></i>Liste';
}
/* ----------------------------------------------------------------------------
UI helpers (errors / loading)
---------------------------------------------------------------------------- */
function setError(msg){
errorMsg.textContent = msg;
errorBox.style.display = 'block';
setModeHome();
}
function clearError(){
errorBox.style.display = 'none';
errorMsg.textContent = '';
}
function setLoading(){
grid.innerHTML =
'<div class="col-sm-6 col-lg-4"><div class="skel"></div></div>' +
'<div class="col-sm-6 col-lg-4"><div class="skel"></div></div>' +
'<div class="col-sm-6 col-lg-4"><div class="skel"></div></div>';
}
/* ----------------------------------------------------------------------------
Like (UI only)
---------------------------------------------------------------------------- */
function toggleLike(btn){
// feedback sur le coeur (plus lisible)
try{
btn.animate(
[{ transform: 'scale(1)' }, { transform: 'scale(1.18)' }, { transform: 'scale(1)' }],
{ duration: 220, easing: 'cubic-bezier(.2,.8,.2,1)' }
);
}catch(e){}
btn.classList.toggle('liked');
const i = btn.querySelector('i');
const liked = btn.classList.contains('liked');
// Trouve la colonne Bootstrap contenant la card
const card = btn.closest('.card-heb');
const col = card ? card.parentElement : null;
const wrap = col ? col.parentElement : null;
// token (sert à la mémoire + markers)
const token = card ? normToken(card.getAttribute('data-token')) : '';
if (liked){
i?.classList.remove('bi-heart');
i?.classList.add('bi-heart-fill');
// --- Mémoire (sans localStorage) ---
if (token){
likedTokens.add(token);
likedAtByToken.set(token, Date.now());
}
if (col){
col.classList.add('is-liked');
col.dataset.likedAt = String(likedAtByToken.get(token) || Date.now()); // pour mettre celle-ci en 1ère
}
if (card){
card.classList.add('is-active'); // état favori (persistant sur la page)
pulseCard(card); // pulse visuel
}
} else {
i?.classList.add('bi-heart');
i?.classList.remove('bi-heart-fill');
// --- Mémoire (sans localStorage) ---
if (token){
likedTokens.delete(token);
likedAtByToken.delete(token);
}
if (col){
col.classList.remove('is-liked');
delete col.dataset.likedAt;
}
if (card){
card.classList.remove('is-active');
}
}
// --- Sync marker color with "liked" ---
const m = token ? markerByToken.get(token) : null;
if (m){
m.setStyle(liked ? MARKER_STYLE_LIKED : MARKER_STYLE_DEFAULT);
}
// Réordonne avec animation, et garde la fiche "sous le doigt" (pas de scroll brutal)
if (wrap && col){
const beforeTop = col.getBoundingClientRect().top;
flipReorder(wrap, () => sortCardsInWrap(wrap), { duration: 280 });
const afterTop = col.getBoundingClientRect().top;
const delta = afterTop - beforeTop;
// ajuste le scroll pour que la fiche reste à la même place à l'écran
if (Math.abs(delta) > 1){
window.scrollBy({ top: delta, left: 0 });
}
// si malgré tout elle sort de l'écran, on fait un scroll doux minimal
requestAnimationFrame(() => ensureVisibleIfNeeded(col, 120, 16));
} else {
requestAnimationFrame(() => ensureVisibleIfNeeded(card || btn, 120, 16));
}
}
/* ----------------------------------------------------------------------------
Card rendering
- photo_src uniquement (jamais r.photo externe)
---------------------------------------------------------------------------- */
function buildRatingHtml(r){
var ratingTxt = formatRating(r.rating);
var reviewsTxt = formatReviews(r.reviews);
if (ratingTxt && reviewsTxt){
return '<span class="badge-soft">' +
'<i class="bi bi-star-fill" style="color:#ff385c"></i> ' +
escapeHtml(ratingTxt) + ' ' +
'<span style="color:var(--muted); font-weight:700;">(' + escapeHtml(reviewsTxt) + ')</span>' +
'</span>';
}
if (ratingTxt){
return '<span class="badge-soft">' +
'<i class="bi bi-star-fill" style="color:#ff385c"></i>' +
escapeHtml(ratingTxt) +
'</span>';
}
return '';
}
function buildDebugHtml(r){
if (!DEBUG_PHOTOS) return '';
var token = String(r.token ?? '');
var photo = String(r.photo ?? '').slice(0, 120);
var ploc = String(r.photo_locale ?? '').slice(0, 120);
var psrc = String(r.photo_src ?? '').slice(0, 120);
var okTxt = r.photo_local_exists ? '✅ true' : '❌ false';
return '<div class="debug-box">' +
'<div><strong>token:</strong> ' + escapeHtml(token) + '</div>' +
'<div><strong>photo:</strong> ' + escapeHtml(photo) + '</div>' +
'<div><strong>photo_locale:</strong> ' + escapeHtml(ploc) + '</div>' +
'<div><strong>photo_src:</strong> ' + escapeHtml(psrc) + '</div>' +
'<div><strong>local_exists:</strong> ' + okTxt + '</div>' +
'</div>';
}
function cardHtml(r){
var imgSrc = '';
if (r.photo_src && String(r.photo_src).trim() !== ''){
imgSrc = toAbsoluteUrl(r.photo_src);
}
if (!imgSrc) imgSrc = PLACEHOLDER_IMG;
var origin = r.origine ? '<span class="tag">Origine: ' + escapeHtml(r.origine) + '</span>' : '';
var type = r.type_hebergement ? '<span class="mini-pill">' + escapeHtml(r.type_hebergement) + '</span>' : '';
var parking = r.parking ? '<span class="tag"><i class="bi bi-car-front me-1"></i>' + escapeHtml(r.parking) + '</span>' : '';
var price = (parseFloat(r.tarif_nuit || 0) > 0) ? (escapeHtml(r.tarif_nuit) + '€') : '';
var ratingHtml = buildRatingHtml(r);
var eqHtml = renderEquipementsChips(r.equipements, 3);
var factsHtml = buildFactsHtml(r);
var equipAll = renderMiniPills(r.equipements, 'equip');
var actAll = renderMiniPills(r.activites, 'act');
var dbgHtml = buildDebugHtml(r);
// IMPORTANT: token "raw" pour le Set/Map, puis token échappé pour l'HTML
var rawToken = normToken(r.token);
var extraPhotos = normalizePhotosLocal(r.photos_local_json);
var hasExtraPhotos = extraPhotos.length > 0;
var isLiked = likedTokens.has(rawToken);
var likedAt = likedAtByToken.get(rawToken) || 0;
var token = escapeHtml(rawToken);
var photosBadgeHtml = hasExtraPhotos
? '<button type="button" class="photos-badge" data-action="open-photos" data-token="' + token + '" title="Voir les photos">' +
'<i class="bi bi-images"></i><span>' + extraPhotos.length + '</span>' +
'</button>'
: '';
var name = escapeHtml(r.name);
var ville = escapeHtml(r.ville);
//var cc = escapeHtml(r.country_code);
var cc = '';
var pc = r.postal_code ? '(' + escapeHtml(r.postal_code) + ')' : '';
//var catHtml = r.category ? '<span class="tag">' + escapeHtml(r.category) + '</span>' : '';
var catHtml = '';
var priceHtml = price
? ('<strong>' + price + '</strong> <span style="color:var(--muted)">/ nuit</span>')
: '<span style="color:var(--muted)">—</span>';
return ''
+ '<div class="col-12 col-sm-6 col-lg-4'
+ (isLiked ? ' is-liked' : '')
+ '"'
+ (isLiked ? (' data-liked-at="' + String(likedAt) + '"') : '')
+ '>'
+ ' <div class="card-heb' + (isLiked ? ' is-active' : '') + '" data-token="' + token + '">'
+ ' <div class="cover">'
+ ' <img class="heb-img" data-token="' + token + '" alt="" loading="lazy"'
+ ' src="' + escapeHtml(imgSrc) + '"'
+ " onerror=\"this.onerror=null; this.src='" + PLACEHOLDER_IMG + "';\" />"
+ ' ' + photosBadgeHtml
+ ' <div class="top-badges">'
+ ' <span class="badge-soft"><i class="bi bi-signpost-2 me-1"></i>' + escapeHtml(r.distance) + ' km</span>'
+ ' <div class="d-flex align-items-center gap-2">'
+ ' ' + (ratingHtml || '')
+ ' <div class="like' + (isLiked ? ' liked' : '') + '" title="Favori"><i class="bi ' + (isLiked ? 'bi-heart-fill' : 'bi-heart') + '"></i></div>'
+ ' </div>'
+ ' </div>'
+ ' </div>'
+ ' <div class="card-bodyy">'
+ ' <div class="title-row">'
+ ' <div class="title">' + name + '</div>'
+ type
+ ' </div>'
+ ' <div class="sub"><i class="bi bi-geo-alt me-1"></i>' + ville + ' ' + pc + '</div>'
+ (factsHtml || '')
+ (equipAll || '')
+ (actAll || '')
+ ' <div class="tags">'
+ ' ' + catHtml
+ ' </div>'
+ ' <div class="price">'
+ ' <div>' + priceHtml + '</div>'
+ ' <button type="button" class="btn btn-ghost btn-sm btnContact"'
+ ' data-token="' + token + '"'
+ ' data-name="' + name + '"'
+ ' data-ville="' + ville + '"'
+ ' data-country="' + cc + '"'
+ ' data-capacite="' + escapeHtml((r.donnees && r.donnees.capacite) ? r.donnees.capacite : '') + '"'
+ ' data-chambres="' + escapeHtml((r.donnees && r.donnees.nb_chambres) ? r.donnees.nb_chambres : '') + '"'
+ ' data-sdb="' + escapeHtml((r.donnees && r.donnees.nb_sdb) ? r.donnees.nb_sdb : '') + '"'
+ ' data-parking="' + escapeHtml(r.parking || '') + '"'
+ ' data-type="' + escapeHtml(r.type_hebergement || '') + '"'
+ ' data-prix="' + escapeHtml(r.tarif_nuit || '') + '">'
+ ' Contacter l’hôte'
+ ' </button>'
+ ' </div>'
+ (dbgHtml ? (' ' + dbgHtml) : '')
+ ' </div>'
+ ' </div>'
+ '</div>';
}
function renderResultsInto(results, targetEl, limit = 10){
const arr = Array.isArray(results) ? results.slice(0, limit) : [];
if (!arr.length){
targetEl.innerHTML = `<div class="col-12"><div class="alert alert-light border">Aucun hébergement trouvé.</div></div>`;
// Mémorise l'ordre initial des colonnes (position d'origine)
[...targetEl.children].forEach((col, idx) => {
col.dataset.order = String(idx);
});
return;
}
targetEl.innerHTML = arr.map(cardHtml).join('');
// Mémorise l'ordre initial des colonnes (position d'origine)
[...targetEl.children].forEach((col, idx) => {
col.dataset.order = String(idx);
});
targetEl.querySelectorAll('.like').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation(); // évite click carte / map
toggleLike(btn);
});
});
syncContactButtons();
}
function renderResults(results){
renderResultsInto(results, grid, 500);
}
// Scroll minimal : uniquement si l'élément sort de l'écran
function ensureVisibleIfNeeded(el, offset = 120, padding = 16){
if (!el) return;
const r = el.getBoundingClientRect();
// déjà suffisamment visible -> on ne fait rien
const topLimit = offset;
const botLimit = window.innerHeight - padding;
if (r.top < topLimit){
const y = window.scrollY + (r.top - topLimit);
window.scrollTo({ top: y, behavior: 'smooth' });
return;
}
if (r.bottom > botLimit){
const y = window.scrollY + (r.bottom - botLimit);
window.scrollTo({ top: y, behavior: 'smooth' });
}
}
// FLIP animation pour rendre le réordonnancement lisible
function flipReorder(wrap, mutateFn, opts = {}){
if (!wrap || typeof mutateFn !== 'function') return;
const duration = Number(opts.duration ?? 260);
const easing = String(opts.easing ?? 'cubic-bezier(.2,.8,.2,1)');
const before = new Map();
[...wrap.children].forEach(el => before.set(el, el.getBoundingClientRect()));
mutateFn();
const afterEls = [...wrap.children];
afterEls.forEach(el => {
const a = el.getBoundingClientRect();
const b = before.get(el);
if (!b) return;
const dx = b.left - a.left;
const dy = b.top - a.top;
if (dx || dy){
el.animate(
[
{ transform: `translate(${dx}px, ${dy}px)` },
{ transform: 'translate(0,0)' }
],
{ duration, easing, fill: 'both' }
);
}
});
}
function pulseCard(card){
if (!card) return;
card.classList.remove('pulse');
// force reflow pour relancer l'animation
void card.offsetWidth;
card.classList.add('pulse');
window.setTimeout(() => card.classList.remove('pulse'), 900);
}
function sortCardsInWrap(wrap){
if (!wrap) return;
const cols = [...wrap.children];
cols.sort((a, b) => {
const al = a.classList.contains('is-liked');
const bl = b.classList.contains('is-liked');
// likés d'abord
if (al !== bl) return bl - al;
// si tous les deux likés : le plus récemment liké en premier
if (al && bl){
return Number(b.dataset.likedAt || 0) - Number(a.dataset.likedAt || 0);
}
// sinon : ordre initial
return Number(a.dataset.order || 0) - Number(b.dataset.order || 0);
});
cols.forEach(c => wrap.appendChild(c));
}
/* ----------------------------------------------------------------------------
Autocomplete (prefix JSON)
---------------------------------------------------------------------------- */
let prefixCache = {};
async function loadPrefix(prefix2){
if (!prefix2) return [];
if (prefixCache[prefix2]) return prefixCache[prefix2];
const url = `includes/data/villes_prefix/villes_${prefix2}.json`;
try{
const res = await fetch(url, {cache:'force-cache'});
if (!res.ok) return [];
const data = await res.json();
prefixCache[prefix2] = Array.isArray(data) ? data : [];
return prefixCache[prefix2];
}catch(e){
return [];
}
}
function showSuggestions(items){
if (!suggestBox) {
return;
}
if (!items || !items.length){
suggestBox.style.display = 'none';
suggestBox.innerHTML = '';
return;
}
var html = '';
for (var i = 0; i < items.length; i++){
var item = items[i];
var city = '';
var postalCode = '';
var cc = '';
var value = '';
var label = '';
// Nouveau format JSON objet
if (typeof item === 'object' && item !== null) {
city = String(item.city || '').trim();
postalCode = String(item.postal_code || '').trim();
cc = String(item.country_code || '').trim();
value = String(item.value || (city + ', ' + postalCode + ', ' + cc)).trim();
label = String(item.label || (city + ' (' + postalCode + '), ' + cc)).trim();
}
// Ancien format JSON texte : "Ville, FR"
else {
value = String(item || '').trim();
var parts = value.split(',');
city = (parts[0] || '').trim();
cc = (parts[1] || '').trim();
label = value;
}
html += ''
+ '<div class="item" data-val="' + escapeHtml(value) + '">'
+ ' <div><strong>' + escapeHtml(city) + '</strong></div>'
+ ' <small>' + escapeHtml(postalCode ? postalCode + ' · ' + cc : cc) + '</small>'
+ '</div>';
}
suggestBox.innerHTML = html;
suggestBox.style.display = 'block';
}
/* ----------------------------------------------------------------------------
Photos (lazy caching)
- Appelle ajax/photo_cache.php (direct -> fallback google côté PHP)
---------------------------------------------------------------------------- */
async function cacheMissingPhotos(results){
const queue = (results || [])
.filter(r => (!r.photo_locale || String(r.photo_locale).trim() === '') && r.photo && r.token)
.map(r => ({token: String(r.token), table: 'heb'}));
if (!queue.length) return;
let idx = 0;
const concurrency = 2;
async function worker(){
while (idx < queue.length){
const job = queue[idx++];
try{
const form = new FormData();
form.append('token', job.token);
form.append('table', job.table);
const res = await fetch('ajax/photo_cache.php', {method:'POST', body: form});
const data = await res.json();
if (data.ok && data.url){
let imgEl = null;
try{
imgEl = document.querySelector(`img.heb-img[data-token="${CSS.escape(job.token)}"]`);
}catch(e){
imgEl = Array.from(document.querySelectorAll('img.heb-img'))
.find(x => x.getAttribute('data-token') === job.token) || null;
}
if (imgEl) imgEl.src = toAbsoluteUrl(data.url);
}
}catch(e){}
}
}
await Promise.all(Array.from({length: concurrency}, worker));
}
/* ----------------------------------------------------------------------------
Leaflet Map
---------------------------------------------------------------------------- */
let map = null;
let markersLayer = null;
let markerByToken = new Map();
let mapReady = false;
const MARKER_STYLE_DEFAULT = { color:'#3388ff', fillColor:'#3388ff' };
const MARKER_STYLE_LIKED = { color:'#ff385c', fillColor:'#ff385c' };
let lastResults = [];
function getResultByToken(token){
token = normToken(token);
if (!token) return null;
return (lastResults || []).find(r => normToken(r.token) === token) || null;
}
function initMapOnce(){
if (mapReady) return;
if (typeof L === 'undefined'){
console.error('[MAP] Leaflet non chargé (L undefined)');
return;
}
map = L.map('map', {zoomControl:true, scrollWheelZoom:true});
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap'
}).addTo(map);
markersLayer = L.layerGroup().addTo(map);
mapReady = true;
}
function clearMap(){
if (!mapReady) return;
markersLayer.clearLayers();
markerByToken.clear();
}
function updateMap(results){
if (!mapReady) return;
clearMap();
const bounds = [];
let added = 0;
(results || []).forEach(r => {
const lat = parseCoord(r.latitude);
const lon = parseCoord(r.longitude);
if (lat === null || lon === null) return;
const token = normToken(r.token);
const title = String(r.name || '');
// si déjà liké, on le crée directement en rouge
const isLiked = likedTokens.has(token);
const m = L.circleMarker([lat, lon], {
radius: 8,
weight: 2,
fillOpacity: 0.9,
...(isLiked ? MARKER_STYLE_LIKED : MARKER_STYLE_DEFAULT)
}).addTo(markersLayer);
m.bindTooltip(title, {direction:'top', offset:[0,-10], opacity:0.95});
markerByToken.set(token, m);
bounds.push([lat, lon]);
added++;
m.on('click', () => {
const card = document.querySelector(`.card-heb[data-token="${CSS.escape(token)}"]`);
if (card){
card.scrollIntoView({behavior:'smooth', block:'center'});
card.classList.add('is-active');
setTimeout(() => card.classList.remove('is-active'), 900);
}
});
});
console.log('[MAP] markers added:', added);
if (bounds.length){
map.fitBounds(bounds, {padding:[40,40]});
} else {
map.setView([48.8566, 2.3522], 12);
}
setTimeout(() => map.invalidateSize(), 50);
}
function bindCardsToMap(){
document.querySelectorAll('.card-heb[data-token]').forEach(card => {
if (card.dataset.mapBound === '1') return;
card.dataset.mapBound = '1';
const token = card.getAttribute('data-token');
card.addEventListener('mouseenter', () => {
const m = markerByToken.get(token);
if (m) m.setStyle({radius:11});
});
card.addEventListener('mouseleave', () => {
const m = markerByToken.get(token);
if (m) m.setStyle({radius:8});
});
card.addEventListener('click', (e) => {
if (e.target.closest('a')) return;
const m = markerByToken.get(token);
if (m){
map.panTo(m.getLatLng(), {animate:true, duration:0.5});
m.openTooltip();
}
});
});
}
/* ----------------------------------------------------------------------------
Home: Top villes (sections)
---------------------------------------------------------------------------- */
async function loadHomeTop3(){
const home = document.getElementById('homeSections');
if (!home) return;
home.innerHTML = '';
try{
const res = await fetch('ajax/top_villes.php?limit=5', {headers:{'Accept':'application/json'}});
const data = await res.json();
if (!data.ok || !Array.isArray(data.items) || !data.items.length){
home.innerHTML = '';
return;
}
for (const it of data.items){
const ville_pays = it.ville_pays;
const rayon = parseInt(it.rayon_km, 10) || 10;
const section = document.createElement('div');
section.className = 'mb-4';
section.innerHTML = ''
+ '<div class="d-flex align-items-end justify-content-between mb-2 mt-5">'
+ ' <h5 class="fw-bold mb-0">'
+ ' Hébergements autour de <span style="color:var(--accent)">“' + escapeHtml(ville_pays) + '”</span>'
+ ' </h5>'
+ ' <div class="small" style="color:var(--muted)">Rayon ' + escapeHtml(rayon) + ' km</div>'
+ '</div>'
+ '<div class="row g-3" data-home-grid="1">'
+ ' <div class="col-12"><div class="skel" style="height:220px"></div></div>'
+ '</div>';
home.appendChild(section);
const targetRow = section.querySelector('[data-home-grid="1"]');
const url = `ajax/recherche_hebergements.php?ville_pays=${encodeURIComponent(ville_pays)}&rayon_km=${encodeURIComponent(rayon)}`;
const r2 = await fetch(url, {headers:{'Accept':'application/json'}});
const d2 = await r2.json();
const results = (d2 && d2.ok && Array.isArray(d2.results)) ? d2.results : [];
renderResultsInto(results, targetRow, 6);
cacheMissingPhotos(results);
}
}catch(e){
home.innerHTML = '';
}
}
/* ----------------------------------------------------------------------------
Top villes (chips) — recherche directe
---------------------------------------------------------------------------- */
function setRadiusValue(rayon){
const r = parseInt(rayon, 10);
if (!isFinite(r) || r <= 0) return;
if (![...radiusSelect.options].some(o => o.value === String(r))){
const opt = document.createElement('option');
opt.value = String(r);
opt.textContent = `${r} km`;
radiusSelect.appendChild(opt);
}
radiusSelect.value = String(r);
}
async function loadTopVilles(){
const box = $('#topVilles');
if (!box) return;
box.innerHTML = '';
try{
const res = await fetch('ajax/top_villes.php?limit=15', {headers:{'Accept':'application/json'}});
const data = await res.json();
if (!data.ok || !Array.isArray(data.items) || !data.items.length) return;
box.innerHTML = data.items.map(it => {
const v = String(it.ville_pays || '');
const parts = v.split(',');
const city = (parts[0] || '').trim();
const postal = (parts[1] || '').trim();
const cc = (parts[2] || '').trim();
const label = postal
? `${city} (${postal}), ${cc}`
: v;
return `
<button class="chip" type="button"
data-ville="${escapeHtml(v)}"
data-rayon="${parseInt(it.rayon_km,10) || 10}">
<i class="bi bi-geo-alt me-1"></i>${escapeHtml(label)}
<span class="ms-1" style="color:var(--muted)">(${parseInt(it.compteur,10) || 0})</span>
</button>
`;
}).join('');
box.querySelectorAll('button[data-ville]').forEach(b => {
b.addEventListener('click', () => {
input.value = b.getAttribute('data-ville');
setRadiusValue(b.getAttribute('data-rayon'));
doSearch({forceLive:false});
// Mobile: remonter en haut
if (window.matchMedia('(max-width: 768px)').matches) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
});
}catch(e){}
}
/* ----------------------------------------------------------------------------
Search (cache BDD)
---------------------------------------------------------------------------- */
async function doSearch(opts = {forceLive:false}){
clearError();
setModeResults();
const ville_pays = input.value.trim();
const rayon_km = radiusSelect.value;
const nombre_voyageurs = guestsSelect ? parseInt(guestsSelect.value, 10) || 0 : 0;
if (!ville_pays){
statusEl.textContent = "Tape une ville + pays.";
input.focus();
return;
}
btnClearCache.style.display = 'none';
if (homeSections) homeSections.innerHTML = '';
setLoading();
statusEl.textContent = "Recherche…";
metaEl.textContent = "—";
// Construire l'URL avec les filtres
const params = new URLSearchParams();
params.set('ville_pays', ville_pays);
params.set('rayon_km', rayon_km);
params.set('nombre_voyageurs', nombre_voyageurs);
params.set('debug', '1');
if (opts.forceLive) params.set('force_live', '1');
const types = getActiveTypes();
if (types.length) params.set('types', types.join(','));
if (filters.piscine) params.set('piscine', '1');
const url = `ajax/recherche_hebergements.php?${params.toString()}`;
const res = await fetch(url, {headers:{'Accept':'application/json'}});
const data = await res.json();
const debugBox = document.getElementById('debugRecherche');
console.log('****');
if (debugBox) {
console.log('debugbox');
debugBox.style.display = 'block';
debugBox.innerHTML = `
<strong>Debug recherche</strong><br>
ville_pays : ${escapeHtml(data.debug_recherche.ville_pays)}<br>
rayon_km : ${escapeHtml(data.debug_recherche.rayon_km)}<br>
nombre_voyageurs : ${escapeHtml(data.debug_recherche.nombre_voyageurs)}<br>
<hr>
<strong>Requête cache :</strong>
<pre style="white-space:pre-wrap;margin-top:8px;">${escapeHtml(data.debug_recherche.requete_insert_cache)}</pre>
`;
}
if (!data.ok){
grid.innerHTML = '';
statusEl.textContent = "Erreur";
metaEl.textContent = "—";
setError(data.error || "Erreur inconnue.");
return;
}
statusEl.textContent = data.from_cache ? "Cache ✅" : "Live ✅";
metaEl.textContent = `${data.count} résultat(s) • Rayon ${data.rayon_km} km • ${data.ville}, ${data.pays}`;
if (data.from_cache){
btnClearCache.style.display = 'inline-block';
}
lastResults = Array.isArray(data.results) ? data.results : [];
renderResults(lastResults);
console.log('[LIKES size]', likedTokens.size, '[sample]', [...likedTokens].slice(0,5));
cacheMissingPhotos(lastResults);
if (splitEl.classList.contains('is-map')){
initMapOnce();
updateMap(lastResults);
bindCardsToMap();
}
}
//gestion de modale photos
let photosModal = null;
function openPhotosModal(token){
const item = getResultByToken(token);
if (!item) return;
const modalEl = document.getElementById('photosModal');
const titleEl = document.getElementById('photosModalTitle');
const metaEl = document.getElementById('photosModalMeta');
const mainEl = document.getElementById('photosModalMain');
const thumbsEl = document.getElementById('photosModalThumbs');
if (!modalEl || !titleEl || !metaEl || !mainEl || !thumbsEl) return;
if (!photosModal) {
photosModal = new bootstrap.Modal(modalEl);
}
const mainPhoto = (item.photo_src && String(item.photo_src).trim() !== '')
? toAbsoluteUrl(item.photo_src)
: PLACEHOLDER_IMG;
const extraPhotos = normalizePhotosLocal(item.photos_local_json).map(buildLocalPhotoUrl);
const allPhotos = [mainPhoto];
extraPhotos.forEach(url => {
if (url && !allPhotos.includes(url)) {
allPhotos.push(url);
}
});
titleEl.textContent = item.name || 'Photos';
metaEl.textContent = (item.ville || '') + ' • ' + allPhotos.length + ' photo' + (allPhotos.length > 1 ? 's' : '');
mainEl.innerHTML = ''
+ '<div class="photos-modal-stage">'
+ ' <img id="photosModalCurrent"'
+ ' src="' + escapeHtml(allPhotos[0]) + '"'
+ ' alt=""'
+ ' class="photos-modal-current">'
+ '</div>';
thumbsEl.innerHTML = allPhotos.map((url, idx) => {
const border = idx === 0 ? '#111' : '#e5e7eb';
return ''
+ '<button type="button" class="photo-thumb-btn" data-photo-url="' + escapeHtml(url) + '"'
+ ' style="border:0; background:none; padding:0;">'
+ ' <img src="' + escapeHtml(url) + '" alt=""'
+ ' style="width:110px; height:80px; object-fit:cover; border-radius:10px; border:2px solid ' + border + ';">'
+ '</button>';
}).join('');
photosModal.show();
}
/* ----------------------------------------------------------------------------
Contact modal
---------------------------------------------------------------------------- */
let contactModal = null;
let isContactDocked = true;
function setContactDocked(docked){
const el = document.getElementById('contactModal');
if (!el) return;
isContactDocked = !!docked;
el.classList.toggle('is-docked', isContactDocked);
// Icône du toggle (expand vs minimize)
const icon = el.querySelector('#btnDockToggle i');
if (icon){
icon.className = isContactDocked ? 'bi bi-arrows-angle-expand' : 'bi bi-dash-lg';
}
// Backdrop : rendu / interaction selon état docké
const backdrop = document.querySelector('.modal-backdrop');
if (backdrop){
backdrop.classList.toggle('docked-backdrop', isContactDocked);
}
// Gestion du scroll body :
// - En mode docké, la page doit rester scrollable.
// - En mode normal (non docké), on garde le comportement modal standard.
const isOpen = el.classList.contains('show');
if (!isOpen){
// Sécurité : si la modale est fermée, on s'assure que le body n'est pas locké.
document.body.classList.remove('modal-open');
document.body.style.removeProperty('overflow');
document.body.style.removeProperty('padding-right');
return;
}
if (isContactDocked){
document.body.classList.remove('modal-open');
document.body.style.removeProperty('overflow');
document.body.style.removeProperty('padding-right');
} else {
document.body.classList.add('modal-open');
document.body.style.overflow = 'hidden';
}
}
function initContactDockUI(){
const el = document.getElementById('contactModal');
if (!el) return;
// Toggle button
el.querySelector('#btnDockToggle')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
setContactDocked(!isContactDocked);
});
// Click header : si docké -> ouvre (comme un chat)
el.querySelector('.modal-header')?.addEventListener('click', () => {
if (isContactDocked) setContactDocked(false);
});
// Quand la modale s’ouvre : dock par défaut
el.addEventListener('shown.bs.modal', () => {
setContactDocked(true);
});
// Quand elle se ferme : reset
el.addEventListener('hidden.bs.modal', () => {
setContactDocked(false);
});
}
initContactDockUI();
let contactSelected = []; // [{token,name,ville,country}]
function isContactModalOpen(){
const el = document.getElementById('contactModal');
return el && el.classList.contains('show');
}
function normalizeToken(t){
return String(t || '').trim();
}
function renderContactSelection(){
const listEl = document.getElementById('contactSelectionList');
const tokensWrap = document.getElementById('contactTokensWrap');
const titleEl = document.getElementById('contactTitle');
const jsonEl = document.getElementById('contactSelectionJson');
if (jsonEl) jsonEl.value = JSON.stringify(contactSelected || []);
if (!listEl || !tokensWrap) return;
// Liste visible
if (!contactSelected.length){
listEl.innerHTML = `<div class="small" style="color:var(--muted)">Aucun hébergement sélectionné</div>`;
} else {
listEl.innerHTML = contactSelected.map(it => `
<div class="contact-pill" data-token="${it.token}">
<div>
<div class="fw-bold">${it.name || 'Hébergement'}</div>
<div class="meta">${it.ville || ''}${it.country ? ', ' + it.country : ''}</div>
</div>
<button type="button" class="remove" data-remove-token="${it.token}" aria-label="Supprimer">
<i class="bi bi-trash"></i>
</button>
</div>
`).join('');
}
// Hidden inputs tokens[]
tokensWrap.innerHTML = contactSelected
.map(it => `<input type="hidden" name="tokens[]" value="${it.token}">`)
.join('');
// Titre avec compteur (facultatif)
if (titleEl){
const n = contactSelected.length;
titleEl.textContent = n ? `${n} hébergement${n > 1 ? 's' : ''} à contacter` : '0 hébergement à contacter';
}
}
function closeContactModalIfEmpty(){
if (contactSelected.length) return;
const el = document.getElementById('contactModal');
if (!el) return;
// Retire l'état docké (et libère le body si besoin)
setContactDocked(false);
// Ferme la modale si elle est ouverte
const inst = bootstrap.Modal.getInstance(el) || contactModal;
if (inst && el.classList.contains('show')){
inst.hide();
}
}
function addContactToModal(data){
const token = normalizeToken(data.token);
if (!token) return;
// éviter les doublons
const exists = contactSelected.some(x => x.token === token);
if (!exists){
contactSelected.push({
token,
name: data.name || 'Hébergement',
ville: data.ville || '',
country: data.country || '',
capacite: data.capacite || '',
chambres: data.chambres || '',
sdb: data.sdb || '',
parking: data.parking || '',
type: data.type || '',
prix: data.prix || ''
});
} else {
// si déjà présent, on peut mettre à jour le nom/meta au cas où
contactSelected = contactSelected.map(x => x.token === token ? ({
...x,
name: data.name || x.name,
ville: data.ville || x.ville,
country: data.country || x.country,
capacite: data.capacite || x.capacite,
chambres: data.chambres || x.chambres,
sdb: data.sdb || x.sdb,
parking: data.parking || x.parking,
type: data.type || x.type,
prix: data.prix || x.prix
}) : x);
}
renderContactSelection();
syncContactButtons();
// Petit feedback visuel sur la pill ajoutée (optionnel)
const pill = document.querySelector(`#contactSelectionList .contact-pill[data-token="${token}"]`);
if (pill){
pill.classList.add('pulse');
setTimeout(() => pill.classList.remove('pulse'), 350);
}
}
function openContactModal(data){
const el = document.getElementById('contactModal');
if (!el) return;
if (!contactModal) contactModal = new bootstrap.Modal(el);
// Si la modale n'est pas déjà ouverte : reset du form (mais on garde la sélection)
if (!isContactModalOpen()){
$('#contactForm').reset();
$('#contactSuccess').classList.add('d-none');
// si tu veux repartir de zéro à chaque "nouvelle session", décommente :
// contactSelected = [];
}
// Met à jour la zone meta “globale” (facultatif)
$('#contactMeta').textContent = 'Ajoutez en d\'autres en cliquant sur "contactez l\'hôte"';
$('#contactMeta1').textContent = '';
// Ajoute l'hébergement cliqué
addContactToModal(data);
// Ouvre la modale (dock ou normal selon ton système)
// setContactDocked(true); // si tu veux toujours docké à l'ouverture
setContactDocked(true);
contactModal.show();
}
document.addEventListener('click', (e) => {
const btn = e.target.closest('.btnContact');
if (!btn) return;
const payload = {
token: btn.getAttribute('data-token'),
name: btn.getAttribute('data-name'),
ville: btn.getAttribute('data-ville'),
country: btn.getAttribute('data-country'),
capacite: btn.getAttribute('data-capacite'),
chambres: btn.getAttribute('data-chambres'),
sdb: btn.getAttribute('data-sdb'),
parking: btn.getAttribute('data-parking'),
type: btn.getAttribute('data-type'),
prix: btn.getAttribute('data-prix')
};
const token = String(payload.token || '').trim();
if (!token) return;
const already = contactSelected.some(x => x.token === token);
if (already){
// toggle off (désélection)
contactSelected = contactSelected.filter(x => x.token !== token);
renderContactSelection();
syncContactButtons();
closeContactModalIfEmpty();
return;
}
// toggle on (sélection) + ouvre la modale
openContactModal(payload);
});
document.getElementById('contactForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const fd = new FormData(form);
const tokens = fd.getAll('tokens[]');
if (!tokens.length){
alert("Merci de sélectionner au moins un hébergement.");
return;
}
const btn = form.querySelector('button[type="submit"]');
const old = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Envoi...';
try{
const res = await fetch('ajax/contact_host.php', {method:'POST', body: fd, headers:{'Accept':'application/json'}});
const data = await res.json();
if (!data.ok){
alert(data.error || "Erreur lors de l'envoi.");
btn.disabled = false;
btn.innerHTML = old;
return;
}
document.getElementById('contactSuccess').classList.remove('d-none');
// Reset sélection après envoi
contactSelected = [];
renderContactSelection();
syncContactButtons();
setTimeout(() => {
const el = document.getElementById('contactModal');
const inst = bootstrap.Modal.getInstance(el);
if (inst) inst.hide();
}, 700);
btn.innerHTML = '<i class="bi bi-check2 me-1"></i>Envoyé';
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = old;
}, 1200);
}catch(err){
alert("Erreur réseau.");
btn.disabled = false;
btn.innerHTML = old;
}
});
document.getElementById('contactModal')?.addEventListener('hidden.bs.modal', () => {
document.getElementById('contactForm')?.reset();
document.getElementById('contactSuccess')?.classList.add('d-none');
});
document.getElementById('contactModal')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-remove-token]');
if (!btn) return;
const token = btn.getAttribute('data-remove-token');
contactSelected = contactSelected.filter(x => x.token !== token);
renderContactSelection();
syncContactButtons();
closeContactModalIfEmpty();
});
/* ----------------------------------------------------------------------------
Events
---------------------------------------------------------------------------- */
$('#btnSearch')?.addEventListener('click', () => doSearch({forceLive:false}));
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action="open-photos"]');
if (!btn) return;
console.log('clic photo détecté', btn.getAttribute('data-token'));
e.preventDefault();
e.stopPropagation();
const token = btn.getAttribute('data-token');
if (!token) return;
openPhotosModal(token);
});
document.addEventListener('click', (e) => {
const btn = e.target.closest('.photo-thumb-btn');
if (!btn) return;
const url = btn.getAttribute('data-photo-url');
const current = document.getElementById('photosModalCurrent');
if (!current || !url) return;
current.src = url;
document.querySelectorAll('.photo-thumb-btn img').forEach(img => {
img.style.border = '2px solid #e5e7eb';
});
const img = btn.querySelector('img');
if (img) {
img.style.border = '2px solid #111';
}
});
// Filtres (Type + Piscine)
setFilterBtnState();
filtersBar?.addEventListener('click', (e) => {
const btn = e.target.closest('.js-filter');
if (!btn) return;
const kind = btn.getAttribute('data-filter');
if (kind === 'reset'){
resetFilters();
} else if (kind === 'type'){
const t = btn.getAttribute('data-value');
if (filters.types.has(t)) filters.types.delete(t);
else filters.types.add(t);
setFilterBtnState();
} else if (kind === 'piscine'){
filters.piscine = !filters.piscine;
setFilterBtnState();
}
// Relance la recherche seulement si une ville est déjà saisie
const ville = (input?.value || '').trim();
if (ville) doSearch({forceLive:false});
});
input?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doSearch({forceLive:false});
});
input?.addEventListener('input', async () => {
const val = input.value.trim();
const p2 = normalizeForPrefix2(val);
if (!p2){
showSuggestions([]);
return;
}
const list = await loadPrefix(p2);
const q = normalizeSearch(val);
const filtered = list.filter(item => {
if (typeof item === 'object' && item !== null) {
const city = normalizeSearch(item.city || '');
const postalCode = String(item.postal_code || '');
const countryCode = String(item.country_code || '').toLowerCase();
const label = normalizeSearch(item.label || '');
const value = normalizeSearch(item.value || '');
return (
city.startsWith(q) ||
label.startsWith(q) ||
value.startsWith(q) ||
postalCode.startsWith(q) ||
(city + ' ' + postalCode + ' ' + countryCode).includes(q)
);
}
// ancien format
return normalizeSearch(item).startsWith(q);
}).slice(0, 12);
showSuggestions(filtered);
});
suggestBox?.addEventListener('click', (e) => {
const item = e.target.closest('.item');
if (!item) return;
input.value = item.getAttribute('data-val');
showSuggestions([]);
input.focus();
});
document.addEventListener('click', (e) => {
if (!e.target.closest('#suggestBox') && !e.target.closest('#qCity')) showSuggestions([]);
});
btnClearCache?.addEventListener('click', async () => {
const ville_pays = input.value.trim();
const rayon_km = radiusSelect.value;
const nombre_voyageurs = guestsSelect ? parseInt(guestsSelect.value, 10) || 0 : 0;
if (!ville_pays) return;
statusEl.textContent = "Suppression du cache…";
btnClearCache.style.display = 'none';
const form = new FormData();
form.append('ville_pays', ville_pays);
form.append('rayon_km', rayon_km);
const res = await fetch('ajax/cache_clear.php', {method:'POST', body: form});
const data = await res.json();
if (!data.ok){
statusEl.textContent = "Erreur cache";
setError(data.error || "Impossible de vider le cache.");
return;
}
await doSearch({forceLive:true});
});
btnToggleMap?.addEventListener('click', () => {
const on = splitEl.classList.toggle('is-map');
btnToggleMap.innerHTML = on
? '<i class="bi bi-list me-1"></i>Liste'
: '<i class="bi bi-map me-1"></i>Carte';
if (on){
initMapOnce();
updateMap(lastResults);
bindCardsToMap();
}
setTimeout(() => map && map.invalidateSize(), 80);
});
btnShuffle?.addEventListener('click', () => {
const items = Array.from(grid.children);
for (let i = items.length - 1; i > 0; i--){
const j = Math.floor(Math.random() * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
items.forEach(x => grid.appendChild(x));
statusEl.textContent = "Cartes mélangées ✔";
});
/* UX: sélection rapide du champ ville */
input?.addEventListener('focus', () => setTimeout(() => input.select(), 0));
input?.addEventListener('click', () => setTimeout(() => input.select(), 0));
/* ----------------------------------------------------------------------------
Init
---------------------------------------------------------------------------- */
window.addEventListener('DOMContentLoaded', () => {
if (input) {
input.value = '';
showSuggestions([]);
setModeHome();
loadTopVilles();
loadHomeTop3();
}
const modalEl = document.getElementById('contactModal');
if (modalEl) {
modalEl.addEventListener('shown.bs.modal', () => {
initContactDates(modalEl);
});
} else {
initContactDates(document);
}
});