@acms-subir/core 0.3.40 → 0.3.41
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 +12 -0
- package/cli/migrations.generated.mjs +5 -0
- package/docs/mobile.md +2 -1
- package/integration.mjs +2 -0
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +66 -31
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +66 -31
- package/mobile-template/recipes/rsvp/recipe.json +2 -2
- package/package.json +1 -1
- package/src/actions/index.ts +1 -1
- package/src/components/EventPage.astro +16 -8
- package/src/components/editor/EventPanel.tsx +2 -2
- package/src/components/editor/PostEditor.tsx +1 -1
- package/src/components/editor/event/RegistrationCard.tsx +5 -0
- package/src/components/editor/event/registration.ts +1 -1
- package/src/lib/booking/agent.ts +1 -1
- package/src/lib/booking/emails.ts +20 -5
- package/src/lib/booking/service.ts +148 -14
- package/src/lib/calendar/rsvp-words.ts +69 -8
- package/src/lib/content-service.ts +11 -6
- package/src/lib/drafts.ts +2 -2
- package/src/lib/editor/bookings.ts +5 -3
- package/src/lib/editor/documents.ts +3 -2
- package/src/lib/editor/schemas.ts +1 -1
- package/src/lib/email/booking-words.ts +26 -2
- package/src/lib/email/store.ts +2 -2
- package/src/lib/forms/events.ts +4 -2
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/text-en.ts +1 -1
- package/src/lib/legal/text-es.ts +1 -1
- package/src/lib/mobile/bookings.ts +4 -3
- package/src/lib/mobile/manage.ts +17 -3
- package/src/lib/mobile/openapi.ts +4 -2
- package/src/lib/mobile/rsvp.ts +10 -5
- package/src/lib/mobile/schemas.ts +10 -3
- package/src/lib/repo/migrations.ts +5 -0
- package/src/lib/repo/sql.ts +53 -13
- package/src/lib/repo/types.ts +11 -4
- package/src/lib/scheduler.ts +3 -1
- package/src/lib/schema.ts +10 -2
- package/src/pages/admin/calendar/bookings.astro +57 -13
- package/src/pages/api/calendar/bookings/index.ts +1 -1
- package/src/pages/api/calendar/docs.ts +2 -2
- package/src/pages/api/v1/bookings/manage/confirm.ts +6 -0
- package/src/pages/book/manage/[token].astro +57 -15
- package/src/pages/events/[slug].astro +3 -2
- package/src/styles/admin.css +3 -1
- package/src/styles/site.css +10 -1
- package/src/theme-sdk/index.ts +1 -1
package/src/lib/scheduler.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { syncDueLive } from './live/service';
|
|
|
2
2
|
import type { ContentRepository } from './repo/types';
|
|
3
3
|
import { processDueDeliveries } from './webhooks';
|
|
4
4
|
import { dispatchEmail } from './email/service';
|
|
5
|
-
import { decideOverdue, sendDueReminders } from './booking/service';
|
|
5
|
+
import { decideOverdue, sendDueReminders, sweepWaitlist } from './booking/service';
|
|
6
6
|
import { runPushJobs } from './push/service';
|
|
7
7
|
import { runAnnouncementJobs } from './announcements/service';
|
|
8
8
|
import { GUEST_IDLE_MS } from './mobile/guest-store';
|
|
@@ -21,6 +21,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
|
|
|
21
21
|
reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
|
|
22
22
|
// pending bookings past their approval deadline (Calendar settings → Bookings)
|
|
23
23
|
approvals: () => decideOverdue(repo, { origin: settings.siteUrl, locals, now: startedAt }),
|
|
24
|
+
// held waitlist spots that ran out, and freed seats offered to those in line (before email, so offers go out in this run)
|
|
25
|
+
waitlist: () => sweepWaitlist(repo, { origin: settings.siteUrl, locals, now: startedAt }),
|
|
24
26
|
email: () => dispatchEmail(repo, locals, 10),
|
|
25
27
|
live: () => syncDueLive(repo, locals),
|
|
26
28
|
// scheduled announcements go up (with their push) and ended ones come down
|
package/src/lib/schema.ts
CHANGED
|
@@ -419,7 +419,9 @@ export const CalEvent = z.object({
|
|
|
419
419
|
// (wall time in the event's zone, like start; every date), else closeBefore
|
|
420
420
|
// minutes before each date's start, else when the date ends. notifyStaff:
|
|
421
421
|
// the hosts (else the church's contact email) hear about each new RSVP and
|
|
422
|
-
// each one the person cancels (lib/booking/service.ts rsvpStaff).
|
|
422
|
+
// each one the person cancels (lib/booking/service.ts rsvpStaff). waitlist:
|
|
423
|
+
// a full date takes places in line, and freed seats are offered in turn
|
|
424
|
+
// (lib/booking/service.ts offerWaitlist); only with a capacity.
|
|
423
425
|
rsvp: z.object({
|
|
424
426
|
enabled: z.boolean().default(false),
|
|
425
427
|
capacity: z.number().int().default(0),
|
|
@@ -428,6 +430,7 @@ export const CalEvent = z.object({
|
|
|
428
430
|
closesAt: z.string().nullable().default(null),
|
|
429
431
|
closeBefore: z.number().int().default(0),
|
|
430
432
|
notifyStaff: z.boolean().default(true),
|
|
433
|
+
waitlist: z.boolean().default(false),
|
|
431
434
|
form: FormDefinition.optional(),
|
|
432
435
|
}).default({}),
|
|
433
436
|
// rooms/people this event uses: they can't be booked while it happens (or
|
|
@@ -653,7 +656,10 @@ export const BookingLink = z.object({
|
|
|
653
656
|
});
|
|
654
657
|
export type BookingLink = z.infer<typeof BookingLink>;
|
|
655
658
|
|
|
656
|
-
|
|
659
|
+
// RSVPs only: waitlisted = in line for a full date (takes no seats); offered =
|
|
660
|
+
// a freed spot held for them until holdUntil (takes its seats); expired = they
|
|
661
|
+
// didn't confirm it in time
|
|
662
|
+
export const BookingStatus = z.enum(['pending', 'confirmed', 'declined', 'cancelled', 'waitlisted', 'offered', 'expired']);
|
|
657
663
|
export type BookingStatus = z.infer<typeof BookingStatus>;
|
|
658
664
|
|
|
659
665
|
export const Booking = z.object({
|
|
@@ -685,6 +691,8 @@ export const Booking = z.object({
|
|
|
685
691
|
// emails and links speak it. '' = the site's main language, as on page rows
|
|
686
692
|
locale: z.string().default(''),
|
|
687
693
|
reminderSentAt: z.number().nullable().default(null),
|
|
694
|
+
// an offered spot is theirs until then (ms)
|
|
695
|
+
holdUntil: z.number().nullable().default(null),
|
|
688
696
|
notificationVersion: z.number().default(0),
|
|
689
697
|
createdAt: z.number(),
|
|
690
698
|
updatedAt: z.number(),
|
|
@@ -19,7 +19,9 @@ import { forbiddenUnless } from '../../../lib/permissions/access';
|
|
|
19
19
|
|
|
20
20
|
// Bookings (mockup I): Cal.com-style tabs, an agenda by day with pending
|
|
21
21
|
// requests first, a Day view with one column per resource, a details panel
|
|
22
|
-
// (?b=<id>) with private notes, plus Block time and Add booking.
|
|
22
|
+
// (?b=<id>) with private notes, plus Block time and Add booking. RSVPs on a
|
|
23
|
+
// full date's waitlist (in line, or a spot offered and held) list apart from
|
|
24
|
+
// the confirmed ones, under Waitlist.
|
|
23
25
|
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
24
26
|
const repo = await getRepo(Astro.locals);
|
|
25
27
|
{ const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'schedule.bookings'); if (denied) return denied; }
|
|
@@ -108,7 +110,7 @@ if (Astro.request.method === 'POST') {
|
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
// ---- data ----
|
|
111
|
-
const tab = (['upcoming', 'unconfirmed', 'past', 'cancelled'] as const).find((t) => t === Astro.url.searchParams.get('tab')) ?? 'upcoming';
|
|
113
|
+
const tab = (['upcoming', 'unconfirmed', 'waitlist', 'past', 'cancelled'] as const).find((t) => t === Astro.url.searchParams.get('tab')) ?? 'upcoming';
|
|
112
114
|
const view = Astro.url.searchParams.get('view') === 'day' ? 'day' : 'agenda';
|
|
113
115
|
const eventFilter = Astro.url.searchParams.get('event') ?? '';
|
|
114
116
|
const resFilter = Astro.url.searchParams.get('r') ?? '';
|
|
@@ -119,7 +121,7 @@ const titleOf = (b: Booking) =>
|
|
|
119
121
|
: b.kind === 'rsvp' ? b.name
|
|
120
122
|
: b.name || 'Booking';
|
|
121
123
|
const contextOf = (b: Booking) =>
|
|
122
|
-
b.kind === 'rsvp' ? `RSVP · ${evById.get(b.eventId)?.title ?? 'event'}
|
|
124
|
+
b.kind === 'rsvp' ? [`RSVP · ${evById.get(b.eventId)?.title ?? 'event'}`, ...(b.seats > 1 ? [`${b.seats} seats`] : []), ...(waiting(b) ? [waitOf(b)] : [])].join(' · ')
|
|
123
125
|
: [linkById.get(b.linkId)?.name, ...Object.values(b.answers).slice(0, 2)].filter(Boolean).join(' · ');
|
|
124
126
|
const where = (b: Booking) => (b.kind === 'rsvp' ? evById.get(b.eventId)?.location.name || 'Event' : resById.get(b.resourceId)?.name ?? '—');
|
|
125
127
|
const matches = (b: Booking) =>
|
|
@@ -127,17 +129,31 @@ const matches = (b: Booking) =>
|
|
|
127
129
|
(!resFilter || b.resourceId === resFilter) &&
|
|
128
130
|
(!q || `${b.name} ${b.email} ${b.phone} ${where(b)} ${contextOf(b)}`.toLowerCase().includes(q));
|
|
129
131
|
const active = (b: Booking) => b.status === 'pending' || b.status === 'confirmed';
|
|
132
|
+
// in line for a full date, or a spot offered and held for them
|
|
133
|
+
const waiting = (b: Booking) => b.status === 'waitlisted' || b.status === 'offered';
|
|
130
134
|
const counts = {
|
|
131
135
|
upcoming: all.filter((b) => active(b) && b.end > now && matches(b)).length,
|
|
132
136
|
unconfirmed: all.filter((b) => b.status === 'pending' && b.end > now && matches(b)).length,
|
|
137
|
+
waitlist: all.filter((b) => waiting(b) && b.end > now && matches(b)).length,
|
|
133
138
|
past: all.filter((b) => active(b) && b.end <= now && matches(b)).length,
|
|
134
|
-
cancelled: all.filter((b) => !active(b) && matches(b)).length,
|
|
139
|
+
cancelled: all.filter((b) => !active(b) && !waiting(b) && matches(b)).length,
|
|
135
140
|
};
|
|
136
141
|
const list = all
|
|
137
142
|
.filter(matches)
|
|
138
|
-
.filter((b) => tab === 'upcoming' ? active(b) && b.end > now : tab === 'unconfirmed' ? b.status === 'pending' && b.end > now
|
|
139
|
-
|
|
143
|
+
.filter((b) => tab === 'upcoming' ? (active(b) || waiting(b)) && b.end > now : tab === 'unconfirmed' ? b.status === 'pending' && b.end > now
|
|
144
|
+
: tab === 'waitlist' ? waiting(b) && b.end > now : tab === 'past' ? active(b) && b.end <= now : !active(b) && !waiting(b))
|
|
145
|
+
.sort((a, b) => (tab === 'past' || tab === 'cancelled' ? b.start - a.start : a.start - b.start || a.createdAt - b.createdAt))
|
|
140
146
|
.slice(0, 300);
|
|
147
|
+
// each date's line: who joined first, then by id (lib/booking/service.ts waitlistLine)
|
|
148
|
+
const lines = new Map<string, Booking[]>();
|
|
149
|
+
for (const b of all.filter((x) => x.status === 'waitlisted').sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1))) {
|
|
150
|
+
const k = `${b.eventId}:${b.start}`;
|
|
151
|
+
lines.set(k, [...(lines.get(k) ?? []), b]);
|
|
152
|
+
}
|
|
153
|
+
const placeOf = (b: Booking) => (lines.get(`${b.eventId}:${b.start}`) ?? []).findIndex((x) => x.id === b.id) + 1;
|
|
154
|
+
const heldUntil = (b: Booking) => (b.holdUntil ? `${f.day.format(b.holdUntil)} ${f.time.format(b.holdUntil)}` : '');
|
|
155
|
+
// "#2 in line" / "held until Fri, Oct 24 9:00 PM"
|
|
156
|
+
const waitOf = (b: Booking) => (b.status === 'waitlisted' ? `#${placeOf(b)} in line` : b.status === 'offered' ? `held until ${heldUntil(b)}` : '');
|
|
141
157
|
if (Astro.url.searchParams.get('format') === 'csv') {
|
|
142
158
|
const iso = (ms: number) => new Date(ms).toISOString();
|
|
143
159
|
const keys = [...new Set(list.flatMap((b) => Object.keys(b.answers)))];
|
|
@@ -150,7 +166,9 @@ if (Astro.url.searchParams.get('format') === 'csv') {
|
|
|
150
166
|
});
|
|
151
167
|
}
|
|
152
168
|
const pending = tab === 'upcoming' ? list.filter((b) => b.status === 'pending') : [];
|
|
153
|
-
|
|
169
|
+
// on Upcoming, the waitlist is a group of its own (like requests to approve), apart from those going
|
|
170
|
+
const queued = tab === 'upcoming' ? list.filter(waiting) : [];
|
|
171
|
+
const agendaItems = tab === 'upcoming' ? list.filter((b) => b.status !== 'pending' && !waiting(b)) : list;
|
|
154
172
|
const days = new Map<string, Booking[]>();
|
|
155
173
|
for (const b of agendaItems) {
|
|
156
174
|
const k = ymd(b.start, tz);
|
|
@@ -183,8 +201,13 @@ const emailNotice = !emailConfig.config.enabled || emailConfig.config.provider =
|
|
|
183
201
|
? 'Emails are turned off. Enable email so guests can receive confirmations and reminders.'
|
|
184
202
|
: !emailStatus.configured ? `Email needs attention: ${emailStatus.error || 'Choose a provider in Email settings.'}`
|
|
185
203
|
: emailStatus.linkError;
|
|
186
|
-
const TABS = [['upcoming', 'Upcoming'], ['unconfirmed', 'Needs approval'], ['past', 'Past'], ['cancelled', 'Cancelled']] as const;
|
|
187
|
-
const
|
|
204
|
+
const TABS = [['upcoming', 'Upcoming'], ['unconfirmed', 'Needs approval'], ['waitlist', 'Waitlist'], ['past', 'Past'], ['cancelled', 'Cancelled']] as const;
|
|
205
|
+
const LABELS: Partial<Record<Booking['status'], string>> = { waitlisted: 'Waitlist', offered: 'Spot offered', expired: 'Hold expired' };
|
|
206
|
+
const statusLabel = (b: Booking) => (b.kind === 'block' ? 'Blocked' : LABELS[b.status] ?? b.status[0].toUpperCase() + b.status.slice(1));
|
|
207
|
+
// an event's registrations: "40 going · 3 waiting" across its upcoming dates
|
|
208
|
+
const evUpcoming = eventFilter ? all.filter((b) => b.kind === 'rsvp' && b.eventId === eventFilter && b.end > now) : [];
|
|
209
|
+
const evWaiting = evUpcoming.filter((b) => b.status === 'waitlisted').length, evOffered = evUpcoming.filter((b) => b.status === 'offered').length;
|
|
210
|
+
const evCounts = [`${evUpcoming.filter(active).reduce((n, b) => n + b.seats, 0)} going`, ...(evWaiting ? [`${evWaiting} waiting`] : []), ...(evOffered ? [`${evOffered} ${evOffered === 1 ? 'spot' : 'spots'} offered`] : [])].join(' · ');
|
|
188
211
|
// where it was made: the website, an app (/api/v1/bookings), staff, an API key or the booking agent
|
|
189
212
|
const SOURCES: Record<Booking['source'], string> = { web: 'Website', app: 'App', admin: 'Staff', api: 'API', agent: 'Agent' };
|
|
190
213
|
const whenOf = (b: Booking) => (b.end - b.start >= 86_400_000 - 3_600_000 && ymd(b.start, tz) !== ymd(b.end - 1, tz)
|
|
@@ -204,7 +227,7 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
204
227
|
<a class="btn" href={keep({ format: 'csv', b: null, notice: null })}>Export</a>
|
|
205
228
|
</BookingsHead>
|
|
206
229
|
{notice && <p class="notice" role="status">{notice}</p>}
|
|
207
|
-
{eventFilter && <p class="notice">Registrations for {evById.get(eventFilter)?.title ?? 'this event'} · <a href={keep({ event: null })}>Show all bookings</a></p>}
|
|
230
|
+
{eventFilter && <p class="notice">Registrations for {evById.get(eventFilter)?.title ?? 'this event'} · {evCounts} · <a href={keep({ event: null })}>Show all bookings</a></p>}
|
|
208
231
|
{error && <p class="notice warn" role="alert">{error}</p>}
|
|
209
232
|
{emailNotice && <p class="notice warn"><span>{emailNotice} <a href={emailStatus.configured && emailStatus.linkError ? '/admin/settings#site' : '/admin/email'}>{emailStatus.configured && emailStatus.linkError ? 'Site settings' : 'Email settings'}</a></span></p>}
|
|
210
233
|
|
|
@@ -227,7 +250,7 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
227
250
|
<div class="list-lead">
|
|
228
251
|
{view === 'agenda' ? (
|
|
229
252
|
<nav class="seg" aria-label="Show">
|
|
230
|
-
{TABS.map(([k, l]) => <a class={tab === k ? 'on' : ''} href={keep({ tab: k === 'upcoming' ? null : k, b: null })}>{l}{counts[k] > 0 && k === 'unconfirmed' ? ` · ${counts[k]}` : ''}</a>)}
|
|
253
|
+
{TABS.map(([k, l]) => <a class={tab === k ? 'on' : ''} href={keep({ tab: k === 'upcoming' ? null : k, b: null })}>{l}{counts[k] > 0 && (k === 'unconfirmed' || k === 'waitlist') ? ` · ${counts[k]}` : ''}</a>)}
|
|
231
254
|
</nav>
|
|
232
255
|
) : (
|
|
233
256
|
<nav class="seg" aria-label="Day">
|
|
@@ -295,6 +318,19 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
295
318
|
))}
|
|
296
319
|
</section>
|
|
297
320
|
)}
|
|
321
|
+
{queued.length > 0 && (
|
|
322
|
+
<section class="card bk-group">
|
|
323
|
+
<div class="bk-dayh"><b>Waitlist</b><span>{queued.length}</span></div>
|
|
324
|
+
{queued.map((b) => (
|
|
325
|
+
<a class={`bk-row${selected?.id === b.id ? ' is-sel' : ''}`} href={keep({ b: b.id })}>
|
|
326
|
+
<span class="bk-when">{f.day.format(b.start)}<small>{whenOf(b)}</small></span>
|
|
327
|
+
<span class="bk-who"><b>{titleOf(b)}</b><small>{contextOf(b)}</small></span>
|
|
328
|
+
<span class="bk-res">{where(b)}</span>
|
|
329
|
+
<span class={`status bk-st is-${b.status}`}>{statusLabel(b)}</span>
|
|
330
|
+
</a>
|
|
331
|
+
))}
|
|
332
|
+
</section>
|
|
333
|
+
)}
|
|
298
334
|
{[...days.entries()].map(([k, items]) => (
|
|
299
335
|
<section class="card bk-group">
|
|
300
336
|
<div class="bk-dayh"><b>{k === today ? 'Today · ' : ''}{f.dayLong.format(items[0].start)}</b><span>{items.length}</span></div>
|
|
@@ -308,7 +344,7 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
308
344
|
))}
|
|
309
345
|
</section>
|
|
310
346
|
))}
|
|
311
|
-
{!list.length && <p class="hint" style="padding:24px 8px">{tab === 'unconfirmed' ? 'Nothing waiting — auto-confirm links never land here.' : 'No bookings here.'}</p>}
|
|
347
|
+
{!list.length && <p class="hint" style="padding:24px 8px">{tab === 'unconfirmed' ? 'Nothing waiting — auto-confirm links never land here.' : tab === 'waitlist' ? 'No one is waiting. People can join a waitlist when an event that offers one is full.' : 'No bookings here.'}</p>}
|
|
312
348
|
</>
|
|
313
349
|
)}
|
|
314
350
|
</div>
|
|
@@ -328,6 +364,8 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
328
364
|
{b.phone && <div><dt>Phone</dt><dd><a href={`tel:${b.phone}`}>{b.phone}</a></dd></div>}
|
|
329
365
|
<div><dt>{ev ? 'Event' : 'Where'}</dt><dd>{ev ? <a href={eventUrl(ev.slug)} target="_blank" rel="noopener">{ev.title}</a> : r?.name ?? '—'}{l ? ` · ${l.name}` : ''}</dd></div>
|
|
330
366
|
{b.seats > 1 && <div><dt>Seats</dt><dd>{b.seats}</dd></div>}
|
|
367
|
+
{b.status === 'waitlisted' && <div><dt>Waitlist</dt><dd>#{placeOf(b)} in line</dd></div>}
|
|
368
|
+
{b.status === 'offered' && <div><dt>Spot held</dt><dd>Until {heldUntil(b)} · they confirm it from their email</dd></div>}
|
|
331
369
|
{Object.entries(b.answers).map(([k, v]) => <div><dt>{k}</dt><dd>{v}</dd></div>)}
|
|
332
370
|
<div><dt>Booked</dt><dd>{f.day.format(b.createdAt)} · {SOURCES[b.source] ?? b.source}</dd></div>
|
|
333
371
|
</dl>
|
|
@@ -361,7 +399,13 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
361
399
|
</form>
|
|
362
400
|
</>
|
|
363
401
|
)}
|
|
364
|
-
{(b
|
|
402
|
+
{waiting(b) && b.end > now && (
|
|
403
|
+
<form method="post">
|
|
404
|
+
<input type="hidden" name="op" value="status" /><input type="hidden" name="id" value={b.id} /><input type="hidden" name="to" value="cancelled" />
|
|
405
|
+
<button class="btn sm danger ghost" type="submit" data-confirm={b.status === 'offered' ? 'Take back the held spot and email them? It goes to the next person in line.' : 'Remove them from the waitlist and email them?'}>{b.status === 'offered' ? 'Take back the spot' : 'Remove from waitlist'}</button>
|
|
406
|
+
</form>
|
|
407
|
+
)}
|
|
408
|
+
{(b.kind === 'block' || !(b.status === 'pending' || b.status === 'confirmed' || waiting(b))) && (
|
|
365
409
|
<form method="post">
|
|
366
410
|
<input type="hidden" name="op" value="delete" /><input type="hidden" name="id" value={b.id} />
|
|
367
411
|
<button class="btn sm danger ghost" type="submit" data-confirm="Remove this entry for good?">{b.kind === 'block' ? 'Remove block' : 'Delete record'}</button>
|
|
@@ -56,7 +56,7 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
|
|
|
56
56
|
const day = new Date(start).toISOString().slice(0, 10);
|
|
57
57
|
const occ = featuredOccurrence(e, settings, Date.now(), day);
|
|
58
58
|
if (!occ || Math.abs(occ.start - start) > 60_000) throw new ApiError(400, 'That start time isn’t an occurrence of this event.');
|
|
59
|
-
const b = await createRsvp(repo, { event: e, start: occ.start, end: occ.end, seats: Number(body.seats) || 1, booker, source }, flow);
|
|
59
|
+
const b = await createRsvp(repo, { event: e, start: occ.start, end: occ.end, seats: Number(body.seats) || 1, booker, source, waitlist: body.waitlist === true }, flow);
|
|
60
60
|
return withCors(json({ booking: bookingJson(b), manageToken: b.manageToken }, 201));
|
|
61
61
|
}
|
|
62
62
|
const resource = await repo.getResourceBySlug(String(body.resource ?? ''));
|
|
@@ -45,8 +45,8 @@ POST /api/calendar/bookings (no key needed — same rules as the bo
|
|
|
45
45
|
"name": "Maria Delgado", "email": "maria@example.com", "phone": "", "answers": { "Expected guests": "60" } }
|
|
46
46
|
RSVP to an event occurrence: { "event": "<slug>", "start": "<occurrence ISO>", "seats": 2, "name": "…", "email": "…" }
|
|
47
47
|
→ 201 { booking, manageToken } status "confirmed" (auto-confirm links) or "pending" (approval)
|
|
48
|
-
→ 409 if the time was just taken
|
|
49
|
-
GET /api/calendar/bookings?status=pending&resource=<slug>&from=…&to=… (bookings:read)
|
|
48
|
+
→ 409 if the time was just taken (a full date: add "waitlist": true to join its waitlist, status "waitlisted", when the event keeps one)
|
|
49
|
+
GET /api/calendar/bookings?status=pending&resource=<slug>&from=…&to=… (bookings:read; RSVP statuses also waitlisted, offered, expired)
|
|
50
50
|
GET /api/calendar/bookings/<id> (bookings:read)
|
|
51
51
|
PATCH /api/calendar/bookings/<id> { "status": "confirmed" | "declined" | "cancelled", "start": "<ISO>", "notes": "…", "message": "…" } (bookings:write)
|
|
52
52
|
Guests manage their own booking at ${o}${CALENDAR.bookBase}/manage/<manageToken>.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { confirmManaged } from '../../../../../lib/mobile/manage';
|
|
2
|
+
import { privateRoute } from '../../../../../lib/mobile/route';
|
|
3
|
+
import { methodNotAllowed } from '../../../../../lib/mobile/http';
|
|
4
|
+
export { preflight as OPTIONS } from '../../../../../lib/mobile/http';
|
|
5
|
+
export const POST = privateRoute(confirmManaged, { write: true });
|
|
6
|
+
export const ALL = () => methodNotAllowed('POST, OPTIONS');
|
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
import Site from '../../../layouts/Site.astro';
|
|
3
3
|
import { getRepo } from '../../../lib/repo';
|
|
4
4
|
import { CALENDAR, bookingLinkUrl, bookingsEnabled, eventUrl, resourceUrl } from '../../../lib/calendar/config';
|
|
5
|
-
import { guestCancel, guestChanges, BookingError } from '../../../lib/booking/service';
|
|
5
|
+
import { confirmOffer, guestCancel, guestChanges, waitlistPosition, BookingError, WAITLIST_HOLD } from '../../../lib/booking/service';
|
|
6
6
|
import { bookingIcs, whenText } from '../../../lib/booking/emails';
|
|
7
7
|
import { resourceZone } from '../../../lib/booking/availability';
|
|
8
8
|
import { eventTimeZone, locationLine } from '../../../lib/calendar/events';
|
|
9
9
|
import { siteOrigin } from '../../../lib/seo';
|
|
10
10
|
import { waitUntilFrom } from '../../../lib/webhooks';
|
|
11
11
|
import { I18N, localePath, requestLocale } from '../../../lib/i18n';
|
|
12
|
-
import { siteLang } from '../../../lib/dates';
|
|
12
|
+
import { siteLang, siteLocale } from '../../../lib/dates';
|
|
13
13
|
import { isExtraLocale, localizeDoc } from '../../../lib/translations';
|
|
14
|
-
import { rsvpStrings } from '../../../lib/calendar/rsvp-words';
|
|
14
|
+
import { deadlineParts, rsvpStrings } from '../../../lib/calendar/rsvp-words';
|
|
15
15
|
import { bookingStrings } from '../../../lib/email/booking-words';
|
|
16
16
|
import { fill } from '../../../lib/live/words';
|
|
17
17
|
|
|
@@ -21,7 +21,10 @@ import { fill } from '../../../lib/live/words';
|
|
|
21
21
|
// speaks the request's language, for RSVPs and room bookings alike
|
|
22
22
|
// (/es/book/manage/… in Spanish, as the emails of a booking made in Spanish
|
|
23
23
|
// link it); opened without the prefix it shows the main language, with no
|
|
24
|
-
// redirect. The room booking flow it links back to stays in English.
|
|
24
|
+
// redirect. The room booking flow it links back to stays in English. An RSVP
|
|
25
|
+
// on a full date's waitlist shows its place in line ("#3 in line · 2 seats")
|
|
26
|
+
// or the spot held for it ("A spot is yours until …", Confirm my spot), and
|
|
27
|
+
// can leave the line or release the spot.
|
|
25
28
|
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
26
29
|
const repo = await getRepo(Astro.locals);
|
|
27
30
|
const booking = await repo.getBookingByToken(Astro.params.token ?? '');
|
|
@@ -52,31 +55,62 @@ if (Astro.url.searchParams.get('ics')) {
|
|
|
52
55
|
}
|
|
53
56
|
let error = '';
|
|
54
57
|
let current = booking;
|
|
58
|
+
// what this request just did: left the line (or released a held spot), or took the spot
|
|
59
|
+
let left = false, took = false;
|
|
55
60
|
if (Astro.request.method === 'POST') {
|
|
56
61
|
const form = await Astro.request.formData();
|
|
57
|
-
|
|
62
|
+
const flow = { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) };
|
|
63
|
+
if (form.get('op') === 'cancel' && ['pending', 'confirmed', 'waitlisted', 'offered'].includes(booking.status)) {
|
|
58
64
|
try {
|
|
59
|
-
current = await guestCancel(repo, booking,
|
|
65
|
+
current = await guestCancel(repo, booking, flow);
|
|
66
|
+
left = booking.status === 'waitlisted' || booking.status === 'offered';
|
|
60
67
|
} catch (err) {
|
|
61
68
|
error = err instanceof BookingError ? err.message : w.cancelError;
|
|
62
69
|
}
|
|
63
70
|
}
|
|
71
|
+
if (form.get('op') === 'confirm' && booking.status === 'offered') {
|
|
72
|
+
try {
|
|
73
|
+
current = await confirmOffer(repo, booking, flow);
|
|
74
|
+
took = true;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
current = (await repo.getBooking(booking.id)) ?? booking;
|
|
77
|
+
// released: the page says so in its own words
|
|
78
|
+
error = current.status === 'expired' ? '' : err instanceof BookingError ? err.message : w.confirmError;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
64
81
|
}
|
|
65
82
|
const tz = event ? eventTimeZone(event, settings) : resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
|
|
66
|
-
const
|
|
67
|
-
const
|
|
83
|
+
const dateSettings = isExtraLocale(locale) ? { ...settings, locale } : settings;
|
|
84
|
+
const when = whenText(current, tz, dateSettings, link?.mode === 'day' || event?.allDay);
|
|
85
|
+
const waiting = current.status === 'waitlisted' || current.status === 'offered';
|
|
86
|
+
const active = current.status === 'pending' || current.status === 'confirmed' || waiting;
|
|
68
87
|
const upcoming = current.end > Date.now();
|
|
69
88
|
const can = guestChanges(current, link);
|
|
70
|
-
const isNew = Astro.url.searchParams.has('new');
|
|
89
|
+
const isNew = Astro.url.searchParams.has('new') || took;
|
|
71
90
|
const moved = Astro.url.searchParams.has('moved');
|
|
72
|
-
const
|
|
91
|
+
const hours = String(WAITLIST_HOLD / 3_600_000);
|
|
92
|
+
// "#3 in line · 2 seats"; "A spot is yours until Fri, Oct 24 at 9 PM"
|
|
93
|
+
const position = await waitlistPosition(repo, current);
|
|
94
|
+
const inLine = position === null ? '' : fill(w.inLine, { position: String(position), seats: fill(current.seats === 1 ? w.seat : w.seats, { count: String(current.seats) }) });
|
|
95
|
+
const holdUntil = current.status === 'offered' && current.holdUntil !== null ? deadlineParts(current.holdUntil, tz, siteLocale(dateSettings)) : null;
|
|
96
|
+
const heading = current.status === 'cancelled' ? (left ? w.offWaitlist : w.cancelledTitle)
|
|
73
97
|
: current.status === 'declined' ? w.notAvailable
|
|
98
|
+
: current.status === 'waitlisted' ? w.onWaitlist
|
|
99
|
+
: current.status === 'offered' ? w.spotOpened
|
|
100
|
+
: current.status === 'expired' ? w.released
|
|
74
101
|
: current.status === 'pending' ? (isNew ? w.requestSent : w.awaitingApproval)
|
|
75
102
|
: moved ? bw.moved : isNew ? w.booked : w.yours;
|
|
76
103
|
const lede = current.status === 'pending' ? fill(w.emailWhenApproved, { email: current.email })
|
|
77
104
|
: current.status === 'confirmed' ? (current.formResponse?.definition.confirmation || link?.confirmMessage || fill(w.confirmationOnItsWay, { email: current.email }))
|
|
78
105
|
: current.status === 'declined' ? w.declined
|
|
79
|
-
: w.
|
|
106
|
+
: current.status === 'waitlisted' ? fill(w.waitlistPromise, { hours })
|
|
107
|
+
: current.status === 'offered' ? (holdUntil ? fill(w.spotYours, holdUntil) : '')
|
|
108
|
+
: current.status === 'expired' ? fill(w.releasedText, { hours })
|
|
109
|
+
: left ? w.offWaitlistText : w.cancelledText;
|
|
110
|
+
// a room booking's own words list only its four statuses
|
|
111
|
+
const statusLabel = (w.statuses as Record<string, string>)[current.status] ?? rsvpStrings(lang).statuses[current.status];
|
|
112
|
+
const cancelLabel = current.status === 'waitlisted' ? w.leaveWaitlist : current.status === 'offered' ? w.releaseSpot : w.cancel;
|
|
113
|
+
const cancelAsk = `return confirm(${JSON.stringify(current.status === 'waitlisted' ? w.leaveConfirm : current.status === 'offered' ? w.releaseConfirm : w.cancelConfirm)})`;
|
|
80
114
|
const cutoff = fill(bw.cutoff, { hours: String(can.cutoffHours) });
|
|
81
115
|
const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchParams({ action: 'TEMPLATE', text: title, dates: `${new Date(current.start).toISOString().replace(/[-:]|\.\d{3}/g, '')}/${new Date(current.end).toISOString().replace(/[-:]|\.\d{3}/g, '')}`, details: url, location })}`;
|
|
82
116
|
---
|
|
@@ -85,10 +119,12 @@ const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchPa
|
|
|
85
119
|
<div class="pc-manage">
|
|
86
120
|
<div class={`pc-manage-icon is-${current.status}`} aria-hidden="true">
|
|
87
121
|
{current.status === 'confirmed' ? <svg viewBox="0 0 24 24"><path d="M5 12l5 5L19 7" /></svg>
|
|
88
|
-
: current.status === 'pending' ? <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" /><path d="M12 8v4l3 2" /></svg>
|
|
122
|
+
: current.status === 'pending' || current.status === 'offered' ? <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" /><path d="M12 8v4l3 2" /></svg>
|
|
123
|
+
: current.status === 'waitlisted' ? <svg viewBox="0 0 24 24"><path d="M5 7h14M5 12h14M5 17h8" /></svg>
|
|
89
124
|
: <svg viewBox="0 0 24 24"><path d="M7 7l10 10M17 7 7 17" /></svg>}
|
|
90
125
|
</div>
|
|
91
126
|
<h1>{heading}</h1>
|
|
127
|
+
{inLine && <p class="pc-manage-wait">{inLine}</p>}
|
|
92
128
|
<p class="pc-manage-lede">{lede}</p>
|
|
93
129
|
{error && <p class="pc-bk-error">{error}</p>}
|
|
94
130
|
<dl class="pc-manage-card">
|
|
@@ -96,7 +132,7 @@ const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchPa
|
|
|
96
132
|
<div><dt>{w.when}</dt><dd>{when}</dd></div>
|
|
97
133
|
{location && <div><dt>{w.where}</dt><dd>{location}</dd></div>}
|
|
98
134
|
{current.seats > 1 && <div><dt>{w.seatsField}</dt><dd>{current.seats}</dd></div>}
|
|
99
|
-
<div><dt>{w.status}</dt><dd class={`pc-manage-status is-${current.status}`}>{
|
|
135
|
+
<div><dt>{w.status}</dt><dd class={`pc-manage-status is-${current.status}`}>{statusLabel}</dd></div>
|
|
100
136
|
<div><dt>{w.reference}</dt><dd class="mono">{current.id.slice(0, 8).toUpperCase()}</dd></div>
|
|
101
137
|
</dl>
|
|
102
138
|
{active && upcoming && (
|
|
@@ -111,13 +147,19 @@ const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchPa
|
|
|
111
147
|
</div>
|
|
112
148
|
</details>
|
|
113
149
|
)}
|
|
150
|
+
{can.confirm && (
|
|
151
|
+
<form method="post">
|
|
152
|
+
<input type="hidden" name="op" value="confirm" />
|
|
153
|
+
<button class="pc-manage-btn is-primary" type="submit">{w.confirmSpot}</button>
|
|
154
|
+
</form>
|
|
155
|
+
)}
|
|
114
156
|
{resource && link && can.reschedule && (
|
|
115
157
|
<a class="pc-manage-btn" href={`${bookingLinkUrl(resource.slug, link.slug)}?reschedule=${encodeURIComponent(current.manageToken)}`}>{bw.reschedule}</a>
|
|
116
158
|
)}
|
|
117
159
|
{can.cancel && (
|
|
118
|
-
<form method="post" onsubmit={
|
|
160
|
+
<form method="post" onsubmit={cancelAsk}>
|
|
119
161
|
<input type="hidden" name="op" value="cancel" />
|
|
120
|
-
<button class="pc-manage-btn is-danger" type="submit">{
|
|
162
|
+
<button class="pc-manage-btn is-danger" type="submit">{cancelLabel}</button>
|
|
121
163
|
</form>
|
|
122
164
|
)}
|
|
123
165
|
</div>
|
|
@@ -22,7 +22,7 @@ import { CALENDAR, eventUrl, withoutPrivateFields } from '../../lib/calendar/con
|
|
|
22
22
|
import { eventIsPublic, featuredOccurrence, rsvpDeadline } from '../../lib/calendar/events';
|
|
23
23
|
import { eventJsonLd } from '../../lib/calendar/seo';
|
|
24
24
|
import { upcomingEvents } from '../../lib/calendar/cards';
|
|
25
|
-
import { seatsTaken, BookingError } from '../../lib/booking/service';
|
|
25
|
+
import { seatsTaken, waitlistOn, BookingError } from '../../lib/booking/service';
|
|
26
26
|
import { manageUrl } from '../../lib/calendar/config';
|
|
27
27
|
import { waitUntilFrom } from '../../lib/webhooks';
|
|
28
28
|
|
|
@@ -77,9 +77,10 @@ const formHtml = hiddenFields(await formVersion(definition), requestId) + `<inpu
|
|
|
77
77
|
const taken = event.rsvp.enabled ? await seatsTaken(repo, event.id, occurrence.start) : 0;
|
|
78
78
|
// the event's RSVP deadline for this date (null: open until it ends)
|
|
79
79
|
const closesAt = event.rsvp.enabled ? rsvpDeadline(occurrence, settings) : null;
|
|
80
|
+
// waitlist: a full date offers "Join the waitlist" (the same form, posting waitlist=1)
|
|
80
81
|
const rsvp = event.rsvp.enabled
|
|
81
82
|
? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, duplicate: rsvpDuplicate, formHtml, fieldsHtml: renderFields(definition, formValues, 'event-registration'), values: coreValues,
|
|
82
|
-
open: occurrence.end > now && !occurrence.cancelled && !(closesAt !== null && closesAt <= now), closesAt }
|
|
83
|
+
open: occurrence.end > now && !occurrence.cancelled && !(closesAt !== null && closesAt <= now), closesAt, waitlist: waitlistOn(event) }
|
|
83
84
|
: null;
|
|
84
85
|
const calendars = event.calendarIds.map((id) => calMap.get(id)).filter((c): c is NonNullable<typeof c> => Boolean(c) && c!.visibility !== 'private');
|
|
85
86
|
const html = highlightCodeHtml(await expandDynamicBlocks(event.bodyHtml, repo));
|
package/src/styles/admin.css
CHANGED
|
@@ -1476,7 +1476,9 @@ a.bk-row:hover, .bk-row.is-sel { background: var(--hover); }
|
|
|
1476
1476
|
.bk-st { font-size: 12px; font-weight: 600; white-space: nowrap; }
|
|
1477
1477
|
.bk-st.is-confirmed { color: var(--green); }
|
|
1478
1478
|
.bk-st.is-pending { color: var(--amber); }
|
|
1479
|
-
.bk-st.is-cancelled, .bk-st.is-declined, .bk-st.is-block { color: var(--muted); }
|
|
1479
|
+
.bk-st.is-cancelled, .bk-st.is-declined, .bk-st.is-block, .bk-st.is-expired { color: var(--muted); }
|
|
1480
|
+
.bk-st.is-waitlisted { color: var(--text-2); }
|
|
1481
|
+
.bk-st.is-offered { color: var(--accent); }
|
|
1480
1482
|
.bk-panel { padding: 16px 18px; position: sticky; top: 64px; }
|
|
1481
1483
|
.bk-panel-head { display: flex; justify-content: space-between; align-items: center; }
|
|
1482
1484
|
.bk-panel h2 { font-size: 18px; margin: 6px 0 2px; }
|
package/src/styles/site.css
CHANGED
|
@@ -1119,7 +1119,9 @@ li::marker { color: var(--muted); }
|
|
|
1119
1119
|
.pc-manage-icon svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
|
|
1120
1120
|
.pc-manage-icon.is-confirmed { background: color-mix(in srgb, var(--success) 14%, var(--panel)); color: var(--success); }
|
|
1121
1121
|
.pc-manage-icon.is-pending { background: color-mix(in srgb, var(--warning) 14%, var(--panel)); color: var(--warning); }
|
|
1122
|
-
.pc-manage-icon.is-cancelled, .pc-manage-icon.is-declined { background: var(--soft); color: var(--muted); }
|
|
1122
|
+
.pc-manage-icon.is-cancelled, .pc-manage-icon.is-declined, .pc-manage-icon.is-expired { background: var(--soft); color: var(--muted); }
|
|
1123
|
+
.pc-manage-icon.is-waitlisted { background: var(--soft); color: var(--ink); }
|
|
1124
|
+
.pc-manage-icon.is-offered { background: color-mix(in srgb, var(--success) 14%, var(--panel)); color: var(--success); }
|
|
1123
1125
|
.pc-manage h1 { font-size: 30px; margin: 0 0 8px; }
|
|
1124
1126
|
.pc-manage-lede { color: var(--muted); margin: 0 0 18px; }
|
|
1125
1127
|
.pc-manage-card { text-align: left; background: var(--panel); border: 1px solid var(--line); border-radius: var(--r); margin: 0; padding: 4px 18px; box-shadow: var(--sh-1); }
|
|
@@ -1130,12 +1132,17 @@ li::marker { color: var(--muted); }
|
|
|
1130
1132
|
.pc-manage-card .mono { font-family: var(--font-mono); font-size: 13px; }
|
|
1131
1133
|
.pc-manage-status.is-confirmed { color: var(--success); font-weight: 600; }
|
|
1132
1134
|
.pc-manage-status.is-pending { color: var(--warning); font-weight: 600; }
|
|
1135
|
+
.pc-manage-status.is-waitlisted { font-weight: 600; }
|
|
1136
|
+
.pc-manage-status.is-offered { color: var(--success); font-weight: 600; }
|
|
1137
|
+
.pc-manage-wait { font-weight: 600; margin: 0 0 6px; }
|
|
1133
1138
|
.pc-manage-actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 16px; }
|
|
1134
1139
|
.pc-manage-actions .pc-ev-add { margin: 0; }
|
|
1135
1140
|
.pc-manage-actions .pc-ev-add > summary { width: auto; }
|
|
1136
1141
|
.pc-manage-btn { display: inline-flex; align-items: center; height: 40px; padding: 0 14px; border: 1px solid var(--line-2); border-radius: 12px; background: var(--panel); font: 550 14px/1.2 var(--font-body); color: var(--ink); text-decoration: none; cursor: pointer; }
|
|
1137
1142
|
.pc-manage-btn:hover { background: var(--soft); }
|
|
1138
1143
|
.pc-manage-btn.is-danger { color: var(--danger); }
|
|
1144
|
+
.pc-manage-btn.is-primary { background: var(--btn); color: var(--btn-ink); border-color: transparent; }
|
|
1145
|
+
.pc-manage-btn.is-primary:hover { background: var(--btn); filter: brightness(1.08); }
|
|
1139
1146
|
.pc-manage-note { font-size: 12.5px; color: var(--muted); margin-top: 22px; }
|
|
1140
1147
|
|
|
1141
1148
|
/* RSVP on the event page */
|
|
@@ -1149,6 +1156,8 @@ li::marker { color: var(--muted); }
|
|
|
1149
1156
|
.pc-ev-full { font-size: 14px; font-weight: 600; color: var(--muted); }
|
|
1150
1157
|
.pc-ev-closes { font-size: 13.5px; color: var(--muted); margin: 0 0 10px; }
|
|
1151
1158
|
.pc-ev-full + .pc-ev-closes { margin-top: 4px; }
|
|
1159
|
+
.pc-ev-full.is-waitlist { color: var(--ink); margin: 0 0 2px; }
|
|
1160
|
+
.pc-ev-waitlist { font-size: 13.5px; color: var(--muted); margin: 0 0 10px; }
|
|
1152
1161
|
.pc-ev-notice { font-size: 14px; line-height: 1.5; background: var(--soft); border-radius: var(--r-sm); padding: 9px 12px; margin: 0 0 14px; }
|
|
1153
1162
|
|
|
1154
1163
|
@media (max-width: 860px) {
|
package/src/theme-sdk/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface HomeProps { settings: Settings; page: Page | null; posts: Post[
|
|
|
11
11
|
export interface PageProps { settings: Settings; page: Page; html: string }
|
|
12
12
|
export interface PostProps { settings: Settings; post: Post; html: string; authors: User[]; related: Post[] }
|
|
13
13
|
import type { Occurrence } from '../lib/calendar/events';
|
|
14
|
-
export interface EventProps { settings: Settings; event: CalEvent; html: string; calendars: Calendar[]; origin: string; occurrence: Occurrence; upcoming: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>[]; preview?: boolean; rsvp?: {going:number;left:number|null;maxSeats:number;error:string;open:boolean;closesAt?:number|null;duplicate?:boolean}|null }
|
|
14
|
+
export interface EventProps { settings: Settings; event: CalEvent; html: string; calendars: Calendar[]; origin: string; occurrence: Occurrence; upcoming: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>[]; preview?: boolean; rsvp?: {going:number;left:number|null;maxSeats:number;error:string;open:boolean;closesAt?:number|null;duplicate?:boolean;waitlist?:boolean}|null }
|
|
15
15
|
/**
|
|
16
16
|
* Props for a coded section. `data` holds resolved values: link fields arrive
|
|
17
17
|
* as LinkValue, lists as row arrays, source fields as fresh item arrays
|