@acms-subir/core 0.3.39 → 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 (55) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/cli/migrations.generated.mjs +5 -0
  3. package/docs/mobile.md +2 -1
  4. package/integration.mjs +3 -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 +3 -2
  12. package/src/components/editor/PostEditor.tsx +1 -1
  13. package/src/components/editor/event/RegistrationCard.tsx +9 -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 +54 -15
  17. package/src/lib/booking/service.ts +207 -23
  18. package/src/lib/calendar/api.ts +3 -2
  19. package/src/lib/calendar/members-config.ts +5 -0
  20. package/src/lib/calendar/requests.ts +4 -4
  21. package/src/lib/calendar/rsvp-words.ts +69 -8
  22. package/src/lib/content-service.ts +13 -5
  23. package/src/lib/drafts.ts +2 -2
  24. package/src/lib/editor/bookings.ts +5 -3
  25. package/src/lib/editor/documents.ts +4 -2
  26. package/src/lib/editor/schemas.ts +1 -1
  27. package/src/lib/email/booking-words.ts +82 -2
  28. package/src/lib/email/service.ts +1 -1
  29. package/src/lib/email/store.ts +17 -1
  30. package/src/lib/email/types.ts +3 -1
  31. package/src/lib/forms/events.ts +4 -2
  32. package/src/lib/legal/app-privacy.ts +2 -2
  33. package/src/lib/legal/text-en.ts +1 -1
  34. package/src/lib/legal/text-es.ts +1 -1
  35. package/src/lib/mobile/bookings.ts +4 -3
  36. package/src/lib/mobile/calendar.ts +23 -1
  37. package/src/lib/mobile/manage.ts +18 -4
  38. package/src/lib/mobile/openapi.ts +6 -3
  39. package/src/lib/mobile/rsvp.ts +10 -5
  40. package/src/lib/mobile/schemas.ts +12 -4
  41. package/src/lib/repo/migrations.ts +5 -0
  42. package/src/lib/repo/sql.ts +54 -13
  43. package/src/lib/repo/types.ts +15 -5
  44. package/src/lib/scheduler.ts +3 -1
  45. package/src/lib/schema.ts +13 -2
  46. package/src/pages/admin/calendar/bookings.astro +57 -13
  47. package/src/pages/api/calendar/bookings/index.ts +1 -1
  48. package/src/pages/api/calendar/docs.ts +2 -2
  49. package/src/pages/api/v1/bookings/manage/confirm.ts +6 -0
  50. package/src/pages/api/v1/events/[slug]/index.ts +8 -0
  51. package/src/pages/book/manage/[token].astro +57 -15
  52. package/src/pages/events/[slug].astro +3 -2
  53. package/src/styles/admin.css +3 -1
  54. package/src/styles/site.css +10 -1
  55. package/src/theme-sdk/index.ts +1 -1
@@ -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
  },
@@ -8,6 +8,7 @@ import { CONTENT, collectionUrl } from './content';
8
8
  import { CALENDAR } from './calendar/config';
9
9
  import { formatWall, isValidTimeZone, normalizeEventTimes, parseWall } from './calendar/events';
10
10
  import { eventChanged } from './calendar/hooks';
11
+ import { offerEventWaitlist } from './booking/service';
11
12
  import { normalizePageLocale } from './i18n';
12
13
  import { siteOrigin } from './seo';
13
14
  import { waitUntilFrom } from './webhooks';
@@ -37,10 +38,10 @@ export type PageSave = Omit<Parameters<ContentRepository['updatePage']>[1], 'blo
37
38
  // location (see mergeLocationInput) — older clients send name/address/url only
38
39
  // cancelledDates is left out: single dates are cancelled at once through
39
40
  // cancelOccurrence/restoreOccurrence (lib/calendar/schedule.ts), never staged
40
- // rsvp may leave out the deadline (older clients): the current one stays
41
+ // rsvp may leave out the deadline, notifyStaff and waitlist (older clients): the current ones stay
41
42
  export type EventSave = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt' | 'location' | 'cancelledDates' | 'rsvp'>> & {
42
43
  location?: Partial<EventLocation>;
43
- rsvp?: Omit<CalEvent['rsvp'], 'closesAt' | 'closeBefore'> & Partial<Pick<CalEvent['rsvp'], 'closesAt' | 'closeBefore'>>;
44
+ rsvp?: Omit<CalEvent['rsvp'], 'closesAt' | 'closeBefore' | 'notifyStaff' | 'waitlist'> & Partial<Pick<CalEvent['rsvp'], 'closesAt' | 'closeBefore' | 'notifyStaff' | 'waitlist'>>;
44
45
  };
45
46
  type EventClean = Omit<EventSave, 'location' | 'rsvp'> & { location?: EventLocation; rsvp?: CalEvent['rsvp'] };
46
47
 
@@ -136,9 +137,11 @@ export async function cleanEventInput(repo: ContentRepository, data: EventSave,
136
137
  const out: EventClean = { ...rest };
137
138
  if (rsvp) {
138
139
  // Older editor clients resend the original RSVP shape. Omission must not
139
- // erase questions or a deadline that were authored in a newer editor.
140
+ // erase questions, a deadline, the staff emails setting or the waitlist
141
+ // that were authored in a newer editor.
140
142
  const previous = mergeEventDraft(before).rsvp;
141
- const next: CalEvent['rsvp'] = { ...rsvp, closesAt: rsvp.closesAt === undefined ? previous.closesAt ?? null : rsvp.closesAt || null, closeBefore: rsvp.closeBefore ?? previous.closeBefore ?? 0 };
143
+ const next: CalEvent['rsvp'] = { ...rsvp, closesAt: rsvp.closesAt === undefined ? previous.closesAt ?? null : rsvp.closesAt || null, closeBefore: rsvp.closeBefore ?? previous.closeBefore ?? 0,
144
+ notifyStaff: rsvp.notifyStaff ?? previous.notifyStaff ?? true, waitlist: rsvp.waitlist ?? previous.waitlist ?? false };
142
145
  if (!Object.hasOwn(rsvp, 'form') && previous.form) next.form = previous.form;
143
146
  if (next.form !== undefined) {
144
147
  const form = FormDefinition.safeParse(next.form);
@@ -153,6 +156,8 @@ export async function cleanEventInput(repo: ContentRepository, data: EventSave,
153
156
  if (!Number.isInteger(next.closeBefore) || next.closeBefore < 0 || next.closeBefore > 60 * 1440) {
154
157
  throw new ApiError(400, 'The RSVP deadline can be at most 60 days before each date (closeBefore: 0–86400 minutes).');
155
158
  }
159
+ if (typeof next.notifyStaff !== 'boolean') throw new ApiError(400, 'rsvp.notifyStaff must be true or false.');
160
+ if (typeof next.waitlist !== 'boolean') throw new ApiError(400, 'rsvp.waitlist must be true or false.');
156
161
  out.rsvp = next;
157
162
  }
158
163
  if (location !== undefined) out.location = mergeLocationInput(mergeEventDraft(before).location, location);
@@ -216,9 +221,12 @@ export async function cleanEventInput(repo: ContentRepository, data: EventSave,
216
221
 
217
222
  export async function eventSideEffects(repo: ContentRepository, hooks: SaveHooks, before: CalEvent, after: CalEvent, applied = false) {
218
223
  const settings = await repo.getSettings();
224
+ const origin = siteOrigin(settings, hooks.url);
219
225
  await eventChanged(repo, before, after, {
220
- origin: siteOrigin(settings, hooks.url), waitUntil: waitUntilFrom(hooks.locals), applied, onRedirect: hooks.onRedirect,
226
+ origin, waitUntil: waitUntilFrom(hooks.locals), applied, onRedirect: hooks.onRedirect,
221
227
  });
228
+ // seats an edit freed (a higher capacity) go to those in line now, not at the next sweep
229
+ if (after.rsvp.waitlist) await offerEventWaitlist(repo, after.id, { origin, locals: hooks.locals, waitUntil: waitUntilFrom(hooks.locals) });
222
230
  }
223
231
 
224
232
  export async function saveEvent(repo: ContentRepository, id: string, data: EventSave, hooks: SaveHooks): Promise<CalEvent> {
package/src/lib/drafts.ts CHANGED
@@ -52,8 +52,8 @@ export function pickEventDraft(draft: Record<string, unknown>): Partial<Pick<Cal
52
52
  const loc = EventLocation.safeParse(out.location);
53
53
  if (loc.success) out.location = loc.data; else delete out.location;
54
54
  }
55
- // and before RSVP deadlines: none
56
- if (out.rsvp) out.rsvp = { ...out.rsvp, closesAt: out.rsvp.closesAt ?? null, closeBefore: out.rsvp.closeBefore ?? 0 };
55
+ // and before RSVP deadlines: none; before staff RSVP emails: on; before waitlists: off
56
+ if (out.rsvp) out.rsvp = { ...out.rsvp, closesAt: out.rsvp.closesAt ?? null, closeBefore: out.rsvp.closeBefore ?? 0, notifyStaff: out.rsvp.notifyStaff ?? true, waitlist: out.rsvp.waitlist ?? false };
57
57
  return out;
58
58
  }
59
59
  export function pickPageDraft(draft: Record<string, unknown>): Partial<Pick<Page, PageDraftKey>> {
@@ -11,10 +11,12 @@ import { pageQuery } from '../mobile/http';
11
11
  // Bookings inbox for editor apps: the same status changes as Admin →
12
12
  // Bookings (confirm, decline, cancel; the guest is emailed, webhooks fire),
13
13
  // available to every signed-in owner or editor exactly like the web page.
14
+ // RSVPs on a full date's waitlist are waitlisted (in line) or offered (a spot
15
+ // held for them); cancel takes them off it.
14
16
 
15
17
  export const InboxQuery = z.object({
16
- /** active = pending + confirmed (default); pending requests always sort first */
17
- status: z.enum(['active', 'pending', 'confirmed', 'declined', 'cancelled', 'all']).default('active'),
18
+ /** active = pending + confirmed (default); waitlist = waitlisted + offered; pending requests always sort first */
19
+ status: z.enum(['active', 'pending', 'confirmed', 'waitlist', 'waitlisted', 'offered', 'expired', 'declined', 'cancelled', 'all']).default('active'),
18
20
  from: z.string().datetime({ offset: true }).optional(),
19
21
  ...pageQuery,
20
22
  }).strict();
@@ -26,7 +28,7 @@ export function requireBookings() {
26
28
 
27
29
  export async function inbox(repo: ContentRepository, q: z.infer<typeof InboxQuery>, now = Date.now()) {
28
30
  requireBookings();
29
- const statuses: BookingStatus[] | undefined = q.status === 'all' ? undefined : q.status === 'active' ? ['pending', 'confirmed'] : [q.status];
31
+ const statuses: BookingStatus[] | undefined = q.status === 'all' ? undefined : q.status === 'active' ? ['pending', 'confirmed'] : q.status === 'waitlist' ? ['waitlisted', 'offered'] : [q.status];
30
32
  const [list, resources, events] = await Promise.all([
31
33
  repo.listBookings({ from: q.from ? Date.parse(q.from) : now - 86_400_000, statuses, kinds: ['booking', 'rsvp'] }),
32
34
  repo.listResources(), repo.listEvents(),
@@ -160,9 +160,11 @@ export const EventFields = z.object({ ...common, excerpt: text(1000), tags: z.ar
160
160
  }).partial({ lat: true, lng: true, placeId: true, showMap: true, mapImage: true, mapImageDark: true }).strict(),
161
161
  cta: z.object({ label: text(120), href: text(1000), style: z.enum(['primary', 'outline']) }).strict(),
162
162
  calendarIds: z.array(text(100)).max(50), fields: z.record(text(2000)),
163
- // closesAt: wall time in the event's zone ('YYYY-MM-DDTHH:mm'); closeBefore: minutes before each date. Left out = kept
163
+ // closesAt: wall time in the event's zone ('YYYY-MM-DDTHH:mm'); closeBefore: minutes before each date;
164
+ // notifyStaff: the hosts get an email about each new or cancelled RSVP; waitlist: a full date
165
+ // takes places in line (with a capacity). Left out = kept
164
166
  rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50),
165
- closesAt: text(40).nullable().optional(), closeBefore: z.number().int().min(0).max(86400).optional(), form: FormDefinition.optional() }).strict(),
167
+ closesAt: text(40).nullable().optional(), closeBefore: z.number().int().min(0).max(86400).optional(), notifyStaff: z.boolean().optional(), waitlist: z.boolean().optional(), form: FormDefinition.optional() }).strict(),
166
168
  }).partial().strict();
167
169
  export const SaveInput = z.object({ revision: z.string().max(60), fields: z.record(z.unknown()) }).strict();
168
170
 
@@ -47,7 +47,7 @@ export const EditorDocument = EditorDocumentSummary.extend({
47
47
  event: z.object({
48
48
  start: str, end: str, allDay: bool, timezone: str, rrule: str, exdates: z.array(str), cancelled: bool,
49
49
  location: z.object({ name: str, address: str, url: str, lat: num.nullable(), lng: num.nullable(), placeId: str, showMap: bool, mapImage: str, mapImageDark: str }), cta: z.object({ label: str, href: str, style: z.enum(['primary', 'outline']) }),
50
- calendarIds: z.array(str), fields: z.record(str), rsvp: z.object({ enabled: bool, capacity: num, autoConfirm: bool, maxSeats: num, closesAt: str.nullable(), closeBefore: num, form: FormDefinition.optional() }),
50
+ calendarIds: z.array(str), fields: z.record(str), rsvp: z.object({ enabled: bool, capacity: num, autoConfirm: bool, maxSeats: num, closesAt: str.nullable(), closeBefore: num, notifyStaff: bool, waitlist: bool, form: FormDefinition.optional() }),
51
51
  }).nullable(),
52
52
  languages: z.array(str),
53
53
  });
@@ -3,10 +3,12 @@
3
3
  // (book/manage; an RSVP's page words are in calendar/rsvp-words.ts). Emails
4
4
  // say usted in Spanish, like the privacy notice; the website's pages say tú,
5
5
  // like the RSVP words. Plain strings with {name}, {title}, {when}, {ref},
6
- // {url} and {hours} slots (live/words.ts `fill`). `rsvp` rewords a few
6
+ // {url} and {hours} slots, and {position} and {until} for the waitlist
7
+ // (live/words.ts `fill`). `rsvp` rewords a few
7
8
  // messages for a seat at an event (English says "booking" for both). Staff
8
9
  // emails use the site's main language. Other languages fall back to English.
9
- export type GuestMail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'updated' | 'rescheduled' | 'reminder' | 'link';
10
+ // waitlisted, offered, left: an RSVP's waitlist (events only), worded for it
11
+ export type GuestMail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'updated' | 'rescheduled' | 'reminder' | 'link' | 'waitlisted' | 'offered' | 'left';
10
12
  export interface MailCopy { subject: string; heading: string; intro: string }
11
13
 
12
14
  export interface BookingStrings {
@@ -14,6 +16,8 @@ export interface BookingStrings {
14
16
  /** the button to the booking's own page */
15
17
  manage: string;
16
18
  rsvp: { manage: string } & Partial<Record<GuestMail, Partial<MailCopy>>>;
19
+ /** the waitlist emails' buttons: the place in line (where they can leave it), and taking a held spot */
20
+ waitlist: { manage: string; confirm: string };
17
21
  /** the details table */
18
22
  what: string;
19
23
  when: string;
@@ -25,6 +29,22 @@ export interface BookingStrings {
25
29
  /** a pending booking nobody answered in time, declined automatically */
26
30
  lateDecline: string;
27
31
  staff: { pending: string; booked: string; review: string; open: string; pendingSubject: string; bookedSubject: string };
32
+ /**
33
+ * the hosts' email about an RSVP (or the contact address's, for an event
34
+ * without hosts): a new one, or one the person cancelled. {seats}, {count}
35
+ * and {capacity} are numbers, {date} the date's short form ("Fri Nov 7");
36
+ * the footer is `via` · `hosts` or `noHosts`.
37
+ */
38
+ rsvpStaff: {
39
+ subject: string; cancelledSubject: string; heading: string; cancelledHeading: string;
40
+ name: string; email: string; phone: string; answers: string; now: string;
41
+ confirmed: string; pending: string; seatFreed: string; seatsFreed: string;
42
+ seatTaken: string; seatsTaken: string; seatsTakenOf: string;
43
+ via: Record<'web' | 'app' | 'staff', string>;
44
+ hosts: string; noHosts: string;
45
+ /** the button for a host without Manage bookings: their My events page */
46
+ openMine: string;
47
+ };
28
48
  /** a room booking's own page: what differs from an RSVP's (rsvp-words.ts) */
29
49
  page: {
30
50
  untitled: string;
@@ -55,11 +75,23 @@ const STRINGS: Record<string, BookingStrings> = {
55
75
  rescheduled: { subject: 'New time: {title} — {when}', heading: 'Booking moved', intro: 'Your booking has a new time. Import the attached calendar file to update your calendar.' },
56
76
  reminder: { subject: 'Reminder: {title} — {when}', heading: 'Coming up soon', intro: 'A quick reminder about your booking.' },
57
77
  link: { subject: 'Your booking: {title}', heading: 'You already have this booking', intro: 'We got another request for this date with your email address, so nothing changed. To see or change your booking, use the button below.' },
78
+ waitlisted: { subject: 'You’re on the waitlist: {title}', heading: 'You’re on the waitlist', intro: 'You’re #{position} in line. If a spot opens, we’ll email you and hold it for {hours} hours.' },
79
+ offered: { subject: 'A spot opened: {title}', heading: 'A spot opened for {title}', intro: 'We’re holding it for you until {until}. Confirm it before then, or it goes to the next person in line.' },
80
+ left: { subject: 'Off the waitlist: {title}', heading: 'You’re off the waitlist', intro: 'You’re no longer on the waitlist for this date.' },
58
81
  },
59
82
  manage: 'View or change your booking', rsvp: { manage: 'View or change your booking' },
83
+ waitlist: { manage: 'See your place or leave the waitlist', confirm: 'Confirm my spot' },
60
84
  what: 'What', when: 'When', where: 'Where', seats: 'Seats', reference: 'Reference {ref}', icsManage: 'Manage: {url}',
61
85
  lateDecline: 'We weren’t able to confirm this booking in time. Please pick another time.',
62
86
  staff: { pending: 'New request to approve', booked: 'New booking', review: 'Review in the admin', open: 'Open in the admin', pendingSubject: 'Approve: {title} — {when}', bookedSubject: 'New booking: {title} — {when}' },
87
+ rsvpStaff: {
88
+ subject: 'New RSVP: {title}, {date}', cancelledSubject: 'RSVP cancelled: {title}, {date}', heading: 'New RSVP for {title}', cancelledHeading: 'RSVP cancelled for {title}',
89
+ name: 'Name', email: 'Email', phone: 'Phone', answers: 'Answers', now: 'Now',
90
+ confirmed: '{seats} · Confirmed', pending: '{seats} · Waiting for your approval', seatFreed: '{seats} freed', seatsFreed: '{seats} freed',
91
+ seatTaken: '{count} seat taken', seatsTaken: '{count} seats taken', seatsTakenOf: '{count} of {capacity} seats taken',
92
+ via: { web: 'Sent from the website', app: 'Sent from the app', staff: 'Added by staff' }, openMine: 'Open in My events',
93
+ hosts: 'You get these because you host this event.', noHosts: 'You get these because no one hosts this event.',
94
+ },
63
95
  page: {
64
96
  untitled: 'Booking', booked: 'You’re booked', yours: 'Your booking', moved: 'Booking moved', notAvailable: 'Not available', cancelledTitle: 'Booking cancelled',
65
97
  declined: 'Sorry — this time couldn’t be confirmed. You’re welcome to pick another.', cancelledText: 'This booking is cancelled.',
@@ -79,6 +111,9 @@ const STRINGS: Record<string, BookingStrings> = {
79
111
  rescheduled: { subject: 'Nuevo horario: {title} — {when}', heading: 'Su reservación cambió de horario', intro: 'Su reservación tiene un nuevo horario. Importe el archivo de calendario adjunto para actualizar su calendario.' },
80
112
  reminder: { subject: 'Recordatorio: {title} — {when}', heading: 'Ya casi es la fecha', intro: 'Un breve recordatorio de su reservación.' },
81
113
  link: { subject: 'Su reservación: {title}', heading: 'Ya tiene esta reservación', intro: 'Recibimos otra solicitud para este horario con su correo, así que no cambió nada. Para ver o cambiar su reservación, use el botón de abajo.' },
114
+ waitlisted: { subject: 'Está en la lista de espera: {title}', heading: 'Está en la lista de espera', intro: 'Tiene el lugar #{position} en la fila. Si se libera un lugar, le escribiremos y se lo apartaremos {hours} horas.' },
115
+ offered: { subject: 'Se liberó un lugar: {title}', heading: 'Se liberó un lugar para {title}', intro: 'Se lo apartamos hasta el {until}. Confírmelo antes, o pasará a la siguiente persona en la fila.' },
116
+ left: { subject: 'Fuera de la lista de espera: {title}', heading: 'Ya no está en la lista de espera', intro: 'Ya no está en la lista de espera para esta fecha.' },
82
117
  },
83
118
  manage: 'Ver o cambiar su reservación',
84
119
  rsvp: {
@@ -90,9 +125,18 @@ const STRINGS: Record<string, BookingStrings> = {
90
125
  reminder: { intro: 'Un breve recordatorio: tiene un lugar apartado.' },
91
126
  link: { subject: 'Su confirmación para {title}', heading: 'Ya tiene una confirmación', intro: 'Recibimos otra confirmación para esta fecha con su correo, así que no cambió nada. Para cambiar la suya, o agregar lugares, use el botón de abajo.' },
92
127
  },
128
+ waitlist: { manage: 'Ver su lugar o salir de la lista', confirm: 'Confirmar mi lugar' },
93
129
  what: 'Qué', when: 'Cuándo', where: 'Dónde', seats: 'Lugares', reference: 'Referencia {ref}', icsManage: 'Ver o cambiar: {url}',
94
130
  lateDecline: 'No pudimos confirmar esta reservación a tiempo. Por favor, elija otro horario.',
95
131
  staff: { pending: 'Nueva solicitud por aprobar', booked: 'Nueva reservación', review: 'Revisar en el panel', open: 'Abrir en el panel', pendingSubject: 'Por aprobar: {title} — {when}', bookedSubject: 'Nueva reservación: {title} — {when}' },
132
+ rsvpStaff: {
133
+ subject: 'Nueva confirmación: {title}, {date}', cancelledSubject: 'Confirmación cancelada: {title}, {date}', heading: 'Nueva confirmación para {title}', cancelledHeading: 'Confirmación cancelada para {title}',
134
+ name: 'Nombre', email: 'Correo', phone: 'Teléfono', answers: 'Respuestas', now: 'Ahora',
135
+ confirmed: '{seats} · Confirmado', pending: '{seats} · Esperando su aprobación', seatFreed: '{seats} liberado', seatsFreed: '{seats} liberados',
136
+ seatTaken: '{count} lugar ocupado', seatsTaken: '{count} lugares ocupados', seatsTakenOf: '{count} de {capacity} lugares ocupados',
137
+ via: { web: 'Enviado desde el sitio web', app: 'Enviado desde la app', staff: 'Agregado por el equipo' }, openMine: 'Abrir en Mis eventos',
138
+ hosts: 'Recibe estos correos porque usted organiza este evento.', noHosts: 'Recibe estos correos porque este evento no tiene organizadores.',
139
+ },
96
140
  page: {
97
141
  untitled: 'Reservación', booked: 'Tu reservación está confirmada', yours: 'Tu reservación', moved: 'Tu reservación cambió de horario', notAvailable: 'No disponible', cancelledTitle: 'Reservación cancelada',
98
142
  declined: 'Lo sentimos, no se pudo confirmar este horario. Puedes elegir otro.', cancelledText: 'Esta reservación se canceló.',
@@ -112,6 +156,9 @@ const STRINGS: Record<string, BookingStrings> = {
112
156
  rescheduled: { subject: 'Nouvel horaire : {title} — {when}', heading: 'Réservation déplacée', intro: 'Votre réservation a un nouvel horaire. Importez le fichier de calendrier joint pour mettre à jour votre calendrier.' },
113
157
  reminder: { subject: 'Rappel : {title} — {when}', heading: 'C’est bientôt', intro: 'Un petit rappel de votre réservation.' },
114
158
  link: { subject: 'Votre réservation : {title}', heading: 'Vous avez déjà cette réservation', intro: 'Nous avons reçu une autre demande pour ce créneau avec votre adresse e-mail, donc rien n’a changé. Pour voir ou modifier votre réservation, utilisez le bouton ci-dessous.' },
159
+ waitlisted: { subject: 'Vous êtes sur la liste d’attente : {title}', heading: 'Vous êtes sur la liste d’attente', intro: 'Vous êtes n° {position} dans la file. Si une place se libère, nous vous écrirons et vous la garderons {hours} heures.' },
160
+ offered: { subject: 'Une place s’est libérée : {title}', heading: 'Une place s’est libérée pour {title}', intro: 'Nous vous la gardons jusqu’au {until}. Confirmez-la d’ici là, sinon elle ira à la personne suivante dans la file.' },
161
+ left: { subject: 'Hors de la liste d’attente : {title}', heading: 'Vous n’êtes plus sur la liste d’attente', intro: 'Vous n’êtes plus sur la liste d’attente pour cette date.' },
115
162
  },
116
163
  manage: 'Voir ou modifier votre réservation',
117
164
  rsvp: {
@@ -123,9 +170,18 @@ const STRINGS: Record<string, BookingStrings> = {
123
170
  reminder: { intro: 'Un petit rappel de votre inscription.' },
124
171
  link: { subject: 'Votre inscription : {title}', heading: 'Vous avez déjà une inscription', intro: 'Nous avons reçu une autre inscription pour cette date avec votre adresse e-mail, donc rien n’a changé. Pour modifier la vôtre ou ajouter des places, utilisez le bouton ci-dessous.' },
125
172
  },
173
+ waitlist: { manage: 'Voir votre place ou quitter la liste', confirm: 'Confirmer ma place' },
126
174
  what: 'Quoi', when: 'Quand', where: 'Où', seats: 'Places', reference: 'Référence {ref}', icsManage: 'Gérer : {url}',
127
175
  lateDecline: 'Nous n’avons pas pu confirmer cette réservation à temps. Veuillez choisir un autre horaire.',
128
176
  staff: { pending: 'Nouvelle demande à valider', booked: 'Nouvelle réservation', review: 'Examiner dans l’administration', open: 'Ouvrir dans l’administration', pendingSubject: 'À valider : {title} — {when}', bookedSubject: 'Nouvelle réservation : {title} — {when}' },
177
+ rsvpStaff: {
178
+ subject: 'Nouvelle inscription : {title}, {date}', cancelledSubject: 'Inscription annulée : {title}, {date}', heading: 'Nouvelle inscription à {title}', cancelledHeading: 'Inscription à {title} annulée',
179
+ name: 'Nom', email: 'E-mail', phone: 'Téléphone', answers: 'Réponses', now: 'Maintenant',
180
+ confirmed: '{seats} · Confirmée', pending: '{seats} · En attente de votre validation', seatFreed: '{seats} libérée', seatsFreed: '{seats} libérées',
181
+ seatTaken: '{count} place prise', seatsTaken: '{count} places prises', seatsTakenOf: '{count} sur {capacity} places prises',
182
+ via: { web: 'Envoyée depuis le site', app: 'Envoyée depuis l’app', staff: 'Ajoutée par l’équipe' }, openMine: 'Ouvrir dans Mes événements',
183
+ hosts: 'Vous recevez ces e-mails parce que vous organisez cet événement.', noHosts: 'Vous recevez ces e-mails parce que cet événement n’a pas d’organisateur.',
184
+ },
129
185
  page: {
130
186
  untitled: 'Réservation', booked: 'Votre réservation est confirmée', yours: 'Votre réservation', moved: 'Réservation déplacée', notAvailable: 'Indisponible', cancelledTitle: 'Réservation annulée',
131
187
  declined: 'Désolés, ce créneau n’a pas pu être confirmé. N’hésitez pas à en choisir un autre.', cancelledText: 'Cette réservation est annulée.',
@@ -145,6 +201,9 @@ const STRINGS: Record<string, BookingStrings> = {
145
201
  rescheduled: { subject: 'Neuer Termin: {title} — {when}', heading: 'Buchung verschoben', intro: 'Ihre Buchung hat einen neuen Termin. Importieren Sie die angehängte Kalenderdatei, um Ihren Kalender zu aktualisieren.' },
146
202
  reminder: { subject: 'Erinnerung: {title} — {when}', heading: 'Bald ist es so weit', intro: 'Eine kurze Erinnerung an Ihre Buchung.' },
147
203
  link: { subject: 'Ihre Buchung: {title}', heading: 'Sie haben diese Buchung schon', intro: 'Wir haben eine weitere Anfrage für diesen Termin mit Ihrer E-Mail-Adresse erhalten, es hat sich also nichts geändert. Über den Button unten sehen oder ändern Sie Ihre Buchung.' },
204
+ waitlisted: { subject: 'Sie stehen auf der Warteliste: {title}', heading: 'Sie stehen auf der Warteliste', intro: 'Sie sind Nr. {position} auf der Warteliste. Wird ein Platz frei, schreiben wir Ihnen und halten ihn {hours} Stunden für Sie frei.' },
205
+ offered: { subject: 'Ein Platz ist frei geworden: {title}', heading: 'Ein Platz für {title} ist frei geworden', intro: 'Wir halten ihn bis {until} für Sie frei. Bestätigen Sie ihn bis dahin, sonst geht er an die nächste Person auf der Warteliste.' },
206
+ left: { subject: 'Nicht mehr auf der Warteliste: {title}', heading: 'Sie stehen nicht mehr auf der Warteliste', intro: 'Sie stehen für diesen Termin nicht mehr auf der Warteliste.' },
148
207
  },
149
208
  manage: 'Buchung ansehen oder ändern',
150
209
  rsvp: {
@@ -156,9 +215,18 @@ const STRINGS: Record<string, BookingStrings> = {
156
215
  reminder: { intro: 'Eine kurze Erinnerung an Ihre Anmeldung.' },
157
216
  link: { subject: 'Ihre Anmeldung: {title}', heading: 'Sie sind schon angemeldet', intro: 'Wir haben eine weitere Anmeldung für diesen Termin mit Ihrer E-Mail-Adresse erhalten, es hat sich also nichts geändert. Über den Button unten ändern Sie Ihre Anmeldung oder fügen Plätze hinzu.' },
158
217
  },
218
+ waitlist: { manage: 'Platz ansehen oder Warteliste verlassen', confirm: 'Platz bestätigen' },
159
219
  what: 'Was', when: 'Wann', where: 'Wo', seats: 'Plätze', reference: 'Referenz {ref}', icsManage: 'Verwalten: {url}',
160
220
  lateDecline: 'Wir konnten diese Buchung nicht rechtzeitig bestätigen. Bitte wählen Sie einen anderen Termin.',
161
221
  staff: { pending: 'Neue Anfrage zur Freigabe', booked: 'Neue Buchung', review: 'In der Verwaltung prüfen', open: 'In der Verwaltung öffnen', pendingSubject: 'Freigeben: {title} — {when}', bookedSubject: 'Neue Buchung: {title} — {when}' },
222
+ rsvpStaff: {
223
+ subject: 'Neue Anmeldung: {title}, {date}', cancelledSubject: 'Anmeldung storniert: {title}, {date}', heading: 'Neue Anmeldung für {title}', cancelledHeading: 'Anmeldung für {title} storniert',
224
+ name: 'Name', email: 'E-Mail', phone: 'Telefon', answers: 'Antworten', now: 'Jetzt',
225
+ confirmed: '{seats} · Bestätigt', pending: '{seats} · Wartet auf Ihre Freigabe', seatFreed: '{seats} frei geworden', seatsFreed: '{seats} frei geworden',
226
+ seatTaken: '{count} Platz belegt', seatsTaken: '{count} Plätze belegt', seatsTakenOf: '{count} von {capacity} Plätzen belegt',
227
+ via: { web: 'Über die Website gesendet', app: 'Über die App gesendet', staff: 'Vom Team eingetragen' }, openMine: 'In Meine Veranstaltungen öffnen',
228
+ hosts: 'Sie erhalten diese E-Mails, weil Sie diese Veranstaltung betreuen.', noHosts: 'Sie erhalten diese E-Mails, weil niemand diese Veranstaltung betreut.',
229
+ },
162
230
  page: {
163
231
  untitled: 'Buchung', booked: 'Ihre Buchung ist bestätigt', yours: 'Ihre Buchung', moved: 'Buchung verschoben', notAvailable: 'Nicht verfügbar', cancelledTitle: 'Buchung storniert',
164
232
  declined: 'Leider konnte dieser Termin nicht bestätigt werden. Wählen Sie gern einen anderen.', cancelledText: 'Diese Buchung ist storniert.',
@@ -178,6 +246,9 @@ const STRINGS: Record<string, BookingStrings> = {
178
246
  rescheduled: { subject: 'Novo horário: {title} — {when}', heading: 'Reserva remarcada', intro: 'Sua reserva tem um novo horário. Importe o arquivo de calendário anexo para atualizar seu calendário.' },
179
247
  reminder: { subject: 'Lembrete: {title} — {when}', heading: 'Está chegando', intro: 'Um lembrete rápido sobre sua reserva.' },
180
248
  link: { subject: 'Sua reserva: {title}', heading: 'Você já tem esta reserva', intro: 'Recebemos outro pedido para este horário com seu e-mail, então nada mudou. Para ver ou alterar sua reserva, use o botão abaixo.' },
249
+ waitlisted: { subject: 'Você está na lista de espera: {title}', heading: 'Você está na lista de espera', intro: 'Você é o nº {position} da fila. Se uma vaga abrir, vamos avisar por e-mail e guardá-la por {hours} horas.' },
250
+ offered: { subject: 'Abriu uma vaga: {title}', heading: 'Abriu uma vaga em {title}', intro: 'Vamos guardá-la para você até {until}. Confirme antes disso, ou ela passa para a próxima pessoa da fila.' },
251
+ left: { subject: 'Fora da lista de espera: {title}', heading: 'Você não está mais na lista de espera', intro: 'Você não está mais na lista de espera desta data.' },
181
252
  },
182
253
  manage: 'Ver ou alterar sua reserva',
183
254
  rsvp: {
@@ -189,9 +260,18 @@ const STRINGS: Record<string, BookingStrings> = {
189
260
  reminder: { intro: 'Um lembrete rápido sobre sua inscrição.' },
190
261
  link: { subject: 'Sua inscrição em {title}', heading: 'Você já tem uma inscrição', intro: 'Recebemos outra inscrição para esta data com seu e-mail, então nada mudou. Para alterar a sua, ou adicionar vagas, use o botão abaixo.' },
191
262
  },
263
+ waitlist: { manage: 'Ver sua posição ou sair da lista', confirm: 'Confirmar minha vaga' },
192
264
  what: 'O quê', when: 'Quando', where: 'Onde', seats: 'Vagas', reference: 'Referência {ref}', icsManage: 'Gerenciar: {url}',
193
265
  lateDecline: 'Não conseguimos confirmar esta reserva a tempo. Por favor, escolha outro horário.',
194
266
  staff: { pending: 'Novo pedido para aprovar', booked: 'Nova reserva', review: 'Revisar no painel', open: 'Abrir no painel', pendingSubject: 'Aprovar: {title} — {when}', bookedSubject: 'Nova reserva: {title} — {when}' },
267
+ rsvpStaff: {
268
+ subject: 'Nova inscrição: {title}, {date}', cancelledSubject: 'Inscrição cancelada: {title}, {date}', heading: 'Nova inscrição em {title}', cancelledHeading: 'Inscrição cancelada em {title}',
269
+ name: 'Nome', email: 'E-mail', phone: 'Telefone', answers: 'Respostas', now: 'Agora',
270
+ confirmed: '{seats} · Confirmada', pending: '{seats} · Aguardando sua aprovação', seatFreed: '{seats} liberada', seatsFreed: '{seats} liberadas',
271
+ seatTaken: '{count} vaga ocupada', seatsTaken: '{count} vagas ocupadas', seatsTakenOf: '{count} de {capacity} vagas ocupadas',
272
+ via: { web: 'Enviada pelo site', app: 'Enviada pelo app', staff: 'Adicionada pela equipe' }, openMine: 'Abrir em Meus eventos',
273
+ hosts: 'Você recebe estes e-mails porque organiza este evento.', noHosts: 'Você recebe estes e-mails porque este evento não tem organizadores.',
274
+ },
195
275
  page: {
196
276
  untitled: 'Reserva', booked: 'Sua reserva está confirmada', yours: 'Sua reserva', moved: 'Reserva remarcada', notAvailable: 'Indisponível', cancelledTitle: 'Reserva cancelada',
197
277
  declined: 'Desculpe, não foi possível confirmar este horário. Fique à vontade para escolher outro.', cancelledText: 'Esta reserva foi cancelada.',
@@ -22,7 +22,7 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
22
22
  const b = await repo.getBooking(payload.bookingId);
23
23
  if (!b || (b.notificationVersion ?? 0) !== payload.version) return null;
24
24
  if (payload.kind === 'reminder' && (b.status !== 'confirmed' || b.start <= Date.now())) return null;
25
- const mail = await bookingMail(repo, b, payload.kind, origin, from, payload);
25
+ const mail = await bookingMail(repo, b, payload.kind, origin, from, payload, job.recipient);
26
26
  return { ...mail, to: job.recipient };
27
27
  }
28
28
  if (job.kind === 'auth') {
@@ -1,5 +1,5 @@
1
1
  import type { SqlDriver, SqlStatement } from '../repo/sql';
2
- import type { ConfigRecord, EmailConfig, EmailJob, Invitation, NewEmailJob, SendResult } from './types';
2
+ import type { ConfigRecord, EmailConfig, EmailJob, Invitation, NewEmailJob, RsvpStaffNotice, SendResult } from './types';
3
3
  const record = (r: Record<string, unknown>): ConfigRecord => ({ version: Number(r.version), config: JSON.parse(String(r.config)), encrypted: String(r.encrypted), createdAt: Number(r.created_at) });
4
4
  function job(r: Record<string, unknown>): EmailJob {
5
5
  return { id: String(r.id), dedupe: String(r.dedupe), kind: String(r.kind), recipient: String(r.recipient), payload: String(r.payload), configVersion: Number(r.config_version), state: r.state as EmailJob['state'], attempts: Number(r.attempts), nextAt: Number(r.next_at), expiresAt: r.expires_at == null ? null : Number(r.expires_at), createdAt: Number(r.created_at), lease: String(r.lease), leaseUntil: r.lease_until == null ? null : Number(r.lease_until), provider: String(r.provider), providerId: String(r.provider_id), error: String(r.error), acceptedAt: r.accepted_at == null ? null : Number(r.accepted_at), updatedAt: Number(r.updated_at), fingerprint: String(r.fingerprint), rendered: String(r.rendered || '') };
@@ -25,6 +25,22 @@ export function bookingJobs(id: string, kind: string, version?: number, staff =
25
25
  });
26
26
  return [select('b.email', 'bookings b', kind), ...(staff ? [select('j.value', 'bookings b JOIN resources r ON r.id = b.resource_id, json_each(r.notify_emails) j', 'staff-new')] : [])];
27
27
  }
28
+ // Staff hear about an RSVP (new, or cancelled by the person) at the addresses
29
+ // the caller picked, in the same batch and only if the change landed.
30
+ // `taken` is the date's seat count right after it (held waitlist spots too).
31
+ export function rsvpStaffJobs(id: string, kind: 'staff-rsvp' | 'staff-rsvp-cancelled', notice: RsvpStaffNotice | undefined, version: number, mutation = ''): SqlStatement[] {
32
+ if (!notice?.to.length) return [];
33
+ const now = Date.now();
34
+ return [{
35
+ sql: `INSERT OR IGNORE INTO email_jobs (id,dedupe,kind,recipient,payload,config_version,next_at,expires_at,created_at,updated_at)
36
+ SELECT lower(hex(randomblob(16))), b.id || ':' || b.notification_version || ':' || ? || ':' || lower(j.value), 'booking', lower(j.value),
37
+ json_object('bookingId',b.id,'version',b.notification_version,'kind',?,'why',?,'via',?,
38
+ 'taken',(SELECT COALESCE(SUM(seats),0) FROM bookings WHERE kind='rsvp' AND event_id=b.event_id AND start_at=b.start_at AND status IN ('pending','confirmed','offered'))),
39
+ COALESCE((SELECT MAX(version) FROM email_configs),0), ?, NULL, ?, ?
40
+ FROM bookings b, json_each(?) j WHERE b.id = ? AND b.kind = 'rsvp' AND j.value != '' AND b.notification_version = ? ${mutation ? 'AND b.email_mutation = ?' : ''}`,
41
+ args: [kind, kind, notice.why, notice.via, now, now, now, JSON.stringify(notice.to), id, version, ...(mutation ? [mutation] : [])],
42
+ }];
43
+ }
28
44
  export class EmailStore {
29
45
  constructor(private getDriver: () => Promise<SqlDriver>) {}
30
46
  async config(version?: number): Promise<ConfigRecord | null> {
@@ -1,4 +1,4 @@
1
- import type { Role } from '../schema';
1
+ import type { Booking, Role } from '../schema';
2
2
  export type Provider = 'resend' | 'mailgun' | 'ses' | 'cloudflare';
3
3
  export type ProviderChoice = Provider | 'auto' | 'none';
4
4
  export interface Attachment { filename: string; type: string; content: string }
@@ -19,6 +19,8 @@ export interface EmailJob {
19
19
  acceptedAt: number | null; updatedAt: number; fingerprint: string;
20
20
  rendered: string;
21
21
  }
22
+ /** staff to tell about an RSVP (booking/service.ts rsvpStaff): to its hosts, else the contact address; via: where the person did it */
23
+ export interface RsvpStaffNotice { to: string[]; why: 'hosts' | 'contact'; via: Booking['source'] }
22
24
  export interface NewEmailJob { id?: string; dedupe: string; kind: string; recipient: string; payload: string; configVersion: number; nextAt?: number; expiresAt?: number }
23
25
  export type SendResult = { state: 'accepted'; providerId: string } | { state: 'retrying' | 'failed' | 'unknown'; error: string; retryAfter?: number };
24
26
  export interface Invitation { id: string; email: string; name: string; role: Role; state: string; expiresAt: number; createdAt: number; invitedBy: string }
@@ -17,7 +17,9 @@ export async function prepareLegacyEventForm(event: CalEvent, start: number, dat
17
17
  }
18
18
  }
19
19
 
20
- // locale: the page's language, so the RSVP's emails and links speak it
20
+ // locale: the page's language, so the RSVP's emails and links speak it. A
21
+ // form with waitlist=1 (a full date's "Join the waitlist") takes a place in
22
+ // line when there's no room
21
23
  export async function submitEventForm(repo: ContentRepository, event: CalEvent, occurrence: { start: number; end: number }, data: FormData, requestId: string, flow: Parameters<typeof createRsvp>[2], locale = '') {
22
24
  if (event.status !== 'published') throw new ApiError(404, 'Event not found.');
23
25
  if (Number(data.get('occurrence')) !== occurrence.start) throw new ApiError(409, 'The event time has changed. Review the date and try again.');
@@ -37,6 +39,6 @@ export async function submitEventForm(repo: ContentRepository, event: CalEvent,
37
39
  const existing = await replay(); if (existing) return existing;
38
40
  await collectResponse(definition, booker.answers, booker.formVersion);
39
41
  // the language isn't part of the retry check: it doesn't change what was asked for
40
- try { return await createRsvp(repo, { event, ...occurrence, seats, booker: { ...booker, locale } }, { ...flow, idempotency }); }
42
+ try { return await createRsvp(repo, { event, ...occurrence, seats, booker: { ...booker, locale }, waitlist: data.get('waitlist') === '1' }, { ...flow, idempotency }); }
41
43
  catch (e) { const existing = await replay(); if (existing) return existing; throw e; }
42
44
  }
@@ -10,8 +10,8 @@ export interface AppPrivacyAnswers { rows: StoreRow[]; tracking: string; notes:
10
10
 
11
11
  export function appPrivacyAnswers(f: LegalFacts, privacyUrl: string): AppPrivacyAnswers {
12
12
  const rows: StoreRow[] = [];
13
- // apps can RSVP wherever the calendar is on; room bookings need bookings too
14
- const contact = [...(f.bookings ? ['bookings'] : []), ...(f.calendar ? ['RSVPs'] : []), ...(f.appForms ? ['forms'] : [])];
13
+ // apps can RSVP (or join a full date's waitlist) wherever the calendar is on; room bookings need bookings too
14
+ const contact = [...(f.bookings ? ['bookings'] : []), ...(f.calendar ? ['RSVPs (including waitlist requests)'] : []), ...(f.appForms ? ['forms'] : [])];
15
15
  const sends = contact.length > 1 ? `${contact.slice(0, -1).join(', ')} or ${contact.at(-1)}` : contact[0];
16
16
  if (contact.length) {
17
17
  rows.push({
@@ -42,7 +42,7 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
42
42
  ] },
43
43
  ];
44
44
  if (f.calendar) s.push({ id: 'bookings', title: f.bookings ? 'Bookings and RSVPs' : 'Event RSVPs', text: [
45
- `When you ${f.bookings ? 'book a time or ' : ''}RSVP to an event, we store your name, email address, any phone number and answers you give, the date and time, the number of seats and the status. We use them to manage your request, prevent duplicate submissions, and email you confirmations, changes and a reminder. Staff who manage the calendar can see them${f.bookings ? ', and the people a room or service notifies receive new bookings by email' : ''}.`,
45
+ `When you ${f.bookings ? 'book a time or ' : ''}RSVP to an event, we store your name, email address, any phone number and answers you give, the date and time, the number of seats and the status. We use them to manage your request, prevent duplicate submissions, and email you confirmations, changes and a reminder. Staff who manage the calendar can see them${f.bookings ? ', and the people a room or service notifies receive new bookings by email' : ''}. When you RSVP or cancel an RSVP, the event’s hosts (or our staff) get an email with your RSVP details. If a date is full and you join its waitlist, we keep your request like an RSVP and email you if a spot opens; we hold that spot for 24 hours.`,
46
46
  'Each booking has a private link to see, change or cancel it. Keep it private: anyone with the link can use it.',
47
47
  ] });
48
48
  s.push({ id: 'forms', title: 'Forms', text: [
@@ -43,7 +43,7 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
43
43
  ] },
44
44
  ];
45
45
  if (f.calendar) s.push({ id: 'bookings', title: f.bookings ? 'Reservaciones y registros' : 'Registros a eventos', text: [
46
- `Cuando ${f.bookings ? 'reserva un horario o ' : ''}se registra a un evento, guardamos su nombre, su correo electrónico, el teléfono y las respuestas que nos dé, la fecha y hora, el número de lugares y el estado. Los usamos para gestionar su solicitud, evitar envíos duplicados y enviarle por correo confirmaciones, cambios y un recordatorio. El personal que administra el calendario puede verlos${f.bookings ? ', y las personas a las que avisa una sala o un servicio reciben las reservaciones nuevas por correo' : ''}.`,
46
+ `Cuando ${f.bookings ? 'reserva un horario o ' : ''}se registra a un evento, guardamos su nombre, su correo electrónico, el teléfono y las respuestas que nos dé, la fecha y hora, el número de lugares y el estado. Los usamos para gestionar su solicitud, evitar envíos duplicados y enviarle por correo confirmaciones, cambios y un recordatorio. El personal que administra el calendario puede verlos${f.bookings ? ', y las personas a las que avisa una sala o un servicio reciben las reservaciones nuevas por correo' : ''}. Cuando se registra a un evento o cancela su registro, los organizadores del evento (o nuestro personal) reciben un correo con los datos de su registro. Si una fecha está llena y usted se une a su lista de espera, guardamos su solicitud como un registro y le escribimos si se libera un lugar; se lo apartamos 24 horas.`,
47
47
  'Cada reservación tiene un enlace privado para verla, cambiarla o cancelarla. Manténgalo en privado: cualquiera que lo tenga puede usarlo.',
48
48
  ] });
49
49
  s.push({ id: 'forms', title: 'Formularios', text: [