nodebb-plugin-onekite-calendar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1801 @@
1
+ /* global FullCalendar, ajaxify */
2
+
3
+ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alerts, bootbox, hooks) {
4
+ 'use strict';
5
+
6
+ // Ensure small UI tweaks are applied even when themes override bootstrap defaults.
7
+ (function ensureOneKiteStyles() {
8
+ try {
9
+ if (document.getElementById('onekite-inline-styles')) return;
10
+ const style = document.createElement('style');
11
+ style.id = 'onekite-inline-styles';
12
+ style.textContent = `
13
+ /* Fix clipped time text in selects on desktop */
14
+ .onekite-time-select.form-select {
15
+ min-height: 38px;
16
+ padding-top: .375rem;
17
+ padding-bottom: .375rem;
18
+ line-height: 1.25;
19
+ font-size: 0.95rem;
20
+ width: 100%;
21
+ max-width: 100%;
22
+ box-sizing: border-box;
23
+ padding-right: 1.75rem; /* leave room for caret */
24
+ min-width: 0;
25
+ text-overflow: clip;
26
+ white-space: nowrap;
27
+ }
28
+ `;
29
+ document.head.appendChild(style);
30
+ } catch (e) {
31
+ // ignore
32
+ }
33
+ })();
34
+
35
+ // Prevent the reservation dialog from opening twice due to select/dateClick
36
+ // interactions or quick re-renders.
37
+ let isDialogOpen = false;
38
+
39
+ function escapeHtml(str) {
40
+ return String(str)
41
+ .replace(/&/g, '&')
42
+ .replace(/</g, '&lt;')
43
+ .replace(/>/g, '&gt;')
44
+ .replace(/"/g, '&quot;')
45
+ .replace(/'/g, '&#39;');
46
+ }
47
+
48
+ function pad2(n) { return String(n).padStart(2, '0'); }
49
+
50
+ function toDateInputValue(d) {
51
+ const dt = (d instanceof Date) ? d : new Date(d);
52
+ return `${dt.getFullYear()}-${pad2(dt.getMonth() + 1)}-${pad2(dt.getDate())}`;
53
+ }
54
+
55
+ function roundTo5Minutes(dt) {
56
+ const d = new Date(dt);
57
+ const m = d.getMinutes();
58
+ const rounded = Math.round(m / 5) * 5;
59
+ d.setMinutes(rounded, 0, 0);
60
+ return d;
61
+ }
62
+
63
+ function timeString(dt) {
64
+ const d = (dt instanceof Date) ? dt : new Date(dt);
65
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
66
+ }
67
+
68
+ // Build an ISO datetime *with timezone* from local date/time inputs.
69
+ // This avoids ambiguity when the server runs in UTC and prevents
70
+ // "single-day" events (07:00→23:59 local) from spilling into the next day
71
+ // when rendered back to the browser.
72
+ function buildLocalIso(dateStr, timeStr) {
73
+ try {
74
+ const d = new Date(`${dateStr}T${timeStr}`);
75
+ return d.toISOString();
76
+ } catch (e) {
77
+ return `${dateStr}T${timeStr}`;
78
+ }
79
+ }
80
+
81
+ function seTimeOptions(selected, include2359) {
82
+ const opts = [];
83
+ for (let h = 7; h < 24; h++) {
84
+ for (let m = 0; m < 60; m += 5) {
85
+ const t = `${pad2(h)}:${pad2(m)}`;
86
+ opts.push(`<option value="${t}" ${t === selected ? 'selected' : ''}>${t}</option>`);
87
+ }
88
+ }
89
+ if (include2359) {
90
+ const t = '23:59';
91
+ opts.push(`<option value="${t}" ${t === selected ? 'selected' : ''}>${t}</option>`);
92
+ }
93
+ return opts.join('');
94
+ }
95
+
96
+ async function openSpecialEventDialog(selectionInfo) {
97
+ const start = selectionInfo.start;
98
+ // FullCalendar can omit `end` for certain interactions. Also, for all-day
99
+ // selections, `end` is exclusive (next day at 00:00). We normalise below
100
+ // so a single-day click always defaults to a single-day range.
101
+ const end = selectionInfo.end;
102
+
103
+ // Prefer event times starting at 07:00 for day-clicks (all-day selections).
104
+ let seStart = new Date(start);
105
+ // If `end` is missing, start with the same day.
106
+ let seEnd = end ? new Date(end) : new Date(seStart);
107
+
108
+ const msSpan = seEnd.getTime() - seStart.getTime();
109
+ const isAllDaySelection = selectionInfo && (selectionInfo.allDay || (
110
+ seStart.getHours() === 0 && seStart.getMinutes() === 0 &&
111
+ seEnd.getHours() === 0 && seEnd.getMinutes() === 0
112
+ ));
113
+
114
+ // For all-day selections, the simplest robust check is:
115
+ // does (end - 1ms) fall on the same calendar day as start?
116
+ const endMinus1 = isAllDaySelection ? new Date(seEnd.getTime() - 1) : null;
117
+ const isSingleDayAllDay = isAllDaySelection && (!end || (endMinus1 && toDateInputValue(seStart) === toDateInputValue(endMinus1)));
118
+
119
+ if (isSingleDayAllDay) {
120
+ // Keep same day; default to a "one day" block (07:00 → 07:00).
121
+ // We keep end date equal to start date in the modal. On submit,
122
+ // if end <= start we automatically roll end by +1 day.
123
+ seStart.setHours(7, 0, 0, 0);
124
+ seEnd = new Date(seStart);
125
+ seEnd.setHours(7, 0, 0, 0);
126
+ } else if (isAllDaySelection) {
127
+ // Multi-day all-day selection: start at 07:00 on first day, end at 07:00 on last day
128
+ // (end date shown is the last selected day; submit logic will roll if needed).
129
+ seStart.setHours(7, 0, 0, 0);
130
+ const lastDay = endMinus1 ? new Date(endMinus1) : new Date(seEnd);
131
+ seEnd = new Date(lastDay);
132
+ seEnd.setHours(7, 0, 0, 0);
133
+ } else {
134
+ seStart = roundTo5Minutes(seStart);
135
+ seEnd = roundTo5Minutes(seEnd);
136
+ if (seStart.getHours() < 7) {
137
+ seStart.setHours(7, 0, 0, 0);
138
+ if (seEnd <= seStart) {
139
+ seEnd = new Date(seStart);
140
+ seEnd.setHours(8, 0, 0, 0);
141
+ }
142
+ }
143
+ }
144
+
145
+ const seStartTime = timeString(seStart);
146
+ const seEndTime = timeString(seEnd);
147
+ const html = `
148
+ <div class="mb-3">
149
+ <label class="form-label">Titre</label>
150
+ <input type="text" class="form-control" id="onekite-se-title" placeholder="Ex: ..." />
151
+ </div>
152
+ <div class="row g-2">
153
+ <div class="col-12 col-md-6">
154
+ <label class="form-label">Début</label>
155
+ <div class="row g-2 align-items-end">
156
+ <div class="col-12 col-sm-7">
157
+ <input type="date" class="form-control" id="onekite-se-start-date" value="${escapeHtml(toDateInputValue(seStart))}" />
158
+ </div>
159
+ <div class="col-12 col-sm-5">
160
+ <select class="form-select onekite-time-select" id="onekite-se-start-time">${seTimeOptions(seStartTime, false)}</select>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ <div class="col-12 col-md-6">
165
+ <label class="form-label">Fin</label>
166
+ <div class="row g-2 align-items-end">
167
+ <div class="col-12 col-sm-7">
168
+ <input type="date" class="form-control" id="onekite-se-end-date" value="${escapeHtml(toDateInputValue(seEnd))}" />
169
+ </div>
170
+ <div class="col-12 col-sm-5">
171
+ <select class="form-select onekite-time-select" id="onekite-se-end-time">${seTimeOptions(seEndTime, true)}</select>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ <div class="mt-3">
177
+ <label class="form-label">Adresse</label>
178
+ <div class="input-group">
179
+ <input type="text" class="form-control" id="onekite-se-address" placeholder="Adresse complète" />
180
+ <button class="btn btn-outline-secondary" type="button" id="onekite-se-geocode">Rechercher</button>
181
+ </div>
182
+ <div id="onekite-se-map" style="height:220px; border:1px solid #ddd; border-radius:6px; margin-top:0.5rem;"></div>
183
+ <input type="hidden" id="onekite-se-lat" />
184
+ <input type="hidden" id="onekite-se-lon" />
185
+ </div>
186
+ <div class="mt-3">
187
+ <label class="form-label">Notes (facultatif)</label>
188
+ <textarea class="form-control" id="onekite-se-notes" rows="3" placeholder="..."></textarea>
189
+ </div>
190
+ `;
191
+
192
+ return await new Promise((resolve) => {
193
+ let resolved = false;
194
+ const dialog = bootbox.dialog({
195
+ title: 'Créer un évènement',
196
+ message: html,
197
+ buttons: {
198
+ cancel: {
199
+ label: 'Annuler',
200
+ className: 'btn-secondary',
201
+ callback: () => {
202
+ resolved = true;
203
+ resolve(null);
204
+ },
205
+ },
206
+ ok: {
207
+ label: 'Créer',
208
+ className: 'btn-primary',
209
+ callback: () => {
210
+ const title = (document.getElementById('onekite-se-title')?.value || '').trim();
211
+ const sd = (document.getElementById('onekite-se-start-date')?.value || '').trim();
212
+ const st = (document.getElementById('onekite-se-start-time')?.value || '').trim();
213
+ const ed = (document.getElementById('onekite-se-end-date')?.value || '').trim();
214
+ const et = (document.getElementById('onekite-se-end-time')?.value || '').trim();
215
+ // If the user keeps the same day but chooses an end time earlier than
216
+ // (or equal to) the start time (default 07:00 → 07:00), interpret
217
+ // it as an overnight/24h range and roll the end date by +1 day.
218
+ let startVal = '';
219
+ let endVal = '';
220
+ try {
221
+ if (sd && st) {
222
+ const startDt = new Date(`${sd}T${st}`);
223
+ startVal = startDt.toISOString();
224
+ if (ed && et) {
225
+ let endDt = new Date(`${ed}T${et}`);
226
+ if (!isNaN(startDt) && !isNaN(endDt) && endDt.getTime() <= startDt.getTime()) {
227
+ // Only auto-roll when user kept the same end date.
228
+ if (ed === sd) {
229
+ endDt = new Date(endDt.getTime() + 24 * 60 * 60 * 1000);
230
+ }
231
+ }
232
+ endVal = endDt.toISOString();
233
+ }
234
+ }
235
+ } catch (e) {
236
+ startVal = (sd && st) ? buildLocalIso(sd, st) : '';
237
+ endVal = (ed && et) ? buildLocalIso(ed, et) : '';
238
+ }
239
+ const address = (document.getElementById('onekite-se-address')?.value || '').trim();
240
+ const notes = (document.getElementById('onekite-se-notes')?.value || '').trim();
241
+ const lat = (document.getElementById('onekite-se-lat')?.value || '').trim();
242
+ const lon = (document.getElementById('onekite-se-lon')?.value || '').trim();
243
+ resolved = true;
244
+ resolve({ title, start: startVal, end: endVal, address, notes, lat, lon });
245
+ return true;
246
+ },
247
+ },
248
+ },
249
+ });
250
+
251
+ // If the modal is closed via ESC, backdrop click, or the "X" button,
252
+ // Bootbox does not trigger our cancel callback. Ensure we always resolve
253
+ // and release the global lock.
254
+ try {
255
+ dialog.on('hidden.bs.modal', () => {
256
+ if (resolved) return;
257
+ resolved = true;
258
+ resolve(null);
259
+ });
260
+ } catch (e) {
261
+ // ignore
262
+ }
263
+
264
+ // init leaflet
265
+ dialog.on('shown.bs.modal', () => {
266
+ setTimeout(async () => {
267
+
268
+ try {
269
+ const mapEl = document.getElementById('onekite-se-map');
270
+ if (!mapEl) return;
271
+ const L = await loadLeaflet();
272
+ const map = L.map(mapEl).setView([46.5, 2.5], 5);
273
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '&copy; OpenStreetMap' }).addTo(map);
274
+ setTimeout(() => { try { map.invalidateSize(); } catch (e) {} }, 100);
275
+ let marker = null;
276
+ function setMarker(lat, lon) {
277
+ if (marker) map.removeLayer(marker);
278
+ marker = L.marker([lat, lon], { draggable: true }).addTo(map);
279
+ marker.on('dragend', () => {
280
+ const p = marker.getLatLng();
281
+ document.getElementById('onekite-se-lat').value = String(p.lat);
282
+ document.getElementById('onekite-se-lon').value = String(p.lng);
283
+ });
284
+ document.getElementById('onekite-se-lat').value = String(lat);
285
+ document.getElementById('onekite-se-lon').value = String(lon);
286
+ map.setView([lat, lon], 14);
287
+ }
288
+ const geocodeBtn = document.getElementById('onekite-se-geocode');
289
+ const addrInput = document.getElementById('onekite-se-address');
290
+ try {
291
+ attachAddressAutocomplete(addrInput, (h) => {
292
+ if (h && Number.isFinite(h.lat) && Number.isFinite(h.lon)) {
293
+ setMarker(h.lat, h.lon);
294
+ }
295
+ });
296
+ } catch (e) {}
297
+ geocodeBtn?.addEventListener('click', async () => {
298
+ const q = (addrInput?.value || '').trim();
299
+ if (!q) return;
300
+ const hit = await geocodeAddress(q);
301
+ if (hit && hit.lat && hit.lon) {
302
+ setMarker(hit.lat, hit.lon);
303
+ } else {
304
+ showAlert('error', 'Adresse introuvable.');
305
+ }
306
+ });
307
+ } catch (e) {
308
+ // ignore leaflet errors
309
+ }
310
+ }, 50);
311
+ });
312
+ });
313
+ }
314
+
315
+ async function openMapViewer(title, address, lat, lon) {
316
+ const mapId = `onekite-map-view-${Date.now()}-${Math.floor(Math.random()*10000)}`;
317
+ const safeAddr = (address || '').trim();
318
+ const html = `
319
+ ${safeAddr ? `<div class="mb-2">${escapeHtml(safeAddr)}</div>` : ''}
320
+ <div id="${mapId}" style="height:260px; border:1px solid #ddd; border-radius:6px;"></div>
321
+ `;
322
+ const dialog = bootbox.dialog({
323
+ title: title || 'Carte',
324
+ message: html,
325
+ buttons: { close: { label: 'Fermer', className: 'btn-secondary' } },
326
+ });
327
+ dialog.on('shown.bs.modal', () => {
328
+ setTimeout(async () => {
329
+
330
+ try {
331
+ const el = document.getElementById(mapId);
332
+ if (!el) return;
333
+ const L = await loadLeaflet();
334
+ const map = L.map(el);
335
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '&copy; OpenStreetMap' }).addTo(map);
336
+ setTimeout(() => { try { map.invalidateSize(); } catch (e) {} }, 100);
337
+
338
+ async function setAt(lat2, lon2) {
339
+ map.setView([lat2, lon2], 14);
340
+ L.marker([lat2, lon2]).addTo(map);
341
+ }
342
+
343
+ const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
344
+ if (hasCoords) {
345
+ await setAt(lat, lon);
346
+ return;
347
+ }
348
+ if (safeAddr) {
349
+ const hit = await geocodeAddress(safeAddr);
350
+ if (hit && hit.lat && hit.lon) {
351
+ await setAt(hit.lat, hit.lon);
352
+ return;
353
+ }
354
+ }
355
+ map.setView([46.5, 2.5], 5);
356
+ } catch (e) {
357
+ // ignore leaflet errors
358
+ }
359
+ }, 50);
360
+ });
361
+ }
362
+
363
+ // Click handler for map links in popups
364
+ document.addEventListener('click', (ev) => {
365
+ const a = ev.target && ev.target.closest ? ev.target.closest('a.onekite-map-link') : null;
366
+ if (!a) return;
367
+ ev.preventDefault();
368
+ const addr = a.getAttribute('data-address') || a.textContent || '';
369
+ const lat = parseFloat(a.getAttribute('data-lat'));
370
+ const lon = parseFloat(a.getAttribute('data-lon'));
371
+ openMapViewer('Adresse', addr, lat, lon);
372
+ });
373
+ // Close any open Bootbox modal when navigating to a user profile from within a modal
374
+ document.addEventListener('click', (ev) => {
375
+ const a = ev.target && ev.target.closest ? ev.target.closest('a.onekite-user-link') : null;
376
+ if (!a) return;
377
+ const inModal = !!(a.closest && a.closest('.modal, .bootbox'));
378
+ if (!inModal) return;
379
+ const href = a.getAttribute('href');
380
+ if (!href) return;
381
+ ev.preventDefault();
382
+ try { bootbox.hideAll(); } catch (e) {}
383
+ setTimeout(() => { window.location.href = href; }, 50);
384
+ });
385
+
386
+
387
+
388
+ function statusLabel(s) {
389
+ const map = {
390
+ pending: 'En attente',
391
+ awaiting_payment: 'Validée – paiement en attente',
392
+ paid: 'Payée',
393
+ rejected: 'Rejetée',
394
+ expired: 'Expirée',
395
+ };
396
+ return map[String(s || '')] || String(s || '');
397
+ }
398
+
399
+ function showAlert(type, msg) {
400
+ try {
401
+ if (alerts && typeof alerts[type] === 'function') {
402
+ alerts[type](msg);
403
+ return;
404
+ }
405
+ } catch (e) {}
406
+ alert(msg);
407
+ }
408
+
409
+ async function fetchJson(url, opts) {
410
+ const res = await fetch(url, {
411
+ credentials: 'same-origin',
412
+ headers: (() => {
413
+ const headers = { 'Content-Type': 'application/json' };
414
+ const token =
415
+ (window.config && (window.config.csrf_token || window.config.csrfToken)) ||
416
+ (window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
417
+ (document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
418
+ (document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
419
+ (typeof app !== 'undefined' && app && app.csrfToken) ||
420
+ null;
421
+ if (token) headers['x-csrf-token'] = token;
422
+ return headers;
423
+ })(),
424
+ ...opts,
425
+ });
426
+ if (!res.ok) {
427
+ let payload = null;
428
+ try { payload = await res.json(); } catch (e) {}
429
+ const err = new Error(`${res.status}`);
430
+ err.status = res.status;
431
+ err.payload = payload;
432
+ throw err;
433
+ }
434
+ return await res.json();
435
+ }
436
+
437
+ // Simple in-memory cache for JSON endpoints (used for events prefetch + ETag).
438
+ const jsonCache = new Map(); // url -> { etag, data, ts }
439
+
440
+ function scheduleRefetch(cal) {
441
+ try {
442
+ if (!cal || typeof cal.refetchEvents !== 'function') return;
443
+ clearTimeout(window.__onekiteRefetchTimer);
444
+ window.__onekiteRefetchTimer = setTimeout(() => {
445
+ try { cal.refetchEvents(); } catch (e) {}
446
+ }, 150);
447
+ } catch (e) {}
448
+ }
449
+
450
+ async function fetchJsonCached(url, opts) {
451
+ const cached = jsonCache.get(url);
452
+ const headers = Object.assign({}, (opts && opts.headers) || {});
453
+ if (cached && cached.etag) {
454
+ headers['If-None-Match'] = cached.etag;
455
+ }
456
+ let res;
457
+ try {
458
+ res = await fetch(url, {
459
+ credentials: 'same-origin',
460
+ headers: (() => {
461
+ // reuse csrf header builder (fetchJson) by calling it indirectly
462
+ const base = { 'Content-Type': 'application/json' };
463
+ const token =
464
+ (window.config && (window.config.csrf_token || window.config.csrfToken)) ||
465
+ (window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
466
+ (document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
467
+ (document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
468
+ (typeof app !== 'undefined' && app && app.csrfToken) ||
469
+ null;
470
+ if (token) base['x-csrf-token'] = token;
471
+ return Object.assign(base, headers);
472
+ })(),
473
+ ...opts,
474
+ });
475
+ } catch (e) {
476
+ // If offline and we have cache, use it.
477
+ if (cached && cached.data) return cached.data;
478
+ throw e;
479
+ }
480
+
481
+ if (res.status === 304 && cached && cached.data) {
482
+ return cached.data;
483
+ }
484
+
485
+ if (!res.ok) {
486
+ let payload = null;
487
+ try { payload = await res.json(); } catch (e) {}
488
+ const err = new Error(`${res.status}`);
489
+ err.status = res.status;
490
+ err.payload = payload;
491
+ throw err;
492
+ }
493
+
494
+ const data = await res.json();
495
+ const etag = res.headers.get('ETag') || '';
496
+ jsonCache.set(url, { etag, data, ts: Date.now() });
497
+ return data;
498
+ }
499
+
500
+ async function loadCapabilities() {
501
+ return await fetchJson('/api/v3/plugins/calendar-onekite/capabilities');
502
+ }
503
+
504
+ // Leaflet (OpenStreetMap) helpers - loaded lazily only when needed.
505
+ let leafletPromise = null;
506
+ function loadLeaflet() {
507
+ if (leafletPromise) return leafletPromise;
508
+ leafletPromise = new Promise((resolve, reject) => {
509
+ try {
510
+ if (window.L && window.L.map) {
511
+ resolve(window.L);
512
+ return;
513
+ }
514
+
515
+ const cssId = 'onekite-leaflet-css';
516
+ const jsId = 'onekite-leaflet-js';
517
+
518
+ if (!document.getElementById(cssId)) {
519
+ const link = document.createElement('link');
520
+ link.id = cssId;
521
+ link.rel = 'stylesheet';
522
+ link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
523
+ document.head.appendChild(link);
524
+ }
525
+
526
+ const existing = document.getElementById(jsId);
527
+ if (existing) {
528
+ existing.addEventListener('load', () => resolve(window.L));
529
+ existing.addEventListener('error', () => reject(new Error('leaflet-load-failed')));
530
+ return;
531
+ }
532
+
533
+ const script = document.createElement('script');
534
+ script.id = jsId;
535
+ script.async = true;
536
+ script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
537
+ script.onload = () => resolve(window.L);
538
+ script.onerror = () => reject(new Error('leaflet-load-failed'));
539
+ document.head.appendChild(script);
540
+ } catch (e) {
541
+ reject(e);
542
+ }
543
+ });
544
+ return leafletPromise;
545
+ }
546
+
547
+ async function geocodeAddress(query) {
548
+ const q = String(query || '').trim();
549
+ if (!q) return null;
550
+ const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(q)}`;
551
+ const res = await fetch(url, {
552
+ method: 'GET',
553
+ headers: {
554
+ 'Accept': 'application/json',
555
+ 'Accept-Language': 'fr',
556
+ },
557
+ });
558
+ if (!res.ok) return null;
559
+ const arr = await res.json();
560
+ if (!Array.isArray(arr) || !arr.length) return null;
561
+ const hit = arr[0];
562
+ const lat = Number(hit.lat);
563
+ const lon = Number(hit.lon);
564
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
565
+ return { lat, lon, displayName: hit.display_name || q };
566
+ }
567
+
568
+ async function searchAddresses(query, limit) {
569
+ const q = String(query || '').trim();
570
+ const lim = Math.min(10, Math.max(1, Number(limit) || 5));
571
+ if (q.length < 3) return [];
572
+ const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&addressdetails=1&limit=${lim}&q=${encodeURIComponent(q)}`;
573
+ const res = await fetch(url, {
574
+ method: 'GET',
575
+ headers: {
576
+ 'Accept': 'application/json',
577
+ 'Accept-Language': 'fr',
578
+ },
579
+ });
580
+ if (!res.ok) return [];
581
+ const arr = await res.json();
582
+ if (!Array.isArray(arr)) return [];
583
+ return arr.map((hit) => {
584
+ const lat = Number(hit.lat);
585
+ const lon = Number(hit.lon);
586
+ return {
587
+ displayName: hit.display_name || q,
588
+ lat: Number.isFinite(lat) ? lat : null,
589
+ lon: Number.isFinite(lon) ? lon : null,
590
+ };
591
+ }).filter(h => h && h.displayName);
592
+ }
593
+
594
+ function attachAddressAutocomplete(inputEl, onPick) {
595
+ if (!inputEl) return;
596
+ // Avoid double attach
597
+ if (inputEl.getAttribute('data-onekite-autocomplete') === '1') return;
598
+ inputEl.setAttribute('data-onekite-autocomplete', '1');
599
+
600
+ const wrapper = document.createElement('div');
601
+ wrapper.style.position = 'relative';
602
+ inputEl.parentNode && inputEl.parentNode.insertBefore(wrapper, inputEl);
603
+ wrapper.appendChild(inputEl);
604
+
605
+ const menu = document.createElement('div');
606
+ menu.className = 'onekite-autocomplete-menu';
607
+ menu.style.position = 'absolute';
608
+ menu.style.left = '0';
609
+ menu.style.right = '0';
610
+ menu.style.top = '100%';
611
+ menu.style.zIndex = '2000';
612
+ menu.style.background = '#fff';
613
+ menu.style.border = '1px solid rgba(0,0,0,.15)';
614
+ menu.style.borderTop = '0';
615
+ menu.style.maxHeight = '220px';
616
+ menu.style.overflowY = 'auto';
617
+ menu.style.display = 'none';
618
+ menu.style.borderRadius = '0 0 .375rem .375rem';
619
+ wrapper.appendChild(menu);
620
+
621
+ let timer = null;
622
+ let lastQuery = '';
623
+ let busy = false;
624
+
625
+ function hide() {
626
+ menu.style.display = 'none';
627
+ menu.innerHTML = '';
628
+ }
629
+
630
+ function show(hits) {
631
+ if (!hits || !hits.length) {
632
+ hide();
633
+ return;
634
+ }
635
+ menu.innerHTML = '';
636
+ hits.forEach((h) => {
637
+ const btn = document.createElement('button');
638
+ btn.type = 'button';
639
+ btn.className = 'onekite-autocomplete-item';
640
+ btn.textContent = h.displayName;
641
+ btn.style.display = 'block';
642
+ btn.style.width = '100%';
643
+ btn.style.textAlign = 'left';
644
+ btn.style.padding = '.35rem .5rem';
645
+ btn.style.border = '0';
646
+ btn.style.background = 'transparent';
647
+ btn.style.cursor = 'pointer';
648
+ btn.addEventListener('click', () => {
649
+ inputEl.value = h.displayName;
650
+ hide();
651
+ try { onPick && onPick(h); } catch (e) {}
652
+ });
653
+ btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(0,0,0,.05)'; });
654
+ btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; });
655
+ menu.appendChild(btn);
656
+ });
657
+ menu.style.display = 'block';
658
+ }
659
+
660
+ async function run(q) {
661
+ if (busy) return;
662
+ busy = true;
663
+ try {
664
+ const hits = await searchAddresses(q, 6);
665
+ // Don't show stale results
666
+ if (String(inputEl.value || '').trim() !== q) return;
667
+ show(hits);
668
+ } catch (e) {
669
+ hide();
670
+ } finally {
671
+ busy = false;
672
+ }
673
+ }
674
+
675
+ inputEl.addEventListener('input', () => {
676
+ const q = String(inputEl.value || '').trim();
677
+ lastQuery = q;
678
+ if (timer) clearTimeout(timer);
679
+ if (q.length < 3) {
680
+ hide();
681
+ return;
682
+ }
683
+ timer = setTimeout(() => run(lastQuery), 250);
684
+ });
685
+
686
+ inputEl.addEventListener('focus', () => {
687
+ const q = String(inputEl.value || '').trim();
688
+ if (q.length >= 3) {
689
+ if (timer) clearTimeout(timer);
690
+ timer = setTimeout(() => run(q), 150);
691
+ }
692
+ });
693
+
694
+ // Close when clicking outside
695
+ document.addEventListener('click', (e) => {
696
+ try {
697
+ if (!wrapper.contains(e.target)) hide();
698
+ } catch (err) {}
699
+ });
700
+
701
+ inputEl.addEventListener('keydown', (e) => {
702
+ if (e.key === 'Escape') hide();
703
+ });
704
+ }
705
+
706
+
707
+ async function loadItems() {
708
+ try {
709
+ return await fetchJson('/api/v3/plugins/calendar-onekite/items');
710
+ } catch (e) {
711
+ return [];
712
+ }
713
+ }
714
+
715
+ async function requestReservation(payload) {
716
+ return await fetchJson('/api/v3/plugins/calendar-onekite/reservations', {
717
+ method: 'POST',
718
+ body: JSON.stringify(payload),
719
+ });
720
+ }
721
+
722
+ async function approveReservation(rid, payload) {
723
+ return await fetchJson(`/api/v3/plugins/calendar-onekite/reservations/${rid}/approve`, {
724
+ method: 'PUT',
725
+ body: JSON.stringify(payload || {}),
726
+ });
727
+ }
728
+
729
+ async function cancelReservation(rid) {
730
+ return await fetchJson(`/api/v3/plugins/calendar-onekite/reservations/${rid}/cancel`, {
731
+ method: 'PUT',
732
+ });
733
+ }
734
+
735
+ async function refuseReservation(rid, payload) {
736
+ return await fetchJson(`/api/v3/plugins/calendar-onekite/reservations/${rid}/refuse`, {
737
+ method: 'PUT',
738
+ body: JSON.stringify(payload || {}),
739
+ });
740
+ }
741
+
742
+ function formatDt(d) {
743
+ // UI requirement: show dates only (no hours)
744
+ try {
745
+ return new Date(d).toLocaleDateString('fr-FR');
746
+ } catch (e) {
747
+ return String(d);
748
+ }
749
+ }
750
+
751
+
752
+ function formatDtWithTime(d) {
753
+ try {
754
+ return new Date(d).toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' });
755
+ } catch (e) {
756
+ return String(d);
757
+ }
758
+ }
759
+
760
+
761
+
762
+ function toLocalYmd(date) {
763
+ const d = new Date(date);
764
+ const pad = (n) => String(n).padStart(2, '0');
765
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
766
+ }
767
+
768
+ function toDatetimeLocalValue(date) {
769
+ const d = new Date(date);
770
+ const pad = (n) => String(n).padStart(2, '0');
771
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
772
+ }
773
+
774
+ async function openReservationDialog(selectionInfo, items) {
775
+ const start = selectionInfo.start;
776
+ const end = selectionInfo.end;
777
+
778
+ // days (end is exclusive in FullCalendar)
779
+ const msPerDay = 24 * 60 * 60 * 1000;
780
+ const days = Math.max(1, Math.round((end.getTime() - start.getTime()) / msPerDay));
781
+
782
+ // Fetch existing events overlapping the selection to disable already reserved items.
783
+ let blocked = new Set();
784
+ try {
785
+ const qs = new URLSearchParams({ start: selectionInfo.startStr, end: selectionInfo.endStr });
786
+ const evs = await fetchJson(`/api/v3/plugins/calendar-onekite/events?${qs.toString()}`);
787
+ (evs || []).forEach((ev) => {
788
+ const st = (ev.extendedProps && ev.extendedProps.status) || '';
789
+ if (!['pending', 'awaiting_payment', 'approved', 'paid'].includes(st)) return;
790
+ const ids = (ev.extendedProps && ev.extendedProps.itemIds) || (ev.extendedProps && ev.extendedProps.itemId ? [ev.extendedProps.itemId] : []);
791
+ ids.forEach((id) => blocked.add(String(id)));
792
+ });
793
+ } catch (e) {}
794
+
795
+ // Price display: prices are returned in cents; show euros (price / 100)
796
+ const fmtPrice = (p) => {
797
+ const n = typeof p === 'number' ? p : parseFloat(String(p || '0'));
798
+ const val = (isNaN(n) ? 0 : n) / 100;
799
+ // Keep 2 decimals if needed
800
+ return val.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
801
+ };
802
+
803
+ const rows = items.map((it, idx) => {
804
+ const id = String(it.id);
805
+ const disabled = blocked.has(id);
806
+ const priceTxt = fmtPrice(it.price || 0);
807
+ const safeName = String(it.name).replace(/</g, '&lt;').replace(/>/g, '&gt;');
808
+ return `
809
+ <div class="d-flex align-items-center py-1 ${disabled ? 'opacity-50' : ''}" style="border-bottom: 1px solid var(--bs-border-color, #ddd);">
810
+ <div style="width: 26px;">
811
+ ${disabled ? '' : `<input type="checkbox" class="form-check-input onekite-item-cb" data-id="${id}" data-name="${safeName}" data-price="${String(it.price || 0)}">`}
812
+ </div>
813
+ <div class="flex-grow-1">
814
+ <div><strong>${safeName}</strong></div>
815
+ <div class="text-muted" style="font-size: 12px;">${priceTxt} € / jour</div>
816
+ </div>
817
+ </div>
818
+ `;
819
+ }).join('');
820
+
821
+ const messageHtml = `
822
+ <div class="mb-2"><strong>Période</strong><br>${formatDt(start)} → ${formatDt(end)} <span class="text-muted">(${days} jour${days > 1 ? 's' : ''})</span></div>
823
+ <div class="mb-2"><strong>Matériel</strong></div>
824
+ <div id="onekite-items" class="mb-2" style="max-height: 320px; overflow: auto; border: 1px solid var(--bs-border-color, #ddd); border-radius: 6px; padding: 6px;">
825
+ ${rows}
826
+ </div>
827
+ <div class="d-flex justify-content-between align-items-center">
828
+ <div class="text-muted">Total estimé</div>
829
+ <div id="onekite-total" style="font-size: 18px;"><strong>0,00 €</strong></div>
830
+ </div>
831
+ <div class="text-muted" style="font-size: 12px;">Les matériels grisés sont déjà réservés ou en attente.</div>
832
+ `;
833
+
834
+ return new Promise((resolve) => {
835
+ let settled = false;
836
+ const safeResolve = (v) => {
837
+ if (settled) { return; }
838
+ settled = true;
839
+ resolve(v);
840
+ };
841
+ const dlg = bootbox.dialog({
842
+ title: 'Demander une réservation',
843
+ message: messageHtml,
844
+ buttons: {
845
+ cancel: {
846
+ label: 'Annuler',
847
+ className: 'btn-secondary',
848
+ callback: function () {
849
+ resolve(null);
850
+ },
851
+ },
852
+ ok: {
853
+ label: 'Envoyer',
854
+ className: 'btn-primary',
855
+ callback: function () {
856
+ const cbs = Array.from(document.querySelectorAll('.onekite-item-cb')).filter(cb => cb.checked);
857
+ if (!cbs.length) {
858
+ showAlert('error', 'Choisis au moins un matériel.');
859
+ return false;
860
+ }
861
+ const itemIds = cbs.map(cb => cb.getAttribute('data-id'));
862
+ const itemNames = cbs.map(cb => cb.getAttribute('data-name'));
863
+ const sum = cbs.reduce((acc, cb) => acc + (parseFloat(cb.getAttribute('data-price') || '0') || 0), 0);
864
+ const total = (sum / 100) * days;
865
+ resolve({ itemIds, itemNames, total, days });
866
+ },
867
+ },
868
+ },
869
+ });
870
+
871
+ // live total update
872
+ setTimeout(() => {
873
+ const totalEl = document.getElementById('onekite-total');
874
+ function refreshTotal() {
875
+ const cbs = Array.from(document.querySelectorAll('.onekite-item-cb')).filter(cb => cb.checked);
876
+ const sum = cbs.reduce((acc, cb) => acc + (parseFloat(cb.getAttribute('data-price') || '0') || 0), 0);
877
+ const total = (sum / 100) * days;
878
+ if (totalEl) {
879
+ totalEl.innerHTML = `<strong>${total.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €</strong>`;
880
+ }
881
+ }
882
+ document.querySelectorAll('.onekite-item-cb').forEach(cb => cb.addEventListener('change', refreshTotal));
883
+ refreshTotal();
884
+ }, 0);
885
+ });
886
+ }
887
+
888
+ async function init(selector) {
889
+ const el = document.querySelector(selector);
890
+ if (!el) {
891
+ return;
892
+ }
893
+
894
+ if (typeof FullCalendar === 'undefined') {
895
+ showAlert('error', 'FullCalendar non chargé');
896
+ return;
897
+ }
898
+
899
+ const items = await loadItems();
900
+ const caps = await loadCapabilities().catch(() => ({}));
901
+ const canCreateSpecial = !!caps.canCreateSpecial;
902
+ const canDeleteSpecial = !!caps.canDeleteSpecial;
903
+
904
+ // Current creation mode: reservation (location) or special (event).
905
+ // Persist the user's last choice so actions (create/approve/refuse/refetch) don't reset it.
906
+ const modeStorageKey = (() => {
907
+ try {
908
+ const uid = (typeof app !== 'undefined' && app && app.user && (app.user.uid || app.user.uid === 0)) ? String(app.user.uid)
909
+ : (window.ajaxify && window.ajaxify.data && (window.ajaxify.data.uid || window.ajaxify.data.uid === 0)) ? String(window.ajaxify.data.uid)
910
+ : '0';
911
+ return `onekiteCalendarMode:${uid}`;
912
+ } catch (e) {
913
+ return 'onekiteCalendarMode:0';
914
+ }
915
+ })();
916
+
917
+ function loadSavedMode() {
918
+ try {
919
+ const v = (window.localStorage && window.localStorage.getItem(modeStorageKey)) || '';
920
+ return (v === 'special' || v === 'reservation') ? v : 'reservation';
921
+ } catch (e) {
922
+ return 'reservation';
923
+ }
924
+ }
925
+
926
+ function saveMode(v) {
927
+ try {
928
+ if (!window.localStorage) return;
929
+ window.localStorage.setItem(modeStorageKey, v);
930
+ } catch (e) {
931
+ // ignore
932
+ }
933
+ }
934
+
935
+ let mode = loadSavedMode();
936
+ // Avoid showing the "mode évènement" hint multiple times (desktop + mobile handlers)
937
+ let lastModeHintAt = 0;
938
+
939
+ function refreshDesktopModeButton() {
940
+ try {
941
+ const btn = document.querySelector('#onekite-calendar .fc-newSpecial-button');
942
+ if (!btn) return;
943
+
944
+ const isSpecial = mode === 'special';
945
+ const label = isSpecial ? 'Évènement ✓' : 'Évènement';
946
+
947
+ // Ensure a single canonical .fc-button-text (prevents "ÉvènementÉvènement" after rerenders)
948
+ let span = btn.querySelector('.fc-button-text');
949
+ if (!span) {
950
+ span = document.createElement('span');
951
+ span.className = 'fc-button-text';
952
+ // Remove stray text nodes before inserting span
953
+ [...btn.childNodes].forEach((n) => {
954
+ if (n && n.nodeType === Node.TEXT_NODE) n.remove();
955
+ });
956
+ btn.appendChild(span);
957
+ } else {
958
+ // Remove any stray text nodes beside the span
959
+ [...btn.childNodes].forEach((n) => {
960
+ if (n && n.nodeType === Node.TEXT_NODE && n.textContent.trim()) n.remove();
961
+ });
962
+ }
963
+
964
+ span.textContent = label;
965
+ btn.classList.toggle('onekite-active', isSpecial);
966
+ } catch (e) {}
967
+ }
968
+
969
+ function setMode(next, opts) {
970
+ if (next !== 'reservation' && next !== 'special') return;
971
+ mode = next;
972
+ saveMode(mode);
973
+
974
+ // Reset any pending selection/dialog state when switching modes
975
+ try { if (mode === 'reservation') { calendar.unselect(); isDialogOpen = false; } } catch (e) {}
976
+
977
+ const silent = !!(opts && opts.silent);
978
+ if (!silent && mode === 'special') {
979
+ const now = Date.now();
980
+ if (now - lastModeHintAt > 1200) {
981
+ lastModeHintAt = now;
982
+ showAlert('success', 'Mode évènement : sélectionnez une date ou une plage');
983
+ }
984
+ }
985
+ refreshDesktopModeButton();
986
+ try {
987
+ const mb = document.querySelector('#onekite-mobile-controls .onekite-mode-btn');
988
+ if (mb) {
989
+ const isSpecial = mode === 'special';
990
+ mb.textContent = isSpecial ? 'Mode évènement ✓' : 'Mode évènement';
991
+ mb.classList.toggle('onekite-active', isSpecial);
992
+ }
993
+ } catch (e) {}
994
+ }
995
+
996
+ // Inject lightweight responsive CSS once.
997
+ try {
998
+ const styleId = 'onekite-responsive-css';
999
+ if (!document.getElementById(styleId)) {
1000
+ const st = document.createElement('style');
1001
+ st.id = styleId;
1002
+ st.textContent = `
1003
+ /* Prevent accidental horizontal scroll on small screens */
1004
+ #onekite-calendar, #onekite-calendar * { box-sizing: border-box; }
1005
+ #onekite-calendar { width: 100%; max-width: 100%; overflow-x: hidden; }
1006
+
1007
+ @media (max-width: 576px) {
1008
+ #onekite-calendar .fc .fc-toolbar-title { font-size: 1.05rem; }
1009
+ #onekite-calendar .fc .fc-button { padding: .25rem .45rem; font-size: .85rem; }
1010
+ #onekite-calendar .fc .fc-daygrid-day-number { font-size: .8rem; }
1011
+ #onekite-calendar .fc .fc-daygrid-event { font-size: .75rem; }
1012
+ #onekite-calendar .fc .fc-timegrid-event { font-size: .75rem; }
1013
+ #onekite-calendar .fc .fc-col-header-cell-cushion { font-size: .8rem; }
1014
+ }
1015
+
1016
+ @media (max-width: 576px) and (orientation: landscape) {
1017
+ #onekite-calendar .fc .fc-toolbar-title { font-size: 1rem; }
1018
+ #onekite-calendar .fc .fc-button { padding: .2rem .4rem; font-size: .8rem; }
1019
+ }
1020
+
1021
+ /* Violet action button (events mode) */
1022
+ #onekite-calendar .fc .fc-newSpecial-button,
1023
+ .onekite-btn-violet {
1024
+ background: #6f42c1 !important;
1025
+ border-color: #6f42c1 !important;
1026
+ color: #fff !important;
1027
+ }
1028
+ /* Active state */
1029
+ #onekite-calendar .fc .fc-newSpecial-button.onekite-active,
1030
+ .onekite-btn-violet.onekite-active {
1031
+ filter: brightness(0.95);
1032
+ box-shadow: 0 0 0 0.15rem rgba(111,66,193,.25);
1033
+ }
1034
+ `;
1035
+ document.head.appendChild(st);
1036
+ }
1037
+ } catch (e) {}
1038
+
1039
+ function isMobileNow() {
1040
+ return !!(window.matchMedia && window.matchMedia('(max-width: 576px)').matches);
1041
+ }
1042
+
1043
+ function isLandscapeNow() {
1044
+ return !!(window.matchMedia && window.matchMedia('(orientation: landscape)').matches);
1045
+ }
1046
+
1047
+ function computeAspectRatio() {
1048
+ const mobile = isMobileNow();
1049
+ if (!mobile) return 1.35;
1050
+ return isLandscapeNow() ? 1.6 : 0.9;
1051
+ }
1052
+
1053
+ const headerToolbar = isMobileNow() ? {
1054
+ left: 'prev,next',
1055
+ center: 'title',
1056
+ right: 'today',
1057
+ } : {
1058
+ left: 'prev,next today',
1059
+ center: 'title',
1060
+ // Only month + week (no day view)
1061
+ right: (canCreateSpecial ? 'newSpecial ' : '') + 'dayGridMonth,timeGridWeek',
1062
+ };
1063
+
1064
+ const calendar = new FullCalendar.Calendar(el, {
1065
+ initialView: 'dayGridMonth',
1066
+ height: 'auto',
1067
+ contentHeight: 'auto',
1068
+ aspectRatio: computeAspectRatio(),
1069
+ dayMaxEvents: true,
1070
+ locale: 'fr',
1071
+ headerToolbar: headerToolbar,
1072
+ // Keep titles short on mobile to avoid horizontal overflow
1073
+ titleFormat: isMobileNow() ? { year: 'numeric', month: 'short' } : undefined,
1074
+ customButtons: canCreateSpecial ? {
1075
+ newSpecial: {
1076
+ text: 'Évènement',
1077
+ click: () => {
1078
+ setMode((mode === 'special') ? 'reservation' : 'special');
1079
+ },
1080
+ },
1081
+ } : {},
1082
+ // We display the time ourselves inside the title for "special" events,
1083
+ // to match reservation icons and avoid FullCalendar's fixed-width time column.
1084
+ displayEventTime: false,
1085
+ selectable: true,
1086
+ selectMirror: true,
1087
+ events: async function (info, successCallback, failureCallback) {
1088
+ try {
1089
+ // Abort previous in-flight events fetch to avoid "double refresh" effects.
1090
+ if (window.__onekiteEventsAbort) {
1091
+ try { window.__onekiteEventsAbort.abort(); } catch (e) {}
1092
+ }
1093
+ const abort = new AbortController();
1094
+ window.__onekiteEventsAbort = abort;
1095
+
1096
+ const qs = new URLSearchParams({ start: info.startStr, end: info.endStr });
1097
+ const url = `/api/v3/plugins/calendar-onekite/events?${qs.toString()}`;
1098
+ const data = await fetchJsonCached(url, { signal: abort.signal });
1099
+
1100
+ // Prefetch adjacent range (previous/next) for snappier navigation.
1101
+ try {
1102
+ const spanMs = (info.end && info.start) ? (info.end.getTime() - info.start.getTime()) : 0;
1103
+ if (spanMs > 0 && spanMs < 1000 * 3600 * 24 * 120) {
1104
+ const prevStart = new Date(info.start.getTime() - spanMs);
1105
+ const prevEnd = new Date(info.start.getTime());
1106
+ const nextStart = new Date(info.end.getTime());
1107
+ const nextEnd = new Date(info.end.getTime() + spanMs);
1108
+ const toStr = (d) => new Date(d.getTime()).toISOString();
1109
+ const qPrev = new URLSearchParams({ start: toStr(prevStart), end: toStr(prevEnd) });
1110
+ const qNext = new URLSearchParams({ start: toStr(nextStart), end: toStr(nextEnd) });
1111
+ fetchJsonCached(`/api/v3/plugins/calendar-onekite/events?${qPrev.toString()}`).catch(() => {});
1112
+ fetchJsonCached(`/api/v3/plugins/calendar-onekite/events?${qNext.toString()}`).catch(() => {});
1113
+ }
1114
+ } catch (e) {}
1115
+
1116
+ // IMPORTANT: align "special" event display exactly like reservation icons.
1117
+ // We inject the clock + time range directly into the event title so FC
1118
+ // doesn't reserve a separate time column (which creates a leading gap).
1119
+ const mapped = (Array.isArray(data) ? data : []).map((ev) => {
1120
+ try {
1121
+ if (ev && ev.extendedProps && ev.extendedProps.type === 'special' && ev.start && ev.end) {
1122
+ // Force the same visual layout as reservations in month view
1123
+ // (avoid the "dot" layout which introduces a leading gap).
1124
+ ev.display = 'block';
1125
+
1126
+ const s = new Date(ev.start);
1127
+ const e = new Date(ev.end);
1128
+ const ts = `${pad2(s.getHours())}:${pad2(s.getMinutes())}`;
1129
+ const te = `${pad2(e.getHours())}:${pad2(e.getMinutes())}`;
1130
+ const baseTitle = (ev.title || '').trim();
1131
+ // Avoid double prefix on rerenders
1132
+ const prefix = `🕒 ${ts}-${te} `;
1133
+ if (!baseTitle.startsWith('🕒 ')) {
1134
+ ev.title = `${prefix}${baseTitle}`.trim();
1135
+ }
1136
+ }
1137
+ } catch (e2) {}
1138
+ return ev;
1139
+ });
1140
+
1141
+ successCallback(mapped);
1142
+ } catch (e) {
1143
+ failureCallback(e);
1144
+ }
1145
+ },
1146
+ eventDidMount: function (arg) {
1147
+ // Keep special event colors consistent.
1148
+ try {
1149
+ const ev = arg && arg.event;
1150
+ if (!ev) return;
1151
+ if (ev.extendedProps && ev.extendedProps.type === 'special') {
1152
+ const el2 = arg.el;
1153
+ if (el2 && el2.style) {
1154
+ el2.style.backgroundColor = '#8e44ad';
1155
+ el2.style.borderColor = '#8e44ad';
1156
+ el2.style.color = '#ffffff';
1157
+ }
1158
+ }
1159
+ } catch (e) {}
1160
+ },
1161
+ select: async function (info) {
1162
+ if (isDialogOpen) {
1163
+ return;
1164
+ }
1165
+ isDialogOpen = true;
1166
+ try {
1167
+ if (mode === 'special' && canCreateSpecial) {
1168
+ const payload = await openSpecialEventDialog(info);
1169
+ if (!payload) {
1170
+ calendar.unselect();
1171
+ isDialogOpen = false;
1172
+ return;
1173
+ }
1174
+ await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1175
+ method: 'POST',
1176
+ body: JSON.stringify(payload),
1177
+ }).catch(async () => {
1178
+ return await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1179
+ method: 'POST',
1180
+ body: JSON.stringify(payload),
1181
+ });
1182
+ });
1183
+ showAlert('success', 'Évènement créé.');
1184
+ calendar.refetchEvents();
1185
+ calendar.unselect();
1186
+ isDialogOpen = false;
1187
+ return;
1188
+ }
1189
+
1190
+ if (!items || !items.length) {
1191
+ showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1192
+ calendar.unselect();
1193
+ isDialogOpen = false;
1194
+ return;
1195
+ }
1196
+ const chosen = await openReservationDialog(info, items);
1197
+ if (!chosen || !chosen.itemIds || !chosen.itemIds.length) {
1198
+ calendar.unselect();
1199
+ isDialogOpen = false;
1200
+ return;
1201
+ }
1202
+ // Send date strings (no hours) so reservations are day-based.
1203
+ const startDate = toLocalYmd(info.start);
1204
+ const endDate = toLocalYmd(info.end);
1205
+ await requestReservation({
1206
+ start: startDate,
1207
+ end: endDate,
1208
+ itemIds: chosen.itemIds,
1209
+ itemNames: chosen.itemNames,
1210
+ total: chosen.total,
1211
+ });
1212
+ showAlert('success', 'Demande envoyée (en attente de validation).');
1213
+ calendar.refetchEvents();
1214
+ calendar.unselect();
1215
+ isDialogOpen = false;
1216
+ } catch (e) {
1217
+ const code = String((e && (e.status || e.message)) || '');
1218
+ const payload = e && e.payload ? e.payload : null;
1219
+
1220
+ if (code === '403') {
1221
+ const msg = payload && (payload.message || payload.error || payload.msg) ? String(payload.message || payload.error || payload.msg) : '';
1222
+ const c = payload && payload.code ? String(payload.code) : '';
1223
+ if (c === 'NOT_MEMBER' || /adh(é|e)rent/i.test(msg) || /membership/i.test(msg)) {
1224
+ showAlert('error', msg || 'Vous devez être adhérent pour pouvoir effectuer une réservation.');
1225
+ } else {
1226
+ showAlert('error', msg || 'Impossible de créer la demande : droits insuffisants (groupe).');
1227
+ }
1228
+ } else if (code === '409') {
1229
+ showAlert('error', 'Impossible : au moins un matériel est déjà réservé ou en attente sur cette période.');
1230
+ } else {
1231
+ const msgRaw = payload && (payload.message || payload.error || payload.msg)
1232
+ ? String(payload.message || payload.error || payload.msg)
1233
+ : '';
1234
+
1235
+ // NodeBB can return a plain "not-logged-in" string when the user is not authenticated.
1236
+ // We want a user-friendly message consistent with the membership requirement.
1237
+ const msg = (/\bnot-logged-in\b/i.test(msgRaw) || /\[\[error:not-logged-in\]\]/i.test(msgRaw))
1238
+ ? 'Vous devez être adhérent Onekite'
1239
+ : msgRaw;
1240
+
1241
+ showAlert('error', msg || ((e && (e.status === 401 || e.status === 403)) ? 'Vous devez être adhérent Onekite' : 'Erreur lors de la création de la demande.'));
1242
+ }
1243
+ calendar.unselect();
1244
+ isDialogOpen = false;
1245
+ }
1246
+ },
1247
+ dateClick: async function (info) {
1248
+ // One-day selection convenience
1249
+ const start = info.date;
1250
+
1251
+ // In "special event" mode, a simple click should propose a one-day event (not two days in the modal)
1252
+ if (mode === 'special' && canCreateSpecial) {
1253
+ if (isDialogOpen) {
1254
+ return;
1255
+ }
1256
+ isDialogOpen = true;
1257
+ try {
1258
+ // Default to a one-day block: 07:00 -> 07:00 (end rolled to +1 day on submit).
1259
+ const start2 = new Date(start);
1260
+ start2.setHours(7, 0, 0, 0);
1261
+ const end2 = new Date(start);
1262
+ end2.setHours(7, 0, 0, 0);
1263
+ const payload = await openSpecialEventDialog({ start: start2, end: end2, allDay: false });
1264
+ if (!payload) {
1265
+ isDialogOpen = false;
1266
+ return;
1267
+ }
1268
+ await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1269
+ method: 'POST',
1270
+ body: JSON.stringify(payload),
1271
+ }).catch(async () => {
1272
+ return await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1273
+ method: 'POST',
1274
+ body: JSON.stringify(payload),
1275
+ });
1276
+ });
1277
+ showAlert('success', 'Évènement créé.');
1278
+ calendar.refetchEvents();
1279
+ } finally {
1280
+ isDialogOpen = false;
1281
+ }
1282
+ return;
1283
+ }
1284
+
1285
+ const end = new Date(start.getTime() + 24 * 60 * 60 * 1000);
1286
+ calendar.select(start, end);
1287
+ },
1288
+
1289
+ eventClick: async function (info) {
1290
+ if (isDialogOpen) return;
1291
+ isDialogOpen = true;
1292
+ const ev = info.event;
1293
+ const p0 = ev.extendedProps || {};
1294
+
1295
+ // Load full details lazily (events list is lightweight for perf).
1296
+ let p = p0;
1297
+ try {
1298
+ if (p0.type === 'reservation' && p0.rid) {
1299
+ const details = await fetchJson(`/api/v3/plugins/calendar-onekite/reservations/${encodeURIComponent(String(p0.rid))}`);
1300
+ p = Object.assign({}, p0, details);
1301
+ } else if (p0.type === 'special' && p0.eid) {
1302
+ const details = await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(String(p0.eid))}`);
1303
+ p = Object.assign({}, p0, details, {
1304
+ // keep backward compat with older field names used by templates below
1305
+ pickupAddress: details.address || details.pickupAddress || p0.pickupAddress,
1306
+ pickupLat: details.lat || details.pickupLat || p0.pickupLat,
1307
+ pickupLon: details.lon || details.pickupLon || p0.pickupLon,
1308
+ });
1309
+ }
1310
+ } catch (e) {
1311
+ // ignore detail fetch errors; fall back to minimal props
1312
+ p = p0;
1313
+ }
1314
+
1315
+ try {
1316
+ if (p.type === 'special') {
1317
+ const username = String(p.username || '').trim();
1318
+ const userLine = username
1319
+ ? `<div class="mb-2"><strong>Créé par</strong><br><a class="onekite-user-link" href="${window.location.origin}/user/${encodeURIComponent(username)}">${escapeHtml(username)}</a></div>`
1320
+ : '';
1321
+ const addr = String(p.pickupAddress || '').trim();
1322
+ const lat = Number(p.pickupLat);
1323
+ const lon = Number(p.pickupLon);
1324
+ const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
1325
+ const notes = String(p.notes || '').trim();
1326
+ const addrHtml = addr
1327
+ ? (hasCoords
1328
+ ? `<a href="#" class="onekite-map-link" data-address="${escapeHtml(addr)}" data-lat="${escapeHtml(String(lat))}" data-lon="${escapeHtml(String(lon))}">${escapeHtml(addr)}</a>`
1329
+ : `${escapeHtml(addr)}`)
1330
+ : '';
1331
+ const html = `
1332
+ <div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
1333
+ ${userLine}
1334
+ <div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDtWithTime(ev.start))} → ${escapeHtml(formatDtWithTime(ev.end))}</div>
1335
+ ${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${addrHtml}</div>` : ''}
1336
+ ${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
1337
+ `;
1338
+ const canDel = !!(p.canDeleteSpecial || canDeleteSpecial);
1339
+ bootbox.dialog({
1340
+ title: 'Évènement',
1341
+ message: html,
1342
+ buttons: {
1343
+ close: { label: 'Fermer', className: 'btn-secondary' },
1344
+ ...(canDel ? {
1345
+ del: {
1346
+ label: 'Supprimer',
1347
+ className: 'btn-danger',
1348
+ callback: async () => {
1349
+ try {
1350
+ const eid = String(p.eid || ev.id).replace(/^special:/, '');
1351
+ await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(eid)}`, { method: 'DELETE' });
1352
+ showAlert('success', 'Évènement supprimé.');
1353
+ calendar.refetchEvents();
1354
+ } catch (e) {
1355
+ showAlert('error', 'Suppression impossible.');
1356
+ }
1357
+ },
1358
+ },
1359
+ } : {}),
1360
+ },
1361
+ });
1362
+ return;
1363
+ }
1364
+ const rid = p.rid || ev.id;
1365
+ const status = p.status || '';
1366
+
1367
+ // Reserved-by line (user profile link)
1368
+ const username = String(p.username || p.user || p.reservedBy || p.ownerUsername || '').trim();
1369
+ const userLine = username
1370
+ ? `<div class="mb-2"><strong>Réservée par</strong><br><a class="onekite-user-link" href="${window.location.origin}/user/${encodeURIComponent(username)}">${escapeHtml(username)}</a></div>`
1371
+ : '';
1372
+ const itemsHtml = (() => {
1373
+ const names = Array.isArray(p.itemNames) ? p.itemNames : (typeof p.itemNames === 'string' && p.itemNames.trim() ? p.itemNames.split(',').map(s=>s.trim()).filter(Boolean) : (p.itemName ? [p.itemName] : []));
1374
+ if (names.length) {
1375
+ return `<ul style="margin:0 0 0 1.1rem; padding:0;">${names.map(n => `<li>${String(n).replace(/</g,'&lt;').replace(/>/g,'&gt;')}</li>`).join('')}</ul>`;
1376
+ }
1377
+ return String(ev.title || '').replace(/</g,'&lt;').replace(/>/g,'&gt;');
1378
+ })();
1379
+ const period = `${formatDt(ev.start)} → ${formatDt(ev.end)}`;
1380
+
1381
+ const approvedBy = String(p.approvedByUsername || '').trim();
1382
+ const validatedByHtml = approvedBy
1383
+ ? `<div class=\"mb-2\"><strong>Validée par</strong><br><a href=\"https://www.onekite.com/user/${encodeURIComponent(approvedBy)}\">${escapeHtml(approvedBy)}</a></div>`
1384
+ : '';
1385
+
1386
+ // Pickup details (address / time / notes) shown once validated
1387
+ const pickupAddress = String(p.pickupAddress || '').trim();
1388
+ const pickupTime = String(p.pickupTime || '').trim();
1389
+ const notes = String(p.notes || '').trim();
1390
+ const lat = Number(p.pickupLat);
1391
+ const lon = Number(p.pickupLon);
1392
+ const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
1393
+ const osmLink = hasCoords
1394
+ ? `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(lat))}&mlon=${encodeURIComponent(String(lon))}#map=17/${encodeURIComponent(String(lat))}/${encodeURIComponent(String(lon))}`
1395
+ : '';
1396
+ const pickupHtml = (pickupAddress || pickupTime || notes)
1397
+ ? `
1398
+ <div class="mb-2">
1399
+ <strong>Récupération</strong><br>
1400
+ ${pickupAddress ? `${escapeHtml(pickupAddress)}<br>` : ''}
1401
+ ${pickupTime ? `Heure : ${escapeHtml(pickupTime)}<br>` : ''}
1402
+ ${hasCoords ? `<a href="${osmLink}" target="_blank" rel="noopener">Voir sur la carte</a><br>` : ''}
1403
+ ${notes ? `<div class="mt-1"><strong>Notes</strong><br>${escapeHtml(notes)}</div>` : ''}
1404
+ </div>
1405
+ `
1406
+ : '';
1407
+
1408
+ const canModerate = !!p.canModerate;
1409
+ const isPending = status === 'pending';
1410
+
1411
+ const refusedReason = String(p.refusedReason || p.refuseReason || '').trim();
1412
+ const refusedReasonHtml = (status === 'refused' && refusedReason)
1413
+ ? `<div class="mb-2"><strong>Raison du refus</strong><br>${escapeHtml(refusedReason)}</div>`
1414
+ : '';
1415
+
1416
+ const baseHtml = `
1417
+ ${userLine}
1418
+ <div class="mb-2"><strong>Matériel</strong><br>${itemsHtml}</div>
1419
+ <div class="mb-2"><strong>Période</strong><br>${period}</div>
1420
+ ${validatedByHtml}
1421
+ ${pickupHtml}
1422
+ ${refusedReasonHtml}
1423
+ <div class="text-muted" style="font-size: 12px;">Statut: ${statusLabel(status)}</div>
1424
+ `;
1425
+
1426
+ const uidNow = String((window.config && window.config.uid) || (typeof app !== 'undefined' && app && app.user && app.user.uid) || (typeof ajaxify !== 'undefined' && ajaxify && ajaxify.data && ajaxify.data.uid) || '');
1427
+ const ownerUid = String(ev.extendedProps && ev.extendedProps.uid ? ev.extendedProps.uid : '');
1428
+ const isOwner = uidNow && ownerUid && uidNow === ownerUid;
1429
+ const showModeration = canModerate && isPending;
1430
+ const showCancel = isOwner && ['pending', 'awaiting_payment'].includes(status);
1431
+ const paymentUrl = String((p && p.paymentUrl) || '');
1432
+ const showPay = isOwner && status === 'awaiting_payment' && /^https?:\/\//i.test(paymentUrl);
1433
+ const buttons = {
1434
+ close: { label: 'Fermer', className: 'btn-secondary' },
1435
+ };
1436
+ if (showPay) {
1437
+ buttons.pay = {
1438
+ label: 'Payer maintenant',
1439
+ className: 'btn-primary',
1440
+ callback: () => {
1441
+ try {
1442
+ window.open(paymentUrl, '_blank', 'noopener');
1443
+ } catch (e) {}
1444
+ return false;
1445
+ },
1446
+ };
1447
+ }
1448
+ if (showCancel) {
1449
+ buttons.cancel = {
1450
+ label: 'Annuler',
1451
+ className: 'btn-outline-warning',
1452
+ callback: async () => {
1453
+ try {
1454
+ await cancelReservation(rid);
1455
+ showAlert('success', 'Réservation annulée.');
1456
+ calendar.refetchEvents();
1457
+ } catch (e) {
1458
+ showAlert('error', 'Annulation impossible.');
1459
+ }
1460
+ },
1461
+ };
1462
+ }
1463
+ if (showModeration) {
1464
+ buttons.refuse = {
1465
+ label: 'Refuser',
1466
+ className: 'btn-outline-danger',
1467
+ callback: async () => {
1468
+ const html = `
1469
+ <div class="mb-3">
1470
+ <label class="form-label">Raison du refus</label>
1471
+ <textarea class="form-control" id="onekite-refuse-reason" rows="3" placeholder="Ex: matériel indisponible, dates impossibles, dossier incomplet..."></textarea>
1472
+ </div>
1473
+ `;
1474
+ return await new Promise((resolve) => {
1475
+ bootbox.dialog({
1476
+ title: 'Refuser la réservation',
1477
+ message: html,
1478
+ buttons: {
1479
+ cancel: { label: 'Annuler', className: 'btn-secondary', callback: () => resolve(false) },
1480
+ ok: {
1481
+ label: 'Refuser',
1482
+ className: 'btn-danger',
1483
+ callback: async () => {
1484
+ try {
1485
+ const reason = (document.getElementById('onekite-refuse-reason')?.value || '').trim();
1486
+ await refuseReservation(rid, { reason });
1487
+ showAlert('success', 'Demande refusée.');
1488
+ calendar.refetchEvents();
1489
+ resolve(true);
1490
+ } catch (e) {
1491
+ showAlert('error', 'Refus impossible.');
1492
+ resolve(false);
1493
+ }
1494
+ return false;
1495
+ },
1496
+ },
1497
+ },
1498
+ });
1499
+ });
1500
+ },
1501
+ };
1502
+ buttons.approve = {
1503
+ label: 'Valider',
1504
+ className: 'btn-success',
1505
+ callback: async () => {
1506
+ const itemNames = Array.isArray(p.itemNames) && p.itemNames.length
1507
+ ? p.itemNames
1508
+ : (typeof p.itemNames === 'string' && p.itemNames.trim() ? p.itemNames.split(',').map(s=>s.trim()).filter(Boolean) : (p.itemName ? [p.itemName] : []));
1509
+ const itemsListHtml = itemNames.length
1510
+ ? `<div class="mb-2"><strong>Matériel</strong><ul style="margin:0.25rem 0 0 1.1rem; padding:0;">${itemNames.map(n => `<li>${String(n).replace(/</g, '&lt;').replace(/>/g, '&gt;')}</li>`).join('')}</ul></div>`
1511
+ : '';
1512
+ const opts = (() => {
1513
+ const out = [];
1514
+ for (let h = 7; h < 24; h++) {
1515
+ for (let m = 0; m < 60; m += 5) {
1516
+ out.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
1517
+ }
1518
+ }
1519
+ return out;
1520
+ })().map(t => `<option value="${t}" ${t === '07:00' ? 'selected' : ''}>${t}</option>`).join('');
1521
+ const html = `
1522
+ ${itemsListHtml}
1523
+ <div class="mb-3">
1524
+ <label class="form-label">Adresse de récupération</label>
1525
+ <div class="input-group">
1526
+ <input type="text" class="form-control" id="onekite-pickup-address" placeholder="Adresse complète" />
1527
+ <button class="btn btn-outline-secondary" type="button" id="onekite-geocode">Rechercher</button>
1528
+ </div>
1529
+ <div id="onekite-map" style="height:220px; border:1px solid #ddd; border-radius:6px; margin-top:0.5rem;"></div>
1530
+ <div class="form-text" id="onekite-map-help">Vous pouvez déplacer le marqueur pour ajuster la position.</div>
1531
+ <input type="hidden" id="onekite-pickup-lat" />
1532
+ <input type="hidden" id="onekite-pickup-lon" />
1533
+ </div>
1534
+ <div class="mb-3">
1535
+ <label class="form-label">Notes (facultatif)</label>
1536
+ <textarea class="form-control" id="onekite-notes" rows="3" placeholder="Ex: code portail, personne à contacter, horaires..."></textarea>
1537
+ </div>
1538
+ <div class="mb-2">
1539
+ <label class="form-label">Heure de récupération</label>
1540
+ <select class="form-select" id="onekite-pickup-time">${opts}</select>
1541
+ </div>
1542
+ `;
1543
+ const dlg = bootbox.dialog({
1544
+ title: 'Valider la demande',
1545
+ message: html,
1546
+ buttons: {
1547
+ cancel: { label: 'Annuler', className: 'btn-secondary' },
1548
+ ok: {
1549
+ label: 'Valider',
1550
+ className: 'btn-success',
1551
+ callback: async () => {
1552
+ try {
1553
+ const pickupAddress = (document.getElementById('onekite-pickup-address')?.value || '').trim();
1554
+ const notes = (document.getElementById('onekite-notes')?.value || '').trim();
1555
+ const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
1556
+ const pickupLat = (document.getElementById('onekite-pickup-lat')?.value || '').trim();
1557
+ const pickupLon = (document.getElementById('onekite-pickup-lon')?.value || '').trim();
1558
+ await approveReservation(rid, { pickupAddress, notes, pickupTime, pickupLat, pickupLon });
1559
+ showAlert('success', 'Demande validée.');
1560
+ calendar.refetchEvents();
1561
+ } catch (e) {
1562
+ showAlert('error', 'Validation impossible.');
1563
+ }
1564
+ },
1565
+ },
1566
+ },
1567
+ });
1568
+
1569
+ // Init Leaflet map once the modal is visible.
1570
+ dlg.on('shown.bs.modal', async () => {
1571
+ try {
1572
+ const L = await loadLeaflet();
1573
+ const mapEl = document.getElementById('onekite-map');
1574
+ if (!mapEl) return;
1575
+
1576
+ const map = L.map(mapEl, { scrollWheelZoom: false });
1577
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
1578
+ maxZoom: 19,
1579
+ attribution: '&copy; OpenStreetMap',
1580
+ }).addTo(map);
1581
+
1582
+ // Default view (France-ish)
1583
+ map.setView([46.7, 2.5], 5);
1584
+
1585
+ let marker = null;
1586
+ function setMarker(lat, lon, zoom) {
1587
+ const ll = [lat, lon];
1588
+ if (!marker) {
1589
+ marker = L.marker(ll, { draggable: true }).addTo(map);
1590
+ marker.on('dragend', () => {
1591
+ const p2 = marker.getLatLng();
1592
+ document.getElementById('onekite-pickup-lat').value = String(p2.lat);
1593
+ document.getElementById('onekite-pickup-lon').value = String(p2.lng);
1594
+ });
1595
+ } else {
1596
+ marker.setLatLng(ll);
1597
+ }
1598
+ document.getElementById('onekite-pickup-lat').value = String(lat);
1599
+ document.getElementById('onekite-pickup-lon').value = String(lon);
1600
+ if (zoom) {
1601
+ map.setView(ll, zoom);
1602
+ } else {
1603
+ map.panTo(ll);
1604
+ }
1605
+ }
1606
+
1607
+ map.on('click', (e) => {
1608
+ if (e && e.latlng) {
1609
+ setMarker(e.latlng.lat, e.latlng.lng, map.getZoom());
1610
+ }
1611
+ });
1612
+
1613
+ const geocodeBtn = document.getElementById('onekite-geocode');
1614
+ const addrInput = document.getElementById('onekite-pickup-address');
1615
+
1616
+ try {
1617
+ attachAddressAutocomplete(addrInput, (h) => {
1618
+ if (h && Number.isFinite(h.lat) && Number.isFinite(h.lon)) {
1619
+ setMarker(h.lat, h.lon, 16);
1620
+ }
1621
+ });
1622
+ } catch (e) {}
1623
+
1624
+ async function runGeocode() {
1625
+ try {
1626
+ const addr = (addrInput?.value || '').trim();
1627
+ if (!addr) return;
1628
+ const hit = await geocodeAddress(addr);
1629
+ if (!hit) {
1630
+ showAlert('error', 'Adresse introuvable.');
1631
+ return;
1632
+ }
1633
+ setMarker(hit.lat, hit.lon, 16);
1634
+ } catch (e) {
1635
+ showAlert('error', 'Recherche adresse impossible.');
1636
+ }
1637
+ }
1638
+
1639
+ if (geocodeBtn) {
1640
+ geocodeBtn.addEventListener('click', runGeocode);
1641
+ }
1642
+ if (addrInput) {
1643
+ addrInput.addEventListener('keydown', (e) => {
1644
+ if (e.key === 'Enter') {
1645
+ e.preventDefault();
1646
+ runGeocode();
1647
+ }
1648
+ });
1649
+ }
1650
+ } catch (e) {
1651
+ // Leaflet load errors shouldn't block moderation
1652
+ }
1653
+ });
1654
+ },
1655
+ };
1656
+ }
1657
+
1658
+ bootbox.dialog({
1659
+ title: 'Réservation',
1660
+ message: baseHtml,
1661
+ buttons,
1662
+ });
1663
+ } finally {
1664
+ isDialogOpen = false;
1665
+ }
1666
+ },
1667
+ });
1668
+
1669
+ // Expose for live updates
1670
+ try { window.oneKiteCalendar = calendar; } catch (e) {}
1671
+
1672
+ calendar.render();
1673
+
1674
+ refreshDesktopModeButton();
1675
+
1676
+
1677
+ // Mobile controls: view (month/week) + mode (reservation/event) without bloating the header.
1678
+ try {
1679
+ const controlsId = 'onekite-mobile-controls';
1680
+ const old = document.getElementById(controlsId);
1681
+ if (old) old.remove();
1682
+
1683
+ if (isMobileNow()) {
1684
+ const controls = document.createElement('div');
1685
+ controls.id = controlsId;
1686
+ controls.className = 'mt-2 d-flex flex-wrap gap-2 align-items-center';
1687
+
1688
+ const viewGroup = document.createElement('div');
1689
+ viewGroup.className = 'btn-group btn-group-sm';
1690
+ viewGroup.setAttribute('role', 'group');
1691
+
1692
+ const btnMonth = document.createElement('button');
1693
+ btnMonth.type = 'button';
1694
+ btnMonth.className = 'btn btn-outline-secondary';
1695
+ btnMonth.textContent = 'Mois';
1696
+
1697
+ const btnWeek = document.createElement('button');
1698
+ btnWeek.type = 'button';
1699
+ btnWeek.className = 'btn btn-outline-secondary';
1700
+ btnWeek.textContent = 'Semaine';
1701
+
1702
+ function refreshViewButtons() {
1703
+ const v = calendar.view && calendar.view.type;
1704
+ btnMonth.classList.toggle('active', v === 'dayGridMonth');
1705
+ btnWeek.classList.toggle('active', v === 'timeGridWeek');
1706
+ }
1707
+
1708
+ btnMonth.addEventListener('click', () => {
1709
+ calendar.changeView('dayGridMonth');
1710
+ refreshViewButtons();
1711
+ });
1712
+ btnWeek.addEventListener('click', () => {
1713
+ calendar.changeView('timeGridWeek');
1714
+ refreshViewButtons();
1715
+ });
1716
+
1717
+ viewGroup.appendChild(btnMonth);
1718
+ viewGroup.appendChild(btnWeek);
1719
+ controls.appendChild(viewGroup);
1720
+
1721
+ if (canCreateSpecial) {
1722
+ const modeBtn = document.createElement('button');
1723
+ modeBtn.type = 'button';
1724
+ modeBtn.className = 'btn btn-sm onekite-btn-violet onekite-mode-btn';
1725
+ function refreshModeBtn() {
1726
+ const isSpecial = mode === 'special';
1727
+ modeBtn.textContent = isSpecial ? 'Mode évènement ✓' : 'Mode évènement';
1728
+ modeBtn.classList.toggle('active', isSpecial);
1729
+ // Keep violet, only toggle active class
1730
+ modeBtn.classList.toggle('onekite-active', isSpecial);
1731
+ }
1732
+ modeBtn.addEventListener('click', () => {
1733
+ setMode((mode === 'special') ? 'reservation' : 'special');
1734
+ refreshModeBtn();
1735
+ });
1736
+ refreshModeBtn();
1737
+ controls.appendChild(modeBtn);
1738
+ }
1739
+
1740
+ el.parentNode && el.parentNode.insertBefore(controls, el.nextSibling);
1741
+ refreshViewButtons();
1742
+ }
1743
+ } catch (e) {}
1744
+
1745
+ // Update sizing when rotating/resizing (especially on mobile landscape).
1746
+ try {
1747
+ let lastMobile = isMobileNow();
1748
+ let lastLandscape = isLandscapeNow();
1749
+ window.addEventListener('resize', () => {
1750
+ const mobile = isMobileNow();
1751
+ const landscape = isLandscapeNow();
1752
+ if (mobile !== lastMobile || landscape !== lastLandscape) {
1753
+ lastMobile = mobile;
1754
+ lastLandscape = landscape;
1755
+ calendar.setOption('aspectRatio', computeAspectRatio());
1756
+ calendar.setOption('titleFormat', mobile ? { year: 'numeric', month: 'short' } : undefined);
1757
+ }
1758
+ try { calendar.updateSize(); } catch (err) {}
1759
+
1760
+ try { refreshDesktopModeButton(); } catch (e) {}
1761
+ });
1762
+ } catch (e) {}
1763
+ }
1764
+
1765
+ // Auto-init on /calendar when ajaxify finishes rendering.
1766
+ function autoInit(data) {
1767
+ try {
1768
+ const tpl = data && data.template ? data.template.name : (ajaxify && ajaxify.data && ajaxify.data.template ? ajaxify.data.template.name : '');
1769
+ if (tpl === 'calendar-onekite') {
1770
+ init('#onekite-calendar');
1771
+ }
1772
+ } catch (e) {}
1773
+ }
1774
+
1775
+ if (hooks && typeof hooks.on === 'function') {
1776
+ hooks.on('action:ajaxify.end', autoInit);
1777
+ }
1778
+
1779
+ // In case the page is served as the initial load (no ajaxify navigation)
1780
+ // call once after current tick.
1781
+ setTimeout(() => autoInit({ template: (ajaxify && ajaxify.data && ajaxify.data.template) || { name: '' } }), 0);
1782
+
1783
+
1784
+
1785
+ // Live refresh when a reservation changes (e.g., payment confirmed by webhook)
1786
+ try {
1787
+ if (!window.__oneKiteSocketBound && typeof socket !== 'undefined' && socket && typeof socket.on === 'function') {
1788
+ window.__oneKiteSocketBound = true;
1789
+ socket.on('event:calendar-onekite.reservationUpdated', function () {
1790
+ try {
1791
+ const cal = window.oneKiteCalendar;
1792
+ scheduleRefetch(cal);
1793
+ } catch (e) {}
1794
+ });
1795
+ }
1796
+ } catch (e) {}
1797
+
1798
+ return {
1799
+ init,
1800
+ };
1801
+ });