@acms-subir/core 0.3.40 → 0.3.42

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 (69) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/cli/migrations.generated.mjs +15 -0
  3. package/docs/mobile.md +12 -2
  4. package/integration.mjs +4 -0
  5. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +66 -31
  6. package/mobile-template/recipes/push/files/app/link-phone.tsx +43 -0
  7. package/mobile-template/recipes/push/files/app/notifications.tsx +14 -4
  8. package/mobile-template/recipes/push/recipe.json +4 -2
  9. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +66 -31
  10. package/mobile-template/recipes/rsvp/recipe.json +2 -2
  11. package/package.json +1 -1
  12. package/src/actions/index.ts +1 -1
  13. package/src/components/Announcements.tsx +11 -2
  14. package/src/components/EventPage.astro +16 -8
  15. package/src/components/MobileApp.tsx +20 -8
  16. package/src/components/TestPhones.tsx +177 -0
  17. package/src/components/editor/EventPanel.tsx +2 -2
  18. package/src/components/editor/PostEditor.tsx +1 -1
  19. package/src/components/editor/event/RegistrationCard.tsx +5 -0
  20. package/src/components/editor/event/registration.ts +1 -1
  21. package/src/lib/announcements/api.ts +19 -2
  22. package/src/lib/announcements/service.ts +1 -1
  23. package/src/lib/booking/agent.ts +1 -1
  24. package/src/lib/booking/emails.ts +20 -5
  25. package/src/lib/booking/service.ts +148 -14
  26. package/src/lib/calendar/rsvp-words.ts +69 -8
  27. package/src/lib/content-service.ts +11 -6
  28. package/src/lib/drafts.ts +2 -2
  29. package/src/lib/editor/bookings.ts +5 -3
  30. package/src/lib/editor/documents.ts +3 -2
  31. package/src/lib/editor/schemas.ts +1 -1
  32. package/src/lib/email/booking-words.ts +26 -2
  33. package/src/lib/email/service.ts +14 -2
  34. package/src/lib/email/store.ts +6 -2
  35. package/src/lib/email/templates.ts +20 -0
  36. package/src/lib/forms/events.ts +4 -2
  37. package/src/lib/legal/app-privacy.ts +4 -4
  38. package/src/lib/legal/text-en.ts +2 -2
  39. package/src/lib/legal/text-es.ts +2 -2
  40. package/src/lib/mobile/bookings.ts +4 -3
  41. package/src/lib/mobile/guest.ts +2 -1
  42. package/src/lib/mobile/manage.ts +17 -3
  43. package/src/lib/mobile/openapi.ts +22 -5
  44. package/src/lib/mobile/rsvp.ts +10 -5
  45. package/src/lib/mobile/schemas.ts +14 -3
  46. package/src/lib/push/api.ts +37 -8
  47. package/src/lib/push/service.ts +6 -2
  48. package/src/lib/push/staff.ts +162 -0
  49. package/src/lib/push/store.ts +49 -2
  50. package/src/lib/push/types.ts +4 -1
  51. package/src/lib/repo/migrations.ts +15 -0
  52. package/src/lib/repo/sql.ts +57 -14
  53. package/src/lib/repo/types.ts +11 -4
  54. package/src/lib/scheduler.ts +3 -1
  55. package/src/lib/schema.ts +10 -2
  56. package/src/pages/admin/calendar/bookings.astro +57 -13
  57. package/src/pages/api/announcements.ts +2 -2
  58. package/src/pages/api/calendar/bookings/index.ts +1 -1
  59. package/src/pages/api/calendar/docs.ts +2 -2
  60. package/src/pages/api/push/admin.ts +1 -1
  61. package/src/pages/api/push/phones.ts +25 -0
  62. package/src/pages/api/v1/bookings/manage/confirm.ts +6 -0
  63. package/src/pages/api/v1/devices/[token]/staff-link.ts +20 -0
  64. package/src/pages/book/manage/[token].astro +57 -15
  65. package/src/pages/events/[slug].astro +3 -2
  66. package/src/styles/admin.css +3 -1
  67. package/src/styles/site.css +10 -1
  68. package/src/styles/test-phones.css +30 -0
  69. package/src/theme-sdk/index.ts +1 -1
@@ -21,8 +21,8 @@ export interface EventMeta {
21
21
  calendarIds: string[];
22
22
  fields: Record<string, string>;
23
23
  // closesAt: wall time like start; closeBefore: minutes before each date (0 = until it ends);
24
- // notifyStaff: the hosts get an email about each new or cancelled RSVP
25
- rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; notifyStaff?: boolean; form?: FormDefinition };
24
+ // notifyStaff: the hosts get an email about each new or cancelled RSVP; waitlist: a full date takes places in line
25
+ rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; notifyStaff?: boolean; waitlist?: boolean; form?: FormDefinition };
26
26
  roomHold: RoomHoldValue;
27
27
  }
28
28
 
@@ -169,7 +169,7 @@ const NO_MAPS: MapsStatus = { enabled: false, provider: '', label: '', search: f
169
169
  const NO_EVENT: EventMeta = {
170
170
  start: '', end: '', allDay: false, timezone: '', rrule: '', exdates: [], cancelled: false,
171
171
  location: NO_LOCATION, cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
172
- rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4, closesAt: null, closeBefore: 0, notifyStaff: true },
172
+ rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4, closesAt: null, closeBefore: 0, notifyStaff: true, waitlist: false },
173
173
  roomHold: NO_HOLD,
174
174
  };
175
175
 
@@ -77,6 +77,11 @@ export function RegistrationCard({ meta, onChange, eventId, title = '' }: { even
77
77
  <span><b>Email the hosts</b><small>About each new RSVP and cancellation. Without hosts, the contact email in Settings → Legal gets them</small></span>
78
78
  <input type="checkbox" role="switch" className="pe-switch" checked={meta.rsvp.notifyStaff !== false} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, notifyStaff: e.target.checked } })} />
79
79
  </label>
80
+ {/* a waitlist needs a limit to be full */}
81
+ <label className="pe-switch-row">
82
+ <span><b>Offer a waitlist when it’s full</b><small>{meta.rsvp.capacity ? 'People join a line. When a seat opens, the next person is emailed and it’s held for them for 24 hours' : 'Set a number of seats to use it'}</small></span>
83
+ <input type="checkbox" role="switch" className="pe-switch" disabled={!meta.rsvp.capacity} checked={Boolean(meta.rsvp.waitlist && meta.rsvp.capacity)} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, waitlist: e.target.checked } })} />
84
+ </label>
80
85
  </div>
81
86
  <Deadline meta={meta} onChange={onChange} />
82
87
  <div className="pe-evr-form">
@@ -9,7 +9,7 @@ export type RegistrationMode = 'none' | 'link' | 'rsvp';
9
9
 
10
10
  interface RegistrationData {
11
11
  cta: EventCta;
12
- rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; notifyStaff?: boolean };
12
+ rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; notifyStaff?: boolean; waitlist?: boolean };
13
13
  }
14
14
 
15
15
  export function registrationMode({ cta, rsvp }: RegistrationData): RegistrationMode {
@@ -10,8 +10,9 @@ import { localeOnlyQuery, localeQuery, mobileLocale } from '../mobile/locale';
10
10
  import { PUSH } from '../push/config';
11
11
  import { deviceLocale, knownTopics, pushTopics, siteOriginOf, sitePath } from '../push/service';
12
12
  import { linkTargets } from '../push/api';
13
+ import { sendTest } from '../push/staff';
13
14
  import { ANNOUNCEMENTS } from './config';
14
- import { announcementJson, isLive, publishAnnouncement, type PublishOptions } from './service';
15
+ import { URGENT_CHANNEL, announcementJson, isLive, localizeAnnouncement, publishAnnouncement, type PublishOptions } from './service';
15
16
  import { announcementStrings } from './words';
16
17
  import { ANNOUNCEMENT_KINDS, AnnouncementInput, AnnouncementKind, LIMITS, type Announcement, type AnnouncementWords } from './types';
17
18
 
@@ -115,6 +116,8 @@ export const AdminAnnouncementAction = z.discriminatedUnion('action', [
115
116
  z.object({ action: z.literal('archive'), id }).strict(),
116
117
  z.object({ action: z.literal('restore'), id }).strict(),
117
118
  z.object({ action: z.literal('delete'), id }).strict(),
119
+ // "Send test to my phone": the draft as it stands, in the preview's language, to the sender's linked phones
120
+ z.object({ action: z.literal('test'), announcement: AnnouncementInput, locale: z.string().max(35).optional() }).strict(),
118
121
  ]);
119
122
  export type AdminAnnouncementAction = z.infer<typeof AdminAnnouncementAction>;
120
123
 
@@ -156,7 +159,7 @@ export function scheduleProblem(a: Pick<Announcement, 'title' | 'message' | 'lis
156
159
  return null;
157
160
  }
158
161
 
159
- export interface AdminContext extends PublishOptions { url: URL; author: string }
162
+ export interface AdminContext extends PublishOptions { url: URL; author: string; userId?: string }
160
163
 
161
164
  export async function adminAnnouncementAction(repo: ContentRepository, input: AdminAnnouncementAction, ctx: AdminContext) {
162
165
  requireAnnouncements();
@@ -166,6 +169,7 @@ export async function adminAnnouncementAction(repo: ContentRepository, input: Ad
166
169
  return { recipients: await countRecipients(repo, input.kind === 'urgent' ? ['*'] : await checkAudience(repo, input.audience)) };
167
170
  }
168
171
  if (input.action === 'save') return save(repo, input, ctx, now);
172
+ if (input.action === 'test') return test(repo, input, ctx);
169
173
  const a = await repo.announcements.get(input.id);
170
174
  if (!a) throw new ApiError(404, 'Announcement not found.');
171
175
  if (input.action === 'archive') {
@@ -184,6 +188,19 @@ export async function adminAnnouncementAction(repo: ContentRepository, input: Ad
184
188
  return { deleted: a.id };
185
189
  }
186
190
 
191
+ // a tap opens the button's link, else the app (a draft has no page yet); urgent uses its channel so it looks the part
192
+ async function test(repo: ContentRepository, input: Extract<AdminAnnouncementAction, { action: 'test' }>, ctx: AdminContext) {
193
+ if (!PUSH.enabled) throw new ApiError(400, 'Push notifications are off on this site (acms({ mobile: { push } })).');
194
+ const a = input.announcement;
195
+ const w = localizeAnnouncement({ ...a, translations: cleanTranslations(a.translations) }, input.locale ?? null);
196
+ if (!w.title) throw new ApiError(400, 'Add a title.');
197
+ const notification = await sendTest(repo, {
198
+ userId: ctx.userId ?? '', author: ctx.author, title: w.title, body: w.message, url: a.link, locale: input.locale ?? null,
199
+ channel: a.kind === 'urgent' ? URGENT_CHANNEL : undefined, data: { announcementKind: a.kind },
200
+ }, { ...ctx, origin: ctx.origin ?? siteOriginOf(await repo.getSettings(), ctx.url.origin) });
201
+ return { notification };
202
+ }
203
+
187
204
  async function save(repo: ContentRepository, input: Extract<AdminAnnouncementAction, { action: 'save' }>, ctx: AdminContext, now: number) {
188
205
  const body = input.announcement;
189
206
  const existing = input.id ? await repo.announcements.get(input.id) : null;
@@ -15,7 +15,7 @@ import type { Announcement, AnnouncementWords } from './types';
15
15
  export const URGENT_CHANNEL = 'urgent';
16
16
 
17
17
  /** Each field in `locale`, falling back to the main words; `translated` = the language has its own title. */
18
- export function localizeAnnouncement(a: Announcement, locale: string | null | undefined): AnnouncementWords & { translated: boolean } {
18
+ export function localizeAnnouncement(a: AnnouncementWords & Pick<Announcement, 'translations'>, locale: string | null | undefined): AnnouncementWords & { translated: boolean } {
19
19
  const main = { title: a.title, message: a.message, details: a.details, buttonLabel: a.buttonLabel };
20
20
  if (!locale || locale === I18N.defaultLocale) return { ...main, translated: true };
21
21
  const t = a.translations[locale] ?? a.translations[locale.toLowerCase().split('-')[0]];
@@ -18,7 +18,7 @@ export const BookingToolInputs = [
18
18
  minutes: z.number().int().optional(),
19
19
  }).strict() }).strict(),
20
20
  z.object({ tool: z.literal('list_bookings'), arguments: z.object({
21
- status: z.enum(['pending', 'confirmed', 'declined', 'cancelled']).optional(), resource: z.string().optional(),
21
+ status: z.enum(['pending', 'confirmed', 'declined', 'cancelled', 'waitlisted', 'offered', 'expired']).optional(), resource: z.string().optional(),
22
22
  from: z.string().optional(), limit: z.number().int().min(1).max(100).default(25),
23
23
  }).strict() }).strict(),
24
24
  z.object({ tool: z.literal('create_booking'), arguments: z.object({
@@ -44,6 +44,11 @@ export function whenText(b: Pick<Booking, 'start' | 'end'>, tz: string, settings
44
44
  return `${f({ weekday: 'long', month: 'long', day: 'numeric' }).format(b.start)} · ${f({ hour: 'numeric', minute: '2-digit' }).format(b.start)} – ${f({ hour: 'numeric', minute: '2-digit' }).format(b.end)} ${zone}`.trim();
45
45
  }
46
46
 
47
+ /** until when a freed spot is held, in the booking's language and the event's zone: "Fri, Nov 7, 6:00 PM UTC" */
48
+ export function untilText(at: number, tz: string, settings: Settings): string {
49
+ return new Intl.DateTimeFormat(siteLocale(settings), { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short', timeZone: tz }).format(at);
50
+ }
51
+
47
52
  const utc = (ms: number) => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
48
53
 
49
54
  // lang: the description's language ("Manage: <url>")
@@ -80,6 +85,8 @@ interface Ctx {
80
85
  rsvpStaff?: { date: string; taken: number; capacity: number; why: RsvpStaffNotice['why']; via: Booking['source'] };
81
86
  /** manageUrl is the recipient's My events page (a host without Manage bookings) */
82
87
  mineLink?: boolean;
88
+ /** waitlist emails: the place in line (null once it isn't in line), how many hours a freed spot is held, and until when this one is */
89
+ waitlist?: { position: number | null; hours: number; until: string };
83
90
  }
84
91
 
85
92
  // own: a staff email's own rows and footer instead of the details and reference
@@ -104,8 +111,11 @@ ${button ? `<p style="margin:22px 0 0"><a href="${esc(button.href)}" style="disp
104
111
  }
105
112
 
106
113
  // link: the booking's own link again, after someone tried to make it twice.
107
- // staff-rsvp(-cancelled): the hosts hear about a new RSVP, or one the person cancelled
108
- export type BookingEmail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'rescheduled' | 'updated' | 'reminder' | 'link' | 'staff-new' | 'staff-rsvp' | 'staff-rsvp-cancelled';
114
+ // waitlisted / offered / left: they joined a full date's waitlist, a spot
115
+ // opened and is held for them, or they're off the waitlist (left it, released
116
+ // the spot or staff removed them). staff-rsvp(-cancelled): the hosts hear
117
+ // about a new RSVP, or one the person cancelled
118
+ export type BookingEmail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'rescheduled' | 'updated' | 'reminder' | 'link' | 'waitlisted' | 'offered' | 'left' | 'staff-new' | 'staff-rsvp' | 'staff-rsvp-cancelled';
109
119
 
110
120
  export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
111
121
  const w = bookingStrings(c.lang);
@@ -115,8 +125,9 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
115
125
  // null: no button (nothing left to change)
116
126
  const guest = (k: Exclude<BookingEmail, `staff-${string}`>, extra: Partial<Ctx> = {}, button: { href: string; label: string } | null = manage) => {
117
127
  const copy = mailCopy(w, k, rsvp);
118
- const vars = { name: c.booking.name || c.booking.email, title: c.title, when: c.when };
119
- return { to: c.booking.email, subject: fill(copy.subject, vars), ...layout(copy.heading, fill(copy.intro, vars), { ...c, ...extra }, w, button ?? undefined) };
128
+ const vars = { name: c.booking.name || c.booking.email, title: c.title, when: c.when,
129
+ position: String(c.waitlist?.position ?? ''), hours: String(c.waitlist?.hours ?? ''), until: c.waitlist?.until ?? '' };
130
+ return { to: c.booking.email, subject: fill(copy.subject, vars), ...layout(fill(copy.heading, vars), fill(copy.intro, vars), { ...c, ...extra }, w, button ?? undefined) };
120
131
  };
121
132
  switch (kind) {
122
133
  case 'received': return guest('received');
@@ -126,7 +137,11 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
126
137
  case 'updated': return { ...guest('updated'), attachments: ics() };
127
138
  case 'rescheduled': return { ...guest('rescheduled'), attachments: ics() };
128
139
  case 'reminder': return guest('reminder');
129
- case 'link': return guest('link');
140
+ // asked again while in line or holding a spot: that email again
141
+ case 'link': return c.booking.status === 'waitlisted' || c.booking.status === 'offered' ? bookingEmail(c.booking.status, c) : guest('link');
142
+ case 'waitlisted': return guest('waitlisted', {}, { href: c.manageUrl, label: w.waitlist.manage });
143
+ case 'offered': return guest('offered', {}, { href: c.manageUrl, label: w.waitlist.confirm });
144
+ case 'left': return guest('left', {}, null);
130
145
  case 'staff-new': {
131
146
  const pending = c.booking.status === 'pending';
132
147
  const who = [c.booking.name, c.booking.email, c.booking.phone].filter(Boolean).join(' · ');
@@ -18,7 +18,7 @@ import { eventTimeZone, locationLine, occurrenceKey, rsvpDeadline } from '../cal
18
18
  import { normalizePageLocale } from '../i18n';
19
19
  import { localizeDoc } from '../translations';
20
20
  import { bookingStrings } from '../email/booking-words';
21
- import { bookingEmail, bookingLanguage, bookingManagePath, whenText, type BookingEmail } from './emails';
21
+ import { bookingEmail, bookingLanguage, bookingManagePath, untilText, whenText, type BookingEmail } from './emails';
22
22
  import { holdOf, isBookable, resourceZone, spanEnd, type AvailabilityContext, type Interval } from './availability';
23
23
  import { decideStatus, linkApproval } from './approval';
24
24
  import { eventHolds } from './holds';
@@ -43,6 +43,9 @@ const token = () => {
43
43
  return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
44
44
  };
45
45
 
46
+ /** how long a spot freed for the next person on a waitlist is held for them (ms) */
47
+ export const WAITLIST_HOLD = 24 * 3_600_000;
48
+
46
49
  // ---- availability context ------------------------------------------------
47
50
 
48
51
  const busyCache = new Map<string, { at: number; intervals: Interval[] }>();
@@ -147,11 +150,16 @@ export async function bookingMail(repo: ContentRepository, b: Booking, kind: Boo
147
150
  // a host without Manage bookings (a member who hosts) gets their My events
148
151
  // page for the event instead of an admin page they can't open
149
152
  const mine = counts && d.event && recipient ? await memberHostPath(repo, d.event, recipient) : '';
153
+ // a place in line (its number when sent) or a held spot (until when, in the event's zone)
154
+ const waitlist = b.status === 'waitlisted' || b.status === 'offered' ? {
155
+ position: await waitlistPosition(repo, b), hours: WAITLIST_HOLD / 3_600_000,
156
+ until: b.holdUntil === null ? '' : untilText(b.holdUntil, d.tz, settings),
157
+ } : undefined;
150
158
  return bookingEmail(kind, { booking:b,resource:d.resource,link:d.link,title:d.title,location:d.location,when:d.when,
151
159
  manageUrl:mine ? origin + mine : staff ? adminBookingUrl(origin, b, kind) : origin + bookingManagePath({ manageToken: b.manageToken, locale: language.locale }),
152
160
  mineLink: !!mine,
153
161
  siteTitle:settings.siteTitle,host:new URL(origin).host,organizer:parseFrom(from)?.email,message:snapshot?.message,
154
- allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay), lang:language.lang, rsvpStaff: counts });
162
+ allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay), lang:language.lang, rsvpStaff: counts, waitlist });
155
163
  }
156
164
 
157
165
  /** The member's My events page for an event they host, when they can't manage bookings in the admin ('' otherwise). */
@@ -239,10 +247,13 @@ export function guestChanges(b: Booking, link: BookingLink | null, now = Date.no
239
247
  const active = b.status === 'pending' || b.status === 'confirmed';
240
248
  const cutoff = b.kind === 'booking' ? (link?.changeCutoff ?? 0) : 0;
241
249
  const closed = active && cutoff > 0 && b.start - now < cutoff * 3_600_000;
242
- const open = active && b.end > now && !closed;
250
+ // a place in line or a held spot can be left until the date ends
251
+ const open = (active || b.status === 'waitlisted' || b.status === 'offered') && b.end > now && !closed;
243
252
  return {
244
253
  cancel: open,
245
- reschedule: open && b.kind === 'booking' && b.start > now && !!link?.active,
254
+ reschedule: open && active && b.kind === 'booking' && b.start > now && !!link?.active,
255
+ // a spot offered from the waitlist, still held for them
256
+ confirm: b.status === 'offered' && (b.holdUntil ?? 0) > now && b.end > now,
246
257
  // hours before the start when guest changes close; 0 = until it ends
247
258
  cutoffHours: cutoff,
248
259
  closed,
@@ -255,8 +266,9 @@ export async function guestCancel(repo: ContentRepository, b: Booking, opts: Flo
255
266
  if (b.status === 'cancelled') return b;
256
267
  const can = guestChanges(b, link, opts.now);
257
268
  if (!can.cancel) throw new BookingError(409, can.closed ? `Changes close ${can.cutoffHours} hours before the start. Please contact us to cancel.` : 'This booking can’t be cancelled.');
258
- // the person cancelled an RSVP: its hosts hear about it (staff cancelling never emails staff)
259
- const event = b.kind === 'rsvp' && b.eventId ? await repo.getEvent(b.eventId) : null;
269
+ // the person cancelled an RSVP: its hosts hear about it (staff cancelling never emails staff;
270
+ // leaving the waitlist or a held spot isn't an RSVP to them)
271
+ const event = b.kind === 'rsvp' && b.eventId && (b.status === 'pending' || b.status === 'confirmed') ? await repo.getEvent(b.eventId) : null;
260
272
  return setBookingStatus(repo, b, 'cancelled', opts, undefined, event ? await rsvpStaff(repo, event, await repo.getSettings(), via) : undefined);
261
273
  }
262
274
 
@@ -313,14 +325,34 @@ export async function createBlock(repo: ContentRepository, resource: Resource, s
313
325
  });
314
326
  }
315
327
 
328
+ // pending, confirmed and held (offered) RSVPs take seats, as in the capacity
329
+ // check; a place in line doesn't
330
+ const seated = (s: BookingStatus) => s === 'pending' || s === 'confirmed' || s === 'offered';
316
331
  export async function seatsTaken(repo: ContentRepository, eventId: string, start: number): Promise<number> {
317
- const list = await repo.listBookings({ eventId, from: start - 1, to: start + 1, statuses: ['pending', 'confirmed'], kinds: ['rsvp'] });
332
+ const list = await repo.listBookings({ eventId, from: start - 1, to: start + 1, statuses: ['pending', 'confirmed', 'offered'], kinds: ['rsvp'] });
318
333
  return list.filter((b) => b.start === start).reduce((n, b) => n + b.seats, 0);
319
334
  }
320
335
 
336
+ /** whether a full date takes places in line: the event keeps a waitlist and has a capacity */
337
+ export const waitlistOn = (e: CalEvent) => e.rsvp.enabled && e.rsvp.waitlist && e.rsvp.capacity > 0;
338
+
339
+ /** a date's waitlist in order: who joined first, then by id */
340
+ export async function waitlistLine(repo: ContentRepository, eventId: string, start: number): Promise<Booking[]> {
341
+ const list = await repo.listBookings({ eventId, from: start - 1, to: start + 1, statuses: ['waitlisted'], kinds: ['rsvp'] });
342
+ return list.filter((b) => b.start === start).sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
343
+ }
344
+
345
+ /** a waitlisted RSVP's place in its date's line (1 = next), else null */
346
+ export async function waitlistPosition(repo: ContentRepository, b: Booking): Promise<number | null> {
347
+ if (b.status !== 'waitlisted') return null;
348
+ const i = (await waitlistLine(repo, b.eventId, b.start)).findIndex((x) => x.id === b.id);
349
+ return i < 0 ? null : i + 1;
350
+ }
351
+
321
352
  export async function createRsvp(
322
353
  repo: ContentRepository,
323
- args: { event: CalEvent; start: number; end: number; seats: number; booker: BookerInput; source?: Booking['source'] },
354
+ // waitlist: a full date (fewer seats left than asked) takes a place in line instead, when the event keeps one
355
+ args: { event: CalEvent; start: number; end: number; seats: number; booker: BookerInput; source?: Booking['source']; waitlist?: boolean },
324
356
  opts: FlowOptions
325
357
  ): Promise<Booking> {
326
358
  const e = args.event;
@@ -343,15 +375,20 @@ export async function createRsvp(
343
375
  const once = args.source !== 'admin';
344
376
  const source = args.source ?? 'web';
345
377
  // the hosts' email is queued with the RSVP: a duplicate or a replay inserts nothing, so it emails no one
346
- const b = await repo.createBooking({
347
- kind: 'rsvp', resourceId: '', linkId: '', eventId: e.id, status: e.rsvp.autoConfirm ? 'confirmed' : 'pending',
378
+ const data = {
379
+ kind: 'rsvp' as const, resourceId: '', linkId: '', eventId: e.id, status: (e.rsvp.autoConfirm ? 'confirmed' : 'pending') as BookingStatus,
348
380
  start: args.start, end: args.end, holdStart: args.start, holdEnd: args.end, timezone: e.timezone, ...booker, seats,
349
381
  formResponse, notes: '', manageToken: token(), source, locale: normalizePageLocale(args.booker.locale ?? ''),
350
- }, { capacity: e.rsvp.capacity, oncePerEmail: once, idempotency: opts.idempotency, staff: await rsvpStaff(repo, e, settings, source) });
382
+ };
383
+ let b = await repo.createBooking(data, { capacity: e.rsvp.capacity, oncePerEmail: once, idempotency: opts.idempotency, staff: await rsvpStaff(repo, e, settings, source) });
384
+ // full: a place in line, which takes no seats and emails no staff (still one per address and date)
385
+ if (!b && args.waitlist && waitlistOn(e) && !(opts.idempotency && await repo.getBookingRequest(opts.idempotency.keyHash))) {
386
+ b = await repo.createBooking({ ...data, status: 'waitlisted' }, { oncePerEmail: once, idempotency: opts.idempotency });
387
+ }
351
388
  if (!b) {
352
389
  // a retry of a request that just committed: the caller replays that one
353
390
  const retry = opts.idempotency ? await repo.getBookingRequest(opts.idempotency.keyHash) : null;
354
- const mine = once && !retry ? (await repo.listBookings({ eventId: e.id, email: booker.email, from: args.start - 1, to: args.start + 1, statuses: ['pending', 'confirmed'], kinds: ['rsvp'] })).find((x) => x.start === args.start) : undefined;
391
+ const mine = once && !retry ? (await repo.listBookings({ eventId: e.id, email: booker.email, from: args.start - 1, to: args.start + 1, statuses: ['pending', 'confirmed', 'offered', 'waitlisted'], kinds: ['rsvp'] })).find((x) => x.start === args.start) : undefined;
355
392
  if (mine) {
356
393
  await resendManageLink(repo, mine, args.booker.locale === undefined ? mine.locale : normalizePageLocale(args.booker.locale), opts);
357
394
  // anyone can type an address: say only that it has one
@@ -365,6 +402,95 @@ export async function createRsvp(
365
402
  return b;
366
403
  }
367
404
 
405
+ /**
406
+ * Offers a date's freed seats to its waitlist, in order: each person whose
407
+ * seats fit what's free gets them held for WAITLIST_HOLD, with an email to
408
+ * confirm; larger requests keep their place. Runs after anything that frees
409
+ * seats (a cancellation, a decline, a spot that ran out, a capacity raised)
410
+ * and from the scheduler. Only while the event keeps a waitlist and before
411
+ * the date starts, past its RSVP deadline too: those in line joined in time.
412
+ * Each offer re-checks the seats in the database, so racing runs (or a new
413
+ * RSVP) never take more than the capacity. Returns how many it offered.
414
+ */
415
+ export async function offerWaitlist(repo: ContentRepository, eventId: string, start: number, opts: FlowOptions): Promise<number> {
416
+ const now = opts.now ?? Date.now();
417
+ if (start <= now) return 0;
418
+ const line = await waitlistLine(repo, eventId, start);
419
+ if (!line.length) return 0;
420
+ const e = await repo.getEvent(eventId);
421
+ // turning the waitlist off only stops new people joining: those already in line still get their turn
422
+ if (!e || !e.rsvp.enabled || e.cancelled) return 0;
423
+ if (e.cancelledDates?.length && e.cancelledDates.includes(occurrenceKey(e, start, await repo.getSettings()))) return 0;
424
+ const cap = e.rsvp.capacity;
425
+ const free = async () => (cap ? cap - await seatsTaken(repo, eventId, start) : Infinity);
426
+ let left = await free(), offered = 0;
427
+ for (const b of line) {
428
+ if (left <= 0) break;
429
+ if (b.seats > left) continue;
430
+ if (await repo.offerBooking(b.id, now + WAITLIST_HOLD, cap)) { left -= b.seats; offered++; }
431
+ // someone took seats meanwhile: count again
432
+ else left = await free();
433
+ }
434
+ if (offered) await kickEmail(repo, opts.locals, opts.waitUntil);
435
+ return offered;
436
+ }
437
+
438
+ /** every upcoming date of one event with people in line (after an edit that may have freed seats) */
439
+ export async function offerEventWaitlist(repo: ContentRepository, eventId: string, opts: FlowOptions): Promise<number> {
440
+ const now = opts.now ?? Date.now();
441
+ const starts = new Set((await repo.listBookings({ eventId, from: now, statuses: ['waitlisted'], kinds: ['rsvp'] })).map((b) => b.start));
442
+ let offered = 0;
443
+ for (const start of starts) offered += await offerWaitlist(repo, eventId, start, opts);
444
+ return offered;
445
+ }
446
+
447
+ /**
448
+ * The person takes the spot held for them (manage page, app): confirmed, or
449
+ * pending when the event approves each RSVP, with the usual email and the
450
+ * hosts' "New RSVP" one. Past its hold the spot is released (410) and goes
451
+ * to the next person in line. Repeating it is harmless.
452
+ */
453
+ export async function confirmOffer(repo: ContentRepository, b: Booking, opts: FlowOptions, via: Booking['source'] = 'web'): Promise<Booking> {
454
+ const now = opts.now ?? Date.now();
455
+ if (b.status === 'confirmed' || b.status === 'pending') return b;
456
+ if (b.status === 'offered' && (b.holdUntil ?? 0) <= now) {
457
+ await releaseOffer(repo, b, opts);
458
+ throw new BookingError(410, 'This held spot was released.');
459
+ }
460
+ if (b.status === 'expired') throw new BookingError(410, 'This held spot was released.');
461
+ if (b.status !== 'offered') throw new BookingError(409, 'There’s no held spot to confirm.');
462
+ if (b.end <= now) throw new BookingError(410, 'This event has already happened.');
463
+ const event = b.eventId ? await repo.getEvent(b.eventId) : null;
464
+ try {
465
+ return await setBookingStatus(repo, b, event?.rsvp.autoConfirm === false ? 'pending' : 'confirmed', opts, undefined, event ? await rsvpStaff(repo, event, await repo.getSettings(), via) : undefined);
466
+ } catch (err) {
467
+ if ((await repo.getBooking(b.id))?.status === 'expired') throw new BookingError(410, 'This held spot was released.');
468
+ throw err;
469
+ }
470
+ }
471
+
472
+ // a held spot nobody confirmed in time: expired (no email; only if it's still
473
+ // offered), and its seats go to the next person in line
474
+ async function releaseOffer(repo: ContentRepository, b: Booking, opts: FlowOptions) {
475
+ if (await repo.expireBooking(b.id, opts.now ?? Date.now())) await offerWaitlist(repo, b.eventId, b.start, opts);
476
+ }
477
+
478
+ // cron: held spots past their time run out, and every upcoming date with
479
+ // people in line is offered what's free (catching up on seats freed by edits,
480
+ // or by a change that lost a race)
481
+ export async function sweepWaitlist(repo: ContentRepository, opts: FlowOptions): Promise<number> {
482
+ const now = opts.now ?? Date.now();
483
+ const list = await repo.listBookings({ from: now, statuses: ['waitlisted', 'offered'], kinds: ['rsvp'] });
484
+ const dates = new Map<string, { eventId: string; start: number }>();
485
+ for (const b of list) {
486
+ if (b.status === 'offered' && (b.holdUntil ?? 0) <= now) await repo.expireBooking(b.id, now);
487
+ dates.set(`${b.eventId}:${b.start}`, { eventId: b.eventId, start: b.start });
488
+ }
489
+ let offered = 0;
490
+ for (const d of dates.values()) offered += await offerWaitlist(repo, d.eventId, d.start, opts);
491
+ return offered;
492
+ }
493
+
368
494
  // The address gets its RSVP's own link again, in the language it just used,
369
495
  // at most once per 10 minutes per RSVP so the form can't flood an inbox.
370
496
  async function resendManageLink(repo: ContentRepository, b: Booking, locale: string, opts: FlowOptions) {
@@ -379,6 +505,10 @@ const TRANSITIONS: Record<BookingStatus, BookingStatus[]> = {
379
505
  confirmed: ['cancelled'],
380
506
  declined: ['confirmed'],
381
507
  cancelled: ['confirmed'],
508
+ // only offerWaitlist offers a place in line, and only the clock expires a held spot
509
+ waitlisted: ['cancelled'],
510
+ offered: ['confirmed', 'pending', 'cancelled'],
511
+ expired: [],
382
512
  };
383
513
 
384
514
  // staff: who to tell that the person cancelled (guestCancel); staff changes tell no one
@@ -391,13 +521,17 @@ export async function setBookingStatus(repo: ContentRepository, b: Booking, stat
391
521
  .some((x) => x.id !== b.id && x.kind !== 'rsvp' && x.holdStart < b.holdEnd && x.holdEnd > b.holdStart);
392
522
  if (clash) throw new BookingError(409, 'That time has been booked since — it can’t be restored.');
393
523
  }
394
- await repo.updateBooking(b.id, { status, notificationMessage: message, staff });
524
+ // only from the status it was read in: a spot that ran out meanwhile can't be confirmed
525
+ await repo.updateBooking(b.id, { status, from: b.status, notificationMessage: message, staff });
395
526
  const next = (await repo.getBooking(b.id))!;
396
527
  if (!next || next.status !== status) throw new BookingError(409, 'This booking changed. Refresh and try again.');
397
528
  if (b.kind !== 'block') {
398
529
  await notify(repo, next, [status === 'confirmed' ? 'confirmed' : status === 'declined' ? 'declined' : 'cancelled'], opts, message);
399
- await hook(repo, status === 'confirmed' ? 'booking.confirmed' : status === 'declined' ? 'booking.declined' : 'booking.cancelled', next, opts);
530
+ // a held spot taken up for approval is no webhook yet (approving it is)
531
+ if (status !== 'pending') await hook(repo, status === 'confirmed' ? 'booking.confirmed' : status === 'declined' ? 'booking.declined' : 'booking.cancelled', next, opts);
400
532
  }
533
+ // the seats it held go to the next people in line
534
+ if (b.kind === 'rsvp' && seated(b.status) && !seated(status)) await offerWaitlist(repo, b.eventId, b.start, opts);
401
535
  return next;
402
536
  }
403
537
 
@@ -1,8 +1,9 @@
1
1
  // Words of the built-in RSVP: the event page's RSVP block, the RSVP's own
2
2
  // page (book/manage) and the app's default button (GET /events/:slug/rsvp).
3
3
  // The same wording as the app's RSVP panel (packages/native strings.ts).
4
- // Plain strings with {count}, {email}, {title}, {date} and {time} slots
5
- // (live/words.ts `fill`); of a pair (`spotLeft`/`spotsLeft`), the first is for 1 and the
4
+ // Plain strings with {count}, {email}, {title}, {date}, {time}, {position},
5
+ // {seats} and {hours} slots (live/words.ts `fill`); of a pair
6
+ // (`spotLeft`/`spotsLeft`), the first is for 1 and the
6
7
  // second for any other number. The event's own form title, questions and
7
8
  // button keep their words. Other languages fall back to English.
8
9
  export interface RsvpStrings {
@@ -28,6 +29,31 @@ export interface RsvpStrings {
28
29
  duplicate: string;
29
30
  duplicateNote: string;
30
31
  saveError: string;
32
+ /** a full date with a waitlist: "Full", "40 going · no spots left", the note and the button (also the form's) */
33
+ fullTag: string;
34
+ noSpotsLeft: string;
35
+ waitlistNote: string;
36
+ joinWaitlist: string;
37
+ /** the RSVP's own page for a place in line: "#3 in line · 2 seats" ({seats}: `seat`/`seats`), and the promise ({hours}) */
38
+ onWaitlist: string;
39
+ inLine: string;
40
+ seat: string;
41
+ seats: string;
42
+ waitlistPromise: string;
43
+ leaveWaitlist: string;
44
+ leaveConfirm: string;
45
+ /** off the line: they left it, released the spot or staff removed them */
46
+ offWaitlist: string;
47
+ offWaitlistText: string;
48
+ /** a spot held for them ({date} and {time}: until when), and one they didn't confirm in time */
49
+ spotOpened: string;
50
+ spotYours: string;
51
+ confirmSpot: string;
52
+ releaseSpot: string;
53
+ releaseConfirm: string;
54
+ released: string;
55
+ releasedText: string;
56
+ confirmError: string;
31
57
  /** the RSVP's own page */
32
58
  booked: string;
33
59
  yours: string;
@@ -44,7 +70,7 @@ export interface RsvpStrings {
44
70
  where: string;
45
71
  status: string;
46
72
  reference: string;
47
- statuses: Record<'pending' | 'confirmed' | 'cancelled' | 'declined', string>;
73
+ statuses: Record<'pending' | 'confirmed' | 'cancelled' | 'declined' | 'waitlisted' | 'offered' | 'expired', string>;
48
74
  addToCalendar: string;
49
75
  downloadIcs: string;
50
76
  cancel: string;
@@ -61,11 +87,18 @@ const STRINGS: Record<string, RsvpStrings> = {
61
87
  full: 'This event is full.', closed: 'RSVPs are closed.', saveError: 'Couldn’t save your RSVP. Please try again.',
62
88
  closesOn: 'RSVPs close {date} at {time}', closedOn: 'They closed on {date}. Questions? Contact us.',
63
89
  duplicate: 'You already have an RSVP for this date.', duplicateNote: 'We sent you an email with a link to change it. Need more seats? Change it there.',
90
+ fullTag: 'Full', noSpotsLeft: 'no spots left', waitlistNote: 'Join the waitlist and we’ll email you if a spot opens.', joinWaitlist: 'Join the waitlist',
91
+ onWaitlist: 'You’re on the waitlist', inLine: '#{position} in line · {seats}', seat: '{count} seat', seats: '{count} seats',
92
+ waitlistPromise: 'If a spot opens, we’ll email you and save it for {hours} hours.', leaveWaitlist: 'Leave the waitlist', leaveConfirm: 'Leave the waitlist?',
93
+ offWaitlist: 'You’re off the waitlist', offWaitlistText: 'You’re no longer on the waitlist for this date.',
94
+ spotOpened: 'A spot opened', spotYours: 'A spot is yours until {date} at {time}', confirmSpot: 'Confirm my spot', releaseSpot: 'Release it',
95
+ releaseConfirm: 'Release this spot? It goes to the next person in line.', released: 'This held spot was released',
96
+ releasedText: 'We held it for {hours} hours, then offered it to the next person in line.', confirmError: 'Couldn’t confirm — please try again.',
64
97
  booked: 'You’re booked', yours: 'Your booking', requestSent: 'Request sent', awaitingApproval: 'Waiting for approval', notAvailable: 'Not available', cancelledTitle: 'Booking cancelled',
65
98
  emailWhenApproved: 'We’ll email {email} once it’s approved.', confirmationOnItsWay: 'A confirmation is on its way to {email}.',
66
99
  declined: 'Sorry, the organiser couldn’t confirm it.', cancelledText: 'This booking is cancelled.',
67
100
  what: 'What', when: 'When', where: 'Where', status: 'Status', reference: 'Reference',
68
- statuses: { pending: 'Pending', confirmed: 'Confirmed', cancelled: 'Cancelled', declined: 'Declined' },
101
+ statuses: { pending: 'Pending', confirmed: 'Confirmed', cancelled: 'Cancelled', declined: 'Declined', waitlisted: 'On the waitlist', offered: 'Spot held for you', expired: 'Released' },
69
102
  addToCalendar: 'Add to calendar', downloadIcs: 'Download .ics', cancel: 'Cancel RSVP', cancelConfirm: 'Cancel this RSVP?', cancelError: 'Couldn’t cancel — please try again.',
70
103
  backTo: 'Back to {title}', keepLink: 'Keep this link — anyone with it can change this booking.',
71
104
  },
@@ -75,11 +108,18 @@ const STRINGS: Record<string, RsvpStrings> = {
75
108
  full: 'Este evento está lleno.', closed: 'Las confirmaciones están cerradas.', saveError: 'No se pudo guardar tu confirmación. Inténtalo de nuevo.',
76
109
  closesOn: 'Las confirmaciones cierran el {date}, {time}', closedOn: 'Cerraron el {date}. ¿Preguntas? Contáctanos.',
77
110
  duplicate: 'Ya tienes una confirmación para esta fecha.', duplicateNote: 'Te enviamos un correo con un enlace para cambiarla. ¿Necesitas más lugares? Cámbiala ahí.',
111
+ fullTag: 'Lleno', noSpotsLeft: 'no quedan lugares', waitlistNote: 'Únete a la lista de espera y te escribiremos si se libera un lugar.', joinWaitlist: 'Unirme a la lista de espera',
112
+ onWaitlist: 'Estás en la lista de espera', inLine: '#{position} en la fila · {seats}', seat: '{count} lugar', seats: '{count} lugares',
113
+ waitlistPromise: 'Si se libera un lugar, te escribiremos y te lo apartaremos {hours} horas.', leaveWaitlist: 'Salir de la lista de espera', leaveConfirm: '¿Salir de la lista de espera?',
114
+ offWaitlist: 'Ya no estás en la lista de espera', offWaitlistText: 'Ya no estás en la lista de espera para esta fecha.',
115
+ spotOpened: 'Se liberó un lugar', spotYours: 'El lugar es tuyo hasta el {date}, {time}', confirmSpot: 'Confirmar mi lugar', releaseSpot: 'Liberarlo',
116
+ releaseConfirm: '¿Liberar este lugar? Pasará a la siguiente persona en la fila.', released: 'Se liberó el lugar que te apartamos',
117
+ releasedText: 'Te lo apartamos {hours} horas y luego se lo ofrecimos a la siguiente persona en la fila.', confirmError: 'No se pudo confirmar. Inténtalo de nuevo.',
78
118
  booked: 'Ya tienes tu lugar', yours: 'Tu confirmación', requestSent: 'Solicitud enviada', awaitingApproval: 'En espera de aprobación', notAvailable: 'No se pudo confirmar', cancelledTitle: 'Confirmación cancelada',
79
119
  emailWhenApproved: 'Te escribiremos a {email} cuando se apruebe.', confirmationOnItsWay: 'Te enviamos la confirmación a {email}.',
80
120
  declined: 'Lo sentimos, el organizador no pudo confirmarla.', cancelledText: 'Esta confirmación se canceló.',
81
121
  what: 'Qué', when: 'Cuándo', where: 'Dónde', status: 'Estado', reference: 'Referencia',
82
- statuses: { pending: 'Pendiente', confirmed: 'Confirmado', cancelled: 'Cancelado', declined: 'Rechazado' },
122
+ statuses: { pending: 'Pendiente', confirmed: 'Confirmado', cancelled: 'Cancelado', declined: 'Rechazado', waitlisted: 'En lista de espera', offered: 'Lugar apartado para ti', expired: 'Liberado' },
83
123
  addToCalendar: 'Agregar al calendario', downloadIcs: 'Descargar .ics', cancel: 'Cancelar mi confirmación', cancelConfirm: '¿Cancelar tu confirmación?', cancelError: 'No se pudo cancelar. Inténtalo de nuevo.',
84
124
  backTo: 'Volver a {title}', keepLink: 'Guarda este enlace: quien lo tenga puede cambiar esta confirmación.',
85
125
  },
@@ -89,11 +129,18 @@ const STRINGS: Record<string, RsvpStrings> = {
89
129
  full: 'Cet événement est complet.', closed: 'Les inscriptions sont closes.', saveError: 'Impossible d’enregistrer votre inscription. Veuillez réessayer.',
90
130
  closesOn: 'Les inscriptions ferment le {date} à {time}', closedOn: 'Elles ont fermé le {date}. Des questions ? Contactez-nous.',
91
131
  duplicate: 'Vous avez déjà une inscription pour cette date.', duplicateNote: 'Nous vous avons envoyé un e-mail avec un lien pour la modifier. Besoin de plus de places ? Modifiez-la depuis ce lien.',
132
+ fullTag: 'Complet', noSpotsLeft: 'plus de place', waitlistNote: 'Inscrivez-vous sur la liste d’attente : nous vous écrirons si une place se libère.', joinWaitlist: 'Rejoindre la liste d’attente',
133
+ onWaitlist: 'Vous êtes sur la liste d’attente', inLine: 'N° {position} dans la file · {seats}', seat: '{count} place', seats: '{count} places',
134
+ waitlistPromise: 'Si une place se libère, nous vous écrirons et vous la garderons {hours} heures.', leaveWaitlist: 'Quitter la liste d’attente', leaveConfirm: 'Quitter la liste d’attente ?',
135
+ offWaitlist: 'Vous n’êtes plus sur la liste d’attente', offWaitlistText: 'Vous n’êtes plus sur la liste d’attente pour cette date.',
136
+ spotOpened: 'Une place s’est libérée', spotYours: 'Une place est à vous jusqu’au {date} à {time}', confirmSpot: 'Confirmer ma place', releaseSpot: 'La libérer',
137
+ releaseConfirm: 'Libérer cette place ? Elle ira à la personne suivante dans la file.', released: 'La place gardée pour vous a été libérée',
138
+ releasedText: 'Nous l’avons gardée {hours} heures, puis proposée à la personne suivante dans la file.', confirmError: 'Impossible de confirmer. Veuillez réessayer.',
92
139
  booked: 'Votre place est réservée', yours: 'Votre inscription', requestSent: 'Demande envoyée', awaitingApproval: 'En attente de validation', notAvailable: 'Non confirmée', cancelledTitle: 'Inscription annulée',
93
140
  emailWhenApproved: 'Nous écrirons à {email} dès qu’elle sera validée.', confirmationOnItsWay: 'Une confirmation est en route vers {email}.',
94
141
  declined: 'Désolés, l’organisateur n’a pas pu la confirmer.', cancelledText: 'Cette inscription est annulée.',
95
142
  what: 'Quoi', when: 'Quand', where: 'Où', status: 'Statut', reference: 'Référence',
96
- statuses: { pending: 'En attente', confirmed: 'Confirmée', cancelled: 'Annulée', declined: 'Refusée' },
143
+ statuses: { pending: 'En attente', confirmed: 'Confirmée', cancelled: 'Annulée', declined: 'Refusée', waitlisted: 'Sur liste d’attente', offered: 'Place gardée pour vous', expired: 'Libérée' },
97
144
  addToCalendar: 'Ajouter au calendrier', downloadIcs: 'Télécharger le .ics', cancel: 'Annuler mon inscription', cancelConfirm: 'Annuler cette inscription ?', cancelError: 'Impossible d’annuler. Veuillez réessayer.',
98
145
  backTo: 'Retour à {title}', keepLink: 'Conservez ce lien : toute personne qui l’a peut modifier cette inscription.',
99
146
  },
@@ -103,11 +150,18 @@ const STRINGS: Record<string, RsvpStrings> = {
103
150
  full: 'Diese Veranstaltung ist ausgebucht.', closed: 'Anmeldungen sind geschlossen.', saveError: 'Ihre Anmeldung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.',
104
151
  closesOn: 'Anmeldung bis {date}, {time}', closedOn: 'Die Anmeldung endete am {date}. Fragen? Kontaktieren Sie uns.',
105
152
  duplicate: 'Sie haben für diesen Termin schon eine Anmeldung.', duplicateNote: 'Wir haben Ihnen eine E-Mail mit einem Link zum Ändern geschickt. Mehr Plätze nötig? Ändern Sie sie dort.',
153
+ fullTag: 'Ausgebucht', noSpotsLeft: 'keine Plätze frei', waitlistNote: 'Tragen Sie sich auf die Warteliste ein. Wird ein Platz frei, schreiben wir Ihnen.', joinWaitlist: 'Auf die Warteliste',
154
+ onWaitlist: 'Sie stehen auf der Warteliste', inLine: 'Nr. {position} auf der Warteliste · {seats}', seat: '{count} Platz', seats: '{count} Plätze',
155
+ waitlistPromise: 'Wird ein Platz frei, schreiben wir Ihnen und halten ihn {hours} Stunden für Sie frei.', leaveWaitlist: 'Warteliste verlassen', leaveConfirm: 'Die Warteliste verlassen?',
156
+ offWaitlist: 'Sie stehen nicht mehr auf der Warteliste', offWaitlistText: 'Sie stehen für diesen Termin nicht mehr auf der Warteliste.',
157
+ spotOpened: 'Ein Platz ist frei geworden', spotYours: 'Der Platz ist bis {date}, {time} für Sie reserviert', confirmSpot: 'Platz bestätigen', releaseSpot: 'Freigeben',
158
+ releaseConfirm: 'Diesen Platz freigeben? Er geht an die nächste Person auf der Warteliste.', released: 'Der für Sie reservierte Platz wurde freigegeben',
159
+ releasedText: 'Wir haben ihn {hours} Stunden für Sie freigehalten und dann der nächsten Person auf der Warteliste angeboten.', confirmError: 'Bestätigung fehlgeschlagen. Bitte versuchen Sie es erneut.',
106
160
  booked: 'Ihr Platz ist reserviert', yours: 'Ihre Anmeldung', requestSent: 'Anfrage gesendet', awaitingApproval: 'Wartet auf Freigabe', notAvailable: 'Nicht bestätigt', cancelledTitle: 'Anmeldung storniert',
107
161
  emailWhenApproved: 'Wir schreiben an {email}, sobald sie freigegeben ist.', confirmationOnItsWay: 'Eine Bestätigung ist unterwegs an {email}.',
108
162
  declined: 'Leider konnte der Veranstalter sie nicht bestätigen.', cancelledText: 'Diese Anmeldung ist storniert.',
109
163
  what: 'Was', when: 'Wann', where: 'Wo', status: 'Status', reference: 'Referenz',
110
- statuses: { pending: 'Ausstehend', confirmed: 'Bestätigt', cancelled: 'Storniert', declined: 'Abgelehnt' },
164
+ statuses: { pending: 'Ausstehend', confirmed: 'Bestätigt', cancelled: 'Storniert', declined: 'Abgelehnt', waitlisted: 'Auf der Warteliste', offered: 'Platz für Sie reserviert', expired: 'Freigegeben' },
111
165
  addToCalendar: 'Zum Kalender hinzufügen', downloadIcs: '.ics herunterladen', cancel: 'Anmeldung stornieren', cancelConfirm: 'Diese Anmeldung stornieren?', cancelError: 'Stornieren fehlgeschlagen. Bitte versuchen Sie es erneut.',
112
166
  backTo: 'Zurück zu {title}', keepLink: 'Bewahren Sie diesen Link auf – wer ihn hat, kann diese Anmeldung ändern.',
113
167
  },
@@ -117,11 +171,18 @@ const STRINGS: Record<string, RsvpStrings> = {
117
171
  full: 'Este evento está lotado.', closed: 'As inscrições estão encerradas.', saveError: 'Não foi possível salvar sua inscrição. Tente novamente.',
118
172
  closesOn: 'As inscrições se encerram em {date}, {time}', closedOn: 'Elas se encerraram em {date}. Dúvidas? Fale com a gente.',
119
173
  duplicate: 'Você já tem uma inscrição para esta data.', duplicateNote: 'Enviamos um e-mail com um link para alterá-la. Precisa de mais vagas? Altere por lá.',
174
+ fullTag: 'Lotado', noSpotsLeft: 'nenhuma vaga', waitlistNote: 'Entre na lista de espera e avisaremos por e-mail se abrir uma vaga.', joinWaitlist: 'Entrar na lista de espera',
175
+ onWaitlist: 'Você está na lista de espera', inLine: 'Nº {position} na fila · {seats}', seat: '{count} vaga', seats: '{count} vagas',
176
+ waitlistPromise: 'Se abrir uma vaga, avisaremos por e-mail e vamos guardá-la por {hours} horas.', leaveWaitlist: 'Sair da lista de espera', leaveConfirm: 'Sair da lista de espera?',
177
+ offWaitlist: 'Você não está mais na lista de espera', offWaitlistText: 'Você não está mais na lista de espera desta data.',
178
+ spotOpened: 'Abriu uma vaga', spotYours: 'A vaga é sua até {date}, {time}', confirmSpot: 'Confirmar minha vaga', releaseSpot: 'Liberar',
179
+ releaseConfirm: 'Liberar esta vaga? Ela passa para a próxima pessoa da fila.', released: 'A vaga guardada para você foi liberada',
180
+ releasedText: 'Nós a guardamos por {hours} horas e depois a oferecemos à próxima pessoa da fila.', confirmError: 'Não foi possível confirmar. Tente novamente.',
120
181
  booked: 'Sua vaga está reservada', yours: 'Sua inscrição', requestSent: 'Solicitação enviada', awaitingApproval: 'Aguardando aprovação', notAvailable: 'Não confirmada', cancelledTitle: 'Inscrição cancelada',
121
182
  emailWhenApproved: 'Vamos escrever para {email} assim que for aprovada.', confirmationOnItsWay: 'Uma confirmação está a caminho de {email}.',
122
183
  declined: 'Desculpe, o organizador não pôde confirmá-la.', cancelledText: 'Esta inscrição foi cancelada.',
123
184
  what: 'O quê', when: 'Quando', where: 'Onde', status: 'Status', reference: 'Referência',
124
- statuses: { pending: 'Pendente', confirmed: 'Confirmada', cancelled: 'Cancelada', declined: 'Recusada' },
185
+ statuses: { pending: 'Pendente', confirmed: 'Confirmada', cancelled: 'Cancelada', declined: 'Recusada', waitlisted: 'Na lista de espera', offered: 'Vaga guardada para você', expired: 'Liberada' },
125
186
  addToCalendar: 'Adicionar ao calendário', downloadIcs: 'Baixar .ics', cancel: 'Cancelar inscrição', cancelConfirm: 'Cancelar esta inscrição?', cancelError: 'Não foi possível cancelar. Tente novamente.',
126
187
  backTo: 'Voltar para {title}', keepLink: 'Guarde este link: qualquer pessoa com ele pode alterar esta inscrição.',
127
188
  },