nodebb-plugin-calendar-onekite 11.1.21 → 11.1.23

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/public/client.js CHANGED
@@ -1,161 +1,224 @@
1
- /* global FullCalendar, ajaxify */
1
+ /* globals define, require */
2
+ 'use strict';
2
3
 
3
- define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alerts, bootbox, hooks) {
4
- 'use strict';
5
-
6
- function showAlert(type, msg) {
7
- try {
8
- if (alerts && typeof alerts[type] === 'function') {
9
- alerts[type](msg);
10
- return;
11
- }
12
- } catch (e) {}
13
- alert(msg);
4
+ define('forum/calendar-onekite', ['api', 'bootbox', 'alerts', 'hooks'], function (api, bootbox, alerts, hooks) {
5
+ function fmtEuros(cents) {
6
+ const v = (Number(cents || 0) / 100);
7
+ return v.toLocaleString(undefined, { style: 'currency', currency: 'EUR' });
14
8
  }
15
9
 
16
- async function fetchJson(url, opts) {
17
- const res = await fetch(url, {
18
- credentials: 'same-origin',
19
- headers: { 'Content-Type': 'application/json' },
20
- ...opts,
21
- });
22
- if (!res.ok) {
23
- throw new Error(`${res.status}`);
24
- }
25
- return await res.json();
10
+ function daysBetween(startStr, endStr) {
11
+ const s = new Date(startStr + 'T00:00:00Z');
12
+ const e = new Date(endStr + 'T00:00:00Z');
13
+ const d = Math.max(1, Math.round((e - s) / 86400000));
14
+ return d;
26
15
  }
27
16
 
28
- async function loadItems() {
29
- try {
30
- return await fetchJson('/api/v3/plugins/calendar-onekite/items');
31
- } catch (e) {
32
- return [];
33
- }
17
+ async function loadCatalog() {
18
+ const res = await fetch('/api/v3/plugins/calendar-onekite/catalog', { credentials: 'same-origin' })
19
+ .catch(() => null);
20
+ if (res && res.ok) return (await res.json()).items || [];
21
+ const res2 = await fetch('/api/plugins/calendar-onekite/catalog', { credentials: 'same-origin' });
22
+ const j = await res2.json();
23
+ return j.items || [];
34
24
  }
35
25
 
36
- async function requestReservation(payload) {
37
- return await fetchJson('/api/v3/plugins/calendar-onekite/reservations', {
38
- method: 'POST',
39
- body: JSON.stringify(payload),
26
+ function showReservationModal(selection, catalog, onSubmit) {
27
+ const start = selection.startStr; // YYYY-MM-DD
28
+ const end = selection.endStr; // YYYY-MM-DD (exclusive)
29
+ const days = daysBetween(start, end);
30
+
31
+ const rows = catalog.map((it) => {
32
+ const price = fmtEuros(it.priceCents || 0);
33
+ return `<div class="onekite-item-row d-flex justify-content-between align-items-center py-1" data-id="${it.id}" data-price="${it.priceCents || 0}">
34
+ <label class="mb-0 d-flex align-items-center gap-2" style="cursor:pointer;">
35
+ <input type="checkbox" class="onekite-item-check" data-id="${it.id}">
36
+ <span>${it.name}</span>
37
+ </label>
38
+ <span class="text-muted">${price}</span>
39
+ </div>`;
40
+ }).join('');
41
+
42
+ const html = `
43
+ <div>
44
+ <div class="mb-2"><strong>Dates :</strong> ${start} → ${end} (${days} jour(s))</div>
45
+ <div class="mb-2 text-muted small">Sélectionne un ou plusieurs matériels. (Tu peux aussi Ctrl+clic sur une ligne.)</div>
46
+ <div class="border rounded p-2" style="max-height: 240px; overflow:auto;" id="onekite-item-list">${rows || '<div class="text-muted">Aucun matériel</div>'}</div>
47
+ <div class="mt-3 d-flex justify-content-between align-items-center">
48
+ <div><strong>Total estimé :</strong> <span id="onekite-total">0 €</span></div>
49
+ <div class="text-muted small">prix/jour × ${days}</div>
50
+ </div>
51
+ </div>
52
+ `;
53
+
54
+ const dialog = bootbox.dialog({
55
+ title: 'Demande de réservation',
56
+ message: html,
57
+ buttons: {
58
+ cancel: { label: 'Annuler', className: 'btn-secondary' },
59
+ ok: {
60
+ label: 'Envoyer',
61
+ className: 'btn-primary',
62
+ callback: function () {
63
+ const checks = dialog.find('.onekite-item-check:checked');
64
+ const ids = Array.from(checks).map(c => c.getAttribute('data-id'));
65
+ if (!ids.length) {
66
+ alerts.error('Sélectionne au moins un matériel.');
67
+ return false;
68
+ }
69
+ onSubmit({ start, end, itemIds: ids, days });
70
+ return false; // keep open; we'll close manually on success
71
+ },
72
+ },
73
+ },
40
74
  });
41
- }
42
75
 
43
- function formatDt(d) {
44
- try {
45
- return new Date(d).toLocaleString();
46
- } catch (e) {
47
- return String(d);
76
+ function computeTotal() {
77
+ const selected = dialog.find('.onekite-item-check:checked');
78
+ let sum = 0;
79
+ selected.each(function () {
80
+ const id = this.getAttribute('data-id');
81
+ const row = dialog.find(`.onekite-item-row[data-id="${id}"]`);
82
+ sum += Number(row.attr('data-price') || 0);
83
+ });
84
+ const total = sum * days;
85
+ dialog.find('#onekite-total').text(fmtEuros(total));
48
86
  }
49
- }
50
-
51
- async function openReservationDialog(selectionInfo, items) {
52
- const start = selectionInfo.start;
53
- const end = selectionInfo.end;
54
87
 
55
- const optionsHtml = items.map(it => `<option value="${String(it.id).replace(/"/g, '&quot;')}">${it.name}</option>`).join('');
88
+ dialog.on('change', '.onekite-item-check', computeTotal);
56
89
 
57
- return new Promise((resolve) => {
58
- bootbox.dialog({
59
- title: 'Demander une réservation',
60
- message: `
61
- <div class="mb-2"><strong>Période</strong><br>${formatDt(start)} → ${formatDt(end)}</div>
62
- <div class="mb-2">
63
- <label class="form-label">Matériel</label>
64
- <select class="form-select" id="onekite-item">${optionsHtml}</select>
65
- </div>
66
- `,
67
- buttons: {
68
- cancel: {
69
- label: 'Annuler',
70
- className: 'btn-secondary',
71
- },
72
- ok: {
73
- label: 'Envoyer',
74
- className: 'btn-primary',
75
- callback: function () {
76
- const el = document.getElementById('onekite-item');
77
- const itemId = el ? el.value : '';
78
- const item = items.find(i => String(i.id) === String(itemId));
79
- resolve({ itemId, itemName: item ? item.name : itemId });
80
- },
81
- },
82
- },
83
- });
90
+ // Ctrl+click on row toggles
91
+ dialog.on('click', '.onekite-item-row', function (ev) {
92
+ if (ev.ctrlKey || ev.metaKey) {
93
+ const chk = this.querySelector('.onekite-item-check');
94
+ chk.checked = !chk.checked;
95
+ computeTotal();
96
+ }
84
97
  });
85
- }
86
98
 
87
- async function init(selector) {
88
- const el = document.querySelector(selector);
89
- if (!el) {
90
- return;
91
- }
99
+ computeTotal();
100
+ return dialog;
101
+ }
92
102
 
93
- if (typeof FullCalendar === 'undefined') {
94
- showAlert('error', 'FullCalendar non chargé');
95
- return;
96
- }
103
+ async function initCalendar() {
104
+ const el = document.getElementById('onekite-calendar');
105
+ if (!el || !window.FullCalendar) return;
97
106
 
98
- const items = await loadItems();
107
+ const catalog = await loadCatalog();
99
108
 
100
- const calendar = new FullCalendar.Calendar(el, {
109
+ const calendar = new window.FullCalendar.Calendar(el, {
101
110
  initialView: 'dayGridMonth',
102
- locale: 'fr',
103
111
  selectable: true,
104
112
  selectMirror: true,
105
- events: async function (info, successCallback, failureCallback) {
106
- try {
107
- const qs = new URLSearchParams({ start: info.startStr, end: info.endStr });
108
- const data = await fetchJson(`/api/v3/plugins/calendar-onekite/events?${qs.toString()}`);
109
- successCallback(data);
110
- } catch (e) {
111
- failureCallback(e);
112
- }
113
- },
114
- select: async function (info) {
113
+ locale: 'fr',
114
+ height: 'auto',
115
+ displayEventTime: false,
116
+ dayMaxEvents: true,
117
+ events: async function (info, success, failure) {
115
118
  try {
116
- if (!items || !items.length) {
117
- showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
118
- return;
119
+ const url = new URL('/api/v3/plugins/calendar-onekite/events', window.location.origin);
120
+ url.searchParams.set('start', info.startStr);
121
+ url.searchParams.set('end', info.endStr);
122
+ let r = await fetch(url.toString(), { credentials: 'same-origin' });
123
+ if (!r.ok) {
124
+ // fallback
125
+ const url2 = new URL('/api/plugins/calendar-onekite/events', window.location.origin);
126
+ url2.searchParams.set('start', info.startStr);
127
+ url2.searchParams.set('end', info.endStr);
128
+ r = await fetch(url2.toString(), { credentials: 'same-origin' });
119
129
  }
120
- const chosen = await openReservationDialog(info, items);
121
- if (!chosen || !chosen.itemId) return;
122
- await requestReservation({ start: info.start, end: info.end, itemId: chosen.itemId, itemName: chosen.itemName });
123
- showAlert('success', 'Demande envoyée (en attente de validation).');
124
- calendar.refetchEvents();
125
- } catch (e) {
126
- showAlert('error', 'Impossible de créer la demande (droits/groupe ?).');
130
+ const j = await r.json();
131
+ success(j);
132
+ } catch (e) { failure(e); }
133
+ },
134
+ select: function (selection) {
135
+ if (!catalog.length) {
136
+ alerts.error('Aucun matériel disponible (catalogue HelloAsso non chargé).');
137
+ calendar.unselect();
138
+ return;
127
139
  }
140
+
141
+ const dialog = showReservationModal(selection, catalog, async function (payload) {
142
+ try {
143
+ let r = await fetch('/api/v3/plugins/calendar-onekite/reservations', {
144
+ method: 'POST',
145
+ headers: { 'Content-Type': 'application/json' },
146
+ credentials: 'same-origin',
147
+ body: JSON.stringify(payload),
148
+ });
149
+ if (!r.ok) {
150
+ r = await fetch('/api/plugins/calendar-onekite/reservations', {
151
+ method: 'POST',
152
+ headers: { 'Content-Type': 'application/json' },
153
+ credentials: 'same-origin',
154
+ body: JSON.stringify(payload),
155
+ });
156
+ }
157
+ const j = await r.json();
158
+ if (!j.ok) throw new Error(j?.status?.message || 'Erreur');
159
+ alerts.success('Demande envoyée.');
160
+ dialog.modal('hide');
161
+ calendar.unselect();
162
+ calendar.refetchEvents();
163
+ } catch (e) {
164
+ alerts.error(e.message || 'Erreur');
165
+ }
166
+ });
128
167
  },
129
- dateClick: async function (info) {
130
- // One-day selection convenience
131
- const start = info.date;
132
- const end = new Date(start.getTime() + 24 * 60 * 60 * 1000);
133
- calendar.select(start, end);
168
+ eventClick: function (info) {
169
+ const p = info.event.extendedProps || {};
170
+ const items = (p.items || []).map(it => `• ${it.name}`).join('\n');
171
+ const total = p.totalCents ? fmtEuros(p.totalCents) : '';
172
+ bootbox.alert({
173
+ title: info.event.title,
174
+ message: `<pre style="white-space: pre-wrap;">${items || ''}\n\nJours: ${p.days || ''}\nTotal estimé: ${total}</pre>`,
175
+ });
134
176
  },
135
177
  });
136
178
 
137
179
  calendar.render();
138
180
  }
139
181
 
140
- // Auto-init on /calendar when ajaxify finishes rendering.
141
- function autoInit(data) {
142
- try {
143
- const tpl = data && data.template ? data.template.name : (ajaxify && ajaxify.data && ajaxify.data.template ? ajaxify.data.template.name : '');
144
- if (tpl === 'calendar-onekite') {
145
- init('#onekite-calendar');
146
- }
147
- } catch (e) {}
182
+ function loadAssetsOnce() {
183
+ // Load FullCalendar from CDN only once
184
+ if (window.FullCalendar) return Promise.resolve();
185
+ const css = document.createElement('link');
186
+ css.rel = 'stylesheet';
187
+ css.href = 'https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.css';
188
+ document.head.appendChild(css);
189
+
190
+ const js1 = document.createElement('script');
191
+ js1.src = 'https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.js';
192
+ js1.async = true;
193
+
194
+ const js2 = document.createElement('script');
195
+ js2.src = 'https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.11/locales-all.global.min.js';
196
+ js2.async = true;
197
+
198
+ return new Promise((resolve) => {
199
+ js1.onload = () => {
200
+ document.head.appendChild(js2);
201
+ js2.onload = () => resolve();
202
+ };
203
+ document.head.appendChild(js1);
204
+ });
148
205
  }
149
206
 
150
- if (hooks && typeof hooks.on === 'function') {
151
- hooks.on('action:ajaxify.end', autoInit);
207
+ function onAjaxifyEnd(data) {
208
+ if (ajaxify?.data?.template?.name !== 'calendar-onekite') return;
209
+ loadAssetsOnce().then(initCalendar);
152
210
  }
153
211
 
154
- // In case the page is served as the initial load (no ajaxify navigation)
155
- // call once after current tick.
156
- setTimeout(() => autoInit({ template: (ajaxify && ajaxify.data && ajaxify.data.template) || { name: '' } }), 0);
212
+ hooks.on('action:ajaxify.end', onAjaxifyEnd);
157
213
 
158
214
  return {
159
- init,
215
+ init: function () {
216
+ // If landing directly
217
+ if (ajaxify?.data?.template?.name === 'calendar-onekite') {
218
+ loadAssetsOnce().then(initCalendar);
219
+ }
220
+ }
160
221
  };
161
222
  });
223
+
224
+ require(['forum/calendar-onekite'], function (m) { m.init(); });
@@ -1,91 +1,88 @@
1
1
  <!-- IMPORT admin/partials/settings/header.tpl -->
2
2
 
3
3
  <div class="row">
4
- <div class="col-lg-9">
5
- <h1>Calendar OneKite</h1>
6
-
7
- <form id="onekite-settings-form" class="mt-3">
8
- <div class="mb-3">
9
- <label class="form-label">Groupes autorisés à réserver (csv)</label>
10
- <input class="form-control" name="allowedGroups" placeholder="ex: registered-users,membres">
11
- </div>
12
-
13
- <div class="mb-3">
14
- <label class="form-label">Groupes notifiés (csv)</label>
15
- <input class="form-control" name="notifyGroups" placeholder="ex: administrators">
4
+ <div class="col-lg-12">
5
+ <h2>Calendar OneKite</h2>
6
+
7
+ <ul class="nav nav-tabs mb-3" role="tablist">
8
+ <li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#onekite-settings" role="tab">Paramètres</a></li>
9
+ <li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#onekite-pending" role="tab">Demandes en attente</a></li>
10
+ <li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#onekite-debug" role="tab">Debug</a></li>
11
+ </ul>
12
+
13
+ <div class="tab-content">
14
+ <div class="tab-pane fade show active" id="onekite-settings" role="tabpanel">
15
+ <div class="card mb-3">
16
+ <div class="card-body">
17
+ <div class="mb-3">
18
+ <label class="form-label">Environnement HelloAsso</label>
19
+ <select class="form-select" id="helloassoEnv">
20
+ <option value="sandbox">Sandbox</option>
21
+ <option value="prod">Production</option>
22
+ </select>
23
+ </div>
24
+
25
+ <div class="row">
26
+ <div class="col-md-6 mb-3">
27
+ <label class="form-label">Client ID</label>
28
+ <input class="form-control" id="helloassoClientId" />
29
+ </div>
30
+ <div class="col-md-6 mb-3">
31
+ <label class="form-label">Client Secret</label>
32
+ <input class="form-control" id="helloassoClientSecret" placeholder="*** pour conserver" />
33
+ </div>
34
+ </div>
35
+
36
+ <div class="row">
37
+ <div class="col-md-4 mb-3">
38
+ <label class="form-label">Organization Slug</label>
39
+ <input class="form-control" id="helloassoOrganizationSlug" />
40
+ </div>
41
+ <div class="col-md-4 mb-3">
42
+ <label class="form-label">Form Type</label>
43
+ <input class="form-control" id="helloassoFormType" placeholder="shop" />
44
+ </div>
45
+ <div class="col-md-4 mb-3">
46
+ <label class="form-label">Form Slug</label>
47
+ <input class="form-control" id="helloassoFormSlug" />
48
+ </div>
49
+ </div>
50
+
51
+ <button class="btn btn-primary" id="onekite-save">Enregistrer</button>
52
+ </div>
53
+ </div>
54
+
55
+ <div class="card">
56
+ <div class="card-body">
57
+ <h5>Purge calendrier</h5>
58
+ <div class="input-group" style="max-width: 300px;">
59
+ <input class="form-control" id="onekite-purge-year" placeholder="YYYY" />
60
+ <button class="btn btn-danger" id="onekite-purge">Purger</button>
61
+ </div>
62
+ <div class="text-muted small mt-2">Supprime toutes les réservations dont le début est dans l'année donnée.</div>
63
+ </div>
64
+ </div>
16
65
  </div>
17
66
 
18
- <div class="mb-3">
19
- <label class="form-label">Durée de blocage en attente (minutes)</label>
20
- <input class="form-control" name="pendingHoldMinutes" placeholder="5">
21
- </div>
22
-
23
- <h4 class="mt-4">HelloAsso</h4>
24
-
25
- <div class="mb-3">
26
- <label class="form-label">Environnement</label>
27
- <select class="form-select" name="helloassoEnv">
28
- <option value="prod">Production</option>
29
- <option value="sandbox">Sandbox</option>
30
- </select>
67
+ <div class="tab-pane fade" id="onekite-pending" role="tabpanel">
68
+ <div class="card">
69
+ <div class="card-body">
70
+ <div id="onekite-pending-list" class="small text-muted">Chargement…</div>
71
+ </div>
72
+ </div>
31
73
  </div>
32
74
 
33
- <div class="mb-3">
34
- <label class="form-label">Client ID</label>
35
- <input class="form-control" name="helloassoClientId">
75
+ <div class="tab-pane fade" id="onekite-debug" role="tabpanel">
76
+ <div class="card">
77
+ <div class="card-body">
78
+ <button class="btn btn-secondary" id="onekite-debug-run">Tester le chargement du matériel (HelloAsso)</button>
79
+ <pre class="mt-3" id="onekite-debug-output" style="white-space: pre-wrap;"></pre>
80
+ </div>
81
+ </div>
36
82
  </div>
37
83
 
38
- <div class="mb-3">
39
- <label class="form-label">Client Secret</label>
40
- <input class="form-control" name="helloassoClientSecret" type="password">
41
- </div>
42
-
43
- <div class="mb-3">
44
- <label class="form-label">Organization Slug</label>
45
- <input class="form-control" name="helloassoOrganizationSlug">
46
- </div>
47
-
48
- <div class="mb-3">
49
- <label class="form-label">Form Type</label>
50
- <input class="form-control" name="helloassoFormType" placeholder="membership | crowdfunding | donation | ticketing | ...">
51
- </div>
52
-
53
- <div class="mb-3">
54
- <label class="form-label">Form Slug</label>
55
- <input class="form-control" name="helloassoFormSlug">
56
- </div>
57
-
58
- <button type="button" class="btn btn-primary" id="onekite-save">Enregistrer</button>
59
- </form>
60
-
61
- <hr class="my-4" />
62
-
63
- <h4>Demandes en attente</h4>
64
- <div id="onekite-pending" class="list-group"></div>
65
-
66
- <hr class="my-4" />
67
-
68
- <h4>Purge</h4>
69
- <div class="d-flex gap-2 align-items-center">
70
- <input class="form-control" style="max-width: 160px;" id="onekite-purge-year" placeholder="YYYY">
71
- <button type="button" class="btn btn-outline-danger" id="onekite-purge">Purger</button>
72
84
  </div>
73
-
74
- <hr class="my-4" />
75
-
76
- <h4>Debug HelloAsso</h4>
77
- <p class="text-muted">Teste la récupération du token et la liste du matériel (items).</p>
78
- <button type="button" class="btn btn-secondary" id="onekite-debug-run">Tester le chargement du matériel</button>
79
- <pre id="onekite-debug-output" class="mt-3 p-3 bg-light" style="max-height: 360px; overflow: auto;"></pre>
80
85
  </div>
81
86
  </div>
82
87
 
83
- <script>
84
- require(['admin/plugins/calendar-onekite'], function (mod) {
85
- if (mod && mod.init) {
86
- mod.init();
87
- }
88
- });
89
- </script>
90
-
91
88
  <!-- IMPORT admin/partials/settings/footer.tpl -->
@@ -1,17 +1,7 @@
1
- <div class="container">
2
- <div class="row">
3
- <div class="col-12">
4
- <h1>Calendrier</h1>
5
- <div id="onekite-calendar" style="margin-top: 1rem;"></div>
6
- </div>
1
+ <div class="calendar-onekite-page">
2
+ <div class="d-flex align-items-center justify-content-between mb-3">
3
+ <h2 class="mb-0">Calendrier</h2>
4
+ <div class="text-muted small">Réservations (jours uniquement)</div>
7
5
  </div>
6
+ <div id="onekite-calendar"></div>
8
7
  </div>
9
-
10
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.css" />
11
- <script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.js"></script>
12
- <script src="https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.11/locales-all.global.min.js"></script>
13
-
14
- <!--
15
- No inline require() here.
16
- The plugin's forum script auto-initialises on the calendar page via ajaxify.
17
- -->