@acms-subir/core 0.3.37 → 0.3.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/cli/migrations.generated.mjs +5 -0
- package/docs/mobile.md +1 -1
- package/docs/theme-authoring.md +1 -1
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +18 -11
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +18 -11
- package/mobile-template/recipes/rsvp/recipe.json +2 -1
- package/package.json +1 -1
- package/src/actions/index.ts +2 -1
- package/src/components/EventPage.astro +53 -30
- package/src/components/MobileApp.tsx +44 -23
- package/src/components/editor/EventPanel.tsx +2 -1
- package/src/components/editor/PostEditor.tsx +1 -1
- package/src/components/editor/event/RegistrationCard.tsx +51 -1
- package/src/components/editor/event/registration.ts +18 -1
- package/src/lib/booking/emails.ts +62 -45
- package/src/lib/booking/service.ts +57 -19
- package/src/lib/calendar/cards.ts +5 -3
- package/src/lib/calendar/event-words.ts +171 -0
- package/src/lib/calendar/events.ts +16 -2
- package/src/lib/calendar/render.ts +3 -1
- package/src/lib/calendar/rrule.ts +14 -18
- package/src/lib/calendar/rsvp-words.ts +31 -2
- package/src/lib/content-service.ts +26 -14
- package/src/lib/drafts.ts +2 -0
- package/src/lib/editor/documents.ts +3 -1
- package/src/lib/editor/schemas.ts +1 -1
- package/src/lib/email/booking-words.ts +212 -0
- package/src/lib/forms/events.ts +4 -2
- package/src/lib/mobile/bookings.ts +10 -3
- package/src/lib/mobile/calendar.ts +6 -4
- package/src/lib/mobile/guest.ts +3 -2
- package/src/lib/mobile/http.ts +1 -1
- package/src/lib/mobile/manage.ts +7 -5
- package/src/lib/mobile/openapi.ts +1 -1
- package/src/lib/mobile/rsvp.ts +14 -8
- package/src/lib/mobile/schemas.ts +7 -2
- package/src/lib/mobile/search.ts +1 -1
- package/src/lib/mobile/site.ts +7 -3
- package/src/lib/push/api.ts +7 -8
- package/src/lib/push/service.ts +26 -11
- package/src/lib/push/types.ts +18 -8
- package/src/lib/repo/migrations.ts +5 -0
- package/src/lib/repo/sql.ts +7 -4
- package/src/lib/repo/types.ts +5 -3
- package/src/lib/schema.ts +8 -1
- package/src/pages/book/[resource]/[link].astro +8 -4
- package/src/pages/book/manage/[token].astro +22 -14
- package/src/pages/events/[slug].astro +12 -5
- package/src/styles/editor.css +4 -0
- package/src/styles/mobile-app.css +7 -0
- package/src/styles/site.css +3 -0
- package/src/theme-sdk/index.ts +4 -1
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Words of the booking and RSVP emails (lib/booking/emails.ts), in the
|
|
2
|
+
// language the person booked in, and of a room booking's own page
|
|
3
|
+
// (book/manage; an RSVP's page words are in calendar/rsvp-words.ts). Emails
|
|
4
|
+
// say usted in Spanish, like the privacy notice; the website's pages say tú,
|
|
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
|
|
7
|
+
// messages for a seat at an event (English says "booking" for both). Staff
|
|
8
|
+
// 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
|
+
export interface MailCopy { subject: string; heading: string; intro: string }
|
|
11
|
+
|
|
12
|
+
export interface BookingStrings {
|
|
13
|
+
mail: Record<GuestMail, MailCopy>;
|
|
14
|
+
/** the button to the booking's own page */
|
|
15
|
+
manage: string;
|
|
16
|
+
rsvp: { manage: string } & Partial<Record<GuestMail, Partial<MailCopy>>>;
|
|
17
|
+
/** the details table */
|
|
18
|
+
what: string;
|
|
19
|
+
when: string;
|
|
20
|
+
where: string;
|
|
21
|
+
seats: string;
|
|
22
|
+
reference: string;
|
|
23
|
+
/** the calendar file's description */
|
|
24
|
+
icsManage: string;
|
|
25
|
+
/** a pending booking nobody answered in time, declined automatically */
|
|
26
|
+
lateDecline: string;
|
|
27
|
+
staff: { pending: string; booked: string; review: string; open: string; pendingSubject: string; bookedSubject: string };
|
|
28
|
+
/** a room booking's own page: what differs from an RSVP's (rsvp-words.ts) */
|
|
29
|
+
page: {
|
|
30
|
+
untitled: string;
|
|
31
|
+
booked: string;
|
|
32
|
+
yours: string;
|
|
33
|
+
moved: string;
|
|
34
|
+
notAvailable: string;
|
|
35
|
+
cancelledTitle: string;
|
|
36
|
+
declined: string;
|
|
37
|
+
cancelledText: string;
|
|
38
|
+
statuses: Record<'pending' | 'confirmed' | 'cancelled' | 'declined', string>;
|
|
39
|
+
reschedule: string;
|
|
40
|
+
cancel: string;
|
|
41
|
+
cancelConfirm: string;
|
|
42
|
+
cutoff: string;
|
|
43
|
+
keepLink: string;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const STRINGS: Record<string, BookingStrings> = {
|
|
48
|
+
en: {
|
|
49
|
+
mail: {
|
|
50
|
+
received: { subject: 'Request received: {title}', heading: 'Request received', intro: 'Thanks, {name}. We\'ll confirm by email soon.' },
|
|
51
|
+
confirmed: { subject: 'Confirmed: {title} — {when}', heading: 'You’re booked', intro: 'It’s confirmed. Import the attached file to add it to your calendar.' },
|
|
52
|
+
declined: { subject: 'Not available: {title}', heading: 'We couldn’t confirm your request', intro: 'Sorry — this time isn’t available. You’re welcome to pick another.' },
|
|
53
|
+
cancelled: { subject: 'Cancelled: {title}', heading: 'Booking cancelled', intro: 'This booking has been cancelled.' },
|
|
54
|
+
updated: { subject: 'Updated: {title}', heading: 'Booking details updated', intro: 'Please review the latest details. Use the attached calendar file to update your calendar.' },
|
|
55
|
+
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
|
+
reminder: { subject: 'Reminder: {title} — {when}', heading: 'Coming up soon', intro: 'A quick reminder about your booking.' },
|
|
57
|
+
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.' },
|
|
58
|
+
},
|
|
59
|
+
manage: 'View or change your booking', rsvp: { manage: 'View or change your booking' },
|
|
60
|
+
what: 'What', when: 'When', where: 'Where', seats: 'Seats', reference: 'Reference {ref}', icsManage: 'Manage: {url}',
|
|
61
|
+
lateDecline: 'We weren’t able to confirm this booking in time. Please pick another time.',
|
|
62
|
+
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}' },
|
|
63
|
+
page: {
|
|
64
|
+
untitled: 'Booking', booked: 'You’re booked', yours: 'Your booking', moved: 'Booking moved', notAvailable: 'Not available', cancelledTitle: 'Booking cancelled',
|
|
65
|
+
declined: 'Sorry — this time couldn’t be confirmed. You’re welcome to pick another.', cancelledText: 'This booking is cancelled.',
|
|
66
|
+
statuses: { pending: 'Pending', confirmed: 'Confirmed', cancelled: 'Cancelled', declined: 'Declined' },
|
|
67
|
+
reschedule: 'Reschedule', cancel: 'Cancel booking', cancelConfirm: 'Cancel this booking?',
|
|
68
|
+
cutoff: 'Changes close {hours} hours before the start. Please contact us to change or cancel.',
|
|
69
|
+
keepLink: 'Keep this link — anyone with it can change this booking.',
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
es: {
|
|
73
|
+
mail: {
|
|
74
|
+
received: { subject: 'Solicitud recibida: {title}', heading: 'Solicitud recibida', intro: 'Gracias, {name}. Le confirmaremos por correo muy pronto.' },
|
|
75
|
+
confirmed: { subject: 'Confirmada: {title} — {when}', heading: 'Su reservación está confirmada', intro: 'Ya está confirmada. Importe el archivo adjunto para agregarla a su calendario.' },
|
|
76
|
+
declined: { subject: 'No disponible: {title}', heading: 'No pudimos confirmar su solicitud', intro: 'Lo sentimos, este horario no está disponible. Puede elegir otro cuando guste.' },
|
|
77
|
+
cancelled: { subject: 'Cancelada: {title}', heading: 'Reservación cancelada', intro: 'Esta reservación se canceló.' },
|
|
78
|
+
updated: { subject: 'Actualizada: {title}', heading: 'Cambiaron los detalles de su reservación', intro: 'Revise los detalles más recientes. Use el archivo de calendario adjunto para actualizar su calendario.' },
|
|
79
|
+
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
|
+
reminder: { subject: 'Recordatorio: {title} — {when}', heading: 'Ya casi es la fecha', intro: 'Un breve recordatorio de su reservación.' },
|
|
81
|
+
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.' },
|
|
82
|
+
},
|
|
83
|
+
manage: 'Ver o cambiar su reservación',
|
|
84
|
+
rsvp: {
|
|
85
|
+
manage: 'Ver o cambiar su confirmación',
|
|
86
|
+
confirmed: { subject: 'Confirmado: {title} — {when}', heading: 'Ya tiene su lugar', intro: 'Está confirmado. Importe el archivo adjunto para agregarlo a su calendario.' },
|
|
87
|
+
declined: { intro: 'Lo sentimos, el organizador no pudo confirmar su lugar.' },
|
|
88
|
+
cancelled: { subject: 'Cancelado: {title}', heading: 'Confirmación cancelada', intro: 'Esta confirmación se canceló.' },
|
|
89
|
+
updated: { subject: 'Actualizado: {title}', heading: 'Cambiaron los detalles' },
|
|
90
|
+
reminder: { intro: 'Un breve recordatorio: tiene un lugar apartado.' },
|
|
91
|
+
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
|
+
},
|
|
93
|
+
what: 'Qué', when: 'Cuándo', where: 'Dónde', seats: 'Lugares', reference: 'Referencia {ref}', icsManage: 'Ver o cambiar: {url}',
|
|
94
|
+
lateDecline: 'No pudimos confirmar esta reservación a tiempo. Por favor, elija otro horario.',
|
|
95
|
+
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}' },
|
|
96
|
+
page: {
|
|
97
|
+
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
|
+
declined: 'Lo sentimos, no se pudo confirmar este horario. Puedes elegir otro.', cancelledText: 'Esta reservación se canceló.',
|
|
99
|
+
statuses: { pending: 'Pendiente', confirmed: 'Confirmada', cancelled: 'Cancelada', declined: 'Rechazada' },
|
|
100
|
+
reschedule: 'Cambiar horario', cancel: 'Cancelar reservación', cancelConfirm: '¿Cancelar esta reservación?',
|
|
101
|
+
cutoff: 'Los cambios se cierran {hours} horas antes del inicio. Escríbenos para cambiarla o cancelarla.',
|
|
102
|
+
keepLink: 'Guarda este enlace: quien lo tenga puede cambiar esta reservación.',
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
fr: {
|
|
106
|
+
mail: {
|
|
107
|
+
received: { subject: 'Demande reçue : {title}', heading: 'Demande reçue', intro: 'Merci, {name}. Nous vous confirmerons par e-mail très bientôt.' },
|
|
108
|
+
confirmed: { subject: 'Confirmée : {title} — {when}', heading: 'Votre réservation est confirmée', intro: 'C’est confirmé. Importez le fichier joint pour l’ajouter à votre calendrier.' },
|
|
109
|
+
declined: { subject: 'Indisponible : {title}', heading: 'Nous n’avons pas pu confirmer votre demande', intro: 'Désolés, ce créneau n’est pas disponible. N’hésitez pas à en choisir un autre.' },
|
|
110
|
+
cancelled: { subject: 'Annulée : {title}', heading: 'Réservation annulée', intro: 'Cette réservation a été annulée.' },
|
|
111
|
+
updated: { subject: 'Mise à jour : {title}', heading: 'Détails de la réservation mis à jour', intro: 'Veuillez vérifier les derniers détails. Utilisez le fichier de calendrier joint pour mettre à jour votre calendrier.' },
|
|
112
|
+
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
|
+
reminder: { subject: 'Rappel : {title} — {when}', heading: 'C’est bientôt', intro: 'Un petit rappel de votre réservation.' },
|
|
114
|
+
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.' },
|
|
115
|
+
},
|
|
116
|
+
manage: 'Voir ou modifier votre réservation',
|
|
117
|
+
rsvp: {
|
|
118
|
+
manage: 'Voir ou modifier votre inscription',
|
|
119
|
+
confirmed: { heading: 'Votre place est réservée' },
|
|
120
|
+
declined: { intro: 'Désolés, l’organisateur n’a pas pu confirmer votre inscription.' },
|
|
121
|
+
cancelled: { heading: 'Inscription annulée', intro: 'Cette inscription a été annulée.' },
|
|
122
|
+
updated: { heading: 'Détails mis à jour' },
|
|
123
|
+
reminder: { intro: 'Un petit rappel de votre inscription.' },
|
|
124
|
+
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
|
+
},
|
|
126
|
+
what: 'Quoi', when: 'Quand', where: 'Où', seats: 'Places', reference: 'Référence {ref}', icsManage: 'Gérer : {url}',
|
|
127
|
+
lateDecline: 'Nous n’avons pas pu confirmer cette réservation à temps. Veuillez choisir un autre horaire.',
|
|
128
|
+
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}' },
|
|
129
|
+
page: {
|
|
130
|
+
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
|
+
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.',
|
|
132
|
+
statuses: { pending: 'En attente', confirmed: 'Confirmée', cancelled: 'Annulée', declined: 'Refusée' },
|
|
133
|
+
reschedule: 'Changer d’horaire', cancel: 'Annuler la réservation', cancelConfirm: 'Annuler cette réservation ?',
|
|
134
|
+
cutoff: 'Les modifications ferment {hours} heures avant le début. Contactez-nous pour la modifier ou l’annuler.',
|
|
135
|
+
keepLink: 'Conservez ce lien : toute personne qui l’a peut modifier cette réservation.',
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
de: {
|
|
139
|
+
mail: {
|
|
140
|
+
received: { subject: 'Anfrage erhalten: {title}', heading: 'Anfrage erhalten', intro: 'Danke, {name}. Wir bestätigen sie Ihnen bald per E-Mail.' },
|
|
141
|
+
confirmed: { subject: 'Bestätigt: {title} — {when}', heading: 'Ihre Buchung ist bestätigt', intro: 'Alles bestätigt. Importieren Sie die angehängte Datei, um den Termin in Ihren Kalender aufzunehmen.' },
|
|
142
|
+
declined: { subject: 'Nicht verfügbar: {title}', heading: 'Wir konnten Ihre Anfrage nicht bestätigen', intro: 'Leider ist dieser Termin nicht verfügbar. Wählen Sie gern einen anderen.' },
|
|
143
|
+
cancelled: { subject: 'Storniert: {title}', heading: 'Buchung storniert', intro: 'Diese Buchung wurde storniert.' },
|
|
144
|
+
updated: { subject: 'Aktualisiert: {title}', heading: 'Buchungsdetails aktualisiert', intro: 'Bitte prüfen Sie die aktuellen Details. Mit der angehängten Kalenderdatei aktualisieren Sie Ihren Kalender.' },
|
|
145
|
+
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
|
+
reminder: { subject: 'Erinnerung: {title} — {when}', heading: 'Bald ist es so weit', intro: 'Eine kurze Erinnerung an Ihre Buchung.' },
|
|
147
|
+
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.' },
|
|
148
|
+
},
|
|
149
|
+
manage: 'Buchung ansehen oder ändern',
|
|
150
|
+
rsvp: {
|
|
151
|
+
manage: 'Anmeldung ansehen oder ändern',
|
|
152
|
+
confirmed: { heading: 'Ihr Platz ist reserviert' },
|
|
153
|
+
declined: { intro: 'Leider konnte der Veranstalter Ihre Anmeldung nicht bestätigen.' },
|
|
154
|
+
cancelled: { heading: 'Anmeldung storniert', intro: 'Diese Anmeldung wurde storniert.' },
|
|
155
|
+
updated: { heading: 'Details aktualisiert' },
|
|
156
|
+
reminder: { intro: 'Eine kurze Erinnerung an Ihre Anmeldung.' },
|
|
157
|
+
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
|
+
},
|
|
159
|
+
what: 'Was', when: 'Wann', where: 'Wo', seats: 'Plätze', reference: 'Referenz {ref}', icsManage: 'Verwalten: {url}',
|
|
160
|
+
lateDecline: 'Wir konnten diese Buchung nicht rechtzeitig bestätigen. Bitte wählen Sie einen anderen Termin.',
|
|
161
|
+
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}' },
|
|
162
|
+
page: {
|
|
163
|
+
untitled: 'Buchung', booked: 'Ihre Buchung ist bestätigt', yours: 'Ihre Buchung', moved: 'Buchung verschoben', notAvailable: 'Nicht verfügbar', cancelledTitle: 'Buchung storniert',
|
|
164
|
+
declined: 'Leider konnte dieser Termin nicht bestätigt werden. Wählen Sie gern einen anderen.', cancelledText: 'Diese Buchung ist storniert.',
|
|
165
|
+
statuses: { pending: 'Ausstehend', confirmed: 'Bestätigt', cancelled: 'Storniert', declined: 'Abgelehnt' },
|
|
166
|
+
reschedule: 'Verschieben', cancel: 'Buchung stornieren', cancelConfirm: 'Diese Buchung stornieren?',
|
|
167
|
+
cutoff: 'Änderungen sind bis {hours} Stunden vor Beginn möglich. Bitte kontaktieren Sie uns, um sie zu ändern oder zu stornieren.',
|
|
168
|
+
keepLink: 'Bewahren Sie diesen Link auf – wer ihn hat, kann diese Buchung ändern.',
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
pt: {
|
|
172
|
+
mail: {
|
|
173
|
+
received: { subject: 'Pedido recebido: {title}', heading: 'Pedido recebido', intro: 'Obrigado, {name}. Vamos confirmar por e-mail em breve.' },
|
|
174
|
+
confirmed: { subject: 'Confirmada: {title} — {when}', heading: 'Sua reserva está confirmada', intro: 'Está confirmada. Importe o arquivo anexo para adicioná-la ao seu calendário.' },
|
|
175
|
+
declined: { subject: 'Indisponível: {title}', heading: 'Não conseguimos confirmar seu pedido', intro: 'Desculpe, este horário não está disponível. Fique à vontade para escolher outro.' },
|
|
176
|
+
cancelled: { subject: 'Cancelada: {title}', heading: 'Reserva cancelada', intro: 'Esta reserva foi cancelada.' },
|
|
177
|
+
updated: { subject: 'Atualizada: {title}', heading: 'Detalhes da reserva atualizados', intro: 'Confira os detalhes mais recentes. Use o arquivo de calendário anexo para atualizar seu calendário.' },
|
|
178
|
+
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
|
+
reminder: { subject: 'Lembrete: {title} — {when}', heading: 'Está chegando', intro: 'Um lembrete rápido sobre sua reserva.' },
|
|
180
|
+
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.' },
|
|
181
|
+
},
|
|
182
|
+
manage: 'Ver ou alterar sua reserva',
|
|
183
|
+
rsvp: {
|
|
184
|
+
manage: 'Ver ou alterar sua inscrição',
|
|
185
|
+
confirmed: { heading: 'Sua vaga está reservada' },
|
|
186
|
+
declined: { intro: 'Desculpe, o organizador não pôde confirmar sua inscrição.' },
|
|
187
|
+
cancelled: { heading: 'Inscrição cancelada', intro: 'Esta inscrição foi cancelada.' },
|
|
188
|
+
updated: { heading: 'Detalhes atualizados' },
|
|
189
|
+
reminder: { intro: 'Um lembrete rápido sobre sua inscrição.' },
|
|
190
|
+
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
|
+
},
|
|
192
|
+
what: 'O quê', when: 'Quando', where: 'Onde', seats: 'Vagas', reference: 'Referência {ref}', icsManage: 'Gerenciar: {url}',
|
|
193
|
+
lateDecline: 'Não conseguimos confirmar esta reserva a tempo. Por favor, escolha outro horário.',
|
|
194
|
+
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}' },
|
|
195
|
+
page: {
|
|
196
|
+
untitled: 'Reserva', booked: 'Sua reserva está confirmada', yours: 'Sua reserva', moved: 'Reserva remarcada', notAvailable: 'Indisponível', cancelledTitle: 'Reserva cancelada',
|
|
197
|
+
declined: 'Desculpe, não foi possível confirmar este horário. Fique à vontade para escolher outro.', cancelledText: 'Esta reserva foi cancelada.',
|
|
198
|
+
statuses: { pending: 'Pendente', confirmed: 'Confirmada', cancelled: 'Cancelada', declined: 'Recusada' },
|
|
199
|
+
reschedule: 'Remarcar', cancel: 'Cancelar reserva', cancelConfirm: 'Cancelar esta reserva?',
|
|
200
|
+
cutoff: 'As alterações se encerram {hours} horas antes do início. Fale com a gente para alterar ou cancelar.',
|
|
201
|
+
keepLink: 'Guarde este link: qualquer pessoa com ele pode alterar esta reserva.',
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export const bookingStrings = (locale: string | null | undefined): BookingStrings =>
|
|
207
|
+
STRINGS[(locale || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
|
|
208
|
+
|
|
209
|
+
/** one guest email's subject, heading and intro: a booking's, or an RSVP's where the language words it differently */
|
|
210
|
+
export function mailCopy(w: BookingStrings, kind: GuestMail, rsvp: boolean): MailCopy {
|
|
211
|
+
return rsvp ? { ...w.mail[kind], ...w.rsvp[kind] } : w.mail[kind];
|
|
212
|
+
}
|
package/src/lib/forms/events.ts
CHANGED
|
@@ -17,7 +17,8 @@ export async function prepareLegacyEventForm(event: CalEvent, start: number, dat
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// locale: the page's language, so the RSVP's emails and links speak it
|
|
21
|
+
export async function submitEventForm(repo: ContentRepository, event: CalEvent, occurrence: { start: number; end: number }, data: FormData, requestId: string, flow: Parameters<typeof createRsvp>[2], locale = '') {
|
|
21
22
|
if (event.status !== 'published') throw new ApiError(404, 'Event not found.');
|
|
22
23
|
if (Number(data.get('occurrence')) !== occurrence.start) throw new ApiError(409, 'The event time has changed. Review the date and try again.');
|
|
23
24
|
const seats = Number(data.get('seats') || 1);
|
|
@@ -35,6 +36,7 @@ export async function submitEventForm(repo: ContentRepository, event: CalEvent,
|
|
|
35
36
|
};
|
|
36
37
|
const existing = await replay(); if (existing) return existing;
|
|
37
38
|
await collectResponse(definition, booker.answers, booker.formVersion);
|
|
38
|
-
|
|
39
|
+
// 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 }); }
|
|
39
41
|
catch (e) { const existing = await replay(); if (existing) return existing; throw e; }
|
|
40
42
|
}
|
|
@@ -9,12 +9,15 @@ import { waitUntilFrom } from '../webhooks';
|
|
|
9
9
|
import { requireCalendar } from './calendar';
|
|
10
10
|
import { appAnswers, joinAnswers } from './forms';
|
|
11
11
|
import { rsvpClosed, rsvpOccurrence, rsvpQuestions } from './rsvp';
|
|
12
|
+
import { localeQuery, mobileLocale } from './locale';
|
|
12
13
|
|
|
13
14
|
const common = {
|
|
14
15
|
start: z.string().datetime({ offset: true }),
|
|
15
16
|
name: z.string().trim().min(1).max(120), email: z.string().trim().email().max(200).transform(v => v.toLowerCase()),
|
|
16
17
|
phone: z.string().trim().max(40).default(''),
|
|
17
18
|
answers: appAnswers.default({}),
|
|
19
|
+
// the app's language (one of the site's locales): the emails and links speak it
|
|
20
|
+
locale: localeQuery,
|
|
18
21
|
};
|
|
19
22
|
export const bookingInput = z.union([
|
|
20
23
|
z.object({ ...common, resource: z.string().min(1).max(200), link: z.string().min(1).max(200),
|
|
@@ -36,7 +39,11 @@ export async function submitBooking(repo: ContentRepository, request: Request, l
|
|
|
36
39
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(key)) throw new ApiError(400, 'Send a random UUID v4 in Idempotency-Key and reuse it only for retries.');
|
|
37
40
|
const body = bookingInput.parse(await readJson(request, 50000));
|
|
38
41
|
if (!('event' in body)) requireCalendar(true);
|
|
39
|
-
const
|
|
42
|
+
const locale = mobileLocale(body.locale) ?? '';
|
|
43
|
+
// the language isn't a booking detail: a retry in another one replays the
|
|
44
|
+
// same booking, and keys saved before apps sent it still match
|
|
45
|
+
const { locale: _language, ...asked } = body;
|
|
46
|
+
const idempotency = { keyHash: await sha256Hex(key.toLowerCase()), requestHash: await sha256Hex(canonical(asked)) };
|
|
40
47
|
const replay = async () => {
|
|
41
48
|
const record = await repo.getBookingRequest(idempotency.keyHash);
|
|
42
49
|
if (!record) return null;
|
|
@@ -51,7 +58,7 @@ export async function submitBooking(repo: ContentRepository, request: Request, l
|
|
|
51
58
|
const settings = await repo.getSettings();
|
|
52
59
|
const flow = { origin: siteOrigin(settings, new URL(request.url)), locals, waitUntil: waitUntilFrom(locals), idempotency };
|
|
53
60
|
const start = Date.parse(body.start);
|
|
54
|
-
const booker = { ...body, answers: joinAnswers(body.answers) };
|
|
61
|
+
const booker = { ...body, answers: joinAnswers(body.answers), locale };
|
|
55
62
|
let booking: Booking;
|
|
56
63
|
if ('event' in body) {
|
|
57
64
|
const occurrence = await rsvpOccurrence(repo, body.event, body.start);
|
|
@@ -59,7 +66,7 @@ export async function submitBooking(repo: ContentRepository, request: Request, l
|
|
|
59
66
|
if (body.seats > (event.rsvp.maxSeats || 1)) throw new ApiError(400, 'Too many seats for one RSVP.');
|
|
60
67
|
// the app must have seen the current questions: a changed form then answers 409, as on the website
|
|
61
68
|
// (closed dates fall through to createRsvp's own answer)
|
|
62
|
-
if (!body.formVersion && rsvpQuestions(event) && !rsvpClosed(occurrence)) throw new ApiError(400, 'This event asks questions. Send formVersion from GET /api/v1/events/{slug}/rsvp with the answers.');
|
|
69
|
+
if (!body.formVersion && rsvpQuestions(event) && !rsvpClosed(occurrence, settings)) throw new ApiError(400, 'This event asks questions. Send formVersion from GET /api/v1/events/{slug}/rsvp with the answers.');
|
|
63
70
|
booking = await createRsvp(repo, { event, start, end: occurrence.end, seats: body.seats, booker, source: 'app' }, flow);
|
|
64
71
|
} else {
|
|
65
72
|
const resource = await repo.getResourceBySlug(body.resource);
|
|
@@ -13,6 +13,7 @@ import { pageQuery, isoDay, isoDayOrInstant, queryParams } from './http';
|
|
|
13
13
|
import { localizeDoc } from '../translations';
|
|
14
14
|
import { localeQuery, mobileLocale, localizedUrl } from './locale';
|
|
15
15
|
import { confirmsInstantly, linkApproval } from '../booking/approval';
|
|
16
|
+
import { changeTopics } from '../push/service';
|
|
16
17
|
|
|
17
18
|
export function requireCalendar(bookings = false) {
|
|
18
19
|
if (!CALENDAR.enabled || (bookings && !bookingsEnabled())) throw new ApiError(404, 'This feature is disabled.');
|
|
@@ -59,20 +60,21 @@ export async function listEvents(repo: ContentRepository, url: URL) {
|
|
|
59
60
|
occurrences: occurrences.slice(start, start + q.limit).map(o => ({
|
|
60
61
|
id: `${o.event.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay,
|
|
61
62
|
cancelled: o.cancelled,
|
|
62
|
-
event: publicEventDto(o.event, origin, publicCalendars, settings
|
|
63
|
+
event: publicEventDto(o.event, origin, publicCalendars, settings, locale),
|
|
63
64
|
})),
|
|
64
65
|
pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
|
|
65
66
|
};
|
|
66
67
|
}
|
|
67
68
|
// e is already localized; publicCalendars limits which calendars are named
|
|
68
|
-
export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>,
|
|
69
|
-
const event = eventToJson(e, origin, publicCalendars,
|
|
69
|
+
export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, settings: Settings, locale: string | null) {
|
|
70
|
+
const event = eventToJson(e, origin, publicCalendars, settings.timezone);
|
|
70
71
|
event.url = localizedUrl(event.url, origin, locale);
|
|
71
72
|
// Only configured public event fields; no undeclared imported metadata, no private fields.
|
|
72
73
|
event.fields = publicEventFields(e.fields);
|
|
73
74
|
event.image = safeUrl(e.cover, origin);
|
|
74
75
|
event.cta = { ...event.cta, href: localizedUrl(safeUrl(event.cta.href, origin, true), origin, locale) };
|
|
75
|
-
|
|
76
|
+
// which push topics hear about date, time or place changes ([] = none do)
|
|
77
|
+
return { ...event, notify: { changes: changeTopics(e, settings, publicCalendars) } };
|
|
76
78
|
}
|
|
77
79
|
export async function listResources(repo: ContentRepository, url: URL) {
|
|
78
80
|
requireCalendar(true);
|
package/src/lib/mobile/guest.ts
CHANGED
|
@@ -4,7 +4,8 @@ import type { Booking } from '../schema';
|
|
|
4
4
|
import { ApiError, readJson } from '../api';
|
|
5
5
|
import { sha256Hex } from '../api-keys';
|
|
6
6
|
import { randomToken, safeEqual } from '../editor/auth';
|
|
7
|
-
import { CALENDAR, bookingsEnabled
|
|
7
|
+
import { CALENDAR, bookingsEnabled } from '../calendar/config';
|
|
8
|
+
import { bookingManagePath } from '../booking/emails';
|
|
8
9
|
import { encrypt, encryptionAvailable, resolveConfig, validAddress } from '../email/config';
|
|
9
10
|
import { kickEmail } from '../email/service';
|
|
10
11
|
import { guestChanges } from '../booking/service';
|
|
@@ -144,7 +145,7 @@ export async function myBookings(c: PublicContext) {
|
|
|
144
145
|
start: new Date(b.start).toISOString(), end: new Date(b.end).toISOString(), seats: b.seats,
|
|
145
146
|
title: event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking',
|
|
146
147
|
// use with the Booking-Token header on /bookings/manage/*
|
|
147
|
-
manageToken: b.manageToken, manageUrl: origin +
|
|
148
|
+
manageToken: b.manageToken, manageUrl: origin + bookingManagePath(b),
|
|
148
149
|
can: guestChanges(b, link, now),
|
|
149
150
|
};
|
|
150
151
|
}),
|
package/src/lib/mobile/http.ts
CHANGED
|
@@ -33,7 +33,7 @@ export function failure(error: unknown) {
|
|
|
33
33
|
const status = error instanceof ApiError || error instanceof BookingError ? error.status : error instanceof z.ZodError ? 400 : 500;
|
|
34
34
|
const codes: Record<number, string> = { 400: 'invalid_request', 401: 'unauthorized', 403: 'forbidden', 404: 'not_found', 405: 'method_not_allowed', 409: 'conflict', 410: 'gone', 413: 'payload_too_large', 415: 'unsupported_media_type', 429: 'rate_limited', 502: 'upstream_error' };
|
|
35
35
|
return Response.json({ version: 1, error: {
|
|
36
|
-
code: codes[status] ?? 'internal_error',
|
|
36
|
+
code: (error instanceof BookingError && error.code) || (codes[status] ?? 'internal_error'),
|
|
37
37
|
message: status === 500 ? 'Request failed. Please try again.' : error instanceof z.ZodError ? 'Invalid request parameters.' : (error as Error).message,
|
|
38
38
|
...(error instanceof z.ZodError ? { issues: error.issues.map(({ path, message }) => ({ path, message })) } : {}),
|
|
39
39
|
} }, { status, headers: { ...headers, ...(status === 429 ? { 'Retry-After': String((error as ApiError).retryAfter ?? 60) } : {}),
|
package/src/lib/mobile/manage.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { Booking, BookingLink, CalEvent, Resource, Settings } from '../schema';
|
|
3
3
|
import { ApiError, readJson } from '../api';
|
|
4
|
-
import { bookingsEnabled, eventUrl
|
|
4
|
+
import { bookingsEnabled, eventUrl } from '../calendar/config';
|
|
5
5
|
import { eventTimeZone, locationLine } from '../calendar/events';
|
|
6
6
|
import { resourceZone, linkLengths } from '../booking/availability';
|
|
7
|
-
import { bookingIcs } from '../booking/emails';
|
|
7
|
+
import { bookingIcs, bookingLanguage, bookingManagePath } from '../booking/emails';
|
|
8
|
+
import { localePath } from '../i18n';
|
|
8
9
|
import { guestCancel, guestChanges, guestMoveLink, guestReschedule } from '../booking/service';
|
|
9
10
|
import { siteOrigin } from '../seo';
|
|
10
11
|
import { waitUntilFrom } from '../webhooks';
|
|
@@ -59,9 +60,10 @@ function view(m: Managed, booking = m.booking) {
|
|
|
59
60
|
location: event ? locationLine(event.location) : resource?.name ?? '',
|
|
60
61
|
resource: resource ? { slug: resource.slug, name: resource.name } : null,
|
|
61
62
|
link: link ? { slug: link.slug, name: link.name, mode: link.mode, lengths: linkLengths(link) } : null,
|
|
62
|
-
|
|
63
|
+
// the web pages in the language it was booked in, as the emails link them
|
|
64
|
+
event: event ? { slug: event.slug, title: event.title, url: origin + localePath(eventUrl(event.slug), bookingLanguage(booking, settings).locale) } : null,
|
|
63
65
|
// the web page for this booking (the same private link the emails carry)
|
|
64
|
-
manageUrl: origin +
|
|
66
|
+
manageUrl: origin + bookingManagePath(booking),
|
|
65
67
|
can,
|
|
66
68
|
// the RSVP form as it was when they sent it (like the web page), else the booking type's message
|
|
67
69
|
confirmation: booking.formResponse?.definition.confirmation || link?.confirmMessage || null,
|
|
@@ -106,7 +108,7 @@ export async function managedIcs(c: PublicContext) {
|
|
|
106
108
|
z.object({}).strict().parse(queryParams(c.url));
|
|
107
109
|
const m = await managed(c);
|
|
108
110
|
const v = view(m);
|
|
109
|
-
const body = bookingIcs(m.booking, v.title, v.location, v.manageUrl, new URL(m.origin).host, m.booking.status === 'cancelled' ? 'CANCEL' : 'REQUEST', { allDay: v.booking.allDay });
|
|
111
|
+
const body = bookingIcs(m.booking, v.title, v.location, v.manageUrl, new URL(m.origin).host, m.booking.status === 'cancelled' ? 'CANCEL' : 'REQUEST', { allDay: v.booking.allDay, lang: bookingLanguage(m.booking, m.settings).lang });
|
|
110
112
|
return new Response(body, { headers: {
|
|
111
113
|
'Content-Type': 'text/calendar; charset=utf-8', 'Content-Disposition': 'attachment; filename="booking.ics"',
|
|
112
114
|
'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*', 'X-Content-Type-Options': 'nosniff',
|
|
@@ -79,7 +79,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
79
79
|
paths['/api/v1/bookings'] = {
|
|
80
80
|
post: {
|
|
81
81
|
summary: 'Create a guest booking or an RSVP, or replay an existing request; RSVPs require calendar, room bookings also bookings',
|
|
82
|
-
description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent. Checkbox groups send a list of answers. An RSVP to an event with questions needs formVersion from GET /events/{slug}/rsvp; a changed form answers 409.',
|
|
82
|
+
description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent. Checkbox groups send a list of answers. An RSVP to an event with questions needs formVersion from GET /events/{slug}/rsvp; a changed form answers 409. An RSVP from an email that already has one for that date answers 409 with code duplicate (and emails that RSVP’s link again); past the event’s RSVP deadline, 410.',
|
|
83
83
|
parameters: [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid', pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' } }],
|
|
84
84
|
requestBody: { required: true, content: media(jsonSchema(bookingInput)) },
|
|
85
85
|
responses: { ...errors,
|
package/src/lib/mobile/rsvp.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
|
-
import type { CalEvent } from '../schema';
|
|
3
|
+
import type { CalEvent, Settings } from '../schema';
|
|
4
4
|
import { ApiError } from '../api';
|
|
5
5
|
import { answerFields } from '../forms/schema';
|
|
6
|
-
import { eventIsPublic, expandEvents, type Occurrence } from '../calendar/events';
|
|
6
|
+
import { eventIsPublic, expandEvents, rsvpDeadline, type Occurrence } from '../calendar/events';
|
|
7
7
|
import { seatsTaken } from '../booking/service';
|
|
8
8
|
import { localizeDoc } from '../translations';
|
|
9
9
|
import { siteLang } from '../dates';
|
|
@@ -15,8 +15,8 @@ import { localeQuery, mobileLocale } from './locale';
|
|
|
15
15
|
|
|
16
16
|
// RSVP status of one date of an event, for apps: whether it takes RSVPs now,
|
|
17
17
|
// live seats and the questions with their version. The same rules as the
|
|
18
|
-
// event's web page: open until the date ends, no
|
|
19
|
-
// Never cached; seats change with every RSVP. POST /bookings saves one.
|
|
18
|
+
// event's web page: open until the event's deadline or the date ends, no
|
|
19
|
+
// waitlist. Never cached; seats change with every RSVP. POST /bookings saves one.
|
|
20
20
|
|
|
21
21
|
/** a public event (slug or id) and its occurrence starting exactly at `start` */
|
|
22
22
|
export async function rsvpOccurrence(repo: ContentRepository, ref: string, start: string): Promise<Occurrence> {
|
|
@@ -31,11 +31,13 @@ export async function rsvpOccurrence(repo: ContentRepository, ref: string, start
|
|
|
31
31
|
}
|
|
32
32
|
/** the RSVP form when it asks something; apps must then send its version */
|
|
33
33
|
export const rsvpQuestions = (e: CalEvent) => e.rsvp.form && answerFields(e.rsvp.form).length ? e.rsvp.form : null;
|
|
34
|
-
/** why this date takes no RSVPs (seats aside), or null */
|
|
35
|
-
export function rsvpClosed(o: Occurrence, now = Date.now()) {
|
|
34
|
+
/** why this date takes no RSVPs (seats aside), or null: closed = past the event's deadline */
|
|
35
|
+
export function rsvpClosed(o: Occurrence, settings: Pick<Settings, 'timezone'>, now = Date.now()) {
|
|
36
36
|
if (!o.event.rsvp.enabled) return 'disabled' as const;
|
|
37
37
|
if (o.cancelled) return 'cancelled' as const;
|
|
38
38
|
if (o.end <= now) return 'ended' as const;
|
|
39
|
+
const deadline = rsvpDeadline(o, settings);
|
|
40
|
+
if (deadline !== null && deadline <= now) return 'closed' as const;
|
|
39
41
|
return null;
|
|
40
42
|
}
|
|
41
43
|
|
|
@@ -48,24 +50,28 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
|
|
|
48
50
|
const q = rsvpQuery.parse(queryParams(url));
|
|
49
51
|
const locale = mobileLocale(q.locale);
|
|
50
52
|
const o = await rsvpOccurrence(repo, slug, q.start);
|
|
53
|
+
const settings = await repo.getSettings();
|
|
51
54
|
const { rsvp } = o.event;
|
|
52
55
|
// pending and confirmed RSVPs hold seats, as in the capacity check
|
|
53
56
|
const going = rsvp.enabled ? await seatsTaken(repo, o.event.id, o.start) : 0;
|
|
54
57
|
const left = rsvp.capacity ? Math.max(0, rsvp.capacity - going) : null;
|
|
55
|
-
const closed = rsvpClosed(o);
|
|
58
|
+
const closed = rsvpClosed(o, settings);
|
|
59
|
+
const deadline = rsvp.enabled ? rsvpDeadline(o, settings) : null;
|
|
56
60
|
const reason = closed ?? (left === 0 ? 'full' as const : null);
|
|
57
61
|
const form = rsvpQuestions(o.event);
|
|
58
62
|
const title = localizeDoc(o.event, locale ?? undefined).title;
|
|
59
63
|
// the built-in button speaks the app's language (the site's own without languages set up)
|
|
60
64
|
let submitLabel = form?.submitLabel ?? '';
|
|
61
65
|
if (form && !submitLabel) {
|
|
62
|
-
const words = rsvpStrings(locale ?? siteLang(
|
|
66
|
+
const words = rsvpStrings(locale ?? siteLang(settings));
|
|
63
67
|
submitLabel = rsvp.autoConfirm ? words.confirmSeat : words.requestSeat;
|
|
64
68
|
}
|
|
65
69
|
return {
|
|
66
70
|
event: { id: o.event.id, slug: o.event.slug, title },
|
|
67
71
|
start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
|
|
68
72
|
open: !reason, reason, going, left, capacity: rsvp.capacity,
|
|
73
|
+
// when RSVPs to this date close, if before it ends
|
|
74
|
+
closesAt: deadline === null ? null : new Date(deadline).toISOString(),
|
|
69
75
|
// seats one RSVP may take right now
|
|
70
76
|
maxSeats: Math.min(rsvp.maxSeats || 1, left ?? Infinity), autoConfirm: rsvp.autoConfirm,
|
|
71
77
|
form: form ? { ...await formJson(form), submitLabel } : null,
|
|
@@ -32,6 +32,8 @@ export const Event = CalEvent.pick({
|
|
|
32
32
|
allDay: true, timezone: true, rrule: true, exdates: true, cancelledDates: true, location: true, cta: true, tags: true, fields: true,
|
|
33
33
|
}).extend({ rsvp: CalEvent.shape.rsvp.optional(), url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
|
|
34
34
|
calendars: z.array(z.object({ id: str, slug: str, name: str, color: str })),
|
|
35
|
+
// push topics told about date, time or place changes (Admin → Mobile app → When an event changes); [] = none
|
|
36
|
+
notify: z.object({ changes: strings }),
|
|
35
37
|
});
|
|
36
38
|
export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
|
|
37
39
|
occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, event: Event })),
|
|
@@ -70,8 +72,10 @@ export const Form = z.object({
|
|
|
70
72
|
export const FormReceipt = z.object({ received: z.literal(true), confirmation: str });
|
|
71
73
|
export const RsvpStatus = z.object({
|
|
72
74
|
event: z.object({ id: str, slug: str, title: str }), start: date, end: date,
|
|
73
|
-
open: bool, reason: z.enum(['disabled', 'ended', 'cancelled', 'full']).nullable(),
|
|
75
|
+
open: bool, reason: z.enum(['disabled', 'ended', 'closed', 'cancelled', 'full']).nullable(),
|
|
74
76
|
going: z.number().int(), left: z.number().int().nullable(), capacity: z.number().int(), maxSeats: z.number().int(), autoConfirm: bool,
|
|
77
|
+
// the event's RSVP deadline for this date (null: open until it ends)
|
|
78
|
+
closesAt: date.nullable(),
|
|
75
79
|
form: Form.omit({ id: true }).nullable(),
|
|
76
80
|
});
|
|
77
81
|
const GuestChanges = z.object({ cancel: bool, reschedule: bool, cutoffHours: z.number().int(), closed: bool });
|
|
@@ -133,6 +137,7 @@ export const LiveStatus = z.object({
|
|
|
133
137
|
display: z.object({ style: z.enum(['poster', 'split', 'row']), bar: bool, header: bool, dock: bool }),
|
|
134
138
|
});
|
|
135
139
|
export const Error = z.object({ version: z.literal(1), error: z.object({
|
|
136
|
-
|
|
140
|
+
// duplicate (409): that email already has an RSVP for the date
|
|
141
|
+
code: z.enum(['invalid_request', 'unauthorized', 'forbidden', 'not_found', 'method_not_allowed', 'conflict', 'duplicate', 'gone', 'payload_too_large', 'unsupported_media_type', 'rate_limited', 'upstream_error', 'internal_error']),
|
|
137
142
|
message: str, issues: z.array(z.object({ path: z.array(z.union([str, z.number()])), message: str })).optional(),
|
|
138
143
|
}) });
|
package/src/lib/mobile/search.ts
CHANGED
|
@@ -87,7 +87,7 @@ export async function search(repo: ContentRepository, url: URL) {
|
|
|
87
87
|
snippet: plainSnippet(snippet),
|
|
88
88
|
// the next occurrence, or the latest one when the event is over
|
|
89
89
|
occurrence: o ? { start: iso(o.start), end: iso(o.end), allDay: o.allDay, cancelled: o.cancelled } : null,
|
|
90
|
-
event: publicEventDto(e, origin, listed, settings
|
|
90
|
+
event: publicEventDto(e, origin, listed, settings, locale),
|
|
91
91
|
};
|
|
92
92
|
}),
|
|
93
93
|
more: { posts: posts.length > q.limit, pages: pages.length > q.limit, events: events.length > q.limit },
|
package/src/lib/mobile/site.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { queryParams } from './http';
|
|
|
13
13
|
import { localeOnlyQuery, localizedUrl, mobileLocale } from './locale';
|
|
14
14
|
import { guestSignInReady } from './guest';
|
|
15
15
|
import { LEGAL } from '../legal/config';
|
|
16
|
+
import { siteStrings } from '../site-words';
|
|
16
17
|
|
|
17
18
|
export async function getSite(repo: ContentRepository, url: URL, _id = '', locals?: unknown) {
|
|
18
19
|
// locale: the legal links point at that language's pages (/es/privacy)
|
|
@@ -23,16 +24,19 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
|
|
|
23
24
|
const origin = siteOrigin(settings, url);
|
|
24
25
|
const bases = new Set(Object.values(CONTENT.collections).map(c => c.base));
|
|
25
26
|
const header = nav.filter(n => n.area === 'header').sort((a, b) => a.order - b.order);
|
|
27
|
+
// the same list the website's header shows in that language: the owner's
|
|
28
|
+
// menu as written, or the automatic one with its words translated
|
|
29
|
+
const words = siteStrings(locale || settings.locale || 'en');
|
|
26
30
|
const menu = header.length ? header : [
|
|
27
|
-
{ label:
|
|
31
|
+
{ label: words.home, url: '/' },
|
|
28
32
|
...pages.filter(p => p.status === 'published' && !p.isHome).map(p => ({ label: p.title, url: `/${p.slug}` })),
|
|
29
33
|
...Object.values(CONTENT.collections).map(c => ({ label: c.plural, url: c.base })),
|
|
30
|
-
{ label:
|
|
34
|
+
{ label: words.links, url: '/links' },
|
|
31
35
|
];
|
|
32
36
|
return {
|
|
33
37
|
name: settings.siteTitle, description: settings.tagline,
|
|
34
38
|
logo: safeUrl(settings.siteLogo, origin), logoDark: safeUrl(settings.siteLogoDark, origin), icon: safeUrl(settings.siteFavicon, origin),
|
|
35
|
-
menu: menu.map(n => ({ label: n.label, href: safeUrl(n.url, origin, true) })),
|
|
39
|
+
menu: menu.map(n => ({ label: n.label, href: localizedUrl(safeUrl(n.url, origin, true), origin, locale) })),
|
|
36
40
|
languages: { default: I18N.defaultLocale || null, locales: I18N.locales.map(code => ({ code, name: localeName(code, true) })) },
|
|
37
41
|
features: {
|
|
38
42
|
// redirectFrom: old addresses the website 301s to url, so apps can follow them too,
|
package/src/lib/push/api.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { localeOnlyQuery, mobileLocale } from '../mobile/locale';
|
|
|
12
12
|
import { EXPO_PUSH_BASE, PUSH, requirePush } from './config';
|
|
13
13
|
import { expoOptions } from './expo';
|
|
14
14
|
import { knownTopics, pushTopics, sendNotification, siteOriginOf, type PushRunOptions } from './service';
|
|
15
|
-
import { PushAuto, type PushDevice } from './types';
|
|
15
|
+
import { MAX_CHANGE_RULES, PushAuto, type PushDevice } from './types';
|
|
16
16
|
|
|
17
17
|
export const pushToken = z.string().max(250).regex(/^Expo(?:nent)?PushToken\[[A-Za-z0-9_-]{8,200}\]$/, 'Use an Expo push token such as ExponentPushToken[…].');
|
|
18
18
|
const topicList = z.array(z.string().min(1).max(200).regex(/^[a-z0-9][a-z0-9:_-]*$/i, 'Use topic ids from GET /api/v1/push/topics.')).max(50);
|
|
@@ -155,20 +155,19 @@ export async function adminPushAction(repo: ContentRepository, input: z.infer<ty
|
|
|
155
155
|
const collections = new Set(Object.keys(CONTENT.collections));
|
|
156
156
|
const calendars = new Set((CALENDAR.enabled ? await repo.listCalendars() : []).filter((c) => c.visibility === 'public').map((c) => c.id));
|
|
157
157
|
const cals = (ids: string[]) => [...new Set(ids)].filter((id) => id === '*' || calendars.has(id));
|
|
158
|
-
const changes = input.auto.eventChanges;
|
|
159
|
-
const tags = changes?.tags ?? [];
|
|
160
158
|
const known = await knownTopics(repo);
|
|
161
159
|
const auto = {
|
|
162
160
|
newPost: { collections: [...new Set(input.auto.newPost.collections)].filter((k) => collections.has(k)) },
|
|
163
161
|
newEvent: { calendars: CALENDAR.enabled ? cals(input.auto.newEvent.calendars) : [] },
|
|
164
162
|
startingSoon: { calendars: CALENDAR.enabled ? cals(input.auto.startingSoon.calendars) : [], minutes: input.auto.startingSoon.minutes },
|
|
165
163
|
live: { enabled: input.auto.live?.enabled === true },
|
|
166
|
-
|
|
167
|
-
|
|
164
|
+
// a rule left without calendars is off: dropped
|
|
165
|
+
eventChanges: CALENDAR.enabled ? (input.auto.eventChanges ?? []).map((r) => ({
|
|
166
|
+
calendars: cals(r.calendars),
|
|
168
167
|
// one of each tag, whatever its case (matching ignores case too)
|
|
169
|
-
tags: tags.filter((t, i) => tags.findIndex((x) => x.toLowerCase() === t.toLowerCase()) === i),
|
|
170
|
-
topics: [...new Set(
|
|
171
|
-
}
|
|
168
|
+
tags: r.tags.filter((t, i) => r.tags.findIndex((x) => x.toLowerCase() === t.toLowerCase()) === i),
|
|
169
|
+
topics: [...new Set(r.topics)].filter((t) => known.has(t)),
|
|
170
|
+
})).filter((r) => r.calendars.length).slice(0, MAX_CHANGE_RULES) : [],
|
|
172
171
|
};
|
|
173
172
|
await repo.updateSettings({ push: { auto } });
|
|
174
173
|
return { auto };
|