@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.
- package/CHANGELOG.md +36 -0
- package/cli/main.mjs +2 -1
- package/cli/migrations.generated.mjs +59 -6
- package/docs/calendar.md +104 -2
- package/docs/google-calendar.md +302 -0
- package/docs/mobile.md +3 -1
- package/docs/server.md +58 -0
- package/integration.mjs +38 -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/LegalSettings.tsx +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/google/client.ts +167 -0
- package/src/lib/calendar/google/connect.ts +161 -0
- package/src/lib/calendar/google/context.ts +50 -0
- package/src/lib/calendar/google/inbound.ts +493 -0
- package/src/lib/calendar/google/links.ts +139 -0
- package/src/lib/calendar/google/map.ts +543 -0
- package/src/lib/calendar/google/match.ts +103 -0
- package/src/lib/calendar/google/migration.ts +71 -0
- package/src/lib/calendar/google/outbound.ts +272 -0
- package/src/lib/calendar/google/service.ts +193 -0
- package/src/lib/calendar/google/store.ts +241 -0
- package/src/lib/calendar/google/types.ts +188 -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/calendar/schedule.ts +8 -8
- package/src/lib/display/board.ts +3 -1
- package/src/lib/email/service.ts +17 -2
- package/src/lib/email/templates.ts +8 -0
- package/src/lib/legal/admin.ts +1 -1
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/facts.ts +7 -0
- package/src/lib/legal/text-en.ts +7 -0
- package/src/lib/legal/text-es.ts +7 -0
- package/src/lib/live/crypto.ts +7 -28
- 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/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +18 -9
- package/src/lib/repo/types.ts +6 -2
- package/src/lib/scheduler.ts +6 -0
- package/src/lib/schema.ts +43 -2
- package/src/lib/sealed.ts +46 -0
- package/src/pages/admin/calendar/events/[id].astro +42 -1
- package/src/pages/admin/calendar/events/index.astro +2 -0
- package/src/pages/admin/calendar/google/match.astro +160 -0
- package/src/pages/admin/calendar/google.astro +107 -0
- package/src/pages/admin/calendar/requests.astro +298 -0
- package/src/pages/admin/calendar/settings.astro +273 -4
- package/src/pages/admin/integrations.astro +6 -0
- package/src/pages/api/calendar/google/oauth/callback.ts +16 -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 +105 -1
- package/src/styles/site.css +29 -0
- 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
|
+
}
|