@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.
- package/CHANGELOG.md +16 -0
- package/docs/calendar.md +75 -2
- package/docs/mobile.md +3 -1
- package/docs/server.md +51 -0
- package/integration.mjs +34 -0
- package/mobile-template/client/AGENTS.md +1 -0
- package/mobile-template/client/README.md +1 -1
- package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
- package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
- package/mobile-template/recipes/event-request/recipe.json +21 -0
- package/package.json +1 -1
- package/src/components/MobileApp.tsx +2 -2
- package/src/components/editor/event/SingleDates.tsx +12 -2
- package/src/layouts/Admin.astro +7 -0
- package/src/lib/calendar/request-admin.ts +211 -0
- package/src/lib/calendar/request-config.ts +8 -0
- package/src/lib/calendar/request-mail.ts +158 -0
- package/src/lib/calendar/request-page.ts +52 -0
- package/src/lib/calendar/request-service.ts +535 -0
- package/src/lib/calendar/request-store.ts +2 -2
- package/src/lib/calendar/request-words.ts +263 -0
- package/src/lib/email/service.ts +6 -1
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/facts.ts +4 -0
- package/src/lib/legal/text-en.ts +3 -0
- package/src/lib/legal/text-es.ts +3 -0
- package/src/lib/mobile/http.ts +2 -1
- package/src/lib/mobile/openapi.ts +8 -0
- package/src/lib/mobile/requests.ts +27 -0
- package/src/lib/mobile/schemas.ts +14 -3
- package/src/lib/mobile/site.ts +3 -0
- package/src/lib/push/types.ts +1 -1
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +43 -2
- package/src/pages/admin/calendar/events/index.astro +2 -0
- package/src/pages/admin/calendar/requests.astro +298 -0
- package/src/pages/api/v1/requests/index.ts +6 -0
- package/src/pages/api/v1/requests/options.ts +7 -0
- package/src/pages/request/index.astro +182 -0
- package/src/server/index.ts +56 -1
- package/src/styles/site.css +29 -0
- 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
|
|
1
|
+
# Calendar: repeats, single dates, rooms, clashes and requests
|
|
2
2
|
|
|
3
|
-
How events repeat, how one date of a series is changed,
|
|
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
|
+
}
|