nodebb-plugin-onekite-calendar 1.0.33 → 2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog – calendar-onekite
2
2
 
3
+ ## 1.2.6
4
+ - ACP : anti double action (verrou UI + boutons désactivés/spinner) sur valider/refuser (unitaire + batch)
5
+
6
+ ## 1.2.5
7
+ - Mobile : bouton flottant « + Réserver »
8
+ - Création : anti double-tap/click (verrou actions) + invalidation cache events + refetch léger
9
+ - Calendrier : jours passés / aujourd’hui affichés comme non-sélectionnables (règle visuelle)
10
+ - Popup réservation : raccourcis de durée (1j/2j/3j/7j) + recalcul total + mise à jour des matériels bloqués
11
+ - ACP : actions en batch (valider/refuser une sélection) + compteur de sélection
12
+ - API : idempotence sur valider/refuser + audit enrichi (refusedBy, cancelledByUsername)
13
+
3
14
  ## 1.0.3
4
15
  - Suppression du texte d’archivage dans le toast de purge (plus de « 0 archivés »)
5
16
  - Renommage du plugin : nodebb-plugin-onekite-calendar
package/lib/api.js CHANGED
@@ -553,6 +553,11 @@ api.getReservationDetails = async function (req, res) {
553
553
  const r = await dbLayer.getReservation(rid);
554
554
  if (!r) return res.status(404).json({ error: 'not-found' });
555
555
 
556
+ // Idempotence: if already refused, return ok.
557
+ if (String(r.status) === 'refused') {
558
+ return res.json({ ok: true, status: 'refused' });
559
+ }
560
+
556
561
  const isOwner = String(r.uid) === String(uid);
557
562
  if (!isOwner && !canMod) return res.status(403).json({ error: 'not-allowed' });
558
563
 
@@ -881,7 +886,13 @@ api.approveReservation = async function (req, res) {
881
886
  const rid = req.params.rid;
882
887
  const r = await dbLayer.getReservation(rid);
883
888
  if (!r) return res.status(404).json({ error: 'not-found' });
884
- if (r.status !== 'pending') return res.status(400).json({ error: 'bad-status' });
889
+ // Idempotence: if already approved (awaiting payment or paid), return ok.
890
+ if (r.status !== 'pending') {
891
+ if (['awaiting_payment', 'approved', 'paid'].includes(String(r.status))) {
892
+ return res.json({ ok: true, status: r.status });
893
+ }
894
+ return res.status(400).json({ error: 'bad-status' });
895
+ }
885
896
 
886
897
  r.status = 'awaiting_payment';
887
898
  // Backwards compatible: old clients sent `adminNote` to describe the pickup place.
@@ -994,6 +1005,14 @@ api.refuseReservation = async function (req, res) {
994
1005
  r.status = 'refused';
995
1006
  r.refusedAt = Date.now();
996
1007
  r.refusedReason = String((req.body && (req.body.reason || req.body.refusedReason || req.body.refuseReason)) || '').trim();
1008
+ try {
1009
+ const refuser = await user.getUserFields(uid, ['username']);
1010
+ r.refusedBy = uid;
1011
+ r.refusedByUsername = refuser && refuser.username ? refuser.username : '';
1012
+ } catch (e) {
1013
+ r.refusedBy = uid;
1014
+ r.refusedByUsername = '';
1015
+ }
997
1016
  await dbLayer.saveReservation(r);
998
1017
 
999
1018
  const requesterUid2 = parseInt(r.uid, 10);
@@ -1043,6 +1062,13 @@ api.cancelReservation = async function (req, res) {
1043
1062
  r.cancelledAt = Date.now();
1044
1063
  r.cancelledBy = uid;
1045
1064
 
1065
+ try {
1066
+ const canceller = await user.getUserFields(uid, ['username']);
1067
+ r.cancelledByUsername = canceller && canceller.username ? canceller.username : '';
1068
+ } catch (e) {
1069
+ r.cancelledByUsername = '';
1070
+ }
1071
+
1046
1072
  await dbLayer.saveReservation(r);
1047
1073
 
1048
1074
  // Discord webhook (optional)
@@ -1058,6 +1084,7 @@ api.cancelReservation = async function (req, res) {
1058
1084
  status: r.status,
1059
1085
  cancelledAt: r.cancelledAt,
1060
1086
  cancelledBy: r.cancelledBy,
1087
+ cancelledByUsername: r.cancelledByUsername || '',
1061
1088
  });
1062
1089
  } catch (e) {}
1063
1090
 
@@ -1074,8 +1101,8 @@ api.cancelReservation = async function (req, res) {
1074
1101
  itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
1075
1102
  itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
1076
1103
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
1077
- cancelledBy: canceller && canceller.username ? canceller.username : '',
1078
- cancelledByUrl: (canceller && canceller.username) ? `${normalizeBaseUrl(meta)}/user/${encodeURIComponent(String(canceller.username))}` : '',
1104
+ cancelledBy: (r.cancelledByUsername || (canceller && canceller.username) || ''),
1105
+ cancelledByUrl: ((r.cancelledByUsername || (canceller && canceller.username)) ? `${normalizeBaseUrl(meta)}/user/${encodeURIComponent(String(r.cancelledByUsername || canceller.username))}` : ''),
1079
1106
  });
1080
1107
  }
1081
1108
  } catch (e) {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "1.0.33",
3
+ "version": "2.0.0",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/plugin.json CHANGED
@@ -39,5 +39,5 @@
39
39
  "acpScripts": [
40
40
  "public/admin.js"
41
41
  ],
42
- "version": "1.0.33"
42
+ "version": "2.0.0"
43
43
  }
package/public/admin.js CHANGED
@@ -6,6 +6,58 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
6
6
  // can open rich modals without embedding large JSON blobs into the DOM.
7
7
  const pendingCache = new Map();
8
8
 
9
+ // Prevent double actions (double taps/clicks) in ACP.
10
+ // Keyed by action (eg. approve:RID, refuse:RID, batch:approve).
11
+ const actionLocks = new Set();
12
+
13
+ function setButtonsDisabled(btns, disabled) {
14
+ (btns || []).filter(Boolean).forEach((b) => {
15
+ try {
16
+ b.disabled = !!disabled;
17
+ b.classList.toggle('disabled', !!disabled);
18
+ b.setAttribute('aria-disabled', disabled ? 'true' : 'false');
19
+ } catch (e) {}
20
+ });
21
+ }
22
+
23
+ function setBtnBusy(btn, busy) {
24
+ if (!btn) return;
25
+ try {
26
+ if (busy) {
27
+ if (!btn.dataset.okcOrigHtml) btn.dataset.okcOrigHtml = btn.innerHTML;
28
+ btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right:6px;"></span>' + btn.dataset.okcOrigHtml;
29
+ } else if (btn.dataset.okcOrigHtml) {
30
+ btn.innerHTML = btn.dataset.okcOrigHtml;
31
+ }
32
+ } catch (e) {}
33
+ }
34
+
35
+ async function withLock(lockKey, btns, fn) {
36
+ if (!lockKey) return await fn();
37
+ if (actionLocks.has(lockKey)) return;
38
+ actionLocks.add(lockKey);
39
+ setButtonsDisabled(btns, true);
40
+ // Only show a spinner on the primary button (first in list)
41
+ setBtnBusy(btns && btns[0], true);
42
+ try {
43
+ return await fn();
44
+ } finally {
45
+ setBtnBusy(btns && btns[0], false);
46
+ setButtonsDisabled(btns, false);
47
+ actionLocks.delete(lockKey);
48
+ }
49
+ }
50
+
51
+ function getActiveBootboxFooterButtons() {
52
+ try {
53
+ const modal = document.querySelector('.bootbox.modal.show');
54
+ if (!modal) return [];
55
+ return Array.from(modal.querySelectorAll('.modal-footer button'));
56
+ } catch (e) {
57
+ return [];
58
+ }
59
+ }
60
+
9
61
  function showAlert(type, msg) {
10
62
  // Deduplicate identical alerts that can be triggered multiple times
11
63
  // by NodeBB ACP save buttons/hooks across ajaxify navigations.
@@ -208,6 +260,17 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
208
260
  return;
209
261
  }
210
262
 
263
+ // Batch actions (low volume but handy on mobile / multiple requests)
264
+ const batchBar = document.createElement('div');
265
+ batchBar.className = 'd-flex flex-wrap gap-2 align-items-center mb-2';
266
+ batchBar.innerHTML = `
267
+ <button type="button" class="btn btn-outline-secondary btn-sm" data-action="toggle-all">Tout sélectionner</button>
268
+ <button type="button" class="btn btn-success btn-sm" data-action="approve-selected">Valider sélection</button>
269
+ <button type="button" class="btn btn-outline-danger btn-sm" data-action="refuse-selected">Refuser sélection</button>
270
+ <span class="text-muted" style="font-size:12px;">(<span id="onekite-selected-count">0</span> sélectionnée)</span>
271
+ `;
272
+ wrap.appendChild(batchBar);
273
+
211
274
  const fmtFR = (ts) => {
212
275
  const d = new Date(parseInt(ts, 10));
213
276
  const dd = String(d.getDate()).padStart(2, '0');
@@ -227,10 +290,15 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
227
290
  div.className = 'list-group-item onekite-pending-row';
228
291
  div.innerHTML = `
229
292
  <div class="d-flex justify-content-between align-items-start gap-2">
230
- <div style="min-width: 0;">
293
+ <div style="min-width: 0;" class="d-flex gap-2">
294
+ <div style="padding-top: 2px;">
295
+ <input type="checkbox" class="form-check-input onekite-pending-select" data-rid="${escapeHtml(String(r.rid || ''))}" />
296
+ </div>
297
+ <div style="min-width:0;">
231
298
  <div><strong>${itemsHtml || escapeHtml(r.itemName || '')}</strong></div>
232
299
  <div class="text-muted" style="font-size: 12px;">Créée: ${escapeHtml(created)}</div>
233
300
  <div class="text-muted" style="font-size: 12px;">Période: ${escapeHtml(new Date(parseInt(r.start, 10)).toLocaleDateString('fr-FR'))} → ${escapeHtml(new Date(parseInt(r.end, 10)).toLocaleDateString('fr-FR'))}</div>
301
+ </div>
234
302
  </div>
235
303
  <div class="d-flex gap-2">
236
304
  <!-- IMPORTANT: type="button" to avoid submitting the settings form and resetting ACP tabs -->
@@ -241,6 +309,17 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
241
309
  `;
242
310
  wrap.appendChild(div);
243
311
  }
312
+
313
+ // Selected counter
314
+ try {
315
+ const countEl = document.getElementById('onekite-selected-count');
316
+ const refreshCount = () => {
317
+ const n = wrap.querySelectorAll('.onekite-pending-select:checked').length;
318
+ if (countEl) countEl.textContent = String(n);
319
+ };
320
+ wrap.querySelectorAll('.onekite-pending-select').forEach((cb) => cb.addEventListener('change', refreshCount));
321
+ refreshCount();
322
+ } catch (e) {}
244
323
  }
245
324
 
246
325
 
@@ -430,6 +509,24 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
430
509
  const pendingWrap = document.getElementById('onekite-pending');
431
510
  if (pendingWrap && !pendingWrap.dataset.okcBound) {
432
511
  pendingWrap.dataset.okcBound = '1';
512
+
513
+ function selectedRids() {
514
+ return Array.from(pendingWrap.querySelectorAll('input.onekite-pending-select:checked'))
515
+ .map(cb => cb.getAttribute('data-rid'))
516
+ .filter(Boolean);
517
+ }
518
+
519
+ function refreshSelectedCount() {
520
+ const el = document.getElementById('onekite-selected-count');
521
+ if (el) el.textContent = String(selectedRids().length);
522
+ }
523
+
524
+ pendingWrap.addEventListener('change', (ev) => {
525
+ const cb = ev.target && ev.target.closest ? ev.target.closest('input.onekite-pending-select') : null;
526
+ if (!cb) return;
527
+ refreshSelectedCount();
528
+ });
529
+
433
530
  pendingWrap.addEventListener('click', async (ev) => {
434
531
  const btn = ev.target && ev.target.closest('button[data-action]');
435
532
  if (!btn) return;
@@ -441,10 +538,178 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
441
538
  } catch (e) {}
442
539
  const action = btn.getAttribute('data-action');
443
540
  const rid = btn.getAttribute('data-rid');
541
+
542
+ // Prevent accidental double-open of modals on mobile/trackpad double taps
543
+ if (rid && (action === 'approve' || action === 'refuse')) {
544
+ const openKey = `open:${action}:${rid}`;
545
+ if (actionLocks.has(openKey)) return;
546
+ actionLocks.add(openKey);
547
+ setTimeout(() => actionLocks.delete(openKey), 800);
548
+ }
549
+
550
+ // Batch actions (no rid)
551
+ if ((action === 'toggle-all' || action === 'approve-selected' || action === 'refuse-selected') && !rid) {
552
+ const batchBtns = Array.from(pendingWrap.querySelectorAll('button[data-action="toggle-all"], button[data-action="approve-selected"], button[data-action="refuse-selected"]'));
553
+ const allCbs = Array.from(pendingWrap.querySelectorAll('input.onekite-pending-select'));
554
+ if (action === 'toggle-all') {
555
+ const want = allCbs.some(cb => !cb.checked);
556
+ allCbs.forEach(cb => { cb.checked = want; });
557
+ refreshSelectedCount();
558
+ btn.textContent = want ? 'Tout désélectionner' : 'Tout sélectionner';
559
+ return;
560
+ }
561
+
562
+ const rids = selectedRids();
563
+ if (!rids.length) {
564
+ showAlert('error', 'Aucune demande sélectionnée.');
565
+ return;
566
+ }
567
+
568
+ if (action === 'refuse-selected') {
569
+ const html = `
570
+ <div class="mb-3">
571
+ <label class="form-label">Raison du refus (appliquée à toutes les demandes sélectionnées)</label>
572
+ <textarea class="form-control" id="onekite-refuse-reason" rows="3" placeholder="Ex: matériel indisponible, dates impossibles, dossier incomplet..."></textarea>
573
+ </div>
574
+ `;
575
+ await new Promise((resolve) => {
576
+ bootbox.dialog({
577
+ title: `Refuser ${rids.length} demande(s)`,
578
+ message: html,
579
+ buttons: {
580
+ cancel: { label: 'Annuler', className: 'btn-secondary', callback: () => resolve(false) },
581
+ ok: {
582
+ label: 'Refuser',
583
+ className: 'btn-danger',
584
+ callback: async () => {
585
+ await withLock(`batch:refuse`, batchBtns.concat(getActiveBootboxFooterButtons()), async () => {
586
+ try {
587
+ const reason = (document.getElementById('onekite-refuse-reason')?.value || '').trim();
588
+ for (const rr of rids) {
589
+ await refuse(rr, { reason });
590
+ }
591
+ showAlert('success', `${rids.length} demande(s) refusée(s).`);
592
+ resolve(true);
593
+ } catch (e) {
594
+ showAlert('error', 'Refus impossible.');
595
+ resolve(false);
596
+ }
597
+ });
598
+ return false;
599
+ },
600
+ },
601
+ },
602
+ });
603
+ });
604
+ await refreshPending();
605
+ return;
606
+ }
607
+
608
+ if (action === 'approve-selected') {
609
+ const opts = timeOptions(5).map(t => `<option value="${t}" ${t === '07:00' ? 'selected' : ''}>${t}</option>`).join('');
610
+ const html = `
611
+ <div class="mb-3">
612
+ <label class="form-label">Adresse de récupération</label>
613
+ <div class="input-group">
614
+ <input type="text" class="form-control" id="onekite-pickup-address" placeholder="Adresse complète" />
615
+ <button class="btn btn-outline-secondary" type="button" id="onekite-geocode">Rechercher</button>
616
+ </div>
617
+ <div id="onekite-map" style="height:220px; border:1px solid #ddd; border-radius:6px; margin-top:0.5rem;"></div>
618
+ <div class="form-text" id="onekite-map-help">Vous pouvez déplacer le marqueur pour ajuster la position.</div>
619
+ <input type="hidden" id="onekite-pickup-lat" />
620
+ <input type="hidden" id="onekite-pickup-lon" />
621
+ </div>
622
+ <div class="mb-3">
623
+ <label class="form-label">Notes (facultatif)</label>
624
+ <textarea class="form-control" id="onekite-notes" rows="3" placeholder="Ex: code portail, personne à contacter, horaires..."></textarea>
625
+ </div>
626
+ <div class="mb-2">
627
+ <label class="form-label">Heure de récupération</label>
628
+ <select class="form-select" id="onekite-pickup-time">${opts}</select>
629
+ </div>
630
+ <div class="text-muted" style="font-size:12px;">Ces infos seront appliquées aux ${rids.length} demandes sélectionnées.</div>
631
+ `;
632
+ const dlg = bootbox.dialog({
633
+ title: `Valider ${rids.length} demande(s)` ,
634
+ message: html,
635
+ buttons: {
636
+ cancel: { label: 'Annuler', className: 'btn-secondary' },
637
+ ok: {
638
+ label: 'Valider',
639
+ className: 'btn-success',
640
+ callback: async () => {
641
+ await withLock(`batch:approve`, batchBtns.concat(getActiveBootboxFooterButtons()), async () => {
642
+ try {
643
+ const pickupAddress = (document.getElementById('onekite-pickup-address')?.value || '').trim();
644
+ const notes = (document.getElementById('onekite-notes')?.value || '').trim();
645
+ const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
646
+ const pickupLat = (document.getElementById('onekite-pickup-lat')?.value || '').trim();
647
+ const pickupLon = (document.getElementById('onekite-pickup-lon')?.value || '').trim();
648
+ for (const rr of rids) {
649
+ await approve(rr, { pickupAddress, notes, pickupTime, pickupLat, pickupLon });
650
+ }
651
+ showAlert('success', `${rids.length} demande(s) validée(s).`);
652
+ await refreshPending();
653
+ } catch (e) {
654
+ showAlert('error', 'Validation impossible.');
655
+ }
656
+ });
657
+ },
658
+ },
659
+ },
660
+ });
661
+
662
+ // Init Leaflet map once the modal is visible.
663
+ dlg.on('shown.bs.modal', async () => {
664
+ try {
665
+ const L = await loadLeaflet();
666
+ const mapEl = document.getElementById('onekite-map');
667
+ if (!mapEl) return;
668
+ const map = L.map(mapEl).setView([45.7640, 4.8357], 12);
669
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
670
+ maxZoom: 19,
671
+ attribution: '&copy; OpenStreetMap contributors',
672
+ }).addTo(map);
673
+ const marker = L.marker([45.7640, 4.8357], { draggable: true }).addTo(map);
674
+
675
+ function setLatLon(lat, lon) {
676
+ const latEl = document.getElementById('onekite-pickup-lat');
677
+ const lonEl = document.getElementById('onekite-pickup-lon');
678
+ if (latEl) latEl.value = String(lat);
679
+ if (lonEl) lonEl.value = String(lon);
680
+ }
681
+ setLatLon(45.7640, 4.8357);
682
+
683
+ marker.on('dragend', () => {
684
+ const p = marker.getLatLng();
685
+ setLatLon(p.lat, p.lng);
686
+ });
687
+
688
+ const btnGeocode = document.getElementById('onekite-geocode');
689
+ if (btnGeocode) {
690
+ btnGeocode.addEventListener('click', async () => {
691
+ try {
692
+ const addr = (document.getElementById('onekite-pickup-address')?.value || '').trim();
693
+ const hit = await geocodeAddress(addr);
694
+ if (!hit) return;
695
+ marker.setLatLng([hit.lat, hit.lon]);
696
+ map.setView([hit.lat, hit.lon], 16);
697
+ setLatLon(hit.lat, hit.lon);
698
+ } catch (e) {}
699
+ });
700
+ }
701
+ setTimeout(() => { try { map.invalidateSize(); } catch (e) {} }, 250);
702
+ } catch (e) {}
703
+ });
704
+ return;
705
+ }
706
+ }
707
+
444
708
  if (!rid) return;
445
709
 
446
710
  // Remove the row immediately on success for a snappier UX
447
711
  const rowEl = btn.closest('tr') || btn.closest('.onekite-pending-row');
712
+ const rowBtns = rowEl ? Array.from(rowEl.querySelectorAll('button[data-action="approve"],button[data-action="refuse"]')) : [btn];
448
713
 
449
714
  try {
450
715
  if (action === 'refuse') {
@@ -464,16 +729,18 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
464
729
  label: 'Refuser',
465
730
  className: 'btn-danger',
466
731
  callback: async () => {
467
- try {
468
- const reason = (document.getElementById('onekite-refuse-reason')?.value || '').trim();
469
- await refuse(rid, { reason });
470
- if (rowEl && rowEl.parentNode) rowEl.parentNode.removeChild(rowEl);
471
- showAlert('success', 'Demande refusée.');
472
- resolve(true);
473
- } catch (e) {
474
- showAlert('error', 'Refus impossible.');
475
- resolve(false);
476
- }
732
+ await withLock(`refuse:${rid}`, rowBtns.concat(getActiveBootboxFooterButtons()), async () => {
733
+ try {
734
+ const reason = (document.getElementById('onekite-refuse-reason')?.value || '').trim();
735
+ await refuse(rid, { reason });
736
+ if (rowEl && rowEl.parentNode) rowEl.parentNode.removeChild(rowEl);
737
+ showAlert('success', 'Demande refusée.');
738
+ resolve(true);
739
+ } catch (e) {
740
+ showAlert('error', 'Refus impossible.');
741
+ resolve(false);
742
+ }
743
+ });
477
744
  return false;
478
745
  },
479
746
  },
@@ -530,19 +797,21 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
530
797
  label: 'Valider',
531
798
  className: 'btn-success',
532
799
  callback: async () => {
533
- try {
534
- const pickupAddress = (document.getElementById('onekite-pickup-address')?.value || '').trim();
535
- const notes = (document.getElementById('onekite-notes')?.value || '').trim();
536
- const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
537
- const pickupLat = (document.getElementById('onekite-pickup-lat')?.value || '').trim();
538
- const pickupLon = (document.getElementById('onekite-pickup-lon')?.value || '').trim();
539
- await approve(rid, { pickupAddress, notes, pickupTime, pickupLat, pickupLon });
540
- if (rowEl && rowEl.parentNode) rowEl.parentNode.removeChild(rowEl);
541
- showAlert('success', 'Demande validée.');
542
- await refreshPending();
543
- } catch (e) {
544
- showAlert('error', 'Validation impossible.');
545
- }
800
+ await withLock(`approve:${rid}`, rowBtns.concat(getActiveBootboxFooterButtons()), async () => {
801
+ try {
802
+ const pickupAddress = (document.getElementById('onekite-pickup-address')?.value || '').trim();
803
+ const notes = (document.getElementById('onekite-notes')?.value || '').trim();
804
+ const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
805
+ const pickupLat = (document.getElementById('onekite-pickup-lat')?.value || '').trim();
806
+ const pickupLon = (document.getElementById('onekite-pickup-lon')?.value || '').trim();
807
+ await approve(rid, { pickupAddress, notes, pickupTime, pickupLat, pickupLon });
808
+ if (rowEl && rowEl.parentNode) rowEl.parentNode.removeChild(rowEl);
809
+ showAlert('success', 'Demande validée.');
810
+ await refreshPending();
811
+ } catch (e) {
812
+ showAlert('error', 'Validation impossible.');
813
+ }
814
+ });
546
815
  return false;
547
816
  },
548
817
  },
package/public/client.js CHANGED
@@ -25,6 +25,22 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
25
25
  text-overflow: clip;
26
26
  white-space: nowrap;
27
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
+ }
28
44
  `;
29
45
  document.head.appendChild(style);
30
46
  } catch (e) {
@@ -36,6 +52,17 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
36
52
  // interactions or quick re-renders.
37
53
  let isDialogOpen = false;
38
54
 
55
+ // Prevent double taps/clicks on actions that trigger network calls (mobile especially).
56
+ const actionLocks = new Map(); // key -> ts
57
+ function lockAction(key, ms) {
58
+ const ttl = Math.max(0, parseInt(ms, 10) || 900);
59
+ const now = Date.now();
60
+ const last = actionLocks.get(key) || 0;
61
+ if (now - last < ttl) return false;
62
+ actionLocks.set(key, now);
63
+ return true;
64
+ }
65
+
39
66
  // Avoid duplicate toasts when the same validation fails both client-side and
40
67
  // server-side (e.g. "today or past" date rule).
41
68
  let lastDateRuleToastAt = 0;
@@ -441,6 +468,16 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
441
468
  // Simple in-memory cache for JSON endpoints (used for events prefetch + ETag).
442
469
  const jsonCache = new Map(); // url -> { etag, data, ts }
443
470
 
471
+ function invalidateEventsCache() {
472
+ try { jsonCache.clear(); } catch (e) {}
473
+ try {
474
+ if (window.__onekiteEventsAbort) {
475
+ window.__onekiteEventsAbort.abort();
476
+ window.__onekiteEventsAbort = null;
477
+ }
478
+ } catch (e) {}
479
+ }
480
+
444
481
  function scheduleRefetch(cal) {
445
482
  try {
446
483
  if (!cal || typeof cal.refetchEvents !== 'function') return;
@@ -777,16 +814,19 @@ function toDatetimeLocalValue(date) {
777
814
 
778
815
  async function openReservationDialog(selectionInfo, items) {
779
816
  const start = selectionInfo.start;
780
- const end = selectionInfo.end;
817
+ let end = selectionInfo.end;
781
818
 
782
819
  // days (end is exclusive in FullCalendar)
783
820
  const msPerDay = 24 * 60 * 60 * 1000;
784
- const days = Math.max(1, Math.round((end.getTime() - start.getTime()) / msPerDay));
821
+ let days = Math.max(1, Math.round((end.getTime() - start.getTime()) / msPerDay));
785
822
 
786
823
  // Fetch existing events overlapping the selection to disable already reserved items.
787
824
  let blocked = new Set();
788
825
  try {
789
- const qs = new URLSearchParams({ start: selectionInfo.startStr, end: selectionInfo.endStr });
826
+ const qs = new URLSearchParams({
827
+ start: (selectionInfo.startStr || (start instanceof Date ? start.toISOString() : String(start))),
828
+ end: (selectionInfo.endStr || (end instanceof Date ? end.toISOString() : String(end))),
829
+ });
790
830
  const evs = await fetchJson(`/api/v3/plugins/calendar-onekite/events?${qs.toString()}`);
791
831
  (evs || []).forEach((ev) => {
792
832
  const st = (ev.extendedProps && ev.extendedProps.status) || '';
@@ -810,9 +850,9 @@ function toDatetimeLocalValue(date) {
810
850
  const priceTxt = fmtPrice(it.price || 0);
811
851
  const safeName = String(it.name).replace(/</g, '&lt;').replace(/>/g, '&gt;');
812
852
  return `
813
- <div class="d-flex align-items-center py-1 ${disabled ? 'opacity-50' : ''}" style="border-bottom: 1px solid var(--bs-border-color, #ddd);">
853
+ <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);">
814
854
  <div style="width: 26px;">
815
- ${disabled ? '' : `<input type="checkbox" class="form-check-input onekite-item-cb" data-id="${id}" data-name="${safeName}" data-price="${String(it.price || 0)}">`}
855
+ <input type="checkbox" class="form-check-input onekite-item-cb" data-id="${id}" data-name="${safeName}" data-price="${String(it.price || 0)}" ${disabled ? 'disabled' : ''}>
816
856
  </div>
817
857
  <div class="flex-grow-1">
818
858
  <div><strong>${safeName}</strong></div>
@@ -822,8 +862,25 @@ function toDatetimeLocalValue(date) {
822
862
  `;
823
863
  }).join('');
824
864
 
865
+ const shortcutsHtml = (() => {
866
+ // Only show shortcuts when selection is a single day (common mobile flow)
867
+ if (days !== 1) return '';
868
+ return `
869
+ <div class="mb-2" id="onekite-duration-shortcuts">
870
+ <div class="text-muted" style="font-size:12px; margin-bottom:6px;">Durée rapide</div>
871
+ <div class="btn-group btn-group-sm" role="group">
872
+ <button type="button" class="btn btn-outline-secondary" data-days="1">1j</button>
873
+ <button type="button" class="btn btn-outline-secondary" data-days="2">2j</button>
874
+ <button type="button" class="btn btn-outline-secondary" data-days="3">3j</button>
875
+ <button type="button" class="btn btn-outline-secondary" data-days="7">7j</button>
876
+ </div>
877
+ </div>
878
+ `;
879
+ })();
880
+
825
881
  const messageHtml = `
826
- <div class="mb-2"><strong>Période</strong><br>${formatDt(start)} → ${formatDt(end)} <span class="text-muted">(${days} jour${days > 1 ? 's' : ''})</span></div>
882
+ <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>
883
+ ${shortcutsHtml}
827
884
  <div class="mb-2"><strong>Matériel</strong></div>
828
885
  <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;">
829
886
  ${rows}
@@ -875,6 +932,47 @@ function toDatetimeLocalValue(date) {
875
932
  // live total update
876
933
  setTimeout(() => {
877
934
  const totalEl = document.getElementById('onekite-total');
935
+ const periodEl = document.getElementById('onekite-period');
936
+ const daysEl = document.getElementById('onekite-days');
937
+
938
+ function rangeQuery(s, e) {
939
+ try {
940
+ const sIso = (s instanceof Date) ? s.toISOString() : new Date(s).toISOString();
941
+ const eIso = (e instanceof Date) ? e.toISOString() : new Date(e).toISOString();
942
+ return new URLSearchParams({ start: sIso, end: eIso }).toString();
943
+ } catch (err) {
944
+ return '';
945
+ }
946
+ }
947
+
948
+ async function refreshBlocked() {
949
+ try {
950
+ const qs = rangeQuery(start, end);
951
+ if (!qs) return;
952
+ const evs = await fetchJson(`/api/v3/plugins/calendar-onekite/events?${qs}`);
953
+ blocked = new Set();
954
+ (evs || []).forEach((ev) => {
955
+ const st = (ev.extendedProps && ev.extendedProps.status) || '';
956
+ if (!['pending', 'awaiting_payment', 'approved', 'paid'].includes(st)) return;
957
+ const ids = (ev.extendedProps && ev.extendedProps.itemIds) || (ev.extendedProps && ev.extendedProps.itemId ? [ev.extendedProps.itemId] : []);
958
+ ids.forEach((id) => blocked.add(String(id)));
959
+ });
960
+
961
+ document.querySelectorAll('#onekite-items [data-itemid]').forEach((row) => {
962
+ const iid = row.getAttribute('data-itemid');
963
+ const dis = blocked.has(String(iid));
964
+ row.classList.toggle('opacity-50', dis);
965
+ const cb = row.querySelector('input.onekite-item-cb');
966
+ if (cb) {
967
+ cb.disabled = dis;
968
+ if (dis) cb.checked = false;
969
+ }
970
+ });
971
+ } catch (e) {
972
+ // ignore
973
+ }
974
+ }
975
+
878
976
  function refreshTotal() {
879
977
  const cbs = Array.from(document.querySelectorAll('.onekite-item-cb')).filter(cb => cb.checked);
880
978
  const sum = cbs.reduce((acc, cb) => acc + (parseFloat(cb.getAttribute('data-price') || '0') || 0), 0);
@@ -885,6 +983,31 @@ function toDatetimeLocalValue(date) {
885
983
  }
886
984
  document.querySelectorAll('.onekite-item-cb').forEach(cb => cb.addEventListener('change', refreshTotal));
887
985
  refreshTotal();
986
+
987
+ // Duration shortcuts (single-day selections): update end/days + refresh blocked items
988
+ const shortcutsWrap = document.getElementById('onekite-duration-shortcuts');
989
+ if (shortcutsWrap) {
990
+ shortcutsWrap.addEventListener('click', async (ev) => {
991
+ const btn = ev.target && ev.target.closest ? ev.target.closest('button[data-days]') : null;
992
+ if (!btn) return;
993
+ const nd = parseInt(btn.getAttribute('data-days'), 10);
994
+ if (!Number.isFinite(nd) || nd < 1) return;
995
+ // end is exclusive -> add nd days to start
996
+ end = new Date(start);
997
+ end.setDate(end.getDate() + nd);
998
+ days = nd;
999
+ if (daysEl) {
1000
+ daysEl.textContent = `(${days} jour${days > 1 ? 's' : ''})`;
1001
+ }
1002
+ if (periodEl) {
1003
+ periodEl.innerHTML = `<strong>Période</strong><br>${formatDt(start)} → ${formatDt(end)} <span class="text-muted" id="onekite-days">(${days} jour${days > 1 ? 's' : ''})</span>`;
1004
+ }
1005
+ // Toggle active button
1006
+ shortcutsWrap.querySelectorAll('button[data-days]').forEach((b) => b.classList.toggle('active', b === btn));
1007
+ await refreshBlocked();
1008
+ refreshTotal();
1009
+ });
1010
+ }
888
1011
  }, 0);
889
1012
  });
890
1013
  }
@@ -1065,7 +1188,127 @@ function toDatetimeLocalValue(date) {
1065
1188
  right: (canCreateSpecial ? 'newSpecial ' : '') + 'dayGridMonth,timeGridWeek',
1066
1189
  };
1067
1190
 
1068
- const calendar = new FullCalendar.Calendar(el, {
1191
+ let calendar;
1192
+
1193
+ // Unified handler for creation actions (reservations vs special events).
1194
+ // On mobile, FullCalendar may emit `dateClick` but not `select` for a simple tap.
1195
+ // We therefore support both without calling `calendar.select()` (which could
1196
+ // double-trigger `select`).
1197
+ async function handleCreateFromSelection(info) {
1198
+ if (isDialogOpen) {
1199
+ return;
1200
+ }
1201
+ // Avoid double-taps creating two dialogs / two requests.
1202
+ if (!lockAction('create', 900)) {
1203
+ return;
1204
+ }
1205
+ isDialogOpen = true;
1206
+ try {
1207
+ if (mode === 'special' && canCreateSpecial) {
1208
+ const payload = await openSpecialEventDialog(info);
1209
+ if (!payload) {
1210
+ calendar.unselect();
1211
+ isDialogOpen = false;
1212
+ return;
1213
+ }
1214
+ await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1215
+ method: 'POST',
1216
+ body: JSON.stringify(payload),
1217
+ }).catch(async () => {
1218
+ return await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1219
+ method: 'POST',
1220
+ body: JSON.stringify(payload),
1221
+ });
1222
+ });
1223
+ showAlert('success', 'Évènement créé.');
1224
+ invalidateEventsCache();
1225
+ scheduleRefetch(calendar);
1226
+ calendar.unselect();
1227
+ isDialogOpen = false;
1228
+ return;
1229
+ }
1230
+
1231
+ // Business rule: reservations cannot start today or in the past.
1232
+ // (We validate again on the server, but this gives immediate feedback.)
1233
+ try {
1234
+ const startDateCheck = toLocalYmd(info.start);
1235
+ const todayCheck = toLocalYmd(new Date());
1236
+ if (startDateCheck <= todayCheck) {
1237
+ lastDateRuleToastAt = Date.now();
1238
+ showAlert('error', "Impossible de réserver pour aujourd’hui ou une date passée.");
1239
+ calendar.unselect();
1240
+ isDialogOpen = false;
1241
+ return;
1242
+ }
1243
+ } catch (e) {
1244
+ // ignore
1245
+ }
1246
+
1247
+ if (!items || !items.length) {
1248
+ showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1249
+ calendar.unselect();
1250
+ isDialogOpen = false;
1251
+ return;
1252
+ }
1253
+ const chosen = await openReservationDialog(info, items);
1254
+ if (!chosen || !chosen.itemIds || !chosen.itemIds.length) {
1255
+ calendar.unselect();
1256
+ isDialogOpen = false;
1257
+ return;
1258
+ }
1259
+ // Send date strings (no hours) so reservations are day-based.
1260
+ const startDate = toLocalYmd(info.start);
1261
+ const endDate = toLocalYmd(info.end);
1262
+ await requestReservation({
1263
+ start: startDate,
1264
+ end: endDate,
1265
+ itemIds: chosen.itemIds,
1266
+ itemNames: chosen.itemNames,
1267
+ total: chosen.total,
1268
+ });
1269
+ showAlert('success', 'Demande envoyée (en attente de validation).');
1270
+ invalidateEventsCache();
1271
+ scheduleRefetch(calendar);
1272
+ calendar.unselect();
1273
+ isDialogOpen = false;
1274
+ } catch (e) {
1275
+ const code = String((e && (e.status || e.message)) || '');
1276
+ const payload = e && e.payload ? e.payload : null;
1277
+
1278
+ if (code === '403') {
1279
+ const msg = payload && (payload.message || payload.error || payload.msg) ? String(payload.message || payload.error || payload.msg) : '';
1280
+ const c = payload && payload.code ? String(payload.code) : '';
1281
+ if (c === 'NOT_MEMBER' || /adh(é|e)rent/i.test(msg) || /membership/i.test(msg)) {
1282
+ showAlert('error', msg || 'Vous devez être adhérent pour pouvoir effectuer une réservation.');
1283
+ } else {
1284
+ showAlert('error', msg || 'Impossible de créer la demande : droits insuffisants (groupe).');
1285
+ }
1286
+ } else if (code === '409') {
1287
+ showAlert('error', 'Impossible : au moins un matériel est déjà réservé ou en attente sur cette période.');
1288
+ } else if (code === '400' && payload && (payload.error === 'date-too-soon' || payload.code === 'date-too-soon')) {
1289
+ // If we already showed the client-side toast a moment ago, avoid a duplicate.
1290
+ if (!lastDateRuleToastAt || (Date.now() - lastDateRuleToastAt) > 1500) {
1291
+ showAlert('error', String(payload.message || "Impossible de réserver pour aujourd’hui ou une date passée."));
1292
+ }
1293
+ } else {
1294
+ const msgRaw = payload && (payload.message || payload.error || payload.msg)
1295
+ ? String(payload.message || payload.error || payload.msg)
1296
+ : '';
1297
+
1298
+ // NodeBB can return a plain "not-logged-in" string when the user is not authenticated.
1299
+ // We want a user-friendly message consistent with the membership requirement.
1300
+ const msg = (/\bnot-logged-in\b/i.test(msgRaw) || /\[\[error:not-logged-in\]\]/i.test(msgRaw))
1301
+ ? 'Vous devez être adhérent Onekite'
1302
+ : msgRaw;
1303
+
1304
+ 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.'));
1305
+ }
1306
+ calendar.unselect();
1307
+ isDialogOpen = false;
1308
+ }
1309
+ }
1310
+
1311
+ calendar = new FullCalendar.Calendar(el, {
1069
1312
  initialView: 'dayGridMonth',
1070
1313
  height: 'auto',
1071
1314
  contentHeight: 'auto',
@@ -1088,6 +1331,22 @@ function toDatetimeLocalValue(date) {
1088
1331
  displayEventTime: false,
1089
1332
  selectable: true,
1090
1333
  selectMirror: true,
1334
+ // Mobile: make selection responsive on touch
1335
+ longPressDelay: 300,
1336
+ selectLongPressDelay: 300,
1337
+ dayCellDidMount: function (arg) {
1338
+ // Visually disable today and past days for reservation creation rules,
1339
+ // without breaking event clicks.
1340
+ try {
1341
+ const cellDate = arg && arg.date ? new Date(arg.date) : null;
1342
+ if (!cellDate) return;
1343
+ const ymd = toLocalYmd(cellDate);
1344
+ const today = toLocalYmd(new Date());
1345
+ if (ymd <= today) {
1346
+ arg.el.classList.add('onekite-day-disabled');
1347
+ }
1348
+ } catch (e) {}
1349
+ },
1091
1350
  events: async function (info, successCallback, failureCallback) {
1092
1351
  try {
1093
1352
  // Abort previous in-flight events fetch to avoid "double refresh" effects.
@@ -1163,156 +1422,15 @@ function toDatetimeLocalValue(date) {
1163
1422
  } catch (e) {}
1164
1423
  },
1165
1424
  select: async function (info) {
1166
- if (isDialogOpen) {
1167
- return;
1168
- }
1169
- isDialogOpen = true;
1170
- try {
1171
- if (mode === 'special' && canCreateSpecial) {
1172
- const payload = await openSpecialEventDialog(info);
1173
- if (!payload) {
1174
- calendar.unselect();
1175
- isDialogOpen = false;
1176
- return;
1177
- }
1178
- await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1179
- method: 'POST',
1180
- body: JSON.stringify(payload),
1181
- }).catch(async () => {
1182
- return await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1183
- method: 'POST',
1184
- body: JSON.stringify(payload),
1185
- });
1186
- });
1187
- showAlert('success', 'Évènement créé.');
1188
- calendar.refetchEvents();
1189
- calendar.unselect();
1190
- isDialogOpen = false;
1191
- return;
1192
- }
1193
-
1194
- // Business rule: reservations cannot start today or in the past.
1195
- // (We validate again on the server, but this gives immediate feedback.)
1196
- try {
1197
- const startDateCheck = toLocalYmd(info.start);
1198
- const todayCheck = toLocalYmd(new Date());
1199
- if (startDateCheck <= todayCheck) {
1200
- lastDateRuleToastAt = Date.now();
1201
- showAlert('error', "Impossible de réserver pour aujourd’hui ou une date passée.");
1202
- calendar.unselect();
1203
- isDialogOpen = false;
1204
- return;
1205
- }
1206
- } catch (e) {
1207
- // ignore
1208
- }
1209
-
1210
- if (!items || !items.length) {
1211
- showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1212
- calendar.unselect();
1213
- isDialogOpen = false;
1214
- return;
1215
- }
1216
- const chosen = await openReservationDialog(info, items);
1217
- if (!chosen || !chosen.itemIds || !chosen.itemIds.length) {
1218
- calendar.unselect();
1219
- isDialogOpen = false;
1220
- return;
1221
- }
1222
- // Send date strings (no hours) so reservations are day-based.
1223
- const startDate = toLocalYmd(info.start);
1224
- const endDate = toLocalYmd(info.end);
1225
- await requestReservation({
1226
- start: startDate,
1227
- end: endDate,
1228
- itemIds: chosen.itemIds,
1229
- itemNames: chosen.itemNames,
1230
- total: chosen.total,
1231
- });
1232
- showAlert('success', 'Demande envoyée (en attente de validation).');
1233
- calendar.refetchEvents();
1234
- calendar.unselect();
1235
- isDialogOpen = false;
1236
- } catch (e) {
1237
- const code = String((e && (e.status || e.message)) || '');
1238
- const payload = e && e.payload ? e.payload : null;
1239
-
1240
- if (code === '403') {
1241
- const msg = payload && (payload.message || payload.error || payload.msg) ? String(payload.message || payload.error || payload.msg) : '';
1242
- const c = payload && payload.code ? String(payload.code) : '';
1243
- if (c === 'NOT_MEMBER' || /adh(é|e)rent/i.test(msg) || /membership/i.test(msg)) {
1244
- showAlert('error', msg || 'Vous devez être adhérent pour pouvoir effectuer une réservation.');
1245
- } else {
1246
- showAlert('error', msg || 'Impossible de créer la demande : droits insuffisants (groupe).');
1247
- }
1248
- } else if (code === '409') {
1249
- showAlert('error', 'Impossible : au moins un matériel est déjà réservé ou en attente sur cette période.');
1250
- } else if (code === '400' && payload && (payload.error === 'date-too-soon' || payload.code === 'date-too-soon')) {
1251
- // If we already showed the client-side toast a moment ago, avoid a duplicate.
1252
- if (!lastDateRuleToastAt || (Date.now() - lastDateRuleToastAt) > 1500) {
1253
- showAlert('error', String(payload.message || "Impossible de réserver pour aujourd’hui ou une date passée."));
1254
- }
1255
- } else {
1256
- const msgRaw = payload && (payload.message || payload.error || payload.msg)
1257
- ? String(payload.message || payload.error || payload.msg)
1258
- : '';
1259
-
1260
- // NodeBB can return a plain "not-logged-in" string when the user is not authenticated.
1261
- // We want a user-friendly message consistent with the membership requirement.
1262
- const msg = (/\bnot-logged-in\b/i.test(msgRaw) || /\[\[error:not-logged-in\]\]/i.test(msgRaw))
1263
- ? 'Vous devez être adhérent Onekite'
1264
- : msgRaw;
1265
-
1266
- 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.'));
1267
- }
1268
- calendar.unselect();
1269
- isDialogOpen = false;
1270
- }
1425
+ return await handleCreateFromSelection(info);
1271
1426
  },
1272
1427
  dateClick: async function (info) {
1273
- // One-day selection convenience
1274
- const start = info.date;
1275
-
1276
- // In "special event" mode, a simple click should propose a one-day event (not two days in the modal)
1277
- if (mode === 'special' && canCreateSpecial) {
1278
- if (isDialogOpen) {
1279
- return;
1280
- }
1281
- isDialogOpen = true;
1282
- try {
1283
- // Default to a one-day block: 07:00 -> 07:00 (end rolled to +1 day on submit).
1284
- const start2 = new Date(start);
1285
- start2.setHours(7, 0, 0, 0);
1286
- const end2 = new Date(start);
1287
- end2.setHours(7, 0, 0, 0);
1288
- const payload = await openSpecialEventDialog({ start: start2, end: end2, allDay: false });
1289
- if (!payload) {
1290
- isDialogOpen = false;
1291
- return;
1292
- }
1293
- await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1294
- method: 'POST',
1295
- body: JSON.stringify(payload),
1296
- }).catch(async () => {
1297
- return await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1298
- method: 'POST',
1299
- body: JSON.stringify(payload),
1300
- });
1301
- });
1302
- showAlert('success', 'Évènement créé.');
1303
- calendar.refetchEvents();
1304
- } finally {
1305
- isDialogOpen = false;
1306
- }
1307
- return;
1308
- }
1309
-
1310
- // IMPORTANT: Do not call `calendar.select()` here.
1311
- // On mobile (and depending on FullCalendar behavior), a simple day click can already
1312
- // trigger the `select` callback. Calling `calendar.select()` would fire `select` a second
1313
- // time, causing duplicate toasts and duplicate dialogs.
1314
- // We rely on FullCalendar's built-in selection on click.
1315
- return;
1428
+ // For a simple tap/click on a day cell, create a 1-day selection.
1429
+ // This restores mobile behavior (where `select` may not fire on tap).
1430
+ const start = new Date(info.date);
1431
+ const end = new Date(start);
1432
+ end.setDate(end.getDate() + 1);
1433
+ return await handleCreateFromSelection({ start: start, end: end, allDay: true });
1316
1434
  },
1317
1435
 
1318
1436
  eventClick: async function (info) {
@@ -1480,9 +1598,11 @@ function toDatetimeLocalValue(date) {
1480
1598
  className: 'btn-outline-warning',
1481
1599
  callback: async () => {
1482
1600
  try {
1601
+ if (!lockAction(`cancel:${rid}`, 1200)) return false;
1483
1602
  await cancelReservation(rid);
1484
1603
  showAlert('success', 'Réservation annulée.');
1485
- calendar.refetchEvents();
1604
+ invalidateEventsCache();
1605
+ scheduleRefetch(calendar);
1486
1606
  } catch (e) {
1487
1607
  showAlert('error', 'Annulation impossible.');
1488
1608
  }
@@ -1512,9 +1632,11 @@ function toDatetimeLocalValue(date) {
1512
1632
  callback: async () => {
1513
1633
  try {
1514
1634
  const reason = (document.getElementById('onekite-refuse-reason')?.value || '').trim();
1635
+ if (!lockAction(`refuse:${rid}`, 1200)) return false;
1515
1636
  await refuseReservation(rid, { reason });
1516
1637
  showAlert('success', 'Demande refusée.');
1517
- calendar.refetchEvents();
1638
+ invalidateEventsCache();
1639
+ scheduleRefetch(calendar);
1518
1640
  resolve(true);
1519
1641
  } catch (e) {
1520
1642
  showAlert('error', 'Refus impossible.');
@@ -1584,9 +1706,11 @@ function toDatetimeLocalValue(date) {
1584
1706
  const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
1585
1707
  const pickupLat = (document.getElementById('onekite-pickup-lat')?.value || '').trim();
1586
1708
  const pickupLon = (document.getElementById('onekite-pickup-lon')?.value || '').trim();
1709
+ if (!lockAction(`approve:${rid}`, 1200)) return false;
1587
1710
  await approveReservation(rid, { pickupAddress, notes, pickupTime, pickupLat, pickupLon });
1588
1711
  showAlert('success', 'Demande validée.');
1589
- calendar.refetchEvents();
1712
+ invalidateEventsCache();
1713
+ scheduleRefetch(calendar);
1590
1714
  } catch (e) {
1591
1715
  showAlert('error', 'Validation impossible.');
1592
1716
  }
@@ -1702,6 +1826,39 @@ function toDatetimeLocalValue(date) {
1702
1826
 
1703
1827
  refreshDesktopModeButton();
1704
1828
 
1829
+ // Mobile: floating action button to create a reservation quickly
1830
+ try {
1831
+ const fabId = 'onekite-fab-create';
1832
+ const existing = document.getElementById(fabId);
1833
+ if (existing) existing.remove();
1834
+ if (isMobileNow()) {
1835
+ const fab = document.createElement('button');
1836
+ fab.id = fabId;
1837
+ fab.type = 'button';
1838
+ fab.className = 'btn btn-primary shadow';
1839
+ fab.textContent = '+ Réserver';
1840
+ fab.style.position = 'fixed';
1841
+ fab.style.right = '14px';
1842
+ fab.style.bottom = '14px';
1843
+ fab.style.zIndex = '1050';
1844
+ fab.style.borderRadius = '999px';
1845
+ fab.style.padding = '12px 16px';
1846
+ fab.addEventListener('click', async () => {
1847
+ try {
1848
+ if (isDialogOpen) return;
1849
+ if (!lockAction('fab', 900)) return;
1850
+ const start = new Date();
1851
+ start.setHours(0, 0, 0, 0);
1852
+ start.setDate(start.getDate() + 1);
1853
+ const end = new Date(start);
1854
+ end.setDate(end.getDate() + 1);
1855
+ await handleCreateFromSelection({ start, end, allDay: true });
1856
+ } catch (e) {}
1857
+ });
1858
+ document.body.appendChild(fab);
1859
+ }
1860
+ } catch (e) {}
1861
+
1705
1862
 
1706
1863
  // Mobile controls: view (month/week) + mode (reservation/event) without bloating the header.
1707
1864
  try {