@acms-subir/core 0.3.61 → 0.3.63
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 +26 -0
- package/cli/migrations.generated.mjs +8 -0
- package/docs/calendar.md +57 -1
- package/docs/mobile.md +7 -1
- package/mobile-template/recipes/booking/files/app/book/[resource].tsx +32 -17
- package/mobile-template/recipes/booking/files/app/book/details.tsx +34 -25
- package/mobile-template/recipes/booking/files/app/book/index.tsx +11 -5
- package/mobile-template/recipes/booking/files/app/book/manage.tsx +82 -0
- package/mobile-template/recipes/booking/recipe.json +6 -3
- package/package.json +1 -1
- package/src/components/MobileApp.tsx +11 -6
- package/src/components/booking/BookingFlow.astro +16 -8
- package/src/lib/booking/public.ts +40 -0
- package/src/lib/booking/service.ts +7 -3
- package/src/lib/booking/view.ts +6 -0
- package/src/lib/calendar/data.ts +25 -31
- package/src/lib/calendar/events.ts +12 -2
- package/src/lib/calendar/google/map.ts +1 -1
- package/src/lib/display/board.ts +5 -3
- package/src/lib/editor/schedule.ts +2 -0
- package/src/lib/mobile/calendar.ts +41 -14
- package/src/lib/mobile/guest.ts +4 -2
- package/src/lib/mobile/manage.ts +8 -4
- package/src/lib/mobile/openapi.ts +4 -4
- package/src/lib/mobile/schemas.ts +6 -1
- package/src/lib/mobile/search.ts +7 -6
- package/src/lib/push/api.ts +4 -2
- package/src/lib/push/service.ts +26 -15
- package/src/lib/repo/migrations.ts +8 -0
- package/src/lib/repo/sql.ts +10 -5
- package/src/lib/schema.ts +21 -6
- package/src/lib/sections.ts +4 -2
- package/src/pages/admin/calendar/resources/link.astro +14 -1
- package/src/pages/api/search.ts +3 -3
- package/src/pages/book/[resource]/[link].astro +10 -5
- package/src/pages/book/[resource]/index.astro +14 -8
- package/src/pages/book/index.astro +8 -3
- package/src/pages/book/manage/[token].astro +5 -3
- package/src/pages/embed/book/[resource]/[link].astro +11 -3
- package/src/pages/search.astro +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.63
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- aa39a23: Booking links can have a name and description in each of the site's other languages, and rooms set to show on the calendar put their confirmed bookings on the public schedule, named by their booking link.
|
|
8
|
+
|
|
9
|
+
- **Names per language:** Admin → Calendar → Rooms → a booking link shows **Name in Español** and **Description in Español** (one pair per extra language). The editor API takes `translations: { es: { name, description } }`. Each falls back to the main one on its own. Migration 72 adds `booking_links.translations` (`'{}'` for existing links), and a change refreshes cached pages and API answers. Backups and environment sync carry it. Site code reads `linkWords(link, locale)` from the schema.
|
|
10
|
+
- **Where they show:** the link's name and description, and the room's name, follow the language on `/book` pages under `/es/book/…` (every step now stays under `/es`, so a booking made there keeps the visitor's language), booking embeds with `?lang=`, `GET /api/v1/resources?locale=` (links point at that language's pages), booking emails and the booker's own page, app view and My bookings (in the language it was booked in), and booking section items on Spanish pages. The admin and staff emails keep the main words.
|
|
11
|
+
- **Bookings in the schedule:** public "Reserved" blocks on `/calendar`, embeds and TV boards now show the booking link's name in the page's language ("Rehearsal", "Ensayo"; "Reserved" when the link was deleted), never who booked. They show on views that name no calendar (unfiltered, or by `room=`); `cal=` and `tag=` views leave them out, as before. TV quick links now show them too, the same way. Saved screens with private details keep "<link name> · <booker>".
|
|
12
|
+
- **App API:** `GET /api/v1/events?bookings=1` adds `bookings` (on page 1): `{ id, title, start, end, allDay, rooms }` for each confirmed booking of rooms shown on the calendar. `id` is an opaque key, never the booking's id. `calendar`, `tag` and `excludeTag` don't drop them; `room` narrows them. A whole-day link's bookings are `allDay`.
|
|
13
|
+
- **"The first booking from that email"** (approval "Confirm unless…"): only a confirmed booking now makes an address "returning". Before, a person's second booking confirmed at once while their first was still waiting; now every booking from that address waits until one is confirmed. Declined and cancelled bookings don't count either. It goes by the email address alone, as people book without an account.
|
|
14
|
+
- `GET /api/v1/resources` declares `capacity` as `number | null` (it was always `null` for rooms without one).
|
|
15
|
+
- **Booking recipe** (`npx acms mobile recipe add booking`): English and Spanish words (from the native package), "Waiting for approval" when a booking comes back pending, length chips with readable lengths, the time zone in words, whole-day links (they showed no times before), the approval note and button, and a new `app/book/manage.tsx` to change the time or cancel from the confirmation (needs App access → Change bookings from the app). Recipes are copied once, so apps that already added it can copy the new files by hand.
|
|
16
|
+
|
|
17
|
+
## 0.3.62
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- 729834a: Unlisted calendars work with app notifications and search.
|
|
22
|
+
|
|
23
|
+
- **A rule that names an event covers it in an Unlisted calendar.** Under Admin → Mobile app, the new event, starting soon and event change rules sent notices only for events in Public calendars. A "Class changes" rule (every calendar, tag `Class`) never fired for classes in an Unlisted rooms calendar. A rule now covers an Unlisted calendar's events when it names them, either by listing that calendar or, for an event change rule, by listing a tag the event has. "All public calendars" with no tags still covers only Public calendars, so Unlisted items don't start notifying on their own. Each rule is judged on its own, so a class goes only to the topics of the rule that names it. Private calendars never notify, and neither do appointments from Google booking pages (`#google-booking`). An event's `notify.changes` in the app API follows the same rules.
|
|
24
|
+
- **The Mobile app page lists Unlisted calendars.** They appear next to the Public ones, marked "unlisted", and saving keeps them in a rule (it used to drop them). In an event change rule that lists tags, "All public calendars" reads "All calendars" and covers the Unlisted ones too.
|
|
25
|
+
- **One search rule for the app and the website.** App search (`/api/v1/search`) now finds events in Unlisted calendars, as the website's `/search` and `/api/search` already did. None of them return Google booking-page appointments ("Booked") any more. `/api/v1/events?q=`, which continues an app search's "more", finds the same events; without `q`, `/api/v1/events` still lists Public calendars only.
|
|
26
|
+
|
|
27
|
+
There's no migration, and nothing to change after updating. To get class change notices from an Unlisted calendar, give the rule the `Class` tag (or tick that calendar) in Admin → Mobile app.
|
|
28
|
+
|
|
3
29
|
## 0.3.61
|
|
4
30
|
|
|
5
31
|
### Patch Changes
|
|
@@ -1370,6 +1370,13 @@ var MIGRATIONS = [
|
|
|
1370
1370
|
`ALTER TABLE resources ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
|
|
1371
1371
|
`DROP TRIGGER content_version_resources_update`,
|
|
1372
1372
|
contentVersionTriggers("resources", [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events", "translations"])[2]
|
|
1373
|
+
] },
|
|
1374
|
+
// a booking link's name and description in other languages
|
|
1375
|
+
// (BookingLink.translations): public, so a change refreshes cached pages and API answers
|
|
1376
|
+
{ version: 72, statements: [
|
|
1377
|
+
`ALTER TABLE booking_links ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
|
|
1378
|
+
`DROP TRIGGER content_version_booking_links_update`,
|
|
1379
|
+
contentVersionTriggers("booking_links", [...CONTENT_VERSION_COLUMNS.booking_links, "translations"])[2]
|
|
1373
1380
|
] }
|
|
1374
1381
|
];
|
|
1375
1382
|
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
@@ -1377,6 +1384,7 @@ var CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
|
1377
1384
|
settings: [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale", "legal"],
|
|
1378
1385
|
events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates", "segments"],
|
|
1379
1386
|
resources: [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events", "translations"],
|
|
1387
|
+
booking_links: [...CONTENT_VERSION_COLUMNS.booking_links, "translations"],
|
|
1380
1388
|
announcements: ANNOUNCEMENT_VISIBLE_COLUMNS
|
|
1381
1389
|
};
|
|
1382
1390
|
var LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
package/docs/calendar.md
CHANGED
|
@@ -298,6 +298,62 @@ Where a room's name follows the language:
|
|
|
298
298
|
|
|
299
299
|
The admin and staff emails use the main name.
|
|
300
300
|
|
|
301
|
+
A room's **booking links** have per-language words too: **Name in Español** and
|
|
302
|
+
**Description in Español** in the link's editor (`translations: { es: { name,
|
|
303
|
+
description } }` in the editor API; site code reads `linkWords(link, locale)` from the
|
|
304
|
+
schema). Each falls back to the main one on its own. The link's name and description,
|
|
305
|
+
and the room's name, follow the language:
|
|
306
|
+
- on `/book` pages under `/es/book/…` (the rest of the booking flow stays in English,
|
|
307
|
+
and every step stays under `/es`, so the booking keeps the visitor's language);
|
|
308
|
+
- in booking embeds with `?lang=es`;
|
|
309
|
+
- in `GET /api/v1/resources?locale=es` (with links to the `/es` pages);
|
|
310
|
+
- in booking emails and the booker's own page and app view, in the language it was
|
|
311
|
+
booked in;
|
|
312
|
+
- in booking section items on Spanish pages;
|
|
313
|
+
- in the schedule items below.
|
|
314
|
+
|
|
315
|
+
`GET /api/v1/availability` has no names to translate. The admin, staff emails and the
|
|
316
|
+
older `/api/calendar/resources` keep the main words.
|
|
317
|
+
|
|
318
|
+
### Bookings in the schedule
|
|
319
|
+
|
|
320
|
+
A room with **Show on the calendar** (`showOnCalendar`) puts its **confirmed** bookings
|
|
321
|
+
on the public schedule. Each one is named by its booking link in the reader's language
|
|
322
|
+
("Rehearsal", "Ensayo"; "Reserved" when the link was deleted), with its time and room.
|
|
323
|
+
It never says who booked: no name, email, phone, notes, answers or manage link, and
|
|
324
|
+
not the booking's id. Pending, declined and cancelled bookings, staff blocks and RSVPs
|
|
325
|
+
never show. Rooms without the switch never show their bookings.
|
|
326
|
+
|
|
327
|
+
- **`/calendar`, embeds and TV quick links:** shown on views that name no calendar,
|
|
328
|
+
unfiltered or by `room=` (each in its own room). A booking belongs to no calendar
|
|
329
|
+
and has no tags, so a `cal=` view and a `tag=` view leave bookings out, and `notag=`
|
|
330
|
+
keeps them. For a room's whole schedule, link `/calendar?room=sanctuary`; for main
|
|
331
|
+
events only, `/calendar?cal=events`. `room=` only takes rooms with **Name it on
|
|
332
|
+
public events**.
|
|
333
|
+
- **Saved screens with private details** keep "<link name> · <booker>" for the rooms
|
|
334
|
+
they name. The admin keeps "<room> · <booker>".
|
|
335
|
+
- **The app API:** `GET /api/v1/events?bookings=1` (or `true`) adds `bookings`, apart
|
|
336
|
+
from `occurrences`:
|
|
337
|
+
|
|
338
|
+
```json
|
|
339
|
+
"bookings": [{ "id": "b3f0…", "title": "Ensayo", "start": "2030-01-04T15:00:00.000Z",
|
|
340
|
+
"end": "2030-01-04T17:00:00.000Z", "allDay": false,
|
|
341
|
+
"rooms": [{ "slug": "sanctuary", "name": "Santuario", "color": "#aa3355" }] }]
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
- The whole window's bookings come with page 1; later pages have `[]`. Without
|
|
345
|
+
`bookings=1` the key is absent.
|
|
346
|
+
- `calendar`, `tag` and `excludeTag` filter occurrences only. `room` narrows bookings
|
|
347
|
+
too, and `q` matches their title.
|
|
348
|
+
- `id` is an opaque key, the same on every call, that doesn't reveal the booking's id.
|
|
349
|
+
- A booking link that books whole days gives `allDay: true`.
|
|
350
|
+
- The client has `events({ from, to, calendar: ['events', 'sanctuary'], bookings:
|
|
351
|
+
true })` (type `ScheduleBooking`); the native `useEvents(query)` passes it through.
|
|
352
|
+
- Answers can be up to a minute old (the response cache), as on `/calendar`.
|
|
353
|
+
|
|
354
|
+
Site code reads them with `scheduleBookings(repo, rooms, from, to)` and names them
|
|
355
|
+
with `publicBookingTitle(link, locale, reserved)` (`lib/booking/public.ts`).
|
|
356
|
+
|
|
301
357
|
### Rooms in event data
|
|
302
358
|
|
|
303
359
|
- `/api/v1/events`, `/api/v1/events/:slug`, `/api/v1/search`, `/api/calendar/events`
|
|
@@ -344,7 +400,7 @@ A full-screen calendar for a TV in a lobby or on a room's door. It shows a week,
|
|
|
344
400
|
|
|
345
401
|
There are two kinds of link:
|
|
346
402
|
|
|
347
|
-
- **Quick links** spell out their settings in the address: `/display/calendar?view=week&hours=8-22`. Nothing is saved. Whatever their parameters, they show only what `calendar.ics` shows: published events in public calendars, or in an unlisted calendar named in `cal=`. There are no subscribed calendars,
|
|
403
|
+
- **Quick links** spell out their settings in the address: `/display/calendar?view=week&hours=8-22`. Nothing is saved. Whatever their parameters, they show only what `calendar.ics` shows: published events in public calendars, or in an unlisted calendar named in `cal=`. They also show bookings as `/calendar` does (see [Bookings in the schedule](#bookings-in-the-schedule)): named by their booking link, never who booked, and not with `cal=` or `tag=`. There are no subscribed calendars, room names (until rooms are shown on public events) or hosts.
|
|
348
404
|
- **Saved screens** live in **Admin → Calendar → Screens**. Each has a name, the same settings and a long random key, which is the whole address: `/display/s/<key>`. A screen can show private details (below).
|
|
349
405
|
|
|
350
406
|
Spanish (or any other site language): `/es/display/calendar?…`, or the screen's Language setting.
|
package/docs/mobile.md
CHANGED
|
@@ -257,7 +257,10 @@ For a search screen across everything, `useSearch(text)` returns groups
|
|
|
257
257
|
(posts, pages, events) with snippets. Group `posts` by `collection` for
|
|
258
258
|
headings, and when `more.posts` is true link to the collection list with that
|
|
259
259
|
`q`. Search matches the main-language text, so Spanish words find content only
|
|
260
|
-
where the original is Spanish.
|
|
260
|
+
where the original is Spanish. Events include unlisted calendars (a rooms
|
|
261
|
+
calendar's classes, say), never private ones or Google booking-page
|
|
262
|
+
appointments; when `more.events` is true, `useEvents({ q })` lists the same
|
|
263
|
+
events by date (without `q` it lists public calendars only).
|
|
261
264
|
|
|
262
265
|
**A native version of a coded section.** From the site folder:
|
|
263
266
|
`npx acms section new visit-banner --theme <slug>` (new) or, for an existing
|
|
@@ -281,6 +284,9 @@ keyed by `locale`.
|
|
|
281
284
|
Optional; the owner sends them from **Admin → Mobile app** (manual messages
|
|
282
285
|
plus automatic ones: new post, new event, "starting soon", event changes,
|
|
283
286
|
"we're live"). The app only registers and chooses topics; it never sends.
|
|
287
|
+
Events in an unlisted calendar notify only when a rule names that calendar or a
|
|
288
|
+
tag they have; an event's `notify.changes` says which topics hear about its
|
|
289
|
+
changes.
|
|
284
290
|
|
|
285
291
|
1. Site: `acms({ mobile: { push: true } })` in `astro.config.mjs` (off by
|
|
286
292
|
default; `/api/v1/devices` and `/api/v1/push/topics` 404 until then).
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
|
3
3
|
import { Stack, router, useLocalSearchParams } from 'expo-router';
|
|
4
|
-
import { ErrorView, useAvailability, useLayout, useLocale, useResources, useTheme } from '@acms-subir/native';
|
|
4
|
+
import { ErrorView, fill, lengthText, useAvailability, useLayout, useLocale, useResources, useStrings, useTheme, zoneName } from '@acms-subir/native';
|
|
5
5
|
|
|
6
|
-
// Open
|
|
6
|
+
// Open times for one booking link over the next two weeks. A link with
|
|
7
|
+
// several lengths shows them as chips; a whole-day link lists how many days
|
|
8
|
+
// fit from each open day instead of times.
|
|
7
9
|
export default function SlotsScreen() {
|
|
8
10
|
const t = useTheme();
|
|
11
|
+
const w = useStrings();
|
|
9
12
|
const { locale } = useLocale();
|
|
10
13
|
const { gutter } = useLayout();
|
|
11
14
|
const { resource, link } = useLocalSearchParams<{ resource: string; link: string }>();
|
|
@@ -14,39 +17,51 @@ export default function SlotsScreen() {
|
|
|
14
17
|
const l = r?.links.find((x) => x.slug === link);
|
|
15
18
|
const [minutes, setMinutes] = useState<number | undefined>();
|
|
16
19
|
const length = minutes ?? l?.lengths[0];
|
|
17
|
-
const
|
|
20
|
+
const days = l?.mode === 'day';
|
|
21
|
+
// whole days come in every length that fits; times come for the chosen length
|
|
22
|
+
const slots = useAvailability(resource && link && l ? { resource, link, days: 14, minutes: days ? undefined : length } : null);
|
|
18
23
|
if (slots.error && !slots.data) return <ErrorView error={slots.error} retry={() => slots.refetch()} />;
|
|
19
24
|
const tz = slots.data?.timezone;
|
|
20
25
|
const day = (date: string) => new Date(`${date}T12:00:00Z`).toLocaleDateString(locale, { weekday: 'long', month: 'short', day: 'numeric', timeZone: 'UTC' });
|
|
21
26
|
const time = (iso: string) => new Date(iso).toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
|
|
22
|
-
const
|
|
27
|
+
const span = (m: number) => lengthText(m, w, locale);
|
|
28
|
+
const open = (slots.data?.days ?? []).filter((d) => d.slots.length);
|
|
23
29
|
return (
|
|
24
30
|
<ScrollView style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 20, width: '100%', maxWidth: 840, alignSelf: 'center' }}>
|
|
25
|
-
<Stack.Screen options={{ title: l?.name ??
|
|
26
|
-
{r && <Text style={t.styles.text.muted}>{r.name}{tz ? ` ·
|
|
27
|
-
{!!l &&
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
<Stack.Screen options={{ title: l?.name ?? w.bookPickTime }} />
|
|
32
|
+
{r && <Text style={t.styles.text.muted}>{r.name}{tz && !days ? ` · ${fill(w.bookTimesIn, { zone: zoneName(tz, locale) })}` : ''}</Text>}
|
|
33
|
+
{!!l?.description && <Text style={t.styles.text.body}>{l.description}</Text>}
|
|
34
|
+
{l && !l.autoConfirm && <Text style={t.styles.text.small}>{w.bookApprovalNote}</Text>}
|
|
35
|
+
{!!l && l.mode === 'range' && l.lengths.length > 1 && (
|
|
36
|
+
<View style={{ gap: 8 }}>
|
|
37
|
+
<Text style={t.styles.text.label}>{w.bookHowLong}</Text>
|
|
38
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }} accessibilityRole="radiogroup">
|
|
39
|
+
{l.lengths.map((m) => {
|
|
40
|
+
const on = m === length;
|
|
41
|
+
return (
|
|
42
|
+
<Pressable key={m} accessibilityRole="radio" accessibilityState={{ checked: on }} onPress={() => setMinutes(m)} hitSlop={4}
|
|
43
|
+
style={{ paddingVertical: 8, paddingHorizontal: 12, borderRadius: Math.min(t.radius, 10), borderWidth: 1, borderColor: on ? t.colors.accent : t.colors.line, backgroundColor: on ? t.colors.soft : t.colors.panel }}>
|
|
44
|
+
<Text style={[t.styles.text.body, { fontWeight: on ? '700' : '400' }]}>{span(m)}</Text>
|
|
45
|
+
</Pressable>
|
|
46
|
+
);
|
|
47
|
+
})}
|
|
48
|
+
</View>
|
|
34
49
|
</View>
|
|
35
50
|
)}
|
|
36
51
|
{slots.isPending && <ActivityIndicator color={t.colors.accent} />}
|
|
37
|
-
{!slots.isPending && !
|
|
38
|
-
{
|
|
52
|
+
{!slots.isPending && !open.length && <Text style={t.styles.text.muted}>{w.bookNoTimes}</Text>}
|
|
53
|
+
{open.map((d) => (
|
|
39
54
|
<View key={d.date} style={{ gap: 10 }}>
|
|
40
55
|
<Text style={t.styles.text.heading}>{day(d.date)}</Text>
|
|
41
56
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
|
42
57
|
{d.slots.map((s) => (
|
|
43
58
|
<Pressable
|
|
44
|
-
key={s.start}
|
|
59
|
+
key={`${s.start}:${s.minutes}`}
|
|
45
60
|
accessibilityRole="button"
|
|
46
61
|
onPress={() => router.push({ pathname: '/book/details', params: { resource, link, start: s.start, end: s.end, minutes: String(s.minutes) } })}
|
|
47
62
|
style={({ pressed }) => ({ minWidth: 96, paddingVertical: 10, paddingHorizontal: 12, borderRadius: Math.min(t.radius, 10), borderWidth: 1, borderColor: t.colors.line, backgroundColor: pressed ? t.colors.soft : t.colors.panel, alignItems: 'center' })}
|
|
48
63
|
>
|
|
49
|
-
<Text style={[t.styles.text.body, { fontWeight: '600' }]}>{time(s.start)}</Text>
|
|
64
|
+
<Text style={[t.styles.text.body, { fontWeight: '600' }]}>{days ? span(s.minutes) : time(s.start)}</Text>
|
|
50
65
|
</Pressable>
|
|
51
66
|
))}
|
|
52
67
|
</View>
|
|
@@ -3,14 +3,20 @@ import { KeyboardAvoidingView, Platform, Pressable, ScrollView, Switch, Text, Te
|
|
|
3
3
|
import { Stack, router, useLocalSearchParams } from 'expo-router';
|
|
4
4
|
import * as Crypto from 'expo-crypto';
|
|
5
5
|
import { isAcmsError } from '@acms-subir/client';
|
|
6
|
-
import { Button,
|
|
6
|
+
import { Button, fill, formatBookingDate, useBooking, useLayout, useLocale, useResources, useSite, useStrings, useTheme } from '@acms-subir/native';
|
|
7
7
|
|
|
8
8
|
// Details form and confirmation. useBooking keeps one idempotency key per
|
|
9
9
|
// booking intent, so pressing "Book" again after a timeout can't double-book.
|
|
10
|
+
// It sends the app's language, so the emails and their links come in it. A
|
|
11
|
+
// booking can come back `pending` (a link that needs approval, or a person's
|
|
12
|
+
// first booking under "Confirm unless…"): then it says it's waiting for
|
|
13
|
+
// approval, not that it's confirmed.
|
|
10
14
|
export default function DetailsScreen() {
|
|
11
15
|
const t = useTheme();
|
|
16
|
+
const w = useStrings();
|
|
12
17
|
const { locale } = useLocale();
|
|
13
18
|
const { gutter } = useLayout();
|
|
19
|
+
const site = useSite();
|
|
14
20
|
const p = useLocalSearchParams<{ resource: string; link: string; start: string; end: string; minutes: string }>();
|
|
15
21
|
const r = useResources().data?.resources.find((x) => x.slug === p.resource);
|
|
16
22
|
const l = r?.links.find((x) => x.slug === p.link);
|
|
@@ -19,7 +25,7 @@ export default function DetailsScreen() {
|
|
|
19
25
|
const [email, setEmail] = useState('');
|
|
20
26
|
const [phone, setPhone] = useState('');
|
|
21
27
|
const [answers, setAnswers] = useState<Record<string, string>>({});
|
|
22
|
-
const when =
|
|
28
|
+
const when = formatBookingDate({ start: p.start, end: p.end, allDay: l?.mode === 'day', timezone: r?.timezone }, locale);
|
|
23
29
|
|
|
24
30
|
const input = { borderWidth: 1, borderColor: t.colors.line, borderRadius: Math.min(t.radius, 10), padding: 12, fontSize: 16, color: t.colors.ink, backgroundColor: t.colors.panel } as const;
|
|
25
31
|
const field = (label: string, value: string, onChange: (v: string) => void, opts: { keyboard?: KeyboardTypeOptions; multiline?: boolean; auto?: 'name' | 'email' | 'tel' } = {}) => (
|
|
@@ -32,16 +38,19 @@ export default function DetailsScreen() {
|
|
|
32
38
|
);
|
|
33
39
|
|
|
34
40
|
if (booking.data) {
|
|
35
|
-
const b = booking.data
|
|
41
|
+
const { booking: b, manageToken } = booking.data;
|
|
42
|
+
const confirmed = b.status === 'confirmed';
|
|
36
43
|
return (
|
|
37
44
|
<ScrollView style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 12, width: '100%', maxWidth: 640, alignSelf: 'center' }}>
|
|
38
|
-
<Stack.Screen options={{ title:
|
|
39
|
-
<Text accessibilityRole="header" style={t.styles.text.display}>{b.status === '
|
|
40
|
-
<Text style={t.styles.text.body}>{l?.name
|
|
41
|
-
<Text style={t.styles.text.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
<Stack.Screen options={{ title: w.yourBooking, headerBackVisible: false }} />
|
|
46
|
+
<Text accessibilityRole="header" style={t.styles.text.display}>{confirmed ? w.booked : b.status === 'pending' ? w.awaitingApproval : w.notConfirmed}</Text>
|
|
47
|
+
<Text style={t.styles.text.body}>{[r?.name, l?.name].filter(Boolean).join(' · ')}</Text>
|
|
48
|
+
<Text style={t.styles.text.body}>{when}</Text>
|
|
49
|
+
<Text style={t.styles.text.muted}>{fill(confirmed ? w.confirmationOnItsWay : w.emailWhenApproved, { email: b.email })}</Text>
|
|
50
|
+
{site.data?.features.bookingChanges
|
|
51
|
+
? <Button label={w.bookManage} variant="outline" onPress={() => router.push({ pathname: '/book/manage', params: { token: manageToken } })} />
|
|
52
|
+
: <Text style={t.styles.text.small}>{w.changeByEmail}</Text>}
|
|
53
|
+
<Button label={w.requestDone} onPress={() => { booking.reset(); router.dismissAll(); }} />
|
|
45
54
|
</ScrollView>
|
|
46
55
|
);
|
|
47
56
|
}
|
|
@@ -51,27 +60,27 @@ export default function DetailsScreen() {
|
|
|
51
60
|
return (
|
|
52
61
|
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
|
53
62
|
<ScrollView keyboardShouldPersistTaps="handled" style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 16, width: '100%', maxWidth: 640, alignSelf: 'center' }}>
|
|
54
|
-
<Stack.Screen options={{ title:
|
|
63
|
+
<Stack.Screen options={{ title: w.bookDetails }} />
|
|
55
64
|
<View style={[t.styles.card, { gap: 2 }]}>
|
|
56
|
-
<Text style={t.styles.text.heading}>{l?.name ??
|
|
65
|
+
<Text style={t.styles.text.heading}>{l?.name ?? w.bookTitle}</Text>
|
|
57
66
|
<Text style={t.styles.text.muted}>{r?.name} · {when}</Text>
|
|
58
67
|
</View>
|
|
59
|
-
{field(
|
|
60
|
-
{field(
|
|
61
|
-
{l?.askPhone && field(
|
|
68
|
+
{field(w.nameField, name, setName, { auto: 'name' })}
|
|
69
|
+
{field(w.emailField, email, setEmail, { keyboard: 'email-address', auto: 'email' })}
|
|
70
|
+
{l?.askPhone && field(w.phoneField, phone, setPhone, { keyboard: 'phone-pad', auto: 'tel' })}
|
|
62
71
|
{l?.questions.map((q) => {
|
|
63
|
-
const label = q.label + (q.required ? '' :
|
|
72
|
+
const label = q.label + (q.required ? '' : ` (${w.requestOptional.toLowerCase()})`);
|
|
64
73
|
const set = (v: string) => setAnswers((a) => ({ ...a, [q.id]: v }));
|
|
65
74
|
if (q.type === 'checkbox') return (
|
|
66
75
|
<View key={q.id} style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
|
67
76
|
<Text style={[t.styles.text.body, { flex: 1 }]}>{label}</Text>
|
|
68
|
-
<Switch value={answers[q.id] === 'yes'} onValueChange={(v) => set(v ? 'yes' : '')} trackColor={{ true: t.colors.accent }} />
|
|
77
|
+
<Switch value={answers[q.id] === 'yes'} onValueChange={(v) => set(v ? 'yes' : '')} trackColor={{ true: t.colors.accent }} accessibilityLabel={q.label} />
|
|
69
78
|
</View>
|
|
70
79
|
);
|
|
71
80
|
if (q.type === 'select') return (
|
|
72
81
|
<View key={q.id} style={{ gap: 6 }}>
|
|
73
82
|
<Text style={t.styles.text.label}>{label}</Text>
|
|
74
|
-
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
|
83
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }} accessibilityRole="radiogroup">
|
|
75
84
|
{q.options.map((o) => {
|
|
76
85
|
const on = answers[q.id] === o;
|
|
77
86
|
return (
|
|
@@ -90,15 +99,15 @@ export default function DetailsScreen() {
|
|
|
90
99
|
});
|
|
91
100
|
})}
|
|
92
101
|
{error && (
|
|
93
|
-
<View style={{ backgroundColor: t.colors.soft, borderRadius: Math.min(t.radius, 10), padding: 12, gap: 4 }}>
|
|
94
|
-
<Text style={[t.styles.text.body, { fontWeight: '600' }]}>
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
</Text>
|
|
102
|
+
<View style={{ backgroundColor: t.colors.soft, borderRadius: Math.min(t.radius, 10), padding: 12, gap: 4 }} accessibilityLiveRegion="polite">
|
|
103
|
+
<Text style={[t.styles.text.body, { fontWeight: '600' }]}>{w.bookNotSent}</Text>
|
|
104
|
+
{/* the site's refusals ("That time was just taken…") come in English */}
|
|
105
|
+
<Text style={t.styles.text.muted}>{isAcmsError(error) && error.status === 0 ? w.bookOfflineRetry : error.message}</Text>
|
|
98
106
|
</View>
|
|
99
107
|
)}
|
|
100
|
-
|
|
101
|
-
<
|
|
108
|
+
{l && !l.autoConfirm && <Text style={t.styles.text.small}>{w.bookApprovalNote}</Text>}
|
|
109
|
+
<Button label={booking.isPending ? w.bookSending : l && !l.autoConfirm ? w.bookRequest : w.bookConfirm} disabled={booking.isPending || !name.trim() || !email.trim()} onPress={submit} />
|
|
110
|
+
<Text style={t.styles.text.small}>{w.bookPrivacy}</Text>
|
|
102
111
|
</ScrollView>
|
|
103
112
|
</KeyboardAvoidingView>
|
|
104
113
|
);
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
|
2
2
|
import { Stack, router } from 'expo-router';
|
|
3
|
-
import { ErrorView, useLayout, useResources, useTheme } from '@acms-subir/native';
|
|
3
|
+
import { ErrorView, lengthText, useLayout, useLocale, useResources, useStrings, useTheme } from '@acms-subir/native';
|
|
4
4
|
|
|
5
|
-
// Everything the site lets people book, grouped by resource.
|
|
5
|
+
// Everything the site lets people book, grouped by resource. Rooms and
|
|
6
|
+
// booking links come named in the app's language (their translations on the site).
|
|
6
7
|
export default function BookScreen() {
|
|
7
8
|
const t = useTheme();
|
|
9
|
+
const w = useStrings();
|
|
10
|
+
const { locale } = useLocale();
|
|
8
11
|
const { gutter, wide } = useLayout();
|
|
9
12
|
const { data, error, isPending, refetch } = useResources();
|
|
10
13
|
if (error && !data) return <ErrorView error={error} retry={refetch} />;
|
|
11
14
|
const resources = (data?.resources ?? []).filter((r) => r.links.length);
|
|
15
|
+
// "45 min", "1 h 30 min"
|
|
16
|
+
const length = (m: number) => lengthText(m, w, locale);
|
|
12
17
|
return (
|
|
13
18
|
<ScrollView style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 16, width: '100%', maxWidth: 1040, alignSelf: 'center' }}>
|
|
14
|
-
<Stack.Screen options={{ title:
|
|
19
|
+
<Stack.Screen options={{ title: w.bookTitle }} />
|
|
15
20
|
{isPending && <ActivityIndicator color={t.colors.accent} />}
|
|
16
|
-
{!isPending && !resources.length && <Text style={t.styles.text.muted}>
|
|
21
|
+
{!isPending && !resources.length && <Text style={t.styles.text.muted}>{w.bookNothing}</Text>}
|
|
17
22
|
<View style={{ flexDirection: wide ? 'row' : 'column', flexWrap: 'wrap', gap: 16 }}>
|
|
18
23
|
{resources.map((r) => (
|
|
19
24
|
<View key={r.id} style={[t.styles.card, { gap: 10, width: wide ? '48.5%' : '100%' }]}>
|
|
@@ -27,7 +32,8 @@ export default function BookScreen() {
|
|
|
27
32
|
style={({ pressed }) => ({ borderColor: t.colors.line, borderWidth: 1, borderRadius: Math.min(t.radius, 10), padding: 12, gap: 2, opacity: pressed ? 0.85 : 1 })}
|
|
28
33
|
>
|
|
29
34
|
<Text style={[t.styles.text.body, { fontWeight: '600' }]}>{l.name}</Text>
|
|
30
|
-
<Text style={t.styles.text.small}>{l.
|
|
35
|
+
{!!l.description && <Text style={t.styles.text.small}>{l.description}</Text>}
|
|
36
|
+
{l.mode !== 'day' && <Text style={t.styles.text.small}>{l.lengths.map(length).join(' · ')}</Text>}
|
|
31
37
|
</Pressable>
|
|
32
38
|
))}
|
|
33
39
|
</View>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
|
3
|
+
import { Stack, useLocalSearchParams } from 'expo-router';
|
|
4
|
+
import { isAcmsError } from '@acms-subir/client';
|
|
5
|
+
import { Button, ErrorView, fill, formatBookingDate, lengthText, useCancelBooking, useLayout, useLocale, useManagedBooking, useRescheduleAvailability, useRescheduleBooking, useStrings, useTheme } from '@acms-subir/native';
|
|
6
|
+
|
|
7
|
+
// The booker's own booking, from its private token (the confirmation screen
|
|
8
|
+
// passes it): its status and time, a new time, or cancel. Needs the site's
|
|
9
|
+
// App access switch for booking changes (site.features.bookingChanges); the
|
|
10
|
+
// link in every booking email does the same on the website either way. The
|
|
11
|
+
// booking link's change cutoff applies (can.reschedule / can.cancel).
|
|
12
|
+
export default function ManageBookingScreen() {
|
|
13
|
+
const t = useTheme();
|
|
14
|
+
const w = useStrings();
|
|
15
|
+
const { locale } = useLocale();
|
|
16
|
+
const { gutter } = useLayout();
|
|
17
|
+
const { token } = useLocalSearchParams<{ token: string }>();
|
|
18
|
+
const managed = useManagedBooking(token);
|
|
19
|
+
const cancel = useCancelBooking(token);
|
|
20
|
+
const move = useRescheduleBooking(token);
|
|
21
|
+
const [picking, setPicking] = useState(false);
|
|
22
|
+
const [asking, setAsking] = useState(false);
|
|
23
|
+
const [moved, setMoved] = useState(false);
|
|
24
|
+
// a link with several lengths offers new times of the booking's own length
|
|
25
|
+
const length = managed.data?.link?.mode === 'range' ? Math.round((Date.parse(managed.data.booking.end) - Date.parse(managed.data.booking.start)) / 60_000) : undefined;
|
|
26
|
+
const slots = useRescheduleAvailability(token, picking ? { days: 14, minutes: length } : null);
|
|
27
|
+
if (managed.error && !managed.data) return <ErrorView error={managed.error} retry={() => managed.refetch()} />;
|
|
28
|
+
if (!managed.data) return <ActivityIndicator color={t.colors.accent} style={{ marginTop: 32 }} />;
|
|
29
|
+
const { booking: b, title, can, link } = managed.data;
|
|
30
|
+
const when = formatBookingDate({ start: b.start, end: b.end, allDay: b.allDay, timezone: b.timezone }, locale);
|
|
31
|
+
const status = b.status === 'confirmed' ? w.confirmed : b.status === 'pending' ? w.awaitingApproval : b.status === 'cancelled' ? w.bookCancelled : b.status === 'declined' ? w.bookDeclined : w.notConfirmed;
|
|
32
|
+
const day = (date: string) => new Date(`${date}T12:00:00Z`).toLocaleDateString(locale, { weekday: 'long', month: 'short', day: 'numeric', timeZone: 'UTC' });
|
|
33
|
+
const time = (iso: string) => new Date(iso).toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', timeZone: slots.data?.timezone ?? b.timezone });
|
|
34
|
+
const failure = cancel.error ?? move.error;
|
|
35
|
+
const open = (slots.data?.days ?? []).filter((d) => d.slots.length);
|
|
36
|
+
return (
|
|
37
|
+
<ScrollView style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' }}>
|
|
38
|
+
<Stack.Screen options={{ title: w.yourBooking }} />
|
|
39
|
+
<View style={[t.styles.card, { gap: 4 }]}>
|
|
40
|
+
<Text style={t.styles.text.heading}>{title}</Text>
|
|
41
|
+
<Text style={t.styles.text.body}>{when}</Text>
|
|
42
|
+
<Text style={t.styles.text.muted}>{status}</Text>
|
|
43
|
+
</View>
|
|
44
|
+
{moved && <Text style={t.styles.text.body} accessibilityLiveRegion="polite">{w.bookMoved}</Text>}
|
|
45
|
+
{failure && <Text style={t.styles.text.muted} accessibilityLiveRegion="polite">{isAcmsError(failure) && failure.status === 0 ? w.checkConnection : failure.message}</Text>}
|
|
46
|
+
{can.closed && <Text style={t.styles.text.small}>{fill(w.bookChangesClosed, { hours: String(can.cutoffHours) })}</Text>}
|
|
47
|
+
|
|
48
|
+
{can.reschedule && !picking && <Button label={w.bookMove} variant="outline" onPress={() => { setMoved(false); setPicking(true); }} />}
|
|
49
|
+
{picking && (
|
|
50
|
+
<View style={{ gap: 12 }}>
|
|
51
|
+
{slots.isPending && <ActivityIndicator color={t.colors.accent} />}
|
|
52
|
+
{!slots.isPending && !open.length && <Text style={t.styles.text.muted}>{w.bookNoTimes}</Text>}
|
|
53
|
+
{open.map((d) => (
|
|
54
|
+
<View key={d.date} style={{ gap: 8 }}>
|
|
55
|
+
<Text style={t.styles.text.label}>{day(d.date)}</Text>
|
|
56
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
|
57
|
+
{d.slots.map((s) => (
|
|
58
|
+
<Pressable key={`${s.start}:${s.minutes}`} accessibilityRole="button" disabled={move.isPending}
|
|
59
|
+
onPress={() => move.mutate({ start: s.start, minutes: s.minutes }, { onSuccess: () => { setPicking(false); setMoved(true); } })}
|
|
60
|
+
style={({ pressed }) => ({ minWidth: 96, paddingVertical: 10, paddingHorizontal: 12, borderRadius: Math.min(t.radius, 10), borderWidth: 1, borderColor: t.colors.line, backgroundColor: pressed ? t.colors.soft : t.colors.panel, alignItems: 'center', opacity: move.isPending ? 0.5 : 1 })}>
|
|
61
|
+
<Text style={[t.styles.text.body, { fontWeight: '600' }]}>{link?.mode === 'day' ? lengthText(s.minutes, w, locale) : time(s.start)}</Text>
|
|
62
|
+
</Pressable>
|
|
63
|
+
))}
|
|
64
|
+
</View>
|
|
65
|
+
</View>
|
|
66
|
+
))}
|
|
67
|
+
{move.isPending && <Text style={t.styles.text.small}>{w.bookMoving}</Text>}
|
|
68
|
+
<Button label={w.keepIt} variant="outline" onPress={() => setPicking(false)} />
|
|
69
|
+
</View>
|
|
70
|
+
)}
|
|
71
|
+
|
|
72
|
+
{can.cancel && !asking && <Button label={w.bookCancel} variant="outline" onPress={() => setAsking(true)} />}
|
|
73
|
+
{can.cancel && asking && (
|
|
74
|
+
<View style={[t.styles.card, { gap: 10 }]}>
|
|
75
|
+
<Text style={t.styles.text.body}>{w.bookCancelAsk}</Text>
|
|
76
|
+
<Button label={cancel.isPending ? w.cancelling : w.yesCancel} disabled={cancel.isPending} onPress={() => cancel.mutate(undefined, { onSuccess: () => setAsking(false) })} />
|
|
77
|
+
<Button label={w.keepIt} variant="outline" onPress={() => setAsking(false)} />
|
|
78
|
+
</View>
|
|
79
|
+
)}
|
|
80
|
+
</ScrollView>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"kind": "client",
|
|
3
3
|
"title": "Booking",
|
|
4
|
-
"description": "Resources and booking links → open
|
|
4
|
+
"description": "Resources and booking links → open times (length chips, whole days) → details form → confirmed or waiting for approval, with retry-safe submits; then change or cancel. English and Spanish built in.",
|
|
5
5
|
"packages": [
|
|
6
6
|
"expo-crypto"
|
|
7
7
|
],
|
|
8
8
|
"routes": [
|
|
9
9
|
"/book",
|
|
10
10
|
"/book/[resource]",
|
|
11
|
-
"/book/details"
|
|
11
|
+
"/book/details",
|
|
12
|
+
"/book/manage"
|
|
12
13
|
],
|
|
13
14
|
"next": [
|
|
14
15
|
"Link to it from a screen: <Link href=\"/book\">Book</Link> (expo-router)",
|
|
15
|
-
"Bookings must be enabled on the site (Admin → Bookings) with at least one public booking link."
|
|
16
|
+
"Bookings must be enabled on the site (Admin → Bookings) with at least one public booking link.",
|
|
17
|
+
"Name each booking link in your other languages (the link's Name in Español), so the app shows it in the person's language.",
|
|
18
|
+
"Change or cancel in the app needs Admin → Mobile app → App access → Change bookings from the app (on by default); otherwise the screen points to the link in the confirmation email."
|
|
16
19
|
]
|
|
17
20
|
}
|
package/package.json
CHANGED
|
@@ -53,7 +53,7 @@ function MobileAppView({ state, setState }: { state: AdminPushState; setState: (
|
|
|
53
53
|
const topicLabel = useMemo(() => {
|
|
54
54
|
const map = new Map(state.topics.map((t) => [t.id, t.label]));
|
|
55
55
|
for (const c of state.collections) if (!map.has(`collection:${c.key}`)) map.set(`collection:${c.key}`, c.label);
|
|
56
|
-
for (const c of state.calendars) if (!map.has(`calendar:${c.slug}`)) map.set(`calendar:${c.slug}`, c.name);
|
|
56
|
+
for (const c of state.calendars) if (c.visibility === 'public' && !map.has(`calendar:${c.slug}`)) map.set(`calendar:${c.slug}`, c.name);
|
|
57
57
|
map.set('*', 'Everyone');
|
|
58
58
|
return (id: string) => map.get(id) ?? id;
|
|
59
59
|
}, [state]);
|
|
@@ -282,14 +282,19 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
|
|
|
282
282
|
const setRule = (i: number, patch: Partial<ChangeRule>) => setAuto({ ...auto, eventChanges: rules.map((r, j) => (j === i ? { ...r, ...patch } : r)) });
|
|
283
283
|
// focus moves to "Add a rule" (enabled again once the tenth rule goes)
|
|
284
284
|
const removeRule = (i: number) => { setAuto({ ...auto, eventChanges: rules.filter((_, j) => j !== i) }); requestAnimationFrame(() => addRule.current?.focus()); };
|
|
285
|
-
|
|
285
|
+
// '*' covers the public calendars; an unlisted one only when it's ticked, or
|
|
286
|
+
// (`tagged`: a change rule that lists tags) for events with those tags
|
|
287
|
+
const calendarChoices = (list: string[], set: (calendars: string[]) => void, tagged = false) => {
|
|
286
288
|
const all = list.includes('*');
|
|
289
|
+
const unlisted = new Set(state.calendars.filter((c) => c.visibility !== 'public').map((c) => c.id));
|
|
290
|
+
const covered = (c: AdminPushState['calendars'][number]) => all && (c.visibility === 'public' || tagged);
|
|
287
291
|
return (
|
|
288
292
|
<div className="ma-choices">
|
|
289
|
-
<label className="ma-check"><input type="checkbox" checked={all}
|
|
293
|
+
<label className="ma-check"><input type="checkbox" checked={all}
|
|
294
|
+
onChange={() => set(all ? list.filter((id) => id !== '*') : ['*', ...list.filter((id) => unlisted.has(id))])} /><span>{tagged ? 'All calendars' : 'All public calendars'}</span></label>
|
|
290
295
|
{state.calendars.map((c) => (
|
|
291
|
-
<label key={c.id} className="ma-check"><input type="checkbox" disabled={
|
|
292
|
-
<span><i className="ma-dot" style={{ background: c.color || 'var(--faint)' }} />{c.name}</span></label>
|
|
296
|
+
<label key={c.id} className="ma-check"><input type="checkbox" disabled={covered(c)} checked={covered(c) || list.includes(c.id)} onChange={() => set(flip(list, c.id))} />
|
|
297
|
+
<span><i className="ma-dot" style={{ background: c.color || 'var(--faint)' }} />{c.name}{c.visibility !== 'public' && <span className="hint"> · unlisted</span>}</span></label>
|
|
293
298
|
))}
|
|
294
299
|
</div>
|
|
295
300
|
);
|
|
@@ -337,7 +342,7 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
|
|
|
337
342
|
<b id={`${uid}-rule-${i}`}>Rule {i + 1}</b>
|
|
338
343
|
<button type="button" className="btn ghost sm" aria-label={`Remove rule ${i + 1}`} onClick={() => removeRule(i)}>Remove</button>
|
|
339
344
|
</div>
|
|
340
|
-
{calendarChoices(r.calendars, (calendars) => setRule(i, { calendars }))}
|
|
345
|
+
{calendarChoices(r.calendars, (calendars) => setRule(i, { calendars }), r.tags.length > 0)}
|
|
341
346
|
<div className="ma-fields">
|
|
342
347
|
<div className="field ma-lead">
|
|
343
348
|
<span>Only events tagged</span>
|