PageSourceSearch

https://visapourlimage.com/wp-content/plugins/visa-agenda/assets/js/visa-agenda.js?ver=0.20.10-1788273756

js visapourlimage.com collected 2026-10-02 06:18:02 UTC 52,269 bytes, 970 lines download raw bytes

1(function () {
2  'use strict';
3
4  const cfg = window.VisaAgendaConfig || {};
5
6  function isSafariBrowser() {
7    const ua = navigator.userAgent || '';
8    const vendor = navigator.vendor || '';
9    const excluded = /CriOS|Chrome|Chromium|FxiOS|Firefox|EdgiOS|Edg\/|OPiOS|OPR\/|DuckDuckGo/i.test(ua);
10    return /Safari/i.test(ua) && /Apple/i.test(vendor) && !excluded;
11  }
12
13  function isMobileSafari() {
14    const ua = navigator.userAgent || '';
15    const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
16    return isIOS && isSafariBrowser();
17  }
18
19  if (isSafariBrowser()) {
20    document.documentElement.classList.add('va-safari');
21  }
22  if (isMobileSafari()) {
23    document.documentElement.classList.add('va-mobile-safari');
24  }
25
26  function decodeEntities(value) {
27    const txt = document.createElement('textarea');
28    txt.innerHTML = String(value == null ? '' : value);
29    return txt.value;
30  }
31
32  function esc(value) {
33    return decodeEntities(value)
34      .replace(/&/g, '&')
35      .replace(/</g, '&lt;')
36      .replace(/>/g, '&gt;')
37      .replace(/"/g, '&quot;')
38      .replace(/'/g, '&#039;');
39  }
40
41  function weekdayLabel(iso, lang) {
42    try {
43      const d = new Date(iso + 'T12:00:00');
44      return new Intl.DateTimeFormat(lang === 'en' ? 'en-GB' : 'fr-FR', { weekday: 'short' }).format(d).replace('.', '');
45    } catch (e) {
46      return '';
47    }
48  }
49
50  function monthLabel(iso, lang) {
51    try {
52      const d = new Date(iso + 'T12:00:00');
53      return new Intl.DateTimeFormat(lang === 'en' ? 'en-GB' : 'fr-FR', { month: 'short' }).format(d).replace('.', '');
54    } catch (e) {
55      return '';
56    }
57  }
58
59  function longDateLabel(iso, lang) {
60    try {
61      const d = new Date(iso + 'T12:00:00');
62      return new Intl.DateTimeFormat(lang === 'en' ? 'en-GB' : 'fr-FR', {
63        weekday: 'long', day: 'numeric', month: 'long'
64      }).format(d);
65    } catch (e) {
66      return '';
67    }
68  }
69
70
71
72  function clockIcon() {
73    return `<svg class="va-icon va-icon-clock" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3.5 2"></path></svg>`;
74  }
75
76  function pinIcon() {
77    return `<svg class="va-icon va-icon-pin" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 21s6-5.2 6-11a6 6 0 1 0-12 0c0 5.8 6 11 6 11Z"></path><circle cx="12" cy="10" r="2.2"></circle></svg>`;
78  }
79
80  function liveIcon() {
81    return `<span class="va-live-icon" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><circle cx="12" cy="12" r="2.2" fill="currentColor"></circle><path d="M7.7 7.7a6 6 0 0 0 0 8.6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path><path d="M16.3 7.7a6 6 0 0 1 0 8.6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path><path d="M4.8 4.8a10 10 0 0 0 0 14.4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" opacity=".8"></path><path d="M19.2 4.8a10 10 0 0 1 0 14.4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" opacity=".8"></path></svg></span>`;
82  }
83
84  function splitTimeLabel(label) {
85    if (!label) return '';
86    const parts = decodeEntities(label).split(/\s+[–—-]\s+/);
87    if (parts.length > 1) {
88      return `<span class="va-time-start">${esc(parts[0])}</span><span class="va-time-sep">—</span><span class="va-time-end">${esc(parts.slice(1).join(' – '))}</span>`;
89    }
90    return esc(label);
91  }
92
93
94  function contrastColor(hex) {
95    const value = String(hex || '').replace('#', '');
96    if (!/^[0-9a-f]{6}$/i.test(value)) return '#111111';
97    const r = parseInt(value.slice(0, 2), 16);
98    const g = parseInt(value.slice(2, 4), 16);
99    const b = parseInt(value.slice(4, 6), 16);
100    const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
101    return lum > 0.58 ? '#111111' : '#ffffff';
102  }
103
104  function logoPlaceholder(baseClass, visualLabel) {
105    return `<div class="${baseClass} va-event-placeholder" aria-hidden="true"><small>${esc(visualLabel)}</small></div>`;
106  }
107
108  function backgroundAttrs(e) {
109    const bg = String((e && e.backgroundColor) || '');
110    if (!/^#[0-9a-f]{6}$/i.test(bg)) return { cls: '', style: '' };
111    const fg = contrastColor(bg);
112    return {
113      cls: ' has-custom-bg',
114      style: ` style="--va-event-bg:${esc(bg)};--va-event-fg:${esc(fg)}"`
115    };
116  }
117
118  class Agenda {
119    constructor(root) {
120      this.root = root;
121      this.lang = root.dataset.lang === 'en' ? 'en' : 'fr';
122      this.str = (cfg.strings && cfg.strings[this.lang]) || {};
123      this.state = {
124        mode: root.dataset.mode || 'auto',
125        date: '',
126        type: '',
127        place: '',
128        photographer: '',
129        exhibition: ''
130      };
131      this.showFilters = root.dataset.showFilters !== 'no';
132      this.showNow = root.dataset.showNow !== 'no';
133      this.updateUrl = root.dataset.updateUrl !== 'no';
134      this.status = root.querySelector('.va-status');
135      this.modeNav = root.querySelector('.va-mode-nav');
136      this.dateNav = root.querySelector('.va-date-nav');
137      this.filters = root.querySelector('.va-filters');
138      this.results = root.querySelector('.va-results');
139      this.timer = null;
140      this.clockTimer = null;
141      this.interacted = false;
142      this.hasUserState = false;
143      this.expandedSchedule = false;
144      this.expandedContinuous = false;
145      this.continuousSectionOpen = null;
146      this.initialEvents = Math.max(1, parseInt(root.dataset.initialEvents || '4', 10) || 4);
147      this.initialContinuous = Math.max(1, parseInt(root.dataset.initialContinuous || '3', 10) || 3);
148      this.visitorToken = this.getVisitorToken();
149      this.readUrl();
150      this.load();
151      this.timer = window.setInterval(() => {
152        if (this.state.mode === 'now' || this.state.mode === 'day' || this.state.mode === 'today' || this.state.mode === 'auto') {
153          this.load(true);
154        }
155      }, 60000);
156      this.clockTimer = window.setInterval(() => this.updateProgressGauges(), 1000);
157    }
158
159    readUrl() {
160      if (!this.updateUrl) return;
161      const q = new URLSearchParams(window.location.search);
162      const mode = q.get('va_mode');
163      const date = q.get('va_date');
164      const type = q.get('va_type');
165      const place = q.get('va_place');
166      const photographer = q.get('va_photographer');
167      const exhibition = q.get('va_exhibition');
168      if (mode) { this.state.mode = mode; this.hasUserState = true; }
169      if (date) { this.state.date = date; this.hasUserState = true; }
170      if (type) { this.state.type = type; this.hasUserState = true; }
171      if (place) { this.state.place = place; this.hasUserState = true; }
172      if (photographer) { this.state.photographer = photographer; this.hasUserState = true; }
173      if (exhibition) { this.state.exhibition = exhibition; this.hasUserState = true; }
174      if ((photographer || type || place || exhibition) && !mode) this.state.mode = 'week';
175    }
176
177    writeUrl() {
178      if (!this.updateUrl || (!this.interacted && !this.hasUserState) || !window.history || !window.history.replaceState) return;
179      const url = new URL(window.location.href);
180      ['va_mode', 'va_date', 'va_type', 'va_place', 'va_photographer', 'va_exhibition'].forEach(k => url.searchParams.delete(k));
181      if (this.state.mode && this.state.mode !== 'auto') url.searchParams.set('va_mode', this.state.mode);
182      if (this.state.date && (this.state.mode === 'day' || this.state.mode === 'week')) url.searchParams.set('va_date', this.state.date);
183      if (this.state.type) url.searchParams.set('va_type', this.state.type);
184      if (this.state.place) url.searchParams.set('va_place', this.state.place);
185      if (this.state.photographer) url.searchParams.set('va_photographer', this.state.photographer);
186      if (this.state.exhibition) url.searchParams.set('va_exhibition', this.state.exhibition);
187      window.history.replaceState({}, '', url.toString());
188    }
189
190    async load(silent) {
191      if (!silent) {
192        this.status.textContent = this.str.loading || 'Chargement…';
193        this.root.classList.add('is-loading');
194      }
195      const url = new URL(cfg.restUrl, window.location.origin);
196      url.searchParams.set('mode', this.state.mode || 'auto');
197      url.searchParams.set('lang', this.lang);
198      url.searchParams.set('_va_v', cfg.version || '1');
199      if (this.visitorToken) url.searchParams.set('visitor_token', this.visitorToken);
200      if (this.state.date) url.searchParams.set('date', this.state.date);
201      if (this.state.type) url.searchParams.set('type', this.state.type);
202      if (this.state.place) url.searchParams.set('place', this.state.place);
203      if (this.state.photographer) url.searchParams.set('photographer', this.state.photographer);
204      if (this.state.exhibition) url.searchParams.set('exhibition', this.state.exhibition);
205
206      try {
207        const res = await fetch(url.toString(), { credentials: 'same-origin', cache: 'no-store', headers: { 'Accept': 'application/json' } });
208        if (!res.ok) throw new Error('HTTP ' + res.status);
209        const data = await res.json();
210        this.normalizeFilters(data);
211        this.state.mode = data.mode || this.state.mode;
212        this.state.date = data.selectedDate || this.state.date;
213        this.render(data);
214        this.writeUrl();
215        this.status.textContent = '';
216      } catch (e) {
217        if (!silent) this.status.textContent = this.str.error || 'Erreur de chargement.';
218      } finally {
219        this.root.classList.remove('is-loading');
220      }
221    }
222
223    normalizeFilters(data) {
224      const normalizeOne = (value, terms) => {
225        if (!value) return '';
226        const found = (terms || []).find(t => String(t.id) === String(value) || String(t.slug) === String(value));
227        return found ? String(found.slug) : '';
228      };
229      this.state.type = normalizeOne(this.state.type, data.types || []);
230      this.state.place = normalizeOne(this.state.place, data.places || []);
231      this.state.photographer = normalizeOne(this.state.photographer, data.photographers || []);
232      this.state.exhibition = normalizeOne(this.state.exhibition, data.exhibitions || []);
233    }
234
235    render(data) {
236      this.currentDate = data.currentDate || '';
237      this.renderModes(data);
238      this.renderDates(data);
239      this.renderFilters(data);
240      this.renderEvents(data);
241    }
242
243    renderModes(data) {
244      if (!this.showNow) {
245        this.modeNav.innerHTML = '';
246        return;
247      }
248      const currentDate = data.currentDate || '';
249      const nowActive = this.state.mode === 'now';
250      const todayActive = this.state.mode === 'day' && this.state.date === currentDate;
251      const liveCount = Math.max(0, parseInt(data.liveCount || '0', 10) || 0);
252      const liveSignal = liveCount > 0
253        ? `<span class="va-now-live-signal" aria-hidden="true"><span class="va-now-live-pill"><span class="va-now-live-icon"><svg viewBox="0 0 24 24" focusable="false"><circle cx="12" cy="12" r="2.4" fill="currentColor"></circle><path d="M8.2 8.2a5.4 5.4 0 0 0 0 7.6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path><path d="M15.8 8.2a5.4 5.4 0 0 1 0 7.6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path><path d="M5.2 5.2a9.5 9.5 0 0 0 0 13.6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity=".82"></path><path d="M18.8 5.2a9.5 9.5 0 0 1 0 13.6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity=".82"></path></svg></span><span class="va-now-live-text">LIVE</span></span></span>`
254        : '';
255      this.modeNav.innerHTML = `
256        <button type="button" class="va-mode va-mode-now ${nowActive ? 'is-active' : ''}${liveCount > 0 ? ' has-live' : ''}" data-mode="now">${liveSignal}<span class="va-mode-label">${esc(this.str.now || 'Maintenant')}</span></button>
257        <button type="button" class="va-mode ${todayActive ? 'is-active' : ''}" data-mode="today">${esc(this.str.today || "Aujourd’hui")}</button>`;
258      this.modeNav.querySelectorAll('button').forEach(btn => {
259        btn.addEventListener('click', () => {
260          this.interacted = true;
261          if (this.state.mode !== btn.dataset.mode) {
262            this.continuousSectionOpen = null;
263          }
264          this.state.mode = btn.dataset.mode;
265          this.state.date = '';
266          this.expandedSchedule = false;
267          this.expandedContinuous = false;
268          this.continuousSectionOpen = null;
269          this.load();
270        });
271      });
272    }
273
274    renderDates(data) {
275      if (this.state.mode === 'now') {
276        this.dateNav.innerHTML = '';
277        return;
278      }
279      const dates = data.dates || [];
280      const counts = data.dayCounts || {};
281      const transverseFilterActive = !!(this.state.type || this.state.place || this.state.photographer || this.state.exhibition);
282      const weekStart = data.weekStart || '';
283      const weekEnd = data.weekEnd || '';
284      this.dateNav.innerHTML = dates.map(d => {
285        const active = this.state.mode === 'day' && d.value === this.state.date;
286        const inWeek = this.state.mode === 'week' && weekStart && weekEnd && d.value >= weekStart && d.value <= weekEnd;
287        const day = d.value ? parseInt(d.value.slice(8, 10), 10) : '';
288        const count = transverseFilterActive ? Math.max(0, parseInt(counts[d.value] || '0', 10) || 0) : 0;
289        const badge = count >
289 0 ? `<span class="va-date-badge" aria-label="${esc(String(count))} ${esc(this.lang === 'en' ? (count === 1 ? 'event' : 'events') : (count === 1 ? 'événement' : 'événements'))}">${esc(String(count))}</span>` : '';
290        return `<button type="button" class="va-date ${active ? 'is-active' : ''}${inWeek ? ' is-week' : ''}" data-date="${esc(d.value)}"><span class="va-date-dow">${esc(weekdayLabel(d.value, this.lang))}</span><strong>${esc(day)}</strong><span class="va-date-month">${esc(monthLabel(d.value, this.lang))}</span>${badge}</button>`;
291      }).join('');
292      this.dateNav.querySelectorAll('button').forEach(btn => {
293        btn.addEventListener('click', () => {
294          this.interacted = true;
295          this.state.mode = 'day';
296          this.state.date = btn.dataset.date;
297          this.expandedSchedule = false;
298          this.expandedContinuous = false;
299          this.continuousSectionOpen = null;
300          this.load();
301        });
302      });
303      const active = this.dateNav.querySelector('.is-active') || this.dateNav.querySelector('.is-week .va-date-badge')?.closest('.va-date');
304      if (active) {
305        // Ne jamais utiliser scrollIntoView ici : Safari iOS peut faire défiler
306        // horizontalement la page entière au lieu du seul rail de dates.
307        // On déplace exclusivement .va-date-nav.
308        const nav = this.dateNav;
309        requestAnimationFrame(() => {
310          const maxLeft = Math.max(0, nav.scrollWidth - nav.clientWidth);
311          const wanted = active.offsetLeft - ((nav.clientWidth - active.offsetWidth) / 2);
312          nav.scrollLeft = Math.max(0, Math.min(maxLeft, wanted));
313          // Le rail bouge, jamais la page entière.
314          requestAnimationFrame(resetPageHorizontalScroll);
315        });
316      }
317    }
318
319    renderFilters(data) {
320      if (!this.showFilters) {
321        this.filters.innerHTML = '';
322        return;
323      }
324      const collator = new Intl.Collator(this.lang === 'en' ? 'en' : 'fr', { sensitivity: 'base', numeric: true });
325      const alpha = (items, labelKey = 'name') => [...(items || [])].sort((a, b) => collator.compare(String(a[labelKey] || ''), String(b[labelKey] || '')));
326      const withCount = item => `${item.name || item.title || ''} (${Math.max(0, parseInt(item.count || '0', 10) || 0)})`;
327      const types = alpha(data.types || []);
328      const places = alpha(data.places || []);
329      const photographers = alpha(data.photographers || []);
330      const exhibitions = alpha(data.exhibitions || [], 'title');
331      const typeOptions = [`<option value="">${esc(this.str.type || 'Tous les types')}</option>`]
332        .concat(types.map(t => `<option value="${esc(t.slug)}" ${String(this.state.type) === String(t.slug) ? 'selected' : ''}>${esc(withCount(t))}</option>`)).join('');
333      const placeOptions = [`<option value="">${esc(this.str.place || 'Tous les lieux')}</option>`]
334        .concat(places.map(p => `<option value="${esc(p.slug)}" ${String(this.state.place) === String(p.slug) ? 'selected' : ''}>${esc(withCount(p))}</option>`)).join('');
335      const photographerOptions = [`<option value="">${esc(this.str.photographer || 'Tous les photographes')}</option>`]
336        .concat(photographers.map(p => `<option value="${esc(p.slug)}" ${String(this.state.photographer) === String(p.slug) ? 'selected' : ''}>${esc(withCount(p))}</option>`)).join('');
337      const exhibitionOptions = [`<option value="">${esc(this.str.exhibition || 'Toutes les expositions')}</option>`]
338        .concat(exhibitions.map(e => `<option value="${esc(e.slug)}" ${String(this.state.exhibition) === String(e.slug) ? 'selected' : ''}>${esc(withCount(e))}</option>`)).join('');
339      const available = data.availableFilters || {};
340      const showPhotographer = available.photographer !== false && photographers.length > 0;
341      const showExhibition = available.exhibition !== false && exhibitions.length > 0;
342      const controls = [
343        `<label><span class="screen-reader-text">${esc(this.str.type || 'Type')}</span><select class="va-filter-type">${typeOptions}</select></label>`,
344        `<label><span class="screen-reader-text">${esc(this.str.place || 'Lieu')}</span><select class="va-filter-place">${placeOptions}</select></label>`
345      ];
346      if (showPhotographer) {
347        controls.push(`<label><span class="screen-reader-text">${esc(this.str.photographer || 'Photographe')}</span><select class="va-filter-photographer">${photographerOptions}</select></label>`);
348      }
349      if (showExhibition) {
350        controls.push(`<label><span class="screen-reader-text">${esc(this.str.exhibition || 'Exposition')}</span><select class="va-filter-exhibition">${exhibitionOptions}</select></label>`);
351      }
352      this.filters.innerHTML = controls.join('');
353      const type = this.filters.querySelector('.va-filter-type');
354      const place = this.filters.querySelector('.va-filter-place');
355      const photographer = this.filters.querySelector('.va-filter-photographer');
356      const exhibition = this.filters.querySelector('.va-filter-exhibition');
357      const applyTransverseFilters = () => {
358        this.expandedSchedule = false;
359        this.expandedContinuous = false;
360          this.continuousSectionOpen = null;
361        const hasFilter = !!(this.state.type || this.state.place || this.state.photographer || this.state.exhibition);
362        if (hasFilter) {
363          // Tous les filtres sont transversaux : on cherche automatiquement la
364          // première semaine contenant l'intersection active, puis on affiche
365          // tous les rendez-vous correspondants de cette semaine.
366          this.state.mode = 'week';
367          this.state.date = '';
368        } else if (this.state.mode === 'week') {
369          this.state.mode = 'day';
370        }
371        this.load();
372      };
373
374      type.addEventListener('change', () => {
375        this.interacted = true;
376        this.state.type = type.value;
377        applyTransverseFilters();
378      });
379      place.addEventListener('change', () => {
380        this.interacted = true;
381        this.state.place = place.value;
382        applyTransverseFilters();
383      });
384      if (photographer) photographer.addEventListener('change', () => {
385        this.interacted = true;
386        this.state.photographer = photographer.value;
387        applyTransverseFilters();
388      });
389      if (exhibition) exhibition.addEventListener('change', () => {
390        this.interacted = true;
391        this.state.exhibition = exhibition.value;
392        applyTransverseFilters();
393      });
394    }
395
396    renderEvents(data) {
397      const events = data.events || [];
398      if (!events.length) {
399        const message = this.state.mode === 'now'
400          ? (this.str.emptyNow || (this.lang === 'en' ? 'No event is currently in progress.' : 'Aucun événement en cours.'))
401          : (this.str.empty || (this.lang === 'en' ? 'No event for this selection.' : 'Aucun événement.'));
402        this.results.innerHTML = `<div class="va-empty">${esc(message)}</div>`;
403        return;
404      }
405
406      if (this.state.mode === 'week' && (this.state.type || this.state.place || this.state.photographer || this.state.exhibition)) {
407        this.renderWeekEvents(data, events);
408        return;
409      }
410
411      const isNow = this.state.mode === 'now';
412      const title = isNow
413        ? (this.str.current || 'EN COURS')
414        : longDateLabel(data.selectedDate || this.state.date, this.lang);
415      const kicker = isNow
416        ? (this.lang === 'en' ? 'Live programme' : 'Programme en direct')
417        : (this.lang === 'en' ? 'Programme' : 'Programme');
418      const cancelledCount = events.filter(e => e.cancelled).length;
419      const baseCountLabel = this.lang === 'en'
420        ? `${events.length} event${events.length > 1 ? 's' : ''}`
421        : `${events.length} événement${events.length > 1 ? 's' : ''}`;
422      const countLabel = cancelledCount > 0
423        ? `${baseCountLabel} · ${cancelledCount} ${this.lang === 'en' ? (cancelledCount === 1 ? 'cancelled' : 'cancelled') : (cancelledCount === 1 ? 'annulé' : 'annulés')}`
424        : baseCountLabel;
425
426      const scheduled = events.filter(e => e.displayGroup !== 'continuous');
427      const continuous = events.filter(e => e.displayGroup === 'continuous');
428
429      const groupHeading = (label, count, kind, open = true) => {
430        if (kind === 'continuous') {
431          return `<div class="va-group-head va-group-head-continuous">
432            <button type="button" class="va-continuous-toggle" aria-expanded="${open ? 'true' : 'false'}">
433              <span class="va-continuous-head-title">${esc(label)}</span>
434              <span class="va-continuous-head-count">${esc(String(count))}</span>
435              <span class="va-continuous-chevron" aria-hidden="true"></span>
436            </button>
437          </div>`;
438        }
439        return `<div class="va-group-head va-group-head-${kind}">
440          <h3>${esc(label)}</h3>
441          <span>${esc(String(count))}</span>
442        </div>`;
443      };
444
445      let groups = '';
446      if (scheduled.length) {
447        const label = isNow
448          ? (this.str.nowAppointments || (this.lang === 'en' ? 'Happening now' : 'Rendez-vous en cours'))
449          : (this.str.appointments || (this.lang === 'en' ? "Today's schedule" : 'Rendez-vous du jour'));
450        const hiddenCount = Math.max(0, scheduled.length - this.initialEvents);
451        const cards = scheduled.map((e, i) => {
452          const hidden = !this.expandedSchedule && i >= this.initialEvents;
453          return this.eventCard(e, hidden ? ' va-more-hidden' : '');
454        }).join('');
455        const more = hiddenCount > 0 ? this.moreButton('schedule', hiddenCount, this.expandedSchedule) : '';
456        groups += `<section class="va-agenda-group va-group-schedule">
457          ${groupHeading(label, scheduled.length, 'schedule')}
458          <div class="va-events">${cards}</div>
459          ${more}
460        </section>`;
461      }
462
463      if (continuous.length) {
464        const label = isNow
465          ? (this.str.nowContinuous || (this.lang === 'en' ? 'Open now' : 'Ouvert en continu'))
466          : (this.str.continuous || (this.lang === 'en' ? 'Open throughout the day' : 'En continu ce jour'));
467        // En mode « Maintenant », un événement en continu réellement actif
468        // doit être visible immédiatement. Pour les journées normales :
469        // 1–2 activités = repliées par défaut, 3+ = ouvertes par défaut.
470        const defaultSectionOpen = isNow || continuous.length >= 3;
471        const sectionOpen = this.continuousSectionOpen === null ? defaultSectionOpen : this.continuousSectionOpen;
472        const hiddenCount = Math.max(0, continuous.length - this.initialContinuous);
473        const cards = continuous.map((e, i) => {
474          const hidden = !this.expandedContinuous && i >= this.initialContinuous;
475          return this.continuousCard(e, hidden ? ' va-more-hidden' : '');
476        }).join('');
477        const more = hiddenCount > 0 ? this.moreButton('continuous', hiddenCount, this.expandedContinuous) : '';
478        groups += `<section class="va-agenda-group va-group-continuous${sectionOpen ? ' is-open' : ' is-collapsed'}">
479          ${groupHeading(label, continuous.length, 'continuous', sectionOpen)}
480          <div class="va-continuous-body"${sectionOpen ? '' : ' hidden'}>
481            <div class="va-continuous-grid${continuous.length === 1 ? ' is-single' : ''}">${cards}</div>
482            ${more}
483          </div>
484        </section>`;
485      }
486
487      this.results.innerHTML = `
488        <div class="va-section-head">
489          <div>
490            <span class="va-section-kicker">${esc(kicker)}</span>
491            <h2 class="va-section-title">${esc(title)}</h2>
492          </div>
493          <div class="va-section-count">${esc(countLabel)}</div>
494        </div>
495        ${groups}`;
496
497      this.results.querySelectorAll('.va-continuous-toggle').forEach(btn => {
498        btn.addEventListener('click', () => {
499          const group = btn.closest('.va-group-continuous');
500          const body = group ? group.querySelector('.va-continuous-body') : null;
501          if (!group || !body) return;
502          const open = btn.getAttribute('aria-expanded') !== 'true';
503          this.continuousSectionOpen = open;
504          btn.setAttribute('aria-expanded', open ? 'true' : 'false');
505          body.hidden = !open;
506          group.classList.toggle('is-open', open);
507          group.classList.toggle('is-collapsed', !open);
508        });
509      });
510
511      this.results.querySelectorAll('.va-details-toggle').forEach(btn => {
512        btn.addEventListener('click', () => {
513          const card = btn.closest('.va-event, .va-continuous-card');
514          const details = card.querySelector('.va-event-details');
515          const open = card.classList.toggle('is-open');
516          details.hidden = !open;
517          btn.textContent = open
518            ? (this.str.hideDetails || (this.lang === 'en' ? 'Close' : 'Réduire'))
519            : (this.str.details || (this.lang === 'en' ? 'Details' : 'Détails'));
520          btn.setAttribute('aria-expanded', open ? 'true' : 'false');
521        });
522      });
523
524      this.results.querySelectorAll('.va-participate').forEach(btn => {
525        btn.addEventListener('click', () => this.toggleParticipation(btn));
526      });
527
528      this.results.querySelectorAll('.va-more-button').forEach(btn => {
529        btn.addEventListener('click', () => {
530          const kind = btn.dataset.group === 'continuous' ? 'continuous' : 'schedule';
531          const group = btn.closest('.va-agenda-group');
532          const isExpanded = kind === 'schedule' ? this.expandedSchedule : this.expandedContinuous;
533          const nextExpanded = !isExpanded;
534          if (kind === 'schedule') this.expandedSchedule = nextExpanded;
535          else this.expandedContinuous = nextExpanded;
536
537          group.querySelectorAll('.va-more-hidden').forEach(card => {
538            card.hidden = !nextExpanded;
539          });
540          btn.classList.toggle('is-expanded', nextExpanded);
541          btn.setAttribute('aria-expanded', nextExpanded ? 'true' : 'false');
542          btn.innerHTML = this.moreButtonLabel(parseInt(btn.dataset.hiddenCount || '0', 10), nextExpanded);
543
544          if (!nextExpanded) {
545            const head = group.querySelector('.va-group-head');
546            if (head && head.scrollIntoView) {
547              try { head.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) {}
548            }
549          }
550        });
551      });
552    }
553
554    renderWeekEvents(data, events) {
555      const photographer = (data.photographers || []).find(p => String(p.slug) === String(this.state.photographer) || String(p.id) === String(this.state.photographer));
556      const type = (data.types || []).find(t => String(t.slug) === String(this.state.type) || String(t.id) === String(this.state.type));
557      const place = (data.places || []).find(p => String(p.slug) === String(this.state.place) || String(p.id) === String(this.state.place));
558      const exhibition = (data.exhibitions || []).find(e => String(e.slug) === String(this.state.exhibition) || String(e.id) === String(this.state.exhibition));
559      const activeLabels = [];
560      if (type) activeLabels.push(type.name);
561      if (place) activeLabels.push(place.name);
562      if (photographer) activeLabels.push(photographer.name);
563      if (exhibition) activeLabels.push(exhibition.title);
564      const name = activeLabels.length ? activeLabels.join(' · ') : (this.lang === 'en' ? 'Weekly programme' : 'Programme de la semaine');
565      const cancelledCount = events.filter(e => e.cancelled).length;
566      const baseCountLabel = this.lang === 'en'
567        ? `${events.length} event${events.length > 1 ? 's' : ''}`
568        : `${events.length} événement${events.length > 1 ? 's' : ''}`;
569      const countLabel = cancelledCount > 0
570        ? `${baseCountLabel} · ${cancelledCount} ${this.lang === 'en' ? (cancelledCount === 1 ? 'cancelled' : 'cancelled') : (cancelledCount === 1 ? 'annulé' : 'annulés')}`
571        : baseCountLabel;
572      const kicker = this.lang === 'en' ? 'Weekly programme' : 'Programme de la semaine';
573
574      const grouped = {};
575      events.forEach(e => {
576        const key = e.date || (e.start ? String(e.start).slice(0, 10) : '');
577        if (!key) return;
578        if (!grouped[key]) grouped[key] = [];
579        grouped[key].push(e);
580      });
581
582      const days = Object.keys(grouped).sort();
583      const blocks = days.map(day => {
584        const items = grouped[day];
585        const cards = items.map(e => this.eventCard(e)).join('');
586        return `<section class="va-agenda-group va-week-day" data-week-date="${esc(day)}">
587          <div class="va-group-head va-group-head-week">
588            <h3>${esc(longDateLabel(day, this.lang))}</h3>
589            <span>${esc(String(items.length))}</span>
590          </div>
591          <div class="va-events">${cards}</div>
592        </section>`;
593      }).join('');
594
595      this.results.innerHTML = `
596        <div class="va-section-head va-week-head">
597          <div>
598            <span class="va-section-kicker">${esc(kicker)}</span>
599            <h2 class="va-section-title">${esc(name)}</h2>
600          </div>
601          <div class="va-section-count">${esc(countLabel)}</div>
602        </div>
603        ${blocks}`;
604
605      this.results.querySelectorAll('.va-details-toggle').forEach(btn => {
606        btn.addEventListener('click', () => {
607          const card = btn.closest('.va-event, .va-continuous-card');
608          const details = card ? card.querySelector('.va-event-details') : null;
609          if (!card || !details) return;
610          const open = card.classList.toggle('is-open');
611          details.hidden = !open;
612          btn.textContent = open
613            ? (this.str.hideDetails || (this.lang === 'en' ? 'Close' : 'Réduire'))
614            : (this.str.details || (this.lang === 'en' ? 'Details' : 'Détails'));
615          btn.setAttribute('aria-expanded', open ? 'true' : 'false');
616        });
617      });
618      this.results.querySelectorAll('.va-participate').forEach(btn => {
619        btn.addEventListener('click', () => this.toggleParticipation(btn));
620      });
621    }
622
623    moreButtonLabel(count, expanded) {
624      if (expanded) {
625        return `<span class="va-more-symbol">−</span><span>${esc(this.str.showLess || (this.lang === 'en' ? 'Show less' : 'Afficher moins'))}</span>`;
626      }
627      const noun = this.lang === 'en'
628        ? (count === 1 ? 'more event' : 'more events')
629        : (count === 1 ? 'événement' : 'événements');
630      return `<span class="va-more-symbol">+</span><span>${esc(String(count))} ${esc(noun)}</span>`;
631    }
632
633    moreButton(kind, hiddenCount, expanded) {
634      return `<div class="va-more-wrap"><button type="button" class="va-more-button${expanded ? ' is-expanded' : ''}" data-group="${esc(kind)}" data-hidden-count="${esc(String(hiddenCount))}" aria-expanded="${expanded ? 'true' : 'false'}">${this.moreButtonLabel(hiddenCount, expanded)}</button></div>`;
635    }
636
637    formatCountdown(ms) {
638      const totalSeconds = Math.max(0, Math.floor(ms / 1000));
639      const hours = Math.floor(totalSeconds / 3600);
640      const minutes = Math.floor((totalSeconds % 3600) / 60);
641      const seconds = totalSeconds % 60;
642      const pad = n => String(n).padStart(2, '0');
643      return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
644    }
645
646    countdownLabel(ms) {
647      const value = this.formatCountdown(ms);
648      return this.lang === 'en' ? `${value} remaining` : `${value} restantes`;
649    }
650
651    progressGauge(e) {
652      if (!e.current || e.allDay || !e.start || !e.end) return '';
653      const start = new Date(e.start).getTime();
654      const end = new Date(e.end).getTime();
655      const now = Date.now();
656      const duration = end - start;
657      if (!Number.isFinite(start) || !Number.isFinite(end) || duration <= 0) return '';
658      const percent = Math.max(0, Math.min(100, ((now - start) / duration) * 100));
659      const remaining = Math.max(0, end - now);
660      const label = this.countdownLabel(remaining);
661      const aria = this.lang === 'en'
662        ? `${Math.round(percent)}% elapsed, ${label}`
663        : `${Math.round(percent)} % écoulés, ${label}`;
664
665      return `<div class="va-progress" data-va-start="${esc(e.start)}" data-va-end="${esc(e.end)}" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(percent)}" aria-label="${esc(aria)}">
666        <div class="va-progress-head"><span class="va-progress-live">${liveIcon()}${esc(this.str.current || 'EN COURS')}</span></div>
667        <div class="va-progress-track">
668          <span class="va-progress-fill" style="width:${percent.toFixed(2)}%"></span>
669          <span class="va-progress-dot" style="left:${percent.toFixed(2)}%" aria-hidden="true"></span>
670        </div>
671        <div class="va-progress-countdown">${esc(label)}</div>
672      </div>`;
673    }
674
675    updateProgressGauges() {
676      const gauges = this.results ? this.results.querySelectorAll('.va-progress[data-va-start][data-va-end]') : [];
677      gauges.forEach(gauge => {
678        const start = new Date(gauge.dataset.vaStart).getTime();
679        const end = new Date(gauge.dataset.vaEnd).getTime();
680        const now = Date.now();
681        const duration = end - start;
682        if (!Number.isFinite(start) || !Number.isFinite(end) || duration <= 0) return;
683        const percent = Math.max(0, Math.min(100, ((now - start) / duration) * 100));
684        const remaining = Math.max(0, end - now);
685        const label = this.countdownLabel(remaining);
686        const fill = gauge.querySelector('.va-progress-fill');
687        const dot = gauge.querySelector('.va-progress-dot');
688        const countdown = gauge.querySelector('.va-progress-countdown');
689        if (fill) fill.style.width = `${percent.toFixed(2)}%`;
690        if (dot) dot.style.left = `${percent.toFixed(2)}%`;
691        if (countdown) countdown.textContent = label;
692        gauge.setAttribute('aria-valuenow', String(Math.round(percent)));
693        gauge.setAttribute('aria-label', this.lang === 'en'
694          ? `${Math.round(percent)}% elapsed, ${label}`
695          : `${Math.round(percent)} % écoulés, ${label}`);
696        if (remaining <= 0 && gauge.dataset.vaFinished !== '1') {
697          gauge.dataset.vaFinished = '1';
698          window.setTimeout(() => this.load(true), 250);
699        }
700      });
701    }
702
703    getVisitorToken() {
704      const key = 'visa_agenda_visitor_v1';
705      try {
706        let token = window.localStorage ? window.localStorage.getItem(key) : '';
707        if (token && /^[A-Za-z0-9_-]{16,128}$/.test(token)) return token;
708        token = (window.crypto && typeof window.crypto.randomUUID === 'function')
709          ? window.crypto.randomUUID().replace(/[^A-Za-z0-9_-]/g, '')
710          : 'va_' + Math.random().toString(36).slice(2) + Date.now().toString(36) + Math.random().toString(36).slice(2);
711        if (window.localStorage) window.localStorage.setItem(key, token);
712        return token;
713      } catch (e) {
714        return 'va_' + Math.random().toString(36).slice(2) + Date.now().toString(36) + Math.random().toString(36).slice(2);
715      }
716    }
717
718    participationButton(e) {
719      if (!e.participationEnabled) return '';
720      const active = !!e.participating;
721      const count = Math.max(0, parseInt(e.participantCount || '0', 10) || 0);
722      const label = active ? (this.str.participating || 'Je participe') : (this.str.participate || 'Je participe');
723      const countLabel = this.lang === 'en' ? `${count} potential attendee${count === 1 ? '' : 's'}` : `${count} participant${count === 1 ? '' : 's'} potentiel${count === 1 ? '' : 's'}`;
724      const mobileLabel = this.lang === 'en' ? 'Join' : 'Participer';
725      const countHtml = count >
725 0 ? `<span class="va-participation-count" aria-hidden="true">${esc(String(count))}</span>` : '';
726      return `<button type="button" class="va-participate ${active ? 'is-participating' : ''} ${count === 0 ? 'is-zero' : ''}" data-event-id="${esc(e.id)}" data-occurrence-key="${esc(e.key)}" data-participating="${active ? '1' : '0'}" aria-pressed="${active ? 'true' : 'false'}" aria-label="${esc(label + ' — ' + countLabel)}" title="${esc(countLabel)}"><span class="va-participate-inner"><span class="va-participate-check" aria-hidden="true">${active ? '✓' : '+'}</span><span class="va-label-desktop">${esc(label)}</span><span class="va-label-mobile">${esc(mobileLabel)}</span></span>${countHtml}</button>`;
727    }
728
729    async toggleParticipation(btn) {
730      if (!cfg.participationUrl || !this.visitorToken || btn.disabled) return;
731      const joining = btn.dataset.participating !== '1';
732      btn.disabled = true;
733      btn.classList.add('is-saving');
734      try {
735        const res = await fetch(cfg.participationUrl, {
736          method: 'POST',
737          credentials: 'same-origin',
738          cache: 'no-store',
739          headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
740          body: JSON.stringify({
741            event_id: parseInt(btn.dataset.eventId || '0', 10),
742            occurrence_key: btn.dataset.occurrenceKey || '',
743            visitor_token: this.visitorToken,
744            action: joining ? 'join' : 'leave'
745          })
746        });
747        if (!res.ok) throw new Error('HTTP ' + res.status);
748        const data = await res.json();
749        const active = !!data.participating;
750        const count = Math.max(0, parseInt(data.count || '0', 10) || 0);
751        this.results.querySelectorAll('.va-participate').forEach(control => {
752          if (String(control.dataset.eventId || '') !== String(btn.dataset.eventId || '') || String(control.dataset.occurrenceKey || '') !== String(btn.dataset.occurrenceKey || '')) return;
753          control.dataset.participating = active ? '1' : '0';
754          control.setAttribute('aria-pressed', active ? 'true' : 'false');
755          control.classList.toggle('is-participating', active);
756          control.classList.toggle('is-zero', count === 0);
757          const check = control.querySelector('.va-participate-check');
758          let countEl = control.querySelector('.va-participation-count');
759          if (check) check.textContent = active ? '✓' : '+';
760          if (count > 0) {
761            if (!countEl) {
762              countEl = document.createElement('span');
763              countEl.className = 'va-participation-count';
764              countEl.setAttribute('aria-hidden', 'true');
765              control.appendChild(countEl);
766            }
767            countEl.textContent = String(count);
768          } else if (countEl) {
769            countEl.remove();
770          }
771          const countLabel = this.lang === 'en' ? `${count} potential attendee${count === 1 ? '' : 's'}` : `${count} participant${count === 1 ? '' : 's'} potentiel${count === 1 ? '' : 's'}`;
772          const label = active ? (this.str.participating || 'Je participe') : (this.str.participate || 'Je participe');
773          control.setAttribute('aria-label', `${label} — ${countLabel}`);
774          control.setAttribute('title', countLabel);
775        });
776      } catch (e) {
777        this.status.textContent = this.str.participationError || (this.lang === 'en' ? 'Unable to save your participation.' : 'Impossible d’enregistrer votre participation.');
778        window.setTimeout(() => { if (this.status) this.status.textContent = ''; }, 3000);
779      } finally {
780        btn.disabled = false;
781        btn.classList.remove('is-saving');
782      }
783    }
784
785    continuousCard(e, extraClass = '') {
786      const type = e.types && e.types.length ? e.types[0].name : '';
787      const place = e.places && e.places.length ? e.places.map(p => p.name).join(' · ') : '';
788      const current = e.current ? `<span class="va-live">${liveIcon()}<span>${esc(this.str.current || 'EN COURS')}</span></span>` : "";
789      const cancelled = !!e.cancelled;
790      const cancelledClass = cancelled ? ' is-cancelled' : '';
791      const cancelledBadge = cancelled ? `<span class="va-cancelled-badge">${esc(this.str.cancelled || (this.lang === 'en' ? 'CANCELLED' : 'ANNULÉ'))}</span>` : '';
792      const cancelledMessage = cancelled ? `<div class="va-cancelled-message">${esc(e.cancellationMessage || this.str.cancelledMessage || (this.lang === 'en' ? 'This event has been cancelled.' : 'Cet événement est annulé.'))}</div>` : '';
793      const progress = cancelled ? '' : this.progressGauge(e);
794      const bg = backgroundAttrs(e);
795      const endedToday = e.ended && e.date === this.currentDate ? ' is-e
795nded-today' : '';
796      const visualLabel = type || "Visa pour l’Image";
797      const image = e.image
798        ? `<div class="va-continuous-image"><span class="va-photo-frame"><img class="va-event-photo" src="${esc(e.image)}" alt="${esc(e.title)}" loading="eager" decoding="async"></span></div>`
799        : logoPlaceholder('va-continuous-image', visualLabel);
800      const time = e.timeLabel ? `${clockIcon()}<span class="va-time-text">${splitTimeLabel(e.timeLabel)}</span>` : `<span class="va-time-text">${this.lang === 'en' ? 'All day' : 'Toute la journée'}</span>`;
801      const meta = [
802        cancelledBadge,
803        type ? `<span class="va-meta va-type">${esc(type)}</span>` : '',
804        place ? `<span class="va-meta va-place">${pinIcon()}<span>${esc(place)}</span></span>` : '',
805        e.access ? `<span class="va-meta va-access">${esc(e.access)}</span>` : ''
806      ].filter(Boolean).join('');
807      const hasDetails = !!(e.content && e.content.replace(/<[^>]*>/g, '').trim());
808      const detailsUrl = (e.exposition && e.exposition.url) ? e.exposition.url : (e.photographerUrl || e.photographerExpositionUrl || '');
809      const detailsButton = hasDetails
810        ? `<button type="button" class="va-details-toggle" aria-expanded="false">${esc(this.str.details || (this.lang === 'en' ? 'Details' : 'Détails'))}</button>`
811        : (detailsUrl ? `<a class="va-details-link" href="${esc(detailsUrl)}">${esc(this.str.details || (this.lang === 'en' ? 'Details' : 'Détails'))}</a>` : '');
812      const external = !cancelled && e.url ? `<a class="va-action" href="${esc(e.url)}" target="_blank" rel="noopener">${esc(e.urlLabel || (this.lang === 'en' ? 'More information' : 'Plus d’informations'))}</a>` : '';
813      const participation = cancelled ? '' : this.participationButton(e);
814      const directions = !cancelled && e.mapsUrl ? `<a class="va-directions" href="${esc(e.mapsUrl)}" target="_blank" rel="noopener">${pinIcon()}<span>${esc(this.str.directions || (this.lang === 'en' ? 'Directions' : 'Itinéraire'))}</span></a>` : '';
815      const calendarLabel = this.str.calendar || (this.lang === 'en' ? 'Add to calendar' : 'Ajouter à mon agenda');
816      const calendarMobileLabel = this.lang === 'en' ? 'Calendar' : 'Agenda';
817      const ics = cancelled ? '' : `<a class="va-calendar" href="${esc(e.ics)}"><span class="va-label-desktop">${esc(calendarLabel)}</span><span class="va-label-mobile">${esc(calendarMobileLabel)}</span></a>`;
818
819      return `<article class="va-continuous-card${cancelledClass}${endedToday}${bg.cls}${extraClass}"${bg.style}${extraClass.includes('va-more-hidden') ? ' hidden' : ''}>
820        ${image}
821        <div class="va-continuous-content">
822          <div class="va-continuous-top"><span class="va-continuous-time">${time}</span>${current}</div>
823          ${progress}
824          <h3>${esc(e.title)}</h3>
825          <div class="va-event-meta">${meta}</div>
826          ${cancelledMessage}
827          <div class="va-event-actions">${detailsButton}${participation}${directions}${external}${ics}</div>
828          ${hasDetails ? `<div class="va-event-details" hidden>${e.content}</div>` : ''}
829        </div>
830      </article>`;
831    }
832
833    eventCard(e, extraClass = '') {
834      const type = e.types && e.types.length ? e.types[0].name : '';
835      const place = e.places && e.places.length ? e.places.map(p => p.name).join(' · ') : '';
836      const current = e.current ? `<span class="va-live">${liveIcon()}<span>${esc(this.str.current || 'EN COURS')}</span></span>` : "";
837      const cancelled = !!e.cancelled;
838      const cancelledClass = cancelled ? ' is-cancelled' : '';
839      const cancelledBadge = cancelled ? `<span class="va-cancelled-badge">${esc(this.str.cancelled || (this.lang === 'en' ? 'CANCELLED' : 'ANNULÉ'))}</span>` : '';
840      const cancelledMessage = cancelled ? `<div class="va-cancelled-message">${esc(e.cancellationMessage || this.str.cancelledMessage || (this.lang === 'en' ? 'This event has been cancelled.' : 'Cet événement est annulé.'))}</div>` : '';
841      const progress = cancelled ? '' : this.progressGauge(e);
842      const bg = backgroundAttrs(e);
843      const endedToday = e.ended && e.date === this.currentDate ? ' is-e
843nded-today' : '';
844      const featured = e.featured ? ' is-featured' : '';
845      const hasImage = e.image ? ' has-image' : ' no-image';
846      const visualLabel = type || (this.lang === 'en' ? 'Visa pour l’Image' : 'Visa pour l’Image');
847      const image = e.image
848        ? `<div class="va-event-image"><span class="va-photo-frame"><img class="va-event-photo" src="${esc(e.image)}" alt="${esc(e.title)}" loading="eager" decoding="async"></span></div>`
849        : logoPlaceholder('va-event-image', visualLabel);
850      const time = e.timeLabel ? `<div class="va-event-time">${clockIcon()}<span class="va-time-text">${splitTimeLabel(e.timeLabel)}</span></div>` : `<div class="va-event-time"><span class="va-time-text">${this.lang === 'en' ? 'All day' : 'Toute la journée'}</span></div>`;
851      const meta = [
852        cancelledBadge,
853        type ? `<span class="va-meta va-type">${esc(type)}</span>` : '',
854        place ? `<span class="va-meta va-place">${pinIcon()}<span>${esc(place)}</span></span>` : '',
855        e.access ? `<span class="va-meta va-access">${esc(e.access)}</span>` : ''
856      ].filter(Boolean).join('');
857      const hasDetails = !!(e.content && e.content.replace(/<[^>]*>/g, '').trim());
858      const detailsUrl = (e.exposition && e.exposition.url) ? e.exposition.url : (e.photographerUrl || e.photographerExpositionUrl || '');
859      const detailsButton = hasDetails
860        ? `<button type="button" class="va-details-toggle" aria-expanded="false">${esc(this.str.details || (this.lang === 'en' ? 'Details' : 'Détails'))}</button>`
861        : (detailsUrl ? `<a class="va-details-link" href="${esc(detailsUrl)}">${esc(this.str.details || (this.lang === 'en' ? 'Details' : 'Détails'))}</a>` : '');
862      const external = !cancelled && e.url ? `<a class="va-action" href="${esc(e.url)}" target="_blank" rel="noopener">${esc(e.urlLabel || (this.lang === 'en' ? 'More information' : 'Plus d’informations'))}</a>` : '';
863      const participation = cancelled ? '' : this.participationButton(e);
864      const directions = !cancelled && e.mapsUrl ? `<a class="va-directions" href="${esc(e.mapsUrl)}" target="_blank" rel="noopener">${pinIcon()}<span>${esc(this.str.directions || (this.lang === 'en' ? 'Directions' : 'Itinéraire'))}</span></a>` : '';
865      const calendarLabel = this.str.calendar || (this.lang === 'en' ? 'Add to calendar' : 'Ajouter à mon agenda');
866      const calendarMobileLabel = this.lang === 'en' ? 'Calendar' : 'Agenda';
867      const ics = cancelled ? '' : `<a class="va-calendar" href="${esc(e.ics)}"><span class="va-label-desktop">${esc(calendarLabel)}</span><span class="va-label-mobile">${esc(calendarMobileLabel)}</span></a>`;
868
869      return `<article class="va-event${cancelledClass}${endedToday}${featured}${hasImage}${bg.cls}${extraClass}"${bg.style}${extraClass.includes('va-more-hidden') ? ' hidden' : ''}>
870        <div class="va-event-timecol">${time}${current}</div>
871        <div class="va-event-body">
872          <div class="va-event-layout">
873            ${image}
874            <div class="va-event-content">
875              <h3>${esc(e.title)}</h3>
876              <div class="va-event-meta">${meta}</div>
877              ${cancelledMessage}
878              ${progress}
879              <div class="va-event-actions">${detailsButton}${participation}${directions}${external}${ics}</div>
880              ${hasDetails ? `<div class="va-event-details" hidden>${e.content}</div>` : ''}
881            </div>
882          </div>
883        </div>
884      </article>`;
885    }
886  }
887
888  function markAgendaHostColumn(root) {
889    let node = root ? root.parentElement : null;
890    while (node && node !== document.body && node !== document.documentElement) {
891      const classes = typeof node.className === 'string' ? node.className : '';
892      const isColumn = node.classList && (
893        node.classList.contains('wpb_column') ||
894        node.classList.contains('vc_column_container') ||
895        node.classList.contains('column_parent') ||
896        /(?:^|\s)(?:vc_)?col-(?:xs|sm|md|lg)-\d+(?:\s|$)/.test(classes)
897      );
898      if (isColumn) {
899        node.classList.add('va-agenda-host-column');
900        return node;
901      }
902      node = node.parentElement;
903    }
904    return null;
905  }
906
907  function resetPageHorizontalScroll() {
908    if (window.innerWidth > 700) return;
909    // Safari iOS peut conserver un scroll horizontal de la page après
910    // le déplacement du rail des dates. On ne touche jamais au scroll Y.
911    if (window.scrollX !== 0) {
912      try { window.scrollTo(0, window.scrollY); } catch (e) {}
913    }
914  }
915
916  function fitAgendaToMobileViewport(root) {
917    if (!root) return;
918
919    // Depuis 0.18.9 la largeur mobile est gérée uniquement par CSS et par
920    // la colonne hôte Uncode. Aucun décalage left/transform n'est appliqué
921    // à l'agenda : c'était une source de dérive horizontale sur Safari iOS.
922    root.style.removeProperty('position');
923    root.style.removeProperty('left');
924
925    if (window.innerWidth > 700) {
926      root.classList.remove('va-mobile-viewport-fit');
927      root.style.removeProperty('width');
928      root.style.removeProperty('max-width');
929      return;
930    }
931
932    root.classList.add('va-mobile-viewport-fit');
933    root.style.width = '100%';
934    root.style.maxWidth = '100%';
935
936    requestAnimationFrame(resetPageHorizontalScroll);
937  }
938
939  function fitAllAgendaToMobileViewport() {
940    document.querySelectorAll('.visa-agenda').forEach(fitAgendaToMobileViewport);
941  }
942
943  document.addEventListener('DOMContentLoaded', function () {
944    const roots = document.querySelectorAll('.visa-agenda');
945    if (roots.length) {
946      document.body.classList.add('va-has-agenda');
947      document.documentElement.classList.add('va-has-agenda');
948      requestAnimationFrame(resetPageHorizontalScroll);
949    }
950
951    roots.forEach(root => {
952      markAgendaHostColumn(root);
953      fitAgendaToMobileViewport(root);
954      new Agenda(root);
955    });
956
957    let resizeFrame = 0;
958    window.addEventListener('resize', function () {
959      if (resizeFrame) cancelAnimationFrame(resizeFrame);
960      resizeFrame = requestAnimationFrame(() => {
961        fitAllAgendaToMobileViewport();
962        resetPageHorizontalScroll();
963      });
964    }, { passive: true });
965
966    window.addEventListener('orientationchange', function () {
967      setTimeout(fitAllAgendaToMobileViewport, 120);
968    }, { passive: true });
969  });
970})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.