nodebb-plugin-onekite-calendar 2.0.11 → 2.0.13
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 +29 -0
- package/lib/admin.js +21 -9
- package/lib/api.js +235 -4
- package/lib/db.js +114 -0
- package/lib/helloassoWebhook.js +28 -0
- package/library.js +7 -0
- package/package.json +1 -1
- package/pkg/package/CHANGELOG.md +106 -0
- package/pkg/package/lib/admin.js +554 -0
- package/pkg/package/lib/api.js +1458 -0
- package/pkg/package/lib/controllers.js +11 -0
- package/pkg/package/lib/db.js +224 -0
- package/pkg/package/lib/discord.js +190 -0
- package/pkg/package/lib/helloasso.js +352 -0
- package/pkg/package/lib/helloassoWebhook.js +389 -0
- package/pkg/package/lib/scheduler.js +201 -0
- package/pkg/package/lib/widgets.js +460 -0
- package/pkg/package/library.js +164 -0
- package/pkg/package/package.json +14 -0
- package/pkg/package/plugin.json +43 -0
- package/pkg/package/public/admin.js +1477 -0
- package/pkg/package/public/client.js +2228 -0
- package/pkg/package/templates/admin/plugins/calendar-onekite.tpl +298 -0
- package/pkg/package/templates/calendar-onekite.tpl +51 -0
- package/pkg/package/templates/emails/calendar-onekite_approved.tpl +40 -0
- package/pkg/package/templates/emails/calendar-onekite_cancelled.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_expired.tpl +11 -0
- package/pkg/package/templates/emails/calendar-onekite_paid.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_pending.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_refused.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_reminder.tpl +20 -0
- package/plugin.json +1 -1
- package/public/admin.js +205 -4
- package/public/client.js +238 -7
- package/templates/admin/plugins/calendar-onekite.tpl +74 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const nconf = require.main.require('nconf');
|
|
4
|
+
|
|
5
|
+
function forumBaseUrl() {
|
|
6
|
+
return String(nconf.get('url') || '').trim().replace(/\/$/, '');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function escapeHtml(s) {
|
|
10
|
+
return String(s || '')
|
|
11
|
+
.replace(/&/g, '&')
|
|
12
|
+
.replace(/</g, '<')
|
|
13
|
+
.replace(/>/g, '>')
|
|
14
|
+
.replace(/"/g, '"')
|
|
15
|
+
.replace(/'/g, ''');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeDomId() {
|
|
19
|
+
const r = Math.floor(Math.random() * 1e9);
|
|
20
|
+
return `onekite-twoweeks-${Date.now()}-${r}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function widgetCalendarUrl() {
|
|
24
|
+
// Per request, keep the public URL fixed (even if forum base differs)
|
|
25
|
+
return 'https://www.onekite.com/calendar';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const widgets = {};
|
|
29
|
+
|
|
30
|
+
widgets.defineWidgets = async function (widgetData) {
|
|
31
|
+
// NodeBB versions differ:
|
|
32
|
+
// - Some pass an object: { widgets: [...] }
|
|
33
|
+
// - Others pass the array directly
|
|
34
|
+
const list = Array.isArray(widgetData)
|
|
35
|
+
? widgetData
|
|
36
|
+
: (widgetData && Array.isArray(widgetData.widgets) ? widgetData.widgets : null);
|
|
37
|
+
|
|
38
|
+
if (!list) {
|
|
39
|
+
return widgetData;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
list.push({
|
|
43
|
+
widget: 'calendar-onekite-twoweeks',
|
|
44
|
+
name: 'Calendrier',
|
|
45
|
+
description: 'Affiche la semaine courante + la semaine suivante (FullCalendar via CDN).',
|
|
46
|
+
content: '',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return widgetData;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
widgets.renderTwoWeeksWidget = async function (data) {
|
|
53
|
+
// data: { widget, ... }
|
|
54
|
+
const id = makeDomId();
|
|
55
|
+
const calUrl = widgetCalendarUrl();
|
|
56
|
+
const apiBase = forumBaseUrl();
|
|
57
|
+
const eventsEndpoint = `${apiBase}/api/v3/plugins/calendar-onekite/events`;
|
|
58
|
+
|
|
59
|
+
const idJson = JSON.stringify(id);
|
|
60
|
+
const calUrlJson = JSON.stringify(calUrl);
|
|
61
|
+
const eventsEndpointJson = JSON.stringify(eventsEndpoint);
|
|
62
|
+
|
|
63
|
+
const html = `
|
|
64
|
+
<div class="onekite-twoweeks">
|
|
65
|
+
<div class="d-flex justify-content-between align-items-center mb-1">
|
|
66
|
+
<div style="font-weight: 600;">Calendrier</div>
|
|
67
|
+
<a href="${escapeHtml(calUrl)}" class="btn btn-sm btn-outline-secondary" style="line-height: 1.1;">Ouvrir</a>
|
|
68
|
+
</div>
|
|
69
|
+
<div id="${escapeHtml(id)}"></div>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@latest/main.min.css" />
|
|
73
|
+
<script>
|
|
74
|
+
(function(){
|
|
75
|
+
const containerId = ${idJson};
|
|
76
|
+
const calUrl = ${calUrlJson};
|
|
77
|
+
const eventsEndpoint = ${eventsEndpointJson};
|
|
78
|
+
|
|
79
|
+
function loadOnce(tag, attrs) {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
try {
|
|
82
|
+
const key = attrs && (attrs.id || attrs.href || attrs.src);
|
|
83
|
+
if (key && document.querySelector((attrs.id ? ('#' + attrs.id) : (attrs.href ? ('link[href="' + attrs.href + '"]') : ('script[src="' + attrs.src + '"]'))))) {
|
|
84
|
+
resolve();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const el = document.createElement(tag);
|
|
88
|
+
Object.keys(attrs || {}).forEach((k) => el.setAttribute(k, attrs[k]));
|
|
89
|
+
el.onload = () => resolve();
|
|
90
|
+
el.onerror = () => reject(new Error('load-failed'));
|
|
91
|
+
document.head.appendChild(el);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
reject(e);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function ensureFullCalendar() {
|
|
99
|
+
if (window.FullCalendar && window.FullCalendar.Calendar) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
await loadOnce('script', {
|
|
103
|
+
id: 'onekite-fullcalendar-global',
|
|
104
|
+
src: 'https://cdn.jsdelivr.net/npm/fullcalendar@latest/index.global.min.js',
|
|
105
|
+
async: 'true'
|
|
106
|
+
});
|
|
107
|
+
await loadOnce('script', {
|
|
108
|
+
id: 'onekite-fullcalendar-locales',
|
|
109
|
+
src: 'https://cdn.jsdelivr.net/npm/@fullcalendar/core@latest/locales-all.global.min.js',
|
|
110
|
+
async: 'true'
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function init() {
|
|
115
|
+
const el = document.getElementById(containerId);
|
|
116
|
+
if (!el) return;
|
|
117
|
+
|
|
118
|
+
await ensureFullCalendar();
|
|
119
|
+
|
|
120
|
+
// Basic lightweight tooltip (no dependencies, works on hover + tap)
|
|
121
|
+
const tip = document.createElement('div');
|
|
122
|
+
tip.className = 'onekite-cal-tooltip';
|
|
123
|
+
tip.style.display = 'none';
|
|
124
|
+
document.body.appendChild(tip);
|
|
125
|
+
|
|
126
|
+
function setTipContent(html) {
|
|
127
|
+
tip.innerHTML = html;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function clamp(n, min, max) {
|
|
131
|
+
return Math.max(min, Math.min(n, max));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function showTipAt(pageX, pageY) {
|
|
135
|
+
// Robust mobile positioning: keep the tooltip inside the viewport.
|
|
136
|
+
const pad = 8;
|
|
137
|
+
|
|
138
|
+
// Ensure the tooltip is measurable.
|
|
139
|
+
tip.style.maxWidth = 'calc(100vw - 16px)';
|
|
140
|
+
tip.style.display = 'block';
|
|
141
|
+
tip.style.visibility = 'hidden';
|
|
142
|
+
|
|
143
|
+
const rect = tip.getBoundingClientRect();
|
|
144
|
+
const vw = window.innerWidth || document.documentElement.clientWidth || 0;
|
|
145
|
+
const vh = window.innerHeight || document.documentElement.clientHeight || 0;
|
|
146
|
+
const sx = window.scrollX || window.pageXOffset || 0;
|
|
147
|
+
const sy = window.scrollY || window.pageYOffset || 0;
|
|
148
|
+
|
|
149
|
+
// Default: below the pointer/anchor, centered.
|
|
150
|
+
let left = pageX - (rect.width / 2);
|
|
151
|
+
let top = pageY + 12;
|
|
152
|
+
|
|
153
|
+
// If it would go below the viewport, try above.
|
|
154
|
+
if ((top - sy + rect.height) > (vh - pad)) {
|
|
155
|
+
top = pageY - rect.height - 12;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Clamp inside viewport (document coordinates).
|
|
159
|
+
left = clamp(left, sx + pad, sx + vw - rect.width - pad);
|
|
160
|
+
top = clamp(top, sy + pad, sy + vh - rect.height - pad);
|
|
161
|
+
|
|
162
|
+
tip.style.left = left + 'px';
|
|
163
|
+
tip.style.top = top + 'px';
|
|
164
|
+
tip.style.visibility = 'visible';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function hideTip() {
|
|
168
|
+
tip.style.display = 'none';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Client-side escaping (the server-side helper is not in scope here)
|
|
172
|
+
function escapeHtml(s) {
|
|
173
|
+
return String(s || '')
|
|
174
|
+
.replace(/&/g, '&')
|
|
175
|
+
.replace(/</g, '<')
|
|
176
|
+
.replace(/>/g, '>')
|
|
177
|
+
.replace(/"/g, '"')
|
|
178
|
+
.replace(/'/g, ''');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
document.addEventListener('pointerdown', (e) => {
|
|
182
|
+
// Close when clicking outside an event
|
|
183
|
+
if (!e.target || !e.target.closest || (!e.target.closest('.fc-event') && !e.target.closest('.onekite-cal-tooltip'))) {
|
|
184
|
+
hideTip();
|
|
185
|
+
}
|
|
186
|
+
}, { passive: true });
|
|
187
|
+
|
|
188
|
+
// Define a 2-week dayGrid view
|
|
189
|
+
const calendar = new window.FullCalendar.Calendar(el, {
|
|
190
|
+
initialView: 'dayGridTwoWeek',
|
|
191
|
+
views: {
|
|
192
|
+
dayGridTwoWeek: {
|
|
193
|
+
type: 'dayGrid',
|
|
194
|
+
duration: { weeks: 2 },
|
|
195
|
+
buttonText: '2 semaines',
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
locale: 'fr',
|
|
199
|
+
firstDay: 1,
|
|
200
|
+
height: 'auto',
|
|
201
|
+
headerToolbar: {
|
|
202
|
+
left: 'prev,next',
|
|
203
|
+
center: 'title',
|
|
204
|
+
right: '',
|
|
205
|
+
},
|
|
206
|
+
navLinks: false,
|
|
207
|
+
eventTimeFormat: { hour: '2-digit', minute: '2-digit', hour12: false },
|
|
208
|
+
// Hide date numbers in column headers (keep only weekday)
|
|
209
|
+
dayHeaderFormat: { weekday: 'short' },
|
|
210
|
+
// Force day number to be numeric only (avoid '1 janvier' style labels)
|
|
211
|
+
dayCellContent: function(arg) {
|
|
212
|
+
try {
|
|
213
|
+
const t = String(arg.dayNumberText || '');
|
|
214
|
+
const m = t.match(/^\d+/);
|
|
215
|
+
return { html: '<span class="fc-daygrid-day-number">' + (m ? m[0] : t) + '</span>' };
|
|
216
|
+
} catch (e) {
|
|
217
|
+
return { html: '<span class="fc-daygrid-day-number">' + String(arg.dayNumberText || '') + '</span>' };
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
// Ensure day numbers never include month text (e.g. '1 janvier')
|
|
221
|
+
dayCellDidMount: function(arg) {
|
|
222
|
+
try {
|
|
223
|
+
const el = arg && arg.el ? arg.el.querySelector('.fc-daygrid-day-number') : null;
|
|
224
|
+
if (!el) return;
|
|
225
|
+
const t = String(el.textContent || '');
|
|
226
|
+
const m = t.match(/^\s*(\d+)/);
|
|
227
|
+
if (m) el.textContent = m[1];
|
|
228
|
+
} catch (e) {}
|
|
229
|
+
},
|
|
230
|
+
eventClassNames: function(arg) {
|
|
231
|
+
try {
|
|
232
|
+
const ev = arg && arg.event;
|
|
233
|
+
if (!ev) return ['onekite-singleday'];
|
|
234
|
+
if (ev.extendedProps && (ev.extendedProps.multiDay === true || ev.extendedProps.days > 1)) return ['onekite-multiday'];
|
|
235
|
+
if (ev.start && ev.end) {
|
|
236
|
+
const diff = (new Date(ev.end)).getTime() - (new Date(ev.start)).getTime();
|
|
237
|
+
return [diff > 86400000 ? 'onekite-multiday' : 'onekite-singleday'];
|
|
238
|
+
}
|
|
239
|
+
} catch (e) {}
|
|
240
|
+
return ['onekite-singleday'];
|
|
241
|
+
},
|
|
242
|
+
// Render: dot for single-day, pill for multi-day
|
|
243
|
+
eventContent: function(arg) {
|
|
244
|
+
const ev = arg && arg.event;
|
|
245
|
+
const isMulti = (function(){
|
|
246
|
+
try {
|
|
247
|
+
if (!ev || !ev.start || !ev.end) return false;
|
|
248
|
+
if (ev.extendedProps && (ev.extendedProps.multiDay === true || ev.extendedProps.days > 1)) return true;
|
|
249
|
+
const s = new Date(ev.start);
|
|
250
|
+
const e = new Date(ev.end);
|
|
251
|
+
const diff = e.getTime() - s.getTime();
|
|
252
|
+
return diff > 86400000; // strictly more than 1 day
|
|
253
|
+
} catch (e) { return false; }
|
|
254
|
+
})();
|
|
255
|
+
|
|
256
|
+
if (isMulti) {
|
|
257
|
+
const spacer = document.createElement('span');
|
|
258
|
+
spacer.className = 'onekite-pill-spacer';
|
|
259
|
+
spacer.appendChild(document.createTextNode('\u00A0'));
|
|
260
|
+
return { domNodes: [spacer] };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const bg = (ev.backgroundColor || (ev.extendedProps && ev.extendedProps.backgroundColor) || '').trim();
|
|
264
|
+
const border = (ev.borderColor || '').trim();
|
|
265
|
+
const color = bg || border || '#3788d8';
|
|
266
|
+
const wrap = document.createElement('span');
|
|
267
|
+
wrap.className = 'onekite-dot-wrap';
|
|
268
|
+
const dot = document.createElement('span');
|
|
269
|
+
dot.className = 'onekite-dot';
|
|
270
|
+
dot.style.backgroundColor = color;
|
|
271
|
+
wrap.appendChild(dot);
|
|
272
|
+
return { domNodes: [wrap] };
|
|
273
|
+
},
|
|
274
|
+
events: function(info, successCallback, failureCallback) {
|
|
275
|
+
const qs = new URLSearchParams({ start: info.startStr, end: info.endStr, widget: '1' });
|
|
276
|
+
fetch(eventsEndpoint + '?' + qs.toString(), { credentials: 'same-origin' })
|
|
277
|
+
.then((r) => r.json())
|
|
278
|
+
.then((json) => successCallback(json || []))
|
|
279
|
+
.catch((e) => failureCallback(e));
|
|
280
|
+
},
|
|
281
|
+
eventDataTransform: function(ev) {
|
|
282
|
+
try {
|
|
283
|
+
if (ev && ev.extendedProps && String(ev.extendedProps.type) === 'special') {
|
|
284
|
+
// Unify rendering with rentals in the widget: use allDay layout
|
|
285
|
+
ev.allDay = true;
|
|
286
|
+
}
|
|
287
|
+
} catch (e) {}
|
|
288
|
+
return ev;
|
|
289
|
+
},
|
|
290
|
+
dateClick: function() { window.location.href = calUrl; },
|
|
291
|
+
eventDidMount: function(info) {
|
|
292
|
+
try {
|
|
293
|
+
const ev = info.event;
|
|
294
|
+
// Improve centering for single-day dots by centering the harness,
|
|
295
|
+
// same visual behavior as rentals.
|
|
296
|
+
try {
|
|
297
|
+
const cls = (info.el && info.el.classList) ? info.el.classList : null;
|
|
298
|
+
const isSingle = cls && cls.contains('onekite-singleday');
|
|
299
|
+
const harness = info.el && info.el.closest ? info.el.closest('.fc-daygrid-event-harness') : null;
|
|
300
|
+
if (harness && harness.classList) {
|
|
301
|
+
if (isSingle) {
|
|
302
|
+
harness.classList.add('onekite-harness-dot');
|
|
303
|
+
} else {
|
|
304
|
+
harness.classList.remove('onekite-harness-dot');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
} catch (e) {}
|
|
308
|
+
|
|
309
|
+
const ep = ev.extendedProps || {};
|
|
310
|
+
const title = (ep.itemNameLine || ep.title || ev.title || '').toString();
|
|
311
|
+
const statusLabel = (function(s){
|
|
312
|
+
const map = {
|
|
313
|
+
pending: 'En attente de validation',
|
|
314
|
+
awaiting_payment: 'Validée – paiement en attente',
|
|
315
|
+
paid: 'Payée',
|
|
316
|
+
rejected: 'Rejetée',
|
|
317
|
+
expired: 'Expirée',
|
|
318
|
+
};
|
|
319
|
+
const k = String(s || '');
|
|
320
|
+
return map[k] || '';
|
|
321
|
+
});
|
|
322
|
+
const status = (String(ep.type || '') === 'reservation') ? statusLabel(ep.status) : '';
|
|
323
|
+
const reservedBy = (String(ep.type || '') === 'reservation') ? String(ep.reservedByUsername || '') : '';
|
|
324
|
+
const html = '' +
|
|
325
|
+
'<div style="font-weight:600; margin-bottom:2px;">' + escapeHtml(title) + '</div>' +
|
|
326
|
+
(reservedBy ? ('<div style="opacity:.85; margin-top:2px; font-size:.85em;">Réservée par ' + escapeHtml(reservedBy) + '.</div>') : '') +
|
|
327
|
+
(status ? ('<div style="opacity:.75; margin-top:2px; font-size:.85em;">' + escapeHtml(status) + '</div>') : '');
|
|
328
|
+
|
|
329
|
+
// Hover (desktop)
|
|
330
|
+
info.el.addEventListener('pointerenter', () => {
|
|
331
|
+
// Only show on devices that actually hover
|
|
332
|
+
if (window.matchMedia && window.matchMedia('(hover: hover)').matches === false) return;
|
|
333
|
+
setTipContent(html);
|
|
334
|
+
const rect = info.el.getBoundingClientRect();
|
|
335
|
+
showTipAt(rect.left + rect.width / 2 + window.scrollX, rect.top + window.scrollY);
|
|
336
|
+
}, { passive: true });
|
|
337
|
+
info.el.addEventListener('pointerleave', hideTip, { passive: true });
|
|
338
|
+
|
|
339
|
+
// Tap (mobile) / click (fallback)
|
|
340
|
+
const openTipFromPoint = (pt) => {
|
|
341
|
+
setTipContent(html);
|
|
342
|
+
showTipAt((pt.clientX || 0) + window.scrollX, (pt.clientY || 0) + window.scrollY);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
info.el.addEventListener('pointerdown', (e) => {
|
|
346
|
+
// On touch devices, FullCalendar can swallow click events during swipe;
|
|
347
|
+
// pointerdown is more reliable.
|
|
348
|
+
if (e && e.preventDefault) e.preventDefault();
|
|
349
|
+
if (e && e.stopPropagation) e.stopPropagation();
|
|
350
|
+
openTipFromPoint(e);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
info.el.addEventListener('touchstart', (e) => {
|
|
354
|
+
const t = e.touches && e.touches[0];
|
|
355
|
+
if (!t) return;
|
|
356
|
+
if (e && e.stopPropagation) e.stopPropagation();
|
|
357
|
+
openTipFromPoint(t);
|
|
358
|
+
}, { passive: true });
|
|
359
|
+
} catch (e) {}
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
calendar.render();
|
|
364
|
+
|
|
365
|
+
// Mobile swipe (left/right) to navigate weeks
|
|
366
|
+
try {
|
|
367
|
+
let touchStartX = null;
|
|
368
|
+
let touchStartY = null;
|
|
369
|
+
el.addEventListener('touchstart', (e) => {
|
|
370
|
+
const t = e.touches && e.touches[0];
|
|
371
|
+
if (!t) return;
|
|
372
|
+
touchStartX = t.clientX;
|
|
373
|
+
touchStartY = t.clientY;
|
|
374
|
+
}, { passive: true });
|
|
375
|
+
el.addEventListener('touchend', (e) => {
|
|
376
|
+
const t = e.changedTouches && e.changedTouches[0];
|
|
377
|
+
if (!t || touchStartX === null || touchStartY === null) return;
|
|
378
|
+
const dx = t.clientX - touchStartX;
|
|
379
|
+
const dy = t.clientY - touchStartY;
|
|
380
|
+
touchStartX = null;
|
|
381
|
+
touchStartY = null;
|
|
382
|
+
if (Math.abs(dx) < 55) return;
|
|
383
|
+
if (Math.abs(dx) < Math.abs(dy) * 1.2) return; // mostly vertical
|
|
384
|
+
if (dx < 0) {
|
|
385
|
+
calendar.next();
|
|
386
|
+
} else {
|
|
387
|
+
calendar.prev();
|
|
388
|
+
}
|
|
389
|
+
}, { passive: true });
|
|
390
|
+
} catch (e) {}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Widgets can be rendered after ajaxify; delay a tick.
|
|
394
|
+
setTimeout(() => init().catch(() => {}), 0);
|
|
395
|
+
})();
|
|
396
|
+
</script>
|
|
397
|
+
|
|
398
|
+
<style>
|
|
399
|
+
.onekite-twoweeks .fc .fc-toolbar-title { font-size: 1rem; }
|
|
400
|
+
.onekite-twoweeks .fc .fc-button { padding: .2rem .35rem; font-size: .75rem; }
|
|
401
|
+
.onekite-twoweeks .fc .fc-daygrid-day-number { font-size: .75rem; padding: 2px; }
|
|
402
|
+
.onekite-twoweeks .fc .fc-col-header-cell-cushion { font-size: .75rem; }
|
|
403
|
+
|
|
404
|
+
/* Single-day: show only a dot */
|
|
405
|
+
.onekite-twoweeks .fc .fc-event.onekite-singleday { background: transparent !important; border: none !important; }
|
|
406
|
+
.onekite-twoweeks .fc .fc-daygrid-event.onekite-singleday,
|
|
407
|
+
.onekite-twoweeks .fc .fc-daygrid-block-event.onekite-singleday {
|
|
408
|
+
background: transparent !important;
|
|
409
|
+
border: none !important;
|
|
410
|
+
box-shadow: none !important;
|
|
411
|
+
}
|
|
412
|
+
.onekite-twoweeks .fc .fc-daygrid-block-event.onekite-singleday .fc-event-main { padding: 0 !important; }
|
|
413
|
+
|
|
414
|
+
/* Multi-day: keep FullCalendar bar, but hide text and make it a pill */
|
|
415
|
+
.onekite-twoweeks .fc .fc-event.onekite-multiday {
|
|
416
|
+
border-radius: 999px !important;
|
|
417
|
+
overflow: hidden;
|
|
418
|
+
}
|
|
419
|
+
.onekite-twoweeks .fc .fc-daygrid-event.onekite-multiday,
|
|
420
|
+
.onekite-twoweeks .fc .fc-daygrid-block-event.onekite-multiday {
|
|
421
|
+
border-radius: 999px !important;
|
|
422
|
+
box-shadow: none !important;
|
|
423
|
+
}
|
|
424
|
+
.onekite-twoweeks .fc .fc-event.onekite-multiday .fc-event-main { padding: 0 !important; }
|
|
425
|
+
.onekite-twoweeks .fc .fc-event.onekite-multiday .fc-event-title,
|
|
426
|
+
.onekite-twoweeks .fc .fc-event.onekite-multiday .fc-event-time { display:none !important; }
|
|
427
|
+
|
|
428
|
+
.onekite-twoweeks .fc .fc-daygrid-event-harness { margin-top: 2px; }
|
|
429
|
+
/* Center single-day dots inside the day cell (match rentals) */
|
|
430
|
+
.onekite-twoweeks .fc .fc-daygrid-event-harness.onekite-harness-dot {
|
|
431
|
+
display: flex;
|
|
432
|
+
justify-content: center;
|
|
433
|
+
}
|
|
434
|
+
.onekite-twoweeks .fc .fc-daygrid-event { padding: 0 !important; }
|
|
435
|
+
.onekite-twoweeks .fc .fc-event-main { color: inherit; }
|
|
436
|
+
.onekite-twoweeks .fc .fc-event-title { display:none; }
|
|
437
|
+
.onekite-dot-wrap { display:flex; align-items:center; justify-content:center; width: 100%; }
|
|
438
|
+
.onekite-dot { width: 10px; height: 10px; border-radius: 999px; display:inline-block; }
|
|
439
|
+
.onekite-pill-spacer { display:block; height: 10px; line-height: 10px; }
|
|
440
|
+
.onekite-cal-tooltip {
|
|
441
|
+
position: absolute;
|
|
442
|
+
z-index: 99999;
|
|
443
|
+
max-width: 260px;
|
|
444
|
+
background: rgba(0,0,0,.92);
|
|
445
|
+
color: #fff;
|
|
446
|
+
padding: 8px 10px;
|
|
447
|
+
border-radius: 10px;
|
|
448
|
+
font-size: 0.9rem;
|
|
449
|
+
box-shadow: 0 8px 24px rgba(0,0,0,.25);
|
|
450
|
+
pointer-events: none;
|
|
451
|
+
}
|
|
452
|
+
</style>
|
|
453
|
+
`;
|
|
454
|
+
|
|
455
|
+
data = data || {};
|
|
456
|
+
data.html = html;
|
|
457
|
+
return data;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
module.exports = widgets;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// We use NodeBB's route helpers for page routes so the rendered page includes
|
|
4
|
+
// the normal client bundle (ajaxify/requirejs). The helper signatures differ
|
|
5
|
+
// across NodeBB versions, but for NodeBB v4.x the order is:
|
|
6
|
+
// setupPageRoute(router, path, middlewaresArray, handler)
|
|
7
|
+
// setupAdminPageRoute(router, path, middlewaresArray, handler)
|
|
8
|
+
const routeHelpers = require.main.require('./src/routes/helpers');
|
|
9
|
+
|
|
10
|
+
const controllers = require('./lib/controllers.js');
|
|
11
|
+
const api = require('./lib/api');
|
|
12
|
+
const admin = require('./lib/admin');
|
|
13
|
+
const scheduler = require('./lib/scheduler');
|
|
14
|
+
const helloassoWebhook = require('./lib/helloassoWebhook');
|
|
15
|
+
const widgets = require('./lib/widgets');
|
|
16
|
+
const bodyParser = require('body-parser');
|
|
17
|
+
|
|
18
|
+
const Plugin = {};
|
|
19
|
+
|
|
20
|
+
const isFn = (fn) => typeof fn === 'function';
|
|
21
|
+
const mw = (...fns) => fns.filter(isFn);
|
|
22
|
+
|
|
23
|
+
Plugin.init = async function (params) {
|
|
24
|
+
const { router, middleware } = params;
|
|
25
|
+
|
|
26
|
+
// Build middleware arrays safely and always spread them into Express route methods.
|
|
27
|
+
// Express will throw if any callback is undefined, so we filter strictly.
|
|
28
|
+
// Auth middlewares differ slightly depending on NodeBB configuration.
|
|
29
|
+
// In v4, some installs rely on middleware.authenticate rather than exposeUid.
|
|
30
|
+
const baseExpose = mw(middleware && (middleware.authenticate || middleware.exposeUid));
|
|
31
|
+
const publicExpose = baseExpose;
|
|
32
|
+
const publicAuth = mw(middleware && (middleware.authenticate || middleware.exposeUid), middleware && middleware.ensureLoggedIn);
|
|
33
|
+
|
|
34
|
+
// Robust admin guard: avoid middleware.admin.checkPrivileges() signature differences
|
|
35
|
+
// across NodeBB versions. We treat membership in the 'administrators' group as admin.
|
|
36
|
+
const Groups = require.main.require('./src/groups');
|
|
37
|
+
async function adminOnly(req, res, next) {
|
|
38
|
+
try {
|
|
39
|
+
const uid = req.uid || (req.user && req.user.uid) || (req.session && req.session.uid);
|
|
40
|
+
if (!uid) {
|
|
41
|
+
return res.status(401).json({ status: { code: 'not-authorized', message: 'Not logged in' } });
|
|
42
|
+
}
|
|
43
|
+
const isAdmin = await Groups.isMember(uid, 'administrators');
|
|
44
|
+
if (!isAdmin) {
|
|
45
|
+
return res.status(403).json({ status: { code: 'not-authorized', message: 'Not allowed' } });
|
|
46
|
+
}
|
|
47
|
+
return next();
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return next(err);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const adminMws = mw(
|
|
53
|
+
middleware && (middleware.authenticate || middleware.exposeUid),
|
|
54
|
+
middleware && middleware.ensureLoggedIn,
|
|
55
|
+
adminOnly
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Page routes (HTML)
|
|
59
|
+
// IMPORTANT: pass an ARRAY for middlewares (even if empty), otherwise
|
|
60
|
+
// setupPageRoute will throw "middlewares is not iterable".
|
|
61
|
+
routeHelpers.setupPageRoute(router, '/calendar', mw(), controllers.renderCalendar);
|
|
62
|
+
routeHelpers.setupAdminPageRoute(router, '/admin/plugins/calendar-onekite', mw(), admin.renderAdmin);
|
|
63
|
+
|
|
64
|
+
// Public API (JSON) — NodeBB 4.x only (v3 API)
|
|
65
|
+
router.get('/api/v3/plugins/calendar-onekite/events', ...publicExpose, api.getEvents);
|
|
66
|
+
router.get('/api/v3/plugins/calendar-onekite/items', ...publicExpose, api.getItems);
|
|
67
|
+
router.get('/api/v3/plugins/calendar-onekite/capabilities', ...publicExpose, api.getCapabilities);
|
|
68
|
+
|
|
69
|
+
// Maintenance / audit (restricted to validator groups)
|
|
70
|
+
router.get('/api/v3/plugins/calendar-onekite/maintenance', ...publicExpose, api.getMaintenance);
|
|
71
|
+
router.put('/api/v3/plugins/calendar-onekite/maintenance', ...publicExpose, api.setMaintenanceAll);
|
|
72
|
+
router.put('/api/v3/plugins/calendar-onekite/maintenance/:itemId', ...publicExpose, api.setMaintenance);
|
|
73
|
+
router.get('/api/v3/plugins/calendar-onekite/audit', ...publicExpose, api.getAudit);
|
|
74
|
+
router.post('/api/v3/plugins/calendar-onekite/audit/purge', ...publicExpose, api.purgeAudit);
|
|
75
|
+
|
|
76
|
+
router.post('/api/v3/plugins/calendar-onekite/reservations', ...publicExpose, api.createReservation);
|
|
77
|
+
router.get('/api/v3/plugins/calendar-onekite/reservations/:rid', ...publicExpose, api.getReservationDetails);
|
|
78
|
+
router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/approve', ...publicExpose, api.approveReservation);
|
|
79
|
+
router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/refuse', ...publicExpose, api.refuseReservation);
|
|
80
|
+
router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/cancel', ...publicExpose, api.cancelReservation);
|
|
81
|
+
|
|
82
|
+
router.post('/api/v3/plugins/calendar-onekite/special-events', ...publicExpose, api.createSpecialEvent);
|
|
83
|
+
router.get('/api/v3/plugins/calendar-onekite/special-events/:eid', ...publicExpose, api.getSpecialEventDetails);
|
|
84
|
+
router.delete('/api/v3/plugins/calendar-onekite/special-events/:eid', ...publicExpose, api.deleteSpecialEvent);
|
|
85
|
+
|
|
86
|
+
// Admin API (JSON)
|
|
87
|
+
const adminBases = ['/api/v3/admin/plugins/calendar-onekite'];
|
|
88
|
+
|
|
89
|
+
adminBases.forEach((base) => {
|
|
90
|
+
router.get(`${base}/settings`, ...adminMws, admin.getSettings);
|
|
91
|
+
router.put(`${base}/settings`, ...adminMws, admin.saveSettings);
|
|
92
|
+
|
|
93
|
+
router.get(`${base}/pending`, ...adminMws, admin.listPending);
|
|
94
|
+
router.put(`${base}/reservations/:rid/approve`, ...adminMws, admin.approveReservation);
|
|
95
|
+
router.put(`${base}/reservations/:rid/refuse`, ...adminMws, admin.refuseReservation);
|
|
96
|
+
|
|
97
|
+
router.post(`${base}/purge`, ...adminMws, admin.purgeByYear);
|
|
98
|
+
router.get(`${base}/debug`, ...adminMws, admin.debugHelloAsso);
|
|
99
|
+
// Accounting / exports
|
|
100
|
+
router.get(`${base}/accounting`, ...adminMws, admin.getAccounting);
|
|
101
|
+
router.get(`${base}/accounting.csv`, ...adminMws, admin.exportAccountingCsv);
|
|
102
|
+
router.post(`${base}/accounting/purge`, ...adminMws, admin.purgeAccounting);
|
|
103
|
+
|
|
104
|
+
// Purge special events by year
|
|
105
|
+
router.post(`${base}/special-events/purge`, ...adminMws, admin.purgeSpecialEventsByYear);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// HelloAsso callback endpoint (hardened)
|
|
109
|
+
// - Only accepts POST
|
|
110
|
+
// - Verifies x-ha-signature (HMAC SHA-256) using the configured client secret
|
|
111
|
+
// - Basic replay protection
|
|
112
|
+
// NOTE: we capture the raw body for signature verification.
|
|
113
|
+
const helloassoJson = bodyParser.json({
|
|
114
|
+
verify: (req, _res, buf) => {
|
|
115
|
+
req.rawBody = buf;
|
|
116
|
+
},
|
|
117
|
+
type: ['application/json', 'application/*+json'],
|
|
118
|
+
});
|
|
119
|
+
// Accept webhook on both legacy root path and namespaced plugin path.
|
|
120
|
+
// Some reverse proxies block unknown root paths, so /plugins/... is recommended.
|
|
121
|
+
router.post('/helloasso', helloassoJson, helloassoWebhook.handler);
|
|
122
|
+
router.post('/plugins/calendar-onekite/helloasso', helloassoJson, helloassoWebhook.handler);
|
|
123
|
+
|
|
124
|
+
// Optional: health checks
|
|
125
|
+
router.get('/helloasso', (req, res) => res.json({ ok: true }));
|
|
126
|
+
router.get('/plugins/calendar-onekite/helloasso', (req, res) => res.json({ ok: true }));
|
|
127
|
+
|
|
128
|
+
scheduler.start();
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
Plugin.addAdminNavigation = async function (header) {
|
|
132
|
+
header.plugins = header.plugins || [];
|
|
133
|
+
header.plugins.push({
|
|
134
|
+
route: '/plugins/calendar-onekite',
|
|
135
|
+
icon: 'fa-calendar',
|
|
136
|
+
name: 'Calendar Onekite',
|
|
137
|
+
});
|
|
138
|
+
return header;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
// Ensure our transactional emails always get a subject.
|
|
143
|
+
// NodeBB's Emailer.sendToEmail signature expects (template, email, language, params),
|
|
144
|
+
// so plugins typically inject/modify the subject via this hook.
|
|
145
|
+
Plugin.emailModify = async function (data) {
|
|
146
|
+
try {
|
|
147
|
+
if (!data || !data.template) return data;
|
|
148
|
+
const tpl = String(data.template);
|
|
149
|
+
if (!tpl.startsWith('calendar-onekite_')) return data;
|
|
150
|
+
|
|
151
|
+
// If the caller provided a subject (we pass it in params.subject), copy it to data.subject.
|
|
152
|
+
const provided = data.params && data.params.subject ? String(data.params.subject) : '';
|
|
153
|
+
if (provided && (!data.subject || !String(data.subject).trim())) {
|
|
154
|
+
data.subject = provided;
|
|
155
|
+
}
|
|
156
|
+
} catch (e) {}
|
|
157
|
+
return data;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Widgets
|
|
161
|
+
Plugin.defineWidgets = widgets.defineWidgets;
|
|
162
|
+
Plugin.renderTwoWeeksWidget = widgets.renderTwoWeeksWidget;
|
|
163
|
+
|
|
164
|
+
module.exports = Plugin;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nodebb-plugin-onekite-calendar",
|
|
3
|
+
"version": "1.3.6",
|
|
4
|
+
"description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
|
|
5
|
+
"main": "library.js",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=18"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {},
|
|
11
|
+
"nbbpm": {
|
|
12
|
+
"compatibility": "^4.0.0"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "nodebb-plugin-onekite-calendar",
|
|
3
|
+
"name": "Onekite Calendar",
|
|
4
|
+
"description": "Equipment reservation calendar (FullCalendar) with admin approval & HelloAsso checkout",
|
|
5
|
+
"url": "https://www.onekite.com/calendar",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"hook": "static:app.load",
|
|
9
|
+
"method": "init"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"hook": "filter:admin.header.build",
|
|
13
|
+
"method": "addAdminNavigation"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"hook": "filter:email.modify",
|
|
17
|
+
"method": "emailModify"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"hook": "filter:widgets.getWidgets",
|
|
21
|
+
"method": "defineWidgets"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"hook": "filter:widget.render:calendar-onekite-twoweeks",
|
|
25
|
+
"method": "renderTwoWeeksWidget"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"staticDirs": {
|
|
29
|
+
"public": "./public"
|
|
30
|
+
},
|
|
31
|
+
"templates": "./templates",
|
|
32
|
+
"modules": {
|
|
33
|
+
"../admin/plugins/calendar-onekite.js": "./public/admin.js",
|
|
34
|
+
"admin/plugins/calendar-onekite": "./public/admin.js"
|
|
35
|
+
},
|
|
36
|
+
"scripts": [
|
|
37
|
+
"public/client.js"
|
|
38
|
+
],
|
|
39
|
+
"acpScripts": [
|
|
40
|
+
"public/admin.js"
|
|
41
|
+
],
|
|
42
|
+
"version": "1.3.6"
|
|
43
|
+
}
|