@acms-subir/core 0.3.62 → 0.3.64
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 +27 -0
- package/cli/migrations.generated.mjs +31 -1
- package/docs/calendar.md +61 -1
- package/docs/google-calendar.md +81 -4
- package/mobile-template/recipes/booking/files/app/book/[resource].tsx +32 -17
- package/mobile-template/recipes/booking/files/app/book/details.tsx +35 -25
- package/mobile-template/recipes/booking/files/app/book/index.tsx +11 -5
- package/mobile-template/recipes/booking/files/app/book/manage.tsx +93 -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/calendar/google/bookings.ts +296 -0
- package/src/lib/calendar/google/inbound.ts +7 -1
- package/src/lib/calendar/google/links.ts +3 -0
- package/src/lib/calendar/google/map.ts +9 -5
- package/src/lib/calendar/google/match.ts +6 -2
- package/src/lib/calendar/google/migration.ts +34 -0
- package/src/lib/calendar/google/outbound.ts +9 -2
- package/src/lib/calendar/google/store.ts +38 -5
- package/src/lib/calendar/google/types.ts +17 -0
- 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 +13 -1
- 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,32 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.64
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 243474e: The booking recipe no longer puts a booking's manage token in a route. On the web a route is the page address, so the token ended up in history and URL logs, against the app template's own rule. Its confirmation now keeps the token in memory and passes a random key to `app/book/manage.tsx`; after a restart, that screen points to the confirmation email's link instead. The native package has the helpers: `rememberBookingToken(token)` returns the key, and `bookingToken(key)` returns the token (or null). Apps that copied the recipe in 0.3.63 can copy the two changed files (`app/book/details.tsx`, `app/book/manage.tsx`).
|
|
8
|
+
- 445ef91: The Google Calendar sync copies the site's confirmed room bookings to Google, out only. Each linked Google calendar whose link holds the booking's room (direction Both ways or Site → Google only, while the Site URL matches) gets one copy, titled with the booking link's name in the main language, with its time (busy) and a line saying it was booked on the website. Copies never carry the booker's name, email, phone, answers or notes, and never have guests; writes use `sendUpdates=none`. A copy is made when the booking is confirmed (pending bookings never go out), changed when it moves or its link is renamed, moved when the link's rooms change, and deleted when the booking is cancelled, declined or deleted.
|
|
9
|
+
|
|
10
|
+
Every copy has a hidden link (`extendedProperties.private` `{ acmsBookingId, acmsSite }`): incoming reads, full resyncs and the first match never take it as a site event or list it under "Only in Google", and `isBooking()` never reads it as a booking page appointment, whatever its title. An edit or delete of a copy in Google never changes the booking: Recent changes lists it once, and the site's version goes back with the booking's next change. Unlinking keeps the copies; linking again pairs them by their hidden links, and deletes stale ones (a booking cancelled, declined or moved to another room meanwhile, or a second copy), but never a copy of a booking the site doesn't have.
|
|
11
|
+
|
|
12
|
+
A Google booking page only treats the copies as busy when it checks the room's calendar: tick it under the booking page's **Booked appointment settings → Check calendar availability** (the guide's "Booking pages" section has the steps for replacing booking pages with the site's booking links).
|
|
13
|
+
|
|
14
|
+
Migration 73 adds `gcal_booking_items` and triggers on `bookings` and `booking_links` (name and mode) that queue a booking's changes in `gcal_queue` (as `booking:<id>` rows). The guide is `acms docs google-calendar` → Site bookings.
|
|
15
|
+
|
|
16
|
+
## 0.3.63
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- 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.
|
|
21
|
+
|
|
22
|
+
- **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.
|
|
23
|
+
- **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.
|
|
24
|
+
- **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>".
|
|
25
|
+
- **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`.
|
|
26
|
+
- **"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.
|
|
27
|
+
- `GET /api/v1/resources` declares `capacity` as `number | null` (it was always `null` for rooms without one).
|
|
28
|
+
- **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.
|
|
29
|
+
|
|
3
30
|
## 0.3.62
|
|
4
31
|
|
|
5
32
|
### Patch Changes
|
|
@@ -519,6 +519,24 @@ var GCAL_MIGRATION = { version: 69, statements: [
|
|
|
519
519
|
`CREATE TRIGGER gcal_queue_update AFTER UPDATE ON events WHEN ${guard} BEGIN ${enqueue3("NEW.id")} END`,
|
|
520
520
|
`CREATE TRIGGER gcal_queue_delete AFTER DELETE ON events WHEN ${guard} BEGIN ${enqueue3("OLD.id")} END`
|
|
521
521
|
] };
|
|
522
|
+
var enqueueBooking = (id) => enqueue3(`'booking:'||${id}`);
|
|
523
|
+
var GCAL_BOOKINGS_MIGRATION = { version: 73, statements: [
|
|
524
|
+
`CREATE TABLE gcal_booking_items (
|
|
525
|
+
link_id TEXT NOT NULL, google_id TEXT NOT NULL, booking_id TEXT NOT NULL, site TEXT NOT NULL DEFAULT '', google TEXT NOT NULL DEFAULT '',
|
|
526
|
+
etag TEXT NOT NULL DEFAULT '', html_link TEXT NOT NULL DEFAULT '', gone INTEGER NOT NULL DEFAULT 0, synced_at INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL,
|
|
527
|
+
PRIMARY KEY (link_id, google_id), UNIQUE (link_id, booking_id)
|
|
528
|
+
)`,
|
|
529
|
+
`CREATE INDEX gcal_booking_items_booking ON gcal_booking_items (booking_id)`,
|
|
530
|
+
`CREATE TRIGGER gcal_booking_insert AFTER INSERT ON bookings WHEN ${guard} AND NEW.kind='booking' AND NEW.status='confirmed' BEGIN ${enqueueBooking("NEW.id")} END`,
|
|
531
|
+
`CREATE TRIGGER gcal_booking_update AFTER UPDATE OF status, start_at, end_at, resource_id, link_id, kind ON bookings WHEN ${guard} AND (NEW.kind='booking' OR OLD.kind='booking')
|
|
532
|
+
AND (OLD.status IS NOT NEW.status OR OLD.start_at IS NOT NEW.start_at OR OLD.end_at IS NOT NEW.end_at OR OLD.resource_id IS NOT NEW.resource_id OR OLD.link_id IS NOT NEW.link_id OR OLD.kind IS NOT NEW.kind)
|
|
533
|
+
BEGIN ${enqueueBooking("NEW.id")} END`,
|
|
534
|
+
`CREATE TRIGGER gcal_booking_delete AFTER DELETE ON bookings WHEN ${guard} AND OLD.kind='booking' BEGIN ${enqueueBooking("OLD.id")} END`,
|
|
535
|
+
`CREATE TRIGGER gcal_booking_link_change AFTER UPDATE OF name, mode ON booking_links WHEN ${guard} AND (OLD.name IS NOT NEW.name OR OLD.mode IS NOT NEW.mode) BEGIN
|
|
536
|
+
INSERT INTO gcal_queue (event_id,version,due_at,attempts,claimed_until,error)
|
|
537
|
+
SELECT 'booking:'||id,1,${now5},0,0,'' FROM bookings WHERE link_id=NEW.id AND kind='booking' AND status='confirmed' AND end_at>${now5}
|
|
538
|
+
ON CONFLICT(event_id) DO UPDATE SET version=version+1,due_at=excluded.due_at,attempts=0,error=''; END`
|
|
539
|
+
] };
|
|
522
540
|
|
|
523
541
|
// src/lib/repo/migrations.ts
|
|
524
542
|
var CONTENT_VERSION_COLUMNS = {
|
|
@@ -1370,13 +1388,25 @@ var MIGRATIONS = [
|
|
|
1370
1388
|
`ALTER TABLE resources ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
|
|
1371
1389
|
`DROP TRIGGER content_version_resources_update`,
|
|
1372
1390
|
contentVersionTriggers("resources", [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events", "translations"])[2]
|
|
1373
|
-
] }
|
|
1391
|
+
] },
|
|
1392
|
+
// a booking link's name and description in other languages
|
|
1393
|
+
// (BookingLink.translations): public, so a change refreshes cached pages and API answers
|
|
1394
|
+
{ version: 72, statements: [
|
|
1395
|
+
`ALTER TABLE booking_links ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
|
|
1396
|
+
`DROP TRIGGER content_version_booking_links_update`,
|
|
1397
|
+
contentVersionTriggers("booking_links", [...CONTENT_VERSION_COLUMNS.booking_links, "translations"])[2]
|
|
1398
|
+
] },
|
|
1399
|
+
// copies of confirmed room bookings in Google (lib/calendar/google/bookings.ts):
|
|
1400
|
+
// their pairs, and triggers on bookings and booking links feeding gcal_queue.
|
|
1401
|
+
// Not public, so no cache trigger.
|
|
1402
|
+
GCAL_BOOKINGS_MIGRATION
|
|
1374
1403
|
];
|
|
1375
1404
|
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
1376
1405
|
...CONTENT_VERSION_COLUMNS,
|
|
1377
1406
|
settings: [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale", "legal"],
|
|
1378
1407
|
events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates", "segments"],
|
|
1379
1408
|
resources: [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events", "translations"],
|
|
1409
|
+
booking_links: [...CONTENT_VERSION_COLUMNS.booking_links, "translations"],
|
|
1380
1410
|
announcements: ANNOUNCEMENT_VISIBLE_COLUMNS
|
|
1381
1411
|
};
|
|
1382
1412
|
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.
|
|
@@ -440,6 +496,10 @@ calendars in step, both ways, within a few minutes. The full guide is
|
|
|
440
496
|
- **Booked appointments** from Google booking pages come in as "Booked" with the
|
|
441
497
|
internal tag `#google-booking` (`GOOGLE_BOOKING_TAG`), holding the link's room. Treat
|
|
442
498
|
them as bookings: the tag never shows publicly.
|
|
499
|
+
- **Site bookings go out.** A confirmed booking of a room a link holds gets a copy in
|
|
500
|
+
that Google calendar, titled with its booking link's name, never who booked. It's out
|
|
501
|
+
only: an edit in Google never changes the booking, and a copy never comes back as an
|
|
502
|
+
event or as "Booked" (`acms docs google-calendar` → Site bookings).
|
|
443
503
|
- **"This and following"** maps to "from this date on", both ways:
|
|
444
504
|
- A Google split changes the event from that date on (`changeFromDate` as `GOOGLE_SYNC`).
|
|
445
505
|
- A change from a date on gives the later part its own Google series.
|
package/docs/google-calendar.md
CHANGED
|
@@ -65,7 +65,8 @@ the account can edit. The settings:
|
|
|
65
65
|
- Site → Google only: Google mirrors the site. Start here, then switch.
|
|
66
66
|
- Google → site only: site edits stay on the site.
|
|
67
67
|
- **Rooms**: the room(s) this Google calendar stands for. Events that come in
|
|
68
|
-
from it hold those rooms. Site events holding one of them go to it
|
|
68
|
+
from it hold those rooms. Site events holding one of them go to it, and so
|
|
69
|
+
do confirmed bookings of them (see [Site bookings](#site-bookings)).
|
|
69
70
|
- **Also gets this calendar's events that hold no room**: one link per site
|
|
70
71
|
calendar may have this.
|
|
71
72
|
- A link with no rooms carries its whole site calendar.
|
|
@@ -140,6 +141,7 @@ suggestion decided.
|
|
|
140
141
|
| Unpublished or deleted | deleted in Google | site → Google |
|
|
141
142
|
| Deleted in Google | **cancelled** on the site (struck through, room freed, restorable) | Google → site |
|
|
142
143
|
| Hidden link | `extendedProperties.private` `{ acmsEventId, acmsSite }` | site → Google |
|
|
144
|
+
| Confirmed room booking | a copy titled with its booking link's name ([Site bookings](#site-bookings)) | site → Google only |
|
|
143
145
|
|
|
144
146
|
Never sent:
|
|
145
147
|
|
|
@@ -220,6 +222,66 @@ Changes are compared field by field against what the pair last agreed:
|
|
|
220
222
|
applies, because it already happened in Google. The clash shows in Recent
|
|
221
223
|
changes and the event editor.
|
|
222
224
|
|
|
225
|
+
## Site bookings
|
|
226
|
+
|
|
227
|
+
Confirmed bookings of the site's rooms (`/book`, the app, staff) get a copy
|
|
228
|
+
in each linked Google calendar that holds their room, so the calendar stays a
|
|
229
|
+
mirror of the room, and the room shows as busy there. A Google booking page
|
|
230
|
+
only sees that when it checks the room's calendar (see
|
|
231
|
+
[Booking pages](#booking-pages-appointment-schedules)). They only go out:
|
|
232
|
+
|
|
233
|
+
- **Which calendars:** links set to Both ways or Site → Google only, whose
|
|
234
|
+
**Rooms** include the booking's room. A link without rooms gets no bookings.
|
|
235
|
+
Each Google calendar gets one copy. Links only run while the Site URL
|
|
236
|
+
matches (see [Safety](#safety)).
|
|
237
|
+
- **Which bookings:** confirmed ones, ending on or after the link's start date.
|
|
238
|
+
Pending bookings never go out (a first booking waiting for staff goes out
|
|
239
|
+
once it's approved). Staff blocks and RSVPs never do.
|
|
240
|
+
- **What a copy has:**
|
|
241
|
+
- the booking link's name in the site's main language ("Rehearsal";
|
|
242
|
+
"Reserved" when the link was deleted);
|
|
243
|
+
- its time (a whole-day link's days as all day), marked busy;
|
|
244
|
+
- one line saying it was booked on the website and is changed there.
|
|
245
|
+
|
|
246
|
+
It never has the booker's name, email, phone, answers or notes, and no
|
|
247
|
+
guests. Every write uses `sendUpdates=none`.
|
|
248
|
+
- **Hidden link:** `extendedProperties.private` `{ acmsBookingId, acmsSite }`.
|
|
249
|
+
- **When it changes:**
|
|
250
|
+
- **Confirmed:** the copy is made.
|
|
251
|
+
- **Moved, or its booking link renamed:** the copy is changed.
|
|
252
|
+
- **The link's rooms change:** the copy moves to the Google calendar that
|
|
253
|
+
now holds the room.
|
|
254
|
+
- **Cancelled, declined or deleted:** the copy is deleted.
|
|
255
|
+
|
|
256
|
+
**Stale copies.** Linking a calendar again finds the copies left from before.
|
|
257
|
+
A copy of a booking that was cancelled, declined or moved to another room in
|
|
258
|
+
the meantime, or a second copy of one booking, is deleted from Google (listed
|
|
259
|
+
in Recent changes), so it doesn't keep the room busy. A copy of a booking the
|
|
260
|
+
site doesn't have (after restoring an older backup) is left alone.
|
|
261
|
+
|
|
262
|
+
**Edits in Google never change the booking.** Changing or deleting a copy in
|
|
263
|
+
Google is listed once in Recent changes ("A booking's copy was changed in …,
|
|
264
|
+
now …"). The copy stays as Google has it until the booking changes on the
|
|
265
|
+
site. Then the site's version goes back, and a deleted copy is made again.
|
|
266
|
+
|
|
267
|
+
**Never read back.** Every copy carries its hidden link, so:
|
|
268
|
+
|
|
269
|
+
- the incoming side, a full resync and the first match never take it as a
|
|
270
|
+
site event, and never list it under "Only in Google";
|
|
271
|
+
- it's never read as a booked appointment from a booking page, even when its
|
|
272
|
+
title is one of the link's booking page titles, or "Rehearsal";
|
|
273
|
+
- unlinking keeps the copies, and linking the calendar again pairs them
|
|
274
|
+
again by their hidden links (Start syncing), with no second copy.
|
|
275
|
+
|
|
276
|
+
A link turned to Google → site only keeps the copies it has, and they're no
|
|
277
|
+
longer updated. Copies made by another site (staging) are left alone.
|
|
278
|
+
|
|
279
|
+
Their pairs are `gcal_booking_items` (migration 73). Their outgoing changes
|
|
280
|
+
share `gcal_queue`, as `booking:<id>` rows filled by triggers on `bookings`
|
|
281
|
+
(a confirmed insert, and any change of status, time, room or booking link,
|
|
282
|
+
or a delete) and on a booking link's name or mode (its upcoming confirmed
|
|
283
|
+
bookings).
|
|
284
|
+
|
|
223
285
|
## Booking pages (appointment schedules)
|
|
224
286
|
|
|
225
287
|
Google's API has no marker for appointments booked through a booking page.
|
|
@@ -244,6 +306,19 @@ unpairs it.
|
|
|
244
306
|
Check this against a real booking before relying on it: book one and look
|
|
245
307
|
at it in Recent changes and on the site.
|
|
246
308
|
|
|
309
|
+
**Replacing them with the site's booking links:**
|
|
310
|
+
1. Add a booking link to the room (for example "Rehearsal", with **Name in
|
|
311
|
+
Español**). Its bookings go to Google as [Site bookings](#site-bookings).
|
|
312
|
+
2. Until the old booking pages are retired, tick the room's Google calendar
|
|
313
|
+
under each booking page's **Booked appointment settings → Check calendar
|
|
314
|
+
availability**, so site bookings block those slots. By default a booking
|
|
315
|
+
page checks only the account's primary calendar.
|
|
316
|
+
3. Keep the old booking pages' titles under **Booking pages** until their
|
|
317
|
+
last appointments have passed, so those still come in as "Booked".
|
|
318
|
+
|
|
319
|
+
Copies of site bookings are never read as booked appointments, even with the
|
|
320
|
+
same title.
|
|
321
|
+
|
|
247
322
|
## Safety
|
|
248
323
|
|
|
249
324
|
- **Nothing before review.** A new link only reads, until Start syncing.
|
|
@@ -261,8 +336,8 @@ at it in Recent changes and on the site.
|
|
|
261
336
|
backup never deletes anything in Google.
|
|
262
337
|
- **Environment sync and backups** never copy the Google account, links or
|
|
263
338
|
pairs. Nothing is stored on the event row.
|
|
264
|
-
- **Unlink** and **Disconnect** keep both copies of every event
|
|
265
|
-
pairs them by their hidden links.
|
|
339
|
+
- **Unlink** and **Disconnect** keep both copies of every event, and the
|
|
340
|
+
copies of bookings. Linking again pairs them by their hidden links.
|
|
266
341
|
- **Loops**: each pair stores a hash of every synced field, per side, as last
|
|
267
342
|
agreed. A change that already matches is never sent back.
|
|
268
343
|
- **Failures** back off like YouTube's, and honour Google's rate limits:
|
|
@@ -289,12 +364,14 @@ Inside the framework, the sync lives in `lib/calendar/google/`:
|
|
|
289
364
|
|
|
290
365
|
- `map.ts`: the field mapping, as pure functions;
|
|
291
366
|
- `outbound.ts`, `inbound.ts`, `match.ts`;
|
|
367
|
+
- `bookings.ts`: copies of the site's room bookings (out only);
|
|
292
368
|
- `client.ts`: Google's API over `fetch`;
|
|
293
369
|
- `service.ts`: the scheduler job and Sync now.
|
|
294
370
|
|
|
295
371
|
Its tables (migration 69): `gcal_account`, `gcal_oauth_states`, `gcal_links`,
|
|
296
372
|
`gcal_items` (one row per pair), `gcal_queue` (filled by triggers on every
|
|
297
|
-
insert, update and delete of `events`) and `gcal_log`.
|
|
373
|
+
insert, update and delete of `events`) and `gcal_log`. Migration 73 adds
|
|
374
|
+
`gcal_booking_items` and the booking triggers ([Site bookings](#site-bookings)).
|
|
298
375
|
|
|
299
376
|
## Not yet
|
|
300
377
|
|
|
@@ -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, rememberBookingToken, 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,20 @@ 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
|
+
// the token stays in memory: only a random key goes in the route (on the web, the page address)
|
|
52
|
+
? <Button label={w.bookManage} variant="outline" onPress={() => router.push({ pathname: '/book/manage', params: { booking: rememberBookingToken(manageToken) } })} />
|
|
53
|
+
: <Text style={t.styles.text.small}>{w.changeByEmail}</Text>}
|
|
54
|
+
<Button label={w.requestDone} onPress={() => { booking.reset(); router.dismissAll(); }} />
|
|
45
55
|
</ScrollView>
|
|
46
56
|
);
|
|
47
57
|
}
|
|
@@ -51,27 +61,27 @@ export default function DetailsScreen() {
|
|
|
51
61
|
return (
|
|
52
62
|
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
|
53
63
|
<ScrollView keyboardShouldPersistTaps="handled" style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 16, width: '100%', maxWidth: 640, alignSelf: 'center' }}>
|
|
54
|
-
<Stack.Screen options={{ title:
|
|
64
|
+
<Stack.Screen options={{ title: w.bookDetails }} />
|
|
55
65
|
<View style={[t.styles.card, { gap: 2 }]}>
|
|
56
|
-
<Text style={t.styles.text.heading}>{l?.name ??
|
|
66
|
+
<Text style={t.styles.text.heading}>{l?.name ?? w.bookTitle}</Text>
|
|
57
67
|
<Text style={t.styles.text.muted}>{r?.name} · {when}</Text>
|
|
58
68
|
</View>
|
|
59
|
-
{field(
|
|
60
|
-
{field(
|
|
61
|
-
{l?.askPhone && field(
|
|
69
|
+
{field(w.nameField, name, setName, { auto: 'name' })}
|
|
70
|
+
{field(w.emailField, email, setEmail, { keyboard: 'email-address', auto: 'email' })}
|
|
71
|
+
{l?.askPhone && field(w.phoneField, phone, setPhone, { keyboard: 'phone-pad', auto: 'tel' })}
|
|
62
72
|
{l?.questions.map((q) => {
|
|
63
|
-
const label = q.label + (q.required ? '' :
|
|
73
|
+
const label = q.label + (q.required ? '' : ` (${w.requestOptional.toLowerCase()})`);
|
|
64
74
|
const set = (v: string) => setAnswers((a) => ({ ...a, [q.id]: v }));
|
|
65
75
|
if (q.type === 'checkbox') return (
|
|
66
76
|
<View key={q.id} style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
|
67
77
|
<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 }} />
|
|
78
|
+
<Switch value={answers[q.id] === 'yes'} onValueChange={(v) => set(v ? 'yes' : '')} trackColor={{ true: t.colors.accent }} accessibilityLabel={q.label} />
|
|
69
79
|
</View>
|
|
70
80
|
);
|
|
71
81
|
if (q.type === 'select') return (
|
|
72
82
|
<View key={q.id} style={{ gap: 6 }}>
|
|
73
83
|
<Text style={t.styles.text.label}>{label}</Text>
|
|
74
|
-
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
|
84
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }} accessibilityRole="radiogroup">
|
|
75
85
|
{q.options.map((o) => {
|
|
76
86
|
const on = answers[q.id] === o;
|
|
77
87
|
return (
|
|
@@ -90,15 +100,15 @@ export default function DetailsScreen() {
|
|
|
90
100
|
});
|
|
91
101
|
})}
|
|
92
102
|
{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>
|
|
103
|
+
<View style={{ backgroundColor: t.colors.soft, borderRadius: Math.min(t.radius, 10), padding: 12, gap: 4 }} accessibilityLiveRegion="polite">
|
|
104
|
+
<Text style={[t.styles.text.body, { fontWeight: '600' }]}>{w.bookNotSent}</Text>
|
|
105
|
+
{/* the site's refusals ("That time was just taken…") come in English */}
|
|
106
|
+
<Text style={t.styles.text.muted}>{isAcmsError(error) && error.status === 0 ? w.bookOfflineRetry : error.message}</Text>
|
|
98
107
|
</View>
|
|
99
108
|
)}
|
|
100
|
-
|
|
101
|
-
<
|
|
109
|
+
{l && !l.autoConfirm && <Text style={t.styles.text.small}>{w.bookApprovalNote}</Text>}
|
|
110
|
+
<Button label={booking.isPending ? w.bookSending : l && !l.autoConfirm ? w.bookRequest : w.bookConfirm} disabled={booking.isPending || !name.trim() || !email.trim()} onPress={submit} />
|
|
111
|
+
<Text style={t.styles.text.small}>{w.bookPrivacy}</Text>
|
|
102
112
|
</ScrollView>
|
|
103
113
|
</KeyboardAvoidingView>
|
|
104
114
|
);
|
|
@@ -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>
|