@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.
- package/CHANGELOG.md +12 -0
- package/cli/migrations.generated.mjs +5 -0
- package/docs/mobile.md +2 -1
- package/integration.mjs +2 -0
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +66 -31
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +66 -31
- package/mobile-template/recipes/rsvp/recipe.json +2 -2
- package/package.json +1 -1
- package/src/actions/index.ts +1 -1
- package/src/components/EventPage.astro +16 -8
- package/src/components/editor/EventPanel.tsx +2 -2
- package/src/components/editor/PostEditor.tsx +1 -1
- package/src/components/editor/event/RegistrationCard.tsx +5 -0
- package/src/components/editor/event/registration.ts +1 -1
- package/src/lib/booking/agent.ts +1 -1
- package/src/lib/booking/emails.ts +20 -5
- package/src/lib/booking/service.ts +148 -14
- package/src/lib/calendar/rsvp-words.ts +69 -8
- package/src/lib/content-service.ts +11 -6
- package/src/lib/drafts.ts +2 -2
- package/src/lib/editor/bookings.ts +5 -3
- package/src/lib/editor/documents.ts +3 -2
- package/src/lib/editor/schemas.ts +1 -1
- package/src/lib/email/booking-words.ts +26 -2
- package/src/lib/email/store.ts +2 -2
- package/src/lib/forms/events.ts +4 -2
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/text-en.ts +1 -1
- package/src/lib/legal/text-es.ts +1 -1
- package/src/lib/mobile/bookings.ts +4 -3
- package/src/lib/mobile/manage.ts +17 -3
- package/src/lib/mobile/openapi.ts +4 -2
- package/src/lib/mobile/rsvp.ts +10 -5
- package/src/lib/mobile/schemas.ts +10 -3
- package/src/lib/repo/migrations.ts +5 -0
- package/src/lib/repo/sql.ts +53 -13
- package/src/lib/repo/types.ts +11 -4
- package/src/lib/scheduler.ts +3 -1
- package/src/lib/schema.ts +10 -2
- package/src/pages/admin/calendar/bookings.astro +57 -13
- package/src/pages/api/calendar/bookings/index.ts +1 -1
- package/src/pages/api/calendar/docs.ts +2 -2
- package/src/pages/api/v1/bookings/manage/confirm.ts +6 -0
- package/src/pages/book/manage/[token].astro +57 -15
- package/src/pages/events/[slug].astro +3 -2
- package/src/styles/admin.css +3 -1
- package/src/styles/site.css +10 -1
- 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.
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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}>{
|
|
124
|
-
<Text style={[t.styles.text.label, { color: t.colors.ink }]}>
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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}>{
|
|
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 ?
|
|
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={
|
|
141
|
-
{cancel.error && <Text style={errorText}>{
|
|
142
|
-
{
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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}>{
|
|
124
|
-
<Text style={[t.styles.text.label, { color: t.colors.ink }]}>
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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}>{
|
|
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 ?
|
|
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={
|
|
141
|
-
{cancel.error && <Text style={errorText}>{
|
|
142
|
-
{
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
|
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
package/src/actions/index.ts
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
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
|
-
{
|
|
163
|
-
|
|
164
|
-
|
|
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:
|
|
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
|
|