@forgebuild/sidon-booking-widget 4.1.7 → 4.2.0

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
@@ -1,629 +1,632 @@
1
- # @forgebuild/sidon-booking-widget
2
-
3
- Drop-in berth booking for marinas on OTA. The package provides two
4
- [custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components) compiled from
5
- Svelte, plus a server entry for Astro sites.
6
-
7
- | Import | What it is |
8
- |---|---|
9
- | `@forgebuild/sidon-booking-widget` | `<sidon-booking-widget>`: the booking wizard, and the booking page guests come back to |
10
- | `@forgebuild/sidon-booking-widget/berth-map` | `<sidon-berth-map>`: a Google map of the marina's berths with a quick-book card, or an operator's occupancy view in [staff mode](#staff-mode) |
11
- | `@forgebuild/sidon-booking-widget/astro-proxy` | `createMarinaProxy` (the forwarder route) and `createMarinaClient` (server-side reads) |
12
-
13
- Each element is one self-contained ES module. The Svelte runtime and every npm dependency are
14
- bundled in, so an element works on any page with one import and one tag.
15
-
16
- The elements never call OTA directly. They call `/api/marina/guest/…` on the page's own origin. A
17
- forwarder route there adds the OTA API key and passes the request on, so the key never reaches the
18
- browser.
19
-
20
- ```
21
- browser <sidon-booking-widget> / <sidon-berth-map>
22
- │ /api/marina/guest/{slug}/… (same origin)
23
- ▼
24
- your site src/pages/api/marina/guest/[...path].ts (createMarinaProxy adds X-Api-Key)
25
- ▼
26
- OTA /api/marina/guest/{slug}/…
27
-
28
- your server and build code ── createMarinaClient (X-Api-Key) ──► OTA
29
- ```
30
-
31
- ## Install
32
-
33
- ```bash
34
- npm install @forgebuild/sidon-booking-widget@^1.1.0
35
- ```
36
-
37
- The package is public on npm, so you need no registry config or token. It has no runtime
38
- dependencies.
39
-
40
- The forwarder and any server code read two variables from the server environment. If a
41
- prerendered page reads marina data, the build needs them too; see
42
- [Build-time reads](#build-time-reads).
43
-
44
- ```
45
- OTA_BASE_URL=https://ota.example.com # OTA's API origin
46
- OTA_API_KEY=… # the OTA property's API key
47
- ```
48
-
49
- ## The forwarder route
50
-
51
- Every site that shows either element needs this route, at exactly this path. The elements build
52
- their URLs as `/api/marina/guest/{slug}/…`.
53
-
54
- ```ts
55
- // src/pages/api/marina/guest/[...path].ts
56
- import { createMarinaProxy } from '@forgebuild/sidon-booking-widget/astro-proxy';
57
-
58
- // Astro finds this by reading this file's source text, so it has to be
59
- // written out here; re-exporting it from a package doesn't work.
60
- export const prerender = false;
61
-
62
- const proxy = createMarinaProxy({
63
- otaBaseUrl: import.meta.env.OTA_BASE_URL,
64
- keys: import.meta.env.OTA_API_KEY,
65
- });
66
-
67
- export const GET = proxy;
68
- export const POST = proxy;
69
- ```
70
-
71
- The route works with `output: 'server'`. It also works with `output: 'static'` plus an adapter. On
72
- a static site, the literal `prerender = false` is what makes the route run on demand.
73
-
74
- **Marinas on several OTA properties.** Each property has its own key, so pass a map from marina
75
- slug to key. The first path segment (the slug) picks the key. An unknown slug gets a 404, and each
76
- slug can appear only once.
77
-
78
- ```ts
79
- const proxy = createMarinaProxy({
80
- otaBaseUrl: import.meta.env.OTA_BASE_URL,
81
- keys: {
82
- 'fox-mooring': import.meta.env.OTA_API_KEY_SARDINIA,
83
- 'pevero-mooring': import.meta.env.OTA_API_KEY_SARDINIA,
84
- 'pirates-bight': import.meta.env.OTA_API_KEY_BVI,
85
- },
86
- });
87
- ```
88
-
89
- What the forwarder does:
90
-
91
- - **Routes.** It forwards `GET`, `HEAD` (sent on as `GET`) and `POST` requests under
92
- `/api/marina/guest/` only. Any other path gets a 404. So does a path with an empty, `.` or `..`
93
- segment, or with a `\`, `%` or control character in it.
94
- - **Request headers.** It passes on only `Content-Type`, `X-File-Name`, `X-Booking-Token` and
95
- `User-Agent`, and never cookies. It adds `X-Api-Key`. It also adds `X-Client-Ip`, taken from
96
- Astro's `clientAddress`, so OTA rate-limits each visitor rather than the whole site. If the
97
- adapter provides no client address, the header is left out.
98
- - **Errors.**
99
- - A body over 4.5 MB gets a 413.
100
- - If OTA is unreachable or answers with a redirect, the forwarder returns 502.
101
- - If OTA doesn't answer within `timeoutMs` (default `60000`), it returns 504.
102
- - **Response.** It answers with a new `Response`. That response carries only OTA's
103
- `Content-Type`, `Cache-Control`, `Retry-After` and `RateLimit*` headers, so middleware that adds
104
- headers (such as Clerk's) keeps working.
105
-
106
- The forwarder passes on every guest route, so a site's own enquiry form can post through it too,
107
- to `/api/marina/guest/{slug}/enquiries`.
108
-
109
- **Signed-in staff: `auth` instead of `keys`.** An operator dashboard forwards the signed-in staff
110
- member's own credential instead of a site key. Give `auth` instead of `keys`. Exactly one of the two
111
- is allowed: `createMarinaProxy` throws when both or neither are given.
112
-
113
- ```ts
114
- // src/pages/api/marina/guest/[...path].ts in the dashboard
115
- import type { APIContext } from 'astro';
116
- import { createMarinaProxy } from '@forgebuild/sidon-booking-widget/astro-proxy';
117
-
118
- export const prerender = false;
119
-
120
- const proxy = createMarinaProxy<APIContext>({
121
- otaBaseUrl: import.meta.env.PUBLIC_OTA_API_BASE_URL,
122
- auth: async (context) => {
123
- const { userId, getToken } = context.locals.auth();
124
- const token = userId ? await getToken() : null;
125
- return token ? { authorization: `Bearer ${token}` } : null;
126
- },
127
- });
128
-
129
- export const GET = proxy;
130
- ```
131
-
132
- - `auth(context, slug)` runs after the method and path checks, so a rejected path never reaches it.
133
- It returns the headers to add upstream. If it returns `null`, the forwarder answers
134
- `401 {"error":"Not signed in"}`.
135
- - The caller's own `Authorization` header and cookies are still never passed on. Only the headers
136
- `auth` returns are. No `X-Api-Key` is added.
137
-
138
- **OTA's limits**, per visitor IP:
139
-
140
- - 10 bookings, 30 document uploads and 10 enquiries an hour
141
- - 100 requests a minute
142
- - at most 3 upcoming bookings per email address
143
-
144
- Over a limit, OTA answers 429 and the elements show its message.
145
-
146
- ## Server-side reads: `createMarinaClient`
147
-
148
- ```ts
149
- // src/lib/marina.ts (server only)
150
- import { createMarinaClient } from '@forgebuild/sidon-booking-widget/astro-proxy';
151
-
152
- export const marinaClient = createMarinaClient({
153
- otaBaseUrl: import.meta.env.OTA_BASE_URL,
154
- keys: import.meta.env.OTA_API_KEY, // or the same slug → key map as the forwarder
155
- });
156
- ```
157
-
158
- ```astro
159
- ---
160
- import { marinaClient } from '../lib/marina';
161
-
162
- const data = await marinaClient.getMarina('fox-mooring', { from: '2026-06-01', to: '2026-06-08' });
163
- if (!data) return new Response(null, { status: 404 });
164
- const { marina, berths, price_tiers, currency } = data;
165
- ---
166
- ```
167
-
168
- - `getMarina(slug, { from, to }?)` returns OTA's payload unchanged, in snake_case: `marina`,
169
- `pontoons`, `berths`, `price_tiers`, `pois`, `images`, `currency` and
170
- `stripe_publishable_key`. It returns `null` when OTA answers 404.
171
- - Each berth's `available` flag covers `[from, to)`, or today if you pass no dates.
172
- - NUMERIC values (`length_m`, `price_per_night`, …) arrive as strings, so wrap them in
173
- `Number()`.
174
- - `getDocRequirements(slug)` returns the marina's active document requirements as OTA sends them.
175
- It returns `null` on a 404.
176
- - `slugs` lists the slugs of a `keys` map, or `[]` for a single key. Use it in
177
- `getStaticPaths`, for example.
178
- - Any other error status throws a `MarinaClientError` (also exported). Its `status` property holds
179
- the HTTP status.
180
- - Each request gives up after `timeoutMs` (default `30000`) and throws a `MarinaClientError` with
181
- status 504. If OTA can't be reached at all, the status is 502.
182
-
183
- ### Build-time reads
184
-
185
- A prerendered page reads OTA from the build machine. So `OTA_BASE_URL` and `OTA_API_KEY` must be
186
- set in the build environment as well as at runtime.
187
-
188
- Build reads carry no visitor IP, so all of a build's reads share the key's budget of 100 requests a
189
- minute. The client retries a 429 up to 3 times. Before each retry it waits `Retry-After` seconds,
190
- capped at 30, or 5 seconds when OTA sends no `Retry-After`.
191
-
192
- The client caches nothing. When many pages read the same marina, read it once per build:
193
-
194
- ```ts
195
- const marinas = new Map<string, ReturnType<typeof marinaClient.getMarina>>();
196
- export function getMarinaOnce(slug: string) {
197
- if (!marinas.has(slug)) marinas.set(slug, marinaClient.getMarina(slug));
198
- return marinas.get(slug)!;
199
- }
200
- ```
201
-
202
- ## `<sidon-booking-widget>`
203
-
204
- ```astro
205
- <sidon-booking-widget marina="fox-mooring" locale="it"></sidon-booking-widget>
206
-
207
- <script>
208
- import '@forgebuild/sidon-booking-widget';
209
- </script>
210
- ```
211
-
212
- Astro doesn't need the `@astrojs/svelte` integration for this, because a custom element is just
213
- an HTML tag. Without a bundler, load `dist/sidon-booking-widget.js` with `<script type="module">`.
214
-
215
- The wizard has four steps:
216
-
217
- 1. **Documents**: the sailor's documents, held once for all marinas. The guest signs in with Sidon
218
- (a popup on Sidon's site), and the step shows which of the marina's requirements are met and lets them upload
219
- what's missing. Uploads go straight to OTA, not through your forwarder.
220
- - The marina's requirement list alone decides the step. A requirement marked mandatory must
221
- have a document for the stay's dates before *Next* unlocks. Optional ones can be uploaded or
222
- skipped.
223
- - A marina with no requirements skips this step: the wizard opens on Details and the stepper
224
- shows 1 Details, 2 Payment, 3 Booking. Events still carry the step numbers below (Details
225
- is `2`).
226
- - It needs `sailor-api-origin`; see [Documents setup](#documents-setup).
227
- 2. **Details**: dates, boat length and berth (with an optional map), then the guest's contact and
228
- boat details. *Next* puts a 30-minute hold on the berth.
229
- 3. **Payment**: Stripe's Payment Element. The card is authorised at this step and captured when the
230
- marina confirms the booking. The authorisation is released if the booking is declined, cancelled
231
- or expires.
232
- - This step appears only when the stay has a price and the property has both Stripe keys.
233
- - Otherwise OTA emails the guest a confirm link, and the hold waits 30 minutes for it.
234
- - *Back* cancels the hold.
235
- 4. **Booking**: the [booking view](#the-booking-page), shown in place.
236
-
237
- The widget keeps the booking's id and token in `sessionStorage`, per marina. After a reload it
238
- shows the booking view again, with a *Start a new booking* action.
239
-
240
- ### Attributes
241
-
242
- | Attribute | Default | Description |
243
- |---|---|---|
244
- | `marina` | required | The marina's OTA slug. |
245
- | `api-origin` | `''` (this page's origin) | Where the forwarder route lives. Leave it unset. The forwarder sends no CORS headers, so another origin works only if you add them. |
246
- | `from` / `to` | today / +3 nights | Initial `YYYY-MM-DD` range. |
247
- | `length` | `15` | Initial boat length in metres. |
248
- | `berth` | | Pre-selected berth label. It's used only if that berth is free and the boat fits. Otherwise the widget picks the cheapest free berth that fits, and the shorter berth on a tie. |
249
- | `locale` | `en` (wizard), the booking's language (booking page) | `en`, `it`, `fr` or `es`. Only the first two letters count (`it-IT` is `it`), and any other value means `en`. It also sets the language of OTA's emails about the booking. On [the booking page](#the-booking-page), leave it unset; see "Language" there. |
250
- | `currency-symbol` | `''` | When empty, amounts are formatted in the marina's currency with `Intl.NumberFormat`. Set it, to `$` for example, to show that fixed symbol before amounts instead. |
251
- | `variant` | `navy` | `navy` or `dark`; see [Theming](#theming). |
252
- | `booking-id` | absent | When present, the element is in [booking-page mode](#the-booking-page). A value overrides the booking id in the URL. |
253
- | `sidon-origin` | `https://sidonmarine.uk` | Where the Documents step's *Sign in with Sidon* popup opens; see [Documents setup](#documents-setup). Override only to test against another Sidon. |
254
- | `sign-in-width` / `sign-in-height` | `480` / `720` | Size in px of the *Sign in with Sidon* window, centred over the page. While it's open the page shows a modal (*Show the window* / *Cancel*). Sidon styles the window with `marina-name`, `logo-mark` and `--brand-gold` / `--brand-navy` / `--brand-ink`; the logo must be on the page's own origin. |
255
- | `sailor-api-origin` | `''` | OTA's origin, where the Documents step uploads to (`/api/sailor/documents`). Uploads don't go through the forwarder. |
256
- | `maps-api-key` | | Google Maps JS API key. The map renders only when this is set. It never renders on step 4 or in booking-page mode. |
257
- | `map-style` | `chart` | `chart` (buoy circles) or `coastal` (SVG pontoon overlays). |
258
- | `map-height` / `map-width` / `map-gap` | `280px` / `100%` / `0px` | The map's size, and the gap below it. `map-width="100vw"` breaks the map out of the card. |
259
- | `width` | `560px` | The element's max width, read once at mount. In `overlay` layout, the card's max width. |
260
- | `layout` | `stacked` | `stacked` puts the map above the card. `overlay` floats the card over the map's left side, `map-gap` down from its top and `--sbw-card-inset` (CSS custom property, default `24px`) in from its left. Read once at mount. Below 768px it stacks. |
261
- | `logo-mark` / `marina-name` | | The marina's logo pin on the map. |
262
- | `otherMarinas` (JS property) | `[]` | Peer-marina pins; see [`otherMarinas`](#othermarinas-js-property). |
263
-
264
- The attributes set only the starting state. The widget never rewrites the host page's URL, except
265
- to remove `token` from the booking page's URL. If the page needs to follow the widget's state,
266
- listen for the events.
267
-
268
- ### Events
269
-
270
- The element dispatches these events with `bubbles: true, composed: true`, so they cross the shadow
271
- root and can be caught anywhere on the page:
272
-
273
- | Event | `detail` | When |
274
- |---|---|---|
275
- | `sidon-step-change` | `{ step: 1 \| 2 \| 3 \| 4 }` | On mount and at every step change (1 Documents, 2 Details, 3 Payment, 4 Booking). |
276
- | `sidon-loa-change` | `{ loa: number }` | On mount and whenever the boat length changes (wizard only; not in booking-page mode). |
277
- | `sidon-booked` | `{ bookingId: string, marina: string }` | Once, on reaching step 4. `bookingId` is OTA's booking UUID and `marina` is the slug. The booking is then `pending` (card authorised) or a `hold` (waiting for the emailed confirm link). |
278
- | `sidon-booking-status` | `{ bookingId: string, status: string }` | Whenever the status changes after the booking view first loads, on step 4 or in booking-page mode. The status is `hold`, `pending`, `confirmed`, `declined` or `cancelled`. An expired booking reports `cancelled`. |
279
-
280
- ### Documents setup
281
-
282
- Sign-in is Sidon's, not the site's: *Sign in with Sidon* opens a popup on Sidon's `/connect` page,
283
- and the sailor signs in (or signs up) with their Sidon account there. The popup hands the widget a
284
- sailor token, which it keeps for the browser tab. The site has no auth setup of its own.
285
-
286
- Per site:
287
-
288
- 1. Ask Sidon to add the site's origin (e.g. `https://www.example.com`) to `CONNECT_ORIGINS`. The
289
- popup refuses any other origin.
290
- 2. Upgrade the widget and the forwarder, then set `sailor-api-origin` on the element. From then
291
- on, any marina with document requirements collects them in the Documents step.
292
-
293
- `sidon-origin` points the popup at another Sidon (a local or staging one); leave it unset in
294
- production. Sidon's Clerk instance needs the `ota` JWT template (claims `email` and `name`), and
295
- OTA's `SAILOR_CLERK_SECRET_KEYS` must hold that instance's secret key.
296
-
297
- The forwarder forwards the `X-Sailor-Token` header, so update `@forgebuild/sidon-booking-widget`
298
- in the forwarder's project too.
299
-
300
- **OTA prerequisite:** OTA must save the expiry it extracts from an uploaded document. Until it does,
301
- insurance, ACORD 25 and passport uploads never count as met, and the Documents step can't be passed
302
- for them.
303
-
304
- ### Removed in 1.0
305
-
306
- - **Documents and the contract** are no longer in the wizard. They need the booking's token, so
307
- they're in the booking view now. The contract preview before booking, and filling the details
308
- form from a scanned document, are gone.
309
- - **The QR code on the confirmation step.** Once the marina has a `booking_url`, OTA's emails carry
310
- the booking-page link and its QR code.
311
- - **Per-marina email branding.** OTA's emails use the property's branding.
312
- - **The old defaults.** `api-origin` used to default to `https://sidonmarine.uk`, and
313
- `currency-symbol` to `€`.
314
-
315
- ## The booking page
316
-
317
- Each site hosts one page per marina where guests follow and manage their booking. OTA's emails
318
- link to this page, and OTA's email confirm link redirects to it.
319
-
320
- ```astro
321
- ---
322
- // src/pages/berths/booking.astro: a bare page, no analytics or tag manager
323
- ---
324
- <html lang="en">
325
- <head>
326
- <meta charset="utf-8" />
327
- <meta name="referrer" content="no-referrer" />
328
- <meta name="viewport" content="width=device-width, initial-scale=1" />
329
- <title>Your berth booking</title>
330
- </head>
331
- <body>
332
- <sidon-booking-widget marina="fox-mooring" booking-id></sidon-booking-widget>
333
- <script>
334
- import '@forgebuild/sidon-booking-widget';
335
- </script>
336
- </body>
337
- </html>
338
- ```
339
-
340
- The page can be prerendered or rendered on demand. In the browser, the widget reads
341
- `?booking=&token=` from the URL and keeps both values in `sessionStorage`. It then removes `token`
342
- from the address bar with `history.replaceState`.
343
-
344
- **Language.** Leave `locale` off this page. The booking view then uses the language the booking was
345
- made in: the wizard's `locale`, or the language staff picked. That is also the language of OTA's
346
- emails. A `locale` attribute overrides it.
347
-
348
- Requirements:
349
-
350
- 1. **The marina's `booking_url`** must be exactly `<page URL>?booking={id}&token={token}`, for
351
- example `https://www.example.com/berths/booking?booking={id}&token={token}`.
352
- - Set it with `PUT /api/marina/marinas/:id` and the body `{ "booking_url": "…" }`. OTA rejects a
353
- URL that lacks either placeholder.
354
- - Until it's set, guest emails carry no booking link, and the confirm link ends on a plain OTA
355
- page.
356
- 2. **`Referrer-Policy: no-referrer`**, because the URL carries the booking token. Set it in one of
357
- two ways:
358
- - The `<meta name="referrer">` tag shown above. Put it near the top of `<head>`, so it comes
359
- before anything loads.
360
- - The response header. On an on-demand page, call
361
- `Astro.response.headers.set('Referrer-Policy', 'no-referrer')`. On Vercel, you can add a
362
- `headers` rule in `vercel.json` instead.
363
- 3. **No third-party scripts except Stripe's, and no analytics.**
364
- - Analytics and tag managers record the full URL, token included, before the widget can remove
365
- the token. For example, GTM's `page_location` keeps the query string.
366
- - On this page the widget itself loads only Stripe.js, and opens an Ably connection for live
367
- status.
368
- - It never loads Google Maps or weather on this page, even with `maps-api-key` set.
369
-
370
- The booking view shows:
371
-
372
- - the status (hold, pending, confirmed, declined, cancelled or expired) and the booking's details.
373
- "Expired" covers both a hold that lapsed and a request the marina didn't answer in time. Its
374
- wording says only that the booking expired before it was confirmed and the berth was released.
375
- - card payment to finish the booking, while it's a `hold` with a card payment
376
- - "check your email" and the hold's expiry time, for a `hold` without a card payment
377
- - the marina's contract to read and agree to, if the marina has one
378
- - a cancel action, while the booking is a hold or pending
379
-
380
- The status updates live over Ably. When Ably isn't available, the view polls every 30 seconds
381
- instead.
382
-
383
- ## `<sidon-berth-map>`
384
-
385
- ```astro
386
- <sidon-berth-map
387
- marina="fox-mooring"
388
- maps-api-key={import.meta.env.PUBLIC_GOOGLE_MAPS_API_KEY}
389
- map-style="chart"
390
- book-path="/services/book"
391
- ></sidon-berth-map>
392
-
393
- <script>
394
- import '@forgebuild/sidon-booking-widget/berth-map';
395
- </script>
396
- ```
397
-
398
- This element shows a map of the marina's berths, as buoy circles or SVG pontoon overlays. A
399
- floating quick-book card on the map has dates, boat length, the selected berth and a submit button.
400
- The element reads the marina through the same forwarder.
401
-
402
- - In either style, guests can't pick a booked berth or one under maintenance.
403
- - If a pontoon SVG has a berth label that OTA doesn't list, that berth counts as unavailable.
404
- - The card's total, and its automatic pick (the cheapest free berth that fits), come from OTA's
405
- availability route. So the price shown is the price charged.
406
- - The map zooms to fit the marina once, on its first load. Changing dates or reloading the data
407
- keeps the visitor's zoom and position.
408
-
409
- ### Attributes
410
-
411
- | Attribute | Default | Description |
412
- |---|---|---|
413
- | `marina` | required | The marina's OTA slug. |
414
- | `maps-api-key` | required | Google Maps JS API key. |
415
- | `mode` | `guest` | `guest` or `staff`; see [Staff mode](#staff-mode). |
416
- | `api-origin` | `''` | Same as for `<sidon-booking-widget>`. |
417
- | `map-style` | `chart` | `chart` (buoy circles) or `coastal` (SVG pontoon overlays). |
418
- | `map-height` | `480px` | Map height. |
419
- | `show-weather` | absent | Boolean. Adds a weather panel (Open-Meteo). |
420
- | `locale` / `currency-symbol` | `en` / `''` | Same as for `<sidon-booking-widget>`. |
421
- | `from` / `to` / `length` / `berth` | today / +1 night / `15` / none | Initial state, same as for `<sidon-booking-widget>`. In staff mode, `from` and `to` stay live. |
422
- | `book-path` | | If set, submitting the card navigates here with `from`, `to`, `length` and `berth` query parameters. A `sidon-quickbook-submit` listener can call `preventDefault()` to stop the navigation. Without `book-path`, the element only fires the event and the host page handles routing. |
423
- | `logo-mark` / `marina-name` | | The marina's logo pin at the map centre. |
424
- | `eyebrow` / `card-title` / `intro` | | Optional heading lines above the card's fields. Each is left out when unset. The attribute is `card-title` rather than `title` because `title` would show the browser's tooltip. |
425
- | `range-label` / `range-placeholder` / `length-label` / `selected-label` / `selected-placeholder` / `cta-label` | "Arrival – Departure", "", "Boat length (m)", "Selected buoy", "Tap a buoy", "Book a mooring" | The card's copy. |
426
-
427
- ### `otherMarinas` (JS property)
428
-
429
- Peer-marina logo pins. This is structured data, so set it as a JS property rather than an HTML
430
- attribute:
431
-
432
- ```ts
433
- document.querySelector('sidon-berth-map')!.otherMarinas = [
434
- { name: 'Pevero Mooring', lat: 41.117, lng: 9.394, logoMark: 'https://…/pevero-mark.svg', href: '/m/pevero-mooring/berths' },
435
- ];
436
- ```
437
-
438
- The host page always supplies `href`. The package doesn't know any site's routes, so it never
439
- builds peer-marina links itself.
440
-
441
- ### Events
442
-
443
- These events also use `bubbles: true, composed: true`:
444
-
445
- - `sidon-berth-selected` sends `{ berthId: string | null }`: the berth label, or `null` when the
446
- berth is deselected.
447
- - `sidon-loa-change` sends `{ loa: number }`. It has the same name and shape as the booking
448
- widget's event, so one listener covers both elements.
449
- - `sidon-quickbook-submit` sends `{ from, to, length, berth }` and is dispatched with
450
- `cancelable: true`.
451
-
452
- ### Staff mode
453
-
454
- `mode="staff"` turns the map into an operator's view of who is on which berth for `[from, to)`. It
455
- is meant for a dashboard whose forwarder uses `auth` (see [the forwarder route](#the-forwarder-route)).
456
-
457
- ```ts
458
- const map = document.createElement('sidon-berth-map');
459
- map.setAttribute('marina', slug);
460
- map.setAttribute('mode', 'staff');
461
- map.setAttribute('from', today); // YYYY-MM-DD, in the marina's own time zone
462
- map.setAttribute('to', tomorrow);
463
- map.setAttribute('maps-api-key', mapsApiKey);
464
- map.addEventListener('sidon-berth-selected', (e) => openPanel(e.detail.berthId)); // null = deselected
465
- host.replaceChildren(map);
466
-
467
- // After an edit, or a live booking event:
468
- map.refresh?.();
469
- ```
470
-
471
- - It shows no quick-book card and makes no availability lookup. Berths aren't greyed by boat
472
- length.
473
- - Every berth OTA lists can be clicked, booked or not. `sidon-berth-selected` works as in guest
474
- mode.
475
- - A berth that isn't available for `[from, to)` (booked, held or under maintenance) is painted as
476
- occupied: a solid navy circle in `chart` style, the usual occupied style in `coastal`. The
477
- selected berth stays highlighted.
478
- - Changing `from` or `to` reloads the map. In guest mode, they're read only once.
479
- - `refresh()` reloads the map data. Call it as `el.refresh?.()`, so a page running an older build
480
- doesn't throw.
481
-
482
- ## TypeScript
483
-
484
- Both entries ship typings for the elements:
485
-
486
- - `document.querySelector('sidon-booking-widget')` returns a `SidonBookingWidgetElement`.
487
- - `document.querySelector('sidon-berth-map')` returns a `SidonBerthMapElement`.
488
- - Listeners on these elements get typed event details.
489
-
490
- ```ts
491
- document.querySelector('sidon-berth-map')?.addEventListener('sidon-quickbook-submit', (e) => {
492
- console.log(e.detail.from, e.detail.to, e.detail.length, e.detail.berth);
493
- });
494
- ```
495
-
496
- You can also import the element and detail types:
497
-
498
- ```ts
499
- import type { SidonBerthMapElement, SidonQuickbookSubmitDetail } from '@forgebuild/sidon-booking-widget';
500
- ```
501
-
502
- ## Theming
503
-
504
- Both elements render in a shadow root with their own CSS. They don't depend on the host page's
505
- stylesheet and don't clash with it. Brand colours and fonts reach inside as inherited CSS custom
506
- properties:
507
-
508
- ```css
509
- sidon-booking-widget,
510
- sidon-berth-map {
511
- --brand-navy: #0e3a3e;
512
- --brand-gold: #d49a3c;
513
- --brand-ink: #0e3a3e; /* dark card surface */
514
- --brand-sea: #cfe3f5; /* optional: the water on the coastal map */
515
- --brand-font-title: 'Teko', sans-serif; /* berth-map card heading */
516
- --brand-font-body: 'Hind', sans-serif; /* berth-map card and weather panel */
517
- }
518
- ```
519
-
520
- When these are unset, the elements use `#16365b` navy, `#c9a86b` gold, `#252525` ink and a system
521
- sans-serif font.
522
-
523
- The `coastal` map paints the water in `--brand-navy`, the colour of the card's header. Where the
524
- card sits over the map (`layout="overlay"`) the two run together: set `--brand-sea` to give the
525
- water its own colour. It has to resolve to a plain colour Google Maps accepts (a hex value, or a
526
- `var()` pointing at one), read once at mount like the others.
527
-
528
- On `<sidon-booking-widget>`, the `variant` attribute switches the buttons and document rows
529
- between two styles:
530
-
531
- - `navy` (the default): gold buttons that turn navy on hover, with rounded corners.
532
- - `dark`: solid `--brand-ink` buttons that turn gold on hover, with square corners.
533
-
534
- ## Upgrading to the Documents-first wizard
535
-
536
- The wizard gained a first step, so the step numbers moved: 1 Documents, 2 Details, 3 Payment,
537
- 4 Booking. **This breaks hosts listening to `sidon-step-change`** (and anything keyed on step 3
538
- being the booking), so it ships as a major version. Update those listeners, and set
539
- `sailor-api-origin` ([Documents setup](#documents-setup)). Documents are collected only in the
540
- wizard now; [the booking page](#the-booking-page) no longer has a per-booking upload.
541
-
542
- ## Migrating from 0.4.x / 0.2.x
543
-
544
- Earlier versions talked to sidon-ai:
545
-
546
- - 0.2.x and 0.4.16 were published as `@robooko/sidon-booking-widget` on GitHub Packages.
547
- - 0.4.17 was published as `@forgebuild/sidon-booking-widget`.
548
-
549
- 1.0 talks to OTA through your own forwarder. To migrate:
550
-
551
- 1. **Package.** From `@robooko`:
552
- ```bash
553
- npm uninstall @robooko/sidon-booking-widget
554
- npm install @forgebuild/sidon-booking-widget@^1.1.0
555
- ```
556
- - Change every `@robooko/sidon-booking-widget` import, including `/berth-map`, to
557
- `@forgebuild/sidon-booking-widget`.
558
- - Delete the `@robooko:registry=https://npm.pkg.github.com` and
559
- `//npm.pkg.github.com/:_authToken=…` lines from `.npmrc`. If the file is then empty, delete
560
- it.
561
-
562
- From 0.4.17, just run `npm install @forgebuild/sidon-booking-widget@^1.1.0`.
563
- 2. **Forwarder.** Add [the forwarder route](#the-forwarder-route), and set `OTA_BASE_URL` and
564
- `OTA_API_KEY`.
565
- 3. **`marina`.** Replace the sidon-ai `ownerId` (`org_…`) with the marina's OTA slug.
566
- 4. **`api-origin`.** Remove it. If it still points at sidon-ai (`https://sidonmarine.uk`), every
567
- call goes there and the elements show a load error.
568
- 5. **`currency-symbol`.** Remove it to format amounts in the marina's currency, or keep it to force
569
- a prefix. It no longer defaults to `€`.
570
- 6. **Booking page.** Documents and the contract moved out of the wizard into the booking view.
571
- Host [the booking page](#the-booking-page) and set the marina's `booking_url`. Without that page,
572
- a guest who closes the tab has nowhere to upload documents or agree to the contract.
573
- 7. **Events.**
574
- - `sidon-booked`'s `bookingId` is now OTA's UUID string.
575
- - `sidon-booking-status` reports every OTA status: `hold`, `pending`, `confirmed`, `declined` and
576
- `cancelled`. It used to report only `confirmed` and `cancelled`, and it didn't exist in 0.2.x.
577
- It now also fires on the booking page.
578
- - `sidon-loa-change` doesn't fire in booking-page mode.
579
- - The wizard steps were 1 Details, 2 Payment, 3 Booking in 1.x; see [the Documents-first upgrade](#upgrading-to-the-documents-first-wizard) for the current numbering.
580
- - Berth ids are still berth labels, in `berth`, `sidon-berth-selected` and
581
- `sidon-quickbook-submit`.
582
- 8. **Server-side reads.** Replace direct fetches of sidon-ai's `/api/marina/public/{ownerId}` with
583
- [`createMarinaClient`](#server-side-reads-createmarinaclient).
584
- - Its payload is in OTA's snake_case (`marina.lat`, `berths[].label`, `available`, `length_m`),
585
- not sidon-ai's camelCase (`profile.lat`, `berthId`, `status`, `lengthM`).
586
- - NUMERIC values come back as strings.
587
-
588
- ## Development
589
-
590
- ```bash
591
- npm install
592
- npm run dev # vite dev server for demo/, importing the same src/ entries consumers get
593
- npm run check # svelte-check and tsc; must report 0 errors
594
- npm test # node assert scripts in scripts/ (astro-proxy and the OTA adapter)
595
- npm run build # dist/
596
- ```
597
-
598
- `npm run build` runs four steps in order:
599
-
600
- 1. The `<sidon-berth-map>` build (`vite.berth-map.config.ts`), which empties `dist/`.
601
- 2. The `<sidon-booking-widget>` build (`vite.config.ts`).
602
- 3. `scripts/write-element-types.mjs`, which copies the hand-written `src/elements.d.ts` to
603
- `dist/elements.d.ts` and writes the two entry `.d.ts` files that re-export it.
604
- 4. `tsc -p tsconfig.proxy.json`, which writes `dist/astro-proxy.js` and its `.d.ts`.
605
-
606
- The demo runs against a local OTA:
607
-
608
- 1. Create the demo marina with OTA's `node scripts/seed-marina-demo.js`, which prints the API key.
609
- 2. Put `OTA_DEMO_API_KEY=<key>` in `demo/.env.local`. If OTA isn't on `http://localhost:3000`, add
610
- `OTA_DEMO_BASE_URL` too.
611
-
612
- The dev server then acts as the forwarder.
613
-
614
- ## Publishing
615
-
616
- ```bash
617
- npm login # an npm account with publish rights on @forgebuild
618
- npm version <patch|minor|major> # bumps package.json and the lockfile, commits, tags
619
- npm pack --dry-run # README.md, package.json and 7 files in dist/
620
- npm publish
621
- git push && git push --tags
622
- ```
623
-
624
- `npm publish` runs `prepublishOnly` first, which rebuilds `dist/`, so a stale build can't ship.
625
- Version 0.4.8 shipped a stale build before this script was added.
626
-
627
- 1.1.0 was bumped with `npm version minor --no-git-tag-version`, so it has no commit or tag of its
628
- own. To release it, skip `npm version`, run `npm publish`, then `git tag v1.1.0` before
629
- `git push && git push --tags`.
1
+ # @forgebuild/sidon-booking-widget
2
+
3
+ Drop-in berth booking for marinas on OTA. The package provides two
4
+ [custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components) compiled from
5
+ Svelte, plus a server entry for Astro sites.
6
+
7
+ | Import | What it is |
8
+ |---|---|
9
+ | `@forgebuild/sidon-booking-widget` | `<sidon-booking-widget>`: the booking wizard, and the booking page guests come back to |
10
+ | `@forgebuild/sidon-booking-widget/berth-map` | `<sidon-berth-map>`: a Google map of the marina's berths with a quick-book card, or an operator's occupancy view in [staff mode](#staff-mode) |
11
+ | `@forgebuild/sidon-booking-widget/astro-proxy` | `createMarinaProxy` (the forwarder route) and `createMarinaClient` (server-side reads) |
12
+
13
+ Each element is one self-contained ES module. The Svelte runtime and every npm dependency are
14
+ bundled in, so an element works on any page with one import and one tag.
15
+
16
+ The elements never call OTA directly. They call `/api/marina/guest/…` on the page's own origin. A
17
+ forwarder route there adds the OTA API key and passes the request on, so the key never reaches the
18
+ browser.
19
+
20
+ ```
21
+ browser <sidon-booking-widget> / <sidon-berth-map>
22
+ │ /api/marina/guest/{slug}/… (same origin)
23
+ ▼
24
+ your site src/pages/api/marina/guest/[...path].ts (createMarinaProxy adds X-Api-Key)
25
+ ▼
26
+ OTA /api/marina/guest/{slug}/…
27
+
28
+ your server and build code ── createMarinaClient (X-Api-Key) ──► OTA
29
+ ```
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ npm install @forgebuild/sidon-booking-widget@^1.1.0
35
+ ```
36
+
37
+ The package is public on npm, so you need no registry config or token. It has no runtime
38
+ dependencies.
39
+
40
+ The forwarder and any server code read two variables from the server environment. If a
41
+ prerendered page reads marina data, the build needs them too; see
42
+ [Build-time reads](#build-time-reads).
43
+
44
+ ```
45
+ OTA_BASE_URL=https://ota.example.com # OTA's API origin
46
+ OTA_API_KEY=… # the OTA property's API key
47
+ ```
48
+
49
+ ## The forwarder route
50
+
51
+ Every site that shows either element needs this route, at exactly this path. The elements build
52
+ their URLs as `/api/marina/guest/{slug}/…`.
53
+
54
+ ```ts
55
+ // src/pages/api/marina/guest/[...path].ts
56
+ import { createMarinaProxy } from '@forgebuild/sidon-booking-widget/astro-proxy';
57
+
58
+ // Astro finds this by reading this file's source text, so it has to be
59
+ // written out here; re-exporting it from a package doesn't work.
60
+ export const prerender = false;
61
+
62
+ const proxy = createMarinaProxy({
63
+ otaBaseUrl: import.meta.env.OTA_BASE_URL,
64
+ keys: import.meta.env.OTA_API_KEY,
65
+ });
66
+
67
+ export const GET = proxy;
68
+ export const POST = proxy;
69
+ ```
70
+
71
+ The route works with `output: 'server'`. It also works with `output: 'static'` plus an adapter. On
72
+ a static site, the literal `prerender = false` is what makes the route run on demand.
73
+
74
+ **Marinas on several OTA properties.** Each property has its own key, so pass a map from marina
75
+ slug to key. The first path segment (the slug) picks the key. An unknown slug gets a 404, and each
76
+ slug can appear only once.
77
+
78
+ ```ts
79
+ const proxy = createMarinaProxy({
80
+ otaBaseUrl: import.meta.env.OTA_BASE_URL,
81
+ keys: {
82
+ 'fox-mooring': import.meta.env.OTA_API_KEY_SARDINIA,
83
+ 'pevero-mooring': import.meta.env.OTA_API_KEY_SARDINIA,
84
+ 'pirates-bight': import.meta.env.OTA_API_KEY_BVI,
85
+ },
86
+ });
87
+ ```
88
+
89
+ What the forwarder does:
90
+
91
+ - **Routes.** It forwards `GET`, `HEAD` (sent on as `GET`) and `POST` requests under
92
+ `/api/marina/guest/` only. Any other path gets a 404. So does a path with an empty, `.` or `..`
93
+ segment, or with a `\`, `%` or control character in it.
94
+ - **Request headers.** It passes on only `Content-Type`, `X-File-Name`, `X-Booking-Token` and
95
+ `User-Agent`, and never cookies. It adds `X-Api-Key`. It also adds `X-Client-Ip`, taken from
96
+ Astro's `clientAddress`, so OTA rate-limits each visitor rather than the whole site. If the
97
+ adapter provides no client address, the header is left out.
98
+ - **Errors.**
99
+ - A body over 4.5 MB gets a 413.
100
+ - If OTA is unreachable or answers with a redirect, the forwarder returns 502.
101
+ - If OTA doesn't answer within `timeoutMs` (default `60000`), it returns 504.
102
+ - **Response.** It answers with a new `Response`. That response carries only OTA's
103
+ `Content-Type`, `Cache-Control`, `Retry-After` and `RateLimit*` headers, so middleware that adds
104
+ headers (such as Clerk's) keeps working.
105
+
106
+ The forwarder passes on every guest route, so a site's own enquiry form can post through it too,
107
+ to `/api/marina/guest/{slug}/enquiries`.
108
+
109
+ **Signed-in staff: `auth` instead of `keys`.** An operator dashboard forwards the signed-in staff
110
+ member's own credential instead of a site key. Give `auth` instead of `keys`. Exactly one of the two
111
+ is allowed: `createMarinaProxy` throws when both or neither are given.
112
+
113
+ ```ts
114
+ // src/pages/api/marina/guest/[...path].ts in the dashboard
115
+ import type { APIContext } from 'astro';
116
+ import { createMarinaProxy } from '@forgebuild/sidon-booking-widget/astro-proxy';
117
+
118
+ export const prerender = false;
119
+
120
+ const proxy = createMarinaProxy<APIContext>({
121
+ otaBaseUrl: import.meta.env.PUBLIC_OTA_API_BASE_URL,
122
+ auth: async (context) => {
123
+ const { userId, getToken } = context.locals.auth();
124
+ const token = userId ? await getToken() : null;
125
+ return token ? { authorization: `Bearer ${token}` } : null;
126
+ },
127
+ });
128
+
129
+ export const GET = proxy;
130
+ ```
131
+
132
+ - `auth(context, slug)` runs after the method and path checks, so a rejected path never reaches it.
133
+ It returns the headers to add upstream. If it returns `null`, the forwarder answers
134
+ `401 {"error":"Not signed in"}`.
135
+ - The caller's own `Authorization` header and cookies are still never passed on. Only the headers
136
+ `auth` returns are. No `X-Api-Key` is added.
137
+
138
+ **OTA's limits**, per visitor IP:
139
+
140
+ - 10 bookings, 30 document uploads and 10 enquiries an hour
141
+ - 100 requests a minute
142
+ - at most 3 upcoming bookings per email address
143
+
144
+ Over a limit, OTA answers 429 and the elements show its message.
145
+
146
+ ## Server-side reads: `createMarinaClient`
147
+
148
+ ```ts
149
+ // src/lib/marina.ts (server only)
150
+ import { createMarinaClient } from '@forgebuild/sidon-booking-widget/astro-proxy';
151
+
152
+ export const marinaClient = createMarinaClient({
153
+ otaBaseUrl: import.meta.env.OTA_BASE_URL,
154
+ keys: import.meta.env.OTA_API_KEY, // or the same slug → key map as the forwarder
155
+ });
156
+ ```
157
+
158
+ ```astro
159
+ ---
160
+ import { marinaClient } from '../lib/marina';
161
+
162
+ const data = await marinaClient.getMarina('fox-mooring', { from: '2026-06-01', to: '2026-06-08' });
163
+ if (!data) return new Response(null, { status: 404 });
164
+ const { marina, berths, price_tiers, currency } = data;
165
+ ---
166
+ ```
167
+
168
+ - `getMarina(slug, { from, to }?)` returns OTA's payload unchanged, in snake_case: `marina`,
169
+ `pontoons`, `berths`, `price_tiers`, `pois`, `images`, `currency` and
170
+ `stripe_publishable_key`. It returns `null` when OTA answers 404.
171
+ - Each berth's `available` flag covers `[from, to)`, or today if you pass no dates.
172
+ - NUMERIC values (`length_m`, `price_per_night`, …) arrive as strings, so wrap them in
173
+ `Number()`.
174
+ - `getDocRequirements(slug)` returns the marina's active document requirements as OTA sends them.
175
+ It returns `null` on a 404.
176
+ - `slugs` lists the slugs of a `keys` map, or `[]` for a single key. Use it in
177
+ `getStaticPaths`, for example.
178
+ - Any other error status throws a `MarinaClientError` (also exported). Its `status` property holds
179
+ the HTTP status.
180
+ - Each request gives up after `timeoutMs` (default `30000`) and throws a `MarinaClientError` with
181
+ status 504. If OTA can't be reached at all, the status is 502.
182
+
183
+ ### Build-time reads
184
+
185
+ A prerendered page reads OTA from the build machine. So `OTA_BASE_URL` and `OTA_API_KEY` must be
186
+ set in the build environment as well as at runtime.
187
+
188
+ Build reads carry no visitor IP, so all of a build's reads share the key's budget of 100 requests a
189
+ minute. The client retries a 429 up to 3 times. Before each retry it waits `Retry-After` seconds,
190
+ capped at 30, or 5 seconds when OTA sends no `Retry-After`.
191
+
192
+ The client caches nothing. When many pages read the same marina, read it once per build:
193
+
194
+ ```ts
195
+ const marinas = new Map<string, ReturnType<typeof marinaClient.getMarina>>();
196
+ export function getMarinaOnce(slug: string) {
197
+ if (!marinas.has(slug)) marinas.set(slug, marinaClient.getMarina(slug));
198
+ return marinas.get(slug)!;
199
+ }
200
+ ```
201
+
202
+ ## `<sidon-booking-widget>`
203
+
204
+ ```astro
205
+ <sidon-booking-widget marina="fox-mooring" locale="it"></sidon-booking-widget>
206
+
207
+ <script>
208
+ import '@forgebuild/sidon-booking-widget';
209
+ </script>
210
+ ```
211
+
212
+ Astro doesn't need the `@astrojs/svelte` integration for this, because a custom element is just
213
+ an HTML tag. Without a bundler, load `dist/sidon-booking-widget.js` with `<script type="module">`.
214
+
215
+ The wizard has four steps:
216
+
217
+ 1. **Documents**: the sailor's documents, held once for all marinas. The guest signs in with Sidon
218
+ (a popup on Sidon's site), and the step shows which of the marina's requirements are met and lets them upload
219
+ what's missing. Uploads go straight to OTA, not through your forwarder.
220
+ - Marinas that keep documents in their own Google Drive (OTA's `sailor_documents` off) show the
221
+ same step without the Sidon sign-in: the guest picks each file there, and the widget sends them
222
+ to the marina's Drive right after Details puts the hold on the berth.
223
+ - The marina's requirement list alone decides the step. A requirement marked mandatory must
224
+ have a document for the stay's dates before *Next* unlocks. Optional ones can be uploaded or
225
+ skipped.
226
+ - A marina with no requirements skips this step: the wizard opens on Details and the stepper
227
+ shows 1 Details, 2 Payment, 3 Booking. Events still carry the step numbers below (Details
228
+ is `2`).
229
+ - It needs `sailor-api-origin`; see [Documents setup](#documents-setup).
230
+ 2. **Details**: dates, boat length and berth (with an optional map), then the guest's contact and
231
+ boat details. *Next* puts a 30-minute hold on the berth.
232
+ 3. **Payment**: Stripe's Payment Element. The card is authorised at this step and captured when the
233
+ marina confirms the booking. The authorisation is released if the booking is declined, cancelled
234
+ or expires.
235
+ - This step appears only when the stay has a price and the property has both Stripe keys.
236
+ - Otherwise OTA emails the guest a confirm link, and the hold waits 30 minutes for it.
237
+ - *Back* cancels the hold.
238
+ 4. **Booking**: the [booking view](#the-booking-page), shown in place.
239
+
240
+ The widget keeps the booking's id and token in `sessionStorage`, per marina. After a reload it
241
+ shows the booking view again, with a *Start a new booking* action.
242
+
243
+ ### Attributes
244
+
245
+ | Attribute | Default | Description |
246
+ |---|---|---|
247
+ | `marina` | required | The marina's OTA slug. |
248
+ | `api-origin` | `''` (this page's origin) | Where the forwarder route lives. Leave it unset. The forwarder sends no CORS headers, so another origin works only if you add them. |
249
+ | `from` / `to` | today / +3 nights | Initial `YYYY-MM-DD` range. |
250
+ | `length` | `15` | Initial boat length in metres. |
251
+ | `berth` | | Pre-selected berth label. It's used only if that berth is free and the boat fits. Otherwise the widget picks the cheapest free berth that fits, and the shorter berth on a tie. |
252
+ | `locale` | `en` (wizard), the booking's language (booking page) | `en`, `it`, `fr` or `es`. Only the first two letters count (`it-IT` is `it`), and any other value means `en`. It also sets the language of OTA's emails about the booking. On [the booking page](#the-booking-page), leave it unset; see "Language" there. |
253
+ | `currency-symbol` | `''` | When empty, amounts are formatted in the marina's currency with `Intl.NumberFormat`. Set it, to `$` for example, to show that fixed symbol before amounts instead. |
254
+ | `variant` | `navy` | `navy` or `dark`; see [Theming](#theming). |
255
+ | `booking-id` | absent | When present, the element is in [booking-page mode](#the-booking-page). A value overrides the booking id in the URL. |
256
+ | `sidon-origin` | `https://sidonmarine.uk` | Where the Documents step's *Sign in with Sidon* popup opens; see [Documents setup](#documents-setup). Override only to test against another Sidon. |
257
+ | `sign-in-width` / `sign-in-height` | `480` / `720` | Size in px of the *Sign in with Sidon* window, centred over the page. While it's open the page shows a modal (*Show the window* / *Cancel*). Sidon styles the window with `marina-name`, `logo-mark` and `--brand-gold` / `--brand-navy` / `--brand-ink`; the logo must be on the page's own origin. |
258
+ | `sailor-api-origin` | `''` | OTA's origin, where the Documents step uploads to (`/api/sailor/documents`). Uploads don't go through the forwarder. |
259
+ | `maps-api-key` | | Google Maps JS API key. The map renders only when this is set. It never renders on step 4 or in booking-page mode. |
260
+ | `map-style` | `chart` | `chart` (buoy circles) or `coastal` (SVG pontoon overlays). |
261
+ | `map-height` / `map-width` / `map-gap` | `280px` / `100%` / `0px` | The map's size, and the gap below it. `map-width="100vw"` breaks the map out of the card. |
262
+ | `width` | `560px` | The element's max width, read once at mount. In `overlay` layout, the card's max width. |
263
+ | `layout` | `stacked` | `stacked` puts the map above the card. `overlay` floats the card over the map's left side, `map-gap` down from its top and `--sbw-card-inset` (CSS custom property, default `24px`) in from its left. Read once at mount. Below 768px it stacks. |
264
+ | `logo-mark` / `marina-name` | | The marina's logo pin on the map. |
265
+ | `otherMarinas` (JS property) | `[]` | Peer-marina pins; see [`otherMarinas`](#othermarinas-js-property). |
266
+
267
+ The attributes set only the starting state. The widget never rewrites the host page's URL, except
268
+ to remove `token` from the booking page's URL. If the page needs to follow the widget's state,
269
+ listen for the events.
270
+
271
+ ### Events
272
+
273
+ The element dispatches these events with `bubbles: true, composed: true`, so they cross the shadow
274
+ root and can be caught anywhere on the page:
275
+
276
+ | Event | `detail` | When |
277
+ |---|---|---|
278
+ | `sidon-step-change` | `{ step: 1 \| 2 \| 3 \| 4 }` | On mount and at every step change (1 Documents, 2 Details, 3 Payment, 4 Booking). |
279
+ | `sidon-loa-change` | `{ loa: number }` | On mount and whenever the boat length changes (wizard only; not in booking-page mode). |
280
+ | `sidon-booked` | `{ bookingId: string, marina: string }` | Once, on reaching step 4. `bookingId` is OTA's booking UUID and `marina` is the slug. The booking is then `pending` (card authorised) or a `hold` (waiting for the emailed confirm link). |
281
+ | `sidon-booking-status` | `{ bookingId: string, status: string }` | Whenever the status changes after the booking view first loads, on step 4 or in booking-page mode. The status is `hold`, `pending`, `confirmed`, `declined` or `cancelled`. An expired booking reports `cancelled`. |
282
+
283
+ ### Documents setup
284
+
285
+ Sign-in is Sidon's, not the site's: *Sign in with Sidon* opens a popup on Sidon's `/connect` page,
286
+ and the sailor signs in (or signs up) with their Sidon account there. The popup hands the widget a
287
+ sailor token, which it keeps for the browser tab. The site has no auth setup of its own.
288
+
289
+ Per site:
290
+
291
+ 1. Ask Sidon to add the site's origin (e.g. `https://www.example.com`) to `CONNECT_ORIGINS`. The
292
+ popup refuses any other origin.
293
+ 2. Upgrade the widget and the forwarder, then set `sailor-api-origin` on the element. From then
294
+ on, any marina with document requirements collects them in the Documents step.
295
+
296
+ `sidon-origin` points the popup at another Sidon (a local or staging one); leave it unset in
297
+ production. Sidon's Clerk instance needs the `ota` JWT template (claims `email` and `name`), and
298
+ OTA's `SAILOR_CLERK_SECRET_KEYS` must hold that instance's secret key.
299
+
300
+ The forwarder forwards the `X-Sailor-Token` header, so update `@forgebuild/sidon-booking-widget`
301
+ in the forwarder's project too.
302
+
303
+ **OTA prerequisite:** OTA must save the expiry it extracts from an uploaded document. Until it does,
304
+ insurance, ACORD 25 and passport uploads never count as met, and the Documents step can't be passed
305
+ for them.
306
+
307
+ ### Removed in 1.0
308
+
309
+ - **Documents and the contract** are no longer in the wizard. They need the booking's token, so
310
+ they're in the booking view now. The contract preview before booking, and filling the details
311
+ form from a scanned document, are gone.
312
+ - **The QR code on the confirmation step.** Once the marina has a `booking_url`, OTA's emails carry
313
+ the booking-page link and its QR code.
314
+ - **Per-marina email branding.** OTA's emails use the property's branding.
315
+ - **The old defaults.** `api-origin` used to default to `https://sidonmarine.uk`, and
316
+ `currency-symbol` to `€`.
317
+
318
+ ## The booking page
319
+
320
+ Each site hosts one page per marina where guests follow and manage their booking. OTA's emails
321
+ link to this page, and OTA's email confirm link redirects to it.
322
+
323
+ ```astro
324
+ ---
325
+ // src/pages/berths/booking.astro: a bare page, no analytics or tag manager
326
+ ---
327
+ <html lang="en">
328
+ <head>
329
+ <meta charset="utf-8" />
330
+ <meta name="referrer" content="no-referrer" />
331
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
332
+ <title>Your berth booking</title>
333
+ </head>
334
+ <body>
335
+ <sidon-booking-widget marina="fox-mooring" booking-id></sidon-booking-widget>
336
+ <script>
337
+ import '@forgebuild/sidon-booking-widget';
338
+ </script>
339
+ </body>
340
+ </html>
341
+ ```
342
+
343
+ The page can be prerendered or rendered on demand. In the browser, the widget reads
344
+ `?booking=&token=` from the URL and keeps both values in `sessionStorage`. It then removes `token`
345
+ from the address bar with `history.replaceState`.
346
+
347
+ **Language.** Leave `locale` off this page. The booking view then uses the language the booking was
348
+ made in: the wizard's `locale`, or the language staff picked. That is also the language of OTA's
349
+ emails. A `locale` attribute overrides it.
350
+
351
+ Requirements:
352
+
353
+ 1. **The marina's `booking_url`** must be exactly `<page URL>?booking={id}&token={token}`, for
354
+ example `https://www.example.com/berths/booking?booking={id}&token={token}`.
355
+ - Set it with `PUT /api/marina/marinas/:id` and the body `{ "booking_url": "…" }`. OTA rejects a
356
+ URL that lacks either placeholder.
357
+ - Until it's set, guest emails carry no booking link, and the confirm link ends on a plain OTA
358
+ page.
359
+ 2. **`Referrer-Policy: no-referrer`**, because the URL carries the booking token. Set it in one of
360
+ two ways:
361
+ - The `<meta name="referrer">` tag shown above. Put it near the top of `<head>`, so it comes
362
+ before anything loads.
363
+ - The response header. On an on-demand page, call
364
+ `Astro.response.headers.set('Referrer-Policy', 'no-referrer')`. On Vercel, you can add a
365
+ `headers` rule in `vercel.json` instead.
366
+ 3. **No third-party scripts except Stripe's, and no analytics.**
367
+ - Analytics and tag managers record the full URL, token included, before the widget can remove
368
+ the token. For example, GTM's `page_location` keeps the query string.
369
+ - On this page the widget itself loads only Stripe.js, and opens an Ably connection for live
370
+ status.
371
+ - It never loads Google Maps or weather on this page, even with `maps-api-key` set.
372
+
373
+ The booking view shows:
374
+
375
+ - the status (hold, pending, confirmed, declined, cancelled or expired) and the booking's details.
376
+ "Expired" covers both a hold that lapsed and a request the marina didn't answer in time. Its
377
+ wording says only that the booking expired before it was confirmed and the berth was released.
378
+ - card payment to finish the booking, while it's a `hold` with a card payment
379
+ - "check your email" and the hold's expiry time, for a `hold` without a card payment
380
+ - the marina's contract to read and agree to, if the marina has one
381
+ - a cancel action, while the booking is a hold or pending
382
+
383
+ The status updates live over Ably. When Ably isn't available, the view polls every 30 seconds
384
+ instead.
385
+
386
+ ## `<sidon-berth-map>`
387
+
388
+ ```astro
389
+ <sidon-berth-map
390
+ marina="fox-mooring"
391
+ maps-api-key={import.meta.env.PUBLIC_GOOGLE_MAPS_API_KEY}
392
+ map-style="chart"
393
+ book-path="/services/book"
394
+ ></sidon-berth-map>
395
+
396
+ <script>
397
+ import '@forgebuild/sidon-booking-widget/berth-map';
398
+ </script>
399
+ ```
400
+
401
+ This element shows a map of the marina's berths, as buoy circles or SVG pontoon overlays. A
402
+ floating quick-book card on the map has dates, boat length, the selected berth and a submit button.
403
+ The element reads the marina through the same forwarder.
404
+
405
+ - In either style, guests can't pick a booked berth or one under maintenance.
406
+ - If a pontoon SVG has a berth label that OTA doesn't list, that berth counts as unavailable.
407
+ - The card's total, and its automatic pick (the cheapest free berth that fits), come from OTA's
408
+ availability route. So the price shown is the price charged.
409
+ - The map zooms to fit the marina once, on its first load. Changing dates or reloading the data
410
+ keeps the visitor's zoom and position.
411
+
412
+ ### Attributes
413
+
414
+ | Attribute | Default | Description |
415
+ |---|---|---|
416
+ | `marina` | required | The marina's OTA slug. |
417
+ | `maps-api-key` | required | Google Maps JS API key. |
418
+ | `mode` | `guest` | `guest` or `staff`; see [Staff mode](#staff-mode). |
419
+ | `api-origin` | `''` | Same as for `<sidon-booking-widget>`. |
420
+ | `map-style` | `chart` | `chart` (buoy circles) or `coastal` (SVG pontoon overlays). |
421
+ | `map-height` | `480px` | Map height. |
422
+ | `show-weather` | absent | Boolean. Adds a weather panel (Open-Meteo). |
423
+ | `locale` / `currency-symbol` | `en` / `''` | Same as for `<sidon-booking-widget>`. |
424
+ | `from` / `to` / `length` / `berth` | today / +1 night / `15` / none | Initial state, same as for `<sidon-booking-widget>`. In staff mode, `from` and `to` stay live. |
425
+ | `book-path` | | If set, submitting the card navigates here with `from`, `to`, `length` and `berth` query parameters. A `sidon-quickbook-submit` listener can call `preventDefault()` to stop the navigation. Without `book-path`, the element only fires the event and the host page handles routing. |
426
+ | `logo-mark` / `marina-name` | | The marina's logo pin at the map centre. |
427
+ | `eyebrow` / `card-title` / `intro` | | Optional heading lines above the card's fields. Each is left out when unset. The attribute is `card-title` rather than `title` because `title` would show the browser's tooltip. |
428
+ | `range-label` / `range-placeholder` / `length-label` / `selected-label` / `selected-placeholder` / `cta-label` | "Arrival – Departure", "", "Boat length (m)", "Selected buoy", "Tap a buoy", "Book a mooring" | The card's copy. |
429
+
430
+ ### `otherMarinas` (JS property)
431
+
432
+ Peer-marina logo pins. This is structured data, so set it as a JS property rather than an HTML
433
+ attribute:
434
+
435
+ ```ts
436
+ document.querySelector('sidon-berth-map')!.otherMarinas = [
437
+ { name: 'Pevero Mooring', lat: 41.117, lng: 9.394, logoMark: 'https://…/pevero-mark.svg', href: '/m/pevero-mooring/berths' },
438
+ ];
439
+ ```
440
+
441
+ The host page always supplies `href`. The package doesn't know any site's routes, so it never
442
+ builds peer-marina links itself.
443
+
444
+ ### Events
445
+
446
+ These events also use `bubbles: true, composed: true`:
447
+
448
+ - `sidon-berth-selected` sends `{ berthId: string | null }`: the berth label, or `null` when the
449
+ berth is deselected.
450
+ - `sidon-loa-change` sends `{ loa: number }`. It has the same name and shape as the booking
451
+ widget's event, so one listener covers both elements.
452
+ - `sidon-quickbook-submit` sends `{ from, to, length, berth }` and is dispatched with
453
+ `cancelable: true`.
454
+
455
+ ### Staff mode
456
+
457
+ `mode="staff"` turns the map into an operator's view of who is on which berth for `[from, to)`. It
458
+ is meant for a dashboard whose forwarder uses `auth` (see [the forwarder route](#the-forwarder-route)).
459
+
460
+ ```ts
461
+ const map = document.createElement('sidon-berth-map');
462
+ map.setAttribute('marina', slug);
463
+ map.setAttribute('mode', 'staff');
464
+ map.setAttribute('from', today); // YYYY-MM-DD, in the marina's own time zone
465
+ map.setAttribute('to', tomorrow);
466
+ map.setAttribute('maps-api-key', mapsApiKey);
467
+ map.addEventListener('sidon-berth-selected', (e) => openPanel(e.detail.berthId)); // null = deselected
468
+ host.replaceChildren(map);
469
+
470
+ // After an edit, or a live booking event:
471
+ map.refresh?.();
472
+ ```
473
+
474
+ - It shows no quick-book card and makes no availability lookup. Berths aren't greyed by boat
475
+ length.
476
+ - Every berth OTA lists can be clicked, booked or not. `sidon-berth-selected` works as in guest
477
+ mode.
478
+ - A berth that isn't available for `[from, to)` (booked, held or under maintenance) is painted as
479
+ occupied: a solid navy circle in `chart` style, the usual occupied style in `coastal`. The
480
+ selected berth stays highlighted.
481
+ - Changing `from` or `to` reloads the map. In guest mode, they're read only once.
482
+ - `refresh()` reloads the map data. Call it as `el.refresh?.()`, so a page running an older build
483
+ doesn't throw.
484
+
485
+ ## TypeScript
486
+
487
+ Both entries ship typings for the elements:
488
+
489
+ - `document.querySelector('sidon-booking-widget')` returns a `SidonBookingWidgetElement`.
490
+ - `document.querySelector('sidon-berth-map')` returns a `SidonBerthMapElement`.
491
+ - Listeners on these elements get typed event details.
492
+
493
+ ```ts
494
+ document.querySelector('sidon-berth-map')?.addEventListener('sidon-quickbook-submit', (e) => {
495
+ console.log(e.detail.from, e.detail.to, e.detail.length, e.detail.berth);
496
+ });
497
+ ```
498
+
499
+ You can also import the element and detail types:
500
+
501
+ ```ts
502
+ import type { SidonBerthMapElement, SidonQuickbookSubmitDetail } from '@forgebuild/sidon-booking-widget';
503
+ ```
504
+
505
+ ## Theming
506
+
507
+ Both elements render in a shadow root with their own CSS. They don't depend on the host page's
508
+ stylesheet and don't clash with it. Brand colours and fonts reach inside as inherited CSS custom
509
+ properties:
510
+
511
+ ```css
512
+ sidon-booking-widget,
513
+ sidon-berth-map {
514
+ --brand-navy: #0e3a3e;
515
+ --brand-gold: #d49a3c;
516
+ --brand-ink: #0e3a3e; /* dark card surface */
517
+ --brand-sea: #cfe3f5; /* optional: the water on the coastal map */
518
+ --brand-font-title: 'Teko', sans-serif; /* berth-map card heading */
519
+ --brand-font-body: 'Hind', sans-serif; /* berth-map card and weather panel */
520
+ }
521
+ ```
522
+
523
+ When these are unset, the elements use `#16365b` navy, `#c9a86b` gold, `#252525` ink and a system
524
+ sans-serif font.
525
+
526
+ The `coastal` map paints the water in `--brand-navy`, the colour of the card's header. Where the
527
+ card sits over the map (`layout="overlay"`) the two run together: set `--brand-sea` to give the
528
+ water its own colour. It has to resolve to a plain colour Google Maps accepts (a hex value, or a
529
+ `var()` pointing at one), read once at mount like the others.
530
+
531
+ On `<sidon-booking-widget>`, the `variant` attribute switches the buttons and document rows
532
+ between two styles:
533
+
534
+ - `navy` (the default): gold buttons that turn navy on hover, with rounded corners.
535
+ - `dark`: solid `--brand-ink` buttons that turn gold on hover, with square corners.
536
+
537
+ ## Upgrading to the Documents-first wizard
538
+
539
+ The wizard gained a first step, so the step numbers moved: 1 Documents, 2 Details, 3 Payment,
540
+ 4 Booking. **This breaks hosts listening to `sidon-step-change`** (and anything keyed on step 3
541
+ being the booking), so it ships as a major version. Update those listeners, and set
542
+ `sailor-api-origin` ([Documents setup](#documents-setup)). Documents are collected only in the
543
+ wizard now; [the booking page](#the-booking-page) no longer has a per-booking upload.
544
+
545
+ ## Migrating from 0.4.x / 0.2.x
546
+
547
+ Earlier versions talked to sidon-ai:
548
+
549
+ - 0.2.x and 0.4.16 were published as `@robooko/sidon-booking-widget` on GitHub Packages.
550
+ - 0.4.17 was published as `@forgebuild/sidon-booking-widget`.
551
+
552
+ 1.0 talks to OTA through your own forwarder. To migrate:
553
+
554
+ 1. **Package.** From `@robooko`:
555
+ ```bash
556
+ npm uninstall @robooko/sidon-booking-widget
557
+ npm install @forgebuild/sidon-booking-widget@^1.1.0
558
+ ```
559
+ - Change every `@robooko/sidon-booking-widget` import, including `/berth-map`, to
560
+ `@forgebuild/sidon-booking-widget`.
561
+ - Delete the `@robooko:registry=https://npm.pkg.github.com` and
562
+ `//npm.pkg.github.com/:_authToken=…` lines from `.npmrc`. If the file is then empty, delete
563
+ it.
564
+
565
+ From 0.4.17, just run `npm install @forgebuild/sidon-booking-widget@^1.1.0`.
566
+ 2. **Forwarder.** Add [the forwarder route](#the-forwarder-route), and set `OTA_BASE_URL` and
567
+ `OTA_API_KEY`.
568
+ 3. **`marina`.** Replace the sidon-ai `ownerId` (`org_…`) with the marina's OTA slug.
569
+ 4. **`api-origin`.** Remove it. If it still points at sidon-ai (`https://sidonmarine.uk`), every
570
+ call goes there and the elements show a load error.
571
+ 5. **`currency-symbol`.** Remove it to format amounts in the marina's currency, or keep it to force
572
+ a prefix. It no longer defaults to `€`.
573
+ 6. **Booking page.** Documents and the contract moved out of the wizard into the booking view.
574
+ Host [the booking page](#the-booking-page) and set the marina's `booking_url`. Without that page,
575
+ a guest who closes the tab has nowhere to upload documents or agree to the contract.
576
+ 7. **Events.**
577
+ - `sidon-booked`'s `bookingId` is now OTA's UUID string.
578
+ - `sidon-booking-status` reports every OTA status: `hold`, `pending`, `confirmed`, `declined` and
579
+ `cancelled`. It used to report only `confirmed` and `cancelled`, and it didn't exist in 0.2.x.
580
+ It now also fires on the booking page.
581
+ - `sidon-loa-change` doesn't fire in booking-page mode.
582
+ - The wizard steps were 1 Details, 2 Payment, 3 Booking in 1.x; see [the Documents-first upgrade](#upgrading-to-the-documents-first-wizard) for the current numbering.
583
+ - Berth ids are still berth labels, in `berth`, `sidon-berth-selected` and
584
+ `sidon-quickbook-submit`.
585
+ 8. **Server-side reads.** Replace direct fetches of sidon-ai's `/api/marina/public/{ownerId}` with
586
+ [`createMarinaClient`](#server-side-reads-createmarinaclient).
587
+ - Its payload is in OTA's snake_case (`marina.lat`, `berths[].label`, `available`, `length_m`),
588
+ not sidon-ai's camelCase (`profile.lat`, `berthId`, `status`, `lengthM`).
589
+ - NUMERIC values come back as strings.
590
+
591
+ ## Development
592
+
593
+ ```bash
594
+ npm install
595
+ npm run dev # vite dev server for demo/, importing the same src/ entries consumers get
596
+ npm run check # svelte-check and tsc; must report 0 errors
597
+ npm test # node assert scripts in scripts/ (astro-proxy and the OTA adapter)
598
+ npm run build # dist/
599
+ ```
600
+
601
+ `npm run build` runs four steps in order:
602
+
603
+ 1. The `<sidon-berth-map>` build (`vite.berth-map.config.ts`), which empties `dist/`.
604
+ 2. The `<sidon-booking-widget>` build (`vite.config.ts`).
605
+ 3. `scripts/write-element-types.mjs`, which copies the hand-written `src/elements.d.ts` to
606
+ `dist/elements.d.ts` and writes the two entry `.d.ts` files that re-export it.
607
+ 4. `tsc -p tsconfig.proxy.json`, which writes `dist/astro-proxy.js` and its `.d.ts`.
608
+
609
+ The demo runs against a local OTA:
610
+
611
+ 1. Create the demo marina with OTA's `node scripts/seed-marina-demo.js`, which prints the API key.
612
+ 2. Put `OTA_DEMO_API_KEY=<key>` in `demo/.env.local`. If OTA isn't on `http://localhost:3000`, add
613
+ `OTA_DEMO_BASE_URL` too.
614
+
615
+ The dev server then acts as the forwarder.
616
+
617
+ ## Publishing
618
+
619
+ ```bash
620
+ npm login # an npm account with publish rights on @forgebuild
621
+ npm version <patch|minor|major> # bumps package.json and the lockfile, commits, tags
622
+ npm pack --dry-run # README.md, package.json and 7 files in dist/
623
+ npm publish
624
+ git push && git push --tags
625
+ ```
626
+
627
+ `npm publish` runs `prepublishOnly` first, which rebuilds `dist/`, so a stale build can't ship.
628
+ Version 0.4.8 shipped a stale build before this script was added.
629
+
630
+ 1.1.0 was bumped with `npm version minor --no-git-tag-version`, so it has no commit or tag of its
631
+ own. To release it, skip `npm version`, run `npm publish`, then `git tag v1.1.0` before
632
+ `git push && git push --tags`.