@acms-subir/core 0.3.55 → 0.3.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/docs/calendar.md +75 -2
  3. package/docs/mobile.md +3 -1
  4. package/docs/server.md +51 -0
  5. package/integration.mjs +34 -0
  6. package/mobile-template/client/AGENTS.md +1 -0
  7. package/mobile-template/client/README.md +1 -1
  8. package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
  9. package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
  10. package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
  11. package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
  12. package/mobile-template/recipes/event-request/recipe.json +21 -0
  13. package/package.json +1 -1
  14. package/src/components/MobileApp.tsx +2 -2
  15. package/src/components/editor/event/SingleDates.tsx +12 -2
  16. package/src/layouts/Admin.astro +7 -0
  17. package/src/lib/calendar/request-admin.ts +211 -0
  18. package/src/lib/calendar/request-config.ts +8 -0
  19. package/src/lib/calendar/request-mail.ts +158 -0
  20. package/src/lib/calendar/request-page.ts +52 -0
  21. package/src/lib/calendar/request-service.ts +535 -0
  22. package/src/lib/calendar/request-store.ts +2 -2
  23. package/src/lib/calendar/request-words.ts +263 -0
  24. package/src/lib/email/service.ts +6 -1
  25. package/src/lib/legal/app-privacy.ts +2 -2
  26. package/src/lib/legal/facts.ts +4 -0
  27. package/src/lib/legal/text-en.ts +3 -0
  28. package/src/lib/legal/text-es.ts +3 -0
  29. package/src/lib/mobile/http.ts +2 -1
  30. package/src/lib/mobile/openapi.ts +8 -0
  31. package/src/lib/mobile/requests.ts +27 -0
  32. package/src/lib/mobile/schemas.ts +14 -3
  33. package/src/lib/mobile/site.ts +3 -0
  34. package/src/lib/push/types.ts +1 -1
  35. package/src/lib/scheduler.ts +3 -0
  36. package/src/lib/schema.ts +43 -2
  37. package/src/pages/admin/calendar/events/index.astro +2 -0
  38. package/src/pages/admin/calendar/requests.astro +298 -0
  39. package/src/pages/api/v1/requests/index.ts +6 -0
  40. package/src/pages/api/v1/requests/options.ts +7 -0
  41. package/src/pages/request/index.astro +182 -0
  42. package/src/server/index.ts +56 -1
  43. package/src/styles/site.css +29 -0
  44. package/src/virtual.d.ts +1 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.56
4
+
5
+ ### Patch Changes
6
+
7
+ - 191830f: Anyone can now ask for an event, or for a change to one, without an account. Turn it on with `acms({ requests: true })`; it needs the calendar.
8
+
9
+ - **On the website**, a form at `/request` (and `/es/request` and so on), in the visitor's language and themed through the site's tokens. It asks what they're planning, the kind, date and times, how it repeats, rooms (or "Not sure"), how many people, and anything else. "Change to an event" asks which event, optionally which date, and what should change. A live line says whether the chosen rooms look free then (free or busy only). `requests: { path: '/ask' }` moves it; `requests: { page: false }` drops it but keeps the rest, and a site file at the path replaces it.
10
+ - **In apps**, `GET /api/v1/requests/options` (open or closed, the kinds and requestable rooms, the reply time, notice and office phone, in the asked language) and `POST /api/v1/requests` (Idempotency-Key; returns `{ id }`; refusals carry a code such as `title`, `past`, `room`, `closed` or `busy`). `site.features.requests` says the site has it. 10 requests a minute and 20 a day per address, 5 a day per email address, plus the site's daily cap.
11
+ - **Calendar → Requests** lists requests from the app, the website and hosts on My events (new events, suggested changes and date moves, each answered there) in one place, with Pending, Approved and Declined tabs and, on each new request, what takes its rooms then (12 weeks for weekly ones). **Approve** makes a draft that's already filled in: title, times, weekly when asked, the kind's tag and calendar, and the rooms held while it's a draft. The person's details stay on the request, never on the event. Approving a change opens the event, or that date's Move… form. **Reply** and **Decline** (with an optional reason) email the person in the language they asked in. **Delete as spam** removes one without an email. The list shows 50 at a time. Reading the list needs "Create and edit content"; answering needs "Publish content".
12
+ - **Settings** on that page (needs "Manage rooms and calendars"): open or closed (closed forms and apps say so and show the office phone, and never disappear), the kinds with their label in each language, tag and calendar, the reply time and opening notice in each language, the office phone, who hears about new requests (else `members.notify`, else the owners), an optional push to their linked phones, the daily cap (100) and how long requests are kept (180 days after the answer, or after they arrive if nobody answers). Rooms marked **Anyone can check if it's free** are the ones people can choose.
13
+ - **Emails:** the person gets "We got your request" in their language. It never repeats what they typed, so the form can't be used to send someone else a message, and each address gets at most 3 a day. Staff get every detail, with Reply-To set to the person, and only while the request still waits.
14
+ - **Privacy:** the generated privacy notice gets an "Event requests" section (what's kept, why, and when it's deleted), and the app store answers list event requests under contact info and user content. Requests are in backups.
15
+ - **Server API** (`@acms-subir/core/server`): `requestOptions`, `submitRequest` (for a site's own form), `listRequests`, `approveRequest`, `declineRequest` and `replyToRequest`. See `npx acms docs calendar` and `npx acms docs server`.
16
+
17
+ Requests use the `event_requests` table that came with hosts' date moves (migration 68); this release adds no migration. After updating, a site that wants requests adds `requests: true`, marks the rooms people may ask for as **Anyone can check if it's free**, and sets the kinds, reply time and office phone in Calendar → Requests → Settings. Update the privacy notice and the app's store answers, which now include requests.
18
+
3
19
  ## 0.3.55
4
20
 
5
21
  ### Patch Changes
package/docs/calendar.md CHANGED
@@ -1,6 +1,7 @@
1
- # Calendar: repeats, single dates, rooms and clashes
1
+ # Calendar: repeats, single dates, rooms, clashes and requests
2
2
 
3
- How events repeat, how one date of a series is changed, and how events hold rooms.
3
+ How events repeat, how one date of a series is changed, how events hold rooms, and
4
+ how people ask for events.
4
5
  Read it with `npx acms docs calendar`. The functions named here come from
5
6
  `@acms-subir/core/server` (`npx acms docs server` has their signatures), and the app
6
7
  API is in `docs/mobile-api-v1.md` in the framework repository.
@@ -315,3 +316,75 @@ calendars in step, both ways, within a few minutes. The full guide is
315
316
  until "from this date on" exists.
316
317
  - Site code can read an event's Google copies with `googleCopies(repo, eventId)`, and
317
318
  Recent changes with `googleSyncHistory(repo)`.
319
+
320
+ ## Requests without an account
321
+
322
+ `acms({ requests: true })` lets anyone ask for a new event, or for a change to one,
323
+ without an account:
324
+
325
+ - from the website's form at `/request` (and `/es/request` and so on);
326
+ - from an app (`GET /api/v1/requests/options`, `POST /api/v1/requests`);
327
+ - from the site's own form, through `submitRequest`.
328
+
329
+ Staff answer them on **Calendar → Requests**. That one list also shows hosts'
330
+ requests from My events. `requests: { path: '/ask' }` moves the form, and
331
+ `requests: { page: false }` drops it but keeps the API and the admin. A site file at
332
+ the form's path replaces it.
333
+
334
+ Each request is a row of its own (`RequestRecord`), not an event:
335
+
336
+ - `kind`:
337
+ - `'new'`: `data` has `type`, `title`, `start`, `end`, `repeat` (`once`, `weekly`
338
+ or `other` with `repeatText`), `roomIds`, `roomsUnsure` and `people`;
339
+ - `'change'`: `eventId`, plus `key` for one date (`''` = every date).
340
+ - `state`: `pending`, `approved`, `declined` or `withdrawn`.
341
+ - `source`: `app`, `web` or `member`.
342
+ - `requester`: `{ name, email, phone, locale }`, for a person without an account.
343
+ - `note`: their own words to staff.
344
+ - `reply`: staff's last answer.
345
+
346
+ Hosts' date moves (C3/C4) use the same table with kinds `move` and `from-date`.
347
+
348
+ **Settings** (Calendar → Requests → Settings, in `settings.calendar.requests`):
349
+
350
+ - **Open or closed.** Closed forms and apps say so and show the office phone; they never
351
+ disappear.
352
+ - **Kinds.** Each has a label per language, plus the tag and calendar an approved
353
+ draft gets.
354
+ - **Reply time and notice**, per language.
355
+ - **Office phone.**
356
+ - **Who hears about new requests.** Their email, else `members.notify`, else the owners.
357
+ Optionally a push to their linked phones, without names.
358
+ - **A daily cap** on new requests (100), on top of 10 a minute and 20 a day per
359
+ address and 5 a day per email address.
360
+ - **How long requests are kept** (180 days) after their answer, or after they
361
+ arrived if nobody answered. The scheduler deletes them then.
362
+
363
+ **Rooms** people can choose are the ones marked **Anyone can check if it’s free**
364
+ (`requestable`). The form's live "free then" line uses `GET /api/v1/rooms/free`, which
365
+ says free or busy, never what or who.
366
+
367
+ **Answering** (needs **Publish content**; reading the list needs **Create and edit
368
+ content**):
369
+
370
+ - **Approve** a new request: makes a draft that's already filled in. It has the
371
+ title and times, `FREQ=WEEKLY` when asked, the kind's tag and calendar (else the
372
+ default calendar), and the rooms held while it's a draft. The draft opens in the
373
+ editor. The person's details stay on the request, never on the event.
374
+ - **Approve** a change: opens the event, or that date's Move… form
375
+ (`/admin/calendar/events/<id>?move=<key>`).
376
+ - Both email the person, with an optional message, unless you untick it.
377
+ - **Decline**, with an optional reason, and **Reply** email the person in the
378
+ language they asked in. A reply leaves the request as it is. **Delete as spam**
379
+ removes a request from the app or the website without emailing anyone.
380
+ - Each row shows what takes its rooms then. Weekly requests are checked for 12
381
+ weeks.
382
+
383
+ **Emails.** The person gets "We got your request" in their language. It never
384
+ repeats what they typed (the title or message), so the form can't be used to send
385
+ someone else a message, and each address gets at most 3 a day. Staff get every
386
+ detail in the site's main language, with Reply-To set to the person.
387
+
388
+ **Privacy.** The generated privacy notice gets an "Event requests" section, and
389
+ the store answers list event requests under contact info and user content.
390
+ Requests are in backups.
package/docs/mobile.md CHANGED
@@ -114,7 +114,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
114
114
 
115
115
  | Hook | For |
116
116
  | --- | --- |
117
- | `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `rsvp`, `blog`, `search`, `bookingChanges`, `guestSignIn`, `forms`, `links`, `live`) |
117
+ | `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `rsvp`, `blog`, `search`, `bookingChanges`, `guestSignIn`, `forms`, `links`, `live`, `announcements`, `requests`) |
118
118
  | `usePage(slug)` | a published page: `mode` (`sections`/`content`), `sections`, `body.html`; 404 when missing |
119
119
  | `usePosts({ collection, limit, q })` / `usePost(slug)` | posts (infinite: `items`, `fetchNextPage`); `q` gives ranked search matches |
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 |
@@ -128,6 +128,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
128
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>` |
129
129
  | `useResources()` / `useAvailability({ resource, link })` / `useBooking()` | bookings |
130
130
  | `useRoomFree({ room, start, end })` | bookings: is a requestable room free (no names) |
131
+ | `useRequestOptions()` / `useSubmitRequest()` | event requests without an account (`features.requests`): the form's kinds, rooms, words and office phone (`open: false` = say so and show the phone, never hide the screen), and sending one with its own Idempotency-Key; `problem` and `field` say what to fix |
131
132
  | `useLocale()`, `useTheme()`, `useLayout()`, `useRefresh()`, `useOpenLink()` | language, tokens, layout, refresh, links |
132
133
  | `usePushRegistration({ topics })` / `usePushTopics()` / `useNewPushTopics()` / `useNotificationTaps()` | push notifications (see Push notifications); topic names come in the app's language |
133
134
  | `useAnnouncements({ kind, pinned })` / `useAnnouncement(id)` / `useAnnouncementState()` | announcements up right now; unread and closed banners kept on the phone (a dot, never a number); idle while `features.announcements` is false |
@@ -150,6 +151,7 @@ npx acms mobile recipe add news # posts list, article
150
151
  npx acms mobile recipe add booking # resources → slots → form → confirmation (expo-crypto)
151
152
  npx acms mobile recipe add push # notifications: turn on, topic toggles, taps open the page (expo-notifications, expo-device)
152
153
  npx acms mobile recipe add announcements # list with kind tabs and unread dots, detail, home banners (site needs announcements on)
154
+ npx acms mobile recipe add event-request # "Request an event" screen, a More row and an events-list link, no account (site needs requests on; expo-crypto, expo-secure-store)
153
155
  ```
154
156
 
155
157
  Each prints the `npx expo install …` line for its packages: run it (doctor
package/docs/server.md CHANGED
@@ -66,6 +66,16 @@ withdrawDateRequest(repo: Repo, who: ServerUser | null, requestId: string): Prom
66
66
  approveDateRequest(repo: Repo, who: ServerUser | null, requestId: string, ctx: WriteContext, seen?: number): Promise<{ request: RequestRecord; moved: MoveResult }>
67
67
  declineDateRequest(repo: Repo, who: ServerUser | null, requestId: string, reply: string, ctx: WriteContext, seen?: number): Promise<RequestRecord>
68
68
 
69
+ // requests without an account (acms({ requests })): asking needs no account; listing needs
70
+ // content.edit, answering content.publish
71
+ requestOptions(repo: Repo, locale?: string): Promise<{ open: boolean; types: { id: string; label: string }[]; rooms: { slug: string; name: string; color: string }[]; replyTime: string; notice: string; phone: string; timezone: string; events: { slug: string; title: string; dates: string[] }[] }>
72
+ submitRequest(repo: Repo, input: RequestInput, opts: { key: string; address: string; context: WriteContext }): Promise<{ id: string; created: boolean }>
73
+ listRequests(repo: Repo, who: ServerUser | null, q?: { states?: RequestState[]; kinds?: RequestKind[]; sources?: RequestSource[]; eventId?: string; userId?: string; order?: 'oldest' | 'newest' | 'decided'; limit?: number; offset?: number }): Promise<RequestRecord[]>
74
+ approveRequest(repo: Repo, who: ServerUser | null, id: string, ctx: WriteContext, seen?: number, opts?: { message?: string; email?: boolean }): Promise<{ request: RequestRecord; event: CalEvent | null; next: string }>
75
+ declineRequest(repo: Repo, who: ServerUser | null, id: string, reason: string, ctx: WriteContext, seen?: number, opts?: { email?: boolean }): Promise<RequestRecord>
76
+ deleteRequest(repo: Repo, who: ServerUser | null, id: string): Promise<void>
77
+ replyToRequest(repo: Repo, who: ServerUser | null, id: string, message: string, ctx: WriteContext): Promise<RequestRecord>
78
+
69
79
  type Role = 'owner' | 'administrator' | 'editor' | 'author' | 'contributor' | (string & {})
70
80
  interface ServerUser { id: string; email: string; name: string; role: Role; staff: boolean; can(permission: string): boolean }
71
81
  interface Occurrence { event: CalEvent; start: number; end: number; allDay: boolean; days: number; key: string; cancelled: boolean; roomIds: string[]; moved?: { from: { start: number; end: number }; note: string; location?: EventLocation } }
@@ -460,3 +470,44 @@ if (Astro.request.method === 'POST') {
460
470
 
461
471
  `/classes/mine` would list `listMyEvents(repo, me)` and show `e.request?.state`
462
472
  (`'pending'`, `'declined'` with `e.request.reply`, or no request once approved).
473
+
474
+ ## Requests without an account
475
+
476
+ With `acms({ requests })`, anyone can ask for an event or a change to one (see `npx
477
+ acms docs calendar`). The built-in form at `/request` and `POST /api/v1/requests` use
478
+ these functions, and so can a site's own form:
479
+
480
+ - **`requestOptions(repo, locale)`**: whether requests are open, the kinds, requestable
481
+ rooms and the events a change can be about (with their next dates), and the site's
482
+ reply time, notice and phone, in that language.
483
+ - **`submitRequest(repo, input, { key, address, context })`**: `input` is the same as
484
+ the API body (`kind: 'new'` or `'change'`, `name`, `email`…). `key` is a random UUID v4
485
+ your page keeps for this form, so a double submit stores one request. `address` is
486
+ `Astro.clientAddress`, for the 10-a-minute limit. Refusals throw with a `status` and a
487
+ `code` (`RequestRefusal`: `title`, `start`, `past`, `room`, `email`, `closed`,
488
+ `busy`…). Show your own words for each code.
489
+ - **`listRequests(repo, who, q)`**: for staff (`content.edit`). `states: ['pending']`
490
+ comes oldest first.
491
+ - **`approveRequest`**, **`declineRequest`** and **`replyToRequest`** answer one (`content.publish`) and
492
+ email the person in their language (approve and decline take `email: false` to stay quiet).
493
+ **`deleteRequest`** removes spam from the app or the website without an email. Pass `seen` (the `updatedAt` you showed) and an
494
+ edited request answers 409 instead.
495
+
496
+ ```astro
497
+ ---
498
+ // src/pages/ask.astro: the site's own request form
499
+ import { getRepo, submitRequest } from '@acms-subir/core/server';
500
+ const repo = await getRepo(Astro.locals);
501
+ let done = false, problem = '';
502
+ if (Astro.request.method === 'POST') {
503
+ const f = await Astro.request.formData();
504
+ try {
505
+ await submitRequest(repo, {
506
+ kind: 'new', title: String(f.get('title')), start: `${f.get('date')}T${f.get('from')}`, end: String(f.get('to')),
507
+ name: String(f.get('name')), email: String(f.get('email')),
508
+ }, { key: String(f.get('key')), address: Astro.clientAddress, context: { url: Astro.url, locals: Astro.locals } });
509
+ done = true;
510
+ } catch (e) { problem = (e as { code?: string }).code ?? 'failed'; }
511
+ }
512
+ ---
513
+ ```
package/integration.mjs CHANGED
@@ -448,6 +448,20 @@ function resolveMembers(members, calendar) {
448
448
  // the built-in pages are "My events": they need the calendar
449
449
  return { enabled: calendar.enabled, base, notify: notify.map((e) => e.trim().toLowerCase()), hostMoves };
450
450
  }
451
+ function resolveRequests(requests, calendar, reserved) {
452
+ if (requests === undefined || requests === false) return { enabled: false, path: '/request', page: true };
453
+ if (requests === true) requests = {};
454
+ if (!requests || typeof requests !== 'object' || Array.isArray(requests)) throw new Error('acms: requests must be true, false or an object');
455
+ unknownKeys('requests', requests, ['path', 'page']);
456
+ const path = requests.path ?? '/request';
457
+ if (typeof path !== 'string' || !/^\/[a-z0-9-]+(\/[a-z0-9-]+)*$/.test(path)) {
458
+ throw new Error(`acms: requests.path must start with "/" and use lowercase letters, digits, and hyphens (no trailing slash) — got "${path}"`);
459
+ }
460
+ if (RESERVED_BASES.has(path) || reserved.includes(path)) throw new Error(`acms: requests.path "${path}" collides with another route — pick another path`);
461
+ if (requests.page !== undefined && typeof requests.page !== 'boolean') throw new Error('acms: requests.page must be true or false');
462
+ // requests become calendar events: they need the calendar
463
+ return { enabled: calendar.enabled, path, page: requests.page ?? true };
464
+ }
451
465
  function resolveAnnouncements(announcements, reserved) {
452
466
  if (announcements === undefined || announcements === false) return { enabled: false, base: '/announcements', pages: true, topics: {} };
453
467
  if (announcements === true) announcements = {};
@@ -713,6 +727,14 @@ function acmsVitePlugin(userConfig) {
713
727
  * `topics` picks the push topic each kind starts with in the composer, e.g.
714
728
  * `{ registration: 'registrations', help: 'help' }` (otherwise a topic named
715
729
  * after the kind, such as `events` or `help`, else everyone).
730
+ * @param {boolean | {path?: string, page?: boolean}} [options.requests]
731
+ * Event requests without an account (off by default; needs the calendar):
732
+ * anyone asks for a new event or a change to one from the app
733
+ * (GET /api/v1/requests/options, POST /api/v1/requests) or the website's
734
+ * form (default path '/request', also under each language's prefix). Staff
735
+ * answer them on Calendar → Requests, where approving makes a filled-in
736
+ * draft event. `page: false` keeps the API and the admin but drops the
737
+ * built-in form, so a site can build its own on the same server API.
716
738
  * @param {string[]} [options.signedRoutes]
717
739
  * The site's own routes that receive posts from another server and check
718
740
  * its signature themselves (an SMS or payment provider's webhook), as route
@@ -741,6 +763,7 @@ export default function acms(options = {}) {
741
763
  if (clash) throw new Error(`acms: members.base "${members.base}" collides with another configured path — pick another`);
742
764
  }
743
765
  const announcements = resolveAnnouncements(options.announcements, [...contentBases.keys(), ...(calendar.enabled ? [calendar.base, calendar.eventsBase, calendar.bookBase] : []), ...(members.enabled ? [members.base] : [])]);
766
+ const requests = resolveRequests(options.requests, calendar, [...contentBases.keys(), ...(calendar.enabled ? [calendar.base, calendar.eventsBase, calendar.bookBase] : []), ...(members.enabled ? [members.base] : []), ...(announcements.enabled ? [announcements.base] : [])]);
744
767
  const roles = resolveRoles(options.roles, members);
745
768
  const permissions = resolvePermissions(options.permissions, roles);
746
769
  const maps = resolveMaps(options.maps);
@@ -771,6 +794,7 @@ export default function acms(options = {}) {
771
794
  permissions,
772
795
  members,
773
796
  announcements,
797
+ requests,
774
798
  };
775
799
  const routes = [...ROUTES,
776
800
  ['/api/v1/site', 'api/v1/site.ts'],
@@ -942,6 +966,16 @@ export default function acms(options = {}) {
942
966
  );
943
967
  if (announcements.pages) routes.push([announcements.base, 'announcements/index.astro'], [`${announcements.base}/[id]`, 'announcements/[id].astro']);
944
968
  }
969
+ // event requests from people without an account (acms({ requests })): the
970
+ // app API, Calendar → Requests and, unless page: false, the public form
971
+ if (requests.enabled) {
972
+ routes.push(
973
+ ['/admin/calendar/requests', 'admin/calendar/requests.astro'],
974
+ ['/api/v1/requests', 'api/v1/requests/index.ts'],
975
+ ['/api/v1/requests/options', 'api/v1/requests/options.ts'],
976
+ );
977
+ if (requests.page) routes.push([requests.path, 'request/index.astro']);
978
+ }
945
979
  // "My events" for signed-in members (acms({ members })): thin pages over
946
980
  // lib/calendar/requests.ts. A site file at any of these paths replaces that
947
981
  // page; `members: false` drops them all (members.enabled needs the calendar).
@@ -78,6 +78,7 @@ npx acms mobile recipe add news # posts list and article
78
78
  npx acms mobile recipe add booking # resources → slots → form → confirmation
79
79
  npx acms mobile recipe add push # notifications: turn on, topics, taps (site needs push on)
80
80
  npx acms mobile recipe add announcements # list, detail, home banners (site needs announcements on)
81
+ npx acms mobile recipe add event-request # Request an event: screen, More row, events-list link (site needs requests on)
81
82
  ```
82
83
 
83
84
  Each recipe prints the extra Expo packages to install (`npx expo install ...`).
@@ -28,7 +28,7 @@ site) and `.env.local` (written by `acms mobile dev`, not committed).
28
28
  ## Add screens
29
29
 
30
30
  ```sh
31
- npx acms mobile recipe add events # also: news, booking, push, rsvp, announcements
31
+ npx acms mobile recipe add events # also: news, booking, push, rsvp, announcements, event-request
32
32
  ```
33
33
 
34
34
  Build your own with the hooks from `@acms-subir/native`. `AGENTS.md` has the
@@ -0,0 +1,305 @@
1
+ import { useEffect, useState, type ReactNode } from 'react';
2
+ import { ActivityIndicator, KeyboardAvoidingView, Linking, Platform, Pressable, ScrollView, Text, TextInput, View, type KeyboardTypeOptions } from 'react-native';
3
+ import { Stack, router } from 'expo-router';
4
+ import * as Crypto from 'expo-crypto';
5
+ import * as SecureStore from 'expo-secure-store';
6
+ import type { RequestInput } from '@acms-subir/client';
7
+ import { Button, ErrorView, fill, formatClock, formatEventDate, localDay, parseClock, requestEnd, requestProblemText, requestProblems, requestSummary, siteInstant, useDebounced, useGuest, useLayout, useLocale, useRequestOptions, useRoomFree, useSite, useStrings, useSubmitRequest, useTheme, type RequestField, type RequestProblem } from '@acms-subir/native';
8
+ import { DayPicker } from '../components/DayPicker';
9
+
10
+ // Request an event, without an account: something new (what, kind, when,
11
+ // how it repeats, rooms with a live "looks free" line, people) or a change
12
+ // to one of the site's events, then how to reach them. Staff answer in the
13
+ // site's Calendar → Requests; nothing is on the calendar until they approve
14
+ // it. While the site isn't taking requests the screen still opens and says
15
+ // so, with the office phone: keep the More row that leads here, so App Review
16
+ // always finds it. useSubmitRequest keeps one idempotency key per request, so
17
+ // sending again after a timeout never asks twice. Words: useStrings(), in the
18
+ // app's language (the website's form says the same).
19
+
20
+ // Name, email and phone stay on this phone for next time: secure storage,
21
+ // this device only, never sent anywhere but the site. Web keeps nothing.
22
+ const CONTACT_KEY = 'acms.request.contact';
23
+ const keychain = { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY };
24
+ type Contact = { name: string; email: string; phone: string };
25
+ const contact = {
26
+ get: async (): Promise<Contact | null> => {
27
+ if (Platform.OS === 'web') return null;
28
+ try {
29
+ const v = JSON.parse((await SecureStore.getItemAsync(CONTACT_KEY, keychain)) ?? 'null');
30
+ return v && typeof v.name === 'string' && typeof v.email === 'string' ? { name: v.name, email: v.email, phone: typeof v.phone === 'string' ? v.phone : '' } : null;
31
+ } catch { return null; }
32
+ },
33
+ set: async (c: Contact) => { if (Platform.OS !== 'web') await SecureStore.setItemAsync(CONTACT_KEY, JSON.stringify(c), keychain).catch(() => {}); },
34
+ };
35
+
36
+ const call = (phone: string) => { Linking.openURL(`tel:${phone.replace(/[^\d+]/g, '')}`).catch(() => {}); };
37
+ type Repeat = 'once' | 'weekly' | 'other';
38
+ const UNSURE = '__unsure__';
39
+
40
+ export default function RequestScreen() {
41
+ const t = useTheme();
42
+ const words = useStrings();
43
+ const { locale } = useLocale();
44
+ const { gutter } = useLayout();
45
+ const { data: site } = useSite();
46
+ const guest = useGuest();
47
+ const options = useRequestOptions();
48
+ const send = useSubmitRequest({ createKey: Crypto.randomUUID });
49
+ const o = options.data;
50
+ const tz = o?.timezone ?? 'UTC';
51
+ const [kind, setKind] = useState<'new' | 'change'>('new');
52
+ const [title, setTitle] = useState('');
53
+ const [type, setType] = useState('');
54
+ const [date, setDate] = useState('');
55
+ const [starts, setStarts] = useState('');
56
+ const [ends, setEnds] = useState('');
57
+ const [repeat, setRepeat] = useState<Repeat>('once');
58
+ const [repeatText, setRepeatText] = useState('');
59
+ const [rooms, setRooms] = useState<string[]>([]);
60
+ const [unsure, setUnsure] = useState(false);
61
+ const [people, setPeople] = useState('');
62
+ const [message, setMessage] = useState('');
63
+ const [event, setEvent] = useState('');
64
+ const [eventDate, setEventDate] = useState('');
65
+ const [change, setChange] = useState('');
66
+ const [name, setName] = useState('');
67
+ const [email, setEmail] = useState('');
68
+ const [phone, setPhone] = useState('');
69
+ const [tried, setTried] = useState(false);
70
+
71
+ // details from last time, else the email they signed in with for "My bookings"
72
+ useEffect(() => {
73
+ let live = true;
74
+ void contact.get().then((c) => {
75
+ if (!live || !c) return;
76
+ setName((v) => v || c.name); setEmail((v) => v || c.email); setPhone((v) => v || c.phone);
77
+ });
78
+ return () => { live = false; };
79
+ }, []);
80
+ useEffect(() => {
81
+ if (site?.features.guestSignIn && guest.email) setEmail((v) => v || guest.email!);
82
+ }, [site?.features.guestSignIn, guest.email]);
83
+
84
+ // ---- what goes out ----
85
+ const startTime = parseClock(starts), endTime = parseClock(ends);
86
+ const count = people.replace(/\D/g, '');
87
+ const input: RequestInput = kind === 'new'
88
+ ? { kind, type: type || undefined, title, start: date && startTime ? `${date}T${startTime}` : '', end: endTime ?? '', repeat, repeatText,
89
+ rooms: unsure ? [] : rooms, roomsUnsure: unsure, people: count ? Number(count) : null, message, name, email, phone }
90
+ : { kind, event, date: eventDate || undefined, message: change, name, email, phone };
91
+ const local = requestProblems(input, o);
92
+ // the site's answer shows by its field until the next send
93
+ const problems: Partial<Record<RequestField, RequestProblem>> = tried ? { ...local, ...(send.field && send.problem ? { [send.field]: send.problem } : {}) } : {};
94
+ const invalid = tried && Object.keys(problems).length > 0;
95
+
96
+ // the live "looks free" line (needs bookings on the site): the first date's times as instants, since rooms/free needs a zone
97
+ const check = input.kind === 'new' && !local.start && !local.end && !unsure && rooms.length > 0 && site?.features.bookings === true;
98
+ const from = check ? siteInstant(input.start, tz) : null, to = check ? siteInstant(requestEnd(input.start, input.end), tz) : null;
99
+ const span = from && to ? `${from}|${to}` : '';
100
+ // asked once typing settles (30 checks a minute per address)
101
+ const checked = useDebounced(span, 400);
102
+
103
+ const submit = () => {
104
+ setTried(true);
105
+ if (Object.keys(local).length) return;
106
+ send.mutate(input, { onSuccess: () => { void contact.set({ name: name.trim(), email: email.trim(), phone: phone.trim() }); } });
107
+ };
108
+ const startOver = () => {
109
+ send.reset(); setTried(false);
110
+ setTitle(''); setType(''); setDate(''); setStarts(''); setEnds(''); setRepeat('once'); setRepeatText(''); setRooms([]); setUnsure(false);
111
+ setPeople(''); setMessage(''); setEvent(''); setEventDate(''); setChange('');
112
+ };
113
+
114
+ const screen = (children: ReactNode) => (
115
+ <KeyboardAvoidingView style={{ flex: 1, backgroundColor: t.colors.bg }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
116
+ <Stack.Screen options={{ title: words.requestEvent }} />
117
+ <ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: gutter, gap: 20, width: '100%', maxWidth: 640, alignSelf: 'center' }}>{children}</ScrollView>
118
+ </KeyboardAvoidingView>
119
+ );
120
+ const officePhone = o?.phone ?? '';
121
+ const phoneLink = (text: string) => (
122
+ <Pressable accessibilityRole="link" onPress={() => call(officePhone)} style={{ minHeight: 44, justifyContent: 'center' }}>
123
+ <Text style={t.styles.text.link}>{text}</Text>
124
+ </Pressable>
125
+ );
126
+
127
+ // ---- loading, off or closed, sent ----
128
+ if (!site || (site.features.requests && options.isPending && !options.off)) return screen(<ActivityIndicator style={{ marginTop: 32 }} color={t.colors.accent} />);
129
+ if (options.error && !options.off && !o) return screen(<ErrorView error={options.error} retry={() => void options.refetch()} />);
130
+ if (options.off || o?.open === false || send.problem === 'closed') return screen(
131
+ <View style={[t.styles.card, { gap: 8 }]}>
132
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{words.requestClosed}</Text>
133
+ {!!officePhone && phoneLink(fill(words.requestCall, { phone: officePhone }))}
134
+ </View>,
135
+ );
136
+ if (send.data && send.variables) {
137
+ const sent = send.variables;
138
+ const eventTitle = sent.kind === 'change' ? o?.events.find((e) => e.slug === sent.event)?.title : undefined;
139
+ const lines = requestSummary(sent, words, { options: o, locale, eventTitle });
140
+ const to = sent.email.trim();
141
+ return screen(<>
142
+ <View style={{ gap: 8 }}>
143
+ <Text accessibilityRole="header" style={t.styles.text.display}>{words.requestSentTitle}</Text>
144
+ <Text style={t.styles.text.body}>{o?.replyTime ? fill(words.requestAnswerWithin, { time: o.replyTime, email: to }) : fill(words.requestAnswerBy, { email: to })}</Text>
145
+ </View>
146
+ <View style={[t.styles.card, { gap: 4 }]}>
147
+ <Text style={t.styles.text.label}>{words.requestYours}</Text>
148
+ {lines.map((line, i) => <Text key={i} style={i ? t.styles.text.muted : t.styles.text.heading}>{line}</Text>)}
149
+ </View>
150
+ <View style={{ gap: 4 }}>
151
+ <Text style={t.styles.text.muted}>{words.requestNotYet}</Text>
152
+ {!!officePhone && phoneLink(fill(words.requestSooner, { phone: officePhone }))}
153
+ </View>
154
+ <View style={{ gap: 10 }}>
155
+ <Button label={words.requestDone} onPress={() => { if (router.canGoBack()) router.back(); else startOver(); }} />
156
+ <Button label={words.requestAnother} variant="outline" onPress={startOver} />
157
+ </View>
158
+ </>);
159
+ }
160
+
161
+ // ---- the form ----
162
+ const radius = Math.min(t.radius, 10);
163
+ const field = { borderWidth: 1, borderColor: t.colors.line, borderRadius: radius, padding: 12, fontSize: 16, color: t.colors.ink, backgroundColor: t.colors.panel } as const;
164
+ const chip = (on: boolean) => ({ paddingVertical: 10, paddingHorizontal: 12, minHeight: 44, justifyContent: 'center' as const, borderRadius: radius, borderWidth: 1, borderColor: on ? t.colors.accent : t.colors.line, backgroundColor: on ? t.colors.soft : t.colors.panel });
165
+ const errorText = [t.styles.text.small, { color: t.colors.ink, fontWeight: '600' as const }];
166
+ const problem = (f: RequestField) => (problems[f] ? <Text style={errorText}>{requestProblemText(problems[f]!, words)}</Text> : null);
167
+ const label = (text: string, optional = false) => (
168
+ <Text style={t.styles.text.label}>{text}{optional ? <Text style={{ fontWeight: '400' }}> · {words.requestOptional}</Text> : null}</Text>
169
+ );
170
+ const text = (lbl: string, value: string, onChange: (v: string) => void, f: RequestField | null, x: { optional?: boolean; keyboard?: KeyboardTypeOptions; multiline?: boolean; auto?: 'name' | 'email' | 'tel'; placeholder?: string; onBlur?: () => void } = {}) => (
171
+ <View style={{ gap: 6 }}>
172
+ {label(lbl, x.optional)}
173
+ <TextInput value={value} onChangeText={onChange} onBlur={x.onBlur} keyboardType={x.keyboard} multiline={x.multiline} autoComplete={x.auto} placeholder={x.placeholder}
174
+ autoCapitalize={x.auto === 'email' ? 'none' : 'sentences'} autoCorrect={x.auto !== 'email'} placeholderTextColor={t.colors.muted}
175
+ accessibilityLabel={lbl} accessibilityHint={f && problems[f] ? requestProblemText(problems[f]!, words) : undefined}
176
+ style={[field, x.multiline && { minHeight: 96, textAlignVertical: 'top' }, !!f && !!problems[f] && { borderColor: t.colors.ink }]} />
177
+ {f && problem(f)}
178
+ </View>
179
+ );
180
+ const choices = <V extends string>(lbl: string, list: { value: V; label: string }[], isOn: (v: V) => boolean, toggle: (v: V) => void, many = false) => (
181
+ <View accessibilityRole={many ? undefined : 'radiogroup'} accessibilityLabel={lbl} style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
182
+ {list.map((c) => (
183
+ <Pressable key={c.value} accessibilityRole={many ? 'checkbox' : 'radio'} accessibilityState={{ checked: isOn(c.value) }} onPress={() => toggle(c.value)} style={chip(isOn(c.value))}>
184
+ <Text style={[t.styles.text.body, isOn(c.value) && { fontWeight: '600' }]}>{c.label}</Text>
185
+ </Pressable>
186
+ ))}
187
+ </View>
188
+ );
189
+ // times show back in the app's format once typed ("18:30" → "6:30 PM")
190
+ const tidy = (raw: string, set: (v: string) => void) => () => { const v = parseClock(raw); if (v) set(formatClock(v, locale)); };
191
+ const today = localDay(new Date().toISOString(), tz);
192
+ // the change picker: the site's public events, each with its next dates (the site's days)
193
+ const choosable = o?.events ?? [];
194
+ const chosen = choosable.find((e) => e.slug === event);
195
+ const showDay = (d: string) => formatEventDate({ start: `${d}T00:00:00.000Z`, allDay: true }, locale);
196
+
197
+ return screen(<>
198
+ <Text style={t.styles.text.muted}>{o?.notice || words.requestNotice}</Text>
199
+ <View accessibilityRole="radiogroup" style={{ flexDirection: 'row', borderWidth: 1, borderColor: t.colors.line, borderRadius: radius, overflow: 'hidden' }}>
200
+ {(['new', 'change'] as const).map((k, i) => (
201
+ <Pressable key={k} accessibilityRole="radio" accessibilityState={{ checked: kind === k }} onPress={() => { setKind(k); setTried(false); if (send.error) send.reset(); }}
202
+ style={{ flex: 1, minHeight: 44, paddingHorizontal: 8, alignItems: 'center', justifyContent: 'center', backgroundColor: kind === k ? t.colors.soft : t.colors.panel, borderLeftWidth: i ? 1 : 0, borderLeftColor: t.colors.line }}>
203
+ <Text style={[t.styles.text.body, { textAlign: 'center' }, kind === k && { fontWeight: '600' }]}>{k === 'new' ? words.requestNew : words.requestChange}</Text>
204
+ </Pressable>
205
+ ))}
206
+ </View>
207
+
208
+ {kind === 'new' ? (<>
209
+ {text(words.requestWhat, title, setTitle, 'title')}
210
+ {!!o?.types.length && (
211
+ <View style={{ gap: 6 }}>
212
+ {label(words.requestKind)}
213
+ {choices(words.requestKind, o.types.map((x) => ({ value: x.id, label: x.label })), (v) => v === type, (v) => setType(v === type ? '' : v))}
214
+ {problem('type')}
215
+ </View>
216
+ )}
217
+ <View style={{ gap: 6 }}>
218
+ {label(words.requestDate)}
219
+ <DayPicker value={date} onChange={setDate} min={today} max={`${Number(today.slice(0, 4)) + 2}${today.slice(4)}`} />
220
+ </View>
221
+ {/* no time picker in the template: typed times, read forgivingly ("6:30 pm", "18:30", "18h30") */}
222
+ <View style={{ flexDirection: 'row', gap: 12 }}>
223
+ <View style={{ flex: 1 }}>{text(words.requestStarts, starts, setStarts, 'start', { keyboard: 'numbers-and-punctuation', placeholder: formatClock('18:00', locale), onBlur: tidy(starts, setStarts) })}</View>
224
+ <View style={{ flex: 1 }}>{text(words.requestEnds, ends, setEnds, 'end', { keyboard: 'numbers-and-punctuation', placeholder: formatClock('20:00', locale), onBlur: tidy(ends, setEnds) })}</View>
225
+ </View>
226
+ <View style={{ gap: 6 }}>
227
+ {label(words.requestRepeats)}
228
+ {choices<Repeat>(words.requestRepeats, [{ value: 'once', label: words.requestOnce }, { value: 'weekly', label: words.requestWeekly }, { value: 'other', label: words.requestRepeatOther }], (v) => v === repeat, setRepeat)}
229
+ {repeat === 'other' && (
230
+ <TextInput value={repeatText} onChangeText={setRepeatText} placeholder={words.requestRepeatHint} accessibilityLabel={words.requestRepeatHint} placeholderTextColor={t.colors.muted}
231
+ style={[field, !!problems.repeat && { borderColor: t.colors.ink }]} />
232
+ )}
233
+ {problem('repeat')}
234
+ </View>
235
+ {!!o?.rooms.length && (
236
+ <View style={{ gap: 6 }}>
237
+ {label(words.requestRoom)}
238
+ {/* rooms, or Not sure (not both) */}
239
+ {choices(words.requestRoom, [...o.rooms.map((r) => ({ value: r.slug, label: r.name })), { value: UNSURE, label: words.requestNotSure }],
240
+ (v) => (v === UNSURE ? unsure : !unsure && rooms.includes(v)),
241
+ (v) => { if (v === UNSURE) { setUnsure(!unsure); return; } setUnsure(false); setRooms(rooms.includes(v) ? rooms.filter((x) => x !== v) : [...rooms, v]); }, true)}
242
+ {problem('room')}
243
+ {!!checked && checked === span && rooms.map((slug) => (
244
+ <RoomLine key={slug} room={slug} name={o.rooms.find((r) => r.slug === slug)?.name ?? slug} start={checked.split('|')[0]} end={checked.split('|')[1]} />
245
+ ))}
246
+ </View>
247
+ )}
248
+ <View style={{ maxWidth: 200 }}>{text(words.requestPeople, people, (v) => setPeople(v.replace(/\D/g, '')), 'people', { optional: true, keyboard: 'number-pad' })}</View>
249
+ {text(words.requestMessage, message, setMessage, null, { optional: true, multiline: true })}
250
+ </>) : (<>
251
+ <View style={{ gap: 6 }}>
252
+ {label(words.requestWhichEvent)}
253
+ {!choosable.length ? <Text style={t.styles.text.muted}>{words.requestNoEvents}</Text> : (
254
+ <View accessibilityRole="radiogroup" accessibilityLabel={words.requestPickEvent} style={[t.styles.card, { paddingVertical: 4, paddingHorizontal: 0 }]}>
255
+ {choosable.map((e, i) => (
256
+ <Pressable key={e.slug} accessibilityRole="radio" accessibilityState={{ checked: e.slug === event }} onPress={() => { setEvent(e.slug); setEventDate(''); }}
257
+ style={{ flexDirection: 'row', alignItems: 'center', gap: 12, minHeight: 48, paddingHorizontal: 16, borderTopWidth: i ? 1 : 0, borderTopColor: t.colors.line, backgroundColor: e.slug === event ? t.colors.soft : undefined }}>
258
+ <Text style={[t.styles.text.body, { flex: 1 }, e.slug === event && { fontWeight: '600' }]}>{e.title}</Text>
259
+ {e.slug === event && <Text style={[t.styles.text.body, { color: t.colors.accent }]} importantForAccessibility="no" accessibilityElementsHidden>✓</Text>}
260
+ </Pressable>
261
+ ))}
262
+ </View>
263
+ )}
264
+ {problem('event')}
265
+ </View>
266
+ {!!chosen && (
267
+ <View style={{ gap: 6 }}>
268
+ {label(words.requestWhichDate)}
269
+ {choices(words.requestWhichDate, [{ value: '', label: words.requestEveryDate }, ...chosen.dates.slice(0, 12).map((d) => ({ value: d, label: showDay(d) }))], (v) => v === eventDate, setEventDate)}
270
+ {problem('date')}
271
+ </View>
272
+ )}
273
+ {text(words.requestWhatChange, change, setChange, 'what', { multiline: true })}
274
+ </>)}
275
+
276
+ <View style={{ gap: 12 }}>
277
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{words.requestContact}</Text>
278
+ {text(words.requestName, name, setName, 'name', { auto: 'name' })}
279
+ {text(words.requestEmail, email, setEmail, 'email', { auto: 'email', keyboard: 'email-address' })}
280
+ {text(words.requestPhone, phone, setPhone, 'phone', { auto: 'tel', keyboard: 'phone-pad', optional: true })}
281
+ <Text style={t.styles.text.small}>
282
+ {words.requestContactWhy}
283
+ {!!site.legal.privacy && <> <Text accessibilityRole="link" style={[t.styles.text.small, { color: t.colors.accent, textDecorationLine: 'underline' }]} onPress={() => { Linking.openURL(site.legal.privacy!).catch(() => {}); }}>{words.requestPrivacy}</Text></>}
284
+ </Text>
285
+ </View>
286
+
287
+ {!!send.problem && !send.field && (
288
+ <View accessibilityRole="alert" style={{ backgroundColor: t.colors.soft, borderRadius: radius, padding: 12, gap: 4 }}>
289
+ <Text style={[t.styles.text.body, { fontWeight: '600' }]}>{requestProblemText(send.problem, words)}</Text>
290
+ </View>
291
+ )}
292
+ {invalid && <Text accessibilityRole="alert" style={errorText}>{words.requestCheck}</Text>}
293
+ <Button label={send.isPending ? words.sending : words.requestSend} disabled={send.isPending} onPress={submit} />
294
+ </>);
295
+ }
296
+
297
+ // "Hall looks free then." for one chosen room, over the first date's times (free or busy only, never what)
298
+ function RoomLine({ room, name, start, end }: { room: string; name: string; start: string; end: string }) {
299
+ const t = useTheme();
300
+ const words = useStrings();
301
+ const free = useRoomFree({ room, start, end });
302
+ // nothing while it loads, or when the site can't say (an error, too many checks)
303
+ if (!free.data) return null;
304
+ return <Text style={t.styles.text.small}>{fill(free.data.free ? words.requestRoomFree : words.requestRoomTaken, { room: name })}</Text>;
305
+ }