nodebb-plugin-onekite-calendar 2.0.11 → 2.0.13

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/lib/admin.js +21 -9
  3. package/lib/api.js +235 -4
  4. package/lib/db.js +114 -0
  5. package/lib/helloassoWebhook.js +28 -0
  6. package/library.js +7 -0
  7. package/package.json +1 -1
  8. package/pkg/package/CHANGELOG.md +106 -0
  9. package/pkg/package/lib/admin.js +554 -0
  10. package/pkg/package/lib/api.js +1458 -0
  11. package/pkg/package/lib/controllers.js +11 -0
  12. package/pkg/package/lib/db.js +224 -0
  13. package/pkg/package/lib/discord.js +190 -0
  14. package/pkg/package/lib/helloasso.js +352 -0
  15. package/pkg/package/lib/helloassoWebhook.js +389 -0
  16. package/pkg/package/lib/scheduler.js +201 -0
  17. package/pkg/package/lib/widgets.js +460 -0
  18. package/pkg/package/library.js +164 -0
  19. package/pkg/package/package.json +14 -0
  20. package/pkg/package/plugin.json +43 -0
  21. package/pkg/package/public/admin.js +1477 -0
  22. package/pkg/package/public/client.js +2228 -0
  23. package/pkg/package/templates/admin/plugins/calendar-onekite.tpl +298 -0
  24. package/pkg/package/templates/calendar-onekite.tpl +51 -0
  25. package/pkg/package/templates/emails/calendar-onekite_approved.tpl +40 -0
  26. package/pkg/package/templates/emails/calendar-onekite_cancelled.tpl +15 -0
  27. package/pkg/package/templates/emails/calendar-onekite_expired.tpl +11 -0
  28. package/pkg/package/templates/emails/calendar-onekite_paid.tpl +15 -0
  29. package/pkg/package/templates/emails/calendar-onekite_pending.tpl +15 -0
  30. package/pkg/package/templates/emails/calendar-onekite_refused.tpl +15 -0
  31. package/pkg/package/templates/emails/calendar-onekite_reminder.tpl +20 -0
  32. package/plugin.json +1 -1
  33. package/public/admin.js +205 -4
  34. package/public/client.js +238 -7
  35. package/templates/admin/plugins/calendar-onekite.tpl +74 -0
@@ -0,0 +1,2228 @@
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
+ /* Mobile: ensure taps reach FullCalendar (Safari/Chrome) */
30
+ .fc {
31
+ touch-action: manipulation;
32
+ }
33
+
34
+ /* Show days that cannot be selected for new reservations (today & past) */
35
+ .fc .onekite-day-disabled {
36
+ background: rgba(0, 0, 0, 0.03);
37
+ }
38
+ .fc .onekite-day-disabled .fc-daygrid-day-number {
39
+ opacity: 0.5;
40
+ }
41
+ .fc .onekite-day-disabled:hover {
42
+ cursor: not-allowed;
43
+ }
44
+
45
+ /* Mobile floating action button (FAB) */
46
+ @media (max-width: 768px) {
47
+ .onekite-fab {
48
+ position: fixed;
49
+ right: 16px;
50
+ bottom: 16px;
51
+ width: 56px;
52
+ height: 56px;
53
+ border-radius: 999px;
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: center;
57
+ z-index: 1055;
58
+ box-shadow: 0 10px 22px rgba(0,0,0,.25);
59
+ }
60
+ .onekite-fab .fa, .onekite-fab .fas, .onekite-fab .fa-solid {
61
+ font-size: 20px;
62
+ }
63
+ }
64
+ `;
65
+ document.head.appendChild(style);
66
+ } catch (e) {
67
+ // ignore
68
+ }
69
+ })();
70
+
71
+ // Prevent the reservation dialog from opening twice due to select/dateClick
72
+ // interactions or quick re-renders.
73
+ let isDialogOpen = false;
74
+
75
+ // Cached list of reservable items (loaded once per page init)
76
+ let cachedItems = null;
77
+
78
+ // Current FullCalendar instance (for refresh after actions)
79
+ let currentCalendar = null;
80
+
81
+ // Mobile FAB (mounted only on the calendar page)
82
+ let fabEl = null;
83
+ let fabHandler = null;
84
+
85
+ // Prevent double taps/clicks on actions that trigger network calls (mobile especially).
86
+ const actionLocks = new Map(); // key -> ts
87
+ function lockAction(key, ms) {
88
+ const ttl = Math.max(0, parseInt(ms, 10) || 900);
89
+ const now = Date.now();
90
+ const last = actionLocks.get(key) || 0;
91
+ if (now - last < ttl) return false;
92
+ actionLocks.set(key, now);
93
+ return true;
94
+ }
95
+
96
+ // Avoid duplicate toasts when the same validation fails both client-side and
97
+ // server-side (e.g. "today or past" date rule).
98
+ let lastDateRuleToastAt = 0;
99
+
100
+ function escapeHtml(str) {
101
+ return String(str)
102
+ .replace(/&/g, '&amp;')
103
+ .replace(/</g, '&lt;')
104
+ .replace(/>/g, '&gt;')
105
+ .replace(/"/g, '&quot;')
106
+ .replace(/'/g, '&#39;');
107
+ }
108
+
109
+ function pad2(n) { return String(n).padStart(2, '0'); }
110
+
111
+ function toDateInputValue(d) {
112
+ const dt = (d instanceof Date) ? d : new Date(d);
113
+ return `${dt.getFullYear()}-${pad2(dt.getMonth() + 1)}-${pad2(dt.getDate())}`;
114
+ }
115
+
116
+ function roundTo5Minutes(dt) {
117
+ const d = new Date(dt);
118
+ const m = d.getMinutes();
119
+ const rounded = Math.round(m / 5) * 5;
120
+ d.setMinutes(rounded, 0, 0);
121
+ return d;
122
+ }
123
+
124
+ function timeString(dt) {
125
+ const d = (dt instanceof Date) ? dt : new Date(dt);
126
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
127
+ }
128
+
129
+ // Build an ISO datetime *with timezone* from local date/time inputs.
130
+ // This avoids ambiguity when the server runs in UTC and prevents
131
+ // "single-day" events (07:00→23:59 local) from spilling into the next day
132
+ // when rendered back to the browser.
133
+ function buildLocalIso(dateStr, timeStr) {
134
+ try {
135
+ const d = new Date(`${dateStr}T${timeStr}`);
136
+ return d.toISOString();
137
+ } catch (e) {
138
+ return `${dateStr}T${timeStr}`;
139
+ }
140
+ }
141
+
142
+ function seTimeOptions(selected, include2359) {
143
+ const opts = [];
144
+ for (let h = 7; h < 24; h++) {
145
+ for (let m = 0; m < 60; m += 5) {
146
+ const t = `${pad2(h)}:${pad2(m)}`;
147
+ opts.push(`<option value="${t}" ${t === selected ? 'selected' : ''}>${t}</option>`);
148
+ }
149
+ }
150
+ if (include2359) {
151
+ const t = '23:59';
152
+ opts.push(`<option value="${t}" ${t === selected ? 'selected' : ''}>${t}</option>`);
153
+ }
154
+ return opts.join('');
155
+ }
156
+
157
+ async function openSpecialEventDialog(selectionInfo) {
158
+ const start = selectionInfo.start;
159
+ // FullCalendar can omit `end` for certain interactions. Also, for all-day
160
+ // selections, `end` is exclusive (next day at 00:00). We normalise below
161
+ // so a single-day click always defaults to a single-day range.
162
+ const end = selectionInfo.end;
163
+
164
+ // Prefer event times starting at 07:00 for day-clicks (all-day selections).
165
+ let seStart = new Date(start);
166
+ // If `end` is missing, start with the same day.
167
+ let seEnd = end ? new Date(end) : new Date(seStart);
168
+
169
+ const msSpan = seEnd.getTime() - seStart.getTime();
170
+ const isAllDaySelection = selectionInfo && (selectionInfo.allDay || (
171
+ seStart.getHours() === 0 && seStart.getMinutes() === 0 &&
172
+ seEnd.getHours() === 0 && seEnd.getMinutes() === 0
173
+ ));
174
+
175
+ // For all-day selections, the simplest robust check is:
176
+ // does (end - 1ms) fall on the same calendar day as start?
177
+ const endMinus1 = isAllDaySelection ? new Date(seEnd.getTime() - 1) : null;
178
+ const isSingleDayAllDay = isAllDaySelection && (!end || (endMinus1 && toDateInputValue(seStart) === toDateInputValue(endMinus1)));
179
+
180
+ if (isSingleDayAllDay) {
181
+ // Keep same day; default to a "one day" block (07:00 → 07:00).
182
+ // We keep end date equal to start date in the modal. On submit,
183
+ // if end <= start we automatically roll end by +1 day.
184
+ seStart.setHours(7, 0, 0, 0);
185
+ seEnd = new Date(seStart);
186
+ seEnd.setHours(7, 0, 0, 0);
187
+ } else if (isAllDaySelection) {
188
+ // Multi-day all-day selection: start at 07:00 on first day, end at 07:00 on last day
189
+ // (end date shown is the last selected day; submit logic will roll if needed).
190
+ seStart.setHours(7, 0, 0, 0);
191
+ const lastDay = endMinus1 ? new Date(endMinus1) : new Date(seEnd);
192
+ seEnd = new Date(lastDay);
193
+ seEnd.setHours(7, 0, 0, 0);
194
+ } else {
195
+ seStart = roundTo5Minutes(seStart);
196
+ seEnd = roundTo5Minutes(seEnd);
197
+ if (seStart.getHours() < 7) {
198
+ seStart.setHours(7, 0, 0, 0);
199
+ if (seEnd <= seStart) {
200
+ seEnd = new Date(seStart);
201
+ seEnd.setHours(8, 0, 0, 0);
202
+ }
203
+ }
204
+ }
205
+
206
+ const seStartTime = timeString(seStart);
207
+ const seEndTime = timeString(seEnd);
208
+ const html = `
209
+ <div class="mb-3">
210
+ <label class="form-label">Titre</label>
211
+ <input type="text" class="form-control" id="onekite-se-title" placeholder="Ex: ..." />
212
+ </div>
213
+ <div class="row g-2">
214
+ <div class="col-12 col-md-6">
215
+ <label class="form-label">Début</label>
216
+ <div class="row g-2 align-items-end">
217
+ <div class="col-12 col-sm-7">
218
+ <input type="date" class="form-control" id="onekite-se-start-date" value="${escapeHtml(toDateInputValue(seStart))}" />
219
+ </div>
220
+ <div class="col-12 col-sm-5">
221
+ <select class="form-select onekite-time-select" id="onekite-se-start-time">${seTimeOptions(seStartTime, false)}</select>
222
+ </div>
223
+ </div>
224
+ </div>
225
+ <div class="col-12 col-md-6">
226
+ <label class="form-label">Fin</label>
227
+ <div class="row g-2 align-items-end">
228
+ <div class="col-12 col-sm-7">
229
+ <input type="date" class="form-control" id="onekite-se-end-date" value="${escapeHtml(toDateInputValue(seEnd))}" />
230
+ </div>
231
+ <div class="col-12 col-sm-5">
232
+ <select class="form-select onekite-time-select" id="onekite-se-end-time">${seTimeOptions(seEndTime, true)}</select>
233
+ </div>
234
+ </div>
235
+ </div>
236
+ </div>
237
+ <div class="mt-3">
238
+ <label class="form-label">Adresse</label>
239
+ <div class="input-group">
240
+ <input type="text" class="form-control" id="onekite-se-address" placeholder="Adresse complète" />
241
+ <button class="btn btn-outline-secondary" type="button" id="onekite-se-geocode">Rechercher</button>
242
+ </div>
243
+ <div id="onekite-se-map" style="height:220px; border:1px solid #ddd; border-radius:6px; margin-top:0.5rem;"></div>
244
+ <input type="hidden" id="onekite-se-lat" />
245
+ <input type="hidden" id="onekite-se-lon" />
246
+ </div>
247
+ <div class="mt-3">
248
+ <label class="form-label">Notes (facultatif)</label>
249
+ <textarea class="form-control" id="onekite-se-notes" rows="3" placeholder="..."></textarea>
250
+ </div>
251
+ `;
252
+
253
+ return await new Promise((resolve) => {
254
+ let resolved = false;
255
+ const dialog = bootbox.dialog({
256
+ title: 'Créer un évènement',
257
+ message: html,
258
+ buttons: {
259
+ cancel: {
260
+ label: 'Annuler',
261
+ className: 'btn-secondary',
262
+ callback: () => {
263
+ resolved = true;
264
+ resolve(null);
265
+ },
266
+ },
267
+ ok: {
268
+ label: 'Créer',
269
+ className: 'btn-primary',
270
+ callback: () => {
271
+ const title = (document.getElementById('onekite-se-title')?.value || '').trim();
272
+ const sd = (document.getElementById('onekite-se-start-date')?.value || '').trim();
273
+ const st = (document.getElementById('onekite-se-start-time')?.value || '').trim();
274
+ const ed = (document.getElementById('onekite-se-end-date')?.value || '').trim();
275
+ const et = (document.getElementById('onekite-se-end-time')?.value || '').trim();
276
+ // If the user keeps the same day but chooses an end time earlier than
277
+ // (or equal to) the start time (default 07:00 → 07:00), interpret
278
+ // it as an overnight/24h range and roll the end date by +1 day.
279
+ let startVal = '';
280
+ let endVal = '';
281
+ try {
282
+ if (sd && st) {
283
+ const startDt = new Date(`${sd}T${st}`);
284
+ startVal = startDt.toISOString();
285
+ if (ed && et) {
286
+ let endDt = new Date(`${ed}T${et}`);
287
+ if (!isNaN(startDt) && !isNaN(endDt) && endDt.getTime() <= startDt.getTime()) {
288
+ // Only auto-roll when user kept the same end date.
289
+ if (ed === sd) {
290
+ endDt = new Date(endDt.getTime() + 24 * 60 * 60 * 1000);
291
+ }
292
+ }
293
+ endVal = endDt.toISOString();
294
+ }
295
+ }
296
+ } catch (e) {
297
+ startVal = (sd && st) ? buildLocalIso(sd, st) : '';
298
+ endVal = (ed && et) ? buildLocalIso(ed, et) : '';
299
+ }
300
+ const address = (document.getElementById('onekite-se-address')?.value || '').trim();
301
+ const notes = (document.getElementById('onekite-se-notes')?.value || '').trim();
302
+ const lat = (document.getElementById('onekite-se-lat')?.value || '').trim();
303
+ const lon = (document.getElementById('onekite-se-lon')?.value || '').trim();
304
+ resolved = true;
305
+ resolve({ title, start: startVal, end: endVal, address, notes, lat, lon });
306
+ return true;
307
+ },
308
+ },
309
+ },
310
+ });
311
+
312
+ // If the modal is closed via ESC, backdrop click, or the "X" button,
313
+ // Bootbox does not trigger our cancel callback. Ensure we always resolve
314
+ // and release the global lock.
315
+ try {
316
+ dialog.on('hidden.bs.modal', () => {
317
+ if (resolved) return;
318
+ resolved = true;
319
+ resolve(null);
320
+ });
321
+ } catch (e) {
322
+ // ignore
323
+ }
324
+
325
+ // init leaflet
326
+ dialog.on('shown.bs.modal', () => {
327
+ setTimeout(async () => {
328
+
329
+ try {
330
+ const mapEl = document.getElementById('onekite-se-map');
331
+ if (!mapEl) return;
332
+ const L = await loadLeaflet();
333
+ const map = L.map(mapEl).setView([46.5, 2.5], 5);
334
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '&copy; OpenStreetMap' }).addTo(map);
335
+ setTimeout(() => { try { map.invalidateSize(); } catch (e) {} }, 100);
336
+ let marker = null;
337
+ function setMarker(lat, lon) {
338
+ if (marker) map.removeLayer(marker);
339
+ marker = L.marker([lat, lon], { draggable: true }).addTo(map);
340
+ marker.on('dragend', () => {
341
+ const p = marker.getLatLng();
342
+ document.getElementById('onekite-se-lat').value = String(p.lat);
343
+ document.getElementById('onekite-se-lon').value = String(p.lng);
344
+ });
345
+ document.getElementById('onekite-se-lat').value = String(lat);
346
+ document.getElementById('onekite-se-lon').value = String(lon);
347
+ map.setView([lat, lon], 14);
348
+ }
349
+ const geocodeBtn = document.getElementById('onekite-se-geocode');
350
+ const addrInput = document.getElementById('onekite-se-address');
351
+ try {
352
+ attachAddressAutocomplete(addrInput, (h) => {
353
+ if (h && Number.isFinite(h.lat) && Number.isFinite(h.lon)) {
354
+ setMarker(h.lat, h.lon);
355
+ }
356
+ });
357
+ } catch (e) {}
358
+ geocodeBtn?.addEventListener('click', async () => {
359
+ const q = (addrInput?.value || '').trim();
360
+ if (!q) return;
361
+ const hit = await geocodeAddress(q);
362
+ if (hit && hit.lat && hit.lon) {
363
+ setMarker(hit.lat, hit.lon);
364
+ } else {
365
+ showAlert('error', 'Adresse introuvable.');
366
+ }
367
+ });
368
+ } catch (e) {
369
+ // ignore leaflet errors
370
+ }
371
+ }, 50);
372
+ });
373
+ });
374
+ }
375
+
376
+ async function openMapViewer(title, address, lat, lon) {
377
+ const mapId = `onekite-map-view-${Date.now()}-${Math.floor(Math.random()*10000)}`;
378
+ const safeAddr = (address || '').trim();
379
+ const html = `
380
+ ${safeAddr ? `<div class="mb-2">${escapeHtml(safeAddr)}</div>` : ''}
381
+ <div id="${mapId}" style="height:260px; border:1px solid #ddd; border-radius:6px;"></div>
382
+ `;
383
+ const dialog = bootbox.dialog({
384
+ title: title || 'Carte',
385
+ message: html,
386
+ buttons: { close: { label: 'Fermer', className: 'btn-secondary' } },
387
+ });
388
+ dialog.on('shown.bs.modal', () => {
389
+ setTimeout(async () => {
390
+
391
+ try {
392
+ const el = document.getElementById(mapId);
393
+ if (!el) return;
394
+ const L = await loadLeaflet();
395
+ const map = L.map(el);
396
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '&copy; OpenStreetMap' }).addTo(map);
397
+ setTimeout(() => { try { map.invalidateSize(); } catch (e) {} }, 100);
398
+
399
+ async function setAt(lat2, lon2) {
400
+ map.setView([lat2, lon2], 14);
401
+ L.marker([lat2, lon2]).addTo(map);
402
+ }
403
+
404
+ const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
405
+ if (hasCoords) {
406
+ await setAt(lat, lon);
407
+ return;
408
+ }
409
+ if (safeAddr) {
410
+ const hit = await geocodeAddress(safeAddr);
411
+ if (hit && hit.lat && hit.lon) {
412
+ await setAt(hit.lat, hit.lon);
413
+ return;
414
+ }
415
+ }
416
+ map.setView([46.5, 2.5], 5);
417
+ } catch (e) {
418
+ // ignore leaflet errors
419
+ }
420
+ }, 50);
421
+ });
422
+ }
423
+
424
+ // Click handler for map links in popups
425
+ document.addEventListener('click', (ev) => {
426
+ const a = ev.target && ev.target.closest ? ev.target.closest('a.onekite-map-link') : null;
427
+ if (!a) return;
428
+ ev.preventDefault();
429
+ const addr = a.getAttribute('data-address') || a.textContent || '';
430
+ const lat = parseFloat(a.getAttribute('data-lat'));
431
+ const lon = parseFloat(a.getAttribute('data-lon'));
432
+ openMapViewer('Adresse', addr, lat, lon);
433
+ });
434
+ // Close any open Bootbox modal when navigating to a user profile from within a modal
435
+ document.addEventListener('click', (ev) => {
436
+ const a = ev.target && ev.target.closest ? ev.target.closest('a.onekite-user-link') : null;
437
+ if (!a) return;
438
+ const inModal = !!(a.closest && a.closest('.modal, .bootbox'));
439
+ if (!inModal) return;
440
+ const href = a.getAttribute('href');
441
+ if (!href) return;
442
+ ev.preventDefault();
443
+ try { bootbox.hideAll(); } catch (e) {}
444
+ setTimeout(() => { window.location.href = href; }, 50);
445
+ });
446
+
447
+
448
+
449
+ function statusLabel(s) {
450
+ const map = {
451
+ pending: 'En attente de validation',
452
+ awaiting_payment: 'Validée – paiement en attente',
453
+ paid: 'Payée',
454
+ rejected: 'Rejetée',
455
+ expired: 'Expirée',
456
+ };
457
+ return map[String(s || '')] || String(s || '');
458
+ }
459
+
460
+ function showAlert(type, msg) {
461
+ try {
462
+ if (alerts && typeof alerts[type] === 'function') {
463
+ alerts[type](msg);
464
+ return;
465
+ }
466
+ } catch (e) {}
467
+ alert(msg);
468
+ }
469
+
470
+ async function fetchJson(url, opts) {
471
+ const res = await fetch(url, {
472
+ credentials: 'same-origin',
473
+ headers: (() => {
474
+ const headers = { 'Content-Type': 'application/json' };
475
+ const token =
476
+ (window.config && (window.config.csrf_token || window.config.csrfToken)) ||
477
+ (window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
478
+ (document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
479
+ (document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
480
+ (typeof app !== 'undefined' && app && app.csrfToken) ||
481
+ null;
482
+ if (token) headers['x-csrf-token'] = token;
483
+ return headers;
484
+ })(),
485
+ ...opts,
486
+ });
487
+ if (!res.ok) {
488
+ let payload = null;
489
+ try { payload = await res.json(); } catch (e) {}
490
+ const err = new Error(`${res.status}`);
491
+ err.status = res.status;
492
+ err.payload = payload;
493
+ throw err;
494
+ }
495
+ return await res.json();
496
+ }
497
+
498
+ // Simple in-memory cache for JSON endpoints (used for events prefetch + ETag).
499
+ const jsonCache = new Map(); // url -> { etag, data, ts }
500
+
501
+ function invalidateEventsCache() {
502
+ try { jsonCache.clear(); } catch (e) {}
503
+ try {
504
+ if (window.__onekiteEventsAbort) {
505
+ window.__onekiteEventsAbort.abort();
506
+ window.__onekiteEventsAbort = null;
507
+ }
508
+ } catch (e) {}
509
+ }
510
+
511
+ function scheduleRefetch(cal) {
512
+ try {
513
+ if (!cal || typeof cal.refetchEvents !== 'function') return;
514
+ clearTimeout(window.__onekiteRefetchTimer);
515
+ window.__onekiteRefetchTimer = setTimeout(() => {
516
+ try { cal.refetchEvents(); } catch (e) {}
517
+ }, 150);
518
+ } catch (e) {}
519
+ }
520
+
521
+ async function fetchJsonCached(url, opts) {
522
+ const cached = jsonCache.get(url);
523
+ const headers = Object.assign({}, (opts && opts.headers) || {});
524
+ if (cached && cached.etag) {
525
+ headers['If-None-Match'] = cached.etag;
526
+ }
527
+ let res;
528
+ try {
529
+ res = await fetch(url, {
530
+ credentials: 'same-origin',
531
+ headers: (() => {
532
+ // reuse csrf header builder (fetchJson) by calling it indirectly
533
+ const base = { 'Content-Type': 'application/json' };
534
+ const token =
535
+ (window.config && (window.config.csrf_token || window.config.csrfToken)) ||
536
+ (window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
537
+ (document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
538
+ (document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
539
+ (typeof app !== 'undefined' && app && app.csrfToken) ||
540
+ null;
541
+ if (token) base['x-csrf-token'] = token;
542
+ return Object.assign(base, headers);
543
+ })(),
544
+ ...opts,
545
+ });
546
+ } catch (e) {
547
+ // If offline and we have cache, use it.
548
+ if (cached && cached.data) return cached.data;
549
+ throw e;
550
+ }
551
+
552
+ if (res.status === 304 && cached && cached.data) {
553
+ return cached.data;
554
+ }
555
+
556
+ if (!res.ok) {
557
+ let payload = null;
558
+ try { payload = await res.json(); } catch (e) {}
559
+ const err = new Error(`${res.status}`);
560
+ err.status = res.status;
561
+ err.payload = payload;
562
+ throw err;
563
+ }
564
+
565
+ const data = await res.json();
566
+ const etag = res.headers.get('ETag') || '';
567
+ jsonCache.set(url, { etag, data, ts: Date.now() });
568
+ return data;
569
+ }
570
+
571
+ async function loadCapabilities() {
572
+ return await fetchJson('/api/v3/plugins/calendar-onekite/capabilities');
573
+ }
574
+
575
+ // Leaflet (OpenStreetMap) helpers - loaded lazily only when needed.
576
+ let leafletPromise = null;
577
+ function loadLeaflet() {
578
+ if (leafletPromise) return leafletPromise;
579
+ leafletPromise = new Promise((resolve, reject) => {
580
+ try {
581
+ if (window.L && window.L.map) {
582
+ resolve(window.L);
583
+ return;
584
+ }
585
+
586
+ const cssId = 'onekite-leaflet-css';
587
+ const jsId = 'onekite-leaflet-js';
588
+
589
+ if (!document.getElementById(cssId)) {
590
+ const link = document.createElement('link');
591
+ link.id = cssId;
592
+ link.rel = 'stylesheet';
593
+ link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
594
+ document.head.appendChild(link);
595
+ }
596
+
597
+ const existing = document.getElementById(jsId);
598
+ if (existing) {
599
+ existing.addEventListener('load', () => resolve(window.L));
600
+ existing.addEventListener('error', () => reject(new Error('leaflet-load-failed')));
601
+ return;
602
+ }
603
+
604
+ const script = document.createElement('script');
605
+ script.id = jsId;
606
+ script.async = true;
607
+ script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
608
+ script.onload = () => resolve(window.L);
609
+ script.onerror = () => reject(new Error('leaflet-load-failed'));
610
+ document.head.appendChild(script);
611
+ } catch (e) {
612
+ reject(e);
613
+ }
614
+ });
615
+ return leafletPromise;
616
+ }
617
+
618
+ async function geocodeAddress(query) {
619
+ const q = String(query || '').trim();
620
+ if (!q) return null;
621
+ const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(q)}`;
622
+ const res = await fetch(url, {
623
+ method: 'GET',
624
+ headers: {
625
+ 'Accept': 'application/json',
626
+ 'Accept-Language': 'fr',
627
+ },
628
+ });
629
+ if (!res.ok) return null;
630
+ const arr = await res.json();
631
+ if (!Array.isArray(arr) || !arr.length) return null;
632
+ const hit = arr[0];
633
+ const lat = Number(hit.lat);
634
+ const lon = Number(hit.lon);
635
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
636
+ return { lat, lon, displayName: hit.display_name || q };
637
+ }
638
+
639
+ async function searchAddresses(query, limit) {
640
+ const q = String(query || '').trim();
641
+ const lim = Math.min(10, Math.max(1, Number(limit) || 5));
642
+ if (q.length < 3) return [];
643
+ const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&addressdetails=1&limit=${lim}&q=${encodeURIComponent(q)}`;
644
+ const res = await fetch(url, {
645
+ method: 'GET',
646
+ headers: {
647
+ 'Accept': 'application/json',
648
+ 'Accept-Language': 'fr',
649
+ },
650
+ });
651
+ if (!res.ok) return [];
652
+ const arr = await res.json();
653
+ if (!Array.isArray(arr)) return [];
654
+ return arr.map((hit) => {
655
+ const lat = Number(hit.lat);
656
+ const lon = Number(hit.lon);
657
+ return {
658
+ displayName: hit.display_name || q,
659
+ lat: Number.isFinite(lat) ? lat : null,
660
+ lon: Number.isFinite(lon) ? lon : null,
661
+ };
662
+ }).filter(h => h && h.displayName);
663
+ }
664
+
665
+ function attachAddressAutocomplete(inputEl, onPick) {
666
+ if (!inputEl) return;
667
+ // Avoid double attach
668
+ if (inputEl.getAttribute('data-onekite-autocomplete') === '1') return;
669
+ inputEl.setAttribute('data-onekite-autocomplete', '1');
670
+
671
+ // In Bootstrap input-groups (especially in ACP), wrapping the input breaks layout.
672
+ // So we anchor the menu to the closest input-group (or parent) without moving the input.
673
+ const host = inputEl.closest && inputEl.closest('.input-group')
674
+ ? inputEl.closest('.input-group')
675
+ : (inputEl.parentNode || document.body);
676
+
677
+ try {
678
+ const cs = window.getComputedStyle(host);
679
+ if (!cs || cs.position === 'static') {
680
+ host.style.position = 'relative';
681
+ }
682
+ } catch (e) {
683
+ host.style.position = 'relative';
684
+ }
685
+
686
+ const menu = document.createElement('div');
687
+ menu.className = 'onekite-autocomplete-menu';
688
+ menu.style.position = 'absolute';
689
+ menu.style.left = '0';
690
+ menu.style.right = '0';
691
+ menu.style.top = '100%';
692
+ menu.style.zIndex = '2000';
693
+ menu.style.background = '#fff';
694
+ menu.style.border = '1px solid rgba(0,0,0,.15)';
695
+ menu.style.borderTop = '0';
696
+ menu.style.maxHeight = '220px';
697
+ menu.style.overflowY = 'auto';
698
+ menu.style.display = 'none';
699
+ menu.style.borderRadius = '0 0 .375rem .375rem';
700
+ host.appendChild(menu);
701
+
702
+ let timer = null;
703
+ let lastQuery = '';
704
+ let busy = false;
705
+
706
+ function hide() {
707
+ menu.style.display = 'none';
708
+ menu.innerHTML = '';
709
+ }
710
+
711
+ function show(hits) {
712
+ if (!hits || !hits.length) {
713
+ hide();
714
+ return;
715
+ }
716
+ menu.innerHTML = '';
717
+ hits.forEach((h) => {
718
+ const btn = document.createElement('button');
719
+ btn.type = 'button';
720
+ btn.className = 'onekite-autocomplete-item';
721
+ btn.textContent = h.displayName;
722
+ btn.style.display = 'block';
723
+ btn.style.width = '100%';
724
+ btn.style.textAlign = 'left';
725
+ btn.style.padding = '.35rem .5rem';
726
+ btn.style.border = '0';
727
+ btn.style.background = 'transparent';
728
+ btn.style.cursor = 'pointer';
729
+ btn.addEventListener('click', () => {
730
+ inputEl.value = h.displayName;
731
+ hide();
732
+ try { onPick && onPick(h); } catch (e) {}
733
+ });
734
+ btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(0,0,0,.05)'; });
735
+ btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; });
736
+ menu.appendChild(btn);
737
+ });
738
+ menu.style.display = 'block';
739
+ }
740
+
741
+ async function run(q) {
742
+ if (busy) return;
743
+ busy = true;
744
+ try {
745
+ const hits = await searchAddresses(q, 6);
746
+ // Don't show stale results
747
+ if (String(inputEl.value || '').trim() !== q) return;
748
+ show(hits);
749
+ } catch (e) {
750
+ hide();
751
+ } finally {
752
+ busy = false;
753
+ }
754
+ }
755
+
756
+ inputEl.addEventListener('input', () => {
757
+ const q = String(inputEl.value || '').trim();
758
+ lastQuery = q;
759
+ if (timer) clearTimeout(timer);
760
+ if (q.length < 3) {
761
+ hide();
762
+ return;
763
+ }
764
+ timer = setTimeout(() => run(lastQuery), 250);
765
+ });
766
+
767
+ inputEl.addEventListener('focus', () => {
768
+ const q = String(inputEl.value || '').trim();
769
+ if (q.length >= 3) {
770
+ if (timer) clearTimeout(timer);
771
+ timer = setTimeout(() => run(q), 150);
772
+ }
773
+ });
774
+
775
+ // Close when clicking outside
776
+ document.addEventListener('click', (e) => {
777
+ try {
778
+ if (!host.contains(e.target)) hide();
779
+ } catch (err) {}
780
+ });
781
+
782
+ inputEl.addEventListener('keydown', (e) => {
783
+ if (e.key === 'Escape') hide();
784
+ });
785
+ }
786
+
787
+
788
+ async function loadItems() {
789
+ try {
790
+ return await fetchJson('/api/v3/plugins/calendar-onekite/items');
791
+ } catch (e) {
792
+ return [];
793
+ }
794
+ }
795
+
796
+ async function requestReservation(payload) {
797
+ return await fetchJson('/api/v3/plugins/calendar-onekite/reservations', {
798
+ method: 'POST',
799
+ body: JSON.stringify(payload),
800
+ });
801
+ }
802
+
803
+ async function approveReservation(rid, payload) {
804
+ return await fetchJson(`/api/v3/plugins/calendar-onekite/reservations/${rid}/approve`, {
805
+ method: 'PUT',
806
+ body: JSON.stringify(payload || {}),
807
+ });
808
+ }
809
+
810
+ async function cancelReservation(rid) {
811
+ return await fetchJson(`/api/v3/plugins/calendar-onekite/reservations/${rid}/cancel`, {
812
+ method: 'PUT',
813
+ });
814
+ }
815
+
816
+ async function refuseReservation(rid, payload) {
817
+ return await fetchJson(`/api/v3/plugins/calendar-onekite/reservations/${rid}/refuse`, {
818
+ method: 'PUT',
819
+ body: JSON.stringify(payload || {}),
820
+ });
821
+ }
822
+
823
+ function formatDt(d) {
824
+ // UI requirement: show dates only (no hours)
825
+ try {
826
+ return new Date(d).toLocaleDateString('fr-FR');
827
+ } catch (e) {
828
+ return String(d);
829
+ }
830
+ }
831
+
832
+
833
+ function formatDtWithTime(d) {
834
+ try {
835
+ return new Date(d).toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' });
836
+ } catch (e) {
837
+ return String(d);
838
+ }
839
+ }
840
+
841
+
842
+
843
+ function toLocalYmd(date) {
844
+ const d = new Date(date);
845
+ const pad = (n) => String(n).padStart(2, '0');
846
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
847
+ }
848
+
849
+ // Calendar-day difference with end treated as EXCLUSIVE (FullCalendar rule).
850
+ // Ignores hours/timezones/DST by projecting local Y/M/D onto UTC midnights.
851
+ function calendarDaysExclusive(startDate, endDate) {
852
+ const s = new Date(startDate);
853
+ const e = new Date(endDate);
854
+ const sUtc = Date.UTC(s.getFullYear(), s.getMonth(), s.getDate());
855
+ const eUtc = Date.UTC(e.getFullYear(), e.getMonth(), e.getDate());
856
+ const diff = Math.floor((eUtc - sUtc) / (24 * 60 * 60 * 1000));
857
+ return Math.max(1, diff);
858
+ }
859
+
860
+ function toDatetimeLocalValue(date) {
861
+ const d = new Date(date);
862
+ const pad = (n) => String(n).padStart(2, '0');
863
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
864
+ }
865
+
866
+ async function openReservationDialog(selectionInfo, items) {
867
+ const start = selectionInfo.start;
868
+ let end = selectionInfo.end;
869
+
870
+ // In some FullCalendar flows (notably on mobile), selectionInfo.end can be undefined.
871
+ // For all-day bookings, end is always treated as EXCLUSIVE.
872
+ if (!end) {
873
+ end = new Date(start);
874
+ end.setDate(end.getDate() + 1);
875
+ }
876
+
877
+ // Days (end is exclusive in FullCalendar) — compute in calendar days only
878
+ // (no dependency on hours, timezone or DST).
879
+ let days = calendarDaysExclusive(start, end);
880
+
881
+ // Maintenance is a simple ON/OFF flag per item (no dates).
882
+ const maintenance = new Set((items || []).filter(it => it && it.maintenance).map(it => String(it.id)));
883
+
884
+ // Fetch existing events overlapping the selection to disable already reserved items.
885
+ let blocked = new Set();
886
+ try {
887
+ // IMPORTANT: never use toISOString() for availability checks.
888
+ // Local midnight in Europe/Paris becomes 23:00Z/22:00Z depending on DST, which makes
889
+ // consecutive all-day ranges appear to overlap and wrongly greys out items.
890
+ // Always query using calendar dates (YYYY-MM-DD) with end exclusive.
891
+ const qs = new URLSearchParams({
892
+ start: toLocalYmd(start),
893
+ end: toLocalYmd(end),
894
+ });
895
+ const evs = await fetchJson(`/api/v3/plugins/calendar-onekite/events?${qs.toString()}`);
896
+ (evs || []).forEach((ev) => {
897
+ const st = (ev.extendedProps && ev.extendedProps.status) || '';
898
+ if (!['pending', 'awaiting_payment', 'approved', 'paid'].includes(st)) return;
899
+ const ids = (ev.extendedProps && ev.extendedProps.itemIds) || (ev.extendedProps && ev.extendedProps.itemId ? [ev.extendedProps.itemId] : []);
900
+ ids.forEach((id) => blocked.add(String(id)));
901
+ });
902
+ } catch (e) {}
903
+
904
+ // Price display: prices are returned in cents; show euros (price / 100)
905
+ const fmtPrice = (p) => {
906
+ const n = typeof p === 'number' ? p : parseFloat(String(p || '0'));
907
+ const val = (isNaN(n) ? 0 : n) / 100;
908
+ // Keep 2 decimals if needed
909
+ return val.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
910
+ };
911
+
912
+ const rows = items.map((it, idx) => {
913
+ const id = String(it.id);
914
+ const isMaint = maintenance.has(id);
915
+ const disabled = blocked.has(id) || isMaint;
916
+ const priceTxt = fmtPrice(it.price || 0);
917
+ const safeName = String(it.name).replace(/</g, '&lt;').replace(/>/g, '&gt;');
918
+ const labelName = isMaint ? `🔧 ${safeName} (en maintenance)` : safeName;
919
+ return `
920
+ <div class="d-flex align-items-center py-1 ${disabled ? 'opacity-50' : ''}" data-itemid="${id}" style="border-bottom: 1px solid var(--bs-border-color, #ddd);">
921
+ <div style="width: 26px;">
922
+ <input type="checkbox" class="form-check-input onekite-item-cb" data-id="${id}" data-name="${safeName}" data-price="${String(it.price || 0)}" ${disabled ? 'disabled' : ''}>
923
+ </div>
924
+ <div class="flex-grow-1">
925
+ <div><strong>${labelName}</strong></div>
926
+ <div class="text-muted" style="font-size: 12px;">${priceTxt} € / jour</div>
927
+ </div>
928
+ </div>
929
+ `;
930
+ }).join('');
931
+
932
+ const shortcutsHtml = (() => {
933
+ // Only show shortcuts when selection is a single day (common mobile flow)
934
+ if (days !== 1) return '';
935
+ return `
936
+ <div class="mb-2" id="onekite-duration-shortcuts">
937
+ <div class="text-muted" style="font-size:12px; margin-bottom:6px;">Durée rapide</div>
938
+ <div class="btn-group btn-group-sm" role="group">
939
+ <button type="button" class="btn btn-outline-secondary" data-days="1">1j</button>
940
+ <button type="button" class="btn btn-outline-secondary" data-days="2">2j</button>
941
+ <button type="button" class="btn btn-outline-secondary" data-days="3">3j</button>
942
+ <button type="button" class="btn btn-outline-secondary" data-days="7">7j</button>
943
+ </div>
944
+ </div>
945
+ `;
946
+ })();
947
+
948
+ const messageHtml = `
949
+ <div class="mb-2" id="onekite-period"><strong>Période</strong><br>${formatDt(start)} → ${formatDt(end)} <span class="text-muted" id="onekite-days">(${days} jour${days > 1 ? 's' : ''})</span></div>
950
+ ${shortcutsHtml}
951
+ <div class="mb-2"><strong>Matériel</strong></div>
952
+ <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;">
953
+ ${rows}
954
+ </div>
955
+ <div class="d-flex justify-content-between align-items-center">
956
+ <div class="text-muted">Total estimé</div>
957
+ <div id="onekite-total" style="font-size: 18px;"><strong>0,00 €</strong></div>
958
+ </div>
959
+ <div class="text-muted" style="font-size: 12px;">Les matériels grisés sont déjà réservés ou en attente.</div>
960
+ `;
961
+
962
+ return new Promise((resolve) => {
963
+ let settled = false;
964
+ const safeResolve = (v) => {
965
+ if (settled) { return; }
966
+ settled = true;
967
+ resolve(v);
968
+ };
969
+ const dlg = bootbox.dialog({
970
+ title: 'Demander une réservation',
971
+ message: messageHtml,
972
+ buttons: {
973
+ cancel: {
974
+ label: 'Annuler',
975
+ className: 'btn-secondary',
976
+ callback: function () {
977
+ resolve(null);
978
+ },
979
+ },
980
+ ok: {
981
+ label: 'Envoyer',
982
+ className: 'btn-primary',
983
+ callback: function () {
984
+ const cbs = Array.from(document.querySelectorAll('.onekite-item-cb')).filter(cb => cb.checked);
985
+ if (!cbs.length) {
986
+ showAlert('error', 'Choisis au moins un matériel.');
987
+ return false;
988
+ }
989
+ const itemIds = cbs.map(cb => cb.getAttribute('data-id'));
990
+ const itemNames = cbs.map(cb => cb.getAttribute('data-name'));
991
+ const sum = cbs.reduce((acc, cb) => acc + (parseFloat(cb.getAttribute('data-price') || '0') || 0), 0);
992
+ const total = (sum / 100) * days;
993
+ // Return the effective end date (exclusive) because duration shortcuts can
994
+ // change the range without updating the original FullCalendar selection.
995
+ resolve({ itemIds, itemNames, total, days, endDate: toLocalYmd(end) });
996
+ },
997
+ },
998
+ },
999
+ });
1000
+
1001
+ // live total update
1002
+ setTimeout(() => {
1003
+ const totalEl = document.getElementById('onekite-total');
1004
+ const periodEl = document.getElementById('onekite-period');
1005
+ const daysEl = document.getElementById('onekite-days');
1006
+
1007
+ function rangeQuery(s, e) {
1008
+ try {
1009
+ // IMPORTANT: For availability checks we must avoid ISO strings with timezone
1010
+ // (e.g. toISOString()) because local midnight in Europe/Paris becomes
1011
+ // 23:00Z or 22:00Z depending on DST, which makes consecutive all-day ranges
1012
+ // appear to overlap and wrongly greys out items.
1013
+ //
1014
+ // We always query using *calendar dates* (YYYY-MM-DD) with end exclusive,
1015
+ // matching FullCalendar's all-day rule.
1016
+ const sYmd = toLocalYmd(s);
1017
+ const eYmd = toLocalYmd(e);
1018
+ return new URLSearchParams({ start: sYmd, end: eYmd }).toString();
1019
+ } catch (err) {
1020
+ return '';
1021
+ }
1022
+ }
1023
+
1024
+ async function refreshBlocked() {
1025
+ try {
1026
+ const qs = rangeQuery(start, end);
1027
+ if (!qs) return;
1028
+ const evs = await fetchJson(`/api/v3/plugins/calendar-onekite/events?${qs}`);
1029
+ blocked = new Set();
1030
+ (evs || []).forEach((ev) => {
1031
+ const st = (ev.extendedProps && ev.extendedProps.status) || '';
1032
+ if (!['pending', 'awaiting_payment', 'approved', 'paid'].includes(st)) return;
1033
+ const ids = (ev.extendedProps && ev.extendedProps.itemIds) || (ev.extendedProps && ev.extendedProps.itemId ? [ev.extendedProps.itemId] : []);
1034
+ ids.forEach((id) => blocked.add(String(id)));
1035
+ });
1036
+
1037
+ document.querySelectorAll('#onekite-items [data-itemid]').forEach((row) => {
1038
+ const iid = row.getAttribute('data-itemid');
1039
+ const dis = blocked.has(String(iid)) || maintenance.has(String(iid));
1040
+ row.classList.toggle('opacity-50', dis);
1041
+ const cb = row.querySelector('input.onekite-item-cb');
1042
+ if (cb) {
1043
+ cb.disabled = dis;
1044
+ if (dis) cb.checked = false;
1045
+ }
1046
+ });
1047
+ } catch (e) {
1048
+ // ignore
1049
+ }
1050
+ }
1051
+
1052
+ function refreshTotal() {
1053
+ const cbs = Array.from(document.querySelectorAll('.onekite-item-cb')).filter(cb => cb.checked);
1054
+ const sum = cbs.reduce((acc, cb) => acc + (parseFloat(cb.getAttribute('data-price') || '0') || 0), 0);
1055
+ const total = (sum / 100) * days;
1056
+ if (totalEl) {
1057
+ totalEl.innerHTML = `<strong>${total.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €</strong>`;
1058
+ }
1059
+ }
1060
+ document.querySelectorAll('.onekite-item-cb').forEach(cb => cb.addEventListener('change', refreshTotal));
1061
+ refreshTotal();
1062
+
1063
+ // Duration shortcuts (single-day selections): update end/days + refresh blocked items
1064
+ const shortcutsWrap = document.getElementById('onekite-duration-shortcuts');
1065
+ if (shortcutsWrap) {
1066
+ shortcutsWrap.addEventListener('click', async (ev) => {
1067
+ const btn = ev.target && ev.target.closest ? ev.target.closest('button[data-days]') : null;
1068
+ if (!btn) return;
1069
+ const nd = parseInt(btn.getAttribute('data-days'), 10);
1070
+ if (!Number.isFinite(nd) || nd < 1) return;
1071
+ // end is exclusive -> add nd days to start
1072
+ end = new Date(start);
1073
+ end.setDate(end.getDate() + nd);
1074
+ days = calendarDaysExclusive(start, end);
1075
+ if (daysEl) {
1076
+ daysEl.textContent = `(${days} jour${days > 1 ? 's' : ''})`;
1077
+ }
1078
+ if (periodEl) {
1079
+ periodEl.innerHTML = `<strong>Période</strong><br>${formatDt(start)} → ${formatDt(end)} <span class="text-muted" id="onekite-days">(${days} jour${days > 1 ? 's' : ''})</span>`;
1080
+ }
1081
+ // Toggle active button
1082
+ shortcutsWrap.querySelectorAll('button[data-days]').forEach((b) => b.classList.toggle('active', b === btn));
1083
+ await refreshBlocked();
1084
+ refreshTotal();
1085
+ });
1086
+ }
1087
+ }, 0);
1088
+ });
1089
+ }
1090
+
1091
+ async function init(selector) {
1092
+ const el = document.querySelector(selector);
1093
+ if (!el) {
1094
+ return;
1095
+ }
1096
+
1097
+ if (typeof FullCalendar === 'undefined') {
1098
+ showAlert('error', 'FullCalendar non chargé');
1099
+ return;
1100
+ }
1101
+
1102
+ const items = await loadItems();
1103
+ cachedItems = items;
1104
+ const caps = await loadCapabilities().catch(() => ({}));
1105
+ const canCreateSpecial = !!caps.canCreateSpecial;
1106
+ const canDeleteSpecial = !!caps.canDeleteSpecial;
1107
+
1108
+ // Current creation mode: reservation (location) or special (event).
1109
+ // Persist the user's last choice so actions (create/approve/refuse/refetch) don't reset it.
1110
+ const modeStorageKey = (() => {
1111
+ try {
1112
+ const uid = (typeof app !== 'undefined' && app && app.user && (app.user.uid || app.user.uid === 0)) ? String(app.user.uid)
1113
+ : (window.ajaxify && window.ajaxify.data && (window.ajaxify.data.uid || window.ajaxify.data.uid === 0)) ? String(window.ajaxify.data.uid)
1114
+ : '0';
1115
+ return `onekiteCalendarMode:${uid}`;
1116
+ } catch (e) {
1117
+ return 'onekiteCalendarMode:0';
1118
+ }
1119
+ })();
1120
+
1121
+ function loadSavedMode() {
1122
+ try {
1123
+ const v = (window.localStorage && window.localStorage.getItem(modeStorageKey)) || '';
1124
+ return (v === 'special' || v === 'reservation') ? v : 'reservation';
1125
+ } catch (e) {
1126
+ return 'reservation';
1127
+ }
1128
+ }
1129
+
1130
+ function saveMode(v) {
1131
+ try {
1132
+ if (!window.localStorage) return;
1133
+ window.localStorage.setItem(modeStorageKey, v);
1134
+ } catch (e) {
1135
+ // ignore
1136
+ }
1137
+ }
1138
+
1139
+ let mode = loadSavedMode();
1140
+ // Avoid showing the "mode évènement" hint multiple times (desktop + mobile handlers)
1141
+ let lastModeHintAt = 0;
1142
+
1143
+ function refreshDesktopModeButton() {
1144
+ try {
1145
+ const btn = document.querySelector('#onekite-calendar .fc-newSpecial-button');
1146
+ if (!btn) return;
1147
+
1148
+ const isSpecial = mode === 'special';
1149
+ const label = isSpecial ? 'Évènement ✓' : 'Évènement';
1150
+
1151
+ // Ensure a single canonical .fc-button-text (prevents "ÉvènementÉvènement" after rerenders)
1152
+ let span = btn.querySelector('.fc-button-text');
1153
+ if (!span) {
1154
+ span = document.createElement('span');
1155
+ span.className = 'fc-button-text';
1156
+ // Remove stray text nodes before inserting span
1157
+ [...btn.childNodes].forEach((n) => {
1158
+ if (n && n.nodeType === Node.TEXT_NODE) n.remove();
1159
+ });
1160
+ btn.appendChild(span);
1161
+ } else {
1162
+ // Remove any stray text nodes beside the span
1163
+ [...btn.childNodes].forEach((n) => {
1164
+ if (n && n.nodeType === Node.TEXT_NODE && n.textContent.trim()) n.remove();
1165
+ });
1166
+ }
1167
+
1168
+ span.textContent = label;
1169
+ btn.classList.toggle('onekite-active', isSpecial);
1170
+ } catch (e) {}
1171
+ }
1172
+
1173
+ function setMode(next, opts) {
1174
+ if (next !== 'reservation' && next !== 'special') return;
1175
+ mode = next;
1176
+ saveMode(mode);
1177
+
1178
+ // Reset any pending selection/dialog state when switching modes
1179
+ try { if (mode === 'reservation') { calendar.unselect(); isDialogOpen = false; } } catch (e) {}
1180
+
1181
+ const silent = !!(opts && opts.silent);
1182
+ if (!silent && mode === 'special') {
1183
+ const now = Date.now();
1184
+ if (now - lastModeHintAt > 1200) {
1185
+ lastModeHintAt = now;
1186
+ showAlert('success', 'Mode évènement : sélectionnez une date ou une plage');
1187
+ }
1188
+ }
1189
+ refreshDesktopModeButton();
1190
+ try {
1191
+ const mb = document.querySelector('#onekite-mobile-controls .onekite-mode-btn');
1192
+ if (mb) {
1193
+ const isSpecial = mode === 'special';
1194
+ mb.textContent = isSpecial ? 'Mode évènement ✓' : 'Mode évènement';
1195
+ mb.classList.toggle('onekite-active', isSpecial);
1196
+ }
1197
+ } catch (e) {}
1198
+ }
1199
+
1200
+ // Inject lightweight responsive CSS once.
1201
+ try {
1202
+ const styleId = 'onekite-responsive-css';
1203
+ if (!document.getElementById(styleId)) {
1204
+ const st = document.createElement('style');
1205
+ st.id = styleId;
1206
+ st.textContent = `
1207
+ /* Prevent accidental horizontal scroll on small screens */
1208
+ #onekite-calendar, #onekite-calendar * { box-sizing: border-box; }
1209
+ #onekite-calendar { width: 100%; max-width: 100%; overflow-x: hidden; }
1210
+
1211
+ @media (max-width: 576px) {
1212
+ #onekite-calendar .fc .fc-toolbar-title { font-size: 1.05rem; }
1213
+ #onekite-calendar .fc .fc-button { padding: .25rem .45rem; font-size: .85rem; }
1214
+ #onekite-calendar .fc .fc-daygrid-day-number { font-size: .8rem; }
1215
+ #onekite-calendar .fc .fc-daygrid-event { font-size: .75rem; }
1216
+ #onekite-calendar .fc .fc-timegrid-event { font-size: .75rem; }
1217
+ #onekite-calendar .fc .fc-col-header-cell-cushion { font-size: .8rem; }
1218
+ }
1219
+
1220
+ @media (max-width: 576px) and (orientation: landscape) {
1221
+ #onekite-calendar .fc .fc-toolbar-title { font-size: 1rem; }
1222
+ #onekite-calendar .fc .fc-button { padding: .2rem .4rem; font-size: .8rem; }
1223
+ }
1224
+
1225
+ /* Violet action button (events mode) */
1226
+ #onekite-calendar .fc .fc-newSpecial-button,
1227
+ .onekite-btn-violet {
1228
+ background: #6f42c1 !important;
1229
+ border-color: #6f42c1 !important;
1230
+ color: #fff !important;
1231
+ }
1232
+ /* Active state */
1233
+ #onekite-calendar .fc .fc-newSpecial-button.onekite-active,
1234
+ .onekite-btn-violet.onekite-active {
1235
+ filter: brightness(0.95);
1236
+ box-shadow: 0 0 0 0.15rem rgba(111,66,193,.25);
1237
+ }
1238
+ `;
1239
+ document.head.appendChild(st);
1240
+ }
1241
+ } catch (e) {}
1242
+
1243
+ function isMobileNow() {
1244
+ return !!(window.matchMedia && window.matchMedia('(max-width: 576px)').matches);
1245
+ }
1246
+
1247
+ function isLandscapeNow() {
1248
+ return !!(window.matchMedia && window.matchMedia('(orientation: landscape)').matches);
1249
+ }
1250
+
1251
+ function computeAspectRatio() {
1252
+ const mobile = isMobileNow();
1253
+ if (!mobile) return 1.35;
1254
+ return isLandscapeNow() ? 1.6 : 0.9;
1255
+ }
1256
+
1257
+ const headerToolbar = isMobileNow() ? {
1258
+ left: 'prev,next',
1259
+ center: 'title',
1260
+ right: 'today',
1261
+ } : {
1262
+ left: 'prev,next today',
1263
+ center: 'title',
1264
+ // Only month + week (no day view)
1265
+ right: (canCreateSpecial ? 'newSpecial ' : '') + 'dayGridMonth,timeGridWeek',
1266
+ };
1267
+
1268
+ let calendar;
1269
+
1270
+ // Unified handler for creation actions (reservations vs special events).
1271
+ // On mobile, FullCalendar may emit `dateClick` but not `select` for a simple tap.
1272
+ // We therefore support both without calling `calendar.select()` (which could
1273
+ // double-trigger `select`).
1274
+ async function handleCreateFromSelection(info) {
1275
+ if (isDialogOpen) {
1276
+ return;
1277
+ }
1278
+ // Avoid double-taps creating two dialogs / two requests.
1279
+ if (!lockAction('create', 900)) {
1280
+ return;
1281
+ }
1282
+ isDialogOpen = true;
1283
+ try {
1284
+ if (mode === 'special' && canCreateSpecial) {
1285
+ const payload = await openSpecialEventDialog(info);
1286
+ if (!payload) {
1287
+ calendar.unselect();
1288
+ isDialogOpen = false;
1289
+ return;
1290
+ }
1291
+ await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1292
+ method: 'POST',
1293
+ body: JSON.stringify(payload),
1294
+ }).catch(async () => {
1295
+ return await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1296
+ method: 'POST',
1297
+ body: JSON.stringify(payload),
1298
+ });
1299
+ });
1300
+ showAlert('success', 'Évènement créé.');
1301
+ invalidateEventsCache();
1302
+ scheduleRefetch(calendar);
1303
+ calendar.unselect();
1304
+ isDialogOpen = false;
1305
+ return;
1306
+ }
1307
+
1308
+ // Business rule: reservations cannot start today or in the past.
1309
+ // (We validate again on the server, but this gives immediate feedback.)
1310
+ try {
1311
+ const startDateCheck = toLocalYmd(info.start);
1312
+ const todayCheck = toLocalYmd(new Date());
1313
+ if (startDateCheck <= todayCheck) {
1314
+ lastDateRuleToastAt = Date.now();
1315
+ showAlert('error', "Impossible de réserver pour aujourd’hui ou une date passée.");
1316
+ calendar.unselect();
1317
+ isDialogOpen = false;
1318
+ return;
1319
+ }
1320
+ } catch (e) {
1321
+ // ignore
1322
+ }
1323
+
1324
+ if (!items || !items.length) {
1325
+ showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1326
+ calendar.unselect();
1327
+ isDialogOpen = false;
1328
+ return;
1329
+ }
1330
+ const chosen = await openReservationDialog(info, items);
1331
+ if (!chosen || !chosen.itemIds || !chosen.itemIds.length) {
1332
+ calendar.unselect();
1333
+ isDialogOpen = false;
1334
+ return;
1335
+ }
1336
+ // Send date strings (no hours) so reservations are day-based.
1337
+ const startDate = toLocalYmd(info.start);
1338
+ // NOTE: FullCalendar's `info.end` reflects the original selection.
1339
+ // If the user used "Durée rapide", the effective end date is held
1340
+ // inside the dialog (returned as `chosen.endDate`).
1341
+ const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(info.end);
1342
+ const resp = await requestReservation({
1343
+ start: startDate,
1344
+ end: endDate,
1345
+ itemIds: chosen.itemIds,
1346
+ itemNames: chosen.itemNames,
1347
+ total: chosen.total,
1348
+ });
1349
+ if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
1350
+ showAlert('success', 'Réservation confirmée.');
1351
+ } else {
1352
+ showAlert('success', 'Demande envoyée (en attente de validation).');
1353
+ }
1354
+ invalidateEventsCache();
1355
+ scheduleRefetch(calendar);
1356
+ calendar.unselect();
1357
+ isDialogOpen = false;
1358
+ } catch (e) {
1359
+ const code = String((e && (e.status || e.message)) || '');
1360
+ const payload = e && e.payload ? e.payload : null;
1361
+
1362
+ if (code === '403') {
1363
+ const msg = payload && (payload.message || payload.error || payload.msg) ? String(payload.message || payload.error || payload.msg) : '';
1364
+ const c = payload && payload.code ? String(payload.code) : '';
1365
+ if (c === 'NOT_MEMBER' || /adh(é|e)rent/i.test(msg) || /membership/i.test(msg)) {
1366
+ showAlert('error', msg || 'Vous devez être adhérent pour pouvoir effectuer une réservation.');
1367
+ } else {
1368
+ showAlert('error', msg || 'Impossible de créer la demande : droits insuffisants (groupe).');
1369
+ }
1370
+ } else if (code === '409') {
1371
+ showAlert('error', 'Impossible : au moins un matériel est déjà réservé ou en attente sur cette période.');
1372
+ } else if (code === '400' && payload && (payload.error === 'date-too-soon' || payload.code === 'date-too-soon')) {
1373
+ // If we already showed the client-side toast a moment ago, avoid a duplicate.
1374
+ if (!lastDateRuleToastAt || (Date.now() - lastDateRuleToastAt) > 1500) {
1375
+ showAlert('error', String(payload.message || "Impossible de réserver pour aujourd’hui ou une date passée."));
1376
+ }
1377
+ } else {
1378
+ const msgRaw = payload && (payload.message || payload.error || payload.msg)
1379
+ ? String(payload.message || payload.error || payload.msg)
1380
+ : '';
1381
+
1382
+ // NodeBB can return a plain "not-logged-in" string when the user is not authenticated.
1383
+ // We want a user-friendly message consistent with the membership requirement.
1384
+ const msg = (/\bnot-logged-in\b/i.test(msgRaw) || /\[\[error:not-logged-in\]\]/i.test(msgRaw))
1385
+ ? 'Vous devez être adhérent Onekite'
1386
+ : msgRaw;
1387
+
1388
+ 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.'));
1389
+ }
1390
+ calendar.unselect();
1391
+ isDialogOpen = false;
1392
+ }
1393
+ }
1394
+
1395
+ calendar = new FullCalendar.Calendar(el, {
1396
+ initialView: 'dayGridMonth',
1397
+ height: 'auto',
1398
+ contentHeight: 'auto',
1399
+ aspectRatio: computeAspectRatio(),
1400
+ dayMaxEvents: true,
1401
+ locale: 'fr',
1402
+ headerToolbar: headerToolbar,
1403
+ // Keep titles short on mobile to avoid horizontal overflow
1404
+ titleFormat: isMobileNow() ? { year: 'numeric', month: 'short' } : undefined,
1405
+ customButtons: canCreateSpecial ? {
1406
+ newSpecial: {
1407
+ text: 'Évènement',
1408
+ click: () => {
1409
+ setMode((mode === 'special') ? 'reservation' : 'special');
1410
+ },
1411
+ },
1412
+ } : {},
1413
+ // We display the time ourselves inside the title for "special" events,
1414
+ // to match reservation icons and avoid FullCalendar's fixed-width time column.
1415
+ displayEventTime: false,
1416
+ selectable: true,
1417
+ selectMirror: true,
1418
+ // Mobile: make selection responsive on touch
1419
+ longPressDelay: 300,
1420
+ selectLongPressDelay: 300,
1421
+ dayCellDidMount: function (arg) {
1422
+ // Visually disable today and past days for reservation creation rules,
1423
+ // without breaking event clicks.
1424
+ try {
1425
+ const cellDate = arg && arg.date ? new Date(arg.date) : null;
1426
+ if (!cellDate) return;
1427
+ const ymd = toLocalYmd(cellDate);
1428
+ const today = toLocalYmd(new Date());
1429
+ if (ymd <= today) {
1430
+ arg.el.classList.add('onekite-day-disabled');
1431
+ }
1432
+ } catch (e) {}
1433
+ },
1434
+ events: async function (info, successCallback, failureCallback) {
1435
+ try {
1436
+ // Abort previous in-flight events fetch to avoid "double refresh" effects.
1437
+ if (window.__onekiteEventsAbort) {
1438
+ try { window.__onekiteEventsAbort.abort(); } catch (e) {}
1439
+ }
1440
+ const abort = new AbortController();
1441
+ window.__onekiteEventsAbort = abort;
1442
+
1443
+ const qs = new URLSearchParams({ start: info.startStr, end: info.endStr });
1444
+ const url = `/api/v3/plugins/calendar-onekite/events?${qs.toString()}`;
1445
+ const data = await fetchJsonCached(url, { signal: abort.signal });
1446
+
1447
+ // Prefetch adjacent range (previous/next) for snappier navigation.
1448
+ try {
1449
+ const spanMs = (info.end && info.start) ? (info.end.getTime() - info.start.getTime()) : 0;
1450
+ if (spanMs > 0 && spanMs < 1000 * 3600 * 24 * 120) {
1451
+ const prevStart = new Date(info.start.getTime() - spanMs);
1452
+ const prevEnd = new Date(info.start.getTime());
1453
+ const nextStart = new Date(info.end.getTime());
1454
+ const nextEnd = new Date(info.end.getTime() + spanMs);
1455
+ const toStr = (d) => new Date(d.getTime()).toISOString();
1456
+ const qPrev = new URLSearchParams({ start: toStr(prevStart), end: toStr(prevEnd) });
1457
+ const qNext = new URLSearchParams({ start: toStr(nextStart), end: toStr(nextEnd) });
1458
+ fetchJsonCached(`/api/v3/plugins/calendar-onekite/events?${qPrev.toString()}`).catch(() => {});
1459
+ fetchJsonCached(`/api/v3/plugins/calendar-onekite/events?${qNext.toString()}`).catch(() => {});
1460
+ }
1461
+ } catch (e) {}
1462
+
1463
+ // IMPORTANT: align "special" event display exactly like reservation icons.
1464
+ // We inject the clock + time range directly into the event title so FC
1465
+ // doesn't reserve a separate time column (which creates a leading gap).
1466
+ const mapped = (Array.isArray(data) ? data : []).map((ev) => {
1467
+ try {
1468
+ if (ev && ev.extendedProps && ev.extendedProps.type === 'special' && ev.start && ev.end) {
1469
+ // Force the same visual layout as reservations in month view
1470
+ // (avoid the "dot" layout which introduces a leading gap).
1471
+ ev.display = 'block';
1472
+
1473
+ const s = new Date(ev.start);
1474
+ const e = new Date(ev.end);
1475
+ const ts = `${pad2(s.getHours())}:${pad2(s.getMinutes())}`;
1476
+ const te = `${pad2(e.getHours())}:${pad2(e.getMinutes())}`;
1477
+ const baseTitle = (ev.title || '').trim();
1478
+ // Avoid double prefix on rerenders
1479
+ const prefix = `🕒 ${ts}-${te} `;
1480
+ if (!baseTitle.startsWith('🕒 ')) {
1481
+ ev.title = `${prefix}${baseTitle}`.trim();
1482
+ }
1483
+ }
1484
+ } catch (e2) {}
1485
+ return ev;
1486
+ });
1487
+
1488
+ successCallback(mapped);
1489
+ } catch (e) {
1490
+ failureCallback(e);
1491
+ }
1492
+ },
1493
+ eventDidMount: function (arg) {
1494
+ // Keep special event colors consistent.
1495
+ try {
1496
+ const ev = arg && arg.event;
1497
+ if (!ev) return;
1498
+ if (ev.extendedProps && ev.extendedProps.type === 'special') {
1499
+ const el2 = arg.el;
1500
+ if (el2 && el2.style) {
1501
+ el2.style.backgroundColor = '#8e44ad';
1502
+ el2.style.borderColor = '#8e44ad';
1503
+ el2.style.color = '#ffffff';
1504
+ }
1505
+ }
1506
+ } catch (e) {}
1507
+ },
1508
+ select: async function (info) {
1509
+ return await handleCreateFromSelection(info);
1510
+ },
1511
+ dateClick: async function (info) {
1512
+ // For a simple tap/click on a day cell, create a 1-day selection.
1513
+ // This restores mobile behavior (where `select` may not fire on tap).
1514
+ const start = new Date(info.date);
1515
+ const end = new Date(start);
1516
+ end.setDate(end.getDate() + 1);
1517
+ return await handleCreateFromSelection({ start: start, end: end, allDay: true });
1518
+ },
1519
+
1520
+ eventClick: async function (info) {
1521
+ if (isDialogOpen) return;
1522
+ isDialogOpen = true;
1523
+ const ev = info.event;
1524
+ const p0 = ev.extendedProps || {};
1525
+
1526
+ // Load full details lazily (events list is lightweight for perf).
1527
+ let p = p0;
1528
+ try {
1529
+ if (p0.type === 'reservation' && p0.rid) {
1530
+ const details = await fetchJson(`/api/v3/plugins/calendar-onekite/reservations/${encodeURIComponent(String(p0.rid))}`);
1531
+ p = Object.assign({}, p0, details);
1532
+ } else if (p0.type === 'special' && p0.eid) {
1533
+ const details = await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(String(p0.eid))}`);
1534
+ p = Object.assign({}, p0, details, {
1535
+ // keep backward compat with older field names used by templates below
1536
+ pickupAddress: details.address || details.pickupAddress || p0.pickupAddress,
1537
+ pickupLat: details.lat || details.pickupLat || p0.pickupLat,
1538
+ pickupLon: details.lon || details.pickupLon || p0.pickupLon,
1539
+ });
1540
+ }
1541
+ } catch (e) {
1542
+ // ignore detail fetch errors; fall back to minimal props
1543
+ p = p0;
1544
+ }
1545
+
1546
+ try {
1547
+ if (p.type === 'special') {
1548
+ const username = String(p.username || '').trim();
1549
+ const userLine = username
1550
+ ? `<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>`
1551
+ : '';
1552
+ const addr = String(p.pickupAddress || '').trim();
1553
+ const lat = Number(p.pickupLat);
1554
+ const lon = Number(p.pickupLon);
1555
+ const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
1556
+ const notes = String(p.notes || '').trim();
1557
+ const addrHtml = addr
1558
+ ? (hasCoords
1559
+ ? `<a href="#" class="onekite-map-link" data-address="${escapeHtml(addr)}" data-lat="${escapeHtml(String(lat))}" data-lon="${escapeHtml(String(lon))}">${escapeHtml(addr)}</a>`
1560
+ : `${escapeHtml(addr)}`)
1561
+ : '';
1562
+ const html = `
1563
+ <div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
1564
+ ${userLine}
1565
+ <div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDtWithTime(ev.start))} → ${escapeHtml(formatDtWithTime(ev.end))}</div>
1566
+ ${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${addrHtml}</div>` : ''}
1567
+ ${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
1568
+ `;
1569
+ const canDel = !!(p.canDeleteSpecial || canDeleteSpecial);
1570
+ bootbox.dialog({
1571
+ title: 'Évènement',
1572
+ message: html,
1573
+ buttons: {
1574
+ close: { label: 'Fermer', className: 'btn-secondary' },
1575
+ ...(canDel ? {
1576
+ del: {
1577
+ label: 'Supprimer',
1578
+ className: 'btn-danger',
1579
+ callback: async () => {
1580
+ try {
1581
+ const eid = String(p.eid || ev.id).replace(/^special:/, '');
1582
+ await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(eid)}`, { method: 'DELETE' });
1583
+ showAlert('success', 'Évènement supprimé.');
1584
+ calendar.refetchEvents();
1585
+ } catch (e) {
1586
+ showAlert('error', 'Suppression impossible.');
1587
+ }
1588
+ },
1589
+ },
1590
+ } : {}),
1591
+ },
1592
+ });
1593
+ return;
1594
+ }
1595
+ const rid = p.rid || ev.id;
1596
+ const status = p.status || '';
1597
+
1598
+ // Reserved-by line (user profile link)
1599
+ const username = String(p.username || p.user || p.reservedBy || p.ownerUsername || '').trim();
1600
+ const userLine = username
1601
+ ? `<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>`
1602
+ : '';
1603
+ const itemsHtml = (() => {
1604
+ 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] : []));
1605
+ if (names.length) {
1606
+ 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>`;
1607
+ }
1608
+ return String(ev.title || '').replace(/</g,'&lt;').replace(/>/g,'&gt;');
1609
+ })();
1610
+ const period = `${formatDt(ev.start)} → ${formatDt(ev.end)}`;
1611
+
1612
+ const approvedBy = String(p.approvedByUsername || '').trim();
1613
+ const validatedByHtml = approvedBy
1614
+ ? `<div class=\"mb-2\"><strong>Validée par</strong><br><a href=\"https://www.onekite.com/user/${encodeURIComponent(approvedBy)}\">${escapeHtml(approvedBy)}</a></div>`
1615
+ : '';
1616
+
1617
+ // Pickup details (address / time / notes) shown once validated
1618
+ const pickupAddress = String(p.pickupAddress || '').trim();
1619
+ const pickupTime = String(p.pickupTime || '').trim();
1620
+ const notes = String(p.notes || '').trim();
1621
+ const lat = Number(p.pickupLat);
1622
+ const lon = Number(p.pickupLon);
1623
+ const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
1624
+ const osmLink = hasCoords
1625
+ ? `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(lat))}&mlon=${encodeURIComponent(String(lon))}#map=17/${encodeURIComponent(String(lat))}/${encodeURIComponent(String(lon))}`
1626
+ : '';
1627
+ const pickupHtml = (pickupAddress || pickupTime || notes)
1628
+ ? `
1629
+ <div class="mb-2">
1630
+ <strong>Récupération</strong><br>
1631
+ ${pickupAddress ? `${escapeHtml(pickupAddress)}<br>` : ''}
1632
+ ${pickupTime ? `Heure : ${escapeHtml(pickupTime)}<br>` : ''}
1633
+ ${hasCoords ? `<a href="${osmLink}" target="_blank" rel="noopener">Voir sur la carte</a><br>` : ''}
1634
+ ${notes ? `<div class="mt-1"><strong>Notes</strong><br>${escapeHtml(notes)}</div>` : ''}
1635
+ </div>
1636
+ `
1637
+ : '';
1638
+
1639
+ const canModerate = !!p.canModerate;
1640
+ const isPending = status === 'pending';
1641
+
1642
+ const refusedReason = String(p.refusedReason || p.refuseReason || '').trim();
1643
+ const refusedReasonHtml = (status === 'refused' && refusedReason)
1644
+ ? `<div class="mb-2"><strong>Raison du refus</strong><br>${escapeHtml(refusedReason)}</div>`
1645
+ : '';
1646
+
1647
+ const baseHtml = `
1648
+ ${userLine}
1649
+ <div class="mb-2"><strong>Matériel</strong><br>${itemsHtml}</div>
1650
+ <div class="mb-2"><strong>Période</strong><br>${period}</div>
1651
+ ${validatedByHtml}
1652
+ ${pickupHtml}
1653
+ ${refusedReasonHtml}
1654
+ <div class="text-muted" style="font-size: 12px;">Statut: ${statusLabel(status)}</div>
1655
+ `;
1656
+
1657
+ 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) || '');
1658
+ const ownerUid = String(ev.extendedProps && ev.extendedProps.uid ? ev.extendedProps.uid : '');
1659
+ const isOwner = uidNow && ownerUid && uidNow === ownerUid;
1660
+ const showModeration = canModerate && isPending;
1661
+ const showCancel = isOwner && ['pending', 'awaiting_payment'].includes(status);
1662
+ const paymentUrl = String((p && p.paymentUrl) || '');
1663
+ const showPay = isOwner && status === 'awaiting_payment' && /^https?:\/\//i.test(paymentUrl);
1664
+ const buttons = {
1665
+ close: { label: 'Fermer', className: 'btn-secondary' },
1666
+ };
1667
+ if (showPay) {
1668
+ buttons.pay = {
1669
+ label: 'Payer maintenant',
1670
+ className: 'btn-primary',
1671
+ callback: () => {
1672
+ try {
1673
+ window.open(paymentUrl, '_blank', 'noopener');
1674
+ } catch (e) {}
1675
+ return false;
1676
+ },
1677
+ };
1678
+ }
1679
+ if (showCancel) {
1680
+ buttons.cancel = {
1681
+ label: 'Annuler',
1682
+ className: 'btn-outline-warning',
1683
+ callback: async () => {
1684
+ try {
1685
+ if (!lockAction(`cancel:${rid}`, 1200)) return false;
1686
+ await cancelReservation(rid);
1687
+ showAlert('success', 'Réservation annulée.');
1688
+ invalidateEventsCache();
1689
+ scheduleRefetch(calendar);
1690
+ } catch (e) {
1691
+ showAlert('error', 'Annulation impossible.');
1692
+ }
1693
+ },
1694
+ };
1695
+ }
1696
+ if (showModeration) {
1697
+ buttons.refuse = {
1698
+ label: 'Refuser',
1699
+ className: 'btn-outline-danger',
1700
+ callback: async () => {
1701
+ const html = `
1702
+ <div class="mb-3">
1703
+ <label class="form-label">Raison du refus</label>
1704
+ <textarea class="form-control" id="onekite-refuse-reason" rows="3" placeholder="Ex: matériel indisponible, dates impossibles, dossier incomplet..."></textarea>
1705
+ </div>
1706
+ `;
1707
+ return await new Promise((resolve) => {
1708
+ bootbox.dialog({
1709
+ title: 'Refuser la réservation',
1710
+ message: html,
1711
+ buttons: {
1712
+ cancel: { label: 'Annuler', className: 'btn-secondary', callback: () => resolve(false) },
1713
+ ok: {
1714
+ label: 'Refuser',
1715
+ className: 'btn-danger',
1716
+ callback: async () => {
1717
+ try {
1718
+ const reason = (document.getElementById('onekite-refuse-reason')?.value || '').trim();
1719
+ if (!lockAction(`refuse:${rid}`, 1200)) return false;
1720
+ await refuseReservation(rid, { reason });
1721
+ showAlert('success', 'Demande refusée.');
1722
+ invalidateEventsCache();
1723
+ scheduleRefetch(calendar);
1724
+ resolve(true);
1725
+ } catch (e) {
1726
+ showAlert('error', 'Refus impossible.');
1727
+ resolve(false);
1728
+ }
1729
+ return false;
1730
+ },
1731
+ },
1732
+ },
1733
+ });
1734
+ });
1735
+ },
1736
+ };
1737
+ buttons.approve = {
1738
+ label: 'Valider',
1739
+ className: 'btn-success',
1740
+ callback: async () => {
1741
+ const itemNames = Array.isArray(p.itemNames) && p.itemNames.length
1742
+ ? p.itemNames
1743
+ : (typeof p.itemNames === 'string' && p.itemNames.trim() ? p.itemNames.split(',').map(s=>s.trim()).filter(Boolean) : (p.itemName ? [p.itemName] : []));
1744
+ const itemsListHtml = itemNames.length
1745
+ ? `<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>`
1746
+ : '';
1747
+ const opts = (() => {
1748
+ const out = [];
1749
+ for (let h = 7; h < 24; h++) {
1750
+ for (let m = 0; m < 60; m += 5) {
1751
+ out.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
1752
+ }
1753
+ }
1754
+ return out;
1755
+ })().map(t => `<option value="${t}" ${t === '07:00' ? 'selected' : ''}>${t}</option>`).join('');
1756
+ const html = `
1757
+ ${itemsListHtml}
1758
+ <div class="mb-3">
1759
+ <label class="form-label">Adresse de récupération</label>
1760
+ <div class="input-group">
1761
+ <input type="text" class="form-control" id="onekite-pickup-address" placeholder="Adresse complète" />
1762
+ <button class="btn btn-outline-secondary" type="button" id="onekite-geocode">Rechercher</button>
1763
+ </div>
1764
+ <div id="onekite-map" style="height:220px; border:1px solid #ddd; border-radius:6px; margin-top:0.5rem;"></div>
1765
+ <div class="form-text" id="onekite-map-help">Vous pouvez déplacer le marqueur pour ajuster la position.</div>
1766
+ <input type="hidden" id="onekite-pickup-lat" />
1767
+ <input type="hidden" id="onekite-pickup-lon" />
1768
+ </div>
1769
+ <div class="mb-3">
1770
+ <label class="form-label">Notes (facultatif)</label>
1771
+ <textarea class="form-control" id="onekite-notes" rows="3" placeholder="Ex: code portail, personne à contacter, horaires..."></textarea>
1772
+ </div>
1773
+ <div class="mb-2">
1774
+ <label class="form-label">Heure de récupération</label>
1775
+ <select class="form-select" id="onekite-pickup-time">${opts}</select>
1776
+ </div>
1777
+ `;
1778
+ const dlg = bootbox.dialog({
1779
+ title: 'Valider la demande',
1780
+ message: html,
1781
+ buttons: {
1782
+ cancel: { label: 'Annuler', className: 'btn-secondary' },
1783
+ ok: {
1784
+ label: 'Valider',
1785
+ className: 'btn-success',
1786
+ callback: async () => {
1787
+ try {
1788
+ const pickupAddress = (document.getElementById('onekite-pickup-address')?.value || '').trim();
1789
+ const notes = (document.getElementById('onekite-notes')?.value || '').trim();
1790
+ const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
1791
+ const pickupLat = (document.getElementById('onekite-pickup-lat')?.value || '').trim();
1792
+ const pickupLon = (document.getElementById('onekite-pickup-lon')?.value || '').trim();
1793
+ if (!lockAction(`approve:${rid}`, 1200)) return false;
1794
+ await approveReservation(rid, { pickupAddress, notes, pickupTime, pickupLat, pickupLon });
1795
+ showAlert('success', 'Demande validée.');
1796
+ invalidateEventsCache();
1797
+ scheduleRefetch(calendar);
1798
+ } catch (e) {
1799
+ showAlert('error', 'Validation impossible.');
1800
+ }
1801
+ },
1802
+ },
1803
+ },
1804
+ });
1805
+
1806
+ // Init Leaflet map once the modal is visible.
1807
+ dlg.on('shown.bs.modal', async () => {
1808
+ try {
1809
+ const L = await loadLeaflet();
1810
+ const mapEl = document.getElementById('onekite-map');
1811
+ if (!mapEl) return;
1812
+
1813
+ const map = L.map(mapEl, { scrollWheelZoom: false });
1814
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
1815
+ maxZoom: 19,
1816
+ attribution: '&copy; OpenStreetMap',
1817
+ }).addTo(map);
1818
+
1819
+ // Default view (France-ish)
1820
+ map.setView([46.7, 2.5], 5);
1821
+
1822
+ let marker = null;
1823
+ function setMarker(lat, lon, zoom) {
1824
+ const ll = [lat, lon];
1825
+ if (!marker) {
1826
+ marker = L.marker(ll, { draggable: true }).addTo(map);
1827
+ marker.on('dragend', () => {
1828
+ const p2 = marker.getLatLng();
1829
+ document.getElementById('onekite-pickup-lat').value = String(p2.lat);
1830
+ document.getElementById('onekite-pickup-lon').value = String(p2.lng);
1831
+ });
1832
+ } else {
1833
+ marker.setLatLng(ll);
1834
+ }
1835
+ document.getElementById('onekite-pickup-lat').value = String(lat);
1836
+ document.getElementById('onekite-pickup-lon').value = String(lon);
1837
+ if (zoom) {
1838
+ map.setView(ll, zoom);
1839
+ } else {
1840
+ map.panTo(ll);
1841
+ }
1842
+ }
1843
+
1844
+ map.on('click', (e) => {
1845
+ if (e && e.latlng) {
1846
+ setMarker(e.latlng.lat, e.latlng.lng, map.getZoom());
1847
+ }
1848
+ });
1849
+
1850
+ const geocodeBtn = document.getElementById('onekite-geocode');
1851
+ const addrInput = document.getElementById('onekite-pickup-address');
1852
+
1853
+ try {
1854
+ attachAddressAutocomplete(addrInput, (h) => {
1855
+ if (h && Number.isFinite(h.lat) && Number.isFinite(h.lon)) {
1856
+ setMarker(h.lat, h.lon, 16);
1857
+ }
1858
+ });
1859
+ } catch (e) {}
1860
+
1861
+ async function runGeocode() {
1862
+ try {
1863
+ const addr = (addrInput?.value || '').trim();
1864
+ if (!addr) return;
1865
+ const hit = await geocodeAddress(addr);
1866
+ if (!hit) {
1867
+ showAlert('error', 'Adresse introuvable.');
1868
+ return;
1869
+ }
1870
+ setMarker(hit.lat, hit.lon, 16);
1871
+ } catch (e) {
1872
+ showAlert('error', 'Recherche adresse impossible.');
1873
+ }
1874
+ }
1875
+
1876
+ if (geocodeBtn) {
1877
+ geocodeBtn.addEventListener('click', runGeocode);
1878
+ }
1879
+ if (addrInput) {
1880
+ addrInput.addEventListener('keydown', (e) => {
1881
+ if (e.key === 'Enter') {
1882
+ e.preventDefault();
1883
+ runGeocode();
1884
+ }
1885
+ });
1886
+ }
1887
+ } catch (e) {
1888
+ // Leaflet load errors shouldn't block moderation
1889
+ }
1890
+ });
1891
+ },
1892
+ };
1893
+ }
1894
+
1895
+ bootbox.dialog({
1896
+ title: 'Réservation',
1897
+ message: baseHtml,
1898
+ buttons,
1899
+ });
1900
+ } finally {
1901
+ isDialogOpen = false;
1902
+ }
1903
+ },
1904
+ });
1905
+
1906
+ // Expose for live updates
1907
+ try { window.oneKiteCalendar = calendar; } catch (e) {}
1908
+ currentCalendar = calendar;
1909
+
1910
+ calendar.render();
1911
+
1912
+ refreshDesktopModeButton();
1913
+
1914
+ // Mobile controls: view (month/week) + mode (reservation/event) without bloating the header.
1915
+ try {
1916
+ const controlsId = 'onekite-mobile-controls';
1917
+ const old = document.getElementById(controlsId);
1918
+ if (old) old.remove();
1919
+
1920
+ if (isMobileNow()) {
1921
+ const controls = document.createElement('div');
1922
+ controls.id = controlsId;
1923
+ controls.className = 'mt-2 d-flex flex-wrap gap-2 align-items-center';
1924
+
1925
+ const viewGroup = document.createElement('div');
1926
+ viewGroup.className = 'btn-group btn-group-sm';
1927
+ viewGroup.setAttribute('role', 'group');
1928
+
1929
+ const btnMonth = document.createElement('button');
1930
+ btnMonth.type = 'button';
1931
+ btnMonth.className = 'btn btn-outline-secondary';
1932
+ btnMonth.textContent = 'Mois';
1933
+
1934
+ const btnWeek = document.createElement('button');
1935
+ btnWeek.type = 'button';
1936
+ btnWeek.className = 'btn btn-outline-secondary';
1937
+ btnWeek.textContent = 'Semaine';
1938
+
1939
+ function refreshViewButtons() {
1940
+ const v = calendar.view && calendar.view.type;
1941
+ btnMonth.classList.toggle('active', v === 'dayGridMonth');
1942
+ btnWeek.classList.toggle('active', v === 'timeGridWeek');
1943
+ }
1944
+
1945
+ btnMonth.addEventListener('click', () => {
1946
+ calendar.changeView('dayGridMonth');
1947
+ refreshViewButtons();
1948
+ });
1949
+ btnWeek.addEventListener('click', () => {
1950
+ calendar.changeView('timeGridWeek');
1951
+ refreshViewButtons();
1952
+ });
1953
+
1954
+ viewGroup.appendChild(btnMonth);
1955
+ viewGroup.appendChild(btnWeek);
1956
+ controls.appendChild(viewGroup);
1957
+
1958
+ if (canCreateSpecial) {
1959
+ const modeBtn = document.createElement('button');
1960
+ modeBtn.type = 'button';
1961
+ modeBtn.className = 'btn btn-sm onekite-btn-violet onekite-mode-btn';
1962
+ function refreshModeBtn() {
1963
+ const isSpecial = mode === 'special';
1964
+ modeBtn.textContent = isSpecial ? 'Mode évènement ✓' : 'Mode évènement';
1965
+ modeBtn.classList.toggle('active', isSpecial);
1966
+ // Keep violet, only toggle active class
1967
+ modeBtn.classList.toggle('onekite-active', isSpecial);
1968
+ }
1969
+ modeBtn.addEventListener('click', () => {
1970
+ setMode((mode === 'special') ? 'reservation' : 'special');
1971
+ refreshModeBtn();
1972
+ });
1973
+ refreshModeBtn();
1974
+ controls.appendChild(modeBtn);
1975
+ }
1976
+
1977
+ el.parentNode && el.parentNode.insertBefore(controls, el.nextSibling);
1978
+ refreshViewButtons();
1979
+ }
1980
+ } catch (e) {}
1981
+
1982
+ // Update sizing when rotating/resizing (especially on mobile landscape).
1983
+ try {
1984
+ let lastMobile = isMobileNow();
1985
+ let lastLandscape = isLandscapeNow();
1986
+ window.addEventListener('resize', () => {
1987
+ const mobile = isMobileNow();
1988
+ const landscape = isLandscapeNow();
1989
+ if (mobile !== lastMobile || landscape !== lastLandscape) {
1990
+ lastMobile = mobile;
1991
+ lastLandscape = landscape;
1992
+ calendar.setOption('aspectRatio', computeAspectRatio());
1993
+ calendar.setOption('titleFormat', mobile ? { year: 'numeric', month: 'short' } : undefined);
1994
+ }
1995
+ try { calendar.updateSize(); } catch (err) {}
1996
+
1997
+ try { refreshDesktopModeButton(); } catch (e) {}
1998
+ });
1999
+ } catch (e) {}
2000
+ }
2001
+
2002
+ // Auto-init on /calendar when ajaxify finishes rendering.
2003
+
2004
+ function unmountMobileFab() {
2005
+ try {
2006
+ if (fabEl) {
2007
+ if (fabHandler) fabEl.removeEventListener('click', fabHandler);
2008
+ fabEl.remove();
2009
+ }
2010
+ } catch (e) {}
2011
+ fabEl = null;
2012
+ fabHandler = null;
2013
+ }
2014
+
2015
+ function formatDdMmYyyy(d) {
2016
+ const pad = (n) => String(n).padStart(2, '0');
2017
+ return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()}`;
2018
+ }
2019
+
2020
+ function parseDdMmYyyy(s) {
2021
+ const m = String(s || '').trim().match(/^([0-3]\d)\/([0-1]\d)\/(\d{4})$/);
2022
+ if (!m) return null;
2023
+ const day = parseInt(m[1], 10);
2024
+ const month = parseInt(m[2], 10);
2025
+ const year = parseInt(m[3], 10);
2026
+ if (month < 1 || month > 12) return null;
2027
+ const d = new Date(year, month - 1, day, 0, 0, 0, 0);
2028
+ // Validate (Date will overflow on invalid day)
2029
+ if (d.getFullYear() !== year || (d.getMonth() + 1) !== month || d.getDate() !== day) return null;
2030
+ return d;
2031
+ }
2032
+
2033
+ async function openFabDatePicker() {
2034
+ if (!lockAction('fab-date-picker', 700)) return;
2035
+
2036
+ // Defaults: tomorrow -> tomorrow (cannot book today or past)
2037
+ const today = new Date();
2038
+ today.setHours(0, 0, 0, 0);
2039
+ const minStart = new Date(today);
2040
+ minStart.setDate(minStart.getDate() + 1);
2041
+
2042
+ const html = `
2043
+ <div class="onekite-fab-dates">
2044
+ <div class="mb-2">
2045
+ <label class="form-label">Date de début</label>
2046
+ <input class="form-control" type="date" id="onekite-fab-start" autocomplete="off" />
2047
+ </div>
2048
+ <div class="mb-2">
2049
+ <label class="form-label">Date de fin (incluse)</label>
2050
+ <input class="form-control" type="date" id="onekite-fab-end" autocomplete="off" />
2051
+ <div class="form-text">Sélectionne une période (la date de fin est incluse).</div>
2052
+ </div>
2053
+ </div>
2054
+ `;
2055
+
2056
+ const dlg = bootbox.dialog({
2057
+ title: 'Choisir des dates',
2058
+ message: html,
2059
+ buttons: {
2060
+ cancel: { label: 'Annuler', className: 'btn-secondary' },
2061
+ ok: {
2062
+ label: 'Continuer',
2063
+ className: 'btn-primary',
2064
+ callback: function () {
2065
+ const sStr = String(document.getElementById('onekite-fab-start').value || '');
2066
+ const eStr = String(document.getElementById('onekite-fab-end').value || '');
2067
+ const s = parseYmdDate(sStr);
2068
+ const e = parseYmdDate(eStr);
2069
+ if (!s || !e) {
2070
+ alerts.error('Dates invalides.');
2071
+ return false;
2072
+ }
2073
+
2074
+ // Cannot book today or past
2075
+ if (s < minStart || e < minStart) {
2076
+ alerts.error('Impossible de réserver pour le jour même ou dans le passé.');
2077
+ return false;
2078
+ }
2079
+ if (e < s) {
2080
+ alerts.error('La date de fin doit être après la date de début.');
2081
+ return false;
2082
+ }
2083
+
2084
+ // Convert end inclusive -> end exclusive (FullCalendar rule)
2085
+ const endExcl = new Date(e);
2086
+ endExcl.setDate(endExcl.getDate() + 1);
2087
+
2088
+ // Open the standard reservation dialog
2089
+ (async () => {
2090
+ try {
2091
+ if (isDialogOpen) return;
2092
+ isDialogOpen = true;
2093
+ const items = cachedItems || (await loadItems());
2094
+ const chosen = await openReservationDialog({ start: s, end: endExcl }, items);
2095
+ if (chosen && chosen.itemIds && chosen.itemIds.length) {
2096
+ const startDate = toLocalYmd(s);
2097
+ const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(endExcl);
2098
+ const resp = await requestReservation({
2099
+ start: startDate,
2100
+ end: endDate,
2101
+ itemIds: chosen.itemIds,
2102
+ itemNames: chosen.itemNames,
2103
+ total: chosen.total,
2104
+ });
2105
+ if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
2106
+ showAlert('success', 'Réservation confirmée.');
2107
+ } else {
2108
+ showAlert('success', 'Demande envoyée (en attente de validation).');
2109
+ }
2110
+ invalidateEventsCache();
2111
+ if (currentCalendar) scheduleRefetch(currentCalendar);
2112
+ }
2113
+ } catch (err) {
2114
+ // ignore
2115
+ } finally {
2116
+ isDialogOpen = false;
2117
+ }
2118
+ })();
2119
+
2120
+ // close picker modal
2121
+ return true;
2122
+ },
2123
+ },
2124
+ },
2125
+ });
2126
+
2127
+ dlg.on('shown.bs.modal', () => {
2128
+ try {
2129
+ const startEl = document.getElementById('onekite-fab-start');
2130
+ const endEl = document.getElementById('onekite-fab-end');
2131
+ const minStr = toLocalYmd(minStart);
2132
+ if (startEl) {
2133
+ startEl.min = minStr;
2134
+ startEl.value = minStr;
2135
+ }
2136
+ if (endEl) {
2137
+ endEl.min = minStr;
2138
+ endEl.value = minStr;
2139
+ }
2140
+ if (startEl && endEl) {
2141
+ startEl.addEventListener('change', () => {
2142
+ try {
2143
+ const d = parseYmdDate(String(startEl.value || ''));
2144
+ if (!d) return;
2145
+ const minEnd = new Date(d);
2146
+ const minEndStr = toLocalYmd(minEnd);
2147
+ endEl.min = minEndStr;
2148
+ if (String(endEl.value || '') < minEndStr) endEl.value = minEndStr;
2149
+ } catch (e) {}
2150
+ });
2151
+ }
2152
+ if (startEl) startEl.focus();
2153
+ } catch (e) {}
2154
+ });
2155
+ }
2156
+
2157
+ function parseYmdDate(ymdStr) {
2158
+ // Expect YYYY-MM-DD (from <input type="date">)
2159
+ if (!ymdStr || typeof ymdStr !== 'string') return null;
2160
+ const m = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(ymdStr);
2161
+ if (!m) return null;
2162
+ const y = parseInt(m[1], 10);
2163
+ const mo = parseInt(m[2], 10);
2164
+ const d = parseInt(m[3], 10);
2165
+ if (!y || !mo || !d) return null;
2166
+ const dt = new Date(y, mo - 1, d);
2167
+ dt.setHours(0, 0, 0, 0);
2168
+ return dt;
2169
+ }
2170
+
2171
+ function mountMobileFab() {
2172
+ try {
2173
+ unmountMobileFab();
2174
+ // Only on mobile screens
2175
+ if (!window.matchMedia || !window.matchMedia('(max-width: 768px)').matches) return;
2176
+
2177
+ fabEl = document.createElement('button');
2178
+ fabEl.type = 'button';
2179
+ fabEl.className = 'btn btn-primary onekite-fab';
2180
+ fabEl.setAttribute('aria-label', 'Nouvelle réservation');
2181
+ fabEl.innerHTML = '<i class="fa fa-plus"></i>';
2182
+
2183
+ fabHandler = () => openFabDatePicker();
2184
+ fabEl.addEventListener('click', fabHandler);
2185
+
2186
+ document.body.appendChild(fabEl);
2187
+ } catch (e) {}
2188
+ }
2189
+
2190
+ function autoInit(data) {
2191
+ try {
2192
+ const tpl = data && data.template ? data.template.name : (ajaxify && ajaxify.data && ajaxify.data.template ? ajaxify.data.template.name : '');
2193
+ if (tpl === 'calendar-onekite') {
2194
+ init('#onekite-calendar');
2195
+ mountMobileFab();
2196
+ } else {
2197
+ unmountMobileFab();
2198
+ }
2199
+ } catch (e) {}
2200
+ }
2201
+
2202
+ if (hooks && typeof hooks.on === 'function') {
2203
+ hooks.on('action:ajaxify.end', autoInit);
2204
+ }
2205
+
2206
+ // In case the page is served as the initial load (no ajaxify navigation)
2207
+ // call once after current tick.
2208
+ setTimeout(() => autoInit({ template: (ajaxify && ajaxify.data && ajaxify.data.template) || { name: '' } }), 0);
2209
+
2210
+
2211
+
2212
+ // Live refresh when a reservation changes (e.g., payment confirmed by webhook)
2213
+ try {
2214
+ if (!window.__oneKiteSocketBound && typeof socket !== 'undefined' && socket && typeof socket.on === 'function') {
2215
+ window.__oneKiteSocketBound = true;
2216
+ socket.on('event:calendar-onekite.reservationUpdated', function () {
2217
+ try {
2218
+ const cal = window.oneKiteCalendar;
2219
+ scheduleRefetch(cal);
2220
+ } catch (e) {}
2221
+ });
2222
+ }
2223
+ } catch (e) {}
2224
+
2225
+ return {
2226
+ init,
2227
+ };
2228
+ });