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 +8 -0
- package/lib/api.js +59 -3
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/public/admin.js +163 -0
- package/public/client.js +15 -4
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
|
|
733
|
-
const
|
|
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 =
|
|
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
package/plugin.json
CHANGED
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
|
-
//
|
|
820
|
-
|
|
821
|
-
let days =
|
|
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 =
|
|
1011
|
+
days = calendarDaysExclusive(start, end);
|
|
1001
1012
|
if (daysEl) {
|
|
1002
1013
|
daysEl.textContent = `(${days} jour${days > 1 ? 's' : ''})`;
|
|
1003
1014
|
}
|