nodebb-plugin-calendar-onekite 11.1.95 → 11.1.97

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
@@ -20,500 +20,32 @@ function formatFR(tsOrIso) {
20
20
 
21
21
  async function sendEmail(template, toEmail, subject, data) {
22
22
  if (!toEmail) return;
23
+ const settings = await meta.settings.get('calendar-onekite').catch(() => ({}));
24
+ const lang = (settings && settings.defaultLang) || (meta && meta.config && meta.config.defaultLang) || 'fr';
25
+ const params = Object.assign({}, data || {}, subject ? { subject } : {});
23
26
  try {
24
27
  if (typeof emailer.sendToEmail === 'function') {
28
+ // NodeBB: sendToEmail(template, email, language, params)
25
29
  if (emailer.sendToEmail.length >= 4) {
26
- await emailer.sendToEmail(template, toEmail, subject, data);
30
+ await emailer.sendToEmail(template, toEmail, lang, params);
27
31
  } else {
28
- const dataWithSubject = Object.assign({}, data || {}, subject ? { subject } : {});
29
- await emailer.sendToEmail(template, toEmail, dataWithSubject);
32
+ // Older signature: sendToEmail(template, email, params)
33
+ await emailer.sendToEmail(template, toEmail, params);
30
34
  }
31
35
  return;
32
36
  }
33
37
  if (typeof emailer.send === 'function') {
34
- if (emailer.send.length >= 4) {
35
- await emailer.send(template, toEmail, subject, data);
36
- return;
37
- }
38
- if (emailer.send.length === 3) {
39
- const dataWithSubject = Object.assign({}, data || {}, subject ? { subject } : {});
40
- await emailer.send(template, toEmail, dataWithSubject);
41
- return;
42
- }
43
- await emailer.send(template, toEmail, subject, data);
38
+ // Fallback: send(template, uid, params) - not usable without uid
39
+ // Best effort: do nothing rather than crash
40
+ return;
44
41
  }
45
42
  } catch (err) {
46
- console.warn('[calendar-onekite] Failed to send email', { template, toEmail, err: String(err && err.message || err) });
47
- }
48
- }
49
-
50
- function normalizeCallbackUrl(configured, meta) {
51
- const base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
52
- let url = (configured || '').trim();
53
- if (!url) {
54
- url = base ? `${base}/helloasso` : '';
55
- }
56
- if (url && url.startsWith('/') && base) {
57
- url = `${base}${url}`;
58
- }
59
- return url;
60
- }
61
-
62
- function normalizeReturnUrl(meta) {
63
- const base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
64
- const b = String(base || '').trim().replace(/\/$/, '');
65
- if (!b) return '';
66
- return `${b}/calendar`;
67
- }
68
-
69
-
70
- const dbLayer = require('./db');
71
- const helloasso = require('./helloasso');
72
-
73
- const ADMIN_PRIV = 'admin:settings';
74
-
75
- const admin = {};
76
-
77
- admin.renderAdmin = async function (req, res) {
78
- res.render('admin/plugins/calendar-onekite', {
79
- title: 'Calendar OneKite',
80
- });
81
- };
82
-
83
- admin.getSettings = async function (req, res) {
84
- const settings = await meta.settings.get('calendar-onekite');
85
- res.json(settings || {});
86
- };
87
-
88
- admin.saveSettings = async function (req, res) {
89
- await meta.settings.set('calendar-onekite', req.body || {});
90
- res.json({ ok: true });
91
- };
92
-
93
- admin.listPending = async function (req, res) {
94
- const ids = await dbLayer.listAllReservationIds(5000);
95
- const pending = [];
96
- for (const rid of ids) {
97
- const r = await dbLayer.getReservation(rid);
98
- if (r && r.status === 'pending') {
99
- pending.push(r);
100
- }
101
- }
102
- pending.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
103
- res.json(pending);
104
- };
105
-
106
- admin.approveReservation = async function (req, res) {
107
- const rid = req.params.rid;
108
- const r = await dbLayer.getReservation(rid);
109
- if (!r) return res.status(404).json({ error: 'not-found' });
110
-
111
- r.status = 'awaiting_payment';
112
- r.pickupAddress = String((req.body && (req.body.pickupAddress || req.body.adminNote || req.body.note)) || '').trim();
113
- r.notes = String((req.body && req.body.notes) || '').trim();
114
- r.pickupTime = String((req.body && (req.body.pickupTime || req.body.pickup)) || '').trim();
115
- r.pickupLat = String((req.body && req.body.pickupLat) || '').trim();
116
- r.pickupLon = String((req.body && req.body.pickupLon) || '').trim();
117
- r.approvedAt = Date.now();
118
-
119
- try {
120
- const approver = await user.getUserFields(req.uid, ['username']);
121
- r.approvedBy = req.uid;
122
- r.approvedByUsername = approver && approver.username ? approver.username : '';
123
- } catch (e) {
124
- r.approvedBy = req.uid;
125
- r.approvedByUsername = '';
126
- }
127
-
128
- // Create HelloAsso payment link if configured
129
- const settings = await meta.settings.get('calendar-onekite');
130
- const env = settings.helloassoEnv || 'prod';
131
- const token = await helloasso.getAccessToken({
132
- env,
133
- clientId: settings.helloassoClientId,
134
- clientSecret: settings.helloassoClientSecret,
135
- });
136
- if (!token) {
137
- console.warn('[calendar-onekite] HelloAsso access token not obtained (approve ACP)', { rid: r && r.rid });
138
- }
139
-
140
- let paymentUrl = null;
141
- if (token) {
142
- const requester = await user.getUserFields(r.uid, ['username', 'email']);
143
- // r.total is stored as an estimated total in euros; HelloAsso expects cents.
144
- const totalAmount = Math.max(0, Math.round((Number(r.total) || 0) * 100));
145
- const base = forumBaseUrl();
146
- const returnUrl = base ? `${base}/calendar` : '';
147
- const webhookUrl = base ? `${base}/plugins/calendar-onekite/helloasso` : '';
148
- const year = new Date(Number(r.start)).getFullYear();
149
- paymentUrl = await helloasso.createCheckoutIntent({
150
- env,
151
- token,
152
- organizationSlug: settings.helloassoOrganizationSlug,
153
- formType: settings.helloassoFormType,
154
- // Form slug is derived from the year
155
- formSlug: `locations-materiel-${year}`,
156
- totalAmount,
157
- payerEmail: requester && requester.email,
158
- // User return/back/error URLs must be real pages; webhook uses the plugin endpoint.
159
- callbackUrl: returnUrl,
160
- webhookUrl: webhookUrl,
161
- itemName: 'Réservation matériel OneKite',
162
- containsDonation: false,
163
- metadata: { reservationId: String(rid) },
43
+ console.warn('[calendar-onekite] Failed to send email', {
44
+ template,
45
+ toEmail,
46
+ err: err && err.message ? err.message : String(err),
164
47
  });
165
48
  }
166
-
167
- if (paymentUrl) {
168
- r.paymentUrl = paymentUrl;
169
- } else {
170
- console.warn('[calendar-onekite] HelloAsso payment link not created (approve ACP)', { rid });
171
- }
172
-
173
- await dbLayer.saveReservation(r);
174
-
175
- // Email requester
176
- try {
177
- const requester = await user.getUserFields(r.uid, ['username', 'email']);
178
- if (requester && requester.email) {
179
- const latNum = Number(r.pickupLat);
180
- const lonNum = Number(r.pickupLon);
181
- const mapUrl = (Number.isFinite(latNum) && Number.isFinite(lonNum))
182
- ? `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(latNum))}&mlon=${encodeURIComponent(String(lonNum))}#map=18/${encodeURIComponent(String(latNum))}/${encodeURIComponent(String(lonNum))}`
183
- : '';
184
- await sendEmail('calendar-onekite_approved', requester.email, 'Location matériel - Réservation validée', {
185
- username: requester.username,
186
- itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
187
- itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
188
- dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
189
- paymentUrl: paymentUrl || '',
190
- pickupAddress: r.pickupAddress || '',
191
- notes: r.notes || '',
192
- pickupTime: r.pickupTime || '',
193
- pickupLat: r.pickupLat || '',
194
- pickupLon: r.pickupLon || '',
195
- mapUrl,
196
- validatedBy: r.approvedByUsername || '',
197
- validatedByUrl: (r.approvedByUsername ? `https://www.onekite.com/user/${encodeURIComponent(String(r.approvedByUsername))}` : ''),
198
- });
199
- }
200
- } catch (e) {}
201
-
202
- res.json({ ok: true, paymentUrl: paymentUrl || null });
203
- };
204
-
205
- admin.refuseReservation = async function (req, res) {
206
- const rid = req.params.rid;
207
- const r = await dbLayer.getReservation(rid);
208
- if (!r) return res.status(404).json({ error: 'not-found' });
209
-
210
- r.status = 'refused';
211
- await dbLayer.saveReservation(r);
212
-
213
- try {
214
- const requester = await user.getUserFields(r.uid, ['username', 'email']);
215
- if (requester && requester.email) {
216
- await sendEmail('calendar-onekite_refused', requester.email, 'Location matériel - Réservation refusée', {
217
- username: requester.username,
218
- itemName: r.itemName,
219
- start: formatFR(r.start),
220
- end: formatFR(r.end),
221
- });
222
- }
223
- } catch (e) {}
224
-
225
- res.json({ ok: true });
226
- };
227
-
228
- admin.purgeByYear = async function (req, res) {
229
- const year = (req.body && req.body.year ? String(req.body.year) : '').trim();
230
- if (!/^\d{4}$/.test(year)) {
231
- return res.status(400).json({ error: 'invalid-year' });
232
- }
233
- const y = parseInt(year, 10);
234
- const startTs = new Date(Date.UTC(y, 0, 1)).getTime();
235
- const endTs = new Date(Date.UTC(y + 1, 0, 1)).getTime() - 1;
236
-
237
- const ids = await dbLayer.listReservationIdsByStartRange(startTs, endTs, 100000);
238
- let count = 0;
239
- for (const rid of ids) {
240
- await dbLayer.removeReservation(rid);
241
- count++;
242
- }
243
- res.json({ ok: true, removed: count });
244
- };
245
-
246
- admin.purgeSpecialEventsByYear = async function (req, res) {
247
- const year = (req.body && req.body.year ? String(req.body.year) : '').trim();
248
- if (!/^\d{4}$/.test(year)) {
249
- return res.status(400).json({ error: 'invalid-year' });
250
- }
251
- const y = parseInt(year, 10);
252
- const startTs = new Date(Date.UTC(y, 0, 1)).getTime();
253
- const endTs = new Date(Date.UTC(y + 1, 0, 1)).getTime() - 1;
254
-
255
- const ids = await dbLayer.listSpecialIdsByStartRange(startTs, endTs, 100000);
256
- let count = 0;
257
- for (const eid of ids) {
258
- await dbLayer.removeSpecialEvent(eid);
259
- count++;
260
- }
261
- return res.json({ ok: true, removed: count });
262
- };
263
-
264
- // Debug endpoint to validate HelloAsso connectivity and item loading
265
-
266
-
267
- admin.debugHelloAsso = async function (req, res) {
268
- const settings = await meta.settings.get('calendar-onekite');
269
- const env = (settings && settings.helloassoEnv) || 'prod';
270
-
271
- // Never expose secrets in debug output
272
- const safeSettings = {
273
- helloassoEnv: env,
274
- helloassoClientId: settings && settings.helloassoClientId ? String(settings.helloassoClientId) : '',
275
- helloassoClientSecret: settings && settings.helloassoClientSecret ? '***' : '',
276
- helloassoOrganizationSlug: settings && settings.helloassoOrganizationSlug ? String(settings.helloassoOrganizationSlug) : '',
277
- helloassoFormType: settings && settings.helloassoFormType ? String(settings.helloassoFormType) : '',
278
- helloassoFormSlug: settings && settings.helloassoFormSlug ? String(settings.helloassoFormSlug) : '',
279
- };
280
-
281
- const out = {
282
- ok: true,
283
- settings: safeSettings,
284
- token: { ok: false },
285
- // Catalog = what you actually want for a shop (available products/material)
286
- catalog: { ok: false, count: 0, sample: [], keys: [] },
287
- // Sold items = items present in orders (can be 0 if no sales yet)
288
- soldItems: { ok: false, count: 0, sample: [] },
289
- };
290
-
291
- try {
292
- const token = await helloasso.getAccessToken({
293
- env,
294
- clientId: settings.helloassoClientId,
295
- clientSecret: settings.helloassoClientSecret,
296
- });
297
- if (!token) {
298
- out.token = { ok: false, error: 'token-null' };
299
- return res.json(out);
300
- }
301
- out.token = { ok: true };
302
-
303
- // Catalog items (via /public)
304
- try {
305
- const y = new Date().getFullYear();
306
- const { publicForm, items } = await helloasso.listCatalogItems({
307
- env,
308
- token,
309
- organizationSlug: settings.helloassoOrganizationSlug,
310
- formType: settings.helloassoFormType,
311
- formSlug: `locations-materiel-${y}`,
312
- });
313
-
314
- const arr = Array.isArray(items) ? items : [];
315
- out.catalog.ok = true;
316
- out.catalog.count = arr.length;
317
- out.catalog.keys = publicForm && typeof publicForm === 'object' ? Object.keys(publicForm) : [];
318
- out.catalog.sample = arr.slice(0, 10).map((it) => ({
319
- id: it.id,
320
- name: it.name,
321
- price: it.price ?? null,
322
- }));
323
- } catch (e) {
324
- out.catalog = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [], keys: [] };
325
- }
326
-
327
- // Sold items
328
- try {
329
- const y2 = new Date().getFullYear();
330
- const items = await helloasso.listItems({
331
- env,
332
- token,
333
- organizationSlug: settings.helloassoOrganizationSlug,
334
- formType: settings.helloassoFormType,
335
- formSlug: `locations-materiel-${y2}`,
336
- });
337
- const arr = Array.isArray(items) ? items : [];
338
- out.soldItems.ok = true;
339
- out.soldItems.count = arr.length;
340
- out.soldItems.sample = arr.slice(0, 10).map((it) => ({
341
- id: it.id || it.itemId || it.reference || it.name,
342
- name: it.name || it.label || it.itemName,
343
- price: it.price || it.amount || it.unitPrice || null,
344
- }));
345
- } catch (e) {
346
- out.soldItems = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [] };
347
- }
348
-
349
- return res.json(out);
350
- } catch (e) {
351
- out.ok = false;
352
- out.token = { ok: false, error: String(e && e.message ? e.message : e) };
353
- return res.json(out);
354
- }
355
- };
356
-
357
- // Accounting endpoint: aggregates paid reservations so you can contabilize what was rented.
358
- // Query params:
359
- // from=YYYY-MM-DD (inclusive, based on reservation.start)
360
- // to=YYYY-MM-DD (exclusive, based on reservation.start)
361
- admin.getAccounting = async function (req, res) {
362
- const qFrom = String((req.query && req.query.from) || '').trim();
363
- const qTo = String((req.query && req.query.to) || '').trim();
364
- const parseDay = (s) => {
365
- if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
366
- const [y, m, d] = s.split('-').map((x) => parseInt(x, 10));
367
- const dt = new Date(Date.UTC(y, m - 1, d));
368
- const ts = dt.getTime();
369
- return Number.isFinite(ts) ? ts : null;
370
- };
371
- const fromTs = parseDay(qFrom);
372
- const toTs = parseDay(qTo);
373
-
374
- // Default: last 12 months (UTC)
375
- const now = new Date();
376
- const defaultTo = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
377
- const defaultFrom = Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth() + 1, 1);
378
- const minTs = fromTs ?? defaultFrom;
379
- const maxTs = toTs ?? defaultTo;
380
-
381
- const ids = await dbLayer.listAllReservationIds(100000);
382
- const rows = [];
383
- const byItem = new Map();
384
-
385
- for (const rid of ids) {
386
- const r = await dbLayer.getReservation(rid);
387
- if (!r) continue;
388
- if (String(r.status) !== 'paid') continue;
389
- if (r.accPurgedAt) continue;
390
- const start = parseInt(r.start, 10);
391
- if (!Number.isFinite(start)) continue;
392
- if (start < minTs || start >= maxTs) continue;
393
-
394
- const itemNames = Array.isArray(r.itemNames) && r.itemNames.length
395
- ? r.itemNames
396
- : (r.itemName ? [r.itemName] : []);
397
-
398
- const total = Number(r.total) || 0;
399
- const startDate = formatFR(r.start);
400
- const endDate = formatFR(r.end);
401
-
402
- rows.push({
403
- rid: r.rid,
404
- uid: r.uid,
405
- username: r.username || '',
406
- start: r.start,
407
- end: r.end,
408
- startDate,
409
- endDate,
410
- items: itemNames,
411
- total,
412
- paidAt: r.paidAt || '',
413
- });
414
-
415
- for (const name of itemNames) {
416
- const key = String(name || '').trim();
417
- if (!key) continue;
418
- const cur = byItem.get(key) || { item: key, count: 0, total: 0 };
419
- cur.count += 1;
420
- cur.total += total;
421
- byItem.set(key, cur);
422
- }
423
- }
424
-
425
- const summary = Array.from(byItem.values()).sort((a, b) => b.count - a.count);
426
- rows.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
427
-
428
- return res.json({
429
- ok: true,
430
- from: new Date(minTs).toISOString().slice(0, 10),
431
- to: new Date(maxTs).toISOString().slice(0, 10),
432
- summary,
433
- rows,
434
- });
435
- };
436
-
437
- admin.exportAccountingCsv = async function (req, res) {
438
- // Reuse the same logic and emit a CSV.
439
- const fakeRes = { json: (x) => x };
440
- const data = await admin.getAccounting(req, fakeRes);
441
- // If getAccounting returned via res.json, data is undefined; rebuild by calling logic directly.
442
- // Easiest: call getAccounting's internals by fetching the endpoint logic via HTTP is not possible here.
443
- // So we re-run getAccounting but capture output by monkeypatching.
444
- let payload;
445
- await admin.getAccounting(req, { json: (x) => { payload = x; return x; } });
446
- if (!payload || !payload.ok) {
447
- return res.status(500).send('error');
448
- }
449
- const escape = (v) => {
450
- const s = String(v ?? '');
451
- if (/[\n\r,\"]/g.test(s)) {
452
- return '"' + s.replace(/"/g, '""') + '"';
453
- }
454
- return s;
455
- };
456
- const lines = [];
457
- lines.push(['rid', 'username', 'uid', 'start', 'end', 'items', 'total', 'paidAt'].map(escape).join(','));
458
- for (const r of payload.rows || []) {
459
- lines.push([
460
- r.rid,
461
- r.username,
462
- r.uid,
463
- r.startDate,
464
- r.endDate,
465
- (Array.isArray(r.items) ? r.items.join(' | ') : ''),
466
- (Number(r.total) || 0).toFixed(2),
467
- r.paidAt ? new Date(parseInt(r.paidAt, 10)).toISOString() : '',
468
- ].map(escape).join(','));
469
- }
470
- const csv = lines.join('\n');
471
- res.setHeader('Content-Type', 'text/csv; charset=utf-8');
472
- res.setHeader('Content-Disposition', 'attachment; filename="calendar-onekite-accounting.csv"');
473
- return res.send(csv);
474
- };
475
-
476
-
477
- admin.purgeAccounting = async function (req, res) {
478
- const qFrom = String((req.query && req.query.from) || '').trim();
479
- const qTo = String((req.query && req.query.to) || '').trim();
480
- const parseDay = (s) => {
481
- if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
482
- const [y, m, d] = s.split('-').map((x) => parseInt(x, 10));
483
- const dt = new Date(Date.UTC(y, m - 1, d));
484
- const ts = dt.getTime();
485
- return Number.isFinite(ts) ? ts : null;
486
- };
487
- const fromTs = parseDay(qFrom);
488
- const toTs = parseDay(qTo);
489
-
490
- const now = new Date();
491
- const defaultTo = Date.UTC(now.getUTCFullYear() + 100, 0, 1); // far future
492
- const defaultFrom = Date.UTC(1970, 0, 1);
493
- const minTs = fromTs ?? defaultFrom;
494
- const maxTs = toTs ?? defaultTo;
495
-
496
- const ids = await dbLayer.listAllReservationIds(200000);
497
- let purged = 0;
498
- const ts = Date.now();
499
-
500
- for (const rid of ids) {
501
- const r = await dbLayer.getReservation(rid);
502
- if (!r) continue;
503
- if (String(r.status) !== 'paid') continue;
504
- // Already purged from accounting
505
- if (r.accPurgedAt) continue;
506
- const start = parseInt(r.start, 10);
507
- if (!Number.isFinite(start)) continue;
508
- if (start < minTs || start >= maxTs) continue;
509
-
510
- r.accPurgedAt = ts;
511
- await dbLayer.saveReservation(r);
512
- purged++;
513
- }
514
-
515
- return res.json({ ok: true, purged });
516
- };
49
+ }
517
50
 
518
51
 
519
- module.exports = admin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version": "11.1.95",
3
+ "version": "11.1.97",
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/public/client.js CHANGED
@@ -189,6 +189,33 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
189
189
  openMapViewer('Adresse', addr, lat, lon);
190
190
  });
191
191
 
192
+
193
+ // Close any open Bootbox modal when navigating to a user profile from within a modal
194
+ document.addEventListener('click', (ev) => {
195
+ const a = ev.target && ev.target.closest ? ev.target.closest('a.onekite-user-link') : null;
196
+ if (!a) return;
197
+ const inModal = !!(a.closest && a.closest('.modal, .bootbox'));
198
+ if (!inModal) return;
199
+ const href = a.getAttribute('href');
200
+ if (!href) return;
201
+ ev.preventDefault();
202
+ try { bootbox.hideAll(); } catch (e) {}
203
+ setTimeout(() => { window.location.href = href; }, 50);
204
+ });
205
+
206
+ // Close any open Bootbox modal when navigating to a user profile from within a modal
207
+ document.addEventListener('click', (ev) => {
208
+ const a = ev.target && ev.target.closest ? ev.target.closest('a.onekite-user-link') : null;
209
+ if (!a) return;
210
+ const inModal = !!(a.closest && a.closest('.modal, .bootbox'));
211
+ if (!inModal) return;
212
+ const href = a.getAttribute('href');
213
+ if (!href) return;
214
+ ev.preventDefault();
215
+ try { bootbox.hideAll(); } catch (e) {}
216
+ setTimeout(() => { window.location.href = href; }, 50);
217
+ });
218
+
192
219
  function statusLabel(s) {
193
220
  const map = {
194
221
  pending: 'En attente',
@@ -228,7 +255,14 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
228
255
  ...opts,
229
256
  });
230
257
  if (!res.ok) {
231
- throw new Error(`${res.status}`);
258
+ let payload = null;
259
+ try {
260
+ payload = await res.json();
261
+ } catch (e) {}
262
+ const err = new Error(`${res.status}`);
263
+ err.status = res.status;
264
+ err.payload = payload;
265
+ throw err;
232
266
  }
233
267
  return await res.json();
234
268
  }
@@ -634,7 +668,8 @@ function toDatetimeLocalValue(date) {
634
668
  const canCreateSpecial = !!caps.canCreateSpecial;
635
669
  const canDeleteSpecial = !!caps.canDeleteSpecial;
636
670
 
637
- let setMode('reservation');
671
+ // Current creation mode: reservation (location) or special (event)
672
+ let mode = 'reservation';
638
673
  function refreshDesktopModeButton() {
639
674
  try {
640
675
  const btn = document.querySelector('#onekite-calendar .fc-newSpecial-button');
@@ -818,13 +853,22 @@ function toDatetimeLocalValue(date) {
818
853
  calendar.unselect();
819
854
  isDialogOpen = false;
820
855
  } catch (e) {
821
- const code = String(e && e.message || '');
856
+ const code = String((e && (e.status || e.message)) || '');
857
+ const payload = e && e.payload ? e.payload : null;
858
+
822
859
  if (code === '403') {
823
- showAlert('error', 'Impossible de créer la demande : droits insuffisants (groupe).');
860
+ const msg = payload && (payload.message || payload.error || payload.msg) ? String(payload.message || payload.error || payload.msg) : '';
861
+ const c = payload && payload.code ? String(payload.code) : '';
862
+ if (c === 'NOT_MEMBER' || /adh(é|e)rent/i.test(msg) || /membership/i.test(msg)) {
863
+ showAlert('error', msg || 'Vous devez être adhérent pour pouvoir effectuer une réservation.');
864
+ } else {
865
+ showAlert('error', msg || 'Impossible de créer la demande : droits insuffisants (groupe).');
866
+ }
824
867
  } else if (code === '409') {
825
868
  showAlert('error', 'Impossible : au moins un matériel est déjà réservé ou en attente sur cette période.');
826
869
  } else {
827
- showAlert('error', 'Erreur lors de la création de la demande.');
870
+ const msg = payload && (payload.message || payload.error || payload.msg) ? String(payload.message || payload.error || payload.msg) : '';
871
+ showAlert('error', msg || 'Erreur lors de la création de la demande.');
828
872
  }
829
873
  calendar.unselect();
830
874
  isDialogOpen = false;
@@ -833,6 +877,39 @@ function toDatetimeLocalValue(date) {
833
877
  dateClick: async function (info) {
834
878
  // One-day selection convenience
835
879
  const start = info.date;
880
+
881
+ // In "special event" mode, a simple click should propose a one-day event (not two days in the modal)
882
+ if (mode === 'special' && canCreateSpecial) {
883
+ if (isDialogOpen) {
884
+ return;
885
+ }
886
+ isDialogOpen = true;
887
+ try {
888
+ // Default to a same-day event: 00:00 -> 23:59
889
+ const end = new Date(start.getTime() + (23 * 60 + 59) * 60 * 1000);
890
+ const payload = await openSpecialEventDialog({ start, end, allDay: true });
891
+ setMode('reservation');
892
+ if (!payload) {
893
+ isDialogOpen = false;
894
+ return;
895
+ }
896
+ await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
897
+ method: 'POST',
898
+ body: JSON.stringify(payload),
899
+ }).catch(async () => {
900
+ return await fetchJson('/api/plugins/calendar-onekite/special-events', {
901
+ method: 'POST',
902
+ body: JSON.stringify(payload),
903
+ });
904
+ });
905
+ showAlert('success', 'Évènement créé.');
906
+ calendar.refetchEvents();
907
+ } finally {
908
+ isDialogOpen = false;
909
+ }
910
+ return;
911
+ }
912
+
836
913
  const end = new Date(start.getTime() + 24 * 60 * 60 * 1000);
837
914
  calendar.select(start, end);
838
915
  },
@@ -843,7 +920,7 @@ function toDatetimeLocalValue(date) {
843
920
  if (p.type === 'special') {
844
921
  const username = String(p.username || '').trim();
845
922
  const userLine = username
846
- ? `<div class="mb-2"><strong>Créé par</strong><br><a href="${window.location.origin}/user/${encodeURIComponent(username)}">${escapeHtml(username)}</a></div>`
923
+ ? `<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>`
847
924
  : '';
848
925
  const addr = String(p.pickupAddress || '').trim();
849
926
  const lat = Number(p.pickupLat);
@@ -895,7 +972,7 @@ function toDatetimeLocalValue(date) {
895
972
  // Reserved-by line (user profile link)
896
973
  const username = String(p.username || p.user || p.reservedBy || p.ownerUsername || '').trim();
897
974
  const userLine = username
898
- ? `<div class="mb-2"><strong>Réservée par</strong><br><a href="${window.location.origin}/user/${encodeURIComponent(username)}">${escapeHtml(username)}</a></div>`
975
+ ? `<div class="mb-2"><strong>Réservée par</strong><br><a class="onekite-user-link" href="${window.location.origin}/user/${encodeURIComponent(username)}">${escapeHtml(username)}</a></div>`
899
976
  : '';
900
977
  const itemsHtml = (() => {
901
978
  const names = Array.isArray(p.itemNames) ? p.itemNames : (typeof p.itemNames === 'string' && p.itemNames.trim() ? p.itemNames.split(',').map(s=>s.trim()).filter(Boolean) : (p.itemName ? [p.itemName] : []));
@@ -908,7 +985,7 @@ function toDatetimeLocalValue(date) {
908
985
 
909
986
  const approvedBy = String(p.approvedByUsername || '').trim();
910
987
  const validatedByHtml = approvedBy
911
- ? `<div class=\"mb-2\"><strong>Validée par</strong><br><a href=\"https://www.onekite.com/user/${encodeURIComponent(approvedBy)}\">${escapeHtml(approvedBy)}</a></div>`
988
+ ? `<div class=\"mb-2\"><strong>Validée par</strong><br><a class=\"onekite-user-link\" href=\"${window.location.origin}/user/${encodeURIComponent(approvedBy)}\">${escapeHtml(approvedBy)}</a></div>`
912
989
  : '';
913
990
 
914
991
  // Pickup details (address / time / notes) shown once validated