@acms-subir/core 0.3.40 → 0.3.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/cli/migrations.generated.mjs +5 -0
  3. package/docs/mobile.md +2 -1
  4. package/integration.mjs +2 -0
  5. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +66 -31
  6. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +66 -31
  7. package/mobile-template/recipes/rsvp/recipe.json +2 -2
  8. package/package.json +1 -1
  9. package/src/actions/index.ts +1 -1
  10. package/src/components/EventPage.astro +16 -8
  11. package/src/components/editor/EventPanel.tsx +2 -2
  12. package/src/components/editor/PostEditor.tsx +1 -1
  13. package/src/components/editor/event/RegistrationCard.tsx +5 -0
  14. package/src/components/editor/event/registration.ts +1 -1
  15. package/src/lib/booking/agent.ts +1 -1
  16. package/src/lib/booking/emails.ts +20 -5
  17. package/src/lib/booking/service.ts +148 -14
  18. package/src/lib/calendar/rsvp-words.ts +69 -8
  19. package/src/lib/content-service.ts +11 -6
  20. package/src/lib/drafts.ts +2 -2
  21. package/src/lib/editor/bookings.ts +5 -3
  22. package/src/lib/editor/documents.ts +3 -2
  23. package/src/lib/editor/schemas.ts +1 -1
  24. package/src/lib/email/booking-words.ts +26 -2
  25. package/src/lib/email/store.ts +2 -2
  26. package/src/lib/forms/events.ts +4 -2
  27. package/src/lib/legal/app-privacy.ts +2 -2
  28. package/src/lib/legal/text-en.ts +1 -1
  29. package/src/lib/legal/text-es.ts +1 -1
  30. package/src/lib/mobile/bookings.ts +4 -3
  31. package/src/lib/mobile/manage.ts +17 -3
  32. package/src/lib/mobile/openapi.ts +4 -2
  33. package/src/lib/mobile/rsvp.ts +10 -5
  34. package/src/lib/mobile/schemas.ts +10 -3
  35. package/src/lib/repo/migrations.ts +5 -0
  36. package/src/lib/repo/sql.ts +53 -13
  37. package/src/lib/repo/types.ts +11 -4
  38. package/src/lib/scheduler.ts +3 -1
  39. package/src/lib/schema.ts +10 -2
  40. package/src/pages/admin/calendar/bookings.astro +57 -13
  41. package/src/pages/api/calendar/bookings/index.ts +1 -1
  42. package/src/pages/api/calendar/docs.ts +2 -2
  43. package/src/pages/api/v1/bookings/manage/confirm.ts +6 -0
  44. package/src/pages/book/manage/[token].astro +57 -15
  45. package/src/pages/events/[slug].astro +3 -2
  46. package/src/styles/admin.css +3 -1
  47. package/src/styles/site.css +10 -1
  48. package/src/theme-sdk/index.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.41
4
+
5
+ ### Patch Changes
6
+
7
+ - 4fed854: Events can keep a waitlist. Turn on **Offer a waitlist when it's full** in the event's CTA card (it needs a number of seats; it's off for every event, including existing ones). When a date is full, the event page says "Full", "40 going · no spots left" and "Join the waitlist and we'll email you if a spot opens.", and **Join the waitlist** opens the same form: name, email, seats and the event's questions. A place in line takes no seats, counts as that email's RSVP for the date (a second one gets the usual "You already have an RSVP" notice), and can only be taken while RSVPs are open. The person gets "You're on the waitlist" by email, with their place in line, and their RSVP's page shows "#3 in line · 2 seats" and **Leave the waitlist**. Staff get no email for it.
8
+
9
+ When seats free up (someone cancels or is declined, staff raise the number of seats, or a held spot runs out), the next people in line whose seats fit are offered them, in the order they joined; a larger request keeps its place. Each gets "A spot opened" by email, and the spot is held for them for 24 hours: their page says "A spot is yours until Fri, Oct 24 at 9 PM" with **Confirm my spot** and **Release it**. Confirming makes it an RSVP like any other (waiting for approval when the event approves RSVPs), with the usual confirmation and the hosts' "New RSVP" email. A spot nobody confirms in time is released and goes to the next person. Offers keep going after the RSVP deadline, since those in line joined in time, but stop once the date starts. Each offer counts the seats in the same database write, so offers, cancellations and new RSVPs at once never go over the limit. The scheduler checks every minute. Emails are in the language the person used; a cancelled date or event cancels the line too.
10
+
11
+ Bookings has a **Waitlist** tab and a Waitlist group under Upcoming, with "#2 in line", "Spot offered" (and until when) and "Hold expired"; staff can remove someone or take back a held spot, and an event's registrations say "40 going · 3 waiting". The app API takes `waitlist: true` on an RSVP to a full date (the booking's status is `waitlisted`), and the REST booking API does too. `GET /api/v1/events/:slug/rsvp` has `waitlist` (`enabled`, `waiting`, `seats`, `maxSeats`), and held spots count as taken in `going` and `left`. Bookings have three new statuses, `waitlisted`, `offered` and `expired`; `GET /api/v1/bookings/manage` adds `booking.position`, `booking.holdUntil` and `can.confirm`, and the new `POST /api/v1/bookings/manage/confirm` takes a held spot (410 once it ran out). Editor documents and the events API carry `rsvp.waitlist`; saves that leave it out keep it. The editor app's bookings inbox filters by `waitlist`. Theme event templates get `rsvp.waitlist`. Migration 61 adds `bookings.hold_until`.
12
+
13
+ The privacy notice now says that when a date is full and someone joins its waitlist, the request is kept like an RSVP and a freed spot is held for 24 hours, so its "Last updated" date moves; the app store answers mention waitlist requests. The RSVP panel in the app recipes shows the waitlist too; update it with `npx acms mobile recipe add rsvp --force`, which overwrites `components/RsvpPanel.tsx`, including any changes you made to it, and needs this release's `@acms-subir/native`.
14
+
3
15
  ## 0.3.40
4
16
 
5
17
  ### Patch Changes
@@ -1116,6 +1116,11 @@ var MIGRATIONS = [
1116
1116
  // emails and links follow it; '' = the main language, so older rows need no backfill
1117
1117
  { version: 60, statements: [
1118
1118
  `ALTER TABLE bookings ADD COLUMN locale TEXT NOT NULL DEFAULT ''`
1119
+ ] },
1120
+ // RSVP waitlists: an offered spot is held until hold_until (ms); null for
1121
+ // everything else, so older rows need no backfill
1122
+ { version: 61, statements: [
1123
+ `ALTER TABLE bookings ADD COLUMN hold_until INTEGER`
1119
1124
  ] }
1120
1125
  ];
1121
1126
  var CURRENT_CONTENT_VERSION_COLUMNS = {
package/docs/mobile.md CHANGED
@@ -120,8 +120,9 @@ 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`, `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`) |
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. A full date whose event keeps a waitlist (`canJoinWaitlist(status)`) takes a place in line: send the details with `waitlist: true` (seats from `waitlistSeatChoices(status)`); the booking comes back `waitlisted`. 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
+ | `useConfirmBooking(token)` | an RSVP on a waitlist: `waitlisted` shows "#3 in line · 2 seats" (`waitlistLineText(booking, words, locale)`) and can leave (`useCancelBooking`); `offered` holds a spot until `booking.holdUntil` (`holdText`: "We're holding it until Fri, Oct 24 at 9 PM") and `can.confirm` takes it. `isReleased(error)`: the hold ran out (410) and the spot went to the next person |
125
126
  | `useGuest()` / `useMyBookings({ when })` | guest sign-in with an emailed code, then every booking made with that email; check `features.guestSignIn` |
126
127
  | `useLinks()` / `useForm(id)` / `useSubmitForm(id)` | the links page; published forms and sending answers; check `features.links` / `features.forms` |
127
128
  | `useLive()` | live video: `snapshot`, `status` (`live`/`upcoming`/`offline`), `isLive`, localized `view` text, the owner's `display` choices (`style`, `bar`, `header`, `dock`). Polls every 30 s only in the foreground, rechecks on resume, drops an expired "live" even offline; idle while `features.live` is false. Render `<LiveCard>` / `<LiveTag>` |
package/integration.mjs CHANGED
@@ -851,6 +851,8 @@ export default function acms(options = {}) {
851
851
  // the booker's own booking from an app (token in the Booking-Token header)
852
852
  ['/api/v1/bookings/manage', 'api/v1/bookings/manage/index.ts'],
853
853
  ['/api/v1/bookings/manage/cancel', 'api/v1/bookings/manage/cancel.ts'],
854
+ // a spot offered from an RSVP's waitlist, taken up
855
+ ['/api/v1/bookings/manage/confirm', 'api/v1/bookings/manage/confirm.ts'],
854
856
  ['/api/v1/bookings/manage/availability', 'api/v1/bookings/manage/availability.ts'],
855
857
  ['/api/v1/bookings/manage/reschedule', 'api/v1/bookings/manage/reschedule.ts'],
856
858
  ['/api/v1/bookings/manage/ics', 'api/v1/bookings/manage/ics.ts'],
@@ -1,16 +1,19 @@
1
- import { Fragment, useEffect, useState, type ReactNode } from 'react';
1
+ import { Fragment, useEffect, useRef, 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, rsvpDeadlineText, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme } from '@acms-subir/native';
6
+ import { Button, answerProblemText, answerProblems, canJoinWaitlist, countWords, fill, holdText, isReleased, rsvpDeadlineText, seatChoices, useCancelBooking, useConfirmBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme, waitlistLineText, waitlistSeatChoices } 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
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
13
+ // per email and date, and the organiser may approve each RSVP. A full date
14
+ // whose event keeps a waitlist offers "Join the waitlist" with the same form;
15
+ // then it shows their place in line ("#3 in line · 2 seats"), and when a spot
16
+ // opens, until when it's held, with Confirm my spot and Release it. useRsvp keeps
14
17
  // one idempotency key per RSVP, so sending again after a timeout can't
15
18
  // register anyone twice. Its words come from
16
19
  // useStrings() in the app's language; the event's own form title, labels and
@@ -37,9 +40,11 @@ const withOther = (f: FormField, value: string | string[] | undefined, other: st
37
40
  };
38
41
  // a count in bold inside its words ("{count} going")
39
42
  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));
43
+ // the site holds a spot freed from the waitlist this long
44
+ const HOLD_HOURS = '24';
40
45
 
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
46
+ // children: shown under the person's RSVP (confirmed or waiting for approval,
47
+ // not a place in line), e.g. an offer to get notified if the date, time or place changes
43
48
  export function RsvpPanel({ slug, start, children }: { slug: string; start: string; children?: ReactNode }) {
44
49
  const t = useTheme();
45
50
  const words = useStrings();
@@ -55,6 +60,7 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
55
60
  const changes = site?.features.bookingChanges === true;
56
61
  const managed = useManagedBooking(changes ? stored : null);
57
62
  const cancel = useCancelBooking(stored);
63
+ const take = useConfirmBooking(stored);
58
64
  const [confirming, setConfirming] = useState(false);
59
65
  const [notice, setNotice] = useState('');
60
66
  const [name, setName] = useState('');
@@ -77,13 +83,17 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
77
83
  }, [site?.features.guestSignIn, guest.email]);
78
84
  // cancelled (here, from the email link or by the organiser) or gone: forget the token
79
85
  const gone = managed.data?.booking.status === 'cancelled' || (isAcmsError(managed.error) && managed.error.status === 404);
86
+ // whether it was a place in line or a held spot before then
87
+ const waited = useRef(false);
88
+ if (managed.data && managed.data.booking.status !== 'cancelled') waited.current = ['waitlisted', 'offered'].includes(managed.data.booking.status);
80
89
  useEffect(() => {
81
90
  if (!key || !stored || !gone) return;
82
91
  void tokens.remove(key);
83
92
  setStored(null);
84
93
  setConfirming(false);
85
94
  rsvp.reset();
86
- if (managed.data?.booking.status === 'cancelled') setNotice(words.rsvpCancelled);
95
+ take.reset();
96
+ if (managed.data?.booking.status === 'cancelled') setNotice(waited.current ? words.offWaitlist : words.rsvpCancelled);
87
97
  }, [key, stored, gone]);
88
98
 
89
99
  if (!s) return status.isPending ? <ActivityIndicator color={t.colors.accent} /> : null;
@@ -94,10 +104,12 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
94
104
  const seatsText = (n: number) => countWords(n, words.seat, words.seats, locale);
95
105
  // "RSVPs close Fri, Oct 24 at 9 PM", or once past it "They closed on …"
96
106
  const deadline = rsvpDeadlineText(s, words, locale);
107
+ // full, and the event keeps a waitlist: the same form takes a place in line
108
+ const joining = canJoinWaitlist(s);
97
109
  const going = (
98
110
  <Text style={t.styles.text.body}>
99
111
  {bold(s.going === 1 ? words.personGoing : words.peopleGoing, num(s.going))}
100
- {s.left !== null && <> · {bold(s.left === 1 ? words.spotLeft : words.spotsLeft, num(s.left))}</>}
112
+ {s.left !== null && <> · {joining ? words.noSpotsLeft : bold(s.left === 1 ? words.spotLeft : words.spotsLeft, num(s.left))}</>}
101
113
  </Text>
102
114
  );
103
115
 
@@ -114,39 +126,59 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
114
126
  </View>
115
127
  );
116
128
  if ((stored && mine) || made) {
117
- const state = mine?.booking.status ?? made!.status;
129
+ // a held spot that ran out (before the site's next sweep, or while they confirmed it) reads as released
130
+ const ranOut = isReleased(take.error) || (mine?.booking.status === 'offered' && !mine.can.confirm);
131
+ const state = ranOut ? 'expired' : mine?.booking.status ?? made!.status;
132
+ const seats = mine?.booking.seats ?? made!.seats;
118
133
  const who = mine?.booking.email ?? made!.email;
119
134
  const thanks = mine ? mine.confirmation : made!.formResponse?.definition.confirmation || null;
120
135
  const canCancel = !!stored && changes && (mine ? mine.can.cancel : false);
136
+ // declined, or a held spot they didn't confirm in time: nothing left to change
137
+ const over = state === 'declined' || state === 'expired';
138
+ const forget = () => { if (key) void tokens.remove(key); setStored(null); rsvp.reset(); take.reset(); };
139
+ // leaving the line or releasing a held spot asks first, like cancelling
140
+ const leave = state === 'waitlisted' ? { label: words.leaveWaitlist, ask: words.leaveWaitlistAsk, busy: words.leaving }
141
+ : state === 'offered' ? { label: words.releaseSpot, ask: words.releaseSpotAsk, busy: words.releasing }
142
+ : { label: words.cancelRsvp, ask: fill(words.cancelRsvpFor, { seats: seatsText(seats) }), busy: words.cancelling };
143
+ const heading = state === 'waitlisted' ? words.onWaitlist : state === 'offered' ? words.spotOpened : state === 'expired' ? words.spotReleased
144
+ : !made ? words.yourRsvp : state === 'confirmed' ? words.confirmed : words.requestSent;
145
+ const line = state === 'waitlisted' ? waitlistLineText({ position: mine?.booking.position, seats }, words, locale)
146
+ : state === 'offered' || state === 'expired' ? seatsText(seats)
147
+ : `${seatsText(seats)} · ${state === 'confirmed' ? words.confirmed : state === 'pending' ? words.awaitingApproval : words.notConfirmed}`;
148
+ const body = state === 'waitlisted' ? fill(words.waitlistPromise, { hours: HOLD_HOURS })
149
+ : state === 'offered' ? holdText(mine?.booking.holdUntil, words, locale)
150
+ : state === 'expired' ? fill(words.spotReleasedNote, { hours: HOLD_HOURS })
151
+ : state === 'confirmed' ? thanks || fill(words.confirmationOnItsWay, { email: who })
152
+ : state === 'pending' ? fill(words.emailWhenApproved, { email: who }) : words.rsvpDeclined;
153
+ const failed = (e: unknown) => (isAcmsError(e) && e.status === 0 ? words.checkConnection : e instanceof Error ? e.message : '');
121
154
  return (<>
122
155
  <View style={[t.styles.card, { gap: 8 }]}>
123
- <Text accessibilityRole="header" style={t.styles.text.heading}>{!made ? words.yourRsvp : state === 'confirmed' ? words.confirmed : words.requestSent}</Text>
124
- <Text style={[t.styles.text.label, { color: t.colors.ink }]}>
125
- {seatsText(mine?.booking.seats ?? made!.seats)} · {state === 'confirmed' ? words.confirmed : state === 'pending' ? words.awaitingApproval : words.notConfirmed}
126
- </Text>
127
- <Text style={t.styles.text.body}>
128
- {state === 'confirmed' ? thanks || fill(words.confirmationOnItsWay, { email: who })
129
- : state === 'pending' ? fill(words.emailWhenApproved, { email: who }) : words.rsvpDeclined}
130
- </Text>
131
- {state === 'declined' && <Button label={words.rsvpAgain} variant="outline" onPress={() => { if (key) void tokens.remove(key); setStored(null); rsvp.reset(); }} />}
132
- {canCancel && state !== 'declined' && (confirming ? (
156
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{heading}</Text>
157
+ <Text style={[t.styles.text.label, { color: t.colors.ink }]}>{line}</Text>
158
+ {!!body && <Text style={t.styles.text.body}>{body}</Text>}
159
+ {state === 'offered' && !confirming && (
160
+ <Button label={take.isPending ? words.confirming : words.confirmSpot} disabled={take.isPending} onPress={() => take.mutate()} />
161
+ )}
162
+ {over && <Button label={words.rsvpAgain} variant="outline" onPress={forget} />}
163
+ {canCancel && !over && (confirming ? (
133
164
  <View style={{ gap: 8 }}>
134
- <Text style={t.styles.text.body}>{fill(words.cancelRsvpFor, { seats: seatsText(mine!.booking.seats) })}</Text>
165
+ <Text style={t.styles.text.body}>{leave.ask}</Text>
135
166
  <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
136
- <Button label={cancel.isPending ? words.cancelling : words.yesCancel} disabled={cancel.isPending} onPress={() => cancel.mutate()} />
167
+ <Button label={cancel.isPending ? leave.busy : state === 'waitlisted' || state === 'offered' ? leave.label : words.yesCancel} disabled={cancel.isPending} onPress={() => cancel.mutate()} />
137
168
  <Button label={words.keepIt} variant="outline" disabled={cancel.isPending} onPress={() => setConfirming(false)} />
138
169
  </View>
139
170
  </View>
140
- ) : <Button label={words.cancelRsvp} variant="outline" onPress={() => setConfirming(true)} />)}
141
- {cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? words.checkConnection : cancel.error.message}</Text>}
142
- {!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>{words.changeByEmail}</Text>}
171
+ ) : <Button label={leave.label} variant="outline" disabled={take.isPending} onPress={() => setConfirming(true)} />)}
172
+ {cancel.error && <Text style={errorText}>{failed(cancel.error)}</Text>}
173
+ {take.error && !isReleased(take.error) && <Text style={errorText}>{failed(take.error)}</Text>}
174
+ {!canCancel && !over && <Text style={t.styles.text.small}>{words.changeByEmail}</Text>}
143
175
  </View>
144
- {state !== 'declined' && children}
176
+ {(state === 'confirmed' || state === 'pending') && children}
145
177
  </>);
146
178
  }
147
179
 
148
180
  if (s.reason === 'disabled') return null;
149
- if (!s.open) return (
181
+ if (!s.open && !joining) return (
150
182
  <View style={[t.styles.card, { gap: 6 }]}>
151
183
  <Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ? words.full : s.reason === 'cancelled' ? words.dateCancelled : words.rsvpClosed}</Text>
152
184
  {s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : s.reason === 'closed' ? deadline || words.rsvpClosed : words.dateEnded}</Text>}
@@ -162,12 +194,14 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
162
194
  const emailProblem = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ? words.emailProblem : '';
163
195
  // the form may ask for a phone itself
164
196
  const askPhone = !fields.some((f) => f.type === 'phone');
165
- const chosen = Math.min(seats, s.maxSeats) || 1;
197
+ // a place in line may ask for the event's own limit (the status's maxSeats is what's left)
198
+ const choices = joining ? waitlistSeatChoices(s) : seatChoices(s);
199
+ const chosen = Math.min(seats, choices.length) || 1;
166
200
  const submit = () => {
167
201
  setTried(true);
168
202
  setNotice('');
169
203
  if (nameProblem || emailProblem || Object.keys(problems).length) return;
170
- rsvp.mutate({ name, email, phone: askPhone ? phone : '', seats: chosen, answers }, {
204
+ rsvp.mutate({ name, email, phone: askPhone ? phone : '', seats: chosen, answers, waitlist: joining }, {
171
205
  onSuccess: (data) => { if (key) void tokens.set(key, data.manageToken); setStored(data.manageToken); },
172
206
  });
173
207
  };
@@ -243,20 +277,21 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
243
277
  return (
244
278
  <View style={[t.styles.card, { gap: 16 }]}>
245
279
  <View style={{ gap: 4 }}>
246
- <Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || words.rsvpTitle}</Text>
280
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{joining ? words.full : form?.title || words.rsvpTitle}</Text>
247
281
  {going}
248
282
  {!!deadline && <Text style={t.styles.text.muted}>{deadline}</Text>}
283
+ {joining && <Text style={t.styles.text.muted}>{words.waitlistNote}</Text>}
249
284
  {!!notice && <Text style={t.styles.text.muted}>{notice}</Text>}
250
285
  {!!form?.intro && <Text style={t.styles.text.muted}>{form.intro}</Text>}
251
286
  </View>
252
287
  {text(words.nameField, name, setName, { required: true, auto: 'name', problem: tried ? nameProblem : '' })}
253
288
  {text(words.emailField, email, setEmail, { required: true, keyboard: 'email-address', auto: 'email', problem: tried ? emailProblem : '' })}
254
289
  {askPhone && text(words.phoneField, phone, setPhone, { keyboard: 'phone-pad', auto: 'tel' })}
255
- {s.maxSeats > 1 && (
290
+ {choices.length > 1 && (
256
291
  <View style={{ gap: 6 }}>
257
292
  {label(words.seatsField, false)}
258
293
  <View accessibilityRole="radiogroup" style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
259
- {seatChoices(s).map((n) => (
294
+ {choices.map((n) => (
260
295
  <Pressable key={n} accessibilityRole="radio" accessibilityLabel={seatsText(n)} accessibilityState={{ checked: n === chosen }} onPress={() => setSeats(n)} style={[chip(n === chosen), { minWidth: 48, alignItems: 'center' }]}>
261
296
  <Text style={[t.styles.text.body, n === chosen && { fontWeight: '600' }]}>{num(n)}</Text>
262
297
  </Pressable>
@@ -274,7 +309,7 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
274
309
  </View>
275
310
  )}
276
311
  {tried && (nameProblem || emailProblem || Object.keys(problems).length > 0) && <Text style={errorText}>{words.checkAnswers}</Text>}
277
- <Button label={rsvp.isPending ? words.sending : form?.submitLabel || (s.autoConfirm ? words.confirmSeat : words.requestSeat)} disabled={rsvp.isPending} onPress={submit} />
312
+ <Button label={rsvp.isPending ? words.sending : joining ? words.joinWaitlist : form?.submitLabel || (s.autoConfirm ? words.confirmSeat : words.requestSeat)} disabled={rsvp.isPending} onPress={submit} />
278
313
  <Text style={t.styles.text.small}>{words.rsvpPrivacy}</Text>
279
314
  </View>
280
315
  );
@@ -1,16 +1,19 @@
1
- import { Fragment, useEffect, useState, type ReactNode } from 'react';
1
+ import { Fragment, useEffect, useRef, 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, rsvpDeadlineText, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme } from '@acms-subir/native';
6
+ import { Button, answerProblemText, answerProblems, canJoinWaitlist, countWords, fill, holdText, isReleased, rsvpDeadlineText, seatChoices, useCancelBooking, useConfirmBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme, waitlistLineText, waitlistSeatChoices } 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
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
13
+ // per email and date, and the organiser may approve each RSVP. A full date
14
+ // whose event keeps a waitlist offers "Join the waitlist" with the same form;
15
+ // then it shows their place in line ("#3 in line · 2 seats"), and when a spot
16
+ // opens, until when it's held, with Confirm my spot and Release it. useRsvp keeps
14
17
  // one idempotency key per RSVP, so sending again after a timeout can't
15
18
  // register anyone twice. Its words come from
16
19
  // useStrings() in the app's language; the event's own form title, labels and
@@ -37,9 +40,11 @@ const withOther = (f: FormField, value: string | string[] | undefined, other: st
37
40
  };
38
41
  // a count in bold inside its words ("{count} going")
39
42
  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));
43
+ // the site holds a spot freed from the waitlist this long
44
+ const HOLD_HOURS = '24';
40
45
 
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
46
+ // children: shown under the person's RSVP (confirmed or waiting for approval,
47
+ // not a place in line), e.g. an offer to get notified if the date, time or place changes
43
48
  export function RsvpPanel({ slug, start, children }: { slug: string; start: string; children?: ReactNode }) {
44
49
  const t = useTheme();
45
50
  const words = useStrings();
@@ -55,6 +60,7 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
55
60
  const changes = site?.features.bookingChanges === true;
56
61
  const managed = useManagedBooking(changes ? stored : null);
57
62
  const cancel = useCancelBooking(stored);
63
+ const take = useConfirmBooking(stored);
58
64
  const [confirming, setConfirming] = useState(false);
59
65
  const [notice, setNotice] = useState('');
60
66
  const [name, setName] = useState('');
@@ -77,13 +83,17 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
77
83
  }, [site?.features.guestSignIn, guest.email]);
78
84
  // cancelled (here, from the email link or by the organiser) or gone: forget the token
79
85
  const gone = managed.data?.booking.status === 'cancelled' || (isAcmsError(managed.error) && managed.error.status === 404);
86
+ // whether it was a place in line or a held spot before then
87
+ const waited = useRef(false);
88
+ if (managed.data && managed.data.booking.status !== 'cancelled') waited.current = ['waitlisted', 'offered'].includes(managed.data.booking.status);
80
89
  useEffect(() => {
81
90
  if (!key || !stored || !gone) return;
82
91
  void tokens.remove(key);
83
92
  setStored(null);
84
93
  setConfirming(false);
85
94
  rsvp.reset();
86
- if (managed.data?.booking.status === 'cancelled') setNotice(words.rsvpCancelled);
95
+ take.reset();
96
+ if (managed.data?.booking.status === 'cancelled') setNotice(waited.current ? words.offWaitlist : words.rsvpCancelled);
87
97
  }, [key, stored, gone]);
88
98
 
89
99
  if (!s) return status.isPending ? <ActivityIndicator color={t.colors.accent} /> : null;
@@ -94,10 +104,12 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
94
104
  const seatsText = (n: number) => countWords(n, words.seat, words.seats, locale);
95
105
  // "RSVPs close Fri, Oct 24 at 9 PM", or once past it "They closed on …"
96
106
  const deadline = rsvpDeadlineText(s, words, locale);
107
+ // full, and the event keeps a waitlist: the same form takes a place in line
108
+ const joining = canJoinWaitlist(s);
97
109
  const going = (
98
110
  <Text style={t.styles.text.body}>
99
111
  {bold(s.going === 1 ? words.personGoing : words.peopleGoing, num(s.going))}
100
- {s.left !== null && <> · {bold(s.left === 1 ? words.spotLeft : words.spotsLeft, num(s.left))}</>}
112
+ {s.left !== null && <> · {joining ? words.noSpotsLeft : bold(s.left === 1 ? words.spotLeft : words.spotsLeft, num(s.left))}</>}
101
113
  </Text>
102
114
  );
103
115
 
@@ -114,39 +126,59 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
114
126
  </View>
115
127
  );
116
128
  if ((stored && mine) || made) {
117
- const state = mine?.booking.status ?? made!.status;
129
+ // a held spot that ran out (before the site's next sweep, or while they confirmed it) reads as released
130
+ const ranOut = isReleased(take.error) || (mine?.booking.status === 'offered' && !mine.can.confirm);
131
+ const state = ranOut ? 'expired' : mine?.booking.status ?? made!.status;
132
+ const seats = mine?.booking.seats ?? made!.seats;
118
133
  const who = mine?.booking.email ?? made!.email;
119
134
  const thanks = mine ? mine.confirmation : made!.formResponse?.definition.confirmation || null;
120
135
  const canCancel = !!stored && changes && (mine ? mine.can.cancel : false);
136
+ // declined, or a held spot they didn't confirm in time: nothing left to change
137
+ const over = state === 'declined' || state === 'expired';
138
+ const forget = () => { if (key) void tokens.remove(key); setStored(null); rsvp.reset(); take.reset(); };
139
+ // leaving the line or releasing a held spot asks first, like cancelling
140
+ const leave = state === 'waitlisted' ? { label: words.leaveWaitlist, ask: words.leaveWaitlistAsk, busy: words.leaving }
141
+ : state === 'offered' ? { label: words.releaseSpot, ask: words.releaseSpotAsk, busy: words.releasing }
142
+ : { label: words.cancelRsvp, ask: fill(words.cancelRsvpFor, { seats: seatsText(seats) }), busy: words.cancelling };
143
+ const heading = state === 'waitlisted' ? words.onWaitlist : state === 'offered' ? words.spotOpened : state === 'expired' ? words.spotReleased
144
+ : !made ? words.yourRsvp : state === 'confirmed' ? words.confirmed : words.requestSent;
145
+ const line = state === 'waitlisted' ? waitlistLineText({ position: mine?.booking.position, seats }, words, locale)
146
+ : state === 'offered' || state === 'expired' ? seatsText(seats)
147
+ : `${seatsText(seats)} · ${state === 'confirmed' ? words.confirmed : state === 'pending' ? words.awaitingApproval : words.notConfirmed}`;
148
+ const body = state === 'waitlisted' ? fill(words.waitlistPromise, { hours: HOLD_HOURS })
149
+ : state === 'offered' ? holdText(mine?.booking.holdUntil, words, locale)
150
+ : state === 'expired' ? fill(words.spotReleasedNote, { hours: HOLD_HOURS })
151
+ : state === 'confirmed' ? thanks || fill(words.confirmationOnItsWay, { email: who })
152
+ : state === 'pending' ? fill(words.emailWhenApproved, { email: who }) : words.rsvpDeclined;
153
+ const failed = (e: unknown) => (isAcmsError(e) && e.status === 0 ? words.checkConnection : e instanceof Error ? e.message : '');
121
154
  return (<>
122
155
  <View style={[t.styles.card, { gap: 8 }]}>
123
- <Text accessibilityRole="header" style={t.styles.text.heading}>{!made ? words.yourRsvp : state === 'confirmed' ? words.confirmed : words.requestSent}</Text>
124
- <Text style={[t.styles.text.label, { color: t.colors.ink }]}>
125
- {seatsText(mine?.booking.seats ?? made!.seats)} · {state === 'confirmed' ? words.confirmed : state === 'pending' ? words.awaitingApproval : words.notConfirmed}
126
- </Text>
127
- <Text style={t.styles.text.body}>
128
- {state === 'confirmed' ? thanks || fill(words.confirmationOnItsWay, { email: who })
129
- : state === 'pending' ? fill(words.emailWhenApproved, { email: who }) : words.rsvpDeclined}
130
- </Text>
131
- {state === 'declined' && <Button label={words.rsvpAgain} variant="outline" onPress={() => { if (key) void tokens.remove(key); setStored(null); rsvp.reset(); }} />}
132
- {canCancel && state !== 'declined' && (confirming ? (
156
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{heading}</Text>
157
+ <Text style={[t.styles.text.label, { color: t.colors.ink }]}>{line}</Text>
158
+ {!!body && <Text style={t.styles.text.body}>{body}</Text>}
159
+ {state === 'offered' && !confirming && (
160
+ <Button label={take.isPending ? words.confirming : words.confirmSpot} disabled={take.isPending} onPress={() => take.mutate()} />
161
+ )}
162
+ {over && <Button label={words.rsvpAgain} variant="outline" onPress={forget} />}
163
+ {canCancel && !over && (confirming ? (
133
164
  <View style={{ gap: 8 }}>
134
- <Text style={t.styles.text.body}>{fill(words.cancelRsvpFor, { seats: seatsText(mine!.booking.seats) })}</Text>
165
+ <Text style={t.styles.text.body}>{leave.ask}</Text>
135
166
  <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
136
- <Button label={cancel.isPending ? words.cancelling : words.yesCancel} disabled={cancel.isPending} onPress={() => cancel.mutate()} />
167
+ <Button label={cancel.isPending ? leave.busy : state === 'waitlisted' || state === 'offered' ? leave.label : words.yesCancel} disabled={cancel.isPending} onPress={() => cancel.mutate()} />
137
168
  <Button label={words.keepIt} variant="outline" disabled={cancel.isPending} onPress={() => setConfirming(false)} />
138
169
  </View>
139
170
  </View>
140
- ) : <Button label={words.cancelRsvp} variant="outline" onPress={() => setConfirming(true)} />)}
141
- {cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? words.checkConnection : cancel.error.message}</Text>}
142
- {!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>{words.changeByEmail}</Text>}
171
+ ) : <Button label={leave.label} variant="outline" disabled={take.isPending} onPress={() => setConfirming(true)} />)}
172
+ {cancel.error && <Text style={errorText}>{failed(cancel.error)}</Text>}
173
+ {take.error && !isReleased(take.error) && <Text style={errorText}>{failed(take.error)}</Text>}
174
+ {!canCancel && !over && <Text style={t.styles.text.small}>{words.changeByEmail}</Text>}
143
175
  </View>
144
- {state !== 'declined' && children}
176
+ {(state === 'confirmed' || state === 'pending') && children}
145
177
  </>);
146
178
  }
147
179
 
148
180
  if (s.reason === 'disabled') return null;
149
- if (!s.open) return (
181
+ if (!s.open && !joining) return (
150
182
  <View style={[t.styles.card, { gap: 6 }]}>
151
183
  <Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ? words.full : s.reason === 'cancelled' ? words.dateCancelled : words.rsvpClosed}</Text>
152
184
  {s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : s.reason === 'closed' ? deadline || words.rsvpClosed : words.dateEnded}</Text>}
@@ -162,12 +194,14 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
162
194
  const emailProblem = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ? words.emailProblem : '';
163
195
  // the form may ask for a phone itself
164
196
  const askPhone = !fields.some((f) => f.type === 'phone');
165
- const chosen = Math.min(seats, s.maxSeats) || 1;
197
+ // a place in line may ask for the event's own limit (the status's maxSeats is what's left)
198
+ const choices = joining ? waitlistSeatChoices(s) : seatChoices(s);
199
+ const chosen = Math.min(seats, choices.length) || 1;
166
200
  const submit = () => {
167
201
  setTried(true);
168
202
  setNotice('');
169
203
  if (nameProblem || emailProblem || Object.keys(problems).length) return;
170
- rsvp.mutate({ name, email, phone: askPhone ? phone : '', seats: chosen, answers }, {
204
+ rsvp.mutate({ name, email, phone: askPhone ? phone : '', seats: chosen, answers, waitlist: joining }, {
171
205
  onSuccess: (data) => { if (key) void tokens.set(key, data.manageToken); setStored(data.manageToken); },
172
206
  });
173
207
  };
@@ -243,20 +277,21 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
243
277
  return (
244
278
  <View style={[t.styles.card, { gap: 16 }]}>
245
279
  <View style={{ gap: 4 }}>
246
- <Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || words.rsvpTitle}</Text>
280
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{joining ? words.full : form?.title || words.rsvpTitle}</Text>
247
281
  {going}
248
282
  {!!deadline && <Text style={t.styles.text.muted}>{deadline}</Text>}
283
+ {joining && <Text style={t.styles.text.muted}>{words.waitlistNote}</Text>}
249
284
  {!!notice && <Text style={t.styles.text.muted}>{notice}</Text>}
250
285
  {!!form?.intro && <Text style={t.styles.text.muted}>{form.intro}</Text>}
251
286
  </View>
252
287
  {text(words.nameField, name, setName, { required: true, auto: 'name', problem: tried ? nameProblem : '' })}
253
288
  {text(words.emailField, email, setEmail, { required: true, keyboard: 'email-address', auto: 'email', problem: tried ? emailProblem : '' })}
254
289
  {askPhone && text(words.phoneField, phone, setPhone, { keyboard: 'phone-pad', auto: 'tel' })}
255
- {s.maxSeats > 1 && (
290
+ {choices.length > 1 && (
256
291
  <View style={{ gap: 6 }}>
257
292
  {label(words.seatsField, false)}
258
293
  <View accessibilityRole="radiogroup" style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
259
- {seatChoices(s).map((n) => (
294
+ {choices.map((n) => (
260
295
  <Pressable key={n} accessibilityRole="radio" accessibilityLabel={seatsText(n)} accessibilityState={{ checked: n === chosen }} onPress={() => setSeats(n)} style={[chip(n === chosen), { minWidth: 48, alignItems: 'center' }]}>
261
296
  <Text style={[t.styles.text.body, n === chosen && { fontWeight: '600' }]}>{num(n)}</Text>
262
297
  </Pressable>
@@ -274,7 +309,7 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
274
309
  </View>
275
310
  )}
276
311
  {tried && (nameProblem || emailProblem || Object.keys(problems).length > 0) && <Text style={errorText}>{words.checkAnswers}</Text>}
277
- <Button label={rsvp.isPending ? words.sending : form?.submitLabel || (s.autoConfirm ? words.confirmSeat : words.requestSeat)} disabled={rsvp.isPending} onPress={submit} />
312
+ <Button label={rsvp.isPending ? words.sending : joining ? words.joinWaitlist : form?.submitLabel || (s.autoConfirm ? words.confirmSeat : words.requestSeat)} disabled={rsvp.isPending} onPress={submit} />
278
313
  <Text style={t.styles.text.small}>{words.rsvpPrivacy}</Text>
279
314
  </View>
280
315
  );
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "kind": "client",
3
3
  "title": "RSVP",
4
- "description": "An RSVP panel for an event date: going and spots left, seats, the event's questions with inline errors, then the person's RSVP with Cancel, or why the date is full or closed. Already part of the events recipe; add it to an event screen of your own.",
4
+ "description": "An RSVP panel for an event date: going and spots left, seats, the event's questions with inline errors, then the person's RSVP with Cancel, or why the date is full or closed. A full date whose event keeps a waitlist takes places in line, with Confirm my spot when one opens. Already part of the events recipe; add it to an event screen of your own.",
5
5
  "packages": [
6
6
  "expo-crypto",
7
7
  "expo-secure-store"
@@ -11,6 +11,6 @@
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
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
+ "Anything you pass as children shows under the person's RSVP (confirmed or waiting for approval, not a place in line), 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)"
15
15
  ]
16
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.40",
3
+ "version": "0.3.41",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -995,7 +995,7 @@ export const server = {
995
995
  calendarIds: z.array(z.string()).max(50).optional(),
996
996
  fields: z.record(z.string().max(2000)).optional(),
997
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(), notifyStaff: z.boolean().optional(), form: z.any().optional() }).optional(),
998
+ closesAt: z.string().max(40).nullable().optional(), closeBefore: z.number().int().min(0).max(86400).optional(), notifyStaff: z.boolean().optional(), waitlist: z.boolean().optional(), form: z.any().optional() }).optional(),
999
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(),
1000
1000
  scheduledAt: nullableTs.optional(),
1001
1001
  }),
@@ -28,8 +28,9 @@ interface Props {
28
28
  preview?: boolean;
29
29
  // RSVP state when the event takes RSVPs (null otherwise). closesAt: this
30
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;
31
+ // address already has an RSVP for it (error then holds the same words);
32
+ // waitlist: a full date takes places in line (the form posts waitlist=1)
33
+ rsvp?: { going: number; left: number | null; maxSeats: number; error: string; open: boolean; closesAt?: number | null; duplicate?: boolean; waitlist?: boolean; formHtml?: string; fieldsHtml?: string; values?: { name: string; email: string; seats: string } } | null;
33
34
  }
34
35
  const { event: e, occurrence: o, settings, calendars, html, upcoming, origin, preview = false, rsvp = null } = Astro.props;
35
36
  // the whole event, or just this date of a repeating one
@@ -81,6 +82,10 @@ const w = rsvpStrings(lang);
81
82
  const going = rsvp ? aroundCount(rsvp.going, w.personGoing, w.peopleGoing) : ['', ''];
82
83
  const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.spotsLeft) : ['', ''];
83
84
  const taking = Boolean(rsvp?.open) && (rsvp!.left === null || rsvp!.left > 0);
85
+ // full, with a waitlist: "Full · 40 going · no spots left", then "Join the waitlist" with the same form
86
+ const joining = Boolean(rsvp?.open && rsvp.waitlist && rsvp.left === 0);
87
+ const formClass = joining ? 'pc-ev-rsvpform is-waitlist' : 'pc-ev-rsvpform';
88
+ const seatOptions = rsvp ? Math.min(rsvp.maxSeats, joining ? rsvp.maxSeats : rsvp.left ?? rsvp.maxSeats) : 0;
84
89
  // "RSVPs close Fri, Oct 24 at 9 PM" while open; once past, "They closed on Fri, Oct 24."
85
90
  const deadline = typeof rsvp?.closesAt === 'number' ? deadlineParts(rsvp.closesAt, tz, locale) : null;
86
91
  const closesLine = deadline && taking ? fill(w.closesOn, deadline) : '';
@@ -157,13 +162,16 @@ const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: tr
157
162
  ))}
158
163
  {rsvp ? (
159
164
  <div class="pc-ev-rsvp">
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>
165
+ {joining && <p class="pc-ev-full is-waitlist">{w.fullTag}</p>}
166
+ <p class="pc-ev-going">{going[0]}<b>{rsvp.going}</b>{going[1]}{rsvp.left !== null && (joining ? <> · {w.noSpotsLeft}</> : <> · {left[0]}<b>{rsvp.left}</b>{left[1]}</>)}</p>
161
167
  {closesLine && <p class="pc-ev-closes">{closesLine}</p>}
162
- {taking ? (
163
- <details class="pc-ev-rsvpform" open={Boolean(rsvp.error)}>
164
- <summary class="pc-ev-cta">{w.saveSeat}</summary>
168
+ {joining && <p class="pc-ev-waitlist">{w.waitlistNote}</p>}
169
+ {taking || joining ? (
170
+ <details class={formClass} open={Boolean(rsvp.error)}>
171
+ <summary class="pc-ev-cta">{joining ? w.joinWaitlist : w.saveSeat}</summary>
165
172
  <form method="post">
166
173
  <Fragment set:html={rsvp.formHtml ?? ''}/>
174
+ {joining && <input type="hidden" name="waitlist" value="1" />}
167
175
  {e.rsvp.form?.title && <h3>{e.rsvp.form.title}</h3>}
168
176
  {e.rsvp.form?.intro && <p>{e.rsvp.form.intro}</p>}
169
177
  {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>}
@@ -171,11 +179,11 @@ const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: tr
171
179
  <label class="pc-bk-field"><span>{w.emailField}</span><input name="email" type="email" required maxlength="200" autocomplete="email" value={rsvp.values?.email} /></label>
172
180
  {rsvp.maxSeats > 1 && (
173
181
  <label class="pc-bk-field"><span>{w.seatsField}</span>
174
- <select name="seats">{Array.from({ length: Math.min(rsvp.maxSeats, rsvp.left ?? rsvp.maxSeats) }, (_, i) => <option value={i + 1} selected={Number(rsvp.values?.seats || 1) === i + 1}>{i + 1}</option>)}</select>
182
+ <select name="seats">{Array.from({ length: seatOptions }, (_, i) => <option value={i + 1} selected={Number(rsvp.values?.seats || 1) === i + 1}>{i + 1}</option>)}</select>
175
183
  </label>
176
184
  )}
177
185
  <Fragment set:html={rsvp.fieldsHtml ?? ''}/>
178
- <button class="pc-bk-submit" type="submit">{e.rsvp.form?.submitLabel || (e.rsvp.autoConfirm ? w.confirmSeat : w.requestSeat)}</button>
186
+ <button class="pc-bk-submit" type="submit">{joining ? w.joinWaitlist : e.rsvp.form?.submitLabel || (e.rsvp.autoConfirm ? w.confirmSeat : w.requestSeat)}</button>
179
187
  </form>
180
188
  </details>
181
189
  ) : <p class="pc-ev-full">{rsvp.open ? w.full : w.closed}</p>}
@@ -21,8 +21,8 @@ export interface EventMeta {
21
21
  calendarIds: string[];
22
22
  fields: Record<string, string>;
23
23
  // closesAt: wall time like start; closeBefore: minutes before each date (0 = until it ends);
24
- // notifyStaff: the hosts get an email about each new or cancelled RSVP
25
- rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; notifyStaff?: boolean; form?: FormDefinition };
24
+ // notifyStaff: the hosts get an email about each new or cancelled RSVP; waitlist: a full date takes places in line
25
+ rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; notifyStaff?: boolean; waitlist?: boolean; form?: FormDefinition };
26
26
  roomHold: RoomHoldValue;
27
27
  }
28
28
 
@@ -169,7 +169,7 @@ const NO_MAPS: MapsStatus = { enabled: false, provider: '', label: '', search: f
169
169
  const NO_EVENT: EventMeta = {
170
170
  start: '', end: '', allDay: false, timezone: '', rrule: '', exdates: [], cancelled: false,
171
171
  location: NO_LOCATION, cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
172
- rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4, closesAt: null, closeBefore: 0, notifyStaff: true },
172
+ rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4, closesAt: null, closeBefore: 0, notifyStaff: true, waitlist: false },
173
173
  roomHold: NO_HOLD,
174
174
  };
175
175