@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,171 @@
|
|
|
1
|
+
// Words of the built-in event page (EventPage.astro) and the bits of it that
|
|
2
|
+
// show elsewhere: the "Cancelled" flag on event cards and the calendar's
|
|
3
|
+
// pop-up, "All day", and the repeat line ("Weekly on Sunday", rrule.ts
|
|
4
|
+
// describeRepeat). Plain strings with {date} and {place} slots (live/words.ts
|
|
5
|
+
// `fill`); the repeat line is built by functions, since word order differs
|
|
6
|
+
// per language. No server imports: the event editor shares the repeat line.
|
|
7
|
+
// The event's own words (title, button, fields) stay as written. Spanish
|
|
8
|
+
// matches the push notices ("Cancelado el …"). Other languages fall back to
|
|
9
|
+
// English.
|
|
10
|
+
export interface EventStrings {
|
|
11
|
+
/** the flag on a card or in the calendar's pop-up */
|
|
12
|
+
cancelled: string;
|
|
13
|
+
/** the whole event: in bold, then the note */
|
|
14
|
+
eventCancelled: string;
|
|
15
|
+
eventCancelledNote: string;
|
|
16
|
+
/** one date of a repeating event */
|
|
17
|
+
dateCancelled: string;
|
|
18
|
+
dateCancelledNote: string;
|
|
19
|
+
/** the side column's landmark label */
|
|
20
|
+
details: string;
|
|
21
|
+
joinOnline: string;
|
|
22
|
+
map: string;
|
|
23
|
+
directionsTo: string;
|
|
24
|
+
mapOf: string;
|
|
25
|
+
/** when the place has neither a name nor an address */
|
|
26
|
+
directionsHere: string;
|
|
27
|
+
mapHere: string;
|
|
28
|
+
/** under the repeat line: the date this page shows */
|
|
29
|
+
showing: string;
|
|
30
|
+
addToCalendar: string;
|
|
31
|
+
downloadIcs: string;
|
|
32
|
+
share: string;
|
|
33
|
+
linkCopied: string;
|
|
34
|
+
moreEvents: string;
|
|
35
|
+
fullCalendar: string;
|
|
36
|
+
allDay: string;
|
|
37
|
+
repeat: RepeatWords;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Weekdays are 0 = Sunday; `n` is the interval (every n days/weeks/months/years). */
|
|
41
|
+
export interface RepeatWords {
|
|
42
|
+
daily: (n: number) => string;
|
|
43
|
+
weekly: (n: number, days: number[]) => string;
|
|
44
|
+
/** `day`: the month day as written in the rule (BYMONTHDAY) */
|
|
45
|
+
monthlyOnDay: (n: number, day: string) => string;
|
|
46
|
+
/** `nth`: 1–5 or -1 (last); '' when the language can't word it */
|
|
47
|
+
monthlyOnWeekday: (n: number, nth: number, dow: number) => string;
|
|
48
|
+
yearly: (n: number) => string;
|
|
49
|
+
/** appended: ", 6 times", ", until Oct 3, 2026" */
|
|
50
|
+
times: (count: number) => string;
|
|
51
|
+
until: (date: string) => string;
|
|
52
|
+
/** a rule the editor doesn't offer */
|
|
53
|
+
other: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const list = (items: string[], and: string) =>
|
|
57
|
+
items.length <= 1 ? items.join('') : `${items.slice(0, -1).join(', ')} ${and} ${items[items.length - 1]}`;
|
|
58
|
+
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
59
|
+
|
|
60
|
+
const EN_DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
61
|
+
const EN_ORD: Record<number, string> = { 1: 'first', 2: 'second', 3: 'third', 4: 'fourth', 5: 'fifth', [-1]: 'last' };
|
|
62
|
+
const ES_DAYS = ['domingo', 'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado'];
|
|
63
|
+
// "todos los domingos", "los lunes": only sábado and domingo change
|
|
64
|
+
const ES_DAYS_PL = ['domingos', 'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábados'];
|
|
65
|
+
const ES_ORD: Record<number, string> = { 1: 'primer', 2: 'segundo', 3: 'tercer', 4: 'cuarto', 5: 'quinto', [-1]: 'último' };
|
|
66
|
+
const FR_DAYS = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
|
|
67
|
+
const FR_ORD: Record<number, string> = { 1: 'premier', 2: 'deuxième', 3: 'troisième', 4: 'quatrième', 5: 'cinquième', [-1]: 'dernier' };
|
|
68
|
+
const DE_DAYS = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
|
|
69
|
+
const DE_ORD: Record<number, string> = { 1: 'ersten', 2: 'zweiten', 3: 'dritten', 4: 'vierten', 5: 'fünften', [-1]: 'letzten' };
|
|
70
|
+
const PT_DAYS = ['domingo', 'segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira', 'sábado'];
|
|
71
|
+
// segunda- to sexta-feira are feminine, sábado and domingo masculine
|
|
72
|
+
const ptFem = (dow: number) => dow >= 1 && dow <= 5;
|
|
73
|
+
const PT_ORD: Record<number, [string, string]> = { 1: ['primeiro', 'primeira'], 2: ['segundo', 'segunda'], 3: ['terceiro', 'terceira'], 4: ['quarto', 'quarta'], 5: ['quinto', 'quinta'], [-1]: ['último', 'última'] };
|
|
74
|
+
|
|
75
|
+
const STRINGS: Record<string, EventStrings> = {
|
|
76
|
+
en: {
|
|
77
|
+
cancelled: 'Cancelled', eventCancelled: 'This event is cancelled.', eventCancelledNote: 'It stays listed so nobody is left wondering.',
|
|
78
|
+
dateCancelled: 'Cancelled on {date}.', dateCancelledNote: 'Other dates go ahead as usual.',
|
|
79
|
+
details: 'Event details', joinOnline: 'Join online', map: 'Map', directionsTo: 'Directions to {place}', mapOf: 'Map of {place}',
|
|
80
|
+
directionsHere: 'Directions to the location', mapHere: 'Map of the location', showing: 'Showing {date}',
|
|
81
|
+
addToCalendar: 'Add to calendar', downloadIcs: 'Download .ics', share: 'Share', linkCopied: 'Link copied',
|
|
82
|
+
moreEvents: 'More events', fullCalendar: 'See the full calendar →', allDay: 'All day',
|
|
83
|
+
repeat: {
|
|
84
|
+
daily: (n) => (n === 1 ? 'Daily' : `Every ${n} days`),
|
|
85
|
+
weekly: (n, days) => `${n === 1 ? 'Weekly' : `Every ${n} weeks`} on ${list(days.map((d) => EN_DAYS[d]), 'and')}`,
|
|
86
|
+
monthlyOnDay: (n, day) => `${n === 1 ? 'Monthly' : `Every ${n} months`} on day ${day}`,
|
|
87
|
+
monthlyOnWeekday: (n, nth, dow) => `${n === 1 ? 'Monthly' : `Every ${n} months`} on the ${EN_ORD[nth] ?? `${nth}th`} ${EN_DAYS[dow]}`,
|
|
88
|
+
yearly: (n) => (n === 1 ? 'Yearly' : `Every ${n} years`),
|
|
89
|
+
times: (c) => (c === 1 ? ', once' : `, ${c} times`),
|
|
90
|
+
until: (d) => `, until ${d}`,
|
|
91
|
+
other: 'Repeats',
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
es: {
|
|
95
|
+
cancelled: 'Cancelado', eventCancelled: 'Este evento está cancelado.', eventCancelledNote: 'Lo dejamos en la lista para que nadie se quede con la duda.',
|
|
96
|
+
dateCancelled: 'Cancelado el {date}.', dateCancelledNote: 'Las demás fechas siguen como siempre.',
|
|
97
|
+
details: 'Detalles del evento', joinOnline: 'Unirse en línea', map: 'Mapa', directionsTo: 'Cómo llegar a {place}', mapOf: 'Mapa de {place}',
|
|
98
|
+
directionsHere: 'Cómo llegar al lugar', mapHere: 'Mapa del lugar', showing: 'Mostrando el {date}',
|
|
99
|
+
addToCalendar: 'Agregar al calendario', downloadIcs: 'Descargar .ics', share: 'Compartir', linkCopied: 'Enlace copiado',
|
|
100
|
+
moreEvents: 'Más eventos', fullCalendar: 'Ver el calendario completo →', allDay: 'Todo el día',
|
|
101
|
+
repeat: {
|
|
102
|
+
daily: (n) => (n === 1 ? 'Todos los días' : `Cada ${n} días`),
|
|
103
|
+
weekly: (n, days) => `${n === 1 ? 'Todos los' : `Cada ${n} semanas, los`} ${list(days.map((d) => ES_DAYS_PL[d]), 'y')}`,
|
|
104
|
+
monthlyOnDay: (n, day) => `El día ${day}${n === 1 ? ' de cada mes' : `, cada ${n} meses`}`,
|
|
105
|
+
monthlyOnWeekday: (n, nth, dow) => (ES_ORD[nth] ? `El ${ES_ORD[nth]} ${ES_DAYS[dow]}${n === 1 ? ' de cada mes' : `, cada ${n} meses`}` : ''),
|
|
106
|
+
yearly: (n) => (n === 1 ? 'Cada año' : `Cada ${n} años`),
|
|
107
|
+
times: (c) => (c === 1 ? ', una vez' : `, ${c} veces`),
|
|
108
|
+
until: (d) => `, hasta el ${d}`,
|
|
109
|
+
other: 'Se repite',
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
fr: {
|
|
113
|
+
cancelled: 'Annulé', eventCancelled: 'Cet événement est annulé.', eventCancelledNote: 'Il reste affiché pour que personne ne se pose de questions.',
|
|
114
|
+
dateCancelled: 'Annulé le {date}.', dateCancelledNote: 'Les autres dates sont maintenues.',
|
|
115
|
+
details: 'Détails de l’événement', joinOnline: 'Rejoindre en ligne', map: 'Carte', directionsTo: 'Itinéraire vers {place}', mapOf: 'Carte de {place}',
|
|
116
|
+
directionsHere: 'Itinéraire vers le lieu', mapHere: 'Carte du lieu', showing: 'Date affichée : {date}',
|
|
117
|
+
addToCalendar: 'Ajouter au calendrier', downloadIcs: 'Télécharger le .ics', share: 'Partager', linkCopied: 'Lien copié',
|
|
118
|
+
moreEvents: 'Autres événements', fullCalendar: 'Voir tout le calendrier →', allDay: 'Toute la journée',
|
|
119
|
+
repeat: {
|
|
120
|
+
daily: (n) => (n === 1 ? 'Tous les jours' : `Tous les ${n} jours`),
|
|
121
|
+
weekly: (n, days) => (n === 1 ? `Tous les ${list(days.map((d) => `${FR_DAYS[d]}s`), 'et')}` : `Toutes les ${n} semaines, ${list(days.map((d) => `le ${FR_DAYS[d]}`), 'et')}`),
|
|
122
|
+
monthlyOnDay: (n, day) => `Le ${day === '1' ? '1er' : day}${n === 1 ? ' de chaque mois' : `, tous les ${n} mois`}`,
|
|
123
|
+
monthlyOnWeekday: (n, nth, dow) => (FR_ORD[nth] ? `Le ${FR_ORD[nth]} ${FR_DAYS[dow]}${n === 1 ? ' de chaque mois' : `, tous les ${n} mois`}` : ''),
|
|
124
|
+
yearly: (n) => (n === 1 ? 'Chaque année' : `Tous les ${n} ans`),
|
|
125
|
+
times: (c) => (c === 1 ? ', une fois' : `, ${c} fois`),
|
|
126
|
+
until: (d) => `, jusqu’au ${d}`,
|
|
127
|
+
other: 'Récurrent',
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
de: {
|
|
131
|
+
cancelled: 'Abgesagt', eventCancelled: 'Diese Veranstaltung ist abgesagt.', eventCancelledNote: 'Sie bleibt sichtbar, damit sich niemand wundert.',
|
|
132
|
+
dateCancelled: 'Abgesagt am {date}.', dateCancelledNote: 'Die anderen Termine finden wie gewohnt statt.',
|
|
133
|
+
details: 'Veranstaltungsdetails', joinOnline: 'Online teilnehmen', map: 'Karte', directionsTo: 'Route zu {place}', mapOf: 'Karte von {place}',
|
|
134
|
+
directionsHere: 'Route zum Ort', mapHere: 'Karte des Ortes', showing: 'Angezeigt: {date}',
|
|
135
|
+
addToCalendar: 'Zum Kalender hinzufügen', downloadIcs: '.ics herunterladen', share: 'Teilen', linkCopied: 'Link kopiert',
|
|
136
|
+
moreEvents: 'Weitere Veranstaltungen', fullCalendar: 'Ganzen Kalender ansehen →', allDay: 'Ganztägig',
|
|
137
|
+
repeat: {
|
|
138
|
+
daily: (n) => (n === 1 ? 'Täglich' : `Alle ${n} Tage`),
|
|
139
|
+
weekly: (n, days) => `${n === 1 ? 'Jeden' : `Alle ${n} Wochen am`} ${list(days.map((d) => DE_DAYS[d]), 'und')}`,
|
|
140
|
+
monthlyOnDay: (n, day) => `${n === 1 ? 'Monatlich' : `Alle ${n} Monate`} am ${day}.`,
|
|
141
|
+
monthlyOnWeekday: (n, nth, dow) => (DE_ORD[nth] ? (n === 1 ? `Jeden ${DE_ORD[nth]} ${DE_DAYS[dow]} im Monat` : `Alle ${n} Monate am ${DE_ORD[nth]} ${DE_DAYS[dow]}`) : ''),
|
|
142
|
+
yearly: (n) => (n === 1 ? 'Jährlich' : `Alle ${n} Jahre`),
|
|
143
|
+
times: (c) => (c === 1 ? ', einmal' : `, ${c}-mal`),
|
|
144
|
+
until: (d) => `, bis zum ${d}`,
|
|
145
|
+
other: 'Wiederkehrend',
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
pt: {
|
|
149
|
+
cancelled: 'Cancelado', eventCancelled: 'Este evento foi cancelado.', eventCancelledNote: 'Ele continua na lista para que ninguém fique na dúvida.',
|
|
150
|
+
dateCancelled: 'Cancelado em {date}.', dateCancelledNote: 'As outras datas continuam normalmente.',
|
|
151
|
+
details: 'Detalhes do evento', joinOnline: 'Participar on-line', map: 'Mapa', directionsTo: 'Como chegar a {place}', mapOf: 'Mapa de {place}',
|
|
152
|
+
directionsHere: 'Como chegar ao local', mapHere: 'Mapa do local', showing: 'Exibindo {date}',
|
|
153
|
+
addToCalendar: 'Adicionar ao calendário', downloadIcs: 'Baixar .ics', share: 'Compartilhar', linkCopied: 'Link copiado',
|
|
154
|
+
moreEvents: 'Mais eventos', fullCalendar: 'Ver o calendário completo →', allDay: 'Dia inteiro',
|
|
155
|
+
repeat: {
|
|
156
|
+
daily: (n) => (n === 1 ? 'Todos os dias' : `A cada ${n} dias`),
|
|
157
|
+
weekly: (n, days) => (n === 1
|
|
158
|
+
? cap(list(days.map((d) => `${ptFem(d) ? 'toda' : 'todo'} ${PT_DAYS[d]}`), 'e'))
|
|
159
|
+
: `A cada ${n} semanas, ${list(days.map((d) => `${ptFem(d) ? 'na' : 'no'} ${PT_DAYS[d]}`), 'e')}`),
|
|
160
|
+
monthlyOnDay: (n, day) => `No dia ${day}${n === 1 ? ' de cada mês' : `, a cada ${n} meses`}`,
|
|
161
|
+
monthlyOnWeekday: (n, nth, dow) => (PT_ORD[nth] ? `${ptFem(dow) ? 'Na' : 'No'} ${PT_ORD[nth][ptFem(dow) ? 1 : 0]} ${PT_DAYS[dow]}${n === 1 ? ' de cada mês' : `, a cada ${n} meses`}` : ''),
|
|
162
|
+
yearly: (n) => (n === 1 ? 'Todo ano' : `A cada ${n} anos`),
|
|
163
|
+
times: (c) => (c === 1 ? ', uma vez' : `, ${c} vezes`),
|
|
164
|
+
until: (d) => `, até ${d}`,
|
|
165
|
+
other: 'Repete',
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const eventStrings = (locale: string | null | undefined): EventStrings =>
|
|
171
|
+
STRINGS[(locale || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { CalEvent, Calendar, EventLocation, Settings } from '../schema';
|
|
5
5
|
import { expandVEvents, tzParts, zonedToUtc, type CalendarWindow, type IcsTime, type VEvent } from './ics';
|
|
6
6
|
import { cleanRrule } from './rrule';
|
|
7
|
+
import { eventStrings } from './event-words';
|
|
7
8
|
|
|
8
9
|
const DAY = 86_400_000;
|
|
9
10
|
|
|
@@ -153,6 +154,17 @@ export function expandEvents(events: CalEvent[], settings: Pick<Settings, 'timez
|
|
|
153
154
|
return out.sort((a, b) => a.start - b.start || a.event.title.localeCompare(b.event.title));
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
// When RSVPs to one date close (UTC ms), or null when they stay open until it
|
|
158
|
+
// ends: the event's closesAt (wall time in its zone, one instant for every
|
|
159
|
+
// date), else closeBefore minutes before this date's start. A deadline at or
|
|
160
|
+
// after the end isn't one.
|
|
161
|
+
export function rsvpDeadline(o: Pick<Occurrence, 'event' | 'start' | 'end'>, settings: Pick<Settings, 'timezone'>): number | null {
|
|
162
|
+
const { closesAt, closeBefore } = o.event.rsvp;
|
|
163
|
+
const w = parseWall(closesAt);
|
|
164
|
+
const at = w && !w.dateOnly ? wallToMs(w, eventTimeZone(o.event, settings)) : closeBefore > 0 ? o.start - closeBefore * 60_000 : null;
|
|
165
|
+
return at !== null && at < o.end ? at : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
156
168
|
// The occurrence of a series with this key (null when the series has no
|
|
157
169
|
// such date: never generated, skipped by exdates, or the series moved)
|
|
158
170
|
export function findOccurrence(e: CalEvent, key: string, settings: Pick<Settings, 'timezone'>, displayTz = settings.timezone || 'UTC'): Occurrence | null {
|
|
@@ -232,8 +244,10 @@ export function formatOccurrence(
|
|
|
232
244
|
let time: string;
|
|
233
245
|
let range: string;
|
|
234
246
|
if (o.allDay) {
|
|
235
|
-
|
|
236
|
-
|
|
247
|
+
// "All day" in the dates' language
|
|
248
|
+
const allDay = eventStrings(locale).allDay;
|
|
249
|
+
time = o.days > 1 ? `${shortFmt.format(o.start)} – ${shortFmt.format(o.end - DAY)}` : allDay;
|
|
250
|
+
range = o.days > 1 ? time : `${shortFmt.format(o.start)} · ${allDay}`;
|
|
237
251
|
} else {
|
|
238
252
|
time = `${timeFmt.format(o.start)} – ${timeFmt.format(o.end)}${zone ? ` ${zone}` : ''}`;
|
|
239
253
|
range = `${shortFmt.format(o.start)} · ${timeFmt.format(o.start)}`;
|
|
@@ -11,6 +11,7 @@ import { siteLocale } from '../dates';
|
|
|
11
11
|
import { getCalendarEvents, tzParts, zonedToUtc } from './ics';
|
|
12
12
|
import { calendarPageUrl } from './src';
|
|
13
13
|
import { collectViewEvents, type ViewEvent } from './data';
|
|
14
|
+
import { eventStrings } from './event-words';
|
|
14
15
|
|
|
15
16
|
// Card sources are ICS feed URLs or native site calendars:
|
|
16
17
|
// 'acms:all' every public calendar (+ uncategorized events)
|
|
@@ -290,7 +291,8 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
|
|
|
290
291
|
min: [minY, minM],
|
|
291
292
|
max: [maxY, maxM],
|
|
292
293
|
months, monthsShort, dows, events, ws,
|
|
293
|
-
|
|
294
|
+
// the pop-up's flag in the dates' language, like event cards
|
|
295
|
+
details: 'Details', cancelled: eventStrings(locale).cancelled,
|
|
294
296
|
};
|
|
295
297
|
const json = JSON.stringify(payload).replace(/</g, '\\u003c');
|
|
296
298
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// The editor offers the patterns community calendars actually use (daily,
|
|
4
4
|
// weekly on chosen days, monthly by date or by "second Tuesday", yearly) plus
|
|
5
5
|
// an end condition; anything else round-trips as a raw custom RRULE.
|
|
6
|
+
import { eventStrings } from './event-words';
|
|
6
7
|
|
|
7
8
|
export type RepeatFreq = 'none' | 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY' | 'custom';
|
|
8
9
|
|
|
@@ -18,8 +19,6 @@ export interface RepeatParts {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export const WEEKDAY_CODES = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const;
|
|
21
|
-
const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
22
|
-
const ORDINALS: Record<number, string> = { 1: 'first', 2: 'second', 3: 'third', 4: 'fourth', 5: 'fifth', [-1]: 'last' };
|
|
23
22
|
const ALLOWED_KEYS = new Set(['FREQ', 'INTERVAL', 'COUNT', 'UNTIL', 'BYDAY', 'BYMONTHDAY', 'BYMONTH', 'WKST']);
|
|
24
23
|
|
|
25
24
|
export function rruleMap(rrule: string): Record<string, string> {
|
|
@@ -105,45 +104,42 @@ export function buildRepeat(parts: RepeatParts, start: string): string {
|
|
|
105
104
|
return out.join(';');
|
|
106
105
|
}
|
|
107
106
|
|
|
108
|
-
const joinList = (items: string[]) =>
|
|
109
|
-
items.length <= 1 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
|
|
110
|
-
|
|
111
107
|
// "Weekly on Tuesday and Thursday", "Monthly on the first Sunday", "Every 2
|
|
112
|
-
// weeks on Monday, 6 times"
|
|
113
|
-
//
|
|
114
|
-
|
|
108
|
+
// weeks on Monday, 6 times", in `locale` ("Todos los martes y jueves"; words
|
|
109
|
+
// in event-words.ts, English by default). Unparseable rules fall back to a
|
|
110
|
+
// generic label.
|
|
111
|
+
export function describeRepeat(rrule: string, start: string, formatDate?: (ymd: string) => string, locale?: string): string {
|
|
115
112
|
if (!rrule) return '';
|
|
113
|
+
const w = eventStrings(locale).repeat;
|
|
116
114
|
const p = rruleMap(rrule);
|
|
117
115
|
const n = Math.max(1, Number(p.INTERVAL) || 1);
|
|
118
116
|
const wd = weekdayOf(start);
|
|
119
117
|
let text: string;
|
|
120
118
|
switch (p.FREQ) {
|
|
121
119
|
case 'DAILY':
|
|
122
|
-
text = n
|
|
120
|
+
text = w.daily(n);
|
|
123
121
|
break;
|
|
124
122
|
case 'WEEKLY': {
|
|
125
123
|
const codes = (p.BYDAY ?? WEEKDAY_CODES[wd.dow]).split(',');
|
|
126
|
-
|
|
127
|
-
text = `${n === 1 ? 'Weekly' : `Every ${n} weeks`} on ${joinList(names)}`;
|
|
124
|
+
text = w.weekly(n, WEEKDAY_CODES.map((c, i) => (codes.includes(c) ? i : -1)).filter((i) => i >= 0));
|
|
128
125
|
break;
|
|
129
126
|
}
|
|
130
127
|
case 'MONTHLY': {
|
|
131
|
-
const lead = n === 1 ? 'Monthly' : `Every ${n} months`;
|
|
132
128
|
const m = (p.BYDAY ?? '').match(/^(-?\d)(SU|MO|TU|WE|TH|FR|SA)$/);
|
|
133
|
-
|
|
134
|
-
else text = `${lead} on day ${p.BYMONTHDAY || wd.day}`;
|
|
129
|
+
text = m ? w.monthlyOnWeekday(n, Number(m[1]), WEEKDAY_CODES.indexOf(m[2] as (typeof WEEKDAY_CODES)[number])) : w.monthlyOnDay(n, p.BYMONTHDAY || String(wd.day));
|
|
135
130
|
break;
|
|
136
131
|
}
|
|
137
132
|
case 'YEARLY':
|
|
138
|
-
text = n
|
|
133
|
+
text = w.yearly(n);
|
|
139
134
|
break;
|
|
140
135
|
default:
|
|
141
|
-
return
|
|
136
|
+
return w.other;
|
|
142
137
|
}
|
|
143
|
-
if (
|
|
138
|
+
if (!text) return w.other;
|
|
139
|
+
if (p.COUNT) text += w.times(Number(p.COUNT));
|
|
144
140
|
else if (p.UNTIL) {
|
|
145
141
|
const ymd = `${p.UNTIL.slice(0, 4)}-${p.UNTIL.slice(4, 6)}-${p.UNTIL.slice(6, 8)}`;
|
|
146
|
-
text +=
|
|
142
|
+
text += w.until(formatDate ? formatDate(ymd) : ymd);
|
|
147
143
|
}
|
|
148
144
|
return text;
|
|
149
145
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
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} and {
|
|
5
|
-
// `fill`); of a pair (`spotLeft`/`spotsLeft`), the first is for 1 and the
|
|
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
|
|
6
6
|
// second for any other number. The event's own form title, questions and
|
|
7
7
|
// button keep their words. Other languages fall back to English.
|
|
8
8
|
export interface RsvpStrings {
|
|
@@ -21,6 +21,12 @@ export interface RsvpStrings {
|
|
|
21
21
|
requestSeat: string;
|
|
22
22
|
full: string;
|
|
23
23
|
closed: string;
|
|
24
|
+
/** the event's RSVP deadline ({date} and {time}: deadlineParts): before, and once it passed (under `closed`) */
|
|
25
|
+
closesOn: string;
|
|
26
|
+
closedOn: string;
|
|
27
|
+
/** the address already has an RSVP for this date (nothing else about it is shown) */
|
|
28
|
+
duplicate: string;
|
|
29
|
+
duplicateNote: string;
|
|
24
30
|
saveError: string;
|
|
25
31
|
/** the RSVP's own page */
|
|
26
32
|
booked: string;
|
|
@@ -53,6 +59,8 @@ const STRINGS: Record<string, RsvpStrings> = {
|
|
|
53
59
|
personGoing: '{count} going', peopleGoing: '{count} going', spotLeft: '{count} spot left', spotsLeft: '{count} spots left',
|
|
54
60
|
saveSeat: 'Save my seat', nameField: 'Your name', emailField: 'Email', seatsField: 'Seats', confirmSeat: 'Confirm my seat', requestSeat: 'Request a seat',
|
|
55
61
|
full: 'This event is full.', closed: 'RSVPs are closed.', saveError: 'Couldn’t save your RSVP. Please try again.',
|
|
62
|
+
closesOn: 'RSVPs close {date} at {time}', closedOn: 'They closed on {date}. Questions? Contact us.',
|
|
63
|
+
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.',
|
|
56
64
|
booked: 'You’re booked', yours: 'Your booking', requestSent: 'Request sent', awaitingApproval: 'Waiting for approval', notAvailable: 'Not available', cancelledTitle: 'Booking cancelled',
|
|
57
65
|
emailWhenApproved: 'We’ll email {email} once it’s approved.', confirmationOnItsWay: 'A confirmation is on its way to {email}.',
|
|
58
66
|
declined: 'Sorry, the organiser couldn’t confirm it.', cancelledText: 'This booking is cancelled.',
|
|
@@ -65,6 +73,8 @@ const STRINGS: Record<string, RsvpStrings> = {
|
|
|
65
73
|
personGoing: '{count} va', peopleGoing: '{count} van', spotLeft: '{count} lugar disponible', spotsLeft: '{count} lugares disponibles',
|
|
66
74
|
saveSeat: 'Apartar mi lugar', nameField: 'Tu nombre', emailField: 'Correo electrónico', seatsField: 'Lugares', confirmSeat: 'Confirmar mi lugar', requestSeat: 'Solicitar un lugar',
|
|
67
75
|
full: 'Este evento está lleno.', closed: 'Las confirmaciones están cerradas.', saveError: 'No se pudo guardar tu confirmación. Inténtalo de nuevo.',
|
|
76
|
+
closesOn: 'Las confirmaciones cierran el {date}, {time}', closedOn: 'Cerraron el {date}. ¿Preguntas? Contáctanos.',
|
|
77
|
+
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í.',
|
|
68
78
|
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',
|
|
69
79
|
emailWhenApproved: 'Te escribiremos a {email} cuando se apruebe.', confirmationOnItsWay: 'Te enviamos la confirmación a {email}.',
|
|
70
80
|
declined: 'Lo sentimos, el organizador no pudo confirmarla.', cancelledText: 'Esta confirmación se canceló.',
|
|
@@ -77,6 +87,8 @@ const STRINGS: Record<string, RsvpStrings> = {
|
|
|
77
87
|
personGoing: '{count} participant', peopleGoing: '{count} participants', spotLeft: '{count} place restante', spotsLeft: '{count} places restantes',
|
|
78
88
|
saveSeat: 'Réserver ma place', nameField: 'Votre nom', emailField: 'E-mail', seatsField: 'Places', confirmSeat: 'Confirmer ma place', requestSeat: 'Demander une place',
|
|
79
89
|
full: 'Cet événement est complet.', closed: 'Les inscriptions sont closes.', saveError: 'Impossible d’enregistrer votre inscription. Veuillez réessayer.',
|
|
90
|
+
closesOn: 'Les inscriptions ferment le {date} à {time}', closedOn: 'Elles ont fermé le {date}. Des questions ? Contactez-nous.',
|
|
91
|
+
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.',
|
|
80
92
|
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',
|
|
81
93
|
emailWhenApproved: 'Nous écrirons à {email} dès qu’elle sera validée.', confirmationOnItsWay: 'Une confirmation est en route vers {email}.',
|
|
82
94
|
declined: 'Désolés, l’organisateur n’a pas pu la confirmer.', cancelledText: 'Cette inscription est annulée.',
|
|
@@ -89,6 +101,8 @@ const STRINGS: Record<string, RsvpStrings> = {
|
|
|
89
101
|
personGoing: '{count} Zusage', peopleGoing: '{count} Zusagen', spotLeft: '{count} Platz frei', spotsLeft: '{count} Plätze frei',
|
|
90
102
|
saveSeat: 'Platz reservieren', nameField: 'Ihr Name', emailField: 'E-Mail', seatsField: 'Plätze', confirmSeat: 'Platz bestätigen', requestSeat: 'Platz anfragen',
|
|
91
103
|
full: 'Diese Veranstaltung ist ausgebucht.', closed: 'Anmeldungen sind geschlossen.', saveError: 'Ihre Anmeldung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.',
|
|
104
|
+
closesOn: 'Anmeldung bis {date}, {time}', closedOn: 'Die Anmeldung endete am {date}. Fragen? Kontaktieren Sie uns.',
|
|
105
|
+
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.',
|
|
92
106
|
booked: 'Ihr Platz ist reserviert', yours: 'Ihre Anmeldung', requestSent: 'Anfrage gesendet', awaitingApproval: 'Wartet auf Freigabe', notAvailable: 'Nicht bestätigt', cancelledTitle: 'Anmeldung storniert',
|
|
93
107
|
emailWhenApproved: 'Wir schreiben an {email}, sobald sie freigegeben ist.', confirmationOnItsWay: 'Eine Bestätigung ist unterwegs an {email}.',
|
|
94
108
|
declined: 'Leider konnte der Veranstalter sie nicht bestätigen.', cancelledText: 'Diese Anmeldung ist storniert.',
|
|
@@ -101,6 +115,8 @@ const STRINGS: Record<string, RsvpStrings> = {
|
|
|
101
115
|
personGoing: '{count} confirmado', peopleGoing: '{count} confirmados', spotLeft: '{count} vaga disponível', spotsLeft: '{count} vagas disponíveis',
|
|
102
116
|
saveSeat: 'Reservar minha vaga', nameField: 'Seu nome', emailField: 'E-mail', seatsField: 'Vagas', confirmSeat: 'Confirmar minha vaga', requestSeat: 'Solicitar uma vaga',
|
|
103
117
|
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
|
+
closesOn: 'As inscrições se encerram em {date}, {time}', closedOn: 'Elas se encerraram em {date}. Dúvidas? Fale com a gente.',
|
|
119
|
+
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á.',
|
|
104
120
|
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',
|
|
105
121
|
emailWhenApproved: 'Vamos escrever para {email} assim que for aprovada.', confirmationOnItsWay: 'Uma confirmação está a caminho de {email}.',
|
|
106
122
|
declined: 'Desculpe, o organizador não pôde confirmá-la.', cancelledText: 'Esta inscrição foi cancelada.',
|
|
@@ -119,3 +135,16 @@ export function aroundCount(count: number, one: string, other: string): [string,
|
|
|
119
135
|
const [before, after = ''] = (count === 1 ? one : other).split('{count}');
|
|
120
136
|
return [before, after];
|
|
121
137
|
}
|
|
138
|
+
|
|
139
|
+
/** A deadline's date and time in the event's zone for closesOn/closedOn: { date: 'Fri, Oct 24', time: '9 PM' } (minutes only when it has some). */
|
|
140
|
+
export function deadlineParts(at: number, tz: string, locale: string): { date: string; time: string } {
|
|
141
|
+
const f = (o: Intl.DateTimeFormatOptions) => {
|
|
142
|
+
try { return new Intl.DateTimeFormat(locale, { ...o, timeZone: tz }); }
|
|
143
|
+
catch { return new Intl.DateTimeFormat('en-US', { ...o, timeZone: 'UTC' }); }
|
|
144
|
+
};
|
|
145
|
+
const time = f({ hour: 'numeric', minute: '2-digit' });
|
|
146
|
+
const hour = f({ hour: 'numeric' }).format(at);
|
|
147
|
+
// "9 PM", "21 Uhr"; a bare "21" (Portuguese) keeps its minutes
|
|
148
|
+
const whole = time.formatToParts(at).find((p) => p.type === 'minute')?.value === '00' && /\D/.test(hour);
|
|
149
|
+
return { date: f({ weekday: 'short', month: 'short', day: 'numeric' }).format(at), time: whole ? hour : time.format(at) };
|
|
150
|
+
}
|
|
@@ -6,7 +6,7 @@ import { ApiError } from './api';
|
|
|
6
6
|
import { deniedMessage } from './permissions/catalog';
|
|
7
7
|
import { CONTENT, collectionUrl } from './content';
|
|
8
8
|
import { CALENDAR } from './calendar/config';
|
|
9
|
-
import { isValidTimeZone, normalizeEventTimes } from './calendar/events';
|
|
9
|
+
import { formatWall, isValidTimeZone, normalizeEventTimes, parseWall } from './calendar/events';
|
|
10
10
|
import { eventChanged } from './calendar/hooks';
|
|
11
11
|
import { normalizePageLocale } from './i18n';
|
|
12
12
|
import { siteOrigin } from './seo';
|
|
@@ -37,10 +37,12 @@ export type PageSave = Omit<Parameters<ContentRepository['updatePage']>[1], 'blo
|
|
|
37
37
|
// location (see mergeLocationInput) — older clients send name/address/url only
|
|
38
38
|
// cancelledDates is left out: single dates are cancelled at once through
|
|
39
39
|
// cancelOccurrence/restoreOccurrence (lib/calendar/schedule.ts), never staged
|
|
40
|
-
|
|
40
|
+
// rsvp may leave out the deadline (older clients): the current one stays
|
|
41
|
+
export type EventSave = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt' | 'location' | 'cancelledDates' | 'rsvp'>> & {
|
|
41
42
|
location?: Partial<EventLocation>;
|
|
43
|
+
rsvp?: Omit<CalEvent['rsvp'], 'closesAt' | 'closeBefore'> & Partial<Pick<CalEvent['rsvp'], 'closesAt' | 'closeBefore'>>;
|
|
42
44
|
};
|
|
43
|
-
type EventClean = Omit<EventSave, 'location'> & { location?: EventLocation };
|
|
45
|
+
type EventClean = Omit<EventSave, 'location' | 'rsvp'> & { location?: EventLocation; rsvp?: CalEvent['rsvp'] };
|
|
44
46
|
|
|
45
47
|
const live = (status: string) => status === 'published' || status === 'scheduled';
|
|
46
48
|
/** publishing, scheduling, unpublishing or applying staged edits needs Publish content */
|
|
@@ -130,18 +132,28 @@ export async function applyPageUpdate(repo: ContentRepository, id: string, hooks
|
|
|
130
132
|
// Validate + normalize an event save. Times are checked as a PAIR against the
|
|
131
133
|
// merged (staged) state so a half-edited range can't slip through.
|
|
132
134
|
export async function cleanEventInput(repo: ContentRepository, data: EventSave, before: CalEvent): Promise<EventClean> {
|
|
133
|
-
const { location, ...rest } = data;
|
|
135
|
+
const { location, rsvp, ...rest } = data;
|
|
134
136
|
const out: EventClean = { ...rest };
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const previous = mergeEventDraft(before).rsvp
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
137
|
+
if (rsvp) {
|
|
138
|
+
// 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
|
+
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 };
|
|
142
|
+
if (!Object.hasOwn(rsvp, 'form') && previous.form) next.form = previous.form;
|
|
143
|
+
if (next.form !== undefined) {
|
|
144
|
+
const form = FormDefinition.safeParse(next.form);
|
|
145
|
+
if (!form.success) throw new ApiError(400, form.error.issues[0]?.message || 'Invalid registration form.');
|
|
146
|
+
next.form = form.data;
|
|
147
|
+
}
|
|
148
|
+
if (next.closesAt !== null) {
|
|
149
|
+
const w = parseWall(next.closesAt);
|
|
150
|
+
if (!w || w.dateOnly) throw new ApiError(400, 'The RSVP deadline must be a date and time (YYYY-MM-DDTHH:mm).');
|
|
151
|
+
next.closesAt = formatWall(w);
|
|
152
|
+
}
|
|
153
|
+
if (!Number.isInteger(next.closeBefore) || next.closeBefore < 0 || next.closeBefore > 60 * 1440) {
|
|
154
|
+
throw new ApiError(400, 'The RSVP deadline can be at most 60 days before each date (closeBefore: 0–86400 minutes).');
|
|
155
|
+
}
|
|
156
|
+
out.rsvp = next;
|
|
145
157
|
}
|
|
146
158
|
if (location !== undefined) out.location = mergeLocationInput(mergeEventDraft(before).location, location);
|
|
147
159
|
if (out.slug !== undefined) {
|
package/src/lib/drafts.ts
CHANGED
|
@@ -52,6 +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
57
|
return out;
|
|
56
58
|
}
|
|
57
59
|
export function pickPageDraft(draft: Record<string, unknown>): Partial<Pick<Page, PageDraftKey>> {
|
|
@@ -160,7 +160,9 @@ 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
|
-
|
|
163
|
+
// closesAt: wall time in the event's zone ('YYYY-MM-DDTHH:mm'); closeBefore: minutes before each date. Left out = kept
|
|
164
|
+
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(),
|
|
164
166
|
}).partial().strict();
|
|
165
167
|
export const SaveInput = z.object({ revision: z.string().max(60), fields: z.record(z.unknown()) }).strict();
|
|
166
168
|
|
|
@@ -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, 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, form: FormDefinition.optional() }),
|
|
51
51
|
}).nullable(),
|
|
52
52
|
languages: z.array(str),
|
|
53
53
|
});
|