@acms-subir/core 0.3.62 → 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 +14 -0
- package/cli/migrations.generated.mjs +8 -0
- package/docs/calendar.md +57 -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/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/display/board.ts +5 -3
- package/src/lib/editor/schedule.ts +2 -0
- package/src/lib/mobile/calendar.ts +30 -6
- 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/repo/migrations.ts +8 -0
- package/src/lib/repo/sql.ts +10 -5
- package/src/lib/schema.ts +19 -5
- package/src/lib/sections.ts +4 -2
- package/src/pages/admin/calendar/resources/link.astro +14 -1
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
3
17
|
## 0.3.62
|
|
4
18
|
|
|
5
19
|
### 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.
|
|
@@ -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
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// is a plain link or form (?date=…&len=…&start=…), so it works without
|
|
5
5
|
// JavaScript and each state is shareable. Used by the page and the embed.
|
|
6
6
|
import type { Booking, BookingLink, Resource, Settings } from '../../lib/schema';
|
|
7
|
+
import { linkWords, roomName } from '../../lib/schema';
|
|
7
8
|
import { bookingLinkUrl, resourceUrl } from '../../lib/calendar/config';
|
|
9
|
+
import { localePath } from '../../lib/i18n';
|
|
8
10
|
import { describeLength, describeLink, linkLengths, openDays, slotsForDay, ymd, type AvailabilityContext } from '../../lib/booking/availability';
|
|
9
11
|
import { addMonths, fmt, monthCells, slotLabel } from '../../lib/booking/view';
|
|
10
12
|
import { confirmsInstantly, linkApproval } from '../../lib/booking/approval';
|
|
@@ -19,8 +21,13 @@ interface Props {
|
|
|
19
21
|
values?: Record<string, string>;
|
|
20
22
|
embed?: boolean;
|
|
21
23
|
reschedule?: Booking | null;
|
|
24
|
+
/** the page's language: the link and room are named in it, and links stay in it ('' = the main one) */
|
|
25
|
+
locale?: string;
|
|
22
26
|
}
|
|
23
|
-
const { resource, link, ctx, settings, url, error = '', values = {}, embed = false, reschedule = null } = Astro.props;
|
|
27
|
+
const { resource, link, ctx, settings, url, error = '', values = {}, embed = false, reschedule = null, locale = '' } = Astro.props;
|
|
28
|
+
// the booking link and room in the page's language (the rest of the flow is in English)
|
|
29
|
+
const words = linkWords(link, locale);
|
|
30
|
+
const room = roomName(resource, locale);
|
|
24
31
|
const tz = ctx.resource.timezone || settings.timezone || 'UTC';
|
|
25
32
|
// "rules" links usually confirm at once, but may not — promise only what's certain
|
|
26
33
|
const instant = confirmsInstantly(linkApproval(link, settings));
|
|
@@ -42,10 +49,11 @@ const startParam = Number(url.searchParams.get('start'));
|
|
|
42
49
|
const chosen = slots.find((s) => s.start === startParam && (link.mode !== 'day' || s.minutes === Number(url.searchParams.get('len') || s.minutes)));
|
|
43
50
|
const minMonth = today.slice(0, 7);
|
|
44
51
|
const maxMonth = ymd(ctx.now + link.maxAdvance * 86_400_000, tz).slice(0, 7);
|
|
45
|
-
const base = embed ? `/embed/book/${resource.slug}/${link.slug}` : bookingLinkUrl(resource.slug, link.slug);
|
|
52
|
+
const base = embed ? `/embed/book/${resource.slug}/${link.slug}` : localePath(bookingLinkUrl(resource.slug, link.slug), locale);
|
|
46
53
|
const q = (patch: Record<string, string | number | undefined>) => {
|
|
47
54
|
const p = new URLSearchParams();
|
|
48
|
-
|
|
55
|
+
// an embed keeps its ?lang= (embeds are never under /es)
|
|
56
|
+
for (const k of ['len', 'm', 'date', 'start', 'reschedule', ...(embed ? ['lang'] : [])]) {
|
|
49
57
|
const v = k in patch ? patch[k] : url.searchParams.get(k) ?? undefined;
|
|
50
58
|
if (v !== undefined && v !== '' && v !== null) p.set(k, String(v));
|
|
51
59
|
}
|
|
@@ -62,10 +70,10 @@ const val = (k: string) => values[k] ?? '';
|
|
|
62
70
|
|
|
63
71
|
<div class={`pc-bk${embed ? ' is-embed' : ''}`}>
|
|
64
72
|
<aside class="pc-bk-info">
|
|
65
|
-
{!embed && <a class="pc-bk-back" href={resourceUrl(resource.slug)}>‹ {
|
|
66
|
-
{embed && <span class="pc-bk-kicker">{
|
|
67
|
-
<h1 class="pc-bk-title">{
|
|
68
|
-
{
|
|
73
|
+
{!embed && <a class="pc-bk-back" href={localePath(resourceUrl(resource.slug), locale)}>‹ {room}</a>}
|
|
74
|
+
{embed && <span class="pc-bk-kicker">{room}</span>}
|
|
75
|
+
<h1 class="pc-bk-title">{words.name}</h1>
|
|
76
|
+
{words.description && <p class="pc-bk-desc">{words.description}</p>}
|
|
69
77
|
<ul class="pc-bk-rules">{rules.map((r) => <li>{r}</li>)}</ul>
|
|
70
78
|
{link.mode === 'range' && lengths.length > 1 && (
|
|
71
79
|
<div class="pc-bk-lens" role="group" aria-label="How long">
|
|
@@ -111,7 +119,7 @@ const val = (k: string) => values[k] ?? '';
|
|
|
111
119
|
) : (
|
|
112
120
|
<section class="pc-bk-form">
|
|
113
121
|
<div class="pc-bk-summary">
|
|
114
|
-
<b>{
|
|
122
|
+
<b>{room} · {words.name}</b>
|
|
115
123
|
<span>{f.dayLong.format(chosen.start)} · {slotLabel(chosen, link, f)}</span>
|
|
116
124
|
<a href={q({ start: undefined })}>Change</a>
|
|
117
125
|
</div>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Confirmed room bookings as schedule items: /calendar, embeds and TV boards
|
|
2
|
+
// (lib/calendar/data.ts) and the app API's `bookings` (lib/mobile/calendar.ts).
|
|
3
|
+
// Publicly a booking is named by its booking link in the reader's language
|
|
4
|
+
// ("Rehearsal", "Ensayo"; the calendar's "Reserved" when the link is gone)
|
|
5
|
+
// and never says who booked: no name, email, phone, notes, answers or manage
|
|
6
|
+
// token, and not the booking's id (publicBookingKey stands in for it).
|
|
7
|
+
import type { ContentRepository } from '../repo/types';
|
|
8
|
+
import { linkWords, type Booking, type BookingLink, type Resource } from '../schema';
|
|
9
|
+
import { sha256Hex } from '../api-keys';
|
|
10
|
+
|
|
11
|
+
export interface ScheduleBooking { booking: Booking; resource: Resource; link: BookingLink | null }
|
|
12
|
+
|
|
13
|
+
/** Confirmed bookings of these rooms overlapping [from, to), with their room and booking link, by start. */
|
|
14
|
+
export async function scheduleBookings(repo: ContentRepository, rooms: Resource[], from: number, to: number, limit = 5000): Promise<ScheduleBooking[]> {
|
|
15
|
+
if (!rooms.length) return [];
|
|
16
|
+
const byId = new Map(rooms.map((r) => [r.id, r]));
|
|
17
|
+
const [booked, links] = await Promise.all([
|
|
18
|
+
repo.listBookings({ from, to, statuses: ['confirmed'], kinds: ['booking'] }),
|
|
19
|
+
repo.listBookingLinks(),
|
|
20
|
+
]);
|
|
21
|
+
const linkById = new Map(links.map((l) => [l.id, l]));
|
|
22
|
+
const out: ScheduleBooking[] = [];
|
|
23
|
+
for (const b of booked) {
|
|
24
|
+
const resource = byId.get(b.resourceId);
|
|
25
|
+
if (resource) out.push({ booking: b, resource, link: linkById.get(b.linkId) ?? null });
|
|
26
|
+
if (out.length >= limit) break;
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether a booking reads as all day: its link books whole days (without its link: a day long, give or take a clock change). */
|
|
32
|
+
export const bookingAllDay = (b: Pick<Booking, 'start' | 'end'>, link: Pick<BookingLink, 'mode'> | null) =>
|
|
33
|
+
link ? link.mode === 'day' : b.end - b.start >= 86_400_000 - 3_600_000;
|
|
34
|
+
|
|
35
|
+
/** A booking's public title: its link's name in `locale`, else `reserved` (the calendar's word in that language). */
|
|
36
|
+
export const publicBookingTitle = (link: BookingLink | null, locale: string | null | undefined, reserved: string) =>
|
|
37
|
+
(link && linkWords(link, locale).name) || reserved;
|
|
38
|
+
|
|
39
|
+
/** A stable key for a booking in public data that doesn't reveal its id. */
|
|
40
|
+
export const publicBookingKey = async (b: Pick<Booking, 'id'>) => `b${(await sha256Hex(`public-booking:${b.id}`)).slice(0, 24)}`;
|