@acms-subir/core 0.3.54 → 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 (75) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/cli/main.mjs +2 -1
  3. package/cli/migrations.generated.mjs +59 -6
  4. package/docs/calendar.md +104 -2
  5. package/docs/google-calendar.md +302 -0
  6. package/docs/mobile.md +3 -1
  7. package/docs/server.md +58 -0
  8. package/integration.mjs +38 -0
  9. package/mobile-template/client/AGENTS.md +1 -0
  10. package/mobile-template/client/README.md +1 -1
  11. package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
  12. package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
  13. package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
  14. package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
  15. package/mobile-template/recipes/event-request/recipe.json +21 -0
  16. package/package.json +1 -1
  17. package/src/components/LegalSettings.tsx +1 -1
  18. package/src/components/MobileApp.tsx +2 -2
  19. package/src/components/editor/event/SingleDates.tsx +12 -2
  20. package/src/layouts/Admin.astro +7 -0
  21. package/src/lib/calendar/google/client.ts +167 -0
  22. package/src/lib/calendar/google/connect.ts +161 -0
  23. package/src/lib/calendar/google/context.ts +50 -0
  24. package/src/lib/calendar/google/inbound.ts +493 -0
  25. package/src/lib/calendar/google/links.ts +139 -0
  26. package/src/lib/calendar/google/map.ts +543 -0
  27. package/src/lib/calendar/google/match.ts +103 -0
  28. package/src/lib/calendar/google/migration.ts +71 -0
  29. package/src/lib/calendar/google/outbound.ts +272 -0
  30. package/src/lib/calendar/google/service.ts +193 -0
  31. package/src/lib/calendar/google/store.ts +241 -0
  32. package/src/lib/calendar/google/types.ts +188 -0
  33. package/src/lib/calendar/request-admin.ts +211 -0
  34. package/src/lib/calendar/request-config.ts +8 -0
  35. package/src/lib/calendar/request-mail.ts +158 -0
  36. package/src/lib/calendar/request-page.ts +52 -0
  37. package/src/lib/calendar/request-service.ts +535 -0
  38. package/src/lib/calendar/request-store.ts +2 -2
  39. package/src/lib/calendar/request-words.ts +263 -0
  40. package/src/lib/calendar/schedule.ts +8 -8
  41. package/src/lib/display/board.ts +3 -1
  42. package/src/lib/email/service.ts +17 -2
  43. package/src/lib/email/templates.ts +8 -0
  44. package/src/lib/legal/admin.ts +1 -1
  45. package/src/lib/legal/app-privacy.ts +2 -2
  46. package/src/lib/legal/facts.ts +7 -0
  47. package/src/lib/legal/text-en.ts +7 -0
  48. package/src/lib/legal/text-es.ts +7 -0
  49. package/src/lib/live/crypto.ts +7 -28
  50. package/src/lib/mobile/http.ts +2 -1
  51. package/src/lib/mobile/openapi.ts +8 -0
  52. package/src/lib/mobile/requests.ts +27 -0
  53. package/src/lib/mobile/schemas.ts +14 -3
  54. package/src/lib/mobile/site.ts +3 -0
  55. package/src/lib/push/types.ts +1 -1
  56. package/src/lib/repo/migrations.ts +4 -0
  57. package/src/lib/repo/sql.ts +18 -9
  58. package/src/lib/repo/types.ts +6 -2
  59. package/src/lib/scheduler.ts +6 -0
  60. package/src/lib/schema.ts +43 -2
  61. package/src/lib/sealed.ts +46 -0
  62. package/src/pages/admin/calendar/events/[id].astro +42 -1
  63. package/src/pages/admin/calendar/events/index.astro +2 -0
  64. package/src/pages/admin/calendar/google/match.astro +160 -0
  65. package/src/pages/admin/calendar/google.astro +107 -0
  66. package/src/pages/admin/calendar/requests.astro +298 -0
  67. package/src/pages/admin/calendar/settings.astro +273 -4
  68. package/src/pages/admin/integrations.astro +6 -0
  69. package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
  70. package/src/pages/api/v1/requests/index.ts +6 -0
  71. package/src/pages/api/v1/requests/options.ts +7 -0
  72. package/src/pages/request/index.astro +182 -0
  73. package/src/server/index.ts +105 -1
  74. package/src/styles/site.css +29 -0
  75. package/src/virtual.d.ts +1 -0
package/docs/server.md CHANGED
@@ -41,6 +41,12 @@ moveOccurrenceBack(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, even
41
41
  eventRoomClashes(repo: Repo, event: string | CalEvent, opts?: { horizonDays?: number; now?: Date | number }): Promise<EventClash[]>
42
42
  roomBusy(repo: Repo, q: { roomIds: string[]; from: Date | number; to: Date | number; ignore?: { eventId: string; key?: string } }): Promise<BusySlot[]>
43
43
 
44
+ // Google Calendar sync (acms docs google-calendar): read-only, names Google calendars, staff only
45
+ googleCopies(repo: Repo, eventId: string): Promise<GoogleCopy[]>
46
+ googleSyncHistory(repo: Repo, opts?: { eventId?: string; limit?: number }): Promise<{ id: number; at: number; eventId: string; direction: 'in' | 'out' | 'clash' | 'info'; action: string; title: string; summary: string; resolved: boolean }[]>
47
+ syncGoogleCalendar(repo: Repo, ctx: { locals: unknown }): Promise<number>
48
+ GOOGLE_BOOKING_TAG = '#google-booking'
49
+
44
50
  // event requests: `who` is currentUser()'s result (anything with `id` and `can()` works)
45
51
  listMyEvents(repo: Repo, who: ServerUser | null): Promise<CalEvent[]>
46
52
  listEventRequests(repo: Repo, who: ServerUser | null): Promise<CalEvent[]>
@@ -60,6 +66,16 @@ withdrawDateRequest(repo: Repo, who: ServerUser | null, requestId: string): Prom
60
66
  approveDateRequest(repo: Repo, who: ServerUser | null, requestId: string, ctx: WriteContext, seen?: number): Promise<{ request: RequestRecord; moved: MoveResult }>
61
67
  declineDateRequest(repo: Repo, who: ServerUser | null, requestId: string, reply: string, ctx: WriteContext, seen?: number): Promise<RequestRecord>
62
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
+
63
79
  type Role = 'owner' | 'administrator' | 'editor' | 'author' | 'contributor' | (string & {})
64
80
  interface ServerUser { id: string; email: string; name: string; role: Role; staff: boolean; can(permission: string): boolean }
65
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 } }
@@ -72,6 +88,7 @@ interface Hold { resourceId: string; eventId: string; key: string; title: string
72
88
  interface EventClash { hold: Hold; other: Hold }
73
89
  interface BusySlot { resourceId: string; start: number; end: number; kind: 'event' | 'booking' | 'block' | 'calendar'; title: string; eventId?: string; key?: string; bookingId?: string }
74
90
  interface WriteContext { url: URL; locals: unknown }
91
+ interface GoogleCopy { googleCalendar: string; googleCalendarId: string; htmlLink: string; syncedAt: number; descriptionFromGoogle: boolean; booking: boolean; clash: { field: string; site: string; google: string; at: number } | null }
75
92
  ```
76
93
 
77
94
  The types `Repo`, `Role`, `ServerUser`, `CalEvent`, `Occurrence`, `ListEventsOptions`,
@@ -453,3 +470,44 @@ if (Astro.request.method === 'POST') {
453
470
 
454
471
  `/classes/mine` would list `listMyEvents(repo, me)` and show `e.request?.state`
455
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'],
@@ -888,6 +912,10 @@ export default function acms(options = {}) {
888
912
  ['/admin/calendar/events/[id]', 'admin/calendar/events/[id].astro'],
889
913
  ['/admin/calendar/calendars', 'admin/calendar/calendars.astro'],
890
914
  ['/admin/calendar/integrations', 'admin/calendar/integrations.astro'],
915
+ // two-way Google Calendar sync: the forms' handler, the first-match review, Google's sign-in callback
916
+ ['/admin/calendar/google', 'admin/calendar/google.astro'],
917
+ ['/admin/calendar/google/match', 'admin/calendar/google/match.astro'],
918
+ ['/api/calendar/google/oauth/callback', 'api/calendar/google/oauth/callback.ts'],
891
919
  ['/api/calendar/docs', 'api/calendar/docs.ts'],
892
920
  [`${calendar.bookBase}/manage/[token]`, 'book/manage/[token].astro'],
893
921
  // the booker's own booking from an app (token in the Booking-Token header)
@@ -938,6 +966,16 @@ export default function acms(options = {}) {
938
966
  );
939
967
  if (announcements.pages) routes.push([announcements.base, 'announcements/index.astro'], [`${announcements.base}/[id]`, 'announcements/[id].astro']);
940
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
+ }
941
979
  // "My events" for signed-in members (acms({ members })): thin pages over
942
980
  // lib/calendar/requests.ts. A site file at any of these paths replaces that
943
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
+ }
@@ -0,0 +1,66 @@
1
+ import { useState } from 'react';
2
+ import { Pressable, Text, View } from 'react-native';
3
+ import { useLocale, useTheme } from '@acms-subir/native';
4
+
5
+ // One month to pick a day from (YYYY-MM-DD), with no native date picker to
6
+ // install: ‹ › change the month, and days before `min` or after `max` can't
7
+ // be picked. Days are plain calendar days (the site's, for requests), so
8
+ // everything here is UTC and never shifts with the phone's time zone.
9
+ const pad = (n: number) => String(n).padStart(2, '0');
10
+ const iso = (y: number, m: number, d: number) => new Date(Date.UTC(y, m - 1, d)).toISOString().slice(0, 10);
11
+
12
+ export function DayPicker({ value, onChange, min, max }: { value: string; onChange: (day: string) => void; min: string; max?: string }) {
13
+ const t = useTheme();
14
+ const { locale } = useLocale();
15
+ const [month, setMonth] = useState(() => (value || min).slice(0, 7));
16
+ const [y, m] = month.split('-').map(Number);
17
+ const f = (o: Intl.DateTimeFormatOptions) => {
18
+ try { return new Intl.DateTimeFormat(locale, { ...o, timeZone: 'UTC' }); } catch { return new Intl.DateTimeFormat(undefined, { ...o, timeZone: 'UTC' }); }
19
+ };
20
+ const monthName = f({ month: 'long', year: 'numeric' });
21
+ const at = (n: number) => new Date(Date.UTC(y, m - 1 + n, 1));
22
+ const shift = (n: number) => setMonth(`${at(n).getUTCFullYear()}-${pad(at(n).getUTCMonth() + 1)}`);
23
+ const earlier = month > min.slice(0, 7), later = !max || month < max.slice(0, 7);
24
+ const length = new Date(Date.UTC(y, m, 0)).getUTCDate();
25
+ // weeks start on Sunday
26
+ const cells = [...Array<string | null>(at(0).getUTCDay()).fill(null), ...Array.from({ length }, (_, i) => iso(y, m, i + 1))];
27
+ const weekday = f({ weekday: 'short' });
28
+ const fullDay = f({ weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
29
+ const radius = Math.min(t.radius, 10);
30
+ const arrow = (n: number, on: boolean, glyph: string) => (
31
+ // read out as the month it goes to ("December 2026"), so it needs no words of its own
32
+ <Pressable accessibilityRole="button" accessibilityLabel={monthName.format(at(n))} accessibilityState={{ disabled: !on }} disabled={!on} onPress={() => shift(n)}
33
+ style={{ minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center', opacity: on ? 1 : 0.3 }}>
34
+ <Text style={[t.styles.text.heading, { color: t.colors.accent }]}>{glyph}</Text>
35
+ </Pressable>
36
+ );
37
+ return (
38
+ <View style={[t.styles.card, { padding: 8, gap: 4 }]}>
39
+ <View style={{ flexDirection: 'row', alignItems: 'center' }}>
40
+ {arrow(-1, earlier, '‹')}
41
+ <Text accessibilityRole="header" style={[t.styles.text.body, { flex: 1, textAlign: 'center', fontWeight: '600' }]}>{monthName.format(at(0))}</Text>
42
+ {arrow(1, later, '›')}
43
+ </View>
44
+ <View style={{ flexDirection: 'row' }} importantForAccessibility="no-hide-descendants" accessibilityElementsHidden>
45
+ {/* 2026-01-04 was a Sunday */}
46
+ {Array.from({ length: 7 }, (_, i) => (
47
+ <Text key={i} style={[t.styles.text.small, { width: `${100 / 7}%`, textAlign: 'center' }]}>{weekday.format(new Date(Date.UTC(2026, 0, 4 + i)))}</Text>
48
+ ))}
49
+ </View>
50
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
51
+ {cells.map((d, i) => {
52
+ if (!d) return <View key={`_${i}`} style={{ width: `${100 / 7}%`, height: 44 }} />;
53
+ const off = d < min || (!!max && d > max), on = d === value;
54
+ return (
55
+ <Pressable key={d} accessibilityRole="button" accessibilityLabel={fullDay.format(new Date(`${d}T00:00:00Z`))} accessibilityState={{ selected: on, disabled: off }}
56
+ disabled={off} onPress={() => onChange(d)} style={{ width: `${100 / 7}%`, height: 44, padding: 2 }}>
57
+ <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', borderRadius: radius, backgroundColor: on ? t.colors.accent : undefined }}>
58
+ <Text style={[t.styles.text.body, { color: on ? t.colors.onAccent : off ? t.colors.line : t.colors.ink }, on && { fontWeight: '600' }]}>{Number(d.slice(8))}</Text>
59
+ </View>
60
+ </Pressable>
61
+ );
62
+ })}
63
+ </View>
64
+ </View>
65
+ );
66
+ }
@@ -0,0 +1,19 @@
1
+ import { Pressable, Text, type StyleProp, type ViewStyle } from 'react-native';
2
+ import { Link } from 'expo-router';
3
+ import { useStrings, useTheme } from '@acms-subir/native';
4
+
5
+ // "Planning something? Request an event" for the end of the events list,
6
+ // e.g. ListFooterComponent={<RequestEventLink />} in app/events/index.tsx.
7
+ export function RequestEventLink({ style }: { style?: StyleProp<ViewStyle> }) {
8
+ const t = useTheme();
9
+ const words = useStrings();
10
+ return (
11
+ <Link href="/request" asChild>
12
+ <Pressable accessibilityRole="link" style={({ pressed }) => [{ minHeight: 44, paddingVertical: 16, justifyContent: 'center', opacity: pressed ? 0.7 : 1 }, style]}>
13
+ <Text style={[t.styles.text.muted, { textAlign: 'center' }]}>
14
+ {words.requestPlanning} <Text style={{ color: t.colors.accent, fontWeight: '600' }}>{words.requestEvent}</Text>
15
+ </Text>
16
+ </Pressable>
17
+ </Link>
18
+ );
19
+ }
@@ -0,0 +1,20 @@
1
+ import { Pressable, Text, type StyleProp, type ViewStyle } from 'react-native';
2
+ import { Link } from 'expo-router';
3
+ import { useStrings, useTheme } from '@acms-subir/native';
4
+
5
+ // The More tab's row that opens /request: "Request an event" ("Solicitar un
6
+ // evento"). Always shown, even while the site isn't taking requests: the
7
+ // screen then says so and gives the office phone. Hiding it would hide a
8
+ // feature from App Review, so don't wrap it in a check.
9
+ export function RequestEventRow({ style }: { style?: StyleProp<ViewStyle> }) {
10
+ const t = useTheme();
11
+ const words = useStrings();
12
+ return (
13
+ <Link href="/request" asChild>
14
+ <Pressable accessibilityRole="button" style={({ pressed }) => [{ flexDirection: 'row', alignItems: 'center', gap: 12, minHeight: 52, paddingVertical: 12, opacity: pressed ? 0.7 : 1 }, style]}>
15
+ <Text style={[t.styles.text.body, { flex: 1 }]}>{words.requestEvent}</Text>
16
+ <Text style={[t.styles.text.heading, { color: t.colors.muted }]} importantForAccessibility="no" accessibilityElementsHidden>›</Text>
17
+ </Pressable>
18
+ </Link>
19
+ );
20
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "kind": "client",
3
+ "title": "Event requests",
4
+ "description": "Request an event without an account: something new (what, kind, date and times, repeats, rooms with a live \"looks free\" line, people) or a change to an event, then name, email and phone, and Request sent with the reply time. When the site isn't taking requests the screen still opens and says so, with the office phone. Includes a row for the More tab and a link for the end of the events list. Needs requests turned on for the site.",
5
+ "packages": [
6
+ "expo-crypto",
7
+ "expo-secure-store"
8
+ ],
9
+ "routes": [
10
+ "/request"
11
+ ],
12
+ "next": [
13
+ "Turn requests on for the site: acms({ requests: true }) in astro.config.mjs (needs the calendar), then restart or deploy. Set the notice, reply time, office phone, kinds and the rooms people may ask for in Admin → Calendar → Requests; staff answer requests there",
14
+ "Add the row to your More tab: import { RequestEventRow } from '../../components/RequestEventRow' in app/(tabs)/more.tsx (or '../components/RequestEventRow' from a screen in app/) and render <RequestEventRow /> in its list. Keep it there even while requests are closed: the screen says so and gives the office phone, and App Review looks for it",
15
+ "Optional, at the end of the events list: ListFooterComponent={<RequestEventLink />} in app/events/index.tsx (from '../../components/RequestEventLink')",
16
+ "Open the screen from site links: in onNavigate, send '/request' (or your acms({ requests: { path } }); '/es/request' in Spanish) to router.push('/request')",
17
+ "The live \"looks free\" line under the rooms needs bookings on the site (it uses GET /api/v1/rooms/free, free or busy only); without them the screen works the same, without that line",
18
+ "App Store Connect → App Review Information → Notes: \"Request an event: open the More tab and tap Request an event. No sign-in is needed. The screen asks for a name, an email address, an optional phone number and details or a message about the event (something new, or a change to an existing event). Staff review every request on the website before anything goes on the calendar, and answer by email. If you send a test request, please write App Review in the message so staff can decline it.\"",
19
+ "App Privacy (App Store Connect) and Data safety (Google Play) for what the screen sends: Contact Info → Name, Email Address, Phone Number (Play: Personal info → Name, Email address, Phone number) and User Content → Other User Content (Play: App activity → Other user-generated content). Purpose: App Functionality; linked to the person; not used for tracking; not shared; optional (people choose to send it). Name, email and phone also stay on the phone in secure storage for next time"
20
+ ]
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.54",
3
+ "version": "0.3.56",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },