nodebb-plugin-calendar-onekite 11.2.38 → 12.0.1

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,8 +1,11 @@
1
+ ## 1.1.0
2
+ ### Perf / prod (NodeBB v4)
3
+ - FullCalendar : passage au CDN **@latest** et utilisation de `main.min.css` (supprime l’erreur 404 `index.global.min.css`).
4
+ - API `events` : payload allégé (les détails sont chargés à la demande), tri stable.
5
+ - Cache intelligent : support **ETag** côté serveur + requêtes conditionnelles côté client (réduit les transferts lors des refetch).
6
+ - Prefetch : préchargement du mois précédent/suivant (si l’utilisateur navigue, la vue est instantanée).
7
+ - Robustesse : anti double-refetch (debounce sur les updates socket) et annulation des fetch concurrents.
1
8
 
2
- ## 1.0.2
3
- - Front : empêche la double initialisation du calendrier (évite le clignotement au chargement / après actions).
4
- - ACP (NodeBB v4) : init robuste via `hooks.on('action:ajaxify.end')` + garde-fou anti double-bind (évite les multi-popups).
5
- - Maintenance : suppression du mapping module ACP legacy (`../admin/plugins/...`).
6
9
 
7
10
  ## 1.0.1.1
8
11
  - ACP (NodeBB v4) : empêche l’affichage multiple des popups de succès lors de l’enregistrement (déduplication des alerts).
package/lib/api.js CHANGED
@@ -252,6 +252,12 @@ function eventsForSpecial(ev) {
252
252
 
253
253
  const api = {};
254
254
 
255
+ function computeEtag(payload) {
256
+ // Weak ETag is fine here: it is only used to skip identical JSON payloads.
257
+ const hash = crypto.createHash('sha1').update(JSON.stringify(payload)).digest('hex');
258
+ return `W/"${hash}"`;
259
+ }
260
+
255
261
  api.getEvents = async function (req, res) {
256
262
  const startTs = toTs(req.query.start) || 0;
257
263
  const endTs = toTs(req.query.end) || (Date.now() + 365 * 24 * 3600 * 1000);
@@ -276,22 +282,35 @@ api.getEvents = async function (req, res) {
276
282
  if (!(rStart < endTs && startTs < rEnd)) continue; // overlap check
277
283
  const evs = eventsFor(r);
278
284
  for (const ev of evs) {
279
- ev.extendedProps = ev.extendedProps || {};
280
- ev.extendedProps.canModerate = canMod;
281
- ev.extendedProps.total = r.total || 0;
282
- // Expose reserver username to owner/moderators for popup display
285
+ const p = ev.extendedProps || {};
286
+ const minimal = {
287
+ id: ev.id,
288
+ title: ev.title,
289
+ backgroundColor: ev.backgroundColor,
290
+ borderColor: ev.borderColor,
291
+ textColor: ev.textColor,
292
+ allDay: ev.allDay,
293
+ start: ev.start,
294
+ end: ev.end,
295
+ extendedProps: {
296
+ type: 'reservation',
297
+ rid: p.rid,
298
+ status: p.status,
299
+ uid: p.uid,
300
+ canModerate: canMod,
301
+ },
302
+ };
303
+ // Only expose username on the event list to owner/moderators.
283
304
  if (r.username && ((req.uid && String(req.uid) === String(r.uid)) || canMod)) {
284
- ev.extendedProps.username = String(r.username);
305
+ minimal.extendedProps.username = String(r.username);
285
306
  }
286
-
287
- ev.extendedProps.createdAt = r.createdAt || null;
288
- // Expose payment URL only to the requester (or moderators) when awaiting payment
307
+ // Let the UI decide if a "Payer" button might exist, without exposing the URL in list.
289
308
  if (r.status === 'awaiting_payment' && r.paymentUrl && (/^https?:\/\//i).test(String(r.paymentUrl))) {
290
309
  if ((req.uid && String(req.uid) === String(r.uid)) || canMod) {
291
- ev.extendedProps.paymentUrl = String(r.paymentUrl);
310
+ minimal.extendedProps.hasPayment = true;
292
311
  }
293
312
  }
294
- out.push(ev);
313
+ out.push(minimal);
295
314
  }
296
315
  }
297
316
 
@@ -304,19 +323,124 @@ api.getEvents = async function (req, res) {
304
323
  const sStart = parseInt(sev.start, 10);
305
324
  const sEnd = parseInt(sev.end, 10);
306
325
  if (!(sStart < endTs && startTs < sEnd)) continue;
307
- const ev = eventsForSpecial(sev);
308
- ev.extendedProps.canCreateSpecial = canSpecialCreate;
309
- ev.extendedProps.canDeleteSpecial = canSpecialDelete;
310
- // Show creator username only to moderators/allowed users
326
+ const full = eventsForSpecial(sev);
327
+ const minimal = {
328
+ id: full.id,
329
+ title: full.title,
330
+ allDay: full.allDay,
331
+ start: full.start,
332
+ end: full.end,
333
+ backgroundColor: full.backgroundColor,
334
+ borderColor: full.borderColor,
335
+ textColor: full.textColor,
336
+ extendedProps: {
337
+ type: 'special',
338
+ eid: sev.eid,
339
+ canCreateSpecial: canSpecialCreate,
340
+ canDeleteSpecial: canSpecialDelete,
341
+ },
342
+ };
311
343
  if (sev.username && (canMod || canSpecialDelete || (req.uid && String(req.uid) === String(sev.uid)))) {
312
- ev.extendedProps.username = String(sev.username);
344
+ minimal.extendedProps.username = String(sev.username);
313
345
  }
314
- out.push(ev);
346
+ out.push(minimal);
315
347
  }
316
348
  } catch (e) {
317
349
  // ignore
318
350
  }
319
- res.json(out);
351
+
352
+ // Stable ordering -> stable ETag
353
+ out.sort((a, b) => {
354
+ const as = String(a.start || '');
355
+ const bs = String(b.start || '');
356
+ if (as !== bs) return as < bs ? -1 : 1;
357
+ const ai = String(a.id || '');
358
+ const bi = String(b.id || '');
359
+ return ai < bi ? -1 : ai > bi ? 1 : 0;
360
+ });
361
+
362
+ const etag = computeEtag(out);
363
+ res.setHeader('ETag', etag);
364
+ res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
365
+ if (String(req.headers['if-none-match'] || '') === etag) {
366
+ return res.status(304).end();
367
+ }
368
+
369
+ return res.json(out);
370
+ };
371
+
372
+ api.getReservationDetails = async function (req, res) {
373
+ const uid = req.uid;
374
+ if (!uid) return res.status(401).json({ error: 'not-logged-in' });
375
+
376
+ const settings = await meta.settings.get('calendar-onekite');
377
+ const canMod = await canValidate(uid, settings);
378
+
379
+ const rid = String(req.params.rid || '').trim();
380
+ if (!rid) return res.status(400).json({ error: 'missing-rid' });
381
+ const r = await dbLayer.getReservation(rid);
382
+ if (!r) return res.status(404).json({ error: 'not-found' });
383
+
384
+ const isOwner = String(r.uid) === String(uid);
385
+ if (!isOwner && !canMod) return res.status(403).json({ error: 'not-allowed' });
386
+
387
+ const out = {
388
+ rid: r.rid,
389
+ status: r.status,
390
+ uid: r.uid,
391
+ username: r.username || '',
392
+ itemNames: Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : []),
393
+ itemIds: Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : []),
394
+ start: r.start,
395
+ end: r.end,
396
+ approvedByUsername: r.approvedByUsername || '',
397
+ pickupAddress: r.pickupAddress || '',
398
+ pickupTime: r.pickupTime || '',
399
+ pickupLat: r.pickupLat || '',
400
+ pickupLon: r.pickupLon || '',
401
+ notes: r.notes || '',
402
+ refusedReason: r.refusedReason || '',
403
+ total: r.total || 0,
404
+ canModerate: canMod,
405
+ };
406
+
407
+ if (r.status === 'awaiting_payment' && r.paymentUrl && (/^https?:\/\//i).test(String(r.paymentUrl))) {
408
+ out.paymentUrl = String(r.paymentUrl);
409
+ }
410
+
411
+ return res.json(out);
412
+ };
413
+
414
+ api.getSpecialEventDetails = async function (req, res) {
415
+ const uid = req.uid;
416
+ if (!uid) return res.status(401).json({ error: 'not-logged-in' });
417
+
418
+ const settings = await meta.settings.get('calendar-onekite');
419
+ const canMod = await canValidate(uid, settings);
420
+ const canSpecialDelete = await canDeleteSpecial(uid, settings);
421
+
422
+ const eid = String(req.params.eid || '').trim();
423
+ if (!eid) return res.status(400).json({ error: 'missing-eid' });
424
+ const ev = await dbLayer.getSpecialEvent(eid);
425
+ if (!ev) return res.status(404).json({ error: 'not-found' });
426
+
427
+ // Anyone who can see the calendar can view special events, but creator username
428
+ // is only visible to moderators/allowed users or the creator.
429
+ const out = {
430
+ eid: ev.eid,
431
+ title: ev.title || '',
432
+ start: ev.start,
433
+ end: ev.end,
434
+ address: ev.address || '',
435
+ lat: ev.lat || '',
436
+ lon: ev.lon || '',
437
+ notes: ev.notes || '',
438
+ canDeleteSpecial: canSpecialDelete,
439
+ };
440
+ if (ev.username && (canMod || canSpecialDelete || (uid && String(uid) === String(ev.uid)))) {
441
+ out.username = String(ev.username);
442
+ }
443
+ return res.json(out);
320
444
  };
321
445
 
322
446
  api.getCapabilities = async function (req, res) {
package/library.js CHANGED
@@ -66,11 +66,13 @@ Plugin.init = async function (params) {
66
66
  router.get('/api/v3/plugins/calendar-onekite/capabilities', ...publicExpose, api.getCapabilities);
67
67
 
68
68
  router.post('/api/v3/plugins/calendar-onekite/reservations', ...publicExpose, api.createReservation);
69
+ router.get('/api/v3/plugins/calendar-onekite/reservations/:rid', ...publicExpose, api.getReservationDetails);
69
70
  router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/approve', ...publicExpose, api.approveReservation);
70
71
  router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/refuse', ...publicExpose, api.refuseReservation);
71
72
  router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/cancel', ...publicExpose, api.cancelReservation);
72
73
 
73
74
  router.post('/api/v3/plugins/calendar-onekite/special-events', ...publicExpose, api.createSpecialEvent);
75
+ router.get('/api/v3/plugins/calendar-onekite/special-events/:eid', ...publicExpose, api.getSpecialEventDetails);
74
76
  router.delete('/api/v3/plugins/calendar-onekite/special-events/:eid', ...publicExpose, api.deleteSpecialEvent);
75
77
 
76
78
  // Admin API (JSON)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version": "11.2.38",
3
+ "version": "12.0.1",
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
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "templates": "./templates",
24
24
  "modules": {
25
+ "../admin/plugins/calendar-onekite.js": "./public/admin.js",
25
26
  "admin/plugins/calendar-onekite": "./public/admin.js"
26
27
  },
27
28
  "scripts": [
@@ -30,5 +31,5 @@
30
31
  "acpScripts": [
31
32
  "public/admin.js"
32
33
  ],
33
- "version": "1.0.2"
34
+ "version": "1.0.1.1"
34
35
  }
package/public/admin.js CHANGED
@@ -1,5 +1,5 @@
1
1
 
2
- define('admin/plugins/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alerts, bootbox, hooks) {
2
+ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts, bootbox) {
3
3
  'use strict';
4
4
 
5
5
  // Cache of pending reservations keyed by rid so delegated click handlers
@@ -91,7 +91,7 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox', 'hooks'], functio
91
91
  const link = document.createElement('link');
92
92
  link.id = cssId;
93
93
  link.rel = 'stylesheet';
94
- link.href = 'https://cdn.jsdelivr.net/npm/leaflet@latest/dist/leaflet.css';
94
+ link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
95
95
  document.head.appendChild(link);
96
96
  }
97
97
  const existing = document.getElementById(jsId);
@@ -103,7 +103,7 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox', 'hooks'], functio
103
103
  const script = document.createElement('script');
104
104
  script.id = jsId;
105
105
  script.async = true;
106
- script.src = 'https://cdn.jsdelivr.net/npm/leaflet@latest/dist/leaflet.js';
106
+ script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
107
107
  script.onload = () => resolve(window.L);
108
108
  script.onerror = () => reject(new Error('leaflet-load-failed'));
109
109
  document.head.appendChild(script);
@@ -335,12 +335,6 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox', 'hooks'], functio
335
335
  const form = document.getElementById('onekite-settings-form');
336
336
  if (!form) return;
337
337
 
338
- // Avoid duplicate listeners/toasts when revisiting the ACP page via ajaxify.
339
- if (form.getAttribute('data-onekite-bound') === '1') {
340
- return;
341
- }
342
- form.setAttribute('data-onekite-bound', '1');
343
-
344
338
  // Make the HelloAsso debug output readable in both light and dark ACP themes.
345
339
  // NodeBB 4.x uses Bootstrap variables, so we can rely on CSS variables here.
346
340
  (function injectAdminCss() {
@@ -807,20 +801,5 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox', 'hooks'], functio
807
801
  }
808
802
  }
809
803
 
810
- // Auto-init when navigating in ACP via ajaxify.
811
- try {
812
- const autoInit = function (data) {
813
- const tpl = data && data.template ? data.template.name : (window.ajaxify && ajaxify.data && ajaxify.data.template ? ajaxify.data.template.name : '');
814
- if (tpl === 'admin/plugins/calendar-onekite') {
815
- init();
816
- }
817
- };
818
- if (hooks && typeof hooks.on === 'function') {
819
- hooks.on('action:ajaxify.end', autoInit);
820
- }
821
- // Also try once for the initial render.
822
- setTimeout(() => autoInit({ template: (window.ajaxify && ajaxify.data && ajaxify.data.template) || { name: '' } }), 0);
823
- } catch (e) {}
824
-
825
804
  return { init };
826
805
  });
package/public/client.js CHANGED
@@ -434,6 +434,69 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
434
434
  return await res.json();
435
435
  }
436
436
 
437
+ // Simple in-memory cache for JSON endpoints (used for events prefetch + ETag).
438
+ const jsonCache = new Map(); // url -> { etag, data, ts }
439
+
440
+ function scheduleRefetch(cal) {
441
+ try {
442
+ if (!cal || typeof cal.refetchEvents !== 'function') return;
443
+ clearTimeout(window.__onekiteRefetchTimer);
444
+ window.__onekiteRefetchTimer = setTimeout(() => {
445
+ try { cal.refetchEvents(); } catch (e) {}
446
+ }, 150);
447
+ } catch (e) {}
448
+ }
449
+
450
+ async function fetchJsonCached(url, opts) {
451
+ const cached = jsonCache.get(url);
452
+ const headers = Object.assign({}, (opts && opts.headers) || {});
453
+ if (cached && cached.etag) {
454
+ headers['If-None-Match'] = cached.etag;
455
+ }
456
+ let res;
457
+ try {
458
+ res = await fetch(url, {
459
+ credentials: 'same-origin',
460
+ headers: (() => {
461
+ // reuse csrf header builder (fetchJson) by calling it indirectly
462
+ const base = { 'Content-Type': 'application/json' };
463
+ const token =
464
+ (window.config && (window.config.csrf_token || window.config.csrfToken)) ||
465
+ (window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
466
+ (document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
467
+ (document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
468
+ (typeof app !== 'undefined' && app && app.csrfToken) ||
469
+ null;
470
+ if (token) base['x-csrf-token'] = token;
471
+ return Object.assign(base, headers);
472
+ })(),
473
+ ...opts,
474
+ });
475
+ } catch (e) {
476
+ // If offline and we have cache, use it.
477
+ if (cached && cached.data) return cached.data;
478
+ throw e;
479
+ }
480
+
481
+ if (res.status === 304 && cached && cached.data) {
482
+ return cached.data;
483
+ }
484
+
485
+ if (!res.ok) {
486
+ let payload = null;
487
+ try { payload = await res.json(); } catch (e) {}
488
+ const err = new Error(`${res.status}`);
489
+ err.status = res.status;
490
+ err.payload = payload;
491
+ throw err;
492
+ }
493
+
494
+ const data = await res.json();
495
+ const etag = res.headers.get('ETag') || '';
496
+ jsonCache.set(url, { etag, data, ts: Date.now() });
497
+ return data;
498
+ }
499
+
437
500
  async function loadCapabilities() {
438
501
  return await fetchJson('/api/v3/plugins/calendar-onekite/capabilities');
439
502
  }
@@ -456,7 +519,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
456
519
  const link = document.createElement('link');
457
520
  link.id = cssId;
458
521
  link.rel = 'stylesheet';
459
- link.href = 'https://cdn.jsdelivr.net/npm/leaflet@latest/dist/leaflet.css';
522
+ link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
460
523
  document.head.appendChild(link);
461
524
  }
462
525
 
@@ -470,7 +533,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
470
533
  const script = document.createElement('script');
471
534
  script.id = jsId;
472
535
  script.async = true;
473
- script.src = 'https://cdn.jsdelivr.net/npm/leaflet@latest/dist/leaflet.js';
536
+ script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
474
537
  script.onload = () => resolve(window.L);
475
538
  script.onerror = () => reject(new Error('leaflet-load-failed'));
476
539
  document.head.appendChild(script);
@@ -828,12 +891,6 @@ function toDatetimeLocalValue(date) {
828
891
  return;
829
892
  }
830
893
 
831
- // Avoid double init (ajaxify + initial load tick).
832
- if (el.getAttribute('data-onekite-initialised') === '1') {
833
- return;
834
- }
835
- el.setAttribute('data-onekite-initialised', '1');
836
-
837
894
  if (typeof FullCalendar === 'undefined') {
838
895
  showAlert('error', 'FullCalendar non chargé');
839
896
  return;
@@ -883,14 +940,28 @@ function toDatetimeLocalValue(date) {
883
940
  try {
884
941
  const btn = document.querySelector('#onekite-calendar .fc-newSpecial-button');
885
942
  if (!btn) return;
943
+
886
944
  const isSpecial = mode === 'special';
887
945
  const label = isSpecial ? 'Évènement ✓' : 'Évènement';
888
- const span = btn.querySelector('.fc-button-text');
889
- if (span) {
890
- span.textContent = label;
946
+
947
+ // Ensure a single canonical .fc-button-text (prevents "ÉvènementÉvènement" after rerenders)
948
+ let span = btn.querySelector('.fc-button-text');
949
+ if (!span) {
950
+ span = document.createElement('span');
951
+ span.className = 'fc-button-text';
952
+ // Remove stray text nodes before inserting span
953
+ [...btn.childNodes].forEach((n) => {
954
+ if (n && n.nodeType === Node.TEXT_NODE) n.remove();
955
+ });
956
+ btn.appendChild(span);
891
957
  } else {
892
- btn.textContent = label;
958
+ // Remove any stray text nodes beside the span
959
+ [...btn.childNodes].forEach((n) => {
960
+ if (n && n.nodeType === Node.TEXT_NODE && n.textContent.trim()) n.remove();
961
+ });
893
962
  }
963
+
964
+ span.textContent = label;
894
965
  btn.classList.toggle('onekite-active', isSpecial);
895
966
  } catch (e) {}
896
967
  }
@@ -1015,8 +1086,32 @@ function toDatetimeLocalValue(date) {
1015
1086
  selectMirror: true,
1016
1087
  events: async function (info, successCallback, failureCallback) {
1017
1088
  try {
1089
+ // Abort previous in-flight events fetch to avoid "double refresh" effects.
1090
+ if (window.__onekiteEventsAbort) {
1091
+ try { window.__onekiteEventsAbort.abort(); } catch (e) {}
1092
+ }
1093
+ const abort = new AbortController();
1094
+ window.__onekiteEventsAbort = abort;
1095
+
1018
1096
  const qs = new URLSearchParams({ start: info.startStr, end: info.endStr });
1019
- const data = await fetchJson(`/api/v3/plugins/calendar-onekite/events?${qs.toString()}`);
1097
+ const url = `/api/v3/plugins/calendar-onekite/events?${qs.toString()}`;
1098
+ const data = await fetchJsonCached(url, { signal: abort.signal });
1099
+
1100
+ // Prefetch adjacent range (previous/next) for snappier navigation.
1101
+ try {
1102
+ const spanMs = (info.end && info.start) ? (info.end.getTime() - info.start.getTime()) : 0;
1103
+ if (spanMs > 0 && spanMs < 1000 * 3600 * 24 * 120) {
1104
+ const prevStart = new Date(info.start.getTime() - spanMs);
1105
+ const prevEnd = new Date(info.start.getTime());
1106
+ const nextStart = new Date(info.end.getTime());
1107
+ const nextEnd = new Date(info.end.getTime() + spanMs);
1108
+ const toStr = (d) => new Date(d.getTime()).toISOString();
1109
+ const qPrev = new URLSearchParams({ start: toStr(prevStart), end: toStr(prevEnd) });
1110
+ const qNext = new URLSearchParams({ start: toStr(nextStart), end: toStr(nextEnd) });
1111
+ fetchJsonCached(`/api/v3/plugins/calendar-onekite/events?${qPrev.toString()}`).catch(() => {});
1112
+ fetchJsonCached(`/api/v3/plugins/calendar-onekite/events?${qNext.toString()}`).catch(() => {});
1113
+ }
1114
+ } catch (e) {}
1020
1115
 
1021
1116
  // IMPORTANT: align "special" event display exactly like reservation icons.
1022
1117
  // We inject the clock + time range directly into the event title so FC
@@ -1192,9 +1287,33 @@ function toDatetimeLocalValue(date) {
1192
1287
  },
1193
1288
 
1194
1289
  eventClick: async function (info) {
1290
+ if (isDialogOpen) return;
1291
+ isDialogOpen = true;
1195
1292
  const ev = info.event;
1196
- const p = ev.extendedProps || {};
1197
- if (p.type === 'special') {
1293
+ const p0 = ev.extendedProps || {};
1294
+
1295
+ // Load full details lazily (events list is lightweight for perf).
1296
+ let p = p0;
1297
+ try {
1298
+ if (p0.type === 'reservation' && p0.rid) {
1299
+ const details = await fetchJson(`/api/v3/plugins/calendar-onekite/reservations/${encodeURIComponent(String(p0.rid))}`);
1300
+ p = Object.assign({}, p0, details);
1301
+ } else if (p0.type === 'special' && p0.eid) {
1302
+ const details = await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(String(p0.eid))}`);
1303
+ p = Object.assign({}, p0, details, {
1304
+ // keep backward compat with older field names used by templates below
1305
+ pickupAddress: details.address || details.pickupAddress || p0.pickupAddress,
1306
+ pickupLat: details.lat || details.pickupLat || p0.pickupLat,
1307
+ pickupLon: details.lon || details.pickupLon || p0.pickupLon,
1308
+ });
1309
+ }
1310
+ } catch (e) {
1311
+ // ignore detail fetch errors; fall back to minimal props
1312
+ p = p0;
1313
+ }
1314
+
1315
+ try {
1316
+ if (p.type === 'special') {
1198
1317
  const username = String(p.username || '').trim();
1199
1318
  const userLine = username
1200
1319
  ? `<div class="mb-2"><strong>Créé par</strong><br><a class="onekite-user-link" href="${window.location.origin}/user/${encodeURIComponent(username)}">${escapeHtml(username)}</a></div>`
@@ -1541,6 +1660,9 @@ function toDatetimeLocalValue(date) {
1541
1660
  message: baseHtml,
1542
1661
  buttons,
1543
1662
  });
1663
+ } finally {
1664
+ isDialogOpen = false;
1665
+ }
1544
1666
  },
1545
1667
  });
1546
1668
 
@@ -1634,6 +1756,8 @@ function toDatetimeLocalValue(date) {
1634
1756
  calendar.setOption('titleFormat', mobile ? { year: 'numeric', month: 'short' } : undefined);
1635
1757
  }
1636
1758
  try { calendar.updateSize(); } catch (err) {}
1759
+
1760
+ try { refreshDesktopModeButton(); } catch (e) {}
1637
1761
  });
1638
1762
  } catch (e) {}
1639
1763
  }
@@ -1662,12 +1786,10 @@ function toDatetimeLocalValue(date) {
1662
1786
  try {
1663
1787
  if (!window.__oneKiteSocketBound && typeof socket !== 'undefined' && socket && typeof socket.on === 'function') {
1664
1788
  window.__oneKiteSocketBound = true;
1665
- socket.on('event:calendar-onekite.reservationUpdated', function (data) {
1789
+ socket.on('event:calendar-onekite.reservationUpdated', function () {
1666
1790
  try {
1667
1791
  const cal = window.oneKiteCalendar;
1668
- if (cal && typeof cal.refetchEvents === 'function') {
1669
- cal.refetchEvents();
1670
- }
1792
+ scheduleRefetch(cal);
1671
1793
  } catch (e) {}
1672
1794
  });
1673
1795
  }
@@ -180,14 +180,11 @@
180
180
  </div>
181
181
 
182
182
  <script>
183
- // NodeBB v4: prefer auto-init via ajaxify hook, but keep a safe fallback.
184
- if (window.require) {
185
- require(['admin/plugins/calendar-onekite'], function (mod) {
186
- if (mod && mod.init) {
187
- mod.init();
188
- }
189
- });
190
- }
183
+ require(['admin/plugins/calendar-onekite'], function (mod) {
184
+ if (mod && mod.init) {
185
+ mod.init();
186
+ }
187
+ });
191
188
  </script>
192
189
 
193
190
  <!-- IMPORT admin/partials/settings/footer.tpl -->
@@ -7,9 +7,14 @@
7
7
  </div>
8
8
  </div>
9
9
 
10
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@latest/index.global.min.css" />
10
+ <!--
11
+ FullCalendar
12
+ - Use CDN "latest" so NodeBB v4 stays simple to maintain.
13
+ - Use main.min.css (the global bundle does not ship index.global.min.css).
14
+ -->
15
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@latest/main.min.css" />
11
16
  <script src="https://cdn.jsdelivr.net/npm/fullcalendar@latest/index.global.min.js"></script>
12
- <script src="https://cdn.jsdelivr.net/npm/fullcalendar@latest/locales-all.global.min.js"></script>
17
+ <script src="https://cdn.jsdelivr.net/npm/@fullcalendar/core@latest/locales-all.global.min.js"></script>
13
18
 
14
19
  <!--
15
20
  No inline require() here.