@acms-subir/core 0.3.3 → 0.3.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 +36 -0
- package/cli/preview.mjs +8 -4
- package/cli/sections.mjs +4 -2
- package/docs/mobile.md +1 -1
- package/docs/theme-authoring.md +19 -3
- package/integration.mjs +21 -4
- package/package.json +14 -12
- package/src/actions/index.ts +186 -3
- package/src/components/booking/BookingFlow.astro +6 -3
- package/src/components/calendar/BookingsHead.astro +51 -0
- package/src/components/calendar/CalendarBoard.tsx +618 -0
- package/src/components/calendar/MiniMonth.tsx +54 -0
- package/src/components/calendar/NewEventDialog.tsx +44 -0
- package/src/components/calendar/QuickAdd.tsx +263 -0
- package/src/components/calendar/RoomHolds.tsx +203 -0
- package/src/components/calendar/WhereItLands.tsx +51 -0
- package/src/components/editor/EventPanel.tsx +23 -2
- package/src/components/editor/PostEditor.tsx +32 -4
- package/src/layouts/Admin.astro +5 -6
- package/src/layouts/Site.astro +29 -2
- package/src/lib/backup/content.ts +6 -1
- package/src/lib/booking/agent.ts +3 -2
- package/src/lib/booking/approval.ts +70 -0
- package/src/lib/booking/holds.ts +120 -0
- package/src/lib/booking/service.ts +37 -3
- package/src/lib/calendar/agent-schema.ts +9 -1
- package/src/lib/calendar/agent.ts +16 -1
- package/src/lib/calendar/board.ts +171 -0
- package/src/lib/calendar/config.ts +3 -0
- package/src/lib/calendar/grid.ts +169 -0
- package/src/lib/calendar/migration.ts +16 -0
- package/src/lib/calendar/render.ts +9 -7
- package/src/lib/calendar/schedule.ts +166 -0
- package/src/lib/content-service.ts +5 -0
- package/src/lib/drafts.ts +1 -1
- package/src/lib/mobile/calendar.ts +10 -6
- package/src/lib/mobile/http.ts +6 -0
- package/src/lib/repo/migrations.ts +3 -0
- package/src/lib/repo/sql.ts +28 -16
- package/src/lib/repo/types.ts +2 -2
- package/src/lib/scheduler.ts +3 -1
- package/src/lib/schema.ts +49 -0
- package/src/lib/sections.ts +33 -8
- package/src/lib/site-chrome.ts +34 -0
- package/src/lib/theme-assets.ts +16 -0
- package/src/lib/theme.ts +17 -3
- package/src/middleware.ts +6 -2
- package/src/pages/admin/calendar/bookings.astro +32 -26
- package/src/pages/admin/calendar/calendars.astro +4 -263
- package/src/pages/admin/calendar/events/index.astro +41 -83
- package/src/pages/admin/calendar/index.astro +43 -0
- package/src/pages/admin/calendar/resources/[id].astro +27 -5
- package/src/pages/admin/calendar/resources/index.astro +16 -17
- package/src/pages/admin/calendar/resources/link.astro +52 -12
- package/src/pages/admin/calendar/settings.astro +458 -0
- package/src/pages/admin/integrations.astro +5 -11
- package/src/pages/book/[resource].ics.ts +7 -1
- package/src/styles/admin.css +48 -0
- package/src/styles/cards.css +5 -1
- package/src/styles/editor.css +36 -0
- package/src/styles/schedule.css +325 -0
- package/src/styles/sections.css +19 -16
- package/src/theme-sdk/fields.mjs +5 -1
- package/src/theme-sdk/index.ts +5 -1
- package/src/virtual.d.ts +3 -0
- package/src/pages/admin/calendar/index.ts +0 -4
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Admin from '../../../../layouts/Admin.astro';
|
|
3
3
|
import Modal from '../../../../components/Modal.astro';
|
|
4
|
+
import NewEventDialog from '../../../../components/calendar/NewEventDialog';
|
|
5
|
+
import { bookingsEnabled } from '../../../../lib/calendar/config';
|
|
6
|
+
import { todayIn } from '../../../../lib/calendar/board';
|
|
4
7
|
import ContentList, { type ContentItem } from '../../../../components/ContentList.astro';
|
|
5
8
|
import { getRepo } from '../../../../lib/repo';
|
|
6
9
|
import { slugify, type Status } from '../../../../lib/schema';
|
|
@@ -16,9 +19,10 @@ import { siteLocale } from '../../../../lib/dates';
|
|
|
16
19
|
import { waitUntilFrom } from '../../../../lib/webhooks';
|
|
17
20
|
import { invalidateRedirectCache } from '../../../../middleware';
|
|
18
21
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// action
|
|
22
|
+
// Schedule → List: upcoming first, grouped by the month each event next
|
|
23
|
+
// happens. Subscribed calendars appear as read-only rows with a "Make an
|
|
24
|
+
// editable copy" action. New events open the quick-add dialog (with its mini
|
|
25
|
+
// month); subscribing lives in Calendar settings.
|
|
22
26
|
if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
23
27
|
|
|
24
28
|
const repo = await getRepo(Astro.locals);
|
|
@@ -107,23 +111,6 @@ if (Astro.request.method === 'POST') {
|
|
|
107
111
|
}
|
|
108
112
|
}
|
|
109
113
|
|
|
110
|
-
// live mirror of an external calendar
|
|
111
|
-
if (op === 'subscribe') {
|
|
112
|
-
const url = normalizeCalendarSrc(String(form.get('url') || ''));
|
|
113
|
-
const name = String(form.get('name') || '').trim() || 'Imported calendar';
|
|
114
|
-
if (!url) return back('Paste a Google Calendar link, a calendar ID, or an .ics feed URL.');
|
|
115
|
-
try {
|
|
116
|
-
await loadFeed(url, now);
|
|
117
|
-
} catch (err) {
|
|
118
|
-
return back(err instanceof Error ? err.message : 'Couldn’t load that feed.');
|
|
119
|
-
}
|
|
120
|
-
const base = slugify(name);
|
|
121
|
-
let slug = base;
|
|
122
|
-
for (let n = 2; await repo.getCalendarBySlug(slug); n++) slug = `${base}-${n}`;
|
|
123
|
-
await repo.createCalendar({ name, slug, kind: 'feed', feedUrl: url, color: safeColor(String(form.get('color') || '#2f9e6e')) });
|
|
124
|
-
return back(`Subscribed to “${name}” — its events now show on your calendar.`);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
114
|
// one-time import: .ics / .csv file, or an ICS URL, into drafts
|
|
128
115
|
if (op === 'import') {
|
|
129
116
|
const calendarId = String(form.get('calendarId') || '');
|
|
@@ -221,16 +208,16 @@ if ((scope === 'upcoming' || scope === 'all') && feedCals.length) {
|
|
|
221
208
|
const uid = uids.get(`${occ.title}|${occ.location}`) ?? '';
|
|
222
209
|
items.push({
|
|
223
210
|
id: `feed-${c.id}-${occ.start}`,
|
|
224
|
-
href: `/admin/calendar/
|
|
211
|
+
href: `/admin/calendar/settings#cal-${c.id}`,
|
|
225
212
|
title: occ.title,
|
|
226
213
|
sub: '',
|
|
227
214
|
meta: [occ.location, `from ${c.name}`].filter(Boolean).join(' · '),
|
|
228
215
|
when: occ.allDay ? f.range : f.range,
|
|
229
216
|
at: occ.start,
|
|
230
217
|
status: 'feed',
|
|
231
|
-
statusLabel: '
|
|
218
|
+
statusLabel: 'Subscribed · read-only',
|
|
232
219
|
readonly: true,
|
|
233
|
-
action: uid ? { label: '
|
|
220
|
+
action: uid ? { label: 'Make editable copy', fields: { op: 'copy', calendarId: c.id, uid } } : undefined,
|
|
234
221
|
});
|
|
235
222
|
}
|
|
236
223
|
} catch (err) {
|
|
@@ -261,72 +248,45 @@ const tomorrowIso = `${tomorrow.y}-${String(tomorrow.m).padStart(2, '0')}-${Stri
|
|
|
261
248
|
const subBits = [
|
|
262
249
|
`${counts.upcoming} upcoming`,
|
|
263
250
|
counts.drafts ? `${counts.drafts} draft${counts.drafts === 1 ? '' : 's'}` : '',
|
|
264
|
-
feedCals.length ? `${feedCals.length}
|
|
251
|
+
feedCals.length ? `${feedCals.length} subscribed calendar${feedCals.length === 1 ? '' : 's'}` : '',
|
|
265
252
|
].filter(Boolean);
|
|
266
253
|
---
|
|
267
254
|
|
|
268
|
-
<Admin title="
|
|
255
|
+
<Admin title="Schedule" section="calendar:schedule">
|
|
269
256
|
<div class="page-head">
|
|
270
257
|
<div>
|
|
271
|
-
<h1>
|
|
258
|
+
<h1>All events</h1>
|
|
272
259
|
<p class="sub">{subBits.join(' · ')} · public at <a href={CALENDAR.base} target="_blank" rel="noopener">{CALENDAR.base}</a></p>
|
|
273
260
|
</div>
|
|
274
261
|
<div class="page-actions">
|
|
275
|
-
<
|
|
276
|
-
|
|
262
|
+
<nav class="seg" aria-label="View">
|
|
263
|
+
<a href="/admin/calendar">Month</a>
|
|
264
|
+
<a href="/admin/calendar?view=week">Week</a>
|
|
265
|
+
<a class="on" href="/admin/calendar/events" aria-current="page">List</a>
|
|
266
|
+
</nav>
|
|
267
|
+
<a class="btn ico" href="/admin/calendar/settings" title="Calendar settings" aria-label="Calendar settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg></a>
|
|
268
|
+
<details class="newmenu">
|
|
269
|
+
<summary class="btn primary">New <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d={chev} /></svg></summary>
|
|
270
|
+
<div class="newmenu-pop" role="menu">
|
|
271
|
+
<button type="button" role="menuitem" data-new-event>Event <kbd>N</kbd></button>
|
|
272
|
+
{bookingsEnabled() && <a role="menuitem" href="/admin/calendar/bookings?block=1">Block time <small>room closed, time off</small></a>}
|
|
273
|
+
<button type="button" role="menuitem" data-modal="import-events">Import events… <small>.ics or .csv</small></button>
|
|
274
|
+
</div>
|
|
275
|
+
</details>
|
|
277
276
|
</div>
|
|
278
277
|
</div>
|
|
279
278
|
|
|
280
279
|
{notice && <p class="notice" role="status">{notice}</p>}
|
|
281
280
|
{feedErrors.map((e) => <p class="notice warn">Couldn’t refresh {e}</p>)}
|
|
282
281
|
|
|
283
|
-
<
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
<label class="field">
|
|
287
|
-
<span>Title</span>
|
|
288
|
-
<input type="text" name="title" placeholder="Fall Festival" required autofocus />
|
|
289
|
-
</label>
|
|
290
|
-
<div class="field-row">
|
|
291
|
-
<label class="field"><span>Date</span><input type="date" name="date" value={tomorrowIso} required /></label>
|
|
292
|
-
<label class="field"><span>Time <em class="hint">blank = all day</em></span><input type="time" name="time" value="10:00" step="300" /></label>
|
|
293
|
-
</div>
|
|
294
|
-
{nativeCals.length > 0 && (
|
|
295
|
-
<label class="field">
|
|
296
|
-
<span>Calendar</span>
|
|
297
|
-
<select name="calendarId">
|
|
298
|
-
<option value="">None — main calendar only</option>
|
|
299
|
-
{nativeCals.map((c) => <option value={c.id} selected={c.id === calFilter}>{c.name}</option>)}
|
|
300
|
-
</select>
|
|
301
|
-
</label>
|
|
302
|
-
)}
|
|
303
|
-
<div class="modal-foot">
|
|
304
|
-
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
305
|
-
<button class="btn primary" type="submit">Create event</button>
|
|
306
|
-
</div>
|
|
307
|
-
<p class="hint modal-note">Creates a draft and opens the editor, where you add the image, location, button and repeat rule.</p>
|
|
308
|
-
</form>
|
|
309
|
-
</Modal>
|
|
282
|
+
<NewEventDialog client:load calendars={nativeCals.map((c) => ({ id: c.id, name: c.name, color: safeColor(c.color) }))}
|
|
283
|
+
defaultCalendarId={settings.calendar.defaultCalendarId || calFilter} weekStart={settings.calendar.weekStart} locale={locale}
|
|
284
|
+
today={todayIn(tz)} day={tomorrowIso} open={Astro.url.searchParams.has('new')} />
|
|
310
285
|
|
|
311
|
-
<Modal id="import-events" title="
|
|
312
|
-
<
|
|
313
|
-
<button type="button" role="tab" aria-selected="true" data-tab="subscribe">Subscribe to a calendar</button>
|
|
314
|
-
<button type="button" role="tab" aria-selected="false" data-tab="once">Import once</button>
|
|
315
|
-
</div>
|
|
316
|
-
<form method="post" data-panel="subscribe">
|
|
317
|
-
<input type="hidden" name="op" value="subscribe" />
|
|
318
|
-
<p class="hint">Mirror a Google, Apple or Outlook calendar live. Its events show on your calendar and stay in sync, read-only.</p>
|
|
319
|
-
<label class="field"><span>Name</span><input type="text" name="name" placeholder="Staff calendar" required /></label>
|
|
320
|
-
<label class="field"><span>Calendar link</span><input class="mono" type="text" name="url" placeholder="Google Calendar link, calendar ID, or an .ics URL" required /></label>
|
|
321
|
-
<label class="field"><span>Color</span><input type="color" name="color" value="#2f9e6e" /></label>
|
|
322
|
-
<div class="modal-foot">
|
|
323
|
-
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
324
|
-
<button class="btn primary" type="submit">Subscribe</button>
|
|
325
|
-
</div>
|
|
326
|
-
</form>
|
|
327
|
-
<form method="post" enctype="multipart/form-data" data-panel="once" hidden>
|
|
286
|
+
<Modal id="import-events" title="Import events" open={Astro.url.searchParams.has('import')}>
|
|
287
|
+
<form method="post" enctype="multipart/form-data">
|
|
328
288
|
<input type="hidden" name="op" value="import" />
|
|
329
|
-
<p class="hint">Copy events in as editable drafts. Works with .ics exports from Google, Apple and Outlook, or a spreadsheet saved as .csv with Title and Start date columns
|
|
289
|
+
<p class="hint">Copy events in once, as editable drafts. Works with .ics exports from Google, Apple and Outlook, or a spreadsheet saved as .csv with Title and Start date columns. To keep a calendar in sync instead, <a href="/admin/calendar/settings?subscribe=1#subscribed">subscribe to it</a>.</p>
|
|
330
290
|
<label class="field"><span>File</span><input type="file" name="file" accept=".ics,.csv,text/calendar,text/csv" /></label>
|
|
331
291
|
<label class="field"><span>Or a calendar link</span><input class="mono" type="text" name="url" placeholder="https://calendar.google.com/…" /></label>
|
|
332
292
|
{nativeCals.length > 0 && (
|
|
@@ -348,10 +308,9 @@ const subBits = [
|
|
|
348
308
|
{events.length === 0 && feedCals.length === 0 ? (
|
|
349
309
|
<div class="empty-state">
|
|
350
310
|
<b>No events yet</b>
|
|
351
|
-
<p>
|
|
311
|
+
<p>Events are things that happen on a date. Add one, or import a calendar file from Google, Apple or Outlook.</p>
|
|
352
312
|
<div class="empty-actions">
|
|
353
|
-
<button class="btn" type="button" data-
|
|
354
|
-
<button class="btn primary" type="button" data-modal="new-event">New event</button>
|
|
313
|
+
<button class="btn primary" type="button" data-new-event>New event</button>
|
|
355
314
|
</div>
|
|
356
315
|
</div>
|
|
357
316
|
) : (
|
|
@@ -386,7 +345,7 @@ const subBits = [
|
|
|
386
345
|
<ContentList items={items} noun="event" timeZone={settings.timezone || undefined} groupByMonth={true} empty="No events match the current filters." />
|
|
387
346
|
{items.length === 0 && (
|
|
388
347
|
<p class="hint" style="padding:24px 8px">
|
|
389
|
-
{scope === 'upcoming' ? 'Nothing coming up.' : 'No events match the current filters.'} <
|
|
348
|
+
{scope === 'upcoming' ? 'Nothing coming up.' : 'No events match the current filters.'} <button class="linklike" type="button" data-new-event>Add an event</button>
|
|
390
349
|
</p>
|
|
391
350
|
)}
|
|
392
351
|
</>
|
|
@@ -399,10 +358,9 @@ const subBits = [
|
|
|
399
358
|
if (!(e.target instanceof Node && d.contains(e.target))) d.open = false;
|
|
400
359
|
});
|
|
401
360
|
});
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
}));
|
|
361
|
+
// the New ▾ menu closes like the filter menus
|
|
362
|
+
document.addEventListener('click', (e) => {
|
|
363
|
+
const m = document.querySelector<HTMLDetailsElement>('details.newmenu[open]');
|
|
364
|
+
if (m && (!(e.target instanceof Node) || !m.contains(e.target) || (e.target as Element).closest('[role=menuitem]'))) m.open = false;
|
|
365
|
+
});
|
|
408
366
|
</script>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../../layouts/Admin.astro';
|
|
3
|
+
import CalendarBoard from '../../../components/calendar/CalendarBoard';
|
|
4
|
+
import { getRepo } from '../../../lib/repo';
|
|
5
|
+
import { CALENDAR, bookingsEnabled } from '../../../lib/calendar/config';
|
|
6
|
+
import { loadBoard, todayIn } from '../../../lib/calendar/board';
|
|
7
|
+
import { isIsoDay, isIsoMonth, monthWeeks, weekDays } from '../../../lib/calendar/grid';
|
|
8
|
+
import { siteLocale } from '../../../lib/dates';
|
|
9
|
+
|
|
10
|
+
// Schedule: the Calendar section's home — a month (or week) grid of every
|
|
11
|
+
// event, draft, subscribed calendar and booking. ?m=YYYY-MM picks the month,
|
|
12
|
+
// ?view=week&d=YYYY-MM-DD a week; the list lives at /admin/calendar/events.
|
|
13
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
14
|
+
if (Astro.url.searchParams.get('view') === 'list') return Astro.redirect('/admin/calendar/events');
|
|
15
|
+
|
|
16
|
+
const repo = await getRepo(Astro.locals);
|
|
17
|
+
const settings = await repo.getSettings();
|
|
18
|
+
const tz = settings.timezone || 'UTC';
|
|
19
|
+
const today = todayIn(tz);
|
|
20
|
+
const weekStart = settings.calendar.weekStart;
|
|
21
|
+
const view = Astro.url.searchParams.get('view') === 'week' ? 'week' : 'month';
|
|
22
|
+
const d = Astro.url.searchParams.get('d') ?? '';
|
|
23
|
+
const m = Astro.url.searchParams.get('m') ?? '';
|
|
24
|
+
const anchor = isIsoDay(d) ? d : isIsoMonth(m) ? `${m}-01` : today;
|
|
25
|
+
const weeks = monthWeeks(anchor.slice(0, 7), weekStart);
|
|
26
|
+
const [from, to] = view === 'week' ? [weekDays(anchor, weekStart)[0], weekDays(anchor, weekStart)[6]] : [weeks[0][0], weeks[weeks.length - 1][6]];
|
|
27
|
+
const initial = await loadBoard(repo, settings, from, to);
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<Admin title="Schedule" section="calendar:schedule" wide>
|
|
31
|
+
<CalendarBoard
|
|
32
|
+
client:load
|
|
33
|
+
initial={initial}
|
|
34
|
+
view={view}
|
|
35
|
+
anchor={anchor}
|
|
36
|
+
today={today}
|
|
37
|
+
weekStart={weekStart}
|
|
38
|
+
locale={siteLocale(settings)}
|
|
39
|
+
defaultCalendarId={settings.calendar.defaultCalendarId}
|
|
40
|
+
bookings={bookingsEnabled()}
|
|
41
|
+
listHref="/admin/calendar/events"
|
|
42
|
+
/>
|
|
43
|
+
</Admin>
|
|
@@ -9,6 +9,9 @@ import { describeLink } from '../../../../lib/booking/availability';
|
|
|
9
9
|
import { isValidTimeZone } from '../../../../lib/calendar/events';
|
|
10
10
|
import { normalizeCalendarSrc } from '../../../../lib/calendar/src';
|
|
11
11
|
import { siteOrigin } from '../../../../lib/seo';
|
|
12
|
+
import { approvalShort, linkApproval } from '../../../../lib/booking/approval';
|
|
13
|
+
import { eventHolds } from '../../../../lib/booking/holds';
|
|
14
|
+
import { fmt } from '../../../../lib/booking/view';
|
|
12
15
|
|
|
13
16
|
// One resource: details, weekly hours, date overrides, calendars that block
|
|
14
17
|
// time, who gets notified, the staff feed, and its booking links.
|
|
@@ -53,7 +56,7 @@ if (Astro.request.method === 'POST') {
|
|
|
53
56
|
const base = slugify(name);
|
|
54
57
|
let slug = base;
|
|
55
58
|
for (let n = 2; await repo.getBookingLinkBySlug(id, slug); n++) slug = `${base}-${n}`;
|
|
56
|
-
const l = await repo.createBookingLink({ resourceId: id, name, slug
|
|
59
|
+
const l = await repo.createBookingLink({ resourceId: id, name, slug });
|
|
57
60
|
return Astro.redirect(`/admin/calendar/resources/${id}/links/${l.id}`);
|
|
58
61
|
} else if (op === 'rotate') {
|
|
59
62
|
await repo.updateResource(id, { feedToken: token() });
|
|
@@ -65,14 +68,17 @@ if (Astro.request.method === 'POST') {
|
|
|
65
68
|
resource = (await repo.getResource(id))!;
|
|
66
69
|
}
|
|
67
70
|
const links = await repo.listBookingLinks(id);
|
|
71
|
+
// events holding this room in the next 90 days (lib/booking/holds.ts)
|
|
72
|
+
const holds = (await eventHolds(repo, settings, Date.now(), Date.now() + 90 * 86_400_000, id)).slice(0, 12);
|
|
73
|
+
const holdFmt = fmt(settings, resource.timezone || settings.timezone || 'UTC');
|
|
68
74
|
const staffFeed = origin + resourceFeedUrl(resource.slug, resource.feedToken);
|
|
69
75
|
const ovRows = [...resource.overrides, { date: '', endDate: '', ranges: [], label: '' }, { date: '', endDate: '', ranges: [], label: '' }];
|
|
70
76
|
---
|
|
71
77
|
|
|
72
|
-
<Admin title={resource.name} section="calendar:
|
|
78
|
+
<Admin title={resource.name} section="calendar:bookings">
|
|
73
79
|
<div class="page-head">
|
|
74
80
|
<div>
|
|
75
|
-
<p class="sub"><a href="/admin/calendar/resources">
|
|
81
|
+
<p class="sub"><a href="/admin/calendar/bookings">Bookings</a> › <a href="/admin/calendar/resources">Rooms & people</a> ›</p>
|
|
76
82
|
<h1>{resource.name}</h1>
|
|
77
83
|
<p class="sub"><a href={resourceUrl(resource.slug)} target="_blank" rel="noopener">{origin.replace(/^https?:\/\//, '')}{resourceUrl(resource.slug)} ↗</a></p>
|
|
78
84
|
</div>
|
|
@@ -89,7 +95,7 @@ const ovRows = [...resource.overrides, { date: '', endDate: '', ranges: [], labe
|
|
|
89
95
|
{links.map((l) => (
|
|
90
96
|
<a class="res-linkrow" href={`/admin/calendar/resources/${id}/links/${l.id}`}>
|
|
91
97
|
<span><b>{l.name}</b>{!l.active && <span class="pill lang">Off</span>}{l.visibility === 'unlisted' && <span class="pill lang">Unlisted</span>}</span>
|
|
92
|
-
<small>{describeLink(l)} · {l.mode === 'range' ? 'choose a length' : l.mode === 'day' ? 'whole days' : 'fixed length'} · <em class={
|
|
98
|
+
<small>{describeLink(l)} · {l.mode === 'range' ? 'choose a length' : l.mode === 'day' ? 'whole days' : 'fixed length'} · {(() => { const a = linkApproval(l, settings); return <em class={a.mode === 'auto' ? 'auto' : 'manual'}>{approvalShort(a)}{a.inherited ? ' (site setting)' : ''}</em>; })()}</small>
|
|
93
99
|
<code>{bookingLinkUrl(resource.slug, l.slug)}</code>
|
|
94
100
|
</a>
|
|
95
101
|
))}
|
|
@@ -97,10 +103,26 @@ const ovRows = [...resource.overrides, { date: '', endDate: '', ranges: [], labe
|
|
|
97
103
|
<form method="post" class="res-newlink">
|
|
98
104
|
<input type="hidden" name="op" value="link" />
|
|
99
105
|
<input type="text" name="linkName" placeholder="New link, e.g. “Event or reception”" required />
|
|
100
|
-
<button class="btn" type="submit">Add link</button>
|
|
106
|
+
<button class="btn" type="submit">Add booking link</button>
|
|
101
107
|
</form>
|
|
102
108
|
</section>
|
|
103
109
|
|
|
110
|
+
<section class="card card-pad">
|
|
111
|
+
<div class="card-head"><b>Held by events</b><span class="hint">Events that use this {resource.kind.toLowerCase()} can’t be booked over</span></div>
|
|
112
|
+
{holds.length === 0 ? (
|
|
113
|
+
<p class="hint" style="margin:0">No events are holding it in the next 90 days. Tick it under <b>Rooms</b> in an event’s settings to hold it.</p>
|
|
114
|
+
) : (
|
|
115
|
+
<div class="res-linklist">
|
|
116
|
+
{holds.map((h) => (
|
|
117
|
+
<a class="res-linkrow" href={`/admin/calendar/events/${h.eventId}`}>
|
|
118
|
+
<span><b>{h.title}</b></span>
|
|
119
|
+
<small>{holdFmt.day.format(h.start)} · {h.end - h.start >= 86_400_000 - 3_600_000 ? 'all day' : `${holdFmt.time.format(h.start)} – ${holdFmt.time.format(h.end)}`}</small>
|
|
120
|
+
</a>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
</section>
|
|
125
|
+
|
|
104
126
|
<section class="card card-pad">
|
|
105
127
|
<div class="card-head"><b>Open hours</b><span class="hint">The outer limit for every link</span></div>
|
|
106
128
|
<HoursEditor hours={resource.hours} prefix="hours" form="res-form" note={`Times in ${resource.timezone || settings.timezone || 'UTC'}. Links can narrow these hours but never extend them.`} />
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Admin from '../../../../layouts/Admin.astro';
|
|
3
3
|
import Modal from '../../../../components/Modal.astro';
|
|
4
|
+
import BookingsHead from '../../../../components/calendar/BookingsHead.astro';
|
|
5
|
+
import { approvalShort, linkApproval } from '../../../../lib/booking/approval';
|
|
4
6
|
import { getRepo } from '../../../../lib/repo';
|
|
5
7
|
import { slugify } from '../../../../lib/schema';
|
|
6
8
|
import { bookingsEnabled, CALENDAR, resourceUrl } from '../../../../lib/calendar/config';
|
|
@@ -9,8 +11,9 @@ import { availabilityContext } from '../../../../lib/booking/service';
|
|
|
9
11
|
import { describeLink, nextOpenings } from '../../../../lib/booking/availability';
|
|
10
12
|
import { fmt } from '../../../../lib/booking/view';
|
|
11
13
|
|
|
12
|
-
//
|
|
13
|
-
// shows its booking links
|
|
14
|
+
// Bookings → Rooms & people: everything people can book (rooms, people,
|
|
15
|
+
// vehicles, equipment). Each row shows its booking links, whether they
|
|
16
|
+
// confirm instantly, and when it's next free.
|
|
14
17
|
if (!bookingsEnabled()) return new Response(null, { status: 404 });
|
|
15
18
|
const repo = await getRepo(Astro.locals);
|
|
16
19
|
const settings = await repo.getSettings();
|
|
@@ -26,8 +29,9 @@ if (Astro.request.method === 'POST') {
|
|
|
26
29
|
const kind = String(form.get('kind') || CALENDAR.resourceKinds[0]).slice(0, 40);
|
|
27
30
|
const capacity = form.get('capacity') ? int(form.get('capacity'), 1, 100000, 1) : null;
|
|
28
31
|
const r = await repo.createResource({ name, slug, kind, capacity, hours: DEFAULT_HOURS, timezone: '' });
|
|
29
|
-
// every
|
|
30
|
-
|
|
32
|
+
// every room or person starts with one ready-to-share link that follows
|
|
33
|
+
// the site's approval setting (Calendar settings → Booking approval)
|
|
34
|
+
await repo.createBookingLink({ resourceId: r.id, name: kind === 'Person' ? 'Meeting' : 'Book', slug: 'book', duration: 60, step: 30 });
|
|
31
35
|
return Astro.redirect(`/admin/calendar/resources/${r.id}?created=1`);
|
|
32
36
|
}
|
|
33
37
|
if (op === 'move') {
|
|
@@ -40,7 +44,7 @@ if (Astro.request.method === 'POST') {
|
|
|
40
44
|
}
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
const [resources, links] = await Promise.all([repo.listResources(), repo.listBookingLinks()]);
|
|
47
|
+
const [resources, links, pending] = await Promise.all([repo.listResources(), repo.listBookingLinks(), repo.listBookings({ from: Date.now(), statuses: ['pending'], kinds: ['booking', 'rsvp'] })]);
|
|
44
48
|
const now = Date.now();
|
|
45
49
|
const rows = await Promise.all(resources.map(async (r) => {
|
|
46
50
|
const own = links.filter((l) => l.resourceId === r.id);
|
|
@@ -51,16 +55,11 @@ const rows = await Promise.all(resources.map(async (r) => {
|
|
|
51
55
|
}));
|
|
52
56
|
---
|
|
53
57
|
|
|
54
|
-
<Admin title="
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
<h1>Resources</h1>
|
|
58
|
-
<p class="sub">Rooms, people and things people can book · public list at <a href={CALENDAR.bookBase} target="_blank" rel="noopener">{CALENDAR.bookBase}</a></p>
|
|
59
|
-
</div>
|
|
60
|
-
<div class="page-actions"><button class="btn primary" type="button" data-modal="res-new">New resource</button></div>
|
|
61
|
-
</div>
|
|
58
|
+
<Admin title="Rooms & people" section="calendar:bookings">
|
|
59
|
+
<BookingsHead active="rooms" pending={pending.length} rooms={resources.length}
|
|
60
|
+
sub={`What people can book, and when it’s free · public list at ${CALENDAR.bookBase}`} />
|
|
62
61
|
|
|
63
|
-
<Modal id="
|
|
62
|
+
<Modal id="new-resource" title="New room or person" open={Astro.url.searchParams.has('new')}>
|
|
64
63
|
<form method="post">
|
|
65
64
|
<input type="hidden" name="op" value="create" />
|
|
66
65
|
<label class="field"><span>Name</span><input type="text" name="name" placeholder="Fellowship Hall" required autofocus /></label>
|
|
@@ -69,7 +68,7 @@ const rows = await Promise.all(resources.map(async (r) => {
|
|
|
69
68
|
<label class="field"><span>Capacity <em class="hint">optional</em></span><input type="number" name="capacity" min="1" placeholder="120" /></label>
|
|
70
69
|
</div>
|
|
71
70
|
<div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Create</button></div>
|
|
72
|
-
<p class="hint modal-note">Starts open weekdays 9–5 with one booking link that
|
|
71
|
+
<p class="hint modal-note">Starts open weekdays 9–5 with one booking link that follows your approval setting. Change both on the next screen.</p>
|
|
73
72
|
</form>
|
|
74
73
|
</Modal>
|
|
75
74
|
|
|
@@ -77,7 +76,7 @@ const rows = await Promise.all(resources.map(async (r) => {
|
|
|
77
76
|
<div class="empty-state">
|
|
78
77
|
<b>Nothing to book yet</b>
|
|
79
78
|
<p>Add a room, a person’s time, or a piece of equipment. Each gets its own booking page and links.</p>
|
|
80
|
-
<button class="btn primary" type="button" data-modal="
|
|
79
|
+
<button class="btn primary" type="button" data-modal="new-resource">Add a room or person</button>
|
|
81
80
|
</div>
|
|
82
81
|
) : (
|
|
83
82
|
<div class="res-list">
|
|
@@ -89,7 +88,7 @@ const rows = await Promise.all(resources.map(async (r) => {
|
|
|
89
88
|
<a class="res-main" href={`/admin/calendar/resources/${r.id}`}>
|
|
90
89
|
<b>{r.name} <span class="pill lang">{r.kind}</span>{r.visibility === 'unlisted' && <span class="pill lang">Unlisted</span>}</b>
|
|
91
90
|
<small>{[r.capacity ? `Up to ${r.capacity}` : '', `${own.length} link${own.length === 1 ? '' : 's'}`, next ? `next free ${next}` : 'no openings soon'].filter(Boolean).join(' · ')}</small>
|
|
92
|
-
<span class="res-links">{own.map((l) => <span class={`res-link${l.active ? '' : ' is-off'}`}>{l.name} · {describeLink(l)} · <em class={
|
|
91
|
+
<span class="res-links">{own.map((l) => { const a = linkApproval(l, settings); return <span class={`res-link${l.active ? '' : ' is-off'}`}>{l.name} · {describeLink(l)} · <em class={a.mode === 'auto' ? 'auto' : 'manual'}>{approvalShort(a)}</em></span>; })}</span>
|
|
93
92
|
</a>
|
|
94
93
|
<div class="cal-actions">
|
|
95
94
|
<a class="icon-btn" href={resourceUrl(r.slug)} target="_blank" rel="noopener" title="View booking page" aria-label="View booking page"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" /></svg></a>
|
|
@@ -7,9 +7,12 @@ import { bookingLinkUrl, bookingsEnabled } from '../../../../lib/calendar/config
|
|
|
7
7
|
import { hoursFromForm, int, list, questionsFromForm } from '../../../../lib/booking/forms';
|
|
8
8
|
import { describeLink } from '../../../../lib/booking/availability';
|
|
9
9
|
import { siteOrigin } from '../../../../lib/seo';
|
|
10
|
+
import { APPROVAL_LABEL, linkApproval, siteApproval } from '../../../../lib/booking/approval';
|
|
11
|
+
import { ApprovalRules } from '../../../../lib/schema';
|
|
10
12
|
|
|
11
|
-
// One booking link
|
|
12
|
-
// own, when it's open (within the
|
|
13
|
+
// One booking link: how people book, how bookings get approved (follow the
|
|
14
|
+
// site setting, or its own mode + rules), when it's open (within the room or
|
|
15
|
+
// person's hours), and what we ask.
|
|
13
16
|
if (!bookingsEnabled()) return new Response(null, { status: 404 });
|
|
14
17
|
const repo = await getRepo(Astro.locals);
|
|
15
18
|
const resource = await repo.getResource(Astro.params.id!);
|
|
@@ -33,6 +36,14 @@ if (Astro.request.method === 'POST') {
|
|
|
33
36
|
for (let n = 2; ; n++) { const hit = await repo.getBookingLinkBySlug(resource.id, slug); if (!hit || hit.id === link.id) break; slug = `${base}-${n}`; }
|
|
34
37
|
const mode = (['slots', 'range', 'day'] as const).find((m) => m === form.get('mode')) ?? 'slots';
|
|
35
38
|
const lengths = list(String(form.get('lengths') || '')).map((x) => Math.round(Number(x) * 60)).filter((n) => n >= 15 && n <= 1440);
|
|
39
|
+
const approval = (['default', 'auto', 'rules', 'manual'] as const).find((m) => m === form.get('approval')) ?? 'default';
|
|
40
|
+
const approvalRules = ApprovalRules.parse({
|
|
41
|
+
longerThan: form.has('ruleLonger') ? int(form.get('longerThanHours'), 1, 24 * 60, 4) * 60 : 0,
|
|
42
|
+
firstTime: form.has('ruleFirst'),
|
|
43
|
+
moreSeats: form.has('ruleSeats') ? int(form.get('moreSeats'), 1, 100000, 20) : 0,
|
|
44
|
+
deadlineHours: form.has('deadline') ? int(form.get('deadlineHours'), 1, 24 * 60, 48) : 0,
|
|
45
|
+
deadlineAction: form.get('deadlineAction') === 'decline' ? 'decline' : 'approve',
|
|
46
|
+
});
|
|
36
47
|
await repo.updateBookingLink(link.id, {
|
|
37
48
|
name, slug, mode: mode as BookingMode,
|
|
38
49
|
description: String(form.get('description') || '').slice(0, 1000),
|
|
@@ -47,7 +58,9 @@ if (Astro.request.method === 'POST') {
|
|
|
47
58
|
maxAdvance: int(form.get('maxAdvance'), 1, 730, 90),
|
|
48
59
|
perDayCap: int(form.get('perDayCap'), 0, 100, 0),
|
|
49
60
|
hours: form.get('customHours') ? hoursFromForm(form, 'lh') : null,
|
|
50
|
-
|
|
61
|
+
approval, approvalRules,
|
|
62
|
+
// legacy flag for older API readers; approval is what decides
|
|
63
|
+
autoConfirm: linkApproval({ approval, approvalRules }, settings).mode === 'auto',
|
|
51
64
|
visibility: form.get('visibility') === 'unlisted' ? 'unlisted' : 'public',
|
|
52
65
|
askPhone: Boolean(form.get('askPhone')),
|
|
53
66
|
questions: questionsFromForm(form),
|
|
@@ -62,12 +75,15 @@ const publicUrl = origin + bookingLinkUrl(resource.slug, link.slug);
|
|
|
62
75
|
const embed = `<iframe src="${origin}/embed/book/${resource.slug}/${link.slug}" style="width:100%;height:620px;border:0" title="${link.name}"></iframe>`;
|
|
63
76
|
const qRows = [...link.questions, ...Array.from({ length: 2 }, () => ({ id: '', label: '', type: 'text' as const, required: false, options: [] as string[], help: '' }))];
|
|
64
77
|
const hours = (link.hours ?? resource.hours);
|
|
78
|
+
const site = siteApproval(settings);
|
|
79
|
+
const own = link.approval === 'default' ? site.rules : link.approvalRules;
|
|
80
|
+
const effective = linkApproval(link, settings).mode;
|
|
65
81
|
---
|
|
66
82
|
|
|
67
|
-
<Admin title={link.name} section="calendar:
|
|
83
|
+
<Admin title={link.name} section="calendar:bookings">
|
|
68
84
|
<div class="page-head">
|
|
69
85
|
<div>
|
|
70
|
-
<p class="sub"><a href="/admin/calendar/resources">
|
|
86
|
+
<p class="sub"><a href="/admin/calendar/bookings">Bookings</a> › <a href="/admin/calendar/resources">Rooms & people</a> › <a href={`/admin/calendar/resources/${resource.id}`}>{resource.name}</a> ›</p>
|
|
71
87
|
<h1>{link.name}</h1>
|
|
72
88
|
<p class="sub"><a href={publicUrl} target="_blank" rel="noopener">{publicUrl.replace(/^https?:\/\//, '')} ↗</a> · {describeLink(link)}</p>
|
|
73
89
|
</div>
|
|
@@ -81,11 +97,32 @@ const hours = (link.hours ?? resource.hours);
|
|
|
81
97
|
<form method="post" id="link-form" class="res-grid">
|
|
82
98
|
<input type="hidden" name="op" value="save" />
|
|
83
99
|
<div>
|
|
84
|
-
<section class=
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
<
|
|
88
|
-
|
|
100
|
+
<section class="card card-pad appr" data-approval>
|
|
101
|
+
<div class="card-head"><b>Approval</b><span class="hint">What happens when someone books this link</span></div>
|
|
102
|
+
<div class="appr-choices" role="radiogroup" aria-label="Approval">
|
|
103
|
+
<label class="appr-choice"><input type="radio" name="approval" value="default" checked={link.approval === 'default'} />
|
|
104
|
+
<span><b>Use the site setting</b><small>Currently: {APPROVAL_LABEL[site.mode]}. <a href="/admin/calendar/settings#bookings">Change it</a></small></span></label>
|
|
105
|
+
<label class="appr-choice"><input type="radio" name="approval" value="auto" checked={link.approval === 'auto'} />
|
|
106
|
+
<span><b>{APPROVAL_LABEL.auto}</b><small>Booked means confirmed. It shows on your Schedule right away.</small></span></label>
|
|
107
|
+
<label class="appr-choice"><input type="radio" name="approval" value="rules" checked={link.approval === 'rules'} />
|
|
108
|
+
<span><b>{APPROVAL_LABEL.rules}</b><small>Confirm most bookings at once; hold unusual ones for you.</small></span></label>
|
|
109
|
+
<label class="appr-choice"><input type="radio" name="approval" value="manual" checked={link.approval === 'manual'} />
|
|
110
|
+
<span><b>{APPROVAL_LABEL.manual}</b><small>Every booking waits under Needs approval.</small></span></label>
|
|
111
|
+
</div>
|
|
112
|
+
<fieldset class="appr-rules" data-rules hidden={link.approval !== 'rules'}>
|
|
113
|
+
<legend>Ask me first when a booking is…</legend>
|
|
114
|
+
<label class="appr-check"><input type="checkbox" name="ruleLonger" checked={own.longerThan > 0} /> longer than <input type="number" name="longerThanHours" min="1" max="1440" value={own.longerThan ? Math.round(own.longerThan / 60) : 4} /> hours</label>
|
|
115
|
+
<label class="appr-check"><input type="checkbox" name="ruleFirst" checked={own.firstTime} /> the first booking from that email address</label>
|
|
116
|
+
<label class="appr-check"><input type="checkbox" name="ruleSeats" checked={own.moreSeats > 0} /> for more than <input type="number" name="moreSeats" min="1" max="100000" value={own.moreSeats || 20} /> people</label>
|
|
117
|
+
</fieldset>
|
|
118
|
+
<div class="appr-deadline" data-deadline hidden={link.approval === 'default' || link.approval === 'auto'}>
|
|
119
|
+
<label class="appr-check"><input type="checkbox" name="deadline" checked={own.deadlineHours > 0} /> If I haven’t answered in <input type="number" name="deadlineHours" min="1" max="1440" value={own.deadlineHours || 48} /> hours,</label>
|
|
120
|
+
<select name="deadlineAction" aria-label="What happens then">
|
|
121
|
+
<option value="approve" selected={own.deadlineAction === 'approve'}>approve it</option>
|
|
122
|
+
<option value="decline" selected={own.deadlineAction === 'decline'}>decline it</option>
|
|
123
|
+
</select>
|
|
124
|
+
</div>
|
|
125
|
+
<p class="hint appr-now">Right now this link {effective === 'auto' ? 'confirms every booking instantly' : effective === 'rules' ? 'confirms most bookings instantly' : 'holds every booking for approval'}.</p>
|
|
89
126
|
</section>
|
|
90
127
|
|
|
91
128
|
<section class="card card-pad">
|
|
@@ -175,8 +212,11 @@ const hours = (link.hours ?? resource.hours);
|
|
|
175
212
|
const mode = (form.querySelector<HTMLInputElement>('input[name=mode]:checked')?.value) ?? 'slots';
|
|
176
213
|
form.querySelectorAll<HTMLElement>('.mode-seg label').forEach((l) => l.classList.toggle('on', l.querySelector('input')?.checked ?? false));
|
|
177
214
|
form.querySelectorAll<HTMLElement>('.mode-fields [data-mode]').forEach((el) => { el.hidden = !(el.dataset.mode ?? '').split(' ').includes(mode); });
|
|
178
|
-
const
|
|
179
|
-
form.querySelector('
|
|
215
|
+
const appr = (form.querySelector<HTMLInputElement>('input[name=approval]:checked')?.value) ?? 'default';
|
|
216
|
+
const rules = form.querySelector<HTMLElement>('[data-rules]');
|
|
217
|
+
const deadline = form.querySelector<HTMLElement>('[data-deadline]');
|
|
218
|
+
if (rules) rules.hidden = appr !== 'rules';
|
|
219
|
+
if (deadline) deadline.hidden = appr === 'default' || appr === 'auto';
|
|
180
220
|
};
|
|
181
221
|
form.addEventListener('change', (e) => {
|
|
182
222
|
sync();
|