nodebb-plugin-onekite-calendar 2.0.2 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog – calendar-onekite
2
2
 
3
+ ## 1.2.11
4
+ - ACP : ajout de la recherche automatique d’adresse (autocomplete Nominatim) dans la modale de validation (unitaire + batch), comme sur le calendrier
5
+
6
+ ## 1.2.10
7
+ - HelloAsso : calcul des jours 100% fiable (différence en jours calendaires Y/M/J, sans dépendance aux heures/au fuseau/DST)
8
+ - FullCalendar : endDate traitée comme exclusive partout (UI + checkout)
9
+ - HelloAsso : montant du checkout recalculé côté serveur à partir du catalogue (prix/jour × nbJours)
10
+
3
11
  ## 1.2.9
4
12
  - Modale réservation : retour du grisé des matériels indisponibles (API events expose à nouveau itemIds)
5
13
 
package/lib/api.js CHANGED
@@ -226,6 +226,24 @@ function toTs(v) {
226
226
  return d.getTime();
227
227
  }
228
228
 
229
+ // Calendar-day difference (end exclusive) computed purely from Y/M/D.
230
+ // Uses UTC midnights to avoid any dependency on local timezone or DST.
231
+ function calendarDaysExclusiveYmd(startYmd, endYmd) {
232
+ try {
233
+ const m1 = /^\d{4}-\d{2}-\d{2}$/.exec(String(startYmd || '').trim());
234
+ const m2 = /^\d{4}-\d{2}-\d{2}$/.exec(String(endYmd || '').trim());
235
+ if (!m1 || !m2) return null;
236
+ const [sy, sm, sd] = startYmd.split('-').map((x) => parseInt(x, 10));
237
+ const [ey, em, ed] = endYmd.split('-').map((x) => parseInt(x, 10));
238
+ const sUtc = Date.UTC(sy, sm - 1, sd);
239
+ const eUtc = Date.UTC(ey, em - 1, ed);
240
+ const diff = Math.floor((eUtc - sUtc) / (24 * 60 * 60 * 1000));
241
+ return Math.max(1, diff);
242
+ } catch (e) {
243
+ return null;
244
+ }
245
+ }
246
+
229
247
  function yearFromTs(ts) {
230
248
  const d = new Date(Number(ts));
231
249
  return Number.isFinite(d.getTime()) ? d.getFullYear() : new Date().getFullYear();
@@ -729,12 +747,19 @@ api.createReservation = async function (req, res) {
729
747
  const ok = await canRequest(uid, settings, startPreview);
730
748
  if (!ok) return res.status(403).json({ error: 'not-allowed' });
731
749
 
732
- const start = parseInt(toTs(req.body.start), 10);
733
- const end = parseInt(toTs(req.body.end), 10);
750
+ const startRaw = req.body.start;
751
+ const endRaw = req.body.end;
752
+ const start = parseInt(toTs(startRaw), 10);
753
+ const end = parseInt(toTs(endRaw), 10);
734
754
  if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
735
755
  return res.status(400).json({ error: "bad-dates" });
736
756
  }
737
757
 
758
+ // Keep the original date-only strings when available. This allows
759
+ // calendar-day calculations that are 100% independent of hours/timezones/DST.
760
+ const startDate = (typeof startRaw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(startRaw.trim())) ? startRaw.trim() : null;
761
+ const endDate = (typeof endRaw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(endRaw.trim())) ? endRaw.trim() : null;
762
+
738
763
  // Business rule: a reservation cannot start on the current day or in the past.
739
764
  // We compare against server-local midnight. (Front-end also prevents it.)
740
765
  try {
@@ -802,6 +827,8 @@ api.createReservation = async function (req, res) {
802
827
  itemName: (itemNames[0] || itemIds[0]),
803
828
  start,
804
829
  end,
830
+ startDate,
831
+ endDate,
805
832
  status: 'pending',
806
833
  createdAt: now,
807
834
  total: isNaN(total) ? 0 : total,
@@ -920,6 +947,33 @@ api.approveReservation = async function (req, res) {
920
947
  const token = await helloasso.getAccessToken({ env: settings2.helloassoEnv || 'prod', clientId: settings2.helloassoClientId, clientSecret: settings2.helloassoClientSecret });
921
948
  const payer = await user.getUserFields(r.uid, ['email']);
922
949
  const year = yearFromTs(r.start);
950
+
951
+ // Reliable calendar-day count (end is EXCLUSIVE, FullCalendar rule)
952
+ const days = calendarDaysExclusiveYmd(r.startDate, r.endDate) || 1;
953
+
954
+ // Recompute total from HelloAsso catalog to avoid any dependency on hours/DST
955
+ // and to ensure checkout amount is always consistent.
956
+ let recomputedTotalCents = null;
957
+ try {
958
+ const { items: catalog } = await helloasso.listCatalogItems({
959
+ env: settings2.helloassoEnv,
960
+ token,
961
+ organizationSlug: settings2.helloassoOrganizationSlug,
962
+ formType: settings2.helloassoFormType,
963
+ formSlug: autoFormSlugForYear(year),
964
+ });
965
+ const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
966
+ const ids = (Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : [])).map(String);
967
+ const sumCentsPerDay = ids.reduce((acc, id) => acc + (byId.get(String(id)) || 0), 0);
968
+ if (sumCentsPerDay > 0) {
969
+ recomputedTotalCents = Math.max(0, Math.round(sumCentsPerDay * days));
970
+ // Keep stored total in sync (euros) for emails/UX.
971
+ r.total = recomputedTotalCents / 100;
972
+ }
973
+ } catch (e) {
974
+ // ignore recompute failures; fallback to stored total
975
+ }
976
+
923
977
  const intent = await helloasso.createCheckoutIntent({
924
978
  env: settings2.helloassoEnv,
925
979
  token,
@@ -929,7 +983,9 @@ api.approveReservation = async function (req, res) {
929
983
  formSlug: autoFormSlugForYear(year),
930
984
  // r.total is stored as an estimated total in euros; HelloAsso expects cents.
931
985
  totalAmount: (() => {
932
- const cents = Math.max(0, Math.round((Number(r.total) || 0) * 100));
986
+ const cents = (typeof recomputedTotalCents === 'number')
987
+ ? recomputedTotalCents
988
+ : Math.max(0, Math.round((Number(r.total) || 0) * 100));
933
989
  if (!cents) {
934
990
  console.warn('[calendar-onekite] HelloAsso totalAmount is 0 (approve API)', { rid, total: r.total });
935
991
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/plugin.json CHANGED
@@ -39,5 +39,5 @@
39
39
  "acpScripts": [
40
40
  "public/admin.js"
41
41
  ],
42
- "version": "2.0.2"
42
+ "version": "2.0.4"
43
43
  }
package/public/admin.js CHANGED
@@ -187,6 +187,148 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
187
187
  return { lat, lon, displayName: hit.display_name || q };
188
188
  }
189
189
 
190
+ // Lightweight address autocomplete (OpenStreetMap Nominatim)
191
+ // Designed to work inside Bootstrap "input-group" without moving DOM nodes.
192
+ async function searchAddresses(query, limit) {
193
+ const q = String(query || '').trim();
194
+ const lim = Math.min(10, Math.max(1, Number(limit) || 6));
195
+ if (q.length < 3) return [];
196
+ const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&addressdetails=1&limit=${lim}&q=${encodeURIComponent(q)}`;
197
+ const res = await fetch(url, {
198
+ method: 'GET',
199
+ headers: {
200
+ 'Accept': 'application/json',
201
+ 'Accept-Language': 'fr',
202
+ },
203
+ });
204
+ if (!res.ok) return [];
205
+ const arr = await res.json();
206
+ if (!Array.isArray(arr)) return [];
207
+ return arr.map((hit) => {
208
+ const lat = Number(hit.lat);
209
+ const lon = Number(hit.lon);
210
+ return {
211
+ displayName: hit.display_name || q,
212
+ lat: Number.isFinite(lat) ? lat : null,
213
+ lon: Number.isFinite(lon) ? lon : null,
214
+ };
215
+ }).filter(h => h && h.displayName);
216
+ }
217
+
218
+ function attachAddressAutocomplete(inputEl, onPick) {
219
+ if (!inputEl) return;
220
+ if (inputEl.getAttribute('data-onekite-autocomplete') === '1') return;
221
+ inputEl.setAttribute('data-onekite-autocomplete', '1');
222
+
223
+ const anchor = inputEl.closest('.input-group') || inputEl.parentNode;
224
+ if (!anchor) return;
225
+
226
+ // Ensure positioning context
227
+ if (getComputedStyle(anchor).position === 'static') {
228
+ anchor.style.position = 'relative';
229
+ }
230
+
231
+ const menu = document.createElement('div');
232
+ menu.className = 'onekite-autocomplete-menu';
233
+ menu.style.position = 'absolute';
234
+ menu.style.left = '0';
235
+ menu.style.right = '0';
236
+ menu.style.top = '100%';
237
+ menu.style.zIndex = '2000';
238
+ menu.style.background = '#fff';
239
+ menu.style.border = '1px solid rgba(0,0,0,.15)';
240
+ menu.style.borderTop = '0';
241
+ menu.style.maxHeight = '220px';
242
+ menu.style.overflowY = 'auto';
243
+ menu.style.display = 'none';
244
+ menu.style.borderRadius = '0 0 .375rem .375rem';
245
+ menu.style.boxShadow = '0 .25rem .75rem rgba(0,0,0,.08)';
246
+ anchor.appendChild(menu);
247
+
248
+ let timer = null;
249
+ let lastQuery = '';
250
+ let busy = false;
251
+
252
+ function hide() {
253
+ menu.style.display = 'none';
254
+ menu.innerHTML = '';
255
+ }
256
+
257
+ function show(hits) {
258
+ if (!hits || !hits.length) {
259
+ hide();
260
+ return;
261
+ }
262
+ menu.innerHTML = '';
263
+ hits.forEach((h) => {
264
+ const btn = document.createElement('button');
265
+ btn.type = 'button';
266
+ btn.className = 'onekite-autocomplete-item';
267
+ btn.textContent = h.displayName;
268
+ btn.style.display = 'block';
269
+ btn.style.width = '100%';
270
+ btn.style.textAlign = 'left';
271
+ btn.style.padding = '.35rem .5rem';
272
+ btn.style.border = '0';
273
+ btn.style.background = 'transparent';
274
+ btn.style.cursor = 'pointer';
275
+ btn.addEventListener('click', () => {
276
+ inputEl.value = h.displayName;
277
+ hide();
278
+ try { onPick && onPick(h); } catch (e) {}
279
+ });
280
+ btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(0,0,0,.05)'; });
281
+ btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; });
282
+ menu.appendChild(btn);
283
+ });
284
+ menu.style.display = 'block';
285
+ }
286
+
287
+ async function run(q) {
288
+ if (busy) return;
289
+ busy = true;
290
+ try {
291
+ const hits = await searchAddresses(q, 6);
292
+ if (String(inputEl.value || '').trim() !== q) return; // ignore stale
293
+ show(hits);
294
+ } catch (e) {
295
+ hide();
296
+ } finally {
297
+ busy = false;
298
+ }
299
+ }
300
+
301
+ inputEl.addEventListener('input', () => {
302
+ const q = String(inputEl.value || '').trim();
303
+ lastQuery = q;
304
+ if (timer) clearTimeout(timer);
305
+ if (q.length < 3) {
306
+ hide();
307
+ return;
308
+ }
309
+ timer = setTimeout(() => run(lastQuery), 250);
310
+ });
311
+
312
+ inputEl.addEventListener('focus', () => {
313
+ const q = String(inputEl.value || '').trim();
314
+ if (q.length >= 3) {
315
+ if (timer) clearTimeout(timer);
316
+ timer = setTimeout(() => run(q), 150);
317
+ }
318
+ });
319
+
320
+ inputEl.addEventListener('keydown', (e) => {
321
+ if (e.key === 'Escape') hide();
322
+ });
323
+
324
+ // Close when clicking outside (once per menu)
325
+ document.addEventListener('click', (e) => {
326
+ try {
327
+ if (!anchor.contains(e.target)) hide();
328
+ } catch (err) {}
329
+ });
330
+ }
331
+
190
332
  function formToObject(form) {
191
333
  const out = {};
192
334
  new FormData(form).forEach((v, k) => {
@@ -698,6 +840,19 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
698
840
  } catch (e) {}
699
841
  });
700
842
  }
843
+
844
+ // Autocomplete like on the calendar validation modal
845
+ const addrInput = document.getElementById('onekite-pickup-address');
846
+ attachAddressAutocomplete(addrInput, (h) => {
847
+ try {
848
+ if (!h || !Number.isFinite(Number(h.lat)) || !Number.isFinite(Number(h.lon))) return;
849
+ const lat = Number(h.lat);
850
+ const lon = Number(h.lon);
851
+ marker.setLatLng([lat, lon]);
852
+ map.setView([lat, lon], 16);
853
+ setLatLon(lat, lon);
854
+ } catch (e) {}
855
+ });
701
856
  setTimeout(() => { try { map.invalidateSize(); } catch (e) {} }, 250);
702
857
  } catch (e) {}
703
858
  });
@@ -887,6 +1042,14 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
887
1042
  }
888
1043
  });
889
1044
  }
1045
+
1046
+ // Autocomplete like on the calendar validation modal
1047
+ attachAddressAutocomplete(addrInput, (h) => {
1048
+ try {
1049
+ if (!h || !Number.isFinite(Number(h.lat)) || !Number.isFinite(Number(h.lon))) return;
1050
+ setMarker(Number(h.lat), Number(h.lon), 16);
1051
+ } catch (e) {}
1052
+ });
890
1053
  } catch (e) {
891
1054
  // ignore
892
1055
  }
package/public/client.js CHANGED
@@ -806,6 +806,17 @@ function attachAddressAutocomplete(inputEl, onPick) {
806
806
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
807
807
  }
808
808
 
809
+ // Calendar-day difference with end treated as EXCLUSIVE (FullCalendar rule).
810
+ // Ignores hours/timezones/DST by projecting local Y/M/D onto UTC midnights.
811
+ function calendarDaysExclusive(startDate, endDate) {
812
+ const s = new Date(startDate);
813
+ const e = new Date(endDate);
814
+ const sUtc = Date.UTC(s.getFullYear(), s.getMonth(), s.getDate());
815
+ const eUtc = Date.UTC(e.getFullYear(), e.getMonth(), e.getDate());
816
+ const diff = Math.floor((eUtc - sUtc) / (24 * 60 * 60 * 1000));
817
+ return Math.max(1, diff);
818
+ }
819
+
809
820
  function toDatetimeLocalValue(date) {
810
821
  const d = new Date(date);
811
822
  const pad = (n) => String(n).padStart(2, '0');
@@ -816,9 +827,9 @@ function toDatetimeLocalValue(date) {
816
827
  const start = selectionInfo.start;
817
828
  let end = selectionInfo.end;
818
829
 
819
- // days (end is exclusive in FullCalendar)
820
- const msPerDay = 24 * 60 * 60 * 1000;
821
- let days = Math.max(1, Math.round((end.getTime() - start.getTime()) / msPerDay));
830
+ // Days (end is exclusive in FullCalendar) — compute in calendar days only
831
+ // (no dependency on hours, timezone or DST).
832
+ let days = calendarDaysExclusive(start, end);
822
833
 
823
834
  // Fetch existing events overlapping the selection to disable already reserved items.
824
835
  let blocked = new Set();
@@ -997,7 +1008,7 @@ function toDatetimeLocalValue(date) {
997
1008
  // end is exclusive -> add nd days to start
998
1009
  end = new Date(start);
999
1010
  end.setDate(end.getDate() + nd);
1000
- days = nd;
1011
+ days = calendarDaysExclusive(start, end);
1001
1012
  if (daysEl) {
1002
1013
  daysEl.textContent = `(${days} jour${days > 1 ? 's' : ''})`;
1003
1014
  }