@acms-subir/client 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -31,11 +31,12 @@ the cache and transport configuration with the original client.
31
31
  | --- | --- |
32
32
  | `site()`, `theme()`, `home()` | Identity, tokens, resolved homepage |
33
33
  | `page(slug, opts)` | Resolved page including sections; nested slugs supported |
34
- | `content(opts)` | `kind`, `collection`, `page`, `limit`, `cursor` |
34
+ | `content(opts)` | `kind`, `collection`, `q`, `page`, `limit`, `cursor` |
35
35
  | `contentById(id, opts)` | Optional `kind` to disambiguate imported IDs |
36
- | `posts(opts)` | Content filtered to posts; `collection`, `tag`, pagination |
36
+ | `posts(opts)` | Content filtered to posts; `collection`, `tag`, `q`, pagination |
37
37
  | `post(slug, opts)` | Scans post summaries, then loads detail by ID; pass `collection` for ambiguous slugs |
38
38
  | `pages(opts)` | Page **summaries**, with pagination; use `page()` for sections |
39
+ | `search(text, opts)` | Top matches in `posts`, `pages` and `events`, plus `more` per group; `types`, `collection`, `limit` (1–20, default 5). Blank text resolves empty without a request |
39
40
  | `events(opts)` | Occurrences with `from`, `to`, `calendar`, `q`, pagination |
40
41
  | `event(slug, opts)` | First matching event from occurrences in the requested window |
41
42
  | `calendars()` | Public calendars |
@@ -47,6 +48,12 @@ the cache and transport configuration with the original client.
47
48
  | `updateDevice(token, { topics?, locale? })` | Change a device's topics or language |
48
49
  | `unregisterDevice(token)` | Opt out (idempotent) |
49
50
  | `pushTopics()` | Topics to offer as notification toggles |
51
+ | `links()` | The links page: profile, socials and visible blocks (`link`, `email`, `phone`, `contact`, `heading`, `text`, `spacer`, `live`, `embed`) |
52
+ | `form(id)`, `submitForm(id, { answers, version?, idempotencyKey? })` | A published form's questions; send answers (checkbox groups as arrays). Each call makes one key; retries reuse it |
53
+ | `manageBooking(token)`, `cancelBooking(token)` | The booker's own booking from its `manageToken`, and cancelling it; `can` says what is allowed |
54
+ | `rescheduleAvailability(token, { from?, days?, minutes? })`, `rescheduleBooking(token, { start, minutes? })` | Open times for moving it (its own time counts as free), then the move |
55
+ | `bookingIcs(token)` | The booking as an iCalendar file (string) |
56
+ | `requestGuestCode(email)`, `guestSignIn({ email, code, deviceName? })`, `guestBookings(guestToken, { when? })`, `guestSignOut(guestToken)` | Guest sign-in for "My bookings" |
50
57
 
51
58
  The server has no post-slug, event-detail or resource-detail endpoint. Those
52
59
  convenience methods may make multiple requests. `event()` only finds events
@@ -54,6 +61,17 @@ with occurrences in its window (default: next 30 UTC days); an unlisted
54
61
  calendar requires `calendar`. It does not fetch arbitrary historical events.
55
62
  If post slugs collide and no collection is supplied, the first listed match wins.
56
63
 
64
+ Search needs `site.features.search` (on unless the site sets
65
+ `acms({ search: { enabled: false } })`). It uses the website's search index and
66
+ ranking: titles count most, then excerpts, tags and body text. Every word
67
+ must match, and the last one matches as a prefix (`birth` finds "birthday"). `q` on `content()`/`posts()` returns
68
+ ranked summaries, posts before pages, and pages through at most 200 matches per
69
+ kind. `search()` is for a combined results screen: each hit carries a plain-text
70
+ `snippet` with `highlights` (`{ start, end }` ranges) to bold, and events add
71
+ their next `occurrence`. Use `more.posts` to offer "See all", then
72
+ `posts({ q, collection })`. Matching uses the main-language text, so a
73
+ translated title is shown but not searched.
74
+
57
75
  Pagination uses `page`/`nextPage`, not opaque cursors. `cursor` is a numeric
58
76
  alias for `page`; an explicit `page` wins. `tag` filters **each fetched page
59
77
  locally**, so a page can be empty with a non-null `nextPage`. Iterators continue
@@ -86,9 +104,14 @@ controller.abort();
86
104
  await pending.catch(() => {});
87
105
  ```
88
106
 
89
- GET and booking POST requests retry network failures, 5xx and 429 responses at
90
- most twice (three attempts total), with 250/500ms backoff and at least the
91
- server's `Retry-After` delay (seconds or HTTP date). Booking retries always
107
+ Requests that are safe to repeat (GET, PUT, DELETE, device registration, and
108
+ POSTs with an Idempotency-Key: bookings and form responses) retry network
109
+ failures, 5xx and 429 responses at most twice (three attempts total), with
110
+ 250/500ms backoff and at least the server's `Retry-After` delay (seconds or HTTP
111
+ date). A wait that would outlast `timeoutMs` throws the 429 straight away, with
112
+ `retryAfter`. One-shot writes (guest code requests and sign-in, cancelling and
113
+ moving a booking, guest sign-out) are never repeated automatically: on a
114
+ network error, check the state before trying again. Booking retries always
92
115
  reuse the same serialized body and UUID. By default each `book()` call creates
93
116
  a key using `crypto.randomUUID`, falling back to a Math.random UUID v4 where
94
117
  unavailable. For recovery across calls/app restarts, supply and persist your own
@@ -155,6 +178,29 @@ and checks generated request/response types byte-for-byte. HEAD and OPTIONS
155
178
  are transport operations and have no separate data-returning methods. No core
156
179
  code, Zod, or Node built-ins ship in this package's runtime.
157
180
 
181
+ ## Managing bookings and "My bookings"
182
+
183
+ ```ts
184
+ // a booking the app made (result.manageToken from book()), or one from guestBookings()
185
+ const view = await client.manageBooking(manageToken);
186
+ if (view.can.reschedule) {
187
+ const open = await client.rescheduleAvailability(manageToken, { days: 7 });
188
+ await client.rescheduleBooking(manageToken, { start: open.days[0].slots[0].start });
189
+ }
190
+ if (view.can.cancel) await client.cancelBooking(manageToken);
191
+
192
+ // guest sign-in (check site.features.guestSignIn first)
193
+ await client.requestGuestCode('ana@example.com');
194
+ const { accessToken } = await client.guestSignIn({ email: 'ana@example.com', code: '481203' });
195
+ const mine = await client.guestBookings(accessToken, { when: 'upcoming' });
196
+ ```
197
+
198
+ Booking and guest tokens travel in headers (`Booking-Token`,
199
+ `Authorization: Bearer`), never in URLs, and their responses are never cached.
200
+ Keep both in secure storage (`expo-secure-store`). `can.closed` means the
201
+ booking type's change cutoff has passed: show "Contact us" instead of the
202
+ buttons. A guest token ends after 90 days unused; 401 means sign in again.
203
+
158
204
  ## Editor apps (staff sign-in)
159
205
 
160
206
  For sites with `acms({ mobile: { editor: { redirectUris } } })`. Staff sign in
@@ -180,3 +226,14 @@ In React Native pass `crypto: { randomBytes, sha256 }` from expo-crypto to
180
226
  `createEditorAuth` (Web Crypto is the default). Refreshes are single-flight;
181
227
  a 401 is retried once after a refresh, then `onAuthError` runs. Writes carry
182
228
  the `revision` you loaded (409 when someone saved first).
229
+
230
+ Schedule and setup: `schedule({ from, to, resource? })` (up to 63 days of
231
+ bookings, blocked time, events and feeds), `createBooking()` (on someone's
232
+ behalf), `rescheduleBooking(id, …)`, `listResources()` / `getResource()` /
233
+ `createResource()` / `updateResource()` / `deleteResource()`,
234
+ `createBookingLink()` / `updateBookingLink()` / `deleteBookingLink()`,
235
+ `blockTime()` / `unblockTime()`, `listCalendars()` / `createCalendar()` /
236
+ `updateCalendar()` / `deleteCalendar()`, `listForms()` /
237
+ `listFormResponses()`, `getLinksPage()` / `saveLinksPage()`. Setup writes need
238
+ `me().permissions.schedule.manage`; form responses and HTML link blocks need
239
+ an administrator or owner (403 otherwise).
package/dist/editor.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AcmsError } from './errors.js';
2
- import type { EditorToken, EditorMe, EditorDocumentList, EditorDocument, EditorSections, EditorSectionResult, EditorRevision, EditorDocumentTranslation, EditorSectionTranslation, EditorMedia, EditorMediaList, EditorBooking, EditorBookingList, EditorCreateInput, EditorSaveInput, EditorListQuery, EditorSectionsQuery, EditorMediaQuery, EditorBookingsQuery } from './types.js';
2
+ import type { EditorToken, EditorMe, EditorDocumentList, EditorDocument, EditorSections, EditorSectionResult, EditorRevision, EditorDocumentTranslation, EditorSectionTranslation, EditorMedia, EditorMediaList, EditorBooking, EditorBookingList, EditorCreateInput, EditorSaveInput, EditorListQuery, EditorSectionsQuery, EditorMediaQuery, EditorBookingsQuery, EditorSchedule, EditorScheduleQuery, EditorStaffBookingInput, EditorRescheduleInput, EditorResource, EditorResourceList, EditorResourceInput, EditorResourcePatch, EditorBookingLink, EditorLinkInput, EditorLinkPatch, EditorBlock, EditorBlockInput, EditorCalendar, EditorCalendarList, EditorCalendarInput, EditorCalendarPatch, EditorDeleted, EditorFormList, EditorFormResponses, EditorResponsesQuery, EditorLinksPage, EditorLinksPut } from './types.js';
3
3
  /** Randomness + SHA-256. Defaults to Web Crypto; in React Native pass expo-crypto:
4
4
  * { randomBytes: (n) => Crypto.getRandomBytes(n), sha256: (d) => Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, d) } */
5
5
  export interface PkceCrypto {
@@ -201,6 +201,90 @@ export declare const editorEndpoints: {
201
201
  readonly method: "POST";
202
202
  readonly path: "/admin/bookings/{id}/cancel";
203
203
  };
204
+ readonly createBooking: {
205
+ readonly method: "POST";
206
+ readonly path: "/admin/bookings";
207
+ };
208
+ readonly rescheduleBooking: {
209
+ readonly method: "POST";
210
+ readonly path: "/admin/bookings/{id}/reschedule";
211
+ };
212
+ readonly schedule: {
213
+ readonly method: "GET";
214
+ readonly path: "/admin/schedule";
215
+ };
216
+ readonly listResources: {
217
+ readonly method: "GET";
218
+ readonly path: "/admin/resources";
219
+ };
220
+ readonly createResource: {
221
+ readonly method: "POST";
222
+ readonly path: "/admin/resources";
223
+ };
224
+ readonly getResource: {
225
+ readonly method: "GET";
226
+ readonly path: "/admin/resources/{id}";
227
+ };
228
+ readonly updateResource: {
229
+ readonly method: "PATCH";
230
+ readonly path: "/admin/resources/{id}";
231
+ };
232
+ readonly deleteResource: {
233
+ readonly method: "DELETE";
234
+ readonly path: "/admin/resources/{id}";
235
+ };
236
+ readonly createBookingLink: {
237
+ readonly method: "POST";
238
+ readonly path: "/admin/resources/{id}/links";
239
+ };
240
+ readonly updateBookingLink: {
241
+ readonly method: "PATCH";
242
+ readonly path: "/admin/resources/{id}/links/{linkId}";
243
+ };
244
+ readonly deleteBookingLink: {
245
+ readonly method: "DELETE";
246
+ readonly path: "/admin/resources/{id}/links/{linkId}";
247
+ };
248
+ readonly blockTime: {
249
+ readonly method: "POST";
250
+ readonly path: "/admin/resources/{id}/blocks";
251
+ };
252
+ readonly unblockTime: {
253
+ readonly method: "DELETE";
254
+ readonly path: "/admin/resources/{id}/blocks/{blockId}";
255
+ };
256
+ readonly listCalendars: {
257
+ readonly method: "GET";
258
+ readonly path: "/admin/calendars";
259
+ };
260
+ readonly createCalendar: {
261
+ readonly method: "POST";
262
+ readonly path: "/admin/calendars";
263
+ };
264
+ readonly updateCalendar: {
265
+ readonly method: "PATCH";
266
+ readonly path: "/admin/calendars/{id}";
267
+ };
268
+ readonly deleteCalendar: {
269
+ readonly method: "DELETE";
270
+ readonly path: "/admin/calendars/{id}";
271
+ };
272
+ readonly listForms: {
273
+ readonly method: "GET";
274
+ readonly path: "/admin/forms";
275
+ };
276
+ readonly listFormResponses: {
277
+ readonly method: "GET";
278
+ readonly path: "/admin/forms/{id}/responses";
279
+ };
280
+ readonly getLinksPage: {
281
+ readonly method: "GET";
282
+ readonly path: "/admin/links";
283
+ };
284
+ readonly saveLinksPage: {
285
+ readonly method: "PUT";
286
+ readonly path: "/admin/links";
287
+ };
204
288
  };
205
289
  export type DocKind = 'post' | 'page' | 'event';
206
290
  /** React Native's FormData file shape, or a Blob/File on the web and Node */
@@ -287,5 +371,31 @@ export declare function createEditorClient(options: EditorClientOptions): {
287
371
  cancelBooking: (id: string, input?: {
288
372
  message?: string;
289
373
  }, o?: EditorRequestOptions) => Promise<EditorBooking>;
374
+ /** book on someone's behalf; ignoreRules needs an administrator or owner (see me().permissions.schedule) */
375
+ createBooking: (input: EditorStaffBookingInput, o?: EditorRequestOptions) => Promise<EditorBooking>;
376
+ rescheduleBooking: (id: string, input: EditorRescheduleInput, o?: EditorRequestOptions) => Promise<EditorBooking>;
377
+ /** bookings, blocked time, events and subscribed calendars; from/to are site-local days, up to 63 days */
378
+ schedule: (q: EditorScheduleQuery, o?: EditorRequestOptions) => Promise<EditorSchedule>;
379
+ listResources: (o?: EditorRequestOptions) => Promise<EditorResourceList>;
380
+ getResource: (id: string, o?: EditorRequestOptions) => Promise<EditorResource>;
381
+ createResource: (input: EditorResourceInput, o?: EditorRequestOptions) => Promise<EditorResource>;
382
+ updateResource: (id: string, input: EditorResourcePatch, o?: EditorRequestOptions) => Promise<EditorResource>;
383
+ deleteResource: (id: string, o?: EditorRequestOptions) => Promise<EditorDeleted>;
384
+ createBookingLink: (resourceId: string, input: EditorLinkInput, o?: EditorRequestOptions) => Promise<EditorBookingLink>;
385
+ updateBookingLink: (resourceId: string, linkId: string, input: EditorLinkPatch, o?: EditorRequestOptions) => Promise<EditorBookingLink>;
386
+ deleteBookingLink: (resourceId: string, linkId: string, o?: EditorRequestOptions) => Promise<EditorDeleted>;
387
+ blockTime: (resourceId: string, input: EditorBlockInput, o?: EditorRequestOptions) => Promise<EditorBlock>;
388
+ unblockTime: (resourceId: string, blockId: string, o?: EditorRequestOptions) => Promise<EditorDeleted>;
389
+ listCalendars: (o?: EditorRequestOptions) => Promise<EditorCalendarList>;
390
+ createCalendar: (input: EditorCalendarInput, o?: EditorRequestOptions) => Promise<EditorCalendar>;
391
+ updateCalendar: (id: string, input: EditorCalendarPatch, o?: EditorRequestOptions) => Promise<EditorCalendar>;
392
+ deleteCalendar: (id: string, o?: EditorRequestOptions) => Promise<EditorDeleted>;
393
+ listForms: (o?: EditorRequestOptions) => Promise<EditorFormList>;
394
+ /** administrators and owners only (me().permissions.forms.readResponses) */
395
+ listFormResponses: (id: string, q?: EditorResponsesQuery, o?: EditorRequestOptions) => Promise<EditorFormResponses>;
396
+ getLinksPage: (o?: EditorRequestOptions) => Promise<EditorLinksPage>;
397
+ /** replaces the whole page: send every block in order (omit id for new ones) */
398
+ /** send the `revision` from linksPage(); 409 when the page changed since (nothing saved) */
399
+ saveLinksPage: (input: EditorLinksPut, o?: EditorRequestOptions) => Promise<EditorLinksPage>;
290
400
  };
291
401
  export type EditorClient = ReturnType<typeof createEditorClient>;
package/dist/editor.js CHANGED
@@ -220,6 +220,21 @@ export const editorEndpoints = {
220
220
  approveBooking: { method: 'POST', path: '/admin/bookings/{id}/approve' },
221
221
  declineBooking: { method: 'POST', path: '/admin/bookings/{id}/decline' },
222
222
  cancelBooking: { method: 'POST', path: '/admin/bookings/{id}/cancel' },
223
+ createBooking: { method: 'POST', path: '/admin/bookings' },
224
+ rescheduleBooking: { method: 'POST', path: '/admin/bookings/{id}/reschedule' },
225
+ schedule: { method: 'GET', path: '/admin/schedule' },
226
+ listResources: { method: 'GET', path: '/admin/resources' }, createResource: { method: 'POST', path: '/admin/resources' },
227
+ getResource: { method: 'GET', path: '/admin/resources/{id}' }, updateResource: { method: 'PATCH', path: '/admin/resources/{id}' },
228
+ deleteResource: { method: 'DELETE', path: '/admin/resources/{id}' },
229
+ createBookingLink: { method: 'POST', path: '/admin/resources/{id}/links' },
230
+ updateBookingLink: { method: 'PATCH', path: '/admin/resources/{id}/links/{linkId}' },
231
+ deleteBookingLink: { method: 'DELETE', path: '/admin/resources/{id}/links/{linkId}' },
232
+ blockTime: { method: 'POST', path: '/admin/resources/{id}/blocks' },
233
+ unblockTime: { method: 'DELETE', path: '/admin/resources/{id}/blocks/{blockId}' },
234
+ listCalendars: { method: 'GET', path: '/admin/calendars' }, createCalendar: { method: 'POST', path: '/admin/calendars' },
235
+ updateCalendar: { method: 'PATCH', path: '/admin/calendars/{id}' }, deleteCalendar: { method: 'DELETE', path: '/admin/calendars/{id}' },
236
+ listForms: { method: 'GET', path: '/admin/forms' }, listFormResponses: { method: 'GET', path: '/admin/forms/{id}/responses' },
237
+ getLinksPage: { method: 'GET', path: '/admin/links' }, saveLinksPage: { method: 'PUT', path: '/admin/links' },
223
238
  };
224
239
  export function createEditorClient(options) {
225
240
  const { api } = origin(options.baseUrl);
@@ -350,5 +365,32 @@ export function createEditorClient(options) {
350
365
  approveBooking: (id, input = {}, o) => call('approveBooking', { id }, { ...R(o), body: input }),
351
366
  declineBooking: (id, input = {}, o) => call('declineBooking', { id }, { ...R(o), body: input }),
352
367
  cancelBooking: (id, input = {}, o) => call('cancelBooking', { id }, { ...R(o), body: input }),
368
+ /** book on someone's behalf; ignoreRules needs an administrator or owner (see me().permissions.schedule) */
369
+ createBooking: (input, o) => call('createBooking', {}, { ...R(o), body: input }),
370
+ rescheduleBooking: (id, input, o) => call('rescheduleBooking', { id }, { ...R(o), body: input }),
371
+ /** bookings, blocked time, events and subscribed calendars; from/to are site-local days, up to 63 days */
372
+ schedule: (q, o) => call('schedule', {}, { ...R(o), query: q }),
373
+ listResources: (o) => call('listResources', {}, R(o)),
374
+ getResource: (id, o) => call('getResource', { id }, R(o)),
375
+ // setup writes need me().permissions.schedule.manage (403 otherwise)
376
+ createResource: (input, o) => call('createResource', {}, { ...R(o), body: input }),
377
+ updateResource: (id, input, o) => call('updateResource', { id }, { ...R(o), body: input }),
378
+ deleteResource: (id, o) => call('deleteResource', { id }, R(o)),
379
+ createBookingLink: (resourceId, input, o) => call('createBookingLink', { id: resourceId }, { ...R(o), body: input }),
380
+ updateBookingLink: (resourceId, linkId, input, o) => call('updateBookingLink', { id: resourceId, linkId }, { ...R(o), body: input }),
381
+ deleteBookingLink: (resourceId, linkId, o) => call('deleteBookingLink', { id: resourceId, linkId }, R(o)),
382
+ blockTime: (resourceId, input, o) => call('blockTime', { id: resourceId }, { ...R(o), body: input }),
383
+ unblockTime: (resourceId, blockId, o) => call('unblockTime', { id: resourceId, blockId }, R(o)),
384
+ listCalendars: (o) => call('listCalendars', {}, R(o)),
385
+ createCalendar: (input, o) => call('createCalendar', {}, { ...R(o), body: input }),
386
+ updateCalendar: (id, input, o) => call('updateCalendar', { id }, { ...R(o), body: input }),
387
+ deleteCalendar: (id, o) => call('deleteCalendar', { id }, R(o)),
388
+ listForms: (o) => call('listForms', {}, R(o)),
389
+ /** administrators and owners only (me().permissions.forms.readResponses) */
390
+ listFormResponses: (id, q = {}, o) => call('listFormResponses', { id }, { ...R(o), query: q }),
391
+ getLinksPage: (o) => call('getLinksPage', {}, R(o)),
392
+ /** replaces the whole page: send every block in order (omit id for new ones) */
393
+ /** send the `revision` from linksPage(); 409 when the page changed since (nothing saved) */
394
+ saveLinksPage: (input, o) => call('saveLinksPage', {}, { ...R(o), body: input }),
353
395
  };
354
396
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Site, Theme, Page, Content, Post, ContentList, Events, Event, Calendars, Resources, Resource, Availability, BookingInput, BookingResult, Device, DeviceInput, DeviceUpdate, DeviceRegistration, DeviceRemoved, PushTopics } from './types.js';
1
+ import type { Site, Theme, Page, Content, Post, ContentList, SearchResults, Links, Form, FormReceipt, FormResponseInput, ManagedBooking, RescheduleInput, ManageAvailabilityQuery, GuestCodeSent, GuestToken, GuestBookings, GuestBookingsQuery, GuestSignedOut, Events, Event, Calendars, Resources, Resource, Availability, BookingInput, BookingResult, Device, DeviceInput, DeviceUpdate, DeviceRegistration, DeviceRemoved, PushTopics } from './types.js';
2
2
  export { AcmsError, isAcmsError } from './errors.js';
3
3
  export type * from './types.js';
4
4
  export * from './editor.js';
@@ -14,6 +14,8 @@ export interface ListOptions extends RequestOptions {
14
14
  export interface ContentOptions extends ListOptions {
15
15
  kind?: 'post' | 'page';
16
16
  collection?: string;
17
+ /** Search text (up to 100 characters): ranked matches instead of newest first, posts before pages. Blank means no search. Requires `site.features.search`. */
18
+ q?: string;
17
19
  }
18
20
  export interface PostOptions extends Omit<ContentOptions, 'kind'> {
19
21
  tag?: string;
@@ -30,6 +32,14 @@ export interface EventOptions extends ListOptions {
30
32
  calendar?: string;
31
33
  q?: string;
32
34
  }
35
+ export interface SearchOptions extends RequestOptions {
36
+ /** Which groups to search; default all three. */
37
+ types?: Array<'post' | 'page' | 'event'>;
38
+ /** Only posts in this collection (pages and events are skipped). */
39
+ collection?: string;
40
+ /** Matches per group, 1–20; default 5. */
41
+ limit?: number;
42
+ }
33
43
  export interface AvailabilityOptions extends RequestOptions {
34
44
  resource: string;
35
45
  link: string;
@@ -94,6 +104,10 @@ export declare const endpoints: {
94
104
  readonly method: "GET";
95
105
  readonly path: "/content/{id}";
96
106
  };
107
+ readonly search: {
108
+ readonly method: "GET";
109
+ readonly path: "/search";
110
+ };
97
111
  readonly events: {
98
112
  readonly method: "GET";
99
113
  readonly path: "/events";
@@ -134,6 +148,54 @@ export declare const endpoints: {
134
148
  readonly method: "GET";
135
149
  readonly path: "/push/topics";
136
150
  };
151
+ readonly links: {
152
+ readonly method: "GET";
153
+ readonly path: "/links";
154
+ };
155
+ readonly form: {
156
+ readonly method: "GET";
157
+ readonly path: "/forms/{id}";
158
+ };
159
+ readonly submitForm: {
160
+ readonly method: "POST";
161
+ readonly path: "/forms/{id}/responses";
162
+ };
163
+ readonly manageBooking: {
164
+ readonly method: "GET";
165
+ readonly path: "/bookings/manage";
166
+ };
167
+ readonly cancelBooking: {
168
+ readonly method: "POST";
169
+ readonly path: "/bookings/manage/cancel";
170
+ };
171
+ readonly rescheduleAvailability: {
172
+ readonly method: "GET";
173
+ readonly path: "/bookings/manage/availability";
174
+ };
175
+ readonly rescheduleBooking: {
176
+ readonly method: "POST";
177
+ readonly path: "/bookings/manage/reschedule";
178
+ };
179
+ readonly bookingIcs: {
180
+ readonly method: "GET";
181
+ readonly path: "/bookings/manage/ics";
182
+ };
183
+ readonly requestGuestCode: {
184
+ readonly method: "POST";
185
+ readonly path: "/guest/code";
186
+ };
187
+ readonly guestSignIn: {
188
+ readonly method: "POST";
189
+ readonly path: "/guest/token";
190
+ };
191
+ readonly guestBookings: {
192
+ readonly method: "GET";
193
+ readonly path: "/guest/bookings";
194
+ };
195
+ readonly guestSignOut: {
196
+ readonly method: "POST";
197
+ readonly path: "/guest/revoke";
198
+ };
137
199
  };
138
200
  export declare function createClient(options: ClientOptions): AcmsClient;
139
201
  export interface AcmsClient {
@@ -150,6 +212,8 @@ export interface AcmsClient {
150
212
  collection?: string;
151
213
  }): Promise<Post>;
152
214
  pages(o?: ListOptions): Promise<ContentList>;
215
+ /** Ranked matches across posts, pages and events, the top few per group. Requires `site.features.search`. */
216
+ search(q: string, o?: SearchOptions): Promise<SearchResults>;
153
217
  events(o?: EventOptions): Promise<Events>;
154
218
  event(slug: string, o?: EventOptions): Promise<Event>;
155
219
  calendars(o?: RequestOptions): Promise<Calendars>;
@@ -166,6 +230,33 @@ export interface AcmsClient {
166
230
  unregisterDevice(token: string, o?: RequestOptions): Promise<DeviceRemoved>;
167
231
  /** Topics the app can offer as notification toggles. */
168
232
  pushTopics(o?: RequestOptions): Promise<PushTopics>;
233
+ /** The links page: profile, socials and visible blocks. Requires `site.features.links`. */
234
+ links(o?: RequestOptions): Promise<Links>;
235
+ /** A published form's questions. 410 when it stopped accepting responses. Requires `site.features.forms`. */
236
+ form(id: string, o?: RequestOptions): Promise<Form>;
237
+ /** Send answers (checkbox groups as arrays). Retries reuse the same Idempotency-Key, so they never duplicate. */
238
+ submitForm(id: string, input: FormResponseInput & {
239
+ idempotencyKey?: string;
240
+ }, o?: RequestOptions): Promise<FormReceipt>;
241
+ /** The booker's own booking, from its private token (`manageToken`). Never cached. Requires `site.features.bookingChanges`. */
242
+ manageBooking(token: string, o?: RequestOptions): Promise<ManagedBooking>;
243
+ cancelBooking(token: string, o?: RequestOptions): Promise<ManagedBooking>;
244
+ /** Open times for moving the booking (its own time counts as free). */
245
+ rescheduleAvailability(token: string, q?: ManageAvailabilityQuery, o?: RequestOptions): Promise<Availability>;
246
+ rescheduleBooking(token: string, input: RescheduleInput, o?: RequestOptions): Promise<ManagedBooking>;
247
+ /** The booking as an iCalendar file (text). */
248
+ bookingIcs(token: string, o?: RequestOptions): Promise<string>;
249
+ /** Email a 6-digit sign-in code. Always resolves the same way. Requires `site.features.guestSignIn`. */
250
+ requestGuestCode(email: string, o?: RequestOptions): Promise<GuestCodeSent>;
251
+ /** Email + code → guest token. Keep it in secure storage; it ends after 90 days unused. */
252
+ guestSignIn(input: {
253
+ email: string;
254
+ code: string;
255
+ deviceName?: string;
256
+ }, o?: RequestOptions): Promise<GuestToken>;
257
+ /** Bookings and RSVPs for the signed-in email; each has a `manageToken` for the methods above. */
258
+ guestBookings(guestToken: string, q?: GuestBookingsQuery, o?: RequestOptions): Promise<GuestBookings>;
259
+ guestSignOut(guestToken: string, o?: RequestOptions): Promise<GuestSignedOut>;
169
260
  withLocale(locale: string): AcmsClient;
170
261
  paginate: {
171
262
  content(o?: ContentOptions): AsyncGenerator<ContentList['items'][number]>;
package/dist/index.js CHANGED
@@ -6,14 +6,24 @@ export const endpoints = {
6
6
  site: { method: 'GET', path: '/site' }, theme: { method: 'GET', path: '/theme' },
7
7
  home: { method: 'GET', path: '/pages/home' }, page: { method: 'GET', path: '/pages/{slug}' },
8
8
  content: { method: 'GET', path: '/content' }, contentById: { method: 'GET', path: '/content/{id}' },
9
+ search: { method: 'GET', path: '/search' },
9
10
  events: { method: 'GET', path: '/events' }, calendars: { method: 'GET', path: '/calendars' },
10
11
  resources: { method: 'GET', path: '/resources' }, availability: { method: 'GET', path: '/availability' },
11
12
  book: { method: 'POST', path: '/bookings' }, openapi: { method: 'GET', path: '/openapi.json' },
12
13
  // push notifications (only when the site enables push; 404 otherwise)
13
14
  registerDevice: { method: 'POST', path: '/devices' }, updateDevice: { method: 'PUT', path: '/devices/{token}' },
14
15
  unregisterDevice: { method: 'DELETE', path: '/devices/{token}' }, pushTopics: { method: 'GET', path: '/push/topics' },
16
+ // links page and forms (owner switches in Admin → Mobile app)
17
+ links: { method: 'GET', path: '/links' }, form: { method: 'GET', path: '/forms/{id}' }, submitForm: { method: 'POST', path: '/forms/{id}/responses' },
18
+ // the booker's own booking: its private token goes in the Booking-Token header
19
+ manageBooking: { method: 'GET', path: '/bookings/manage' }, cancelBooking: { method: 'POST', path: '/bookings/manage/cancel' },
20
+ rescheduleAvailability: { method: 'GET', path: '/bookings/manage/availability' }, rescheduleBooking: { method: 'POST', path: '/bookings/manage/reschedule' },
21
+ bookingIcs: { method: 'GET', path: '/bookings/manage/ics' },
22
+ // guest sign-in for "My bookings" (emailed code → guest token)
23
+ requestGuestCode: { method: 'POST', path: '/guest/code' }, guestSignIn: { method: 'POST', path: '/guest/token' },
24
+ guestBookings: { method: 'GET', path: '/guest/bookings' }, guestSignOut: { method: 'POST', path: '/guest/revoke' },
15
25
  };
16
- const localized = new Set(['home', 'page', 'content', 'contentById', 'events']);
26
+ const localized = new Set(['home', 'page', 'content', 'contentById', 'search', 'events']);
17
27
  function uuid() {
18
28
  if (globalThis.crypto?.randomUUID)
19
29
  return globalThis.crypto.randomUUID();
@@ -72,7 +82,8 @@ export function createClient(options) {
72
82
  memory.delete(url); },
73
83
  } : options.cache;
74
84
  const defaultHeaders = new Headers(options.headers);
75
- async function request(name, opts = {}, query = {}, param, payload, key) {
85
+ // private: credentials in `headers`, never cached; text: a non-JSON body (calendar files)
86
+ async function request(name, opts = {}, query = {}, param, payload, key, extra = {}) {
76
87
  const endpoint = endpoints[name];
77
88
  const path = endpoint.path.replace(/\{\w+\}/, () => {
78
89
  if (!param || param.split('/').some(p => p === '.' || p === '..'))
@@ -94,7 +105,11 @@ export function createClient(options) {
94
105
  opts.signal?.addEventListener('abort', abort, { once: true });
95
106
  const timer = setTimeout(() => controller.abort(new AcmsError(0, 'timeout', 'Request timed out.', undefined, undefined, key)), timeoutMs);
96
107
  const signal = controller.signal;
97
- const cacheable = endpoint.method === 'GET' && name !== 'availability' && cache;
108
+ const cacheable = endpoint.method === 'GET' && name !== 'availability' && !extra.headers && cache;
109
+ // only replay what the server treats as repeatable: a lost reply to a code
110
+ // exchange or a move must not run it twice
111
+ const replayable = endpoint.method !== 'POST' || !!key || name === 'registerDevice';
112
+ const deadline = Date.now() + timeoutMs;
98
113
  try {
99
114
  if (signal.aborted)
100
115
  throw signal.reason;
@@ -107,6 +122,10 @@ export function createClient(options) {
107
122
  headers.set('If-None-Match', cached.etag);
108
123
  if (key)
109
124
  headers.set('Idempotency-Key', key);
125
+ for (const [k, v] of Object.entries(extra.headers ?? {}))
126
+ headers.set(k, v);
127
+ if (extra.text)
128
+ headers.set('Accept', 'text/calendar');
110
129
  if (payload !== undefined)
111
130
  headers.set('Content-Type', 'application/json');
112
131
  const body = payload !== undefined ? JSON.stringify(payload) : undefined;
@@ -118,22 +137,26 @@ export function createClient(options) {
118
137
  catch (error) {
119
138
  if (signal.aborted)
120
139
  throw signal.reason;
121
- if (attempt < 2) {
140
+ if (replayable && attempt < 2) {
122
141
  await pause(250 * 2 ** attempt, signal);
123
142
  continue;
124
143
  }
125
144
  throw new AcmsError(0, 'network_error', error instanceof Error ? error.message : 'Network request failed.', undefined, undefined, key);
126
145
  }
127
146
  const retryAfter = retrySeconds(res.headers.get('Retry-After'));
128
- if ((res.status === 429 || res.status >= 500) && attempt < 2) {
147
+ const wait = Math.max(250 * 2 ** attempt, (retryAfter ?? 0) * 1000);
148
+ // a wait that would outlast the timeout returns the 429/5xx (with retryAfter) instead
149
+ if ((res.status === 429 || res.status >= 500) && replayable && attempt < 2 && Date.now() + wait < deadline) {
129
150
  // Release the response stream before retrying.
130
151
  if (res.body)
131
152
  await abortable(res.body.cancel().catch(() => { }), signal);
132
- await pause(Math.max(250 * 2 ** attempt, (retryAfter ?? 0) * 1000), signal);
153
+ await pause(wait, signal);
133
154
  continue;
134
155
  }
135
156
  if (res.status === 304 && cached)
136
157
  return cached.body;
158
+ if (extra.text && res.ok)
159
+ return await abortable(res.text(), signal);
137
160
  let json;
138
161
  try {
139
162
  json = await abortable(res.json(), signal);
@@ -143,7 +166,7 @@ export function createClient(options) {
143
166
  throw signal.reason;
144
167
  // fetch can succeed before a network failure interrupts the body stream.
145
168
  if (error instanceof TypeError) {
146
- if (attempt < 2) {
169
+ if (replayable && attempt < 2) {
147
170
  await pause(250 * 2 ** attempt, signal);
148
171
  continue;
149
172
  }
@@ -169,6 +192,10 @@ export function createClient(options) {
169
192
  }
170
193
  }
171
194
  const paging = (o) => ({ page: o.page ?? o.cursor, limit: o.limit });
195
+ const secret = (value, what) => { if (!value || /[\r\n]/.test(value))
196
+ throw new TypeError(`A ${what} is required.`); return value; };
197
+ const bookingAuth = (token) => ({ headers: { 'Booking-Token': secret(token, 'booking token') } });
198
+ const guestAuth = (token) => ({ headers: { Authorization: `Bearer ${secret(token, 'guest token')}` } });
172
199
  async function* iterate(load, field, opts) {
173
200
  let page = opts.page ?? opts.cursor ?? 1;
174
201
  const seen = new Set();
@@ -186,7 +213,7 @@ export function createClient(options) {
186
213
  }
187
214
  throw new AcmsError(0, 'invalid_response', 'Pagination repeated a page.');
188
215
  }
189
- const content = (o = {}) => request('content', o, { ...paging(o), kind: o.kind, collection: o.collection });
216
+ const content = (o = {}) => request('content', o, { ...paging(o), kind: o.kind, collection: o.collection, q: o.q?.trim() || undefined });
190
217
  const posts = async (o = {}) => {
191
218
  const result = await content({ ...o, kind: 'post' });
192
219
  return { ...result, items: result.items.filter(p => !o.tag || p.tags.includes(o.tag)) };
@@ -207,6 +234,13 @@ export function createClient(options) {
207
234
  home: (o = {}) => request('home', o),
208
235
  page: (slug, o = {}) => request('page', o, {}, slug),
209
236
  content, posts, pages, events, resources, paginate,
237
+ async search(q, o = {}) {
238
+ const query = q.trim();
239
+ // blank input (an emptied search box) needs no round trip
240
+ if (!query)
241
+ return { locale: o.locale ?? options.locale ?? null, query: '', posts: [], pages: [], events: [], more: { posts: false, pages: false, events: false } };
242
+ return request('search', o, { q: query, types: o.types?.join(','), collection: o.collection, limit: o.limit });
243
+ },
210
244
  contentById: (id, o = {}) => request('contentById', o, { kind: o.kind }, id),
211
245
  async post(slug, o = {}) {
212
246
  for await (const item of paginate.posts(o))
@@ -239,6 +273,21 @@ export function createClient(options) {
239
273
  updateDevice: (token, input, o = {}) => request('updateDevice', o, {}, token, input),
240
274
  unregisterDevice: (token, o = {}) => request('unregisterDevice', o, {}, token),
241
275
  pushTopics: (o = {}) => request('pushTopics', o),
276
+ links: (o = {}) => request('links', o),
277
+ form: (id, o = {}) => request('form', o, {}, id),
278
+ submitForm: (id, input, o = {}) => {
279
+ const { idempotencyKey, ...body } = input;
280
+ return request('submitForm', o, {}, id, body, idempotencyKey ?? uuid());
281
+ },
282
+ manageBooking: (token, o = {}) => request('manageBooking', o, {}, undefined, undefined, undefined, bookingAuth(token)),
283
+ cancelBooking: (token, o = {}) => request('cancelBooking', o, {}, undefined, undefined, undefined, bookingAuth(token)),
284
+ rescheduleAvailability: (token, q = {}, o = {}) => request('rescheduleAvailability', o, { from: q.from, days: q.days, minutes: q.minutes }, undefined, undefined, undefined, bookingAuth(token)),
285
+ rescheduleBooking: (token, input, o = {}) => request('rescheduleBooking', o, {}, undefined, input, undefined, bookingAuth(token)),
286
+ bookingIcs: (token, o = {}) => request('bookingIcs', o, {}, undefined, undefined, undefined, { ...bookingAuth(token), text: true }),
287
+ requestGuestCode: (email, o = {}) => request('requestGuestCode', o, {}, undefined, { email }),
288
+ guestSignIn: (input, o = {}) => request('guestSignIn', o, {}, undefined, input),
289
+ guestBookings: (guestToken, q = {}, o = {}) => request('guestBookings', o, { when: q.when, page: q.page, limit: q.limit }, undefined, undefined, undefined, guestAuth(guestToken)),
290
+ guestSignOut: (guestToken, o = {}) => request('guestSignOut', o, {}, undefined, undefined, undefined, guestAuth(guestToken)),
242
291
  withLocale: (locale) => createClient({ ...options, locale, cache, headers: defaultHeaders }),
243
292
  };
244
293
  }