@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/cli/migrations.generated.mjs +5 -0
  3. package/docs/mobile.md +2 -1
  4. package/integration.mjs +2 -0
  5. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +66 -31
  6. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +66 -31
  7. package/mobile-template/recipes/rsvp/recipe.json +2 -2
  8. package/package.json +1 -1
  9. package/src/actions/index.ts +1 -1
  10. package/src/components/EventPage.astro +16 -8
  11. package/src/components/editor/EventPanel.tsx +2 -2
  12. package/src/components/editor/PostEditor.tsx +1 -1
  13. package/src/components/editor/event/RegistrationCard.tsx +5 -0
  14. package/src/components/editor/event/registration.ts +1 -1
  15. package/src/lib/booking/agent.ts +1 -1
  16. package/src/lib/booking/emails.ts +20 -5
  17. package/src/lib/booking/service.ts +148 -14
  18. package/src/lib/calendar/rsvp-words.ts +69 -8
  19. package/src/lib/content-service.ts +11 -6
  20. package/src/lib/drafts.ts +2 -2
  21. package/src/lib/editor/bookings.ts +5 -3
  22. package/src/lib/editor/documents.ts +3 -2
  23. package/src/lib/editor/schemas.ts +1 -1
  24. package/src/lib/email/booking-words.ts +26 -2
  25. package/src/lib/email/store.ts +2 -2
  26. package/src/lib/forms/events.ts +4 -2
  27. package/src/lib/legal/app-privacy.ts +2 -2
  28. package/src/lib/legal/text-en.ts +1 -1
  29. package/src/lib/legal/text-es.ts +1 -1
  30. package/src/lib/mobile/bookings.ts +4 -3
  31. package/src/lib/mobile/manage.ts +17 -3
  32. package/src/lib/mobile/openapi.ts +4 -2
  33. package/src/lib/mobile/rsvp.ts +10 -5
  34. package/src/lib/mobile/schemas.ts +10 -3
  35. package/src/lib/repo/migrations.ts +5 -0
  36. package/src/lib/repo/sql.ts +53 -13
  37. package/src/lib/repo/types.ts +11 -4
  38. package/src/lib/scheduler.ts +3 -1
  39. package/src/lib/schema.ts +10 -2
  40. package/src/pages/admin/calendar/bookings.astro +57 -13
  41. package/src/pages/api/calendar/bookings/index.ts +1 -1
  42. package/src/pages/api/calendar/docs.ts +2 -2
  43. package/src/pages/api/v1/bookings/manage/confirm.ts +6 -0
  44. package/src/pages/book/manage/[token].astro +57 -15
  45. package/src/pages/events/[slug].astro +3 -2
  46. package/src/styles/admin.css +3 -1
  47. package/src/styles/site.css +10 -1
  48. package/src/theme-sdk/index.ts +1 -1
@@ -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
- export const BookingStatus = z.enum(['pending', 'confirmed', 'declined', 'cancelled']);
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'}${b.seats > 1 ? ` · ${b.seats} seats` : ''}`
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 : tab === 'past' ? active(b) && b.end <= now : !active(b))
139
- .sort((a, b) => (tab === 'past' || tab === 'cancelled' ? b.start - a.start : a.start - b.start))
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
- const agendaItems = tab === 'upcoming' ? list.filter((b) => b.status !== 'pending') : list;
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 statusLabel = (b: Booking) => (b.kind === 'block' ? 'Blocked' : b.status[0].toUpperCase() + b.status.slice(1));
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.kind === 'block' || !(b.status === 'pending' || b.status === 'confirmed')) && (
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
- if (form.get('op') === 'cancel' && (booking.status === 'pending' || booking.status === 'confirmed')) {
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, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) });
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 when = whenText(current, tz, isExtraLocale(locale) ? { ...settings, locale } : settings, link?.mode === 'day' || event?.allDay);
67
- const active = current.status === 'pending' || current.status === 'confirmed';
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 heading = current.status === 'cancelled' ? w.cancelledTitle
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.cancelledText;
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}`}>{w.statuses[current.status]}</dd></div>
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={`return confirm(${JSON.stringify(w.cancelConfirm)})`}>
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">{w.cancel}</button>
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));
@@ -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; }
@@ -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) {
@@ -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