@acms-subir/core 0.3.36 → 0.3.38

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 (35) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/cli/migrations.generated.mjs +5 -0
  3. package/docs/theme-authoring.md +1 -1
  4. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +54 -47
  5. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +54 -47
  6. package/mobile-template/recipes/rsvp/recipe.json +2 -1
  7. package/package.json +1 -1
  8. package/src/components/EventPage.astro +50 -30
  9. package/src/lib/booking/emails.ts +59 -44
  10. package/src/lib/booking/service.ts +22 -11
  11. package/src/lib/calendar/cards.ts +5 -3
  12. package/src/lib/calendar/event-words.ts +171 -0
  13. package/src/lib/calendar/events.ts +5 -2
  14. package/src/lib/calendar/render.ts +3 -1
  15. package/src/lib/calendar/rrule.ts +14 -18
  16. package/src/lib/calendar/rsvp-words.ts +121 -0
  17. package/src/lib/email/booking-words.ts +203 -0
  18. package/src/lib/forms/events.ts +4 -2
  19. package/src/lib/mobile/bookings.ts +9 -2
  20. package/src/lib/mobile/calendar.ts +6 -4
  21. package/src/lib/mobile/guest.ts +3 -2
  22. package/src/lib/mobile/manage.ts +7 -5
  23. package/src/lib/mobile/rsvp.ts +9 -1
  24. package/src/lib/mobile/schemas.ts +2 -0
  25. package/src/lib/mobile/search.ts +1 -1
  26. package/src/lib/mobile/site.ts +7 -3
  27. package/src/lib/push/service.ts +10 -0
  28. package/src/lib/repo/migrations.ts +5 -0
  29. package/src/lib/repo/sql.ts +4 -4
  30. package/src/lib/repo/types.ts +2 -1
  31. package/src/lib/schema.ts +3 -0
  32. package/src/pages/book/[resource]/[link].astro +8 -4
  33. package/src/pages/book/manage/[token].astro +48 -29
  34. package/src/pages/events/[slug].astro +8 -5
  35. package/src/theme-sdk/index.ts +3 -0
@@ -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
 
@@ -232,8 +233,10 @@ export function formatOccurrence(
232
233
  let time: string;
233
234
  let range: string;
234
235
  if (o.allDay) {
235
- time = o.days > 1 ? `${shortFmt.format(o.start)} – ${shortFmt.format(o.end - DAY)}` : 'All day';
236
- range = o.days > 1 ? time : `${shortFmt.format(o.start)} · All day`;
236
+ // "All day" in the dates' language
237
+ const allDay = eventStrings(locale).allDay;
238
+ time = o.days > 1 ? `${shortFmt.format(o.start)} – ${shortFmt.format(o.end - DAY)}` : allDay;
239
+ range = o.days > 1 ? time : `${shortFmt.format(o.start)} · ${allDay}`;
237
240
  } else {
238
241
  time = `${timeFmt.format(o.start)} – ${timeFmt.format(o.end)}${zone ? ` ${zone}` : ''}`;
239
242
  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
- details: 'Details', cancelled: 'Cancelled',
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". English on purpose: it's shown next to other
113
- // fixed UI copy. Unparseable rules fall back to a generic label.
114
- export function describeRepeat(rrule: string, start: string, formatDate?: (ymd: string) => string): string {
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 === 1 ? 'Daily' : `Every ${n} days`;
120
+ text = w.daily(n);
123
121
  break;
124
122
  case 'WEEKLY': {
125
123
  const codes = (p.BYDAY ?? WEEKDAY_CODES[wd.dow]).split(',');
126
- const names = WEEKDAY_CODES.filter((c) => codes.includes(c)).map((c) => WEEKDAY_NAMES[WEEKDAY_CODES.indexOf(c)]);
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
- if (m) text = `${lead} on the ${ORDINALS[Number(m[1])] ?? `${m[1]}th`} ${WEEKDAY_NAMES[WEEKDAY_CODES.indexOf(m[2] as (typeof WEEKDAY_CODES)[number])]}`;
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 === 1 ? 'Yearly' : `Every ${n} years`;
133
+ text = w.yearly(n);
139
134
  break;
140
135
  default:
141
- return 'Repeats';
136
+ return w.other;
142
137
  }
143
- if (p.COUNT) text += `, ${p.COUNT} times`;
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 += `, until ${formatDate ? formatDate(ymd) : ymd}`;
142
+ text += w.until(formatDate ? formatDate(ymd) : ymd);
147
143
  }
148
144
  return text;
149
145
  }
@@ -0,0 +1,121 @@
1
+ // Words of the built-in RSVP: the event page's RSVP block, the RSVP's own
2
+ // page (book/manage) and the app's default button (GET /events/:slug/rsvp).
3
+ // The same wording as the app's RSVP panel (packages/native strings.ts).
4
+ // Plain strings with {count}, {email} and {title} slots (live/words.ts
5
+ // `fill`); of a pair (`spotLeft`/`spotsLeft`), the first is for 1 and the
6
+ // second for any other number. The event's own form title, questions and
7
+ // button keep their words. Other languages fall back to English.
8
+ export interface RsvpStrings {
9
+ /** "12 going · 3 spots left" (the page puts the numbers in bold) */
10
+ personGoing: string;
11
+ peopleGoing: string;
12
+ spotLeft: string;
13
+ spotsLeft: string;
14
+ /** the event page's form */
15
+ saveSeat: string;
16
+ nameField: string;
17
+ emailField: string;
18
+ seatsField: string;
19
+ /** the button when the form has no label of its own: confirmed right away, or the organiser approves */
20
+ confirmSeat: string;
21
+ requestSeat: string;
22
+ full: string;
23
+ closed: string;
24
+ saveError: string;
25
+ /** the RSVP's own page */
26
+ booked: string;
27
+ yours: string;
28
+ requestSent: string;
29
+ awaitingApproval: string;
30
+ notAvailable: string;
31
+ cancelledTitle: string;
32
+ emailWhenApproved: string;
33
+ confirmationOnItsWay: string;
34
+ declined: string;
35
+ cancelledText: string;
36
+ what: string;
37
+ when: string;
38
+ where: string;
39
+ status: string;
40
+ reference: string;
41
+ statuses: Record<'pending' | 'confirmed' | 'cancelled' | 'declined', string>;
42
+ addToCalendar: string;
43
+ downloadIcs: string;
44
+ cancel: string;
45
+ cancelConfirm: string;
46
+ cancelError: string;
47
+ backTo: string;
48
+ keepLink: string;
49
+ }
50
+
51
+ const STRINGS: Record<string, RsvpStrings> = {
52
+ en: {
53
+ personGoing: '{count} going', peopleGoing: '{count} going', spotLeft: '{count} spot left', spotsLeft: '{count} spots left',
54
+ saveSeat: 'Save my seat', nameField: 'Your name', emailField: 'Email', seatsField: 'Seats', confirmSeat: 'Confirm my seat', requestSeat: 'Request a seat',
55
+ full: 'This event is full.', closed: 'RSVPs are closed.', saveError: 'Couldn’t save your RSVP. Please try again.',
56
+ booked: 'You’re booked', yours: 'Your booking', requestSent: 'Request sent', awaitingApproval: 'Waiting for approval', notAvailable: 'Not available', cancelledTitle: 'Booking cancelled',
57
+ emailWhenApproved: 'We’ll email {email} once it’s approved.', confirmationOnItsWay: 'A confirmation is on its way to {email}.',
58
+ declined: 'Sorry, the organiser couldn’t confirm it.', cancelledText: 'This booking is cancelled.',
59
+ what: 'What', when: 'When', where: 'Where', status: 'Status', reference: 'Reference',
60
+ statuses: { pending: 'Pending', confirmed: 'Confirmed', cancelled: 'Cancelled', declined: 'Declined' },
61
+ addToCalendar: 'Add to calendar', downloadIcs: 'Download .ics', cancel: 'Cancel RSVP', cancelConfirm: 'Cancel this RSVP?', cancelError: 'Couldn’t cancel — please try again.',
62
+ backTo: 'Back to {title}', keepLink: 'Keep this link — anyone with it can change this booking.',
63
+ },
64
+ es: {
65
+ personGoing: '{count} va', peopleGoing: '{count} van', spotLeft: '{count} lugar disponible', spotsLeft: '{count} lugares disponibles',
66
+ saveSeat: 'Apartar mi lugar', nameField: 'Tu nombre', emailField: 'Correo electrónico', seatsField: 'Lugares', confirmSeat: 'Confirmar mi lugar', requestSeat: 'Solicitar un lugar',
67
+ full: 'Este evento está lleno.', closed: 'Las confirmaciones están cerradas.', saveError: 'No se pudo guardar tu confirmación. Inténtalo de nuevo.',
68
+ 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
+ emailWhenApproved: 'Te escribiremos a {email} cuando se apruebe.', confirmationOnItsWay: 'Te enviamos la confirmación a {email}.',
70
+ declined: 'Lo sentimos, el organizador no pudo confirmarla.', cancelledText: 'Esta confirmación se canceló.',
71
+ what: 'Qué', when: 'Cuándo', where: 'Dónde', status: 'Estado', reference: 'Referencia',
72
+ statuses: { pending: 'Pendiente', confirmed: 'Confirmado', cancelled: 'Cancelado', declined: 'Rechazado' },
73
+ 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.',
74
+ backTo: 'Volver a {title}', keepLink: 'Guarda este enlace: quien lo tenga puede cambiar esta confirmación.',
75
+ },
76
+ fr: {
77
+ personGoing: '{count} participant', peopleGoing: '{count} participants', spotLeft: '{count} place restante', spotsLeft: '{count} places restantes',
78
+ saveSeat: 'Réserver ma place', nameField: 'Votre nom', emailField: 'E-mail', seatsField: 'Places', confirmSeat: 'Confirmer ma place', requestSeat: 'Demander une place',
79
+ full: 'Cet événement est complet.', closed: 'Les inscriptions sont closes.', saveError: 'Impossible d’enregistrer votre inscription. Veuillez réessayer.',
80
+ 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
+ emailWhenApproved: 'Nous écrirons à {email} dès qu’elle sera validée.', confirmationOnItsWay: 'Une confirmation est en route vers {email}.',
82
+ declined: 'Désolés, l’organisateur n’a pas pu la confirmer.', cancelledText: 'Cette inscription est annulée.',
83
+ what: 'Quoi', when: 'Quand', where: 'Où', status: 'Statut', reference: 'Référence',
84
+ statuses: { pending: 'En attente', confirmed: 'Confirmée', cancelled: 'Annulée', declined: 'Refusée' },
85
+ 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.',
86
+ backTo: 'Retour à {title}', keepLink: 'Conservez ce lien : toute personne qui l’a peut modifier cette inscription.',
87
+ },
88
+ de: {
89
+ personGoing: '{count} Zusage', peopleGoing: '{count} Zusagen', spotLeft: '{count} Platz frei', spotsLeft: '{count} Plätze frei',
90
+ saveSeat: 'Platz reservieren', nameField: 'Ihr Name', emailField: 'E-Mail', seatsField: 'Plätze', confirmSeat: 'Platz bestätigen', requestSeat: 'Platz anfragen',
91
+ full: 'Diese Veranstaltung ist ausgebucht.', closed: 'Anmeldungen sind geschlossen.', saveError: 'Ihre Anmeldung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.',
92
+ booked: 'Ihr Platz ist reserviert', yours: 'Ihre Anmeldung', requestSent: 'Anfrage gesendet', awaitingApproval: 'Wartet auf Freigabe', notAvailable: 'Nicht bestätigt', cancelledTitle: 'Anmeldung storniert',
93
+ emailWhenApproved: 'Wir schreiben an {email}, sobald sie freigegeben ist.', confirmationOnItsWay: 'Eine Bestätigung ist unterwegs an {email}.',
94
+ declined: 'Leider konnte der Veranstalter sie nicht bestätigen.', cancelledText: 'Diese Anmeldung ist storniert.',
95
+ what: 'Was', when: 'Wann', where: 'Wo', status: 'Status', reference: 'Referenz',
96
+ statuses: { pending: 'Ausstehend', confirmed: 'Bestätigt', cancelled: 'Storniert', declined: 'Abgelehnt' },
97
+ addToCalendar: 'Zum Kalender hinzufügen', downloadIcs: '.ics herunterladen', cancel: 'Anmeldung stornieren', cancelConfirm: 'Diese Anmeldung stornieren?', cancelError: 'Stornieren fehlgeschlagen. Bitte versuchen Sie es erneut.',
98
+ backTo: 'Zurück zu {title}', keepLink: 'Bewahren Sie diesen Link auf – wer ihn hat, kann diese Anmeldung ändern.',
99
+ },
100
+ pt: {
101
+ personGoing: '{count} confirmado', peopleGoing: '{count} confirmados', spotLeft: '{count} vaga disponível', spotsLeft: '{count} vagas disponíveis',
102
+ saveSeat: 'Reservar minha vaga', nameField: 'Seu nome', emailField: 'E-mail', seatsField: 'Vagas', confirmSeat: 'Confirmar minha vaga', requestSeat: 'Solicitar uma vaga',
103
+ full: 'Este evento está lotado.', closed: 'As inscrições estão encerradas.', saveError: 'Não foi possível salvar sua inscrição. Tente novamente.',
104
+ 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
+ emailWhenApproved: 'Vamos escrever para {email} assim que for aprovada.', confirmationOnItsWay: 'Uma confirmação está a caminho de {email}.',
106
+ declined: 'Desculpe, o organizador não pôde confirmá-la.', cancelledText: 'Esta inscrição foi cancelada.',
107
+ what: 'O quê', when: 'Quando', where: 'Onde', status: 'Status', reference: 'Referência',
108
+ statuses: { pending: 'Pendente', confirmed: 'Confirmada', cancelled: 'Cancelada', declined: 'Recusada' },
109
+ 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.',
110
+ backTo: 'Voltar para {title}', keepLink: 'Guarde este link: qualquer pessoa com ele pode alterar esta inscrição.',
111
+ },
112
+ };
113
+
114
+ export const rsvpStrings = (locale: string | null | undefined): RsvpStrings =>
115
+ STRINGS[(locale || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
116
+
117
+ /** The words for `count` split where it goes, so a page can bold the number: aroundCount(3, w.spotLeft, w.spotsLeft) → ["", " spots left"]. */
118
+ export function aroundCount(count: number, one: string, other: string): [string, string] {
119
+ const [before, after = ''] = (count === 1 ? one : other).split('{count}');
120
+ return [before, after];
121
+ }
@@ -0,0 +1,203 @@
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';
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
+ },
58
+ manage: 'View or change your booking', rsvp: { manage: 'View or change your booking' },
59
+ what: 'What', when: 'When', where: 'Where', seats: 'Seats', reference: 'Reference {ref}', icsManage: 'Manage: {url}',
60
+ lateDecline: 'We weren’t able to confirm this booking in time. Please pick another time.',
61
+ 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}' },
62
+ page: {
63
+ untitled: 'Booking', booked: 'You’re booked', yours: 'Your booking', moved: 'Booking moved', notAvailable: 'Not available', cancelledTitle: 'Booking cancelled',
64
+ declined: 'Sorry — this time couldn’t be confirmed. You’re welcome to pick another.', cancelledText: 'This booking is cancelled.',
65
+ statuses: { pending: 'Pending', confirmed: 'Confirmed', cancelled: 'Cancelled', declined: 'Declined' },
66
+ reschedule: 'Reschedule', cancel: 'Cancel booking', cancelConfirm: 'Cancel this booking?',
67
+ cutoff: 'Changes close {hours} hours before the start. Please contact us to change or cancel.',
68
+ keepLink: 'Keep this link — anyone with it can change this booking.',
69
+ },
70
+ },
71
+ es: {
72
+ mail: {
73
+ received: { subject: 'Solicitud recibida: {title}', heading: 'Solicitud recibida', intro: 'Gracias, {name}. Le confirmaremos por correo muy pronto.' },
74
+ confirmed: { subject: 'Confirmada: {title} — {when}', heading: 'Su reservación está confirmada', intro: 'Ya está confirmada. Importe el archivo adjunto para agregarla a su calendario.' },
75
+ declined: { subject: 'No disponible: {title}', heading: 'No pudimos confirmar su solicitud', intro: 'Lo sentimos, este horario no está disponible. Puede elegir otro cuando guste.' },
76
+ cancelled: { subject: 'Cancelada: {title}', heading: 'Reservación cancelada', intro: 'Esta reservación se canceló.' },
77
+ 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.' },
78
+ 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.' },
79
+ reminder: { subject: 'Recordatorio: {title} — {when}', heading: 'Ya casi es la fecha', intro: 'Un breve recordatorio de su reservación.' },
80
+ },
81
+ manage: 'Ver o cambiar su reservación',
82
+ rsvp: {
83
+ manage: 'Ver o cambiar su confirmación',
84
+ confirmed: { subject: 'Confirmado: {title} — {when}', heading: 'Ya tiene su lugar', intro: 'Está confirmado. Importe el archivo adjunto para agregarlo a su calendario.' },
85
+ declined: { intro: 'Lo sentimos, el organizador no pudo confirmar su lugar.' },
86
+ cancelled: { subject: 'Cancelado: {title}', heading: 'Confirmación cancelada', intro: 'Esta confirmación se canceló.' },
87
+ updated: { subject: 'Actualizado: {title}', heading: 'Cambiaron los detalles' },
88
+ reminder: { intro: 'Un breve recordatorio: tiene un lugar apartado.' },
89
+ },
90
+ what: 'Qué', when: 'Cuándo', where: 'Dónde', seats: 'Lugares', reference: 'Referencia {ref}', icsManage: 'Ver o cambiar: {url}',
91
+ lateDecline: 'No pudimos confirmar esta reservación a tiempo. Por favor, elija otro horario.',
92
+ 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}' },
93
+ page: {
94
+ 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',
95
+ declined: 'Lo sentimos, no se pudo confirmar este horario. Puedes elegir otro.', cancelledText: 'Esta reservación se canceló.',
96
+ statuses: { pending: 'Pendiente', confirmed: 'Confirmada', cancelled: 'Cancelada', declined: 'Rechazada' },
97
+ reschedule: 'Cambiar horario', cancel: 'Cancelar reservación', cancelConfirm: '¿Cancelar esta reservación?',
98
+ cutoff: 'Los cambios se cierran {hours} horas antes del inicio. Escríbenos para cambiarla o cancelarla.',
99
+ keepLink: 'Guarda este enlace: quien lo tenga puede cambiar esta reservación.',
100
+ },
101
+ },
102
+ fr: {
103
+ mail: {
104
+ received: { subject: 'Demande reçue : {title}', heading: 'Demande reçue', intro: 'Merci, {name}. Nous vous confirmerons par e-mail très bientôt.' },
105
+ 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.' },
106
+ 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.' },
107
+ cancelled: { subject: 'Annulée : {title}', heading: 'Réservation annulée', intro: 'Cette réservation a été annulée.' },
108
+ 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.' },
109
+ 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.' },
110
+ reminder: { subject: 'Rappel : {title} — {when}', heading: 'C’est bientôt', intro: 'Un petit rappel de votre réservation.' },
111
+ },
112
+ manage: 'Voir ou modifier votre réservation',
113
+ rsvp: {
114
+ manage: 'Voir ou modifier votre inscription',
115
+ confirmed: { heading: 'Votre place est réservée' },
116
+ declined: { intro: 'Désolés, l’organisateur n’a pas pu confirmer votre inscription.' },
117
+ cancelled: { heading: 'Inscription annulée', intro: 'Cette inscription a été annulée.' },
118
+ updated: { heading: 'Détails mis à jour' },
119
+ reminder: { intro: 'Un petit rappel de votre inscription.' },
120
+ },
121
+ what: 'Quoi', when: 'Quand', where: 'Où', seats: 'Places', reference: 'Référence {ref}', icsManage: 'Gérer : {url}',
122
+ lateDecline: 'Nous n’avons pas pu confirmer cette réservation à temps. Veuillez choisir un autre horaire.',
123
+ 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}' },
124
+ page: {
125
+ 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',
126
+ 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.',
127
+ statuses: { pending: 'En attente', confirmed: 'Confirmée', cancelled: 'Annulée', declined: 'Refusée' },
128
+ reschedule: 'Changer d’horaire', cancel: 'Annuler la réservation', cancelConfirm: 'Annuler cette réservation ?',
129
+ cutoff: 'Les modifications ferment {hours} heures avant le début. Contactez-nous pour la modifier ou l’annuler.',
130
+ keepLink: 'Conservez ce lien : toute personne qui l’a peut modifier cette réservation.',
131
+ },
132
+ },
133
+ de: {
134
+ mail: {
135
+ received: { subject: 'Anfrage erhalten: {title}', heading: 'Anfrage erhalten', intro: 'Danke, {name}. Wir bestätigen sie Ihnen bald per E-Mail.' },
136
+ 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.' },
137
+ 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.' },
138
+ cancelled: { subject: 'Storniert: {title}', heading: 'Buchung storniert', intro: 'Diese Buchung wurde storniert.' },
139
+ updated: { subject: 'Aktualisiert: {title}', heading: 'Buchungsdetails aktualisiert', intro: 'Bitte prüfen Sie die aktuellen Details. Mit der angehängten Kalenderdatei aktualisieren Sie Ihren Kalender.' },
140
+ 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.' },
141
+ reminder: { subject: 'Erinnerung: {title} — {when}', heading: 'Bald ist es so weit', intro: 'Eine kurze Erinnerung an Ihre Buchung.' },
142
+ },
143
+ manage: 'Buchung ansehen oder ändern',
144
+ rsvp: {
145
+ manage: 'Anmeldung ansehen oder ändern',
146
+ confirmed: { heading: 'Ihr Platz ist reserviert' },
147
+ declined: { intro: 'Leider konnte der Veranstalter Ihre Anmeldung nicht bestätigen.' },
148
+ cancelled: { heading: 'Anmeldung storniert', intro: 'Diese Anmeldung wurde storniert.' },
149
+ updated: { heading: 'Details aktualisiert' },
150
+ reminder: { intro: 'Eine kurze Erinnerung an Ihre Anmeldung.' },
151
+ },
152
+ what: 'Was', when: 'Wann', where: 'Wo', seats: 'Plätze', reference: 'Referenz {ref}', icsManage: 'Verwalten: {url}',
153
+ lateDecline: 'Wir konnten diese Buchung nicht rechtzeitig bestätigen. Bitte wählen Sie einen anderen Termin.',
154
+ 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}' },
155
+ page: {
156
+ untitled: 'Buchung', booked: 'Ihre Buchung ist bestätigt', yours: 'Ihre Buchung', moved: 'Buchung verschoben', notAvailable: 'Nicht verfügbar', cancelledTitle: 'Buchung storniert',
157
+ declined: 'Leider konnte dieser Termin nicht bestätigt werden. Wählen Sie gern einen anderen.', cancelledText: 'Diese Buchung ist storniert.',
158
+ statuses: { pending: 'Ausstehend', confirmed: 'Bestätigt', cancelled: 'Storniert', declined: 'Abgelehnt' },
159
+ reschedule: 'Verschieben', cancel: 'Buchung stornieren', cancelConfirm: 'Diese Buchung stornieren?',
160
+ cutoff: 'Änderungen sind bis {hours} Stunden vor Beginn möglich. Bitte kontaktieren Sie uns, um sie zu ändern oder zu stornieren.',
161
+ keepLink: 'Bewahren Sie diesen Link auf – wer ihn hat, kann diese Buchung ändern.',
162
+ },
163
+ },
164
+ pt: {
165
+ mail: {
166
+ received: { subject: 'Pedido recebido: {title}', heading: 'Pedido recebido', intro: 'Obrigado, {name}. Vamos confirmar por e-mail em breve.' },
167
+ confirmed: { subject: 'Confirmada: {title} — {when}', heading: 'Sua reserva está confirmada', intro: 'Está confirmada. Importe o arquivo anexo para adicioná-la ao seu calendário.' },
168
+ 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.' },
169
+ cancelled: { subject: 'Cancelada: {title}', heading: 'Reserva cancelada', intro: 'Esta reserva foi cancelada.' },
170
+ 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.' },
171
+ 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.' },
172
+ reminder: { subject: 'Lembrete: {title} — {when}', heading: 'Está chegando', intro: 'Um lembrete rápido sobre sua reserva.' },
173
+ },
174
+ manage: 'Ver ou alterar sua reserva',
175
+ rsvp: {
176
+ manage: 'Ver ou alterar sua inscrição',
177
+ confirmed: { heading: 'Sua vaga está reservada' },
178
+ declined: { intro: 'Desculpe, o organizador não pôde confirmar sua inscrição.' },
179
+ cancelled: { heading: 'Inscrição cancelada', intro: 'Esta inscrição foi cancelada.' },
180
+ updated: { heading: 'Detalhes atualizados' },
181
+ reminder: { intro: 'Um lembrete rápido sobre sua inscrição.' },
182
+ },
183
+ what: 'O quê', when: 'Quando', where: 'Onde', seats: 'Vagas', reference: 'Referência {ref}', icsManage: 'Gerenciar: {url}',
184
+ lateDecline: 'Não conseguimos confirmar esta reserva a tempo. Por favor, escolha outro horário.',
185
+ 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}' },
186
+ page: {
187
+ untitled: 'Reserva', booked: 'Sua reserva está confirmada', yours: 'Sua reserva', moved: 'Reserva remarcada', notAvailable: 'Indisponível', cancelledTitle: 'Reserva cancelada',
188
+ declined: 'Desculpe, não foi possível confirmar este horário. Fique à vontade para escolher outro.', cancelledText: 'Esta reserva foi cancelada.',
189
+ statuses: { pending: 'Pendente', confirmed: 'Confirmada', cancelled: 'Cancelada', declined: 'Recusada' },
190
+ reschedule: 'Remarcar', cancel: 'Cancelar reserva', cancelConfirm: 'Cancelar esta reserva?',
191
+ cutoff: 'As alterações se encerram {hours} horas antes do início. Fale com a gente para alterar ou cancelar.',
192
+ keepLink: 'Guarde este link: qualquer pessoa com ele pode alterar esta reserva.',
193
+ },
194
+ },
195
+ };
196
+
197
+ export const bookingStrings = (locale: string | null | undefined): BookingStrings =>
198
+ STRINGS[(locale || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
199
+
200
+ /** one guest email's subject, heading and intro: a booking's, or an RSVP's where the language words it differently */
201
+ export function mailCopy(w: BookingStrings, kind: GuestMail, rsvp: boolean): MailCopy {
202
+ return rsvp ? { ...w.mail[kind], ...w.rsvp[kind] } : w.mail[kind];
203
+ }