nodebb-plugin-calendar-onekite 11.1.69 → 11.1.71

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/lib/admin.js CHANGED
@@ -131,12 +131,14 @@ admin.approveReservation = async function (req, res) {
131
131
  const base = forumBaseUrl();
132
132
  const returnUrl = base ? `${base}/calendar` : '';
133
133
  const webhookUrl = base ? `${base}/plugins/calendar-onekite/helloasso` : '';
134
+ const year = new Date(Number(r.start)).getFullYear();
134
135
  paymentUrl = await helloasso.createCheckoutIntent({
135
136
  env,
136
137
  token,
137
138
  organizationSlug: settings.helloassoOrganizationSlug,
138
139
  formType: settings.helloassoFormType,
139
- formSlug: settings.helloassoFormSlug,
140
+ // Form slug is derived from the year
141
+ formSlug: `locations-materiel-${year}`,
140
142
  totalAmount,
141
143
  payerEmail: requester && requester.email,
142
144
  // User return/back/error URLs must be real pages; webhook uses the plugin endpoint.
@@ -266,12 +268,13 @@ admin.debugHelloAsso = async function (req, res) {
266
268
 
267
269
  // Catalog items (via /public)
268
270
  try {
271
+ const y = new Date().getFullYear();
269
272
  const { publicForm, items } = await helloasso.listCatalogItems({
270
273
  env,
271
274
  token,
272
275
  organizationSlug: settings.helloassoOrganizationSlug,
273
276
  formType: settings.helloassoFormType,
274
- formSlug: settings.helloassoFormSlug,
277
+ formSlug: `locations-materiel-${y}`,
275
278
  });
276
279
 
277
280
  const arr = Array.isArray(items) ? items : [];
@@ -289,12 +292,13 @@ admin.debugHelloAsso = async function (req, res) {
289
292
 
290
293
  // Sold items
291
294
  try {
295
+ const y2 = new Date().getFullYear();
292
296
  const items = await helloasso.listItems({
293
297
  env,
294
298
  token,
295
299
  organizationSlug: settings.helloassoOrganizationSlug,
296
300
  formType: settings.helloassoFormType,
297
- formSlug: settings.helloassoFormSlug,
301
+ formSlug: `locations-materiel-${y2}`,
298
302
  });
299
303
  const arr = Array.isArray(items) ? items : [];
300
304
  out.soldItems.ok = true;
package/lib/api.js CHANGED
@@ -114,9 +114,26 @@ function toTs(v) {
114
114
  return d.getTime();
115
115
  }
116
116
 
117
- async function canRequest(uid, settings) {
118
- const allowed = (settings.creatorGroups || settings.allowedGroups || '').split(',').map(s => s.trim()).filter(Boolean);
119
- if (!allowed.length) return true; // if empty, allow all logged in users
117
+ function yearFromTs(ts) {
118
+ const d = new Date(Number(ts));
119
+ return Number.isFinite(d.getTime()) ? d.getFullYear() : new Date().getFullYear();
120
+ }
121
+
122
+ function autoCreatorGroupForYear(year) {
123
+ return `onekite-ffvl-${year}`;
124
+ }
125
+
126
+ function autoFormSlugForYear(year) {
127
+ return `locations-materiel-${year}`;
128
+ }
129
+
130
+ async function canRequest(uid, settings, startTs) {
131
+ const year = yearFromTs(startTs);
132
+ const defaultGroup = autoCreatorGroupForYear(year);
133
+ const extras = (settings.creatorGroups || settings.allowedGroups || '').split(',').map(s => s.trim()).filter(Boolean);
134
+ const allowed = [defaultGroup, ...extras].filter(Boolean);
135
+ // If only the default group exists, enforce membership (do not open access to all).
136
+ if (!allowed.length) return true;
120
137
  for (const g of allowed) {
121
138
  const isMember = await groups.isMember(uid, g);
122
139
  if (isMember) return true;
@@ -143,6 +160,38 @@ async function canValidate(uid, settings) {
143
160
  return false;
144
161
  }
145
162
 
163
+ async function canCreateSpecial(uid, settings) {
164
+ if (!uid) return false;
165
+ try {
166
+ const isAdmin = await groups.isMember(uid, 'administrators');
167
+ if (isAdmin) return true;
168
+ const isGlobalMod = await groups.isMember(uid, 'Global Moderators');
169
+ if (isGlobalMod) return true;
170
+ } catch (e) {}
171
+ const allowed = (settings.specialCreatorGroups || '').split(',').map(s => s.trim()).filter(Boolean);
172
+ if (!allowed.length) return false;
173
+ for (const g of allowed) {
174
+ if (await groups.isMember(uid, g)) return true;
175
+ }
176
+ return false;
177
+ }
178
+
179
+ async function canDeleteSpecial(uid, settings) {
180
+ if (!uid) return false;
181
+ try {
182
+ const isAdmin = await groups.isMember(uid, 'administrators');
183
+ if (isAdmin) return true;
184
+ const isGlobalMod = await groups.isMember(uid, 'Global Moderators');
185
+ if (isGlobalMod) return true;
186
+ } catch (e) {}
187
+ const allowed = (settings.specialDeleterGroups || settings.specialCreatorGroups || '').split(',').map(s => s.trim()).filter(Boolean);
188
+ if (!allowed.length) return false;
189
+ for (const g of allowed) {
190
+ if (await groups.isMember(uid, g)) return true;
191
+ }
192
+ return false;
193
+ }
194
+
146
195
  function eventsFor(resv) {
147
196
  const status = resv.status;
148
197
  const icons = { pending: '⏳', awaiting_payment: '💳', paid: '✅' };
@@ -179,6 +228,32 @@ function eventsFor(resv) {
179
228
  return out;
180
229
  }
181
230
 
231
+ function eventsForSpecial(ev) {
232
+ const start = new Date(parseInt(ev.start, 10));
233
+ const end = new Date(parseInt(ev.end, 10));
234
+ const startIso = start.toISOString();
235
+ const endIso = end.toISOString();
236
+ return {
237
+ id: `special:${ev.eid}`,
238
+ title: `📌 ${ev.title || 'Évènement'}`.trim(),
239
+ allDay: false,
240
+ start: startIso,
241
+ end: endIso,
242
+ color: '#8e44ad',
243
+ extendedProps: {
244
+ type: 'special',
245
+ eid: ev.eid,
246
+ title: ev.title || '',
247
+ notes: ev.notes || '',
248
+ pickupAddress: ev.address || '',
249
+ pickupLat: ev.lat || '',
250
+ pickupLon: ev.lon || '',
251
+ createdBy: ev.uid || 0,
252
+ username: ev.username || '',
253
+ },
254
+ };
255
+ }
256
+
182
257
  const api = {};
183
258
 
184
259
  api.getEvents = async function (req, res) {
@@ -187,6 +262,8 @@ api.getEvents = async function (req, res) {
187
262
 
188
263
  const settings = await meta.settings.get('calendar-onekite');
189
264
  const canMod = req.uid ? await canValidate(req.uid, settings) : false;
265
+ const canSpecialCreate = req.uid ? await canCreateSpecial(req.uid, settings) : false;
266
+ const canSpecialDelete = req.uid ? await canDeleteSpecial(req.uid, settings) : false;
190
267
 
191
268
  // Fetch a wider window because an event can start before the query range
192
269
  // and still overlap.
@@ -221,9 +298,89 @@ api.getEvents = async function (req, res) {
221
298
  out.push(ev);
222
299
  }
223
300
  }
301
+
302
+ // Special events
303
+ try {
304
+ const specialIds = await dbLayer.listSpecialIdsByStartRange(wideStart, endTs, 5000);
305
+ for (const eid of specialIds) {
306
+ const sev = await dbLayer.getSpecialEvent(eid);
307
+ if (!sev) continue;
308
+ const sStart = parseInt(sev.start, 10);
309
+ const sEnd = parseInt(sev.end, 10);
310
+ if (!(sStart < endTs && startTs < sEnd)) continue;
311
+ const ev = eventsForSpecial(sev);
312
+ ev.extendedProps.canCreateSpecial = canSpecialCreate;
313
+ ev.extendedProps.canDeleteSpecial = canSpecialDelete;
314
+ // Show creator username only to moderators/allowed users
315
+ if (sev.username && (canMod || canSpecialDelete || (req.uid && String(req.uid) === String(sev.uid)))) {
316
+ ev.extendedProps.username = String(sev.username);
317
+ }
318
+ out.push(ev);
319
+ }
320
+ } catch (e) {
321
+ // ignore
322
+ }
224
323
  res.json(out);
225
324
  };
226
325
 
326
+ api.getCapabilities = async function (req, res) {
327
+ const settings = await meta.settings.get('calendar-onekite');
328
+ const uid = req.uid || 0;
329
+ const canMod = uid ? await canValidate(uid, settings) : false;
330
+ res.json({
331
+ canModerate: canMod,
332
+ canCreateSpecial: uid ? await canCreateSpecial(uid, settings) : false,
333
+ canDeleteSpecial: uid ? await canDeleteSpecial(uid, settings) : false,
334
+ });
335
+ };
336
+
337
+ api.createSpecialEvent = async function (req, res) {
338
+ const settings = await meta.settings.get('calendar-onekite');
339
+ if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
340
+ const ok = await canCreateSpecial(req.uid, settings);
341
+ if (!ok) return res.status(403).json({ error: 'not-allowed' });
342
+
343
+ const title = String((req.body && req.body.title) || '').trim() || 'Évènement';
344
+ const startTs = toTs(req.body && req.body.start);
345
+ const endTs = toTs(req.body && req.body.end);
346
+ if (!Number.isFinite(startTs) || !Number.isFinite(endTs) || !(startTs < endTs)) {
347
+ return res.status(400).json({ error: 'bad-dates' });
348
+ }
349
+ const address = String((req.body && req.body.address) || '').trim();
350
+ const notes = String((req.body && req.body.notes) || '').trim();
351
+ const lat = String((req.body && req.body.lat) || '').trim();
352
+ const lon = String((req.body && req.body.lon) || '').trim();
353
+
354
+ const u = await user.getUserFields(req.uid, ['username']);
355
+ const eid = crypto.randomUUID();
356
+ const ev = {
357
+ eid,
358
+ title,
359
+ start: String(startTs),
360
+ end: String(endTs),
361
+ address,
362
+ notes,
363
+ lat,
364
+ lon,
365
+ uid: String(req.uid),
366
+ username: u && u.username ? String(u.username) : '',
367
+ createdAt: String(Date.now()),
368
+ };
369
+ await dbLayer.saveSpecialEvent(ev);
370
+ res.json({ ok: true, eid });
371
+ };
372
+
373
+ api.deleteSpecialEvent = async function (req, res) {
374
+ const settings = await meta.settings.get('calendar-onekite');
375
+ if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
376
+ const ok = await canDeleteSpecial(req.uid, settings);
377
+ if (!ok) return res.status(403).json({ error: 'not-allowed' });
378
+ const eid = String(req.params.eid || '').replace(/^special:/, '').trim();
379
+ if (!eid) return res.status(400).json({ error: 'bad-id' });
380
+ await dbLayer.removeSpecialEvent(eid);
381
+ res.json({ ok: true });
382
+ };
383
+
227
384
  api.getItems = async function (req, res) {
228
385
  const settings = await meta.settings.get('calendar-onekite');
229
386
 
@@ -243,12 +400,14 @@ api.getItems = async function (req, res) {
243
400
 
244
401
  // Important: the /items endpoint on HelloAsso lists *sold items*.
245
402
  // For a shop catalog, use the /public form endpoint and extract the catalog.
403
+ const year = new Date().getFullYear();
246
404
  const { items: catalog } = await helloasso.listCatalogItems({
247
405
  env,
248
406
  token,
249
407
  organizationSlug: settings.helloassoOrganizationSlug,
250
408
  formType: settings.helloassoFormType,
251
- formSlug: settings.helloassoFormSlug,
409
+ // Form slug is derived from the year
410
+ formSlug: autoFormSlugForYear(year),
252
411
  });
253
412
 
254
413
  const normalized = (catalog || []).map((it) => ({
@@ -265,7 +424,8 @@ api.createReservation = async function (req, res) {
265
424
  if (!uid) return res.status(401).json({ error: 'not-logged-in' });
266
425
 
267
426
  const settings = await meta.settings.get('calendar-onekite');
268
- const ok = await canRequest(uid, settings);
427
+ const startPreview = toTs(req.body.start);
428
+ const ok = await canRequest(uid, settings, startPreview);
269
429
  if (!ok) return res.status(403).json({ error: 'not-allowed' });
270
430
 
271
431
  const start = parseInt(toTs(req.body.start), 10);
@@ -396,12 +556,14 @@ api.approveReservation = async function (req, res) {
396
556
  const settings2 = await meta.settings.get('calendar-onekite');
397
557
  const token = await helloasso.getAccessToken({ env: settings2.helloassoEnv || 'prod', clientId: settings2.helloassoClientId, clientSecret: settings2.helloassoClientSecret });
398
558
  const payer = await user.getUserFields(r.uid, ['email']);
559
+ const year = yearFromTs(r.start);
399
560
  const intent = await helloasso.createCheckoutIntent({
400
561
  env: settings2.helloassoEnv,
401
562
  token,
402
563
  organizationSlug: settings2.helloassoOrganizationSlug,
403
564
  formType: settings2.helloassoFormType,
404
- formSlug: settings2.helloassoFormSlug,
565
+ // Form slug is derived from the year of the reservation start date
566
+ formSlug: autoFormSlugForYear(year),
405
567
  // r.total is stored as an estimated total in euros; HelloAsso expects cents.
406
568
  totalAmount: (() => {
407
569
  const cents = Math.max(0, Math.round((Number(r.total) || 0) * 100));
package/lib/db.js CHANGED
@@ -6,6 +6,10 @@ const KEY_ZSET = 'calendar-onekite:reservations';
6
6
  const KEY_OBJ = (rid) => `calendar-onekite:reservation:${rid}`;
7
7
  const KEY_CHECKOUT_INTENT_TO_RID = 'calendar-onekite:helloasso:checkoutIntentToRid';
8
8
 
9
+ // Special events (non-reservation events shown in a different colour)
10
+ const KEY_SPECIAL_ZSET = 'calendar-onekite:special';
11
+ const KEY_SPECIAL_OBJ = (eid) => `calendar-onekite:special:${eid}`;
12
+
9
13
  async function getReservation(rid) {
10
14
  return await db.getObject(KEY_OBJ(rid));
11
15
  }
@@ -43,10 +47,26 @@ async function listAllReservationIds(limit = 5000) {
43
47
 
44
48
  module.exports = {
45
49
  KEY_ZSET,
50
+ KEY_SPECIAL_ZSET,
46
51
  KEY_CHECKOUT_INTENT_TO_RID,
47
52
  getReservation,
48
53
  saveReservation,
49
54
  removeReservation,
55
+ // Special events
56
+ getSpecialEvent: async (eid) => await db.getObject(KEY_SPECIAL_OBJ(eid)),
57
+ saveSpecialEvent: async (ev) => {
58
+ await db.setObject(KEY_SPECIAL_OBJ(ev.eid), ev);
59
+ await db.sortedSetAdd(KEY_SPECIAL_ZSET, ev.start, ev.eid);
60
+ },
61
+ removeSpecialEvent: async (eid) => {
62
+ await db.sortedSetRemove(KEY_SPECIAL_ZSET, eid);
63
+ await db.delete(KEY_SPECIAL_OBJ(eid));
64
+ },
65
+ listSpecialIdsByStartRange: async (startTs, endTs, limit = 2000) => {
66
+ const start = 0;
67
+ const stop = Math.max(0, (parseInt(limit, 10) || 2000) - 1);
68
+ return await db.getSortedSetRangeByScore(KEY_SPECIAL_ZSET, start, stop, startTs, endTs);
69
+ },
50
70
  listReservationIdsByStartRange,
51
71
  listAllReservationIds,
52
72
  };
package/lib/scheduler.js CHANGED
@@ -5,10 +5,17 @@ const dbLayer = require('./db');
5
5
 
6
6
  let timer = null;
7
7
 
8
+ function getSetting(settings, key, fallback) {
9
+ const v = settings && Object.prototype.hasOwnProperty.call(settings, key) ? settings[key] : undefined;
10
+ if (v == null || v === '') return fallback;
11
+ if (typeof v === 'object' && v && typeof v.value !== 'undefined') return v.value;
12
+ return v;
13
+ }
14
+
8
15
  // Pending holds: short lock after a user creates a request (defaults to 5 minutes)
9
16
  async function expirePending() {
10
17
  const settings = await meta.settings.get('calendar-onekite');
11
- const holdMins = parseInt(settings.pendingHoldMinutes || '5', 10) || 5;
18
+ const holdMins = parseInt(getSetting(settings, 'pendingHoldMinutes', '5'), 10) || 5;
12
19
  const now = Date.now();
13
20
 
14
21
  const ids = await dbLayer.listAllReservationIds(5000);
@@ -36,7 +43,10 @@ async function expirePending() {
36
43
  // - We expire (and remove) after `2 * paymentHoldMinutes`
37
44
  async function processAwaitingPayment() {
38
45
  const settings = await meta.settings.get('calendar-onekite');
39
- const holdMins = parseInt(settings.paymentHoldMinutes || settings.holdMinutes || '60', 10) || 60;
46
+ const holdMins = parseInt(
47
+ getSetting(settings, 'paymentHoldMinutes', getSetting(settings, 'holdMinutes', '60')),
48
+ 10
49
+ ) || 60;
40
50
  const now = Date.now();
41
51
 
42
52
  const ids = await dbLayer.listAllReservationIds(5000);
package/library.js CHANGED
@@ -64,10 +64,22 @@ Plugin.init = async function (params) {
64
64
  router.get(p, ...publicExpose, api.getItems);
65
65
  });
66
66
 
67
+ ['/api/v3/plugins/calendar-onekite/capabilities', '/api/plugins/calendar-onekite/capabilities'].forEach((p) => {
68
+ router.get(p, ...publicExpose, api.getCapabilities);
69
+ });
70
+
67
71
  ['/api/v3/plugins/calendar-onekite/reservations', '/api/plugins/calendar-onekite/reservations'].forEach((p) => {
68
72
  router.post(p, ...publicAuth, api.createReservation);
69
73
  });
70
74
 
75
+ // Special events (other colour) - created/deleted by configured groups
76
+ ['/api/v3/plugins/calendar-onekite/special-events', '/api/plugins/calendar-onekite/special-events'].forEach((p) => {
77
+ router.post(p, ...publicAuth, api.createSpecialEvent);
78
+ });
79
+ ['/api/v3/plugins/calendar-onekite/special-events/:eid', '/api/plugins/calendar-onekite/special-events/:eid'].forEach((p) => {
80
+ router.delete(p, ...publicAuth, api.deleteSpecialEvent);
81
+ });
82
+
71
83
  // Validator actions from the calendar popup (requires login + validatorGroups)
72
84
  ['/api/v3/plugins/calendar-onekite/reservations/:rid/approve', '/api/plugins/calendar-onekite/reservations/:rid/approve'].forEach((p) => {
73
85
  router.put(p, ...publicAuth, api.approveReservation);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version": "11.1.69",
3
+ "version": "11.1.71",
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
@@ -27,5 +27,5 @@
27
27
  "acpScripts": [
28
28
  "public/admin.js"
29
29
  ],
30
- "version": "1.0.40"
30
+ "version": "1.0.46"
31
31
  }
package/public/admin.js CHANGED
@@ -138,6 +138,49 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
138
138
  });
139
139
  }
140
140
 
141
+ function normalizeCsvGroupsWithDefault(csv, defaultGroup) {
142
+ const extras = String(csv || '').split(',').map(s => s.trim()).filter(Boolean);
143
+ const set = new Set();
144
+ const out = [];
145
+ if (defaultGroup) {
146
+ const dg = String(defaultGroup).trim();
147
+ if (dg) {
148
+ set.add(dg);
149
+ out.push(dg);
150
+ }
151
+ }
152
+ for (const g of extras) {
153
+ if (!set.has(g)) {
154
+ set.add(g);
155
+ out.push(g);
156
+ }
157
+ }
158
+ return out.join(', ');
159
+ }
160
+
161
+ function ensureSpecialFieldsExist(form) {
162
+ // If the ACP template didn't include these fields (older installs), inject them.
163
+ if (!form) return;
164
+ const hasCreator = form.querySelector('[name="specialCreatorGroups"]');
165
+ const hasDeleter = form.querySelector('[name="specialDeleterGroups"]');
166
+ if (hasCreator && hasDeleter) return;
167
+ const wrap = document.createElement('div');
168
+ wrap.innerHTML = `
169
+ <hr />
170
+ <h4>Évènements (autre couleur)</h4>
171
+ <p class="text-muted" style="max-width: 900px;">Permet de créer des évènements non liés aux locations (autre couleur), avec date/heure, adresse (OpenStreetMap) et notes.</p>
172
+ <div class="mb-3">
173
+ <label class="form-label">Groupes autorisés à créer ces évènements (CSV)</label>
174
+ <input type="text" class="form-control" name="specialCreatorGroups" placeholder="ex: staff, instructors" />
175
+ </div>
176
+ <div class="mb-3">
177
+ <label class="form-label">Groupes autorisés à supprimer ces évènements (CSV)</label>
178
+ <input type="text" class="form-control" name="specialDeleterGroups" placeholder="ex: administrators" />
179
+ </div>
180
+ `;
181
+ form.appendChild(wrap);
182
+ }
183
+
141
184
 
142
185
  function renderPending(list) {
143
186
  const wrap = document.getElementById('onekite-pending');
@@ -396,10 +439,34 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
396
439
  const form = document.getElementById('onekite-settings-form');
397
440
  if (!form) return;
398
441
 
442
+ // Inject missing settings fields if the template is older
443
+ function ensureTextInput(name, label, help) {
444
+ if (form.querySelector(`[name="${name}"]`)) return;
445
+ const div = document.createElement('div');
446
+ div.className = 'mb-3';
447
+ div.innerHTML = `
448
+ <label class="form-label">${label}</label>
449
+ <input type="text" class="form-control" name="${name}" placeholder="" />
450
+ ${help ? `<div class="form-text">${help}</div>` : ''}
451
+ `;
452
+ form.appendChild(div);
453
+ }
454
+
455
+ ensureTextInput('specialCreatorGroups', 'Groupes autorisés à créer des évènements (csv)', 'Ex: groupA,groupB');
456
+ ensureTextInput('specialDeleterGroups', 'Groupes autorisés à supprimer des évènements (csv)', 'Par défaut, si vide : même liste que la création');
457
+
399
458
  // Load settings
400
459
  try {
401
460
  const s = await loadSettings();
402
461
  fillForm(form, s || {});
462
+
463
+ // Ensure default creator group prefix appears in the ACP field
464
+ const y = new Date().getFullYear();
465
+ const defaultGroup = `onekite-ffvl-${y}`;
466
+ const cgEl = form.querySelector('[name="creatorGroups"]');
467
+ if (cgEl) {
468
+ cgEl.value = normalizeCsvGroupsWithDefault(cgEl.value, defaultGroup);
469
+ }
403
470
  } catch (e) {
404
471
  showAlert('error', 'Impossible de charger les paramètres.');
405
472
  }
@@ -426,7 +493,14 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
426
493
  doSave._inFlight = true;
427
494
  try {
428
495
  if (ev && typeof ev.preventDefault === 'function') ev.preventDefault();
429
- await saveSettings(formToObject(form));
496
+ const payload = formToObject(form);
497
+ // Always prefix with default yearly group
498
+ const y = new Date().getFullYear();
499
+ const defaultGroup = `onekite-ffvl-${y}`;
500
+ if (Object.prototype.hasOwnProperty.call(payload, 'creatorGroups')) {
501
+ payload.creatorGroups = normalizeCsvGroupsWithDefault(payload.creatorGroups, defaultGroup);
502
+ }
503
+ await saveSettings(payload);
430
504
  showAlert('success', 'Paramètres enregistrés.');
431
505
  } catch (e) {
432
506
  showAlert('error', 'Échec de l\'enregistrement.');
@@ -435,9 +509,9 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
435
509
  }
436
510
  }
437
511
 
438
- // Save buttons (bottom button + NodeBB header/footer "Enregistrer" + floppy icon)
512
+ // Save buttons (NodeBB header/footer "Enregistrer" + floppy icon)
439
513
  // Use ONE delegated listener to avoid double submissions.
440
- const SAVE_SELECTOR = '#onekite-save, #save, .save, [data-action="save"], .settings-save, .floating-save, .btn[data-action="save"]';
514
+ const SAVE_SELECTOR = '#save, .save, [data-action="save"], .settings-save, .floating-save, .btn[data-action="save"]';
441
515
  document.addEventListener('click', (ev) => {
442
516
  const btn = ev.target && ev.target.closest && ev.target.closest(SAVE_SELECTOR);
443
517
  if (!btn) return;
package/public/client.js CHANGED
@@ -16,6 +16,108 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
16
16
  .replace(/'/g, '&#39;');
17
17
  }
18
18
 
19
+ async function openSpecialEventDialog(selectionInfo) {
20
+ const start = selectionInfo.start;
21
+ const end = selectionInfo.end;
22
+ const html = `
23
+ <div class="mb-3">
24
+ <label class="form-label">Titre</label>
25
+ <input type="text" class="form-control" id="onekite-se-title" placeholder="Ex: ..." />
26
+ </div>
27
+ <div class="row g-2">
28
+ <div class="col-12 col-md-6">
29
+ <label class="form-label">Début</label>
30
+ <input type="datetime-local" class="form-control" id="onekite-se-start" value="${escapeHtml(toDatetimeLocalValue(start))}" />
31
+ </div>
32
+ <div class="col-12 col-md-6">
33
+ <label class="form-label">Fin</label>
34
+ <input type="datetime-local" class="form-control" id="onekite-se-end" value="${escapeHtml(toDatetimeLocalValue(end))}" />
35
+ </div>
36
+ </div>
37
+ <div class="mt-3">
38
+ <label class="form-label">Adresse</label>
39
+ <div class="input-group">
40
+ <input type="text" class="form-control" id="onekite-se-address" placeholder="Adresse complète" />
41
+ <button class="btn btn-outline-secondary" type="button" id="onekite-se-geocode">Rechercher</button>
42
+ </div>
43
+ <div id="onekite-se-map" style="height:220px; border:1px solid #ddd; border-radius:6px; margin-top:0.5rem;"></div>
44
+ <input type="hidden" id="onekite-se-lat" />
45
+ <input type="hidden" id="onekite-se-lon" />
46
+ </div>
47
+ <div class="mt-3">
48
+ <label class="form-label">Notes (facultatif)</label>
49
+ <textarea class="form-control" id="onekite-se-notes" rows="3" placeholder="..."></textarea>
50
+ </div>
51
+ `;
52
+
53
+ return await new Promise((resolve) => {
54
+ bootbox.dialog({
55
+ title: 'Créer un évènement',
56
+ message: html,
57
+ buttons: {
58
+ cancel: {
59
+ label: 'Annuler',
60
+ className: 'btn-secondary',
61
+ callback: () => resolve(null),
62
+ },
63
+ ok: {
64
+ label: 'Créer',
65
+ className: 'btn-primary',
66
+ callback: () => {
67
+ const title = (document.getElementById('onekite-se-title')?.value || '').trim();
68
+ const startVal = (document.getElementById('onekite-se-start')?.value || '').trim();
69
+ const endVal = (document.getElementById('onekite-se-end')?.value || '').trim();
70
+ const address = (document.getElementById('onekite-se-address')?.value || '').trim();
71
+ const notes = (document.getElementById('onekite-se-notes')?.value || '').trim();
72
+ const lat = (document.getElementById('onekite-se-lat')?.value || '').trim();
73
+ const lon = (document.getElementById('onekite-se-lon')?.value || '').trim();
74
+ resolve({ title, start: startVal, end: endVal, address, notes, lat, lon });
75
+ return true;
76
+ },
77
+ },
78
+ },
79
+ });
80
+
81
+ // init leaflet
82
+ setTimeout(async () => {
83
+ try {
84
+ const mapEl = document.getElementById('onekite-se-map');
85
+ if (!mapEl) return;
86
+ const L = await loadLeaflet();
87
+ const map = L.map(mapEl).setView([46.5, 2.5], 5);
88
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '&copy; OpenStreetMap' }).addTo(map);
89
+ let marker = null;
90
+ function setMarker(lat, lon) {
91
+ if (marker) map.removeLayer(marker);
92
+ marker = L.marker([lat, lon], { draggable: true }).addTo(map);
93
+ marker.on('dragend', () => {
94
+ const p = marker.getLatLng();
95
+ document.getElementById('onekite-se-lat').value = String(p.lat);
96
+ document.getElementById('onekite-se-lon').value = String(p.lng);
97
+ });
98
+ document.getElementById('onekite-se-lat').value = String(lat);
99
+ document.getElementById('onekite-se-lon').value = String(lon);
100
+ map.setView([lat, lon], 14);
101
+ }
102
+ const geocodeBtn = document.getElementById('onekite-se-geocode');
103
+ const addrInput = document.getElementById('onekite-se-address');
104
+ geocodeBtn?.addEventListener('click', async () => {
105
+ const q = (addrInput?.value || '').trim();
106
+ if (!q) return;
107
+ const hit = await geocodeAddress(q);
108
+ if (hit && hit.lat && hit.lon) {
109
+ setMarker(hit.lat, hit.lon);
110
+ } else {
111
+ showAlert('error', 'Adresse introuvable.');
112
+ }
113
+ });
114
+ } catch (e) {
115
+ // ignore leaflet errors
116
+ }
117
+ }, 0);
118
+ });
119
+ }
120
+
19
121
  function statusLabel(s) {
20
122
  const map = {
21
123
  pending: 'En attente',
@@ -60,6 +162,14 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
60
162
  return await res.json();
61
163
  }
62
164
 
165
+ async function loadCapabilities() {
166
+ try {
167
+ return await fetchJson('/api/v3/plugins/calendar-onekite/capabilities');
168
+ } catch (e) {
169
+ return await fetchJson('/api/plugins/calendar-onekite/capabilities');
170
+ }
171
+ }
172
+
63
173
  // Leaflet (OpenStreetMap) helpers - loaded lazily only when needed.
64
174
  let leafletPromise = null;
65
175
  function loadLeaflet() {
@@ -167,6 +277,12 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
167
277
  }
168
278
  }
169
279
 
280
+ function toDatetimeLocalValue(date) {
281
+ const d = new Date(date);
282
+ const pad = (n) => String(n).padStart(2, '0');
283
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
284
+ }
285
+
170
286
  async function openReservationDialog(selectionInfo, items) {
171
287
  const start = selectionInfo.start;
172
288
  const end = selectionInfo.end;
@@ -287,12 +403,31 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
287
403
  }
288
404
 
289
405
  const items = await loadItems();
406
+ const caps = await loadCapabilities().catch(() => ({}));
407
+ const canCreateSpecial = !!caps.canCreateSpecial;
408
+ const canDeleteSpecial = !!caps.canDeleteSpecial;
409
+
410
+ let mode = 'reservation'; // or 'special'
290
411
 
291
412
  const calendar = new FullCalendar.Calendar(el, {
292
413
  initialView: 'dayGridMonth',
293
414
  locale: 'fr',
294
- // Do not display hours anywhere
295
- displayEventTime: false,
415
+ headerToolbar: {
416
+ left: 'prev,next today',
417
+ center: 'title',
418
+ right: (canCreateSpecial ? 'newSpecial ' : '') + 'dayGridMonth,timeGridWeek,timeGridDay',
419
+ },
420
+ customButtons: canCreateSpecial ? {
421
+ newSpecial: {
422
+ text: 'Évènement',
423
+ click: () => {
424
+ mode = 'special';
425
+ showAlert('success', 'Mode évènement : sélectionne une plage (date/heure) sur le calendrier.');
426
+ },
427
+ },
428
+ } : {},
429
+ // Display time for special events, but keep reservations as all-day events
430
+ displayEventTime: true,
296
431
  selectable: true,
297
432
  selectMirror: true,
298
433
  events: async function (info, successCallback, failureCallback) {
@@ -310,6 +445,30 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
310
445
  }
311
446
  isDialogOpen = true;
312
447
  try {
448
+ if (mode === 'special' && canCreateSpecial) {
449
+ const payload = await openSpecialEventDialog(info);
450
+ mode = 'reservation';
451
+ if (!payload) {
452
+ calendar.unselect();
453
+ isDialogOpen = false;
454
+ return;
455
+ }
456
+ await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
457
+ method: 'POST',
458
+ body: JSON.stringify(payload),
459
+ }).catch(async () => {
460
+ return await fetchJson('/api/plugins/calendar-onekite/special-events', {
461
+ method: 'POST',
462
+ body: JSON.stringify(payload),
463
+ });
464
+ });
465
+ showAlert('success', 'Évènement créé.');
466
+ calendar.refetchEvents();
467
+ calendar.unselect();
468
+ isDialogOpen = false;
469
+ return;
470
+ }
471
+
313
472
  if (!items || !items.length) {
314
473
  showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
315
474
  calendar.unselect();
@@ -323,9 +482,11 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
323
482
  return;
324
483
  }
325
484
  // Send date strings (no hours) so reservations are day-based.
485
+ const startDate = new Date(info.start).toISOString().slice(0, 10);
486
+ const endDate = new Date(info.end).toISOString().slice(0, 10);
326
487
  await requestReservation({
327
- start: info.startStr,
328
- end: info.endStr,
488
+ start: startDate,
489
+ end: endDate,
329
490
  itemIds: chosen.itemIds,
330
491
  itemNames: chosen.itemNames,
331
492
  total: chosen.total,
@@ -357,6 +518,47 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
357
518
  eventClick: async function (info) {
358
519
  const ev = info.event;
359
520
  const p = ev.extendedProps || {};
521
+ if (p.type === 'special') {
522
+ const username = String(p.username || '').trim();
523
+ const userLine = username
524
+ ? `<div class="mb-2"><strong>Créé par</strong><br><a href="${window.location.origin}/user/${encodeURIComponent(username)}">${escapeHtml(username)}</a></div>`
525
+ : '';
526
+ const addr = String(p.pickupAddress || '').trim();
527
+ const notes = String(p.notes || '').trim();
528
+ const html = `
529
+ <div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
530
+ ${userLine}
531
+ <div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDt(ev.start))} → ${escapeHtml(formatDt(ev.end))}</div>
532
+ ${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${escapeHtml(addr)}</div>` : ''}
533
+ ${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
534
+ `;
535
+ const canDel = !!(p.canDeleteSpecial || canDeleteSpecial);
536
+ bootbox.dialog({
537
+ title: 'Évènement',
538
+ message: html,
539
+ buttons: {
540
+ close: { label: 'Fermer', className: 'btn-secondary' },
541
+ ...(canDel ? {
542
+ del: {
543
+ label: 'Supprimer',
544
+ className: 'btn-danger',
545
+ callback: async () => {
546
+ try {
547
+ const eid = String(p.eid || ev.id).replace(/^special:/, '');
548
+ await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(eid)}`, { method: 'DELETE' })
549
+ .catch(() => fetchJson(`/api/plugins/calendar-onekite/special-events/${encodeURIComponent(eid)}`, { method: 'DELETE' }));
550
+ showAlert('success', 'Évènement supprimé.');
551
+ calendar.refetchEvents();
552
+ } catch (e) {
553
+ showAlert('error', 'Suppression impossible.');
554
+ }
555
+ },
556
+ },
557
+ } : {}),
558
+ },
559
+ });
560
+ return;
561
+ }
360
562
  const rid = p.rid || ev.id;
361
563
  const status = p.status || '';
362
564
 
@@ -85,7 +85,6 @@
85
85
  <input class="form-control" name="helloassoFormSlug">
86
86
  </div>
87
87
 
88
- <button type="button" class="btn btn-primary" id="onekite-save">Enregistrer</button>
89
88
  </form>
90
89
  </div>
91
90