@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/cli/migrations.generated.mjs +5 -0
  3. package/docs/mobile.md +1 -1
  4. package/docs/theme-authoring.md +1 -1
  5. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +18 -11
  6. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +18 -11
  7. package/mobile-template/recipes/rsvp/recipe.json +2 -1
  8. package/package.json +1 -1
  9. package/src/actions/index.ts +2 -1
  10. package/src/components/EventPage.astro +53 -30
  11. package/src/components/MobileApp.tsx +44 -23
  12. package/src/components/editor/EventPanel.tsx +2 -1
  13. package/src/components/editor/PostEditor.tsx +1 -1
  14. package/src/components/editor/event/RegistrationCard.tsx +51 -1
  15. package/src/components/editor/event/registration.ts +18 -1
  16. package/src/lib/booking/emails.ts +62 -45
  17. package/src/lib/booking/service.ts +57 -19
  18. package/src/lib/calendar/cards.ts +5 -3
  19. package/src/lib/calendar/event-words.ts +171 -0
  20. package/src/lib/calendar/events.ts +16 -2
  21. package/src/lib/calendar/render.ts +3 -1
  22. package/src/lib/calendar/rrule.ts +14 -18
  23. package/src/lib/calendar/rsvp-words.ts +31 -2
  24. package/src/lib/content-service.ts +26 -14
  25. package/src/lib/drafts.ts +2 -0
  26. package/src/lib/editor/documents.ts +3 -1
  27. package/src/lib/editor/schemas.ts +1 -1
  28. package/src/lib/email/booking-words.ts +212 -0
  29. package/src/lib/forms/events.ts +4 -2
  30. package/src/lib/mobile/bookings.ts +10 -3
  31. package/src/lib/mobile/calendar.ts +6 -4
  32. package/src/lib/mobile/guest.ts +3 -2
  33. package/src/lib/mobile/http.ts +1 -1
  34. package/src/lib/mobile/manage.ts +7 -5
  35. package/src/lib/mobile/openapi.ts +1 -1
  36. package/src/lib/mobile/rsvp.ts +14 -8
  37. package/src/lib/mobile/schemas.ts +7 -2
  38. package/src/lib/mobile/search.ts +1 -1
  39. package/src/lib/mobile/site.ts +7 -3
  40. package/src/lib/push/api.ts +7 -8
  41. package/src/lib/push/service.ts +26 -11
  42. package/src/lib/push/types.ts +18 -8
  43. package/src/lib/repo/migrations.ts +5 -0
  44. package/src/lib/repo/sql.ts +7 -4
  45. package/src/lib/repo/types.ts +5 -3
  46. package/src/lib/schema.ts +8 -1
  47. package/src/pages/book/[resource]/[link].astro +8 -4
  48. package/src/pages/book/manage/[token].astro +22 -14
  49. package/src/pages/events/[slug].astro +12 -5
  50. package/src/styles/editor.css +4 -0
  51. package/src/styles/mobile-app.css +7 -0
  52. package/src/styles/site.css +3 -0
  53. package/src/theme-sdk/index.ts +4 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.39
4
+
5
+ ### Patch Changes
6
+
7
+ - 4c33f48: **When an event changes** in **Admin → Mobile app** now takes a list of rules (up to 10), each with its own calendars, **Only events tagged** and **Send to**. Use **Add a rule** to add one and **Remove** to take one away; with no rules, change notices are off. A site can now send classes tagged "Class" in a shared rooms calendar to a `classes` topic, and every event in its Events calendar to that event's own topics (Events and its calendar). An event that several rules cover gets one notice, sent to all of their topics, so each phone gets it once. Events in the app API list those same topics in `notify.changes`. The rule you already have becomes the first rule, with nothing to redo.
8
+ - 527ba2f: Events can stop taking RSVPs before they happen. In the event's CTA card (Built-in form), a one-time event sets when RSVPs close (a date and time in the event's time zone), and a repeating one closes each date some hours or days before it starts ("Close RSVPs 2 hours before each date", up to 60 days). Until then the event page says "RSVPs close Fri, Oct 24 at 9 PM"; afterwards the form gives way to "RSVPs are closed." and "They closed on Fri, Oct 24. Questions? Contact us.", in the page's language. With no deadline, RSVPs stay open until the date ends, as before. The website and apps both turn late RSVPs away (410 in the app API). `GET /api/v1/events/:slug/rsvp` has `closesAt` (the date's deadline, or `null`) and the new reason `closed`, and events in the app API carry `rsvp.closesAt` and `rsvp.closeBefore`. Saves that leave the new settings out, from older editors and editor apps, keep them.
9
+
10
+ A second RSVP from the same email for the same date no longer takes more seats. The page says "You already have an RSVP for this date. We sent you an email with a link to change it. Need more seats? Change it there." and keeps the form, and the address gets its RSVP's link again by email, at most once every 10 minutes per RSVP, so the form can't flood someone's inbox. Nothing else about the RSVP is shown, since anyone can type an address. In the app API that's a 409 with the new error code `duplicate`. Cancelled and declined RSVPs don't count, and other dates are separate. The check is part of the same database write as the seat count, so two at once can't both get in.
11
+
12
+ The RSVP panel in the app recipes shows when RSVPs close, says when they closed, and shows the "You already have an RSVP" notice. Update it with `npx acms mobile recipe add rsvp --force`; that overwrites `components/RsvpPanel.tsx`, including any changes you made to it, and needs this release's `@acms-subir/native`. Theme event templates get `rsvp.closesAt` and `rsvp.duplicate`; a duplicate's words also come in `rsvp.error`, so templates that don't know it still say something.
13
+
14
+ ## 0.3.38
15
+
16
+ ### Patch Changes
17
+
18
+ - 8d85520: `GET /api/v1/site?locale=es` returns the menu the way the `/es` header shows it: links to the Spanish pages, and the automatic menu's own words translated ("Inicio", "Enlaces"). Labels you wrote in Navigation stay as written. Events in the app API carry `notify.changes`, the push topics that hear when the event's date, time or place changes (Admin → Mobile app → When an event changes), or `[]`, so an app only promises "we'll let you know if it changes" when it's true. The RSVP panel recipe takes `children`, shown under the person's RSVP.
19
+ - 7b71457: Event pages now follow the page's language, in English, Spanish, French, German and Portuguese (other languages stay in English). On `/es` event pages, and on a site whose main language is Spanish, a cancelled date said "Cancelled on … Other dates go ahead as usual." in English; now it says "Cancelado el domingo, 25 de octubre de 2026. Las demás fechas siguen como siempre.", so the "Cancelado el …" push opens a page that matches it. The same goes for a cancelled event, the "Cancelado" flag on event cards and in the calendar's pop-up, "Todo el día", the repeat line ("Todos los domingos", "El primer domingo de cada mes", "Cada 2 semanas, los lunes, 6 veces"), Agregar al calendario, Descargar .ics, Compartir and Enlace copiado, Más eventos, Ver el calendario completo, the map's labels and "Mostrando el …". Links on a `/es` event page (more events, the calendar, sharing) stay in Spanish. Theme event templates can use the same words with `eventStrings` and `describeRepeat` from `@acms-subir/core/theme`.
20
+
21
+ Booking and RSVP emails now speak the language the person used: the page's on the website (RSVPs from `/es` event pages, and bookings from `/es/book/…`, whose pages stay in English), the app's `locale` on `POST /api/v1/bookings`, and the site's main language for bookings staff add. The subject, message, details, dates and button are in that language, RSVPs show the event's translated title and place, and every link (the booking's own page, the calendar file) keeps it, such as `/es/book/manage/…`. Spanish emails say _usted_, like the privacy notice. In the app API, `manageUrl` (and the event's `url` from `GET /api/v1/bookings/manage`) points to the page in that language too. Staff notifications stay in the main language. Older bookings, and backups made before this release, use the main language. On a site whose main language isn't English, booking emails without a language (and staff notifications) now come in that language instead of English. Migration 60 adds `bookings.locale` (default `''`, the main language).
22
+
23
+ A room booking's own page (`/book/manage/…`, the page booking emails link to) now follows the page's language too, like an RSVP's: "Tu reservación está confirmada", Cambiar horario, Cancelar reservación and so on. Opened without `/es`, it shows the main language.
24
+
3
25
  ## 0.3.37
4
26
 
5
27
  ### Patch Changes
@@ -1111,6 +1111,11 @@ var MIGRATIONS = [
1111
1111
  { version: 59, statements: [
1112
1112
  `ALTER TABLE push_queue ADD COLUMN payload TEXT`,
1113
1113
  `ALTER TABLE push_queue ADD COLUMN due_at INTEGER`
1114
+ ] },
1115
+ // the language a booking or RSVP was made in (Booking.locale), so its
1116
+ // emails and links follow it; '' = the main language, so older rows need no backfill
1117
+ { version: 60, statements: [
1118
+ `ALTER TABLE bookings ADD COLUMN locale TEXT NOT NULL DEFAULT ''`
1114
1119
  ] }
1115
1120
  ];
1116
1121
  var CURRENT_CONTENT_VERSION_COLUMNS = {
package/docs/mobile.md CHANGED
@@ -120,7 +120,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
120
120
  | `useSearch(text, { types, collection, limit })` | site-wide search: `data.posts`, `data.pages`, `data.events` (each with `snippet.text` + `snippet.highlights`), `data.more`; debounced, blank text fetches nothing |
121
121
  | `useEvents({ calendar, from, to, limit, q })` | `data.occurrences[]` (`start`, `end`, `allDay`, `event`) in a window (default 30 days, max 90). `from`/`to` take a site-local day (`'2026-10-01'`) or an ISO date-time with a zone (`new Date().toISOString()`) |
122
122
  | `useEvent(slug)` | one event |
123
- | `useRsvpStatus(slug, start)` / `useRsvp(slug, start)` | RSVP to one date (`start` exactly as `useEvents` returns it, a UTC ISO string) when `event.rsvp.enabled`: `open` or `reason` (`full`, `ended`, `cancelled`, `disabled`), live `going`/`left`, `maxSeats`, the questions in `form`; `answerProblems(form.fields, answers)` for inline errors. Needs only the calendar (`features.rsvp`). Keep `manageToken` in `expo-secure-store` to show or cancel the RSVP later (`useManagedBooking`, `useCancelBooking`) |
123
+ | `useRsvpStatus(slug, start)` / `useRsvp(slug, start)` | RSVP to one date (`start` exactly as `useEvents` returns it, a UTC ISO string) when `event.rsvp.enabled`: `open` or `reason` (`full`, `ended`, `closed`, `cancelled`, `disabled`), `closesAt` (the deadline; `rsvpDeadlineText` words it), live `going`/`left`, `maxSeats`, the questions in `form`; `answerProblems(form.fields, answers)` for inline errors. One RSVP per email and date: `rsvp.duplicate` means that email already has one (the site emailed its link again); say so and keep the form. Needs only the calendar (`features.rsvp`). Keep `manageToken` in `expo-secure-store` to show or cancel the RSVP later (`useManagedBooking`, `useCancelBooking`) |
124
124
  | `useManagedBooking(token)` / `useCancelBooking` / `useRescheduleBooking` / `useRescheduleAvailability` | the booker's own booking (from `book()`'s `manageToken`): `can.cancel`, `can.reschedule`, `can.closed` (change cutoff passed: show "Contact us") |
125
125
  | `useGuest()` / `useMyBookings({ when })` | guest sign-in with an emailed code, then every booking made with that email; check `features.guestSignIn` |
126
126
  | `useLinks()` / `useForm(id)` / `useSubmitForm(id)` | the links page; published forms and sending answers; check `features.links` / `features.forms` |
@@ -32,7 +32,7 @@ const { page, posts } = Astro.props;
32
32
  {posts.map(post => <PostCard href={collectionUrl(post.collection, post.slug)} title={post.title} excerpt={post.excerpt} />)}
33
33
  ```
34
34
 
35
- Public types: ChromeProps (site), HomeProps (settings, page, posts, tags), PageProps (settings, page, html), PostProps (settings, post, html, authors, related), EventProps (settings, event, occurrence, calendars, html, upcoming, origin, preview, rsvp), SectionProps (settings, page, variant, data). Public components: PostCard with card/row/feature variants, Pager with base/page/count, TagLabel with name/href/accent. Helpers include collectionUrl, postUrl, postsIndexUrl, tagUrl, formatDate, slugify, authorSlug, localeFromPath, localeHomeUrl and CONTENT. Use collectionUrl for non-default collections; postUrl is the built-in posts alias.
35
+ Public types: ChromeProps (site), HomeProps (settings, page, posts, tags), PageProps (settings, page, html), PostProps (settings, post, html, authors, related), EventProps (settings, event, occurrence, calendars, html, upcoming, origin, preview, rsvp), SectionProps (settings, page, variant, data). Public components: PostCard with card/row/feature variants, Pager with base/page/count, TagLabel with name/href/accent. Helpers include collectionUrl, postUrl, postsIndexUrl, tagUrl, formatDate, slugify, authorSlug, localeFromPath, localeHomeUrl and CONTENT. For an event template, `eventStrings(lang)` gives the built-in event page's words (Cancelled, Add to calendar, Share, …) and `describeRepeat(event.rrule, event.start, formatDate?, lang)` its repeat line ("Weekly on Sunday", "Todos los domingos"), in English, Spanish, French, German or Portuguese, otherwise English. Use collectionUrl for non-default collections; postUrl is the built-in posts alias.
36
36
 
37
37
  ### Upcoming events in coded templates
38
38
 
@@ -1,17 +1,18 @@
1
- import { Fragment, useEffect, useState } from 'react';
1
+ import { Fragment, useEffect, useState, type ReactNode } from 'react';
2
2
  import { ActivityIndicator, Platform, Pressable, Switch, Text, TextInput, View, type KeyboardTypeOptions } from 'react-native';
3
3
  import * as Crypto from 'expo-crypto';
4
4
  import * as SecureStore from 'expo-secure-store';
5
5
  import { isAcmsError, type FormField } from '@acms-subir/client';
6
- import { Button, answerProblemText, answerProblems, countWords, fill, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme } from '@acms-subir/native';
6
+ import { Button, answerProblemText, answerProblems, countWords, fill, rsvpDeadlineText, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme } from '@acms-subir/native';
7
7
 
8
8
  // RSVP to one date of an event: <RsvpPanel slug={event.slug} start={occurrence.start} />,
9
9
  // with `start` exactly as useEvents returns it. Shows going and spots left,
10
10
  // seats, contact details and the event's questions, then the person's RSVP
11
11
  // with a way to cancel it; or why the date takes no RSVPs. Same rules as the
12
- // event's web page: open until the date ends, and the organiser may approve
13
- // each RSVP. useRsvp keeps one idempotency key per RSVP, so sending again
14
- // after a timeout can't register anyone twice. Its words come from
12
+ // event's web page: open until the event's deadline or the date ends, one RSVP
13
+ // per email and date, and the organiser may approve each RSVP. useRsvp keeps
14
+ // one idempotency key per RSVP, so sending again after a timeout can't
15
+ // register anyone twice. Its words come from
15
16
  // useStrings() in the app's language; the event's own form title, labels and
16
17
  // button win when the site has them.
17
18
 
@@ -37,7 +38,9 @@ const withOther = (f: FormField, value: string | string[] | undefined, other: st
37
38
  // a count in bold inside its words ("{count} going")
38
39
  const bold = (template: string, n: string) => template.split('{count}').map((part, i) => (i ? <Fragment key={i}><Text style={{ fontWeight: '700' }}>{n}</Text>{part}</Fragment> : part));
39
40
 
40
- export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
41
+ // children: shown under the person's RSVP (not a declined one), e.g. an offer
42
+ // to get notified if the date, time or place changes
43
+ export function RsvpPanel({ slug, start, children }: { slug: string; start: string; children?: ReactNode }) {
41
44
  const t = useTheme();
42
45
  const words = useStrings();
43
46
  const { locale } = useLocale();
@@ -89,6 +92,8 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
89
92
  const errorText = [t.styles.text.small, { color: t.colors.ink, fontWeight: '600' as const }];
90
93
  const num = (n: number) => n.toLocaleString(locale);
91
94
  const seatsText = (n: number) => countWords(n, words.seat, words.seats, locale);
95
+ // "RSVPs close Fri, Oct 24 at 9 PM", or once past it "They closed on …"
96
+ const deadline = rsvpDeadlineText(s, words, locale);
92
97
  const going = (
93
98
  <Text style={t.styles.text.body}>
94
99
  {bold(s.going === 1 ? words.personGoing : words.peopleGoing, num(s.going))}
@@ -113,7 +118,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
113
118
  const who = mine?.booking.email ?? made!.email;
114
119
  const thanks = mine ? mine.confirmation : made!.formResponse?.definition.confirmation || null;
115
120
  const canCancel = !!stored && changes && (mine ? mine.can.cancel : false);
116
- return (
121
+ return (<>
117
122
  <View style={[t.styles.card, { gap: 8 }]}>
118
123
  <Text accessibilityRole="header" style={t.styles.text.heading}>{!made ? words.yourRsvp : state === 'confirmed' ? words.confirmed : words.requestSent}</Text>
119
124
  <Text style={[t.styles.text.label, { color: t.colors.ink }]}>
@@ -136,14 +141,15 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
136
141
  {cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? words.checkConnection : cancel.error.message}</Text>}
137
142
  {!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>{words.changeByEmail}</Text>}
138
143
  </View>
139
- );
144
+ {state !== 'declined' && children}
145
+ </>);
140
146
  }
141
147
 
142
148
  if (s.reason === 'disabled') return null;
143
149
  if (!s.open) return (
144
150
  <View style={[t.styles.card, { gap: 6 }]}>
145
151
  <Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ? words.full : s.reason === 'cancelled' ? words.dateCancelled : words.rsvpClosed}</Text>
146
- {s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : words.dateEnded}</Text>}
152
+ {s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : s.reason === 'closed' ? deadline || words.rsvpClosed : words.dateEnded}</Text>}
147
153
  </View>
148
154
  );
149
155
 
@@ -239,6 +245,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
239
245
  <View style={{ gap: 4 }}>
240
246
  <Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || words.rsvpTitle}</Text>
241
247
  {going}
248
+ {!!deadline && <Text style={t.styles.text.muted}>{deadline}</Text>}
242
249
  {!!notice && <Text style={t.styles.text.muted}>{notice}</Text>}
243
250
  {!!form?.intro && <Text style={t.styles.text.muted}>{form.intro}</Text>}
244
251
  </View>
@@ -260,9 +267,9 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
260
267
  {fields.map(field)}
261
268
  {error && (
262
269
  <View style={{ backgroundColor: t.colors.soft, borderRadius: Math.min(t.radius, 10), padding: 12, gap: 4 }}>
263
- <Text style={[t.styles.text.body, { fontWeight: '600' }]}>{words.notSent}</Text>
270
+ <Text style={[t.styles.text.body, { fontWeight: '600' }]}>{rsvp.duplicate ? words.alreadyRsvped : words.notSent}</Text>
264
271
  <Text style={t.styles.text.muted}>
265
- {isAcmsError(error) && error.status === 0 ? words.offlineRetry : error.message}
272
+ {rsvp.duplicate ? words.alreadyRsvpedNote : isAcmsError(error) && error.status === 0 ? words.offlineRetry : error.message}
266
273
  </Text>
267
274
  </View>
268
275
  )}
@@ -1,17 +1,18 @@
1
- import { Fragment, useEffect, useState } from 'react';
1
+ import { Fragment, useEffect, useState, type ReactNode } from 'react';
2
2
  import { ActivityIndicator, Platform, Pressable, Switch, Text, TextInput, View, type KeyboardTypeOptions } from 'react-native';
3
3
  import * as Crypto from 'expo-crypto';
4
4
  import * as SecureStore from 'expo-secure-store';
5
5
  import { isAcmsError, type FormField } from '@acms-subir/client';
6
- import { Button, answerProblemText, answerProblems, countWords, fill, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme } from '@acms-subir/native';
6
+ import { Button, answerProblemText, answerProblems, countWords, fill, rsvpDeadlineText, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme } from '@acms-subir/native';
7
7
 
8
8
  // RSVP to one date of an event: <RsvpPanel slug={event.slug} start={occurrence.start} />,
9
9
  // with `start` exactly as useEvents returns it. Shows going and spots left,
10
10
  // seats, contact details and the event's questions, then the person's RSVP
11
11
  // with a way to cancel it; or why the date takes no RSVPs. Same rules as the
12
- // event's web page: open until the date ends, and the organiser may approve
13
- // each RSVP. useRsvp keeps one idempotency key per RSVP, so sending again
14
- // after a timeout can't register anyone twice. Its words come from
12
+ // event's web page: open until the event's deadline or the date ends, one RSVP
13
+ // per email and date, and the organiser may approve each RSVP. useRsvp keeps
14
+ // one idempotency key per RSVP, so sending again after a timeout can't
15
+ // register anyone twice. Its words come from
15
16
  // useStrings() in the app's language; the event's own form title, labels and
16
17
  // button win when the site has them.
17
18
 
@@ -37,7 +38,9 @@ const withOther = (f: FormField, value: string | string[] | undefined, other: st
37
38
  // a count in bold inside its words ("{count} going")
38
39
  const bold = (template: string, n: string) => template.split('{count}').map((part, i) => (i ? <Fragment key={i}><Text style={{ fontWeight: '700' }}>{n}</Text>{part}</Fragment> : part));
39
40
 
40
- export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
41
+ // children: shown under the person's RSVP (not a declined one), e.g. an offer
42
+ // to get notified if the date, time or place changes
43
+ export function RsvpPanel({ slug, start, children }: { slug: string; start: string; children?: ReactNode }) {
41
44
  const t = useTheme();
42
45
  const words = useStrings();
43
46
  const { locale } = useLocale();
@@ -89,6 +92,8 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
89
92
  const errorText = [t.styles.text.small, { color: t.colors.ink, fontWeight: '600' as const }];
90
93
  const num = (n: number) => n.toLocaleString(locale);
91
94
  const seatsText = (n: number) => countWords(n, words.seat, words.seats, locale);
95
+ // "RSVPs close Fri, Oct 24 at 9 PM", or once past it "They closed on …"
96
+ const deadline = rsvpDeadlineText(s, words, locale);
92
97
  const going = (
93
98
  <Text style={t.styles.text.body}>
94
99
  {bold(s.going === 1 ? words.personGoing : words.peopleGoing, num(s.going))}
@@ -113,7 +118,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
113
118
  const who = mine?.booking.email ?? made!.email;
114
119
  const thanks = mine ? mine.confirmation : made!.formResponse?.definition.confirmation || null;
115
120
  const canCancel = !!stored && changes && (mine ? mine.can.cancel : false);
116
- return (
121
+ return (<>
117
122
  <View style={[t.styles.card, { gap: 8 }]}>
118
123
  <Text accessibilityRole="header" style={t.styles.text.heading}>{!made ? words.yourRsvp : state === 'confirmed' ? words.confirmed : words.requestSent}</Text>
119
124
  <Text style={[t.styles.text.label, { color: t.colors.ink }]}>
@@ -136,14 +141,15 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
136
141
  {cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? words.checkConnection : cancel.error.message}</Text>}
137
142
  {!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>{words.changeByEmail}</Text>}
138
143
  </View>
139
- );
144
+ {state !== 'declined' && children}
145
+ </>);
140
146
  }
141
147
 
142
148
  if (s.reason === 'disabled') return null;
143
149
  if (!s.open) return (
144
150
  <View style={[t.styles.card, { gap: 6 }]}>
145
151
  <Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ? words.full : s.reason === 'cancelled' ? words.dateCancelled : words.rsvpClosed}</Text>
146
- {s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : words.dateEnded}</Text>}
152
+ {s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : s.reason === 'closed' ? deadline || words.rsvpClosed : words.dateEnded}</Text>}
147
153
  </View>
148
154
  );
149
155
 
@@ -239,6 +245,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
239
245
  <View style={{ gap: 4 }}>
240
246
  <Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || words.rsvpTitle}</Text>
241
247
  {going}
248
+ {!!deadline && <Text style={t.styles.text.muted}>{deadline}</Text>}
242
249
  {!!notice && <Text style={t.styles.text.muted}>{notice}</Text>}
243
250
  {!!form?.intro && <Text style={t.styles.text.muted}>{form.intro}</Text>}
244
251
  </View>
@@ -260,9 +267,9 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
260
267
  {fields.map(field)}
261
268
  {error && (
262
269
  <View style={{ backgroundColor: t.colors.soft, borderRadius: Math.min(t.radius, 10), padding: 12, gap: 4 }}>
263
- <Text style={[t.styles.text.body, { fontWeight: '600' }]}>{words.notSent}</Text>
270
+ <Text style={[t.styles.text.body, { fontWeight: '600' }]}>{rsvp.duplicate ? words.alreadyRsvped : words.notSent}</Text>
264
271
  <Text style={t.styles.text.muted}>
265
- {isAcmsError(error) && error.status === 0 ? words.offlineRetry : error.message}
272
+ {rsvp.duplicate ? words.alreadyRsvpedNote : isAcmsError(error) && error.status === 0 ? words.offlineRetry : error.message}
266
273
  </Text>
267
274
  </View>
268
275
  )}
@@ -10,6 +10,7 @@
10
10
  "next": [
11
11
  "Mount it on your event screen where the date is known: <RsvpPanel slug={occurrence.event.slug} start={occurrence.start} /> from '../../components/RsvpPanel', with start exactly as useEvents returns it (the UTC ISO string, e.g. pass o.start through router params), and only when occurrence.event.rsvp?.enabled",
12
12
  "The screen should scroll and avoid the keyboard: wrap it in <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined}> with <ScrollView keyboardShouldPersistTaps=\"handled\">",
13
- "RSVPs need only the calendar (useSite().data.features.rsvp). Each RSVP's private manage token stays in expo-secure-store (never a URL or log): the panel shows the person's RSVP and lets them cancel it; the confirmation email's link works too"
13
+ "RSVPs need only the calendar (useSite().data.features.rsvp). Each RSVP's private manage token stays in expo-secure-store (never a URL or log): the panel shows the person's RSVP and lets them cancel it; the confirmation email's link works too",
14
+ "Anything you pass as children shows under the person's RSVP (not a declined one), e.g. an offer to get notified if the date, time or place changes: <RsvpPanel slug={…} start={…}><MyOffer /></RsvpPanel>. With push on, occurrence.event.notify.changes lists the topics that hear about changes ([] = none)"
14
15
  ]
15
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.37",
3
+ "version": "0.3.39",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -994,7 +994,8 @@ export const server = {
994
994
  cta: z.object({ label: z.string().max(120), href: z.string().max(1000), style: z.enum(['primary', 'outline']) }).optional(),
995
995
  calendarIds: z.array(z.string()).max(50).optional(),
996
996
  fields: z.record(z.string().max(2000)).optional(),
997
- 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), form: z.any().optional() }).optional(),
997
+ 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),
998
+ closesAt: z.string().max(40).nullable().optional(), closeBefore: z.number().int().min(0).max(86400).optional(), form: z.any().optional() }).optional(),
998
999
  roomHold: z.object({ resourceIds: z.array(z.string()).max(50), span: z.enum(['event', 'day']), before: z.number().int().min(0).max(1440), after: z.number().int().min(0).max(1440), whileDraft: z.boolean(), keep: z.array(z.string()).max(500).default([]) }).optional(),
999
1000
  scheduledAt: nullableTs.optional(),
1000
1001
  }),
@@ -2,15 +2,18 @@
2
2
  // The built-in event page (feature image, date block, place, button, add to
3
3
  // calendar, share, related events). Themes replace it with
4
4
  // templates/event.astro, which receives the same props. Every element
5
- // carries a stable .pc-ev-* class for CSS overrides.
5
+ // carries a stable .pc-ev-* class for CSS overrides. Built-in words follow
6
+ // the page's language (lib/calendar/event-words.ts, also in the theme SDK).
6
7
  import type { CalEvent, Calendar, Settings } from '../lib/schema';
7
8
  import { CALENDAR, eventIcsUrl, eventUrl, calendarPageUrl } from '../lib/calendar/config';
8
9
  import { addToCalendarLinks, eventTimeZone, formatOccurrence, safeColor, type Occurrence } from '../lib/calendar/events';
9
10
  import { describeRepeat } from '../lib/calendar/rrule';
10
11
  import { eventCardHtml, safeHref } from '../lib/calendar/cards';
11
12
  import { siteLang, siteLocale } from '../lib/dates';
12
- import { I18N, requestLocale } from '../lib/i18n';
13
- import { aroundCount, rsvpStrings } from '../lib/calendar/rsvp-words';
13
+ import { I18N, localePath, requestLocale } from '../lib/i18n';
14
+ import { aroundCount, deadlineParts, rsvpStrings } from '../lib/calendar/rsvp-words';
15
+ import { eventStrings } from '../lib/calendar/event-words';
16
+ import { fill } from '../lib/live/words';
14
17
  import { eventImage } from '../lib/calendar/images';
15
18
  import { directionsUrl, mapAttribution, MAP_HEIGHT, MAP_WIDTH } from '../lib/maps/service';
16
19
 
@@ -23,8 +26,10 @@ interface Props {
23
26
  upcoming: (Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'> & { cancelled?: boolean })[];
24
27
  origin: string;
25
28
  preview?: boolean;
26
- // RSVP state when the event takes RSVPs (null otherwise)
27
- rsvp?: { going: number; left: number | null; maxSeats: number; error: string; open: boolean; formHtml?: string; fieldsHtml?: string; values?: { name: string; email: string; seats: string } } | null;
29
+ // RSVP state when the event takes RSVPs (null otherwise). closesAt: this
30
+ // date's RSVP deadline (ms; null = open until it ends); duplicate: the
31
+ // address already has an RSVP for it (error then holds the same words)
32
+ rsvp?: { going: number; left: number | null; maxSeats: number; error: string; open: boolean; closesAt?: number | null; duplicate?: boolean; formHtml?: string; fieldsHtml?: string; values?: { name: string; email: string; seats: string } } | null;
28
33
  }
29
34
  const { event: e, occurrence: o, settings, calendars, html, upcoming, origin, preview = false, rsvp = null } = Astro.props;
30
35
  // the whole event, or just this date of a repeating one
@@ -33,12 +38,18 @@ const image = eventImage(e, settings);
33
38
  const tz = eventTimeZone(e, settings);
34
39
  const locale = siteLocale(settings);
35
40
  const f = formatOccurrence(o, tz, locale);
36
- const pageUrl = origin + eventUrl(e.slug);
41
+ // the page's language, as the layout's: its built-in words, and links that
42
+ // keep the visitor in it (/es/events/…)
43
+ const reqLocale = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
44
+ const lang = reqLocale || siteLang(settings);
45
+ const ew = eventStrings(lang);
46
+ const here = (path: string) => localePath(path, reqLocale);
47
+ const pageUrl = origin + here(eventUrl(e.slug));
37
48
  const links = addToCalendarLinks(e, o, { pageUrl, icsUrl: eventIcsUrl(e.slug), tz });
38
49
  const repeats = describeRepeat(e.rrule, e.start, (ymd) => {
39
50
  const [y, m, d] = ymd.split('-').map(Number);
40
51
  return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
41
- });
52
+ }, lang);
42
53
  const ctaHref = safeHref(e.cta.href);
43
54
  const mapHref = e.location.url
44
55
  ? safeHref(e.location.url)
@@ -50,7 +61,9 @@ const loc = e.location;
50
61
  const showMap = loc.showMap !== false && Boolean(loc.mapImage) && typeof loc.lat === 'number' && typeof loc.lng === 'number';
51
62
  const directions = showMap ? safeHref(directionsUrl(loc)) : '';
52
63
  const mapCredit = showMap ? mapAttribution(loc.mapImage) : '';
53
- const mapLabel = loc.name || loc.address || 'the location';
64
+ const place = loc.name || loc.address;
65
+ const directionsLabel = place ? fill(ew.directionsTo, { place }) : ew.directionsHere;
66
+ const mapAlt = place ? fill(ew.mapOf, { place }) : ew.mapHere;
54
67
  const fieldRows = CALENDAR.eventFields.filter((fd) => !fd.private && e.fields[fd.key]).map((fd) => ({ label: fd.label, value: e.fields[fd.key], type: fd.type }));
55
68
  const visibleTags = e.tags.filter((t) => !t.startsWith('#'));
56
69
  const I = {
@@ -63,10 +76,18 @@ const I = {
63
76
  video: 'M3 7h12v10H3zM15 10l6-3v10l-6-3',
64
77
  };
65
78
  const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.test(e.location.url);
66
- // the RSVP block speaks the page's language, as the layout does
67
- const w = rsvpStrings((I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '') || siteLang(settings));
79
+ // the RSVP block speaks the page's language too
80
+ const w = rsvpStrings(lang);
68
81
  const going = rsvp ? aroundCount(rsvp.going, w.personGoing, w.peopleGoing) : ['', ''];
69
82
  const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.spotsLeft) : ['', ''];
83
+ const taking = Boolean(rsvp?.open) && (rsvp!.left === null || rsvp!.left > 0);
84
+ // "RSVPs close Fri, Oct 24 at 9 PM" while open; once past, "They closed on Fri, Oct 24."
85
+ const deadline = typeof rsvp?.closesAt === 'number' ? deadlineParts(rsvp.closesAt, tz, locale) : null;
86
+ const closesLine = deadline && taking ? fill(w.closesOn, deadline) : '';
87
+ const closedLine = deadline && !rsvp!.open && !cancelled && rsvp!.closesAt! <= Date.now() ? fill(w.closedOn, deadline) : '';
88
+ const dateCancelled = fill(ew.dateCancelled, { date: f.date });
89
+ const showing = fill(ew.showing, { date: f.date });
90
+ const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: true, href: here(eventUrl(u.event.slug)) })).join('');
70
91
  ---
71
92
 
72
93
  <article class={`pc-ev-page${cancelled ? ' is-cancelled' : ''}`}>
@@ -79,19 +100,19 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
79
100
  {(calendars.length > 0 || visibleTags.length > 0) && (
80
101
  <div class="pc-ev-chips">
81
102
  {calendars.map((c) => (
82
- <a class="pc-ev-chip" href={calendarPageUrl(c.slug)}><i style={`background:${safeColor(c.color)}`} aria-hidden="true" />{c.name}</a>
103
+ <a class="pc-ev-chip" href={here(calendarPageUrl(c.slug))}><i style={`background:${safeColor(c.color)}`} aria-hidden="true" />{c.name}</a>
83
104
  ))}
84
105
  {visibleTags.map((t) => <span class="pc-ev-chip is-tag">{t}</span>)}
85
106
  </div>
86
107
  )}
87
108
  <h1 class="pc-ev-title">{e.title}</h1>
88
- {e.cancelled && <p class="pc-ev-cancelled"><b>This event is cancelled.</b> It stays listed so nobody is left wondering.</p>}
89
- {!e.cancelled && cancelled && <p class="pc-ev-cancelled"><b>Cancelled on {f.date}.</b> Other dates go ahead as usual.</p>}
109
+ {e.cancelled && <p class="pc-ev-cancelled"><b>{ew.eventCancelled}</b> {ew.eventCancelledNote}</p>}
110
+ {!e.cancelled && cancelled && <p class="pc-ev-cancelled"><b>{dateCancelled}</b> {ew.dateCancelledNote}</p>}
90
111
  {e.excerpt && <p class="pc-ev-lede">{e.excerpt}</p>}
91
112
  <div class="pc-ev-body post-content" set:html={html} />
92
113
  </div>
93
114
 
94
- <aside class="pc-ev-side" aria-label="Event details">
115
+ <aside class="pc-ev-side" aria-label={ew.details}>
95
116
  <div class="pc-ev-date">
96
117
  <span class="pc-ev-tile" aria-hidden="true"><b>{f.month}</b><span>{f.day}</span></span>
97
118
  <div>
@@ -107,7 +128,7 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
107
128
  {(e.location.address || mapHref) && (
108
129
  <span>
109
130
  {e.location.address}
110
- {mapHref && <>{e.location.address && ' · '}<a href={mapHref} target="_blank" rel="noopener">{online ? 'Join online' : 'Map'}</a></>}
131
+ {mapHref && <>{e.location.address && ' · '}<a href={mapHref} target="_blank" rel="noopener">{online ? ew.joinOnline : ew.map}</a></>}
111
132
  </span>
112
133
  )}
113
134
  </div>
@@ -115,9 +136,9 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
115
136
  )}
116
137
  {showMap && (
117
138
  <figure class="pc-ev-map">
118
- <a href={directions || undefined} target="_blank" rel="noopener" aria-label={`Directions to ${mapLabel}`}>
119
- <img class:list={['pc-ev-map-img', { 'is-light': Boolean(loc.mapImageDark) }]} src={loc.mapImage} alt={`Map of ${mapLabel}`} width={MAP_WIDTH} height={MAP_HEIGHT} loading="lazy" decoding="async" />
120
- {loc.mapImageDark && <img class="pc-ev-map-img is-dark" src={loc.mapImageDark} alt={`Map of ${mapLabel}`} width={MAP_WIDTH} height={MAP_HEIGHT} loading="lazy" decoding="async" />}
139
+ <a href={directions || undefined} target="_blank" rel="noopener" aria-label={directionsLabel}>
140
+ <img class:list={['pc-ev-map-img', { 'is-light': Boolean(loc.mapImageDark) }]} src={loc.mapImage} alt={mapAlt} width={MAP_WIDTH} height={MAP_HEIGHT} loading="lazy" decoding="async" />
141
+ {loc.mapImageDark && <img class="pc-ev-map-img is-dark" src={loc.mapImageDark} alt={mapAlt} width={MAP_WIDTH} height={MAP_HEIGHT} loading="lazy" decoding="async" />}
121
142
  </a>
122
143
  {mapCredit && <figcaption class="pc-ev-map-credit">{mapCredit}</figcaption>}
123
144
  </figure>
@@ -125,7 +146,7 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
125
146
  {repeats && (
126
147
  <div class="pc-ev-fact">
127
148
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.repeat} /></svg>
128
- <div><b>{repeats}</b><span>Showing {f.date}</span></div>
149
+ <div><b>{repeats}</b><span>{showing}</span></div>
129
150
  </div>
130
151
  )}
131
152
  {fieldRows.map((r) => (
@@ -137,14 +158,15 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
137
158
  {rsvp ? (
138
159
  <div class="pc-ev-rsvp">
139
160
  <p class="pc-ev-going">{going[0]}<b>{rsvp.going}</b>{going[1]}{rsvp.left !== null && <> · {left[0]}<b>{rsvp.left}</b>{left[1]}</>}</p>
140
- {rsvp.open && (rsvp.left === null || rsvp.left > 0) ? (
161
+ {closesLine && <p class="pc-ev-closes">{closesLine}</p>}
162
+ {taking ? (
141
163
  <details class="pc-ev-rsvpform" open={Boolean(rsvp.error)}>
142
164
  <summary class="pc-ev-cta">{w.saveSeat}</summary>
143
165
  <form method="post">
144
166
  <Fragment set:html={rsvp.formHtml ?? ''}/>
145
167
  {e.rsvp.form?.title && <h3>{e.rsvp.form.title}</h3>}
146
168
  {e.rsvp.form?.intro && <p>{e.rsvp.form.intro}</p>}
147
- {rsvp.error && <p class="pc-bk-error">{rsvp.error}</p>}
169
+ {rsvp.duplicate ? <p class="pc-ev-notice" role="status"><b>{w.duplicate}</b> {w.duplicateNote}</p> : rsvp.error && <p class="pc-bk-error">{rsvp.error}</p>}
148
170
  <label class="pc-bk-field"><span>{w.nameField}</span><input name="name" required maxlength="120" autocomplete="name" value={rsvp.values?.name} /></label>
149
171
  <label class="pc-bk-field"><span>{w.emailField}</span><input name="email" type="email" required maxlength="200" autocomplete="email" value={rsvp.values?.email} /></label>
150
172
  {rsvp.maxSeats > 1 && (
@@ -157,6 +179,7 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
157
179
  </form>
158
180
  </details>
159
181
  ) : <p class="pc-ev-full">{rsvp.open ? w.full : w.closed}</p>}
182
+ {closedLine && <p class="pc-ev-closes">{closedLine}</p>}
160
183
  </div>
161
184
  ) : e.cta.label && ctaHref && !cancelled && (
162
185
  <a class={`pc-ev-cta${e.cta.style === 'outline' ? ' is-outline' : ''}`} href={ctaHref}>{e.cta.label}</a>
@@ -164,33 +187,33 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
164
187
  <details class="pc-ev-add">
165
188
  <summary>
166
189
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.cal} /></svg>
167
- Add to calendar
190
+ {ew.addToCalendar}
168
191
  </summary>
169
192
  <div class="pc-ev-add-menu">
170
193
  <a href={links.google} target="_blank" rel="noopener">Google Calendar</a>
171
194
  <a href={links.ics}>Apple Calendar</a>
172
195
  <a href={links.outlook} target="_blank" rel="noopener">Outlook</a>
173
- <a href={links.ics} download={`${e.slug}.ics`}>Download .ics</a>
196
+ <a href={links.ics} download={`${e.slug}.ics`}>{ew.downloadIcs}</a>
174
197
  </div>
175
198
  </details>
176
- <button type="button" class="pc-ev-share" data-url={pageUrl} data-title={e.title}>
199
+ <button type="button" class="pc-ev-share" data-url={pageUrl} data-title={e.title} data-share={ew.share} data-copied={ew.linkCopied}>
177
200
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.share} /></svg>
178
- <span>Share</span>
201
+ <span>{ew.share}</span>
179
202
  </button>
180
203
  </aside>
181
204
  </div>
182
205
 
183
206
  {upcoming.length > 0 && (
184
207
  <section class="pc-ev-more">
185
- <h2>More events</h2>
186
- <div class="pc-ev-cards" data-layout="grid" set:html={upcoming.map((u) => eventCardHtml(u, settings, { showImage: true })).join('')} />
187
- <a class="pc-ev-all" href={CALENDAR.base}>See the full calendar →</a>
208
+ <h2>{ew.moreEvents}</h2>
209
+ <div class="pc-ev-cards" data-layout="grid" set:html={moreCards} />
210
+ <a class="pc-ev-all" href={here(CALENDAR.base)}>{ew.fullCalendar}</a>
188
211
  </section>
189
212
  )}
190
213
  </article>
191
214
 
192
215
  <script>
193
- // share sheet where available, else copy the link
216
+ // share sheet where available, else copy the link (words from the page's language)
194
217
  document.querySelectorAll<HTMLButtonElement>('.pc-ev-share').forEach((b) => b.addEventListener('click', async () => {
195
218
  const url = b.dataset.url ?? location.href;
196
219
  if (navigator.share) {
@@ -198,7 +221,7 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
198
221
  }
199
222
  await navigator.clipboard.writeText(url);
200
223
  const label = b.querySelector('span');
201
- if (label) { label.textContent = 'Link copied'; setTimeout(() => (label.textContent = 'Share'), 1400); }
224
+ if (label) { label.textContent = b.dataset.copied ?? 'Link copied'; setTimeout(() => (label.textContent = b.dataset.share ?? 'Share'), 1400); }
202
225
  }));
203
226
  // one open menu; outside click closes
204
227
  document.addEventListener('click', (ev) => {