@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 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, booking blocks, room names (until rooms are shown on public events) or hosts.
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 slots for one booking link over the next two weeks.
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 slots = useAvailability(resource && link ? { resource, link, days: 14, minutes: length } : null);
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 days = (slots.data?.days ?? []).filter((d) => d.slots.length);
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 ?? 'Choose a time' }} />
26
- {r && <Text style={t.styles.text.muted}>{r.name}{tz ? ` · times in ${tz}` : ''}</Text>}
27
- {!!l && l.lengths.length > 1 && (
28
- <View style={{ flexDirection: 'row', gap: 16 }} accessibilityRole="tablist">
29
- {l.lengths.map((m) => (
30
- <Pressable key={m} accessibilityRole="tab" accessibilityState={{ selected: m === length }} onPress={() => setMinutes(m)} hitSlop={8}>
31
- <Text style={[t.styles.text.body, { color: m === length ? t.colors.ink : t.colors.muted, fontWeight: m === length ? '700' : '400' }]}>{m} min</Text>
32
- </Pressable>
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 && !days.length && <Text style={t.styles.text.muted}>No open times in the next two weeks.</Text>}
38
- {days.map((d) => (
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, formatEventDate, useBooking, useLayout, useLocale, useResources, useTheme } from '@acms-subir/native';
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 = formatEventDate({ start: p.start, end: p.end, timezone: r?.timezone }, locale);
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.booking;
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: 'Booked', headerBackVisible: false }} />
39
- <Text accessibilityRole="header" style={t.styles.text.display}>{b.status === 'confirmed' ? 'You’re booked' : 'Request sent'}</Text>
40
- <Text style={t.styles.text.body}>{l?.name} · {when}</Text>
41
- <Text style={t.styles.text.muted}>
42
- {b.status === 'confirmed' ? `A confirmation is on its way to ${b.email}.` : `We’ll email ${b.email} once it’s confirmed.`}
43
- </Text>
44
- <Button label="Done" onPress={() => { booking.reset(); router.dismissAll(); }} />
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: 'Your details' }} />
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 ?? 'Booking'}</Text>
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('Name', name, setName, { auto: 'name' })}
60
- {field('Email', email, setEmail, { keyboard: 'email-address', auto: 'email' })}
61
- {l?.askPhone && field('Phone', phone, setPhone, { keyboard: 'phone-pad', auto: 'tel' })}
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 ? '' : ' (optional)');
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' }]}>That didn’t go through.</Text>
95
- <Text style={t.styles.text.muted}>
96
- {isAcmsError(error) && error.status === 0 ? 'Check your connection and try again. You won’t be booked twice.' : error.message}
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
- <Button label={booking.isPending ? 'Booking…' : 'Book'} disabled={booking.isPending || !name.trim() || !email.trim()} onPress={submit} />
101
- <Text style={t.styles.text.small}>Your details go only to the organiser of this booking.</Text>
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: 'Book' }} />
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}>Nothing is open for booking right now.</Text>}
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.lengths.length ? l.lengths.map((m) => `${m} min`).join(' · ') : l.mode}</Text>
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 slots → details form → confirmation, with retry-safe submits.",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.62",
3
+ "version": "0.3.63",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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
- for (const k of ['len', 'm', 'date', 'start', 'reschedule']) {
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)}>‹ {resource.name}</a>}
66
- {embed && <span class="pc-bk-kicker">{resource.name}</span>}
67
- <h1 class="pc-bk-title">{link.name}</h1>
68
- {link.description && <p class="pc-bk-desc">{link.description}</p>}
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>{resource.name} · {link.name}</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)}`;