nodebb-plugin-calendar-onekite 11.1.76 → 11.1.78

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
@@ -21,28 +21,39 @@ function formatFR(tsOrIso) {
21
21
  async function sendEmail(template, toEmail, subject, data) {
22
22
  if (!toEmail) return;
23
23
  try {
24
- // Some NodeBB emailer implementations only read subject from the data object,
25
- // others accept it as an explicit argument. To maximize compatibility we set both.
26
24
  const dataWithSubject = Object.assign({}, data || {}, subject ? { subject, _subject: subject } : {});
25
+
26
+ // Same compatibility strategy as lib/api.js
27
+ const attempts = [];
27
28
  if (typeof emailer.sendToEmail === 'function') {
28
- if (emailer.sendToEmail.length >= 4) {
29
- await emailer.sendToEmail(template, toEmail, subject, dataWithSubject);
29
+ if (emailer.sendToEmail.length === 3) {
30
+ attempts.push(() => emailer.sendToEmail(template, toEmail, dataWithSubject));
30
31
  } else {
31
- await emailer.sendToEmail(template, toEmail, dataWithSubject);
32
+ attempts.push(() => emailer.sendToEmail(template, toEmail, subject, dataWithSubject));
33
+ attempts.push(() => emailer.sendToEmail(template, toEmail, dataWithSubject, subject));
34
+ attempts.push(() => emailer.sendToEmail(template, toEmail, dataWithSubject));
32
35
  }
33
- return;
34
36
  }
35
37
  if (typeof emailer.send === 'function') {
36
- if (emailer.send.length >= 4) {
37
- await emailer.send(template, toEmail, subject, dataWithSubject);
38
- return;
39
- }
40
38
  if (emailer.send.length === 3) {
41
- await emailer.send(template, toEmail, dataWithSubject);
39
+ attempts.push(() => emailer.send(template, toEmail, dataWithSubject));
40
+ } else {
41
+ attempts.push(() => emailer.send(template, toEmail, subject, dataWithSubject));
42
+ attempts.push(() => emailer.send(template, toEmail, dataWithSubject, subject));
43
+ attempts.push(() => emailer.send(template, toEmail, dataWithSubject));
44
+ }
45
+ }
46
+
47
+ let lastErr = null;
48
+ for (const fn of attempts) {
49
+ try {
50
+ await fn();
42
51
  return;
52
+ } catch (e) {
53
+ lastErr = e;
43
54
  }
44
- await emailer.send(template, toEmail, subject, dataWithSubject);
45
55
  }
56
+ if (lastErr) throw lastErr;
46
57
  } catch (err) {
47
58
  console.warn('[calendar-onekite] Failed to send email', { template, toEmail, err: String(err && err.message || err) });
48
59
  }
package/lib/api.js CHANGED
@@ -15,27 +15,43 @@ const helloasso = require('./helloasso');
15
15
  async function sendEmail(template, toEmail, subject, data) {
16
16
  if (!toEmail) return;
17
17
  const emailer = require.main.require('./src/emailer');
18
- const dataWithSubject = Object.assign({}, data || {}, subject ? { subject } : {});
18
+ const dataWithSubject = Object.assign({}, data || {}, subject ? { subject, _subject: subject } : {});
19
19
  try {
20
- // NodeBB's Emailer API differs across versions; the most reliable approach is to pass `subject` inside `data`.
20
+ // NodeBB Emailer signatures vary across versions/plugins.
21
+ // IMPORTANT: some implementations accept (template, email, data) and will NOT throw if we pass a string as `data`,
22
+ // which can lead to "sent but empty subject". So we prefer the (template, email, data) call first when arity hints it.
23
+ const attempts = [];
24
+
21
25
  if (typeof emailer.sendToEmail === 'function') {
22
- // Prefer 3-args (template, email, data) when possible
23
- if (emailer.sendToEmail.length >= 3) {
24
- await emailer.sendToEmail(template, toEmail, dataWithSubject);
25
- return;
26
+ if (emailer.sendToEmail.length === 3) {
27
+ attempts.push(() => emailer.sendToEmail(template, toEmail, dataWithSubject));
28
+ } else {
29
+ // Try the common forms that include subject
30
+ attempts.push(() => emailer.sendToEmail(template, toEmail, subject, dataWithSubject));
31
+ attempts.push(() => emailer.sendToEmail(template, toEmail, dataWithSubject, subject));
32
+ attempts.push(() => emailer.sendToEmail(template, toEmail, dataWithSubject));
26
33
  }
27
- // Fallback
28
- await emailer.sendToEmail(template, toEmail, subject, data);
29
- return;
30
34
  }
31
35
  if (typeof emailer.send === 'function') {
32
- if (emailer.send.length >= 3) {
33
- await emailer.send(template, toEmail, dataWithSubject);
36
+ if (emailer.send.length === 3) {
37
+ attempts.push(() => emailer.send(template, toEmail, dataWithSubject));
38
+ } else {
39
+ attempts.push(() => emailer.send(template, toEmail, subject, dataWithSubject));
40
+ attempts.push(() => emailer.send(template, toEmail, dataWithSubject, subject));
41
+ attempts.push(() => emailer.send(template, toEmail, dataWithSubject));
42
+ }
43
+ }
44
+
45
+ let lastErr = null;
46
+ for (const fn of attempts) {
47
+ try {
48
+ await fn();
34
49
  return;
50
+ } catch (e) {
51
+ lastErr = e;
35
52
  }
36
- await emailer.send(template, toEmail, subject, data);
37
- return;
38
53
  }
54
+ if (lastErr) throw lastErr;
39
55
  } catch (err) {
40
56
  // eslint-disable-next-line no-console
41
57
  console.warn('[calendar-onekite] Failed to send email', { template, toEmail, err: String((err && err.message) || err) });
package/lib/scheduler.js CHANGED
@@ -58,21 +58,38 @@ async function processAwaitingPayment() {
58
58
  async function sendEmail(template, toEmail, subject, data) {
59
59
  if (!toEmail) return;
60
60
  try {
61
+ const dataWithSubject = Object.assign({}, data || {}, subject ? { subject, _subject: subject } : {});
62
+
63
+ const attempts = [];
61
64
  if (typeof emailer.sendToEmail === 'function') {
62
- await emailer.sendToEmail(template, toEmail, subject, data);
63
- return;
65
+ if (emailer.sendToEmail.length === 3) {
66
+ attempts.push(() => emailer.sendToEmail(template, toEmail, dataWithSubject));
67
+ } else {
68
+ attempts.push(() => emailer.sendToEmail(template, toEmail, subject, dataWithSubject));
69
+ attempts.push(() => emailer.sendToEmail(template, toEmail, dataWithSubject, subject));
70
+ attempts.push(() => emailer.sendToEmail(template, toEmail, dataWithSubject));
71
+ }
64
72
  }
65
73
  if (typeof emailer.send === 'function') {
66
- if (emailer.send.length >= 4) {
67
- await emailer.send(template, toEmail, subject, data);
68
- return;
69
- }
70
74
  if (emailer.send.length === 3) {
71
- await emailer.send(template, toEmail, data);
75
+ attempts.push(() => emailer.send(template, toEmail, dataWithSubject));
76
+ } else {
77
+ attempts.push(() => emailer.send(template, toEmail, subject, dataWithSubject));
78
+ attempts.push(() => emailer.send(template, toEmail, dataWithSubject, subject));
79
+ attempts.push(() => emailer.send(template, toEmail, dataWithSubject));
80
+ }
81
+ }
82
+
83
+ let lastErr = null;
84
+ for (const fn of attempts) {
85
+ try {
86
+ await fn();
72
87
  return;
88
+ } catch (e) {
89
+ lastErr = e;
73
90
  }
74
- await emailer.send(template, toEmail, subject, data);
75
91
  }
92
+ if (lastErr) throw lastErr;
76
93
  } catch (err) {
77
94
  // eslint-disable-next-line no-console
78
95
  console.warn('[calendar-onekite] Failed to send email (scheduler)', { template, toEmail, err: String(err && err.message || err) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version": "11.1.76",
3
+ "version": "11.1.78",
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,15 +27,11 @@
27
27
  "public": "./public"
28
28
  },
29
29
  "templates": "./templates",
30
- "modules": {
31
- "../admin/plugins/calendar-onekite.js": "./public/admin.js",
32
- "admin/plugins/calendar-onekite": "./public/admin.js"
33
- },
34
30
  "scripts": [
35
31
  "public/client.js"
36
32
  ],
37
33
  "acpScripts": [
38
34
  "public/admin.js"
39
35
  ],
40
- "version": "1.0.53"
41
- }
36
+ "version": "1.0.55"
37
+ }
@@ -0,0 +1,761 @@
1
+
2
+ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts, bootbox) {
3
+ 'use strict';
4
+
5
+ function showAlert(type, msg) {
6
+ try {
7
+ if (alerts && typeof alerts[type] === 'function') {
8
+ alerts[type](msg);
9
+ return;
10
+ }
11
+ } catch (e) {}
12
+ alert(msg);
13
+ }
14
+
15
+ async function fetchJson(url, opts) {
16
+ const res = await fetch(url, {
17
+ credentials: 'same-origin',
18
+ headers: (() => {
19
+ const headers = { 'Content-Type': 'application/json' };
20
+ const token =
21
+ (window.config && (window.config.csrf_token || window.config.csrfToken)) ||
22
+ (window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
23
+ (document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
24
+ (document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
25
+ (typeof app !== 'undefined' && app && app.csrfToken) ||
26
+ null;
27
+ if (token) headers['x-csrf-token'] = token;
28
+ return headers;
29
+ })(),
30
+ ...opts,
31
+ });
32
+
33
+
34
+ if (!res.ok) {
35
+ // NodeBB versions differ: some expose admin APIs under /api/admin instead of /api/v3/admin
36
+ if (res.status === 404 && typeof url === 'string' && url.includes('/api/v3/admin/')) {
37
+ const altUrl = url.replace('/api/v3/admin/', '/api/admin/');
38
+ const res2 = await fetch(altUrl, {
39
+ credentials: 'same-origin',
40
+ headers: (() => {
41
+ const headers = { 'Content-Type': 'application/json' };
42
+ const token =
43
+ (window.config && (window.config.csrf_token || window.config.csrfToken)) ||
44
+ (window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
45
+ (document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
46
+ (document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
47
+ (typeof app !== 'undefined' && app && app.csrfToken) ||
48
+ null;
49
+ if (token) headers['x-csrf-token'] = token;
50
+ return headers;
51
+ })(),
52
+ ...opts,
53
+ });
54
+ if (res2.ok) {
55
+ return await res2.json();
56
+ }
57
+ }
58
+ const text = await res.text().catch(() => '');
59
+ throw new Error(`${res.status} ${text}`);
60
+ }
61
+ return await res.json();
62
+ }
63
+
64
+ // Leaflet (OpenStreetMap) helpers - loaded lazily only when needed.
65
+ let leafletPromise = null;
66
+ function loadLeaflet() {
67
+ if (leafletPromise) return leafletPromise;
68
+ leafletPromise = new Promise((resolve, reject) => {
69
+ try {
70
+ if (window.L && window.L.map) {
71
+ resolve(window.L);
72
+ return;
73
+ }
74
+ const cssId = 'onekite-leaflet-css';
75
+ const jsId = 'onekite-leaflet-js';
76
+ if (!document.getElementById(cssId)) {
77
+ const link = document.createElement('link');
78
+ link.id = cssId;
79
+ link.rel = 'stylesheet';
80
+ link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
81
+ document.head.appendChild(link);
82
+ }
83
+ const existing = document.getElementById(jsId);
84
+ if (existing) {
85
+ existing.addEventListener('load', () => resolve(window.L));
86
+ existing.addEventListener('error', () => reject(new Error('leaflet-load-failed')));
87
+ return;
88
+ }
89
+ const script = document.createElement('script');
90
+ script.id = jsId;
91
+ script.async = true;
92
+ script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
93
+ script.onload = () => resolve(window.L);
94
+ script.onerror = () => reject(new Error('leaflet-load-failed'));
95
+ document.head.appendChild(script);
96
+ } catch (e) {
97
+ reject(e);
98
+ }
99
+ });
100
+ return leafletPromise;
101
+ }
102
+
103
+ async function geocodeAddress(query) {
104
+ const q = String(query || '').trim();
105
+ if (!q) return null;
106
+ const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(q)}`;
107
+ const res = await fetch(url, {
108
+ method: 'GET',
109
+ headers: {
110
+ 'Accept': 'application/json',
111
+ 'Accept-Language': 'fr',
112
+ },
113
+ });
114
+ if (!res.ok) return null;
115
+ const arr = await res.json();
116
+ if (!Array.isArray(arr) || !arr.length) return null;
117
+ const hit = arr[0];
118
+ const lat = Number(hit.lat);
119
+ const lon = Number(hit.lon);
120
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
121
+ return { lat, lon, displayName: hit.display_name || q };
122
+ }
123
+
124
+ function formToObject(form) {
125
+ const out = {};
126
+ new FormData(form).forEach((v, k) => {
127
+ out[k] = String(v);
128
+ });
129
+ return out;
130
+ }
131
+
132
+ function fillForm(form, data) {
133
+ [...form.elements].forEach((el) => {
134
+ if (!el.name) return;
135
+ if (Object.prototype.hasOwnProperty.call(data, el.name)) {
136
+ el.value = data[el.name];
137
+ }
138
+ });
139
+ }
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
+
184
+
185
+ function renderPending(list) {
186
+ const wrap = document.getElementById('onekite-pending');
187
+ if (!wrap) return;
188
+ wrap.innerHTML = '';
189
+
190
+ if (!list || !list.length) {
191
+ wrap.innerHTML = '<div class="text-muted">Aucune demande.</div>';
192
+ return;
193
+ }
194
+
195
+ const fmtFR = (ts) => {
196
+ const d = new Date(parseInt(ts, 10));
197
+ const dd = String(d.getDate()).padStart(2, '0');
198
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
199
+ const yyyy = d.getFullYear();
200
+ const hh = String(d.getHours()).padStart(2, '0');
201
+ const mi = String(d.getMinutes()).padStart(2, '0');
202
+ return `${dd}/${mm}/${yyyy} ${hh}:${mi}`;
203
+ };
204
+
205
+ for (const r of list) {
206
+ const created = r.createdAt ? fmtFR(r.createdAt) : '';
207
+ const itemNames = Array.isArray(r.itemNames) && r.itemNames.length ? r.itemNames : [r.itemName || r.itemId].filter(Boolean);
208
+ const itemsHtml = `<ul style="margin: 0 0 10px 18px;">${itemNames.map(n => `<li>${escapeHtml(String(n))}</li>`).join('')}</ul>`;
209
+ const div = document.createElement('div');
210
+ div.className = 'list-group-item';
211
+ div.innerHTML = `
212
+ <div class="d-flex justify-content-between align-items-start gap-2">
213
+ <div style="min-width: 0;">
214
+ <div><strong>${itemsHtml || escapeHtml(r.itemName || '')}</strong></div>
215
+ <div class="text-muted" style="font-size: 12px;">Créée: ${escapeHtml(created)}</div>
216
+ <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>
217
+ </div>
218
+ <div class="d-flex gap-2">
219
+ <button class="btn btn-outline-danger btn-sm">Rejeter</button>
220
+ <button class="btn btn-success btn-sm">Valider</button>
221
+ </div>
222
+ </div>
223
+ `;
224
+ const [refuseBtn, approveBtn] = div.querySelectorAll('button');
225
+ refuseBtn.addEventListener('click', async () => {
226
+ if (!confirm('Rejeter cette demande ?')) return;
227
+ try {
228
+ // Server route: PUT /api/v3/admin/plugins/calendar-onekite/reservations/:rid/refuse
229
+ await fetchJson(`/api/v3/admin/plugins/calendar-onekite/reservations/${encodeURIComponent(r.rid)}/refuse`, { method: 'PUT' });
230
+ showAlert('success', 'Demande rejetée.');
231
+ await loadPending();
232
+ } catch (e) {
233
+ showAlert('error', 'Rejet impossible.');
234
+ }
235
+ });
236
+
237
+ approveBtn.addEventListener('click', async () => {
238
+ const itemNamesModal = Array.isArray(r.itemNames) && r.itemNames.length ? r.itemNames : [r.itemName || r.itemId].filter(Boolean);
239
+ const itemsHtmlModal = `<ul style="margin: 0 0 10px 18px;">${itemNamesModal.map(n => `<li>${escapeHtml(String(n))}</li>`).join('')}</ul>`;
240
+
241
+ const opts = (() => {
242
+ const out = [];
243
+ for (let h = 7; h < 24; h++) {
244
+ for (let m = 0; m < 60; m += 5) {
245
+ out.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
246
+ }
247
+ }
248
+ return out;
249
+ })().map(t => `<option value="${t}">${t}</option>`).join('');
250
+
251
+ const html = `
252
+ <div class="mb-2"><strong>Matériel</strong>${itemsHtmlModal}</div>
253
+ <div class="mb-3">
254
+ <label class="form-label">Adresse de récupération</label>
255
+ <div class="input-group">
256
+ <input type="text" class="form-control" id="onekite-pickup-address" placeholder="Adresse complète" />
257
+ <button class="btn btn-outline-secondary" type="button" id="onekite-geocode">Rechercher</button>
258
+ </div>
259
+ <div id="onekite-map" style="height:220px; border:1px solid #ddd; border-radius:6px; margin-top:0.5rem;"></div>
260
+ <div class="form-text">Vous pouvez déplacer le marqueur pour ajuster la position.</div>
261
+ <input type="hidden" id="onekite-pickup-lat" />
262
+ <input type="hidden" id="onekite-pickup-lon" />
263
+ </div>
264
+ <div class="mb-3">
265
+ <label class="form-label">Notes (facultatif)</label>
266
+ <textarea class="form-control" id="onekite-notes" rows="3" placeholder="Ex: code portail, personne à contacter, horaires..."></textarea>
267
+ </div>
268
+ <div class="mb-2">
269
+ <label class="form-label">Heure de récupération</label>
270
+ <select class="form-select" id="onekite-pickup-time">${opts}</select>
271
+ </div>
272
+ `;
273
+
274
+ const dlg = bootbox.dialog({
275
+ title: 'Valider la demande',
276
+ message: html,
277
+ buttons: {
278
+ cancel: { label: 'Annuler', className: 'btn-secondary' },
279
+ ok: {
280
+ label: 'Valider',
281
+ className: 'btn-success',
282
+ callback: async () => {
283
+ try {
284
+ const pickupAddress = (document.getElementById('onekite-pickup-address')?.value || '').trim();
285
+ const notes = (document.getElementById('onekite-notes')?.value || '').trim();
286
+ const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
287
+ const pickupLat = (document.getElementById('onekite-pickup-lat')?.value || '').trim();
288
+ const pickupLon = (document.getElementById('onekite-pickup-lon')?.value || '').trim();
289
+ // Server route: PUT /api/v3/admin/plugins/calendar-onekite/reservations/:rid/approve
290
+ await fetchJson(`/api/v3/admin/plugins/calendar-onekite/reservations/${encodeURIComponent(r.rid)}/approve`, { method: 'PUT', body: JSON.stringify({ pickupAddress, notes, pickupTime, pickupLat, pickupLon }) });
291
+ showAlert('success', 'Demande validée.');
292
+ await loadPending();
293
+ } catch (e) {
294
+ showAlert('error', 'Validation impossible.');
295
+ }
296
+ },
297
+ },
298
+ },
299
+ });
300
+
301
+ dlg.on('shown.bs.modal', async () => {
302
+ try {
303
+ const L = await loadLeaflet();
304
+ const mapEl = document.getElementById('onekite-map');
305
+ if (!mapEl) return;
306
+ const map = L.map(mapEl, { scrollWheelZoom: false });
307
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
308
+ maxZoom: 19,
309
+ attribution: '&copy; OpenStreetMap',
310
+ }).addTo(map);
311
+ map.setView([46.7, 2.5], 5);
312
+ let marker = null;
313
+ function setMarker(lat, lon, zoom) {
314
+ const ll = [lat, lon];
315
+ if (!marker) {
316
+ marker = L.marker(ll, { draggable: true }).addTo(map);
317
+ marker.on('dragend', () => {
318
+ const p2 = marker.getLatLng();
319
+ document.getElementById('onekite-pickup-lat').value = String(p2.lat);
320
+ document.getElementById('onekite-pickup-lon').value = String(p2.lng);
321
+ });
322
+ } else {
323
+ marker.setLatLng(ll);
324
+ }
325
+ document.getElementById('onekite-pickup-lat').value = String(lat);
326
+ document.getElementById('onekite-pickup-lon').value = String(lon);
327
+ if (zoom) map.setView(ll, zoom); else map.panTo(ll);
328
+ }
329
+ map.on('click', (e) => {
330
+ if (e && e.latlng) setMarker(e.latlng.lat, e.latlng.lng, map.getZoom());
331
+ });
332
+ const geocodeBtn = document.getElementById('onekite-geocode');
333
+ const addrInput = document.getElementById('onekite-pickup-address');
334
+ async function runGeocode() {
335
+ try {
336
+ const addr = (addrInput?.value || '').trim();
337
+ if (!addr) return;
338
+ const hit = await geocodeAddress(addr);
339
+ if (!hit) {
340
+ showAlert('error', 'Adresse introuvable.');
341
+ return;
342
+ }
343
+ setMarker(hit.lat, hit.lon, 16);
344
+ } catch (e) {
345
+ showAlert('error', 'Recherche adresse impossible.');
346
+ }
347
+ }
348
+ if (geocodeBtn) geocodeBtn.addEventListener('click', runGeocode);
349
+ if (addrInput) {
350
+ addrInput.addEventListener('keydown', (e) => {
351
+ if (e.key === 'Enter') {
352
+ e.preventDefault();
353
+ runGeocode();
354
+ }
355
+ });
356
+ }
357
+ } catch (e) {}
358
+ });
359
+ return false;
360
+ });
361
+
362
+ wrap.appendChild(div);
363
+ }
364
+ }
365
+
366
+
367
+ function timeOptions(stepMinutes) {
368
+ const step = stepMinutes || 5;
369
+ const out = [];
370
+ for (let h = 7; h < 24; h++) {
371
+ for (let m = 0; m < 60; m += step) {
372
+ const hh = String(h).padStart(2, '0');
373
+ const mm = String(m).padStart(2, '0');
374
+ out.push(`${hh}:${mm}`);
375
+ }
376
+ }
377
+ return out;
378
+ }
379
+
380
+ function escapeHtml(s) {
381
+ return String(s || '')
382
+ .replace(/&/g, '&amp;')
383
+ .replace(/</g, '&lt;')
384
+ .replace(/>/g, '&gt;')
385
+ .replace(/"/g, '&quot;')
386
+ .replace(/'/g, '&#39;');
387
+ }
388
+
389
+ async function loadSettings() {
390
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/settings');
391
+ }
392
+
393
+ async function saveSettings(payload) {
394
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/settings', {
395
+ method: 'PUT',
396
+ body: JSON.stringify(payload),
397
+ });
398
+ }
399
+
400
+ async function loadPending() {
401
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/pending');
402
+ }
403
+
404
+ async function approve(rid, payload) {
405
+ return await fetchJson(`/api/v3/admin/plugins/calendar-onekite/reservations/${rid}/approve`, {
406
+ method: 'PUT',
407
+ body: JSON.stringify(payload || {}),
408
+ });
409
+ }
410
+
411
+ async function refuse(rid) {
412
+ return await fetchJson(`/api/v3/admin/plugins/calendar-onekite/reservations/${rid}/refuse`, { method: 'PUT' });
413
+ }
414
+
415
+ async function purge(year) {
416
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/purge', {
417
+ method: 'POST',
418
+ body: JSON.stringify({ year }),
419
+ });
420
+ }
421
+
422
+ async function purgeSpecialEvents(year) {
423
+ try {
424
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/special-events/purge', {
425
+ method: 'POST',
426
+ body: JSON.stringify({ year }),
427
+ });
428
+ } catch (e) {
429
+ return await fetchJson('/api/admin/plugins/calendar-onekite/special-events/purge', {
430
+ method: 'POST',
431
+ body: JSON.stringify({ year }),
432
+ });
433
+ }
434
+ }
435
+
436
+ async function debugHelloAsso() {
437
+ try {
438
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/debug');
439
+ } catch (e) {
440
+ return await fetchJson('/api/admin/plugins/calendar-onekite/debug');
441
+ }
442
+ }
443
+
444
+ async function loadAccounting(from, to) {
445
+ const params = new URLSearchParams();
446
+ if (from) params.set('from', from);
447
+ if (to) params.set('to', to);
448
+ const qs = params.toString();
449
+ return await fetchJson(`/api/v3/admin/plugins/calendar-onekite/accounting${qs ? `?${qs}` : ''}`);
450
+ }
451
+
452
+ async function init() {
453
+ const form = document.getElementById('onekite-settings-form');
454
+ if (!form) return;
455
+
456
+ // Load settings
457
+ try {
458
+ const s = await loadSettings();
459
+ fillForm(form, s || {});
460
+
461
+ // Ensure default creator group prefix appears in the ACP field
462
+ const y = new Date().getFullYear();
463
+ const defaultGroup = `onekite-ffvl-${y}`;
464
+ const cgEl = form.querySelector('[name="creatorGroups"]');
465
+ if (cgEl) {
466
+ cgEl.value = normalizeCsvGroupsWithDefault(cgEl.value, defaultGroup);
467
+ }
468
+ } catch (e) {
469
+ showAlert('error', 'Impossible de charger les paramètres.');
470
+ }
471
+
472
+ // Load pending
473
+ async function refreshPending() {
474
+ try {
475
+ const p = await loadPending();
476
+ renderPending(p);
477
+ } catch (e) {
478
+ showAlert('error', 'Impossible de charger les demandes en attente.');
479
+ }
480
+ }
481
+
482
+ await refreshPending();
483
+
484
+ async function doSave(ev) {
485
+ // Guard against duplicate handlers (some themes bind multiple save buttons)
486
+ // and against rapid double-clicks.
487
+ if (doSave._inFlight) {
488
+ if (ev && typeof ev.preventDefault === 'function') ev.preventDefault();
489
+ return;
490
+ }
491
+ doSave._inFlight = true;
492
+ try {
493
+ if (ev && typeof ev.preventDefault === 'function') ev.preventDefault();
494
+ const payload = formToObject(form);
495
+ // Always prefix with default yearly group
496
+ const y = new Date().getFullYear();
497
+ const defaultGroup = `onekite-ffvl-${y}`;
498
+ if (Object.prototype.hasOwnProperty.call(payload, 'creatorGroups')) {
499
+ payload.creatorGroups = normalizeCsvGroupsWithDefault(payload.creatorGroups, defaultGroup);
500
+ }
501
+ await saveSettings(payload);
502
+ showAlert('success', 'Paramètres enregistrés.');
503
+ } catch (e) {
504
+ showAlert('error', 'Échec de l\'enregistrement.');
505
+ } finally {
506
+ doSave._inFlight = false;
507
+ }
508
+ }
509
+
510
+ // Save buttons (NodeBB header/footer "Enregistrer" + floppy icon)
511
+ // Use ONE delegated listener to avoid double submissions.
512
+ const SAVE_SELECTOR = '#save, .save, [data-action="save"], .settings-save, .floating-save, .btn[data-action="save"]';
513
+ document.addEventListener('click', (ev) => {
514
+ const btn = ev.target && ev.target.closest && ev.target.closest(SAVE_SELECTOR);
515
+ if (!btn) return;
516
+ // Only handle clicks while we're on this plugin page
517
+ if (!document.getElementById('onekite-settings-form')) return;
518
+ doSave(ev);
519
+ });
520
+
521
+ // Approve/refuse buttons
522
+ const pendingWrap = document.getElementById('onekite-pending');
523
+ if (pendingWrap) {
524
+ pendingWrap.addEventListener('click', async (ev) => {
525
+ const btn = ev.target && ev.target.closest('button[data-action]');
526
+ if (!btn) return;
527
+ const action = btn.getAttribute('data-action');
528
+ const rid = btn.getAttribute('data-rid');
529
+ if (!rid) return;
530
+
531
+ try {
532
+ if (action === 'approve') {
533
+ const opts = timeOptions(5).map(t => `<option value="${t}">${t}</option>`).join('');
534
+ const html = `
535
+ <div class="mb-3">
536
+ <label class="form-label">Note (incluse dans l'email)</label>
537
+ <textarea class="form-control" id="onekite-admin-note" rows="3" placeholder="Matériel à récupérer à l'adresse : ..."></textarea>
538
+ </div>
539
+ <div class="mb-2">
540
+ <label class="form-label">Heure de récupération</label>
541
+ <select class="form-select" id="onekite-pickup-time">${opts}</select>
542
+ </div>
543
+ `;
544
+ await new Promise((resolve) => {
545
+ bootbox.dialog({
546
+ title: 'Valider la réservation',
547
+ message: html,
548
+ buttons: {
549
+ cancel: { label: 'Annuler', className: 'btn-secondary', callback: () => resolve(false) },
550
+ ok: {
551
+ label: 'Valider',
552
+ className: 'btn-success',
553
+ callback: async () => {
554
+ try {
555
+ const adminNote = (document.getElementById('onekite-admin-note')?.value || '').trim();
556
+ const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
557
+ await approve(rid, { adminNote, pickupTime });
558
+ bootbox.alert('Réservation validée.');
559
+ resolve(true);
560
+ } catch (e) {
561
+ showAlert('error', 'Validation impossible.');
562
+ resolve(false);
563
+ }
564
+ return false;
565
+ },
566
+ },
567
+ },
568
+ });
569
+ });
570
+ } else if (action === 'refuse') {
571
+ await refuse(rid);
572
+ bootbox.alert('Réservation refusée.');
573
+ }
574
+ await refreshPending();
575
+ } catch (e) {
576
+ showAlert('error', 'Action impossible.');
577
+ }
578
+ });
579
+ }
580
+
581
+ // Purge
582
+ const purgeBtn = document.getElementById('onekite-purge');
583
+ if (purgeBtn) {
584
+ purgeBtn.addEventListener('click', async () => {
585
+ const yearInput = document.getElementById('onekite-purge-year');
586
+ const year = (yearInput ? yearInput.value : '').trim();
587
+ if (!/^\d{4}$/.test(year)) {
588
+ showAlert('error', 'Année invalide (YYYY)');
589
+ return;
590
+ }
591
+ bootbox.confirm(`Purger toutes les réservations de ${year} ?`, async (ok) => {
592
+ if (!ok) return;
593
+ try {
594
+ const r = await purge(year);
595
+ showAlert('success', `Purge OK (${r.removed || 0} supprimées).`);
596
+ await refreshPending();
597
+ } catch (e) {
598
+ showAlert('error', 'Purge impossible.');
599
+ }
600
+ });
601
+ });
602
+ }
603
+
604
+ // Purge special events by year
605
+ const sePurgeBtn = document.getElementById('onekite-se-purge');
606
+ if (sePurgeBtn) {
607
+ sePurgeBtn.addEventListener('click', async () => {
608
+ const yearInput = document.getElementById('onekite-se-purge-year');
609
+ const year = (yearInput ? yearInput.value : '').trim();
610
+ if (!/^\d{4}$/.test(year)) {
611
+ showAlert('error', 'Année invalide (YYYY)');
612
+ return;
613
+ }
614
+ bootbox.confirm(`Purger tous les évènements de ${year} ?`, async (ok) => {
615
+ if (!ok) return;
616
+ try {
617
+ const r = await purgeSpecialEvents(year);
618
+ showAlert('success', `Purge OK (${r.removed || 0} supprimé(s)).`);
619
+ } catch (e) {
620
+ showAlert('error', 'Purge impossible.');
621
+ }
622
+ });
623
+ });
624
+ }
625
+
626
+ // Debug
627
+ const debugBtn = document.getElementById('onekite-debug-run');
628
+ if (debugBtn) {
629
+ debugBtn.addEventListener('click', async () => {
630
+ const out = document.getElementById('onekite-debug-output');
631
+ if (out) out.textContent = 'Chargement...';
632
+ try {
633
+ const result = await debugHelloAsso();
634
+ if (out) out.textContent = JSON.stringify(result, null, 2);
635
+ const catalogCount = result && result.catalog ? parseInt(result.catalog.count, 10) || 0 : 0;
636
+ const catalogOk = !!(result && result.catalog && result.catalog.ok);
637
+ // Accept "count > 0" even if ok flag is false (some proxies can strip fields, etc.)
638
+ if (catalogOk || catalogCount > 0) {
639
+ showAlert('success', `Catalogue HelloAsso: ${catalogCount} item(s)`);
640
+ } else {
641
+ showAlert('error', 'HelloAsso: impossible de récupérer le catalogue.');
642
+ }
643
+ } catch (e) {
644
+ if (out) out.textContent = String(e && e.message ? e.message : e);
645
+ showAlert('error', 'Debug impossible.');
646
+ }
647
+ });
648
+ }
649
+
650
+ // Accounting (paid reservations)
651
+ const accFrom = document.getElementById('onekite-acc-from');
652
+ const accTo = document.getElementById('onekite-acc-to');
653
+ const accRefresh = document.getElementById('onekite-acc-refresh');
654
+ const accExport = document.getElementById('onekite-acc-export');
655
+ const accPurge = document.getElementById('onekite-acc-purge');
656
+ const accSummary = document.querySelector('#onekite-acc-summary tbody');
657
+ const accRows = document.querySelector('#onekite-acc-rows tbody');
658
+
659
+ function ymd(d) {
660
+ const yyyy = d.getUTCFullYear();
661
+ const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
662
+ const dd = String(d.getUTCDate()).padStart(2, '0');
663
+ return `${yyyy}-${mm}-${dd}`;
664
+ }
665
+ if (accFrom && accTo) {
666
+ const now = new Date();
667
+ const to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
668
+ const from = new Date(Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth() + 1, 1));
669
+ if (!accFrom.value) accFrom.value = ymd(from);
670
+ if (!accTo.value) accTo.value = ymd(to);
671
+ }
672
+
673
+ function renderAccounting(payload) {
674
+ if (accSummary) accSummary.innerHTML = '';
675
+ if (accRows) accRows.innerHTML = '';
676
+ if (!payload || !payload.ok) {
677
+ return;
678
+ }
679
+
680
+ (payload.summary || []).forEach((s) => {
681
+ const tr = document.createElement('tr');
682
+ tr.innerHTML = `<td>${escapeHtml(s.item)}</td><td>${escapeHtml(String(s.count || 0))}</td><td>${escapeHtml((Number(s.total) || 0).toFixed(2))}</td>`;
683
+ accSummary && accSummary.appendChild(tr);
684
+ });
685
+
686
+ (payload.rows || []).forEach((r) => {
687
+ const tr = document.createElement('tr');
688
+ const user = r.username ? `<a href="/user/${encodeURIComponent(r.username)}" target="_blank">${escapeHtml(r.username)}</a>` : '';
689
+ const items = Array.isArray(r.items) ? r.items.map((x) => escapeHtml(x)).join('<br>') : '';
690
+ tr.innerHTML = `<td>${escapeHtml(r.startDate)} → ${escapeHtml(r.endDate)}</td><td>${user}</td><td>${items}</td><td>${escapeHtml((Number(r.total) || 0).toFixed(2))}</td><td><code>${escapeHtml(r.rid)}</code></td>`;
691
+ accRows && accRows.appendChild(tr);
692
+ });
693
+ }
694
+
695
+ async function refreshAccounting() {
696
+ if (!accRefresh) return;
697
+ try {
698
+ const from = accFrom ? accFrom.value : '';
699
+ const to = accTo ? accTo.value : '';
700
+ accRefresh.disabled = true;
701
+ const payload = await loadAccounting(from, to);
702
+ renderAccounting(payload);
703
+ } catch (e) {
704
+ showAlert('error', 'Impossible de charger la comptabilisation.');
705
+ } finally {
706
+ accRefresh.disabled = false;
707
+ }
708
+ }
709
+
710
+ if (accRefresh) {
711
+ accRefresh.addEventListener('click', refreshAccounting);
712
+ // Load once on init
713
+ refreshAccounting();
714
+ }
715
+ if (accExport) {
716
+ accExport.addEventListener('click', () => {
717
+ const params = new URLSearchParams();
718
+ if (accFrom && accFrom.value) params.set('from', accFrom.value);
719
+ if (accTo && accTo.value) params.set('to', accTo.value);
720
+ const qs = params.toString();
721
+ const url = `/api/v3/admin/plugins/calendar-onekite/accounting.csv${qs ? `?${qs}` : ''}`;
722
+ window.open(url, '_blank');
723
+ });
724
+
725
+ if (accPurge) {
726
+ accPurge.addEventListener('click', async () => {
727
+ const ok = window.confirm('Purger la comptabilité pour la période sélectionnée ?\nCela masquera ces réservations dans l\'onglet Comptabilisation (sans modifier leur statut payé).');
728
+ if (!ok) return;
729
+ try {
730
+ const params = new URLSearchParams();
731
+ if (accFrom && accFrom.value) params.set('from', accFrom.value);
732
+ if (accTo && accTo.value) params.set('to', accTo.value);
733
+ const qs = params.toString();
734
+ const url = `/api/v3/admin/plugins/calendar-onekite/accounting/purge${qs ? `?${qs}` : ''}`;
735
+ const res = await fetchJson(url, { method: 'POST' });
736
+ if (res && res.ok) {
737
+ showAlert('success', `Compta purgée : ${res.purged || 0} réservation(s).`);
738
+ // Refresh accounting tables after purge
739
+ try {
740
+ const from = accFrom ? accFrom.value : '';
741
+ const to = accTo ? accTo.value : '';
742
+ const data = await loadAccounting(from, to);
743
+ renderAccounting(data);
744
+ } catch (e) {
745
+ // If refresh fails, keep the success message and show a soft warning
746
+ showAlert('error', 'Compta purgée, mais rafraîchissement impossible.');
747
+ }
748
+ } else {
749
+ showAlert('error', 'Purge impossible.');
750
+ }
751
+ } catch (e) {
752
+ showAlert('error', 'Purge impossible.');
753
+ }
754
+ });
755
+ }
756
+
757
+ }
758
+ }
759
+
760
+ return { init };
761
+ });
package/public/admin.js CHANGED
@@ -1,5 +1,22 @@
1
+ /* global app, ajaxify, bootbox, alerts */
2
+ (function (factory) {
3
+ if (typeof define === 'function' && define.amd) {
4
+ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], factory);
5
+ } else {
6
+ // No RequireJS in this ACP context; fallback to globals
7
+ var mod = factory(window.alerts || null, window.bootbox || null);
8
+ if (mod && typeof mod.init === 'function') {
9
+ if (document.readyState === 'loading') {
10
+ document.addEventListener('DOMContentLoaded', mod.init);
11
+ } else {
12
+ mod.init();
13
+ }
14
+ document.addEventListener('action:ajaxify.end', mod.init);
15
+ }
16
+ }
17
+ })(function (alerts, bootbox) {
18
+ 'use strict';
1
19
 
2
- define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts, bootbox) {
3
20
  'use strict';
4
21
 
5
22
  function showAlert(type, msg) {
@@ -758,4 +775,5 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
758
775
  }
759
776
 
760
777
  return { init };
778
+
761
779
  });