| Current Path : /home/happyrenas/find.myreco.online/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/js/find_myreco_js_refactor.js |
/* =============================================================================
Find.MyReco — JS refactorisé
1) find-recherche.js
- Configuration
- Helpers
- Etat global
- Filtres
- Cards / favoris
- Autocomplete villes
- Cache photos
- Carte Leaflet
- Home / top villes
- Recherche API
- Events principaux
2) find-modales.js
- Modale photos
- Modale contact
- Sélection des hébergements à contacter
- Envoi formulaire contact
Important :
- Chargement find-recherche.js AVANT find-modales.js.
- Les fonctions globales utiles aux modales sont exposées via window.FindMyReco.
============================================================================= */
/* =============================================================================
FICHIER 1 — find-recherche.js
============================================================================= */
(() => {
'use strict';
/* --------------------------------------------------------------------------
Configuration
-------------------------------------------------------------------------- */
const CONFIG = {
debugPhotos: false,
siteBaseUrl: 'https://www.myreco.online',
placeholderImg: 'https://find.myreco.online/img/placeholder.svg',
maxResults: 500,
maxResultsHome: 6,
autocompletePath: 'includes/data/villes_prefix',
photoCacheUrl: 'ajax/photo_cache.php',
searchUrl: 'ajax/recherche_hebergements.php',
topVillesUrl: 'ajax/top_villes.php',
cacheClearUrl: 'ajax/cache_clear.php'
};
const MARKER_STYLE_DEFAULT = { color: '#3388ff', fillColor: '#3388ff' };
const MARKER_STYLE_LIKED = { color: '#ff385c', fillColor: '#ff385c' };
/* --------------------------------------------------------------------------
Helpers DOM / chaînes / formatage
-------------------------------------------------------------------------- */
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
}[char]));
}
function normToken(token) {
return String(token ?? '').trim();
}
function toAbsoluteUrl(url) {
const cleanUrl = String(url ?? '').trim();
if (!cleanUrl) return '';
if (cleanUrl.startsWith('http://') || cleanUrl.startsWith('https://')) return cleanUrl;
if (cleanUrl.startsWith('//')) return `https:${cleanUrl}`;
if (cleanUrl.startsWith('/')) return `${CONFIG.siteBaseUrl}${cleanUrl}`;
return `${CONFIG.siteBaseUrl}/${cleanUrl}`;
}
function buildLocalPhotoUrl(relativePath) {
const rel = String(relativePath ?? '').trim();
return rel ? `${CONFIG.siteBaseUrl}/upload/hebergement_multiple/${rel}` : '';
}
function normalizePhotosLocal(value) {
return Array.isArray(value)
? value.map((item) => String(item ?? '').trim()).filter(Boolean)
: [];
}
function parseCoord(value) {
const number = parseFloat(String(value ?? '').replace(',', '.'));
return Number.isFinite(number) ? number : null;
}
function formatRating(rating) {
return rating;
}
function formatReviews(reviews) {
const number = parseInt(reviews, 10);
return Number.isFinite(number) && number > 0 ? number.toLocaleString('fr-FR') : '';
}
function fixBrokenUnicode(value) {
// Corrige les chaînes du type "Pu00e9tanque" -> "Pétanque".
return String(value ?? '').replace(/u([0-9a-fA-F]{4})/g, (_, hex) => {
return String.fromCharCode(parseInt(hex, 16));
});
}
function normalizeList(value) {
return Array.isArray(value)
? value.map((item) => fixBrokenUnicode(String(item ?? '')).trim()).filter(Boolean)
: [];
}
function normalizeSearch(value) {
if (!value) return '';
return String(value)
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[-'’]/g, ' ')
.replace(/[^a-z0-9 ]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function normalizeForPrefix2(value) {
const city = (String(value ?? '').split(',')[0] || '').trim().toLowerCase();
if (city.length < 2) return '';
return city
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z]/g, '')
.slice(0, 2);
}
function safeCssEscape(value) {
if (window.CSS && typeof CSS.escape === 'function') return CSS.escape(value);
return String(value).replace(/"/g, '\\"');
}
function buildQueryString(paramsObject) {
const params = new URLSearchParams();
Object.entries(paramsObject).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
params.set(key, value);
}
});
return params.toString();
}
async function fetchJson(url, options = {}) {
const response = await fetch(url, {
headers: { Accept: 'application/json', ...(options.headers || {}) },
...options
});
return response.json();
}
/* --------------------------------------------------------------------------
Références DOM
-------------------------------------------------------------------------- */
const dom = {
input: $('#qCity'),
suggestBox: $('#suggestBox'),
grid: $('#grid'),
status: $('#status'),
meta: $('#meta'),
errorBox: $('#errorBox'),
errorMsg: $('#errorMsg'),
btnClearCache: $('#btnClearCache'),
radiusSelect: $('#qRadius'),
guestsSelect: $('#qGuests'),
split: $('#resultsSplit'),
btnToggleMap: $('#btnToggleMap'),
btnShuffle: $('#btnShuffle'),
filtersBar: $('#filtersBar'),
resultsBlock: $('#resultsBlock'),
homeSections: $('#homeSections'),
topVilles: $('#topVilles'),
debugRecherche: $('#debugRecherche'),
btnSearch: $('#btnSearch')
};
/* --------------------------------------------------------------------------
Etat global
-------------------------------------------------------------------------- */
const state = {
lastResults: [],
prefixCache: {},
likedTokens: new Set(),
likedAtByToken: new Map(),
filters: {
types: new Set(),
piscine: false
},
map: {
instance: null,
layer: null,
ready: false,
markerByToken: new Map()
}
};
function getResultByToken(token) {
const cleanToken = normToken(token);
return state.lastResults.find((item) => normToken(item.token) === cleanToken) || null;
}
/* --------------------------------------------------------------------------
UI générale
-------------------------------------------------------------------------- */
function setModeHome() {
if (dom.resultsBlock) dom.resultsBlock.style.display = 'none';
if (dom.homeSections) dom.homeSections.style.display = 'block';
if (dom.grid) dom.grid.innerHTML = '';
if (dom.status) dom.status.textContent = '—';
if (dom.meta) dom.meta.textContent = '—';
if (dom.btnClearCache) dom.btnClearCache.style.display = 'none';
}
function setModeResults() {
if (dom.homeSections) dom.homeSections.style.display = 'none';
if (dom.resultsBlock) dom.resultsBlock.style.display = 'block';
// Carte affichée par défaut en mode résultats.
if (dom.split) dom.split.classList.add('is-map');
if (dom.btnToggleMap) dom.btnToggleMap.innerHTML = '<i class="bi bi-list me-1"></i>Liste';
}
function setError(message) {
if (dom.errorMsg) dom.errorMsg.textContent = message;
if (dom.errorBox) dom.errorBox.style.display = 'block';
setModeHome();
}
function clearError() {
if (dom.errorBox) dom.errorBox.style.display = 'none';
if (dom.errorMsg) dom.errorMsg.textContent = '';
}
function setLoading() {
if (!dom.grid) return;
dom.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>
`;
}
function ensureVisibleIfNeeded(element, offset = 120, padding = 16) {
if (!element) return;
const rect = element.getBoundingClientRect();
const topLimit = offset;
const bottomLimit = window.innerHeight - padding;
if (rect.top < topLimit) {
window.scrollTo({ top: window.scrollY + rect.top - topLimit, behavior: 'smooth' });
return;
}
if (rect.bottom > bottomLimit) {
window.scrollTo({ top: window.scrollY + rect.bottom - bottomLimit, behavior: 'smooth' });
}
}
function pulseCard(card) {
if (!card) return;
card.classList.remove('pulse');
void card.offsetWidth;
card.classList.add('pulse');
window.setTimeout(() => card.classList.remove('pulse'), 900);
}
function flipReorder(container, mutateFn, options = {}) {
if (!container || typeof mutateFn !== 'function') return;
const duration = Number(options.duration ?? 260);
const easing = String(options.easing ?? 'cubic-bezier(.2,.8,.2,1)');
const before = new Map();
Array.from(container.children).forEach((element) => {
before.set(element, element.getBoundingClientRect());
});
mutateFn();
Array.from(container.children).forEach((element) => {
const after = element.getBoundingClientRect();
const previous = before.get(element);
if (!previous) return;
const dx = previous.left - after.left;
const dy = previous.top - after.top;
if (dx || dy) {
element.animate([
{ transform: `translate(${dx}px, ${dy}px)` },
{ transform: 'translate(0,0)' }
], { duration, easing, fill: 'both' });
}
});
}
/* --------------------------------------------------------------------------
Filtres
-------------------------------------------------------------------------- */
function getActiveTypes() {
return Array.from(state.filters.types);
}
function setFilterBtnState() {
if (!dom.filtersBar) return;
$$('.js-filter[data-filter="type"]', dom.filtersBar).forEach((button) => {
const type = button.getAttribute('data-value');
button.classList.toggle('active', state.filters.types.has(type));
});
const piscineButton = $('.js-filter[data-filter="piscine"]', dom.filtersBar);
if (piscineButton) piscineButton.classList.toggle('active', state.filters.piscine);
}
function resetFilters() {
state.filters.types.clear();
state.filters.piscine = false;
setFilterBtnState();
}
/* --------------------------------------------------------------------------
Equipements / mini-pills / facts
-------------------------------------------------------------------------- */
function renderEquipementsChips(equipements, max = 3) {
const list = normalizeList(equipements);
if (!list.length) return '';
const shown = list.slice(0, max);
const more = list.length - shown.length;
const chips = shown.map((label) => `<span class="chip-mini">${escapeHtml(label)}</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>`;
}
function buildParkingHtml(parking) {
const value = String(parking ?? '').toLowerCase();
let label = '—';
let icon = 'bi bi-p-circle';
let className = 'mini-pill';
if (!value || value.includes('aucun')) {
label = 'Pas de parking';
icon = 'bi bi-ban';
className += ' is-muted';
} else if (value.includes('ouvert')) {
label = 'Parking ouvert';
icon = 'bi bi-p-circle-fill';
} else if (value.includes('ferme') || value.includes('fermé')) {
label = 'Parking fermé';
icon = 'bi bi-p-square-fill';
} else {
label = parking;
}
return `<span class="${className}"><i class="${icon}"></i><span class="lbl">${escapeHtml(label)}</span></span>`;
}
function buildFactsHtml(result) {
const data = result.donnees && typeof result.donnees === 'object' ? result.donnees : {};
const capacite = parseInt(data.capacite, 10);
const chambres = parseInt(data.nb_chambres, 10);
const sdb = parseInt(data.nb_sdb, 10);
const items = [];
if (Number.isFinite(capacite) && capacite > 0) {
items.push(`<span class="mini-pill"><i class="bi bi-people-fill"></i><span class="lbl">${capacite} pers.</span></span>`);
}
if (Number.isFinite(chambres) && chambres > 0) {
items.push(`<span class="mini-pill"><i class="bi bi-door-closed-fill"></i><span class="lbl">${chambres} ch.</span></span>`);
}
if (Number.isFinite(sdb) && sdb > 0) {
items.push(`<span class="mini-pill"><i class="bi bi-droplet-fill"></i><span class="lbl">${sdb} sdb</span></span>`);
}
items.push(buildParkingHtml(result.parking));
return `<div class="facts-row">${items.join('')}</div>`;
}
function iconForItem(label) {
const text = String(label || '').toLowerCase();
if (text.includes('piscine')) return 'bi bi-water';
if (text.includes('jacuzzi')) return 'bi bi-droplet';
if (text.includes('hammam')) return 'bi bi-cloud-haze2';
if (text.includes('barbecue') || text.includes('plancha')) return 'bi bi-fire';
if (text.includes('ping')) return 'bi bi-circle';
if (text.includes('pétanque') || text.includes('petanque')) return 'bi bi-bullseye';
if (text.includes('babyfoot')) return 'bi bi-controller';
if (text.includes('billard')) return 'bi bi-record-circle';
if (text.includes('fléchette') || text.includes('flechette')) return 'bi bi-bullseye';
if (text.includes('raquette')) return 'bi bi-activity';
if (text.includes('toboggan')) return 'bi bi-water';
return 'bi bi-check2-circle';
}
function renderMiniPills(items, kind) {
const list = normalizeList(items);
if (!list.length) return '';
const title = kind === 'act' ? 'Activités' : 'Équipements';
const pills = list.map((label) => `
<span class="mini-pill">
<i class="${iconForItem(label)}"></i>
<span class="lbl">${escapeHtml(label)}</span>
</span>
`).join('');
return `
<div class="mini-line">
<div class="mini-title">${title}</div>
<div class="mini-wrap">${pills}</div>
</div>
`;
}
/* --------------------------------------------------------------------------
Cards / favoris
-------------------------------------------------------------------------- */
function buildRatingHtml(result) {
const ratingText = formatRating(result.rating);
const reviewsText = formatReviews(result.reviews);
if (ratingText && reviewsText) {
return `
<span class="badge-soft">
<i class="bi bi-star-fill" style="color:#ff385c"></i>
${escapeHtml(ratingText)}
<span style="color:var(--muted); font-weight:700;">(${escapeHtml(reviewsText)})</span>
</span>
`;
}
if (ratingText) {
return `
<span class="badge-soft">
<i class="bi bi-star-fill" style="color:#ff385c"></i>${escapeHtml(ratingText)}
</span>
`;
}
return '';
}
function buildDebugHtml(result) {
if (!CONFIG.debugPhotos) return '';
return `
<div class="debug-box">
<div><strong>token:</strong> ${escapeHtml(String(result.token ?? ''))}</div>
<div><strong>photo:</strong> ${escapeHtml(String(result.photo ?? '').slice(0, 120))}</div>
<div><strong>photo_locale:</strong> ${escapeHtml(String(result.photo_locale ?? '').slice(0, 120))}</div>
<div><strong>photo_src:</strong> ${escapeHtml(String(result.photo_src ?? '').slice(0, 120))}</div>
<div><strong>local_exists:</strong> ${result.photo_local_exists ? '✅ true' : '❌ false'}</div>
</div>
`;
}
function cardHtml(result) {
const rawToken = normToken(result.token);
const token = escapeHtml(rawToken);
const isLiked = state.likedTokens.has(rawToken);
const likedAt = state.likedAtByToken.get(rawToken) || 0;
const imgSrc = result.photo_src && String(result.photo_src).trim() !== ''
? toAbsoluteUrl(result.photo_src)
: CONFIG.placeholderImg;
const extraPhotos = normalizePhotosLocal(result.photos_local_json);
const photosBadgeHtml = extraPhotos.length
? `
<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>
`
: '';
const name = escapeHtml(result.name);
const ville = escapeHtml(result.ville);
const postalCode = result.postal_code ? `(${escapeHtml(result.postal_code)})` : '';
const type = result.type_hebergement ? `<span class="mini-pill">${escapeHtml(result.type_hebergement)}</span>` : '';
const price = parseFloat(result.tarif_nuit || 0) > 0 ? `${escapeHtml(result.tarif_nuit)}€` : '';
const priceHtml = price
? `<strong>${price}</strong> <span style="color:var(--muted)">/ nuit</span>`
: '<span style="color:var(--muted)">—</span>';
const data = result.donnees && typeof result.donnees === 'object' ? result.donnees : {};
return `
<div class="col-12 col-sm-6 col-lg-4${isLiked ? ' is-liked' : ''}" ${isLiked ? `data-liked-at="${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='${CONFIG.placeholderImg}';" />
${photosBadgeHtml}
<div class="top-badges">
<span class="badge-soft"><i class="bi bi-signpost-2 me-1"></i>${escapeHtml(result.distance)} km</span>
<div class="d-flex align-items-center gap-2">
${buildRatingHtml(result)}
<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} ${postalCode}</div>
${buildFactsHtml(result)}
${renderMiniPills(result.equipements, 'equip')}
${renderMiniPills(result.activites, 'act')}
<div class="tags"></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=""
data-capacite="${escapeHtml(data.capacite || '')}"
data-chambres="${escapeHtml(data.nb_chambres || '')}"
data-sdb="${escapeHtml(data.nb_sdb || '')}"
data-parking="${escapeHtml(result.parking || '')}"
data-type="${escapeHtml(result.type_hebergement || '')}"
data-prix="${escapeHtml(result.tarif_nuit || '')}">
Contacter l’hôte
</button>
</div>
${buildDebugHtml(result)}
</div>
</div>
</div>
`;
}
function sortCardsInWrap(container) {
if (!container) return;
const columns = Array.from(container.children);
columns.sort((a, b) => {
const aLiked = a.classList.contains('is-liked');
const bLiked = b.classList.contains('is-liked');
if (aLiked !== bLiked) return bLiked - aLiked;
if (aLiked && bLiked) {
return Number(b.dataset.likedAt || 0) - Number(a.dataset.likedAt || 0);
}
return Number(a.dataset.order || 0) - Number(b.dataset.order || 0);
});
columns.forEach((column) => container.appendChild(column));
}
function syncMarkerLikeState(token, liked) {
const marker = state.map.markerByToken.get(token);
if (marker) marker.setStyle(liked ? MARKER_STYLE_LIKED : MARKER_STYLE_DEFAULT);
}
function toggleLike(button) {
try {
button.animate([
{ transform: 'scale(1)' },
{ transform: 'scale(1.18)' },
{ transform: 'scale(1)' }
], { duration: 220, easing: 'cubic-bezier(.2,.8,.2,1)' });
} catch (error) {}
button.classList.toggle('liked');
const icon = $('i', button);
const liked = button.classList.contains('liked');
const card = button.closest('.card-heb');
const column = card ? card.parentElement : null;
const container = column ? column.parentElement : null;
const token = card ? normToken(card.getAttribute('data-token')) : '';
if (liked) {
icon?.classList.remove('bi-heart');
icon?.classList.add('bi-heart-fill');
if (token) {
state.likedTokens.add(token);
state.likedAtByToken.set(token, Date.now());
}
if (column) {
column.classList.add('is-liked');
column.dataset.likedAt = String(state.likedAtByToken.get(token) || Date.now());
}
if (card) {
card.classList.add('is-active');
pulseCard(card);
}
} else {
icon?.classList.add('bi-heart');
icon?.classList.remove('bi-heart-fill');
if (token) {
state.likedTokens.delete(token);
state.likedAtByToken.delete(token);
}
if (column) {
column.classList.remove('is-liked');
delete column.dataset.likedAt;
}
if (card) card.classList.remove('is-active');
}
if (token) syncMarkerLikeState(token, liked);
if (container && column) {
const beforeTop = column.getBoundingClientRect().top;
flipReorder(container, () => sortCardsInWrap(container), { duration: 280 });
const afterTop = column.getBoundingClientRect().top;
const delta = afterTop - beforeTop;
if (Math.abs(delta) > 1) window.scrollBy({ top: delta, left: 0 });
requestAnimationFrame(() => ensureVisibleIfNeeded(column, 120, 16));
} else {
requestAnimationFrame(() => ensureVisibleIfNeeded(card || button, 120, 16));
}
}
function renderResultsInto(results, targetElement, limit = 10) {
if (!targetElement) return;
const list = Array.isArray(results) ? results.slice(0, limit) : [];
if (!list.length) {
targetElement.innerHTML = '<div class="col-12"><div class="alert alert-light border">Aucun hébergement trouvé.</div></div>';
return;
}
targetElement.innerHTML = list.map(cardHtml).join('');
Array.from(targetElement.children).forEach((column, index) => {
column.dataset.order = String(index);
});
$$('.like', targetElement).forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
toggleLike(button);
});
});
window.FindMyReco?.syncContactButtons?.();
}
function renderResults(results) {
renderResultsInto(results, dom.grid, CONFIG.maxResults);
}
/* --------------------------------------------------------------------------
Autocomplete villes
-------------------------------------------------------------------------- */
async function loadPrefix(prefix2) {
if (!prefix2) return [];
if (state.prefixCache[prefix2]) return state.prefixCache[prefix2];
try {
const data = await fetchJson(`${CONFIG.autocompletePath}/villes_${prefix2}.json`, { cache: 'force-cache' });
state.prefixCache[prefix2] = Array.isArray(data) ? data : [];
return state.prefixCache[prefix2];
} catch (error) {
return [];
}
}
function buildSuggestionItem(item) {
let city = '';
let postalCode = '';
let countryCode = '';
let value = '';
if (typeof item === 'object' && item !== null) {
city = String(item.city || '').trim();
postalCode = String(item.postal_code || '').trim();
countryCode = String(item.country_code || '').trim();
value = String(item.value || `${city}, ${postalCode}, ${countryCode}`).trim();
} else {
value = String(item || '').trim();
const parts = value.split(',');
city = (parts[0] || '').trim();
countryCode = (parts[1] || '').trim();
}
return `
<div class="item" data-val="${escapeHtml(value)}">
<div><strong>${escapeHtml(city)}</strong></div>
<small>${escapeHtml(postalCode ? `${postalCode} · ${countryCode}` : countryCode)}</small>
</div>
`;
}
function showSuggestions(items) {
if (!dom.suggestBox) return;
if (!items || !items.length) {
dom.suggestBox.style.display = 'none';
dom.suggestBox.innerHTML = '';
return;
}
dom.suggestBox.innerHTML = items.map(buildSuggestionItem).join('');
dom.suggestBox.style.display = 'block';
}
function filterAutocompleteItems(list, value) {
const query = normalizeSearch(value);
return 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 itemValue = normalizeSearch(item.value || '');
return (
city.startsWith(query) ||
label.startsWith(query) ||
itemValue.startsWith(query) ||
postalCode.startsWith(query) ||
`${city} ${postalCode} ${countryCode}`.includes(query)
);
}
return normalizeSearch(item).startsWith(query);
}).slice(0, 12);
}
/* --------------------------------------------------------------------------
Cache photos
-------------------------------------------------------------------------- */
async function cacheMissingPhotos(results) {
const queue = (results || [])
.filter((item) => (!item.photo_locale || String(item.photo_locale).trim() === '') && item.photo && item.token)
.map((item) => ({ token: String(item.token), table: 'heb' }));
if (!queue.length) return;
let index = 0;
const concurrency = 2;
async function worker() {
while (index < queue.length) {
const job = queue[index++];
try {
const form = new FormData();
form.append('token', job.token);
form.append('table', job.table);
const data = await fetchJson(CONFIG.photoCacheUrl, { method: 'POST', body: form });
if (data.ok && data.url) {
const selector = `img.heb-img[data-token="${safeCssEscape(job.token)}"]`;
const image = $(selector) || $$('img.heb-img').find((img) => img.getAttribute('data-token') === job.token);
if (image) image.src = toAbsoluteUrl(data.url);
}
} catch (error) {}
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
}
/* --------------------------------------------------------------------------
Carte Leaflet
-------------------------------------------------------------------------- */
function initMapOnce() {
if (state.map.ready) return;
if (typeof L === 'undefined') {
console.error('[MAP] Leaflet non chargé : L est undefined.');
return;
}
state.map.instance = L.map('map', { zoomControl: true, scrollWheelZoom: true });
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap'
}).addTo(state.map.instance);
state.map.layer = L.layerGroup().addTo(state.map.instance);
state.map.ready = true;
}
function clearMap() {
if (!state.map.ready) return;
state.map.layer.clearLayers();
state.map.markerByToken.clear();
}
function updateMap(results) {
if (!state.map.ready) return;
clearMap();
const bounds = [];
let added = 0;
(results || []).forEach((result) => {
const lat = parseCoord(result.latitude);
const lon = parseCoord(result.longitude);
if (lat === null || lon === null) return;
const token = normToken(result.token);
const isLiked = state.likedTokens.has(token);
const marker = L.circleMarker([lat, lon], {
radius: 8,
weight: 2,
fillOpacity: 0.9,
...(isLiked ? MARKER_STYLE_LIKED : MARKER_STYLE_DEFAULT)
}).addTo(state.map.layer);
marker.bindTooltip(String(result.name || ''), {
direction: 'top',
offset: [0, -10],
opacity: 0.95
});
marker.on('click', () => {
const card = $(`.card-heb[data-token="${safeCssEscape(token)}"]`);
if (!card) return;
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.classList.add('is-active');
setTimeout(() => card.classList.remove('is-active'), 900);
});
state.map.markerByToken.set(token, marker);
bounds.push([lat, lon]);
added++;
});
console.log('[MAP] markers added:', added);
if (bounds.length) {
state.map.instance.fitBounds(bounds, { padding: [40, 40] });
} else {
state.map.instance.setView([48.8566, 2.3522], 12);
}
setTimeout(() => state.map.instance.invalidateSize(), 50);
}
function bindCardsToMap() {
$$('.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 marker = state.map.markerByToken.get(token);
if (marker) marker.setStyle({ radius: 11 });
});
card.addEventListener('mouseleave', () => {
const marker = state.map.markerByToken.get(token);
if (marker) marker.setStyle({ radius: 8 });
});
card.addEventListener('click', (event) => {
if (event.target.closest('a, button, .like, .photos-badge')) return;
const marker = state.map.markerByToken.get(token);
if (!marker || !state.map.instance) return;
state.map.instance.panTo(marker.getLatLng(), { animate: true, duration: 0.5 });
marker.openTooltip();
});
});
}
function refreshMapIfVisible() {
if (!dom.split?.classList.contains('is-map')) return;
initMapOnce();
updateMap(state.lastResults);
bindCardsToMap();
}
/* --------------------------------------------------------------------------
Home / top villes
-------------------------------------------------------------------------- */
function setRadiusValue(rayon) {
if (!dom.radiusSelect) return;
const value = parseInt(rayon, 10);
if (!Number.isFinite(value) || value <= 0) return;
if (!Array.from(dom.radiusSelect.options).some((option) => option.value === String(value))) {
const option = document.createElement('option');
option.value = String(value);
option.textContent = `${value} km`;
dom.radiusSelect.appendChild(option);
}
dom.radiusSelect.value = String(value);
}
function setGuestsValue(value) {
if (dom.guestsSelect && value) dom.guestsSelect.value = value;
}
function runSearchFromCity(ville, rayon, voyageurs) {
if (!dom.input) return;
dom.input.value = ville;
setRadiusValue(rayon);
setGuestsValue(voyageurs);
doSearch({ forceLive: false });
if (window.matchMedia('(max-width: 768px)').matches) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
async function loadHomeTop3() {
if (!dom.homeSections) return;
dom.homeSections.innerHTML = '';
try {
const data = await fetchJson(`${CONFIG.topVillesUrl}?limit=5`);
if (!data.ok || !Array.isArray(data.items) || !data.items.length) {
dom.homeSections.innerHTML = '';
return;
}
for (const item of data.items) {
const villePays = item.ville_pays;
const rayon = parseInt(item.rayon_km, 10) || 10;
const voyageurs = item.nombre_voyageurs || 0;
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 home-link" style="cursor:pointer;"
data-ville="${escapeHtml(villePays)}"
data-rayon="${escapeHtml(rayon)}"
data-voyageurs="${escapeHtml(voyageurs)}">
Hébergements autour de <span style="color:var(--accent)">“${escapeHtml(villePays)}”</span>
</h5>
<div class="small" style="color:var(--muted)">
Rayon ${escapeHtml(rayon)} km${voyageurs ? ` • ${escapeHtml(voyageurs)} pers.` : ''}
</div>
</div>
<div class="row g-3" data-home-grid="1">
<div class="col-12"><div class="skel" style="height:220px"></div></div>
</div>
`;
dom.homeSections.appendChild(section);
$('.home-link', section)?.addEventListener('click', (event) => {
const link = event.currentTarget;
runSearchFromCity(link.dataset.ville, link.dataset.rayon, link.dataset.voyageurs);
});
const params = buildQueryString({ ville_pays: villePays, rayon_km: rayon });
const searchData = await fetchJson(`${CONFIG.searchUrl}?${params}`);
const results = searchData?.ok && Array.isArray(searchData.results) ? searchData.results : [];
renderResultsInto(results, $('[data-home-grid="1"]', section), CONFIG.maxResultsHome);
cacheMissingPhotos(results);
}
} catch (error) {
dom.homeSections.innerHTML = '';
}
}
async function loadTopVilles() {
if (!dom.topVilles) return;
dom.topVilles.innerHTML = '';
try {
const data = await fetchJson(`${CONFIG.topVillesUrl}?limit=15`);
if (!data.ok || !Array.isArray(data.items) || !data.items.length) return;
dom.topVilles.innerHTML = data.items.map((item) => {
const value = String(item.ville_pays || '');
const parts = value.split(',');
const city = (parts[0] || '').trim();
const postal = (parts[1] || '').trim();
const countryCode = (parts[2] || '').trim();
const label = postal ? `${city} (${postal}), ${countryCode}` : value;
const rayon = parseInt(item.rayon_km, 10) || 10;
const compteur = parseInt(item.compteur, 10) || 0;
return `
<button class="chip" type="button"
data-ville="${escapeHtml(value)}"
data-voyageurs="${escapeHtml(item.nombre_voyageurs || '')}"
data-rayon="${rayon}">
<i class="bi bi-geo-alt me-1"></i>${escapeHtml(label)}
<span class="ms-1" style="color:var(--muted)">(${compteur})</span>
</button>
`;
}).join('');
$$('button[data-ville]', dom.topVilles).forEach((button) => {
button.addEventListener('click', () => {
runSearchFromCity(button.dataset.ville, button.dataset.rayon, button.dataset.voyageurs);
});
});
} catch (error) {}
}
/* --------------------------------------------------------------------------
Recherche API
-------------------------------------------------------------------------- */
function renderDebugRecherche(data) {
if (!dom.debugRecherche) return;
if (!data.debug_recherche) {
dom.debugRecherche.style.display = 'none';
dom.debugRecherche.innerHTML = '';
return;
}
dom.debugRecherche.style.display = 'block';
dom.debugRecherche.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>
`;
}
async function doSearch(options = { forceLive: false }) {
clearError();
setModeResults();
const villePays = dom.input?.value.trim() || '';
const rayonKm = dom.radiusSelect?.value || '';
const nombreVoyageurs = dom.guestsSelect ? parseInt(dom.guestsSelect.value, 10) || 0 : 0;
if (!villePays) {
if (dom.status) dom.status.textContent = 'Tape une ville + pays.';
dom.input?.focus();
return;
}
if (dom.btnClearCache) dom.btnClearCache.style.display = 'none';
if (dom.homeSections) dom.homeSections.innerHTML = '';
setLoading();
if (dom.status) dom.status.textContent = 'Recherche…';
if (dom.meta) dom.meta.textContent = '—';
const params = {
ville_pays: villePays,
rayon_km: rayonKm,
nombre_voyageurs: nombreVoyageurs,
debug: '1'
};
if (options.forceLive) params.force_live = '1';
const types = getActiveTypes();
if (types.length) params.types = types.join(',');
if (state.filters.piscine) params.piscine = '1';
try {
const data = await fetchJson(`${CONFIG.searchUrl}?${buildQueryString(params)}`);
console.log(data.debug_recherche?.ville_pays || '');
renderDebugRecherche(data);
if (!data.ok) {
if (dom.grid) dom.grid.innerHTML = '';
if (dom.status) dom.status.textContent = 'Erreur';
if (dom.meta) dom.meta.textContent = '—';
setError(data.error || 'Erreur inconnue.');
return;
}
if (dom.status) dom.status.textContent = data.from_cache ? 'Cache ✅' : 'Live ✅';
if (dom.meta) {
const voyageursLabel = `${data.nombre_voyageurs || 0} voyageur${(data.nombre_voyageurs || 0) > 1 ? 's' : ''}`;
dom.meta.textContent = `${data.count} résultat(s) • ${voyageursLabel} • Rayon ${data.rayon_km} km • ${data.ville}, ${data.pays}`;
}
if (data.from_cache && dom.btnClearCache) {
dom.btnClearCache.style.display = 'inline-block';
}
state.lastResults = Array.isArray(data.results) ? data.results : [];
renderResults(state.lastResults);
cacheMissingPhotos(state.lastResults);
refreshMapIfVisible();
} catch (error) {
if (dom.grid) dom.grid.innerHTML = '';
if (dom.status) dom.status.textContent = 'Erreur';
if (dom.meta) dom.meta.textContent = '—';
setError('Erreur réseau ou réponse JSON invalide.');
}
}
async function clearCurrentSearchCache() {
const villePays = dom.input?.value.trim() || '';
const rayonKm = dom.radiusSelect?.value || '';
if (!villePays) return;
if (dom.status) dom.status.textContent = 'Suppression du cache…';
if (dom.btnClearCache) dom.btnClearCache.style.display = 'none';
const form = new FormData();
form.append('ville_pays', villePays);
form.append('rayon_km', rayonKm);
try {
const data = await fetchJson(CONFIG.cacheClearUrl, { method: 'POST', body: form });
if (!data.ok) {
if (dom.status) dom.status.textContent = 'Erreur cache';
setError(data.error || 'Impossible de vider le cache.');
return;
}
await doSearch({ forceLive: true });
} catch (error) {
if (dom.status) dom.status.textContent = 'Erreur cache';
setError('Erreur réseau pendant la suppression du cache.');
}
}
/* --------------------------------------------------------------------------
Events principaux
-------------------------------------------------------------------------- */
function bindEvents() {
dom.btnSearch?.addEventListener('click', () => doSearch({ forceLive: false }));
dom.input?.addEventListener('keydown', (event) => {
if (event.key === 'Enter') doSearch({ forceLive: false });
});
dom.input?.addEventListener('input', async () => {
const value = dom.input.value.trim();
const prefix = normalizeForPrefix2(value);
if (!prefix) {
showSuggestions([]);
return;
}
const list = await loadPrefix(prefix);
showSuggestions(filterAutocompleteItems(list, value));
});
dom.input?.addEventListener('focus', () => setTimeout(() => dom.input.select(), 0));
dom.input?.addEventListener('click', () => setTimeout(() => dom.input.select(), 0));
dom.suggestBox?.addEventListener('click', (event) => {
const item = event.target.closest('.item');
if (!item) return;
dom.input.value = item.getAttribute('data-val');
showSuggestions([]);
dom.input.focus();
});
document.addEventListener('click', (event) => {
if (!event.target.closest('#suggestBox') && !event.target.closest('#qCity')) {
showSuggestions([]);
}
});
dom.filtersBar?.addEventListener('click', (event) => {
const button = event.target.closest('.js-filter');
if (!button) return;
const kind = button.getAttribute('data-filter');
if (kind === 'reset') {
resetFilters();
} else if (kind === 'type') {
const type = button.getAttribute('data-value');
if (state.filters.types.has(type)) state.filters.types.delete(type);
else state.filters.types.add(type);
setFilterBtnState();
} else if (kind === 'piscine') {
state.filters.piscine = !state.filters.piscine;
setFilterBtnState();
}
if ((dom.input?.value || '').trim()) doSearch({ forceLive: false });
});
dom.btnClearCache?.addEventListener('click', clearCurrentSearchCache);
dom.btnToggleMap?.addEventListener('click', () => {
if (!dom.split) return;
const enabled = dom.split.classList.toggle('is-map');
dom.btnToggleMap.innerHTML = enabled
? '<i class="bi bi-list me-1"></i>Liste'
: '<i class="bi bi-map me-1"></i>Carte';
if (enabled) refreshMapIfVisible();
setTimeout(() => state.map.instance?.invalidateSize(), 80);
});
dom.btnShuffle?.addEventListener('click', () => {
if (!dom.grid) return;
const items = Array.from(dom.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((item) => dom.grid.appendChild(item));
if (dom.status) dom.status.textContent = 'Cartes mélangées ✔';
});
}
/* --------------------------------------------------------------------------
API exposée aux autres fichiers
-------------------------------------------------------------------------- */
window.FindMyReco = {
...(window.FindMyReco || {}),
config: CONFIG,
dom,
state,
helpers: {
$,
$$,
escapeHtml,
normToken,
toAbsoluteUrl,
buildLocalPhotoUrl,
normalizePhotosLocal
},
getResultByToken,
doSearch,
setModeHome,
showSuggestions
};
/* --------------------------------------------------------------------------
Init
-------------------------------------------------------------------------- */
window.addEventListener('DOMContentLoaded', () => {
bindEvents();
setFilterBtnState();
if (dom.input) {
dom.input.value = '';
showSuggestions([]);
setModeHome();
loadTopVilles();
loadHomeTop3();
}
});
})();
/* =============================================================================
FICHIER 2 — find-modales.js
============================================================================= */
(() => {
'use strict';
const app = window.FindMyReco || {};
const helpers = app.helpers || {};
const $ = helpers.$ || ((selector, root = document) => root.querySelector(selector));
const $$ = helpers.$$ || ((selector, root = document) => Array.from(root.querySelectorAll(selector)));
const escapeHtml = helpers.escapeHtml || ((value) => String(value ?? ''));
const normToken = helpers.normToken || ((value) => String(value ?? '').trim());
const toAbsoluteUrl = helpers.toAbsoluteUrl || ((value) => String(value ?? ''));
const buildLocalPhotoUrl = helpers.buildLocalPhotoUrl || ((value) => String(value ?? ''));
const normalizePhotosLocal = helpers.normalizePhotosLocal || (() => []);
const PLACEHOLDER_IMG = app.config?.placeholderImg || 'https://find.myreco.online/img/placeholder.svg';
/* --------------------------------------------------------------------------
Dates formulaire contact
-------------------------------------------------------------------------- */
function initContactDates(root = document) {
const dateDebut = $('#date_debut', root);
const dateFin = $('#date_fin', root);
if (!dateDebut || !dateFin) return;
const pad = (number) => String(number).padStart(2, '0');
const toYMD = (date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const today = new Date();
const todayStr = toYMD(today);
const plus3 = new Date(today);
plus3.setDate(plus3.getDate() + 3);
const plus3Str = toYMD(plus3);
if (!dateDebut.value) dateDebut.value = todayStr;
if (!dateFin.value) dateFin.value = plus3Str;
dateDebut.min = todayStr;
dateFin.min = todayStr;
function syncMinEnd() {
if (dateDebut.value) {
dateFin.min = dateDebut.value;
if (dateFin.value && dateFin.value < dateDebut.value) {
dateFin.value = dateDebut.value;
}
} else {
dateFin.min = todayStr;
}
}
dateDebut.removeEventListener('change', syncMinEnd);
dateDebut.addEventListener('change', syncMinEnd);
syncMinEnd();
}
/* --------------------------------------------------------------------------
Modale photos
-------------------------------------------------------------------------- */
let photosModal = null;
function openPhotosModal(token) {
const item = app.getResultByToken?.(token);
if (!item) return;
const modalEl = $('#photosModal');
const titleEl = $('#photosModalTitle');
const metaEl = $('#photosModalMeta');
const mainEl = $('#photosModalMain');
const thumbsEl = $('#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 allPhotos = [mainPhoto];
normalizePhotosLocal(item.photos_local_json)
.map(buildLocalPhotoUrl)
.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, index) => `
<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 ${index === 0 ? '#111' : '#e5e7eb'};">
</button>
`).join('');
photosModal.show();
}
function bindPhotosEvents() {
document.addEventListener('click', (event) => {
const button = event.target.closest('[data-action="open-photos"]');
if (!button) return;
event.preventDefault();
event.stopPropagation();
const token = button.getAttribute('data-token');
if (token) openPhotosModal(token);
});
document.addEventListener('click', (event) => {
const button = event.target.closest('.photo-thumb-btn');
if (!button) return;
const url = button.getAttribute('data-photo-url');
const current = $('#photosModalCurrent');
if (!current || !url) return;
current.src = url;
$$('.photo-thumb-btn img').forEach((img) => {
img.style.border = '2px solid #e5e7eb';
});
const img = $('img', button);
if (img) img.style.border = '2px solid #111';
});
}
/* --------------------------------------------------------------------------
Modale contact : état docké
-------------------------------------------------------------------------- */
let contactModal = null;
let isContactDocked = true;
let contactSelected = [];
function isContactModalOpen() {
const modalEl = $('#contactModal');
return modalEl && modalEl.classList.contains('show');
}
function setContactDocked(docked) {
const modalEl = $('#contactModal');
if (!modalEl) return;
isContactDocked = Boolean(docked);
modalEl.classList.toggle('is-docked', isContactDocked);
const icon = $('#btnDockToggle i', modalEl);
if (icon) {
icon.className = isContactDocked ? 'bi bi-arrows-angle-expand' : 'bi bi-dash-lg';
}
const backdrop = $('.modal-backdrop');
if (backdrop) backdrop.classList.toggle('docked-backdrop', isContactDocked);
const isOpen = modalEl.classList.contains('show');
if (!isOpen) {
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 modalEl = $('#contactModal');
if (!modalEl) return;
$('#btnDockToggle', modalEl)?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
setContactDocked(!isContactDocked);
});
$('.modal-header', modalEl)?.addEventListener('click', () => {
if (isContactDocked) setContactDocked(false);
});
modalEl.addEventListener('shown.bs.modal', () => {
initContactDates(modalEl);
setContactDocked(true);
});
modalEl.addEventListener('hidden.bs.modal', () => {
$('#contactForm')?.reset();
$('#contactSuccess')?.classList.add('d-none');
setContactDocked(false);
});
}
/* --------------------------------------------------------------------------
Modale contact : sélection
-------------------------------------------------------------------------- */
function syncContactButtons() {
const selectedTokens = new Set(contactSelected.map((item) => String(item.token)));
$$('.btnContact[data-token]').forEach((button) => {
const token = String(button.getAttribute('data-token') || '');
const selected = selectedTokens.has(token);
button.classList.toggle('is-active', selected);
button.setAttribute('aria-pressed', selected ? 'true' : 'false');
});
}
function renderContactSelection() {
const listEl = $('#contactSelectionList');
const tokensWrap = $('#contactTokensWrap');
const titleEl = $('#contactTitle');
const jsonEl = $('#contactSelectionJson');
if (jsonEl) jsonEl.value = JSON.stringify(contactSelected || []);
if (!listEl || !tokensWrap) return;
if (!contactSelected.length) {
listEl.innerHTML = '<div class="small" style="color:var(--muted)">Aucun hébergement sélectionné</div>';
} else {
listEl.innerHTML = contactSelected.map((item) => `
<div class="contact-pill" data-token="${escapeHtml(item.token)}">
<div>
<div class="fw-bold">${escapeHtml(item.name || 'Hébergement')}</div>
<div class="meta">${escapeHtml(item.ville || '')}${item.country ? `, ${escapeHtml(item.country)}` : ''}</div>
</div>
<button type="button" class="remove" data-remove-token="${escapeHtml(item.token)}" aria-label="Supprimer">
<i class="bi bi-trash"></i>
</button>
</div>
`).join('');
}
tokensWrap.innerHTML = contactSelected
.map((item) => `<input type="hidden" name="tokens[]" value="${escapeHtml(item.token)}">`)
.join('');
if (titleEl) {
const count = contactSelected.length;
titleEl.textContent = count
? `${count} hébergement${count > 1 ? 's' : ''} à contacter`
: '0 hébergement à contacter';
}
}
function closeContactModalIfEmpty() {
if (contactSelected.length) return;
const modalEl = $('#contactModal');
if (!modalEl) return;
setContactDocked(false);
const instance = bootstrap.Modal.getInstance(modalEl) || contactModal;
if (instance && modalEl.classList.contains('show')) instance.hide();
}
function addContactToModal(data) {
const token = normToken(data.token);
if (!token) return;
const nextItem = {
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 || ''
};
const exists = contactSelected.some((item) => item.token === token);
if (!exists) {
contactSelected.push(nextItem);
} else {
contactSelected = contactSelected.map((item) => {
return item.token === token ? { ...item, ...nextItem } : item;
});
}
renderContactSelection();
syncContactButtons();
const pill = $(`#contactSelectionList .contact-pill[data-token="${token.replace(/"/g, '\\"')}"]`);
if (pill) {
pill.classList.add('pulse');
setTimeout(() => pill.classList.remove('pulse'), 350);
}
}
function openContactModal(data) {
const modalEl = $('#contactModal');
if (!modalEl) return;
if (!contactModal) contactModal = new bootstrap.Modal(modalEl);
if (!isContactModalOpen()) {
$('#contactForm')?.reset();
$('#contactSuccess')?.classList.add('d-none');
}
const contactMeta = $('#contactMeta');
const contactMeta1 = $('#contactMeta1');
if (contactMeta) contactMeta.textContent = 'Ajoutez en d\'autres en cliquant sur "contactez l\'hôte"';
if (contactMeta1) contactMeta1.textContent = '';
addContactToModal(data);
setContactDocked(true);
contactModal.show();
}
function payloadFromContactButton(button) {
return {
token: button.getAttribute('data-token'),
name: button.getAttribute('data-name'),
ville: button.getAttribute('data-ville'),
country: button.getAttribute('data-country'),
capacite: button.getAttribute('data-capacite'),
chambres: button.getAttribute('data-chambres'),
sdb: button.getAttribute('data-sdb'),
parking: button.getAttribute('data-parking'),
type: button.getAttribute('data-type'),
prix: button.getAttribute('data-prix')
};
}
function bindContactEvents() {
document.addEventListener('click', (event) => {
const button = event.target.closest('.btnContact');
if (!button) return;
const payload = payloadFromContactButton(button);
const token = normToken(payload.token);
if (!token) return;
const alreadySelected = contactSelected.some((item) => item.token === token);
if (alreadySelected) {
contactSelected = contactSelected.filter((item) => item.token !== token);
renderContactSelection();
syncContactButtons();
closeContactModalIfEmpty();
return;
}
openContactModal(payload);
});
$('#contactModal')?.addEventListener('click', (event) => {
const button = event.target.closest('[data-remove-token]');
if (!button) return;
const token = button.getAttribute('data-remove-token');
contactSelected = contactSelected.filter((item) => item.token !== token);
renderContactSelection();
syncContactButtons();
closeContactModalIfEmpty();
});
$('#contactForm')?.addEventListener('submit', submitContactForm);
}
async function submitContactForm(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const tokens = formData.getAll('tokens[]');
if (!tokens.length) {
alert('Merci de sélectionner au moins un hébergement.');
return;
}
const button = form.querySelector('button[type="submit"]');
const oldHtml = button ? button.innerHTML : '';
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Envoi...';
}
try {
const response = await fetch('ajax/contact_host.php', {
method: 'POST',
body: formData,
headers: { Accept: 'application/json' }
});
const data = await response.json();
if (!data.ok) {
alert(data.error || 'Erreur lors de l\'envoi.');
if (button) {
button.disabled = false;
button.innerHTML = oldHtml;
}
return;
}
$('#contactSuccess')?.classList.remove('d-none');
contactSelected = [];
renderContactSelection();
syncContactButtons();
setTimeout(() => {
const modalEl = $('#contactModal');
const instance = bootstrap.Modal.getInstance(modalEl);
if (instance) instance.hide();
}, 700);
if (button) button.innerHTML = '<i class="bi bi-check2 me-1"></i>Envoyé';
setTimeout(() => {
if (!button) return;
button.disabled = false;
button.innerHTML = oldHtml;
}, 1200);
} catch (error) {
alert('Erreur réseau.');
if (button) {
button.disabled = false;
button.innerHTML = oldHtml;
}
}
}
/* --------------------------------------------------------------------------
API exposée au fichier principal
-------------------------------------------------------------------------- */
window.FindMyReco = {
...(window.FindMyReco || {}),
syncContactButtons,
getContactSelected: () => contactSelected.slice()
};
/* --------------------------------------------------------------------------
Init
-------------------------------------------------------------------------- */
window.addEventListener('DOMContentLoaded', () => {
bindPhotosEvents();
initContactDockUI();
bindContactEvents();
const modalEl = $('#contactModal');
if (!modalEl) initContactDates(document);
});
})();