@acms-subir/core 0.3.40 → 0.3.42
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 +24 -0
- package/cli/migrations.generated.mjs +15 -0
- package/docs/mobile.md +12 -2
- package/integration.mjs +4 -0
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +66 -31
- package/mobile-template/recipes/push/files/app/link-phone.tsx +43 -0
- package/mobile-template/recipes/push/files/app/notifications.tsx +14 -4
- package/mobile-template/recipes/push/recipe.json +4 -2
- 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/Announcements.tsx +11 -2
- package/src/components/EventPage.astro +16 -8
- package/src/components/MobileApp.tsx +20 -8
- package/src/components/TestPhones.tsx +177 -0
- 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/announcements/api.ts +19 -2
- package/src/lib/announcements/service.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/service.ts +14 -2
- package/src/lib/email/store.ts +6 -2
- package/src/lib/email/templates.ts +20 -0
- package/src/lib/forms/events.ts +4 -2
- package/src/lib/legal/app-privacy.ts +4 -4
- package/src/lib/legal/text-en.ts +2 -2
- package/src/lib/legal/text-es.ts +2 -2
- package/src/lib/mobile/bookings.ts +4 -3
- package/src/lib/mobile/guest.ts +2 -1
- package/src/lib/mobile/manage.ts +17 -3
- package/src/lib/mobile/openapi.ts +22 -5
- package/src/lib/mobile/rsvp.ts +10 -5
- package/src/lib/mobile/schemas.ts +14 -3
- package/src/lib/push/api.ts +37 -8
- package/src/lib/push/service.ts +6 -2
- package/src/lib/push/staff.ts +162 -0
- package/src/lib/push/store.ts +49 -2
- package/src/lib/push/types.ts +4 -1
- package/src/lib/repo/migrations.ts +15 -0
- package/src/lib/repo/sql.ts +57 -14
- 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/announcements.ts +2 -2
- package/src/pages/api/calendar/bookings/index.ts +1 -1
- package/src/pages/api/calendar/docs.ts +2 -2
- package/src/pages/api/push/admin.ts +1 -1
- package/src/pages/api/push/phones.ts +25 -0
- package/src/pages/api/v1/bookings/manage/confirm.ts +6 -0
- package/src/pages/api/v1/devices/[token]/staff-link.ts +20 -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/styles/test-phones.css +30 -0
- package/src/theme-sdk/index.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.42
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b5964fd: Staff can send a test notification to their own phone before anything goes out. Admin → Announcements and Admin → Mobile app have a **Test phones** panel: **Link my phone** shows a 6-digit code that works once for 10 minutes; in the app, open Notifications, choose **Staff? Link this phone** and enter it. The panel lists your linked phones (when linked, when the app was last opened) with **Remove**; each person sees only their own. Signed in with the owner password, there's no account to link a phone to, so the panel says to use a Team account with an email. When a phone is linked, its owner gets an email, "A phone was linked to your account", in the site's main language with a link back to the panel; it goes out whenever email is set up, even with staff notifications off, because it's a security notice.
|
|
8
|
+
|
|
9
|
+
**Send test to my phone**, under the preview in the announcement composer and the Mobile app composer, sends the draft as it stands (title, message, and the button's link or the app's home as the tap) to your linked phones only, titled "[Test] …" in the language chosen in the preview ("[Prueba]" in Spanish, "[Teste]" in Portuguese). An urgent draft uses the urgent channel, so you see how it looks. Tests show in the delivery log as "Test by …", never count toward the weekly pace, and are limited to 10 an hour per person. With no phone linked, the button opens the Test phones panel.
|
|
10
|
+
|
|
11
|
+
Codes are stored only as a hash. Guessing is capped: 5 tries per phone and 5 per address in 10 minutes, and after 20 wrong codes across the site every waiting code stops working (make a new one). The app API adds `POST /api/v1/devices/{token}/staff-link` (body `{ code }`, answers `{ linked, staff: { name } }`) and `DELETE` to unlink; device responses carry `staff` (the name only, never an email), test pushes carry `data.test: true` and `source: 'test'`, and turning notifications off unlinks the phone. Migration 62 adds `push_devices.staff_user_id` and `staff_linked_at` and the `push_link_codes` table.
|
|
12
|
+
|
|
13
|
+
The privacy notice now says staff can link their own phone to their account to receive test notifications and that the link is removed when they remove it or turn notifications off, so its "Last updated" date moves on sites with push; the app store answers say the device ID is linked to a staff member only then. The push recipe's Notifications screen gets the row (only while notifications are on) and a new `/link-phone` screen; update it with `npx acms mobile recipe add push --force`, which overwrites the recipe's files, including any changes you made to them, and needs this release's `@acms-subir/native`. Describe the screen in App Review Notes: "Notifications has a staff-only screen to receive test notifications; it needs a code from the website admin."
|
|
14
|
+
|
|
15
|
+
## 0.3.41
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
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`.
|
|
24
|
+
|
|
25
|
+
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`.
|
|
26
|
+
|
|
3
27
|
## 0.3.40
|
|
4
28
|
|
|
5
29
|
### Patch Changes
|
|
@@ -1116,6 +1116,21 @@ 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`
|
|
1124
|
+
] },
|
|
1125
|
+
// staff test phones: a device a staff member linked with a one-time code
|
|
1126
|
+
// (only its SHA-256 is stored, keyed by it: the app sends the code alone)
|
|
1127
|
+
// gets their "Send test to my phone" pushes. Not public, so no cache trigger.
|
|
1128
|
+
{ version: 62, statements: [
|
|
1129
|
+
`ALTER TABLE push_devices ADD COLUMN staff_user_id TEXT`,
|
|
1130
|
+
`ALTER TABLE push_devices ADD COLUMN staff_linked_at INTEGER`,
|
|
1131
|
+
`CREATE INDEX push_devices_staff ON push_devices (staff_user_id)`,
|
|
1132
|
+
`CREATE TABLE push_link_codes (code_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL)`,
|
|
1133
|
+
`CREATE INDEX push_link_codes_user ON push_link_codes (user_id)`
|
|
1119
1134
|
] }
|
|
1120
1135
|
];
|
|
1121
1136
|
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>` |
|
|
@@ -288,6 +289,14 @@ plus automatic ones: new post, new event, "starting soon", event changes,
|
|
|
288
289
|
`<AcmsProvider>` in `app/_layout.tsx`. Link to `/notifications`.
|
|
289
290
|
3. Taps carry `data.url` / `data.path` and go through the provider's
|
|
290
291
|
`onNavigate`, so route `/events/<slug>` and post paths to your screens there.
|
|
292
|
+
4. Staff test phones: the recipe's Notifications screen ends with a visible
|
|
293
|
+
"Staff? Link this phone" row (only while notifications are on) that opens
|
|
294
|
+
`/link-phone`, where staff type the code from Admin → Announcements or
|
|
295
|
+
Mobile app → Test phones (`useLinkStaffPhone()`); then "Send test to my
|
|
296
|
+
phone" in the admin reaches their phone. Never hide it behind a long press
|
|
297
|
+
or gesture (App Review 2.3.1(a), 2.5.19). In App Store Connect's **App
|
|
298
|
+
Review Notes**, say: "Notifications has a staff-only screen to receive test
|
|
299
|
+
notifications; it needs a code from the website admin."
|
|
291
300
|
|
|
292
301
|
Rules: ask for permission from a button after saying why (the recipe does),
|
|
293
302
|
never on first launch. Topic ids and names come from the site
|
|
@@ -321,7 +330,8 @@ a pre-ticked choice doesn't count. So:
|
|
|
321
330
|
completes it in **Settings → Legal**, which also lists the App Privacy and
|
|
322
331
|
Data safety answers. Link to it inside the app: `useSite().data.legal.privacy`
|
|
323
332
|
(and `.terms`). The site stores only the Expo token, platform, language and
|
|
324
|
-
topics, with no name or email
|
|
333
|
+
topics, with no name or email, except a staff member's own test phone, which
|
|
334
|
+
records who linked it (the notice and the store answers say so).
|
|
325
335
|
|
|
326
336
|
## Editor apps (staff sign-in)
|
|
327
337
|
|
package/integration.mjs
CHANGED
|
@@ -765,8 +765,10 @@ export default function acms(options = {}) {
|
|
|
765
765
|
routes.push(
|
|
766
766
|
['/api/v1/devices', 'api/v1/devices/index.ts'],
|
|
767
767
|
['/api/v1/devices/[token]', 'api/v1/devices/[token].ts'],
|
|
768
|
+
['/api/v1/devices/[token]/staff-link', 'api/v1/devices/[token]/staff-link.ts'],
|
|
768
769
|
['/api/v1/push/topics', 'api/v1/push/topics.ts'],
|
|
769
770
|
['/api/push/admin', 'api/push/admin.ts'],
|
|
771
|
+
['/api/push/phones', 'api/push/phones.ts'],
|
|
770
772
|
);
|
|
771
773
|
}
|
|
772
774
|
// Admin → Mobile app: app access switches always; notifications when push is on
|
|
@@ -851,6 +853,8 @@ export default function acms(options = {}) {
|
|
|
851
853
|
// the booker's own booking from an app (token in the Booking-Token header)
|
|
852
854
|
['/api/v1/bookings/manage', 'api/v1/bookings/manage/index.ts'],
|
|
853
855
|
['/api/v1/bookings/manage/cancel', 'api/v1/bookings/manage/cancel.ts'],
|
|
856
|
+
// a spot offered from an RSVP's waitlist, taken up
|
|
857
|
+
['/api/v1/bookings/manage/confirm', 'api/v1/bookings/manage/confirm.ts'],
|
|
854
858
|
['/api/v1/bookings/manage/availability', 'api/v1/bookings/manage/availability.ts'],
|
|
855
859
|
['/api/v1/bookings/manage/reschedule', 'api/v1/bookings/manage/reschedule.ts'],
|
|
856
860
|
['/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
|
);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { ScrollView, Text, TextInput, View } from 'react-native';
|
|
3
|
+
import { Stack } from 'expo-router';
|
|
4
|
+
import { Button, fill, linkCodeInput, staffLinkProblemText, useLayout, useLinkStaffPhone, useStrings, useTheme } from '@acms-subir/native';
|
|
5
|
+
|
|
6
|
+
// Staff only: link this phone with the 6-digit code from the website admin
|
|
7
|
+
// (Announcements or Mobile app → Test phones), so "Send test to my phone"
|
|
8
|
+
// reaches it. Opened from the last row of /notifications while notifications
|
|
9
|
+
// are on; turning them off unlinks it. Words: useStrings() in the app's language.
|
|
10
|
+
export default function LinkPhoneScreen() {
|
|
11
|
+
const t = useTheme();
|
|
12
|
+
const { gutter } = useLayout();
|
|
13
|
+
const words = useStrings();
|
|
14
|
+
const staff = useLinkStaffPhone();
|
|
15
|
+
const [code, setCode] = useState('');
|
|
16
|
+
const input = { borderWidth: 1, borderColor: t.colors.line, borderRadius: Math.min(t.radius, 10), padding: 12, fontSize: 22, letterSpacing: 4, color: t.colors.ink, backgroundColor: t.colors.panel } as const;
|
|
17
|
+
const link = async () => { if (await staff.link(code)) setCode(''); };
|
|
18
|
+
return (
|
|
19
|
+
<ScrollView style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 16, width: '100%', maxWidth: 640, alignSelf: 'center' }} keyboardShouldPersistTaps="handled">
|
|
20
|
+
<Stack.Screen options={{ title: words.linkPhoneTitle }} />
|
|
21
|
+
<Text style={t.styles.text.muted}>{words.linkPhoneIntro}</Text>
|
|
22
|
+
|
|
23
|
+
{staff.status === 'off' ? (
|
|
24
|
+
<Text style={t.styles.text.body}>{words.linkNeedsNotifications}</Text>
|
|
25
|
+
) : staff.linked ? (
|
|
26
|
+
<View style={[t.styles.card, { flexDirection: 'row', alignItems: 'center', gap: 12 }]}>
|
|
27
|
+
<Text style={[t.styles.text.body, { flex: 1 }]}>{fill(words.linkedTo, staff.linked)}</Text>
|
|
28
|
+
<Button variant="outline" label={words.unlinkPhone} disabled={staff.status !== 'ready'} onPress={() => { staff.unlink(); }} />
|
|
29
|
+
</View>
|
|
30
|
+
) : (
|
|
31
|
+
<View style={{ gap: 6 }}>
|
|
32
|
+
<Text style={t.styles.text.label}>{words.linkCode}</Text>
|
|
33
|
+
<TextInput value={code} onChangeText={(v) => setCode(linkCodeInput(v))} onSubmitEditing={link} keyboardType="number-pad" textContentType="oneTimeCode"
|
|
34
|
+
autoComplete="one-time-code" maxLength={6} accessibilityLabel={words.linkCode} placeholder="000000" placeholderTextColor={t.colors.muted} style={input} />
|
|
35
|
+
</View>
|
|
36
|
+
)}
|
|
37
|
+
{!!staff.problem && <Text accessibilityRole="alert" style={[t.styles.text.small, { color: t.colors.ink, fontWeight: '600' }]}>{staffLinkProblemText(staff.problem, words)}</Text>}
|
|
38
|
+
{staff.status !== 'off' && !staff.linked && (
|
|
39
|
+
<Button label={staff.status === 'linking' ? words.linkingPhone : words.linkPhone} disabled={code.length !== 6 || staff.status !== 'ready'} onPress={link} />
|
|
40
|
+
)}
|
|
41
|
+
</ScrollView>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import { Linking, ScrollView, Switch, Text, View } from 'react-native';
|
|
2
|
-
import { Stack } from 'expo-router';
|
|
3
|
-
import { Button, useLayout, usePushRegistration, usePushTopics, useTheme } from '@acms-subir/native';
|
|
1
|
+
import { Linking, Pressable, ScrollView, Switch, Text, View } from 'react-native';
|
|
2
|
+
import { Link, Stack } from 'expo-router';
|
|
3
|
+
import { Button, fill, useLayout, useLinkStaffPhone, usePushRegistration, usePushTopics, useStrings, useTheme } from '@acms-subir/native';
|
|
4
4
|
import { STARTING_TOPICS } from '../lib/push';
|
|
5
5
|
|
|
6
6
|
// Turn notifications on, choose topics, or turn them off. Topics and their
|
|
7
7
|
// names come from the site, so new calendars or collections appear here
|
|
8
|
-
// without an app update.
|
|
8
|
+
// without an app update. While notifications are on, the last row opens
|
|
9
|
+
// /link-phone, where staff link their own phone for test notifications (a
|
|
10
|
+
// visible row, so App Review can find it: describe it in the Review Notes).
|
|
9
11
|
export default function NotificationsScreen() {
|
|
10
12
|
const t = useTheme();
|
|
11
13
|
const { gutter } = useLayout();
|
|
14
|
+
const words = useStrings();
|
|
12
15
|
const push = usePushRegistration({ topics: STARTING_TOPICS });
|
|
16
|
+
const staff = useLinkStaffPhone();
|
|
13
17
|
const offered = usePushTopics();
|
|
14
18
|
const toggle = (id: string, on: boolean) => {
|
|
15
19
|
const next = on ? [...push.topics, id] : push.topics.filter((x) => x !== id);
|
|
@@ -57,6 +61,12 @@ export default function NotificationsScreen() {
|
|
|
57
61
|
</View>
|
|
58
62
|
<Text style={t.styles.text.small}>Announcements from the site reach everyone with notifications on.</Text>
|
|
59
63
|
<Button variant="outline" label="Turn off notifications" onPress={() => { push.unregister().catch(() => {}); }} />
|
|
64
|
+
<Link href="/link-phone" asChild>
|
|
65
|
+
<Pressable accessibilityRole="button" style={[t.styles.card, { gap: 2, minHeight: 52, justifyContent: 'center' }]}>
|
|
66
|
+
<Text style={t.styles.text.body}>{words.staffRow}</Text>
|
|
67
|
+
<Text style={t.styles.text.small}>{staff.linked ? fill(words.linkedTo, staff.linked) : words.staffRowNote}</Text>
|
|
68
|
+
</Pressable>
|
|
69
|
+
</Link>
|
|
60
70
|
</>
|
|
61
71
|
)}
|
|
62
72
|
</ScrollView>
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
"expo-device"
|
|
8
8
|
],
|
|
9
9
|
"routes": [
|
|
10
|
-
"/notifications"
|
|
10
|
+
"/notifications",
|
|
11
|
+
"/link-phone"
|
|
11
12
|
],
|
|
12
13
|
"next": [
|
|
13
14
|
"Turn push on for the site: acms({ mobile: { push: true } }) in astro.config.mjs, then restart or deploy",
|
|
@@ -15,6 +16,7 @@
|
|
|
15
16
|
"Push tokens need an EAS project id: npx eas-cli init (saves extra.eas.projectId in app.json)",
|
|
16
17
|
"Mount <PushSetup /> inside <AcmsProvider> in app/_layout.tsx (re-registers on start, opens tapped notifications)",
|
|
17
18
|
"Link to the screen: <Link href=\"/notifications\">Notifications</Link> (expo-router)",
|
|
18
|
-
"Send from the site: Admin → Mobile app"
|
|
19
|
+
"Send from the site: Admin → Mobile app",
|
|
20
|
+
"Staff test their notifications on their own phone: Notifications → \"Staff? Link this phone\" (/link-phone, shown while notifications are on) takes the code from Admin → Announcements or Mobile app → Test phones. Describe it in App Store Connect's App Review Notes: \"Notifications has a staff-only screen to receive test notifications; it needs a code from the website admin.\""
|
|
19
21
|
]
|
|
20
22
|
}
|