@forgebuild/sidon-booking-widget 3.0.0 → 3.0.1

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,625 +1,622 @@
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. *Next* unlocks once
220
- OTA says the requirements pass for the stay's dates.
221
- - It applies only where OTA has switched on the marina's `sailor_documents`. Other marinas keep
222
- the per-booking upload on [the booking page](#the-booking-page), and the wizard skips this
223
- step: it opens on Details and the stepper shows 1 Details, 2 Payment, 3 Booking. Events
224
- still carry the step numbers below (Details is `2`).
225
- - It needs `sailor-api-origin`; see [Documents setup](#documents-setup).
226
- 2. **Details**: dates, boat length and berth (with an optional map), then the guest's contact and
227
- boat details. *Next* puts a 30-minute hold on the berth.
228
- 3. **Payment**: Stripe's Payment Element. The card is authorised at this step and captured when the
229
- marina confirms the booking. The authorisation is released if the booking is declined, cancelled
230
- or expires.
231
- - This step appears only when the stay has a price and the property has both Stripe keys.
232
- - Otherwise OTA emails the guest a confirm link, and the hold waits 30 minutes for it.
233
- - *Back* cancels the hold.
234
- 4. **Booking**: the [booking view](#the-booking-page), shown in place.
235
-
236
- The widget keeps the booking's id and token in `sessionStorage`, per marina. After a reload it
237
- shows the booking view again, with a *Start a new booking* action.
238
-
239
- ### Attributes
240
-
241
- | Attribute | Default | Description |
242
- |---|---|---|
243
- | `marina` | required | The marina's OTA slug. |
244
- | `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. |
245
- | `from` / `to` | today / +3 nights | Initial `YYYY-MM-DD` range. |
246
- | `length` | `15` | Initial boat length in metres. |
247
- | `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. |
248
- | `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. |
249
- | `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. |
250
- | `variant` | `navy` | `navy` or `dark`; see [Theming](#theming). |
251
- | `booking-id` | absent | When present, the element is in [booking-page mode](#the-booking-page). A value overrides the booking id in the URL. |
252
- | `sidon-origin` | `https://marina.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. |
253
- | `sailor-api-origin` | `''` | OTA's origin, where the Documents step uploads to (`/api/sailor/documents`). Uploads don't go through the forwarder. |
254
- | `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. |
255
- | `map-style` | `chart` | `chart` (buoy circles) or `coastal` (SVG pontoon overlays). |
256
- | `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. |
257
- | `width` | `560px` | The element's max width, read once at mount. In `overlay` layout, the card's max width. |
258
- | `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. |
259
- | `logo-mark` / `marina-name` | | The marina's logo pin on the map. |
260
- | `otherMarinas` (JS property) | `[]` | Peer-marina pins; see [`otherMarinas`](#othermarinas-js-property). |
261
-
262
- The attributes set only the starting state. The widget never rewrites the host page's URL, except
263
- to remove `token` from the booking page's URL. If the page needs to follow the widget's state,
264
- listen for the events.
265
-
266
- ### Events
267
-
268
- The element dispatches these events with `bubbles: true, composed: true`, so they cross the shadow
269
- root and can be caught anywhere on the page:
270
-
271
- | Event | `detail` | When |
272
- |---|---|---|
273
- | `sidon-step-change` | `{ step: 1 \| 2 \| 3 \| 4 }` | On mount and at every step change (1 Documents, 2 Details, 3 Payment, 4 Booking). |
274
- | `sidon-loa-change` | `{ loa: number }` | On mount and whenever the boat length changes (wizard only; not in booking-page mode). |
275
- | `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). |
276
- | `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`. |
277
-
278
- ### Documents setup
279
-
280
- Sign-in is Sidon's, not the site's: *Sign in with Sidon* opens a popup on Sidon's `/connect` page,
281
- and the sailor signs in (or signs up) with their Sidon account there. The popup hands the widget a
282
- sailor token, which it keeps for the browser tab. The site has no auth setup of its own.
283
-
284
- Per site:
285
-
286
- 1. Ask Sidon to add the site's origin (e.g. `https://www.example.com`) to `CONNECT_ORIGINS`. The
287
- popup refuses any other origin.
288
- 2. Upgrade the widget and the forwarder, then set `sailor-api-origin` on the element.
289
- 3. On OTA, switch on the marina's `sailor_documents`, last, and only once every site booking that
290
- marina runs the new widget. 1.x sites get a raw "Sign in required" once it's on.
291
-
292
- `sidon-origin` points the popup at another Sidon (a local or staging one); leave it unset in
293
- production. Sidon's Clerk instance needs the `ota` JWT template (claims `email` and `name`), and
294
- OTA's `SAILOR_CLERK_SECRET_KEYS` must hold that instance's secret key.
295
-
296
- The forwarder forwards the `X-Sailor-Token` header, so update `@forgebuild/sidon-booking-widget`
297
- in the forwarder's project too.
298
-
299
- **OTA prerequisite:** OTA must save the expiry it extracts from an uploaded document. Until it does,
300
- insurance, ACORD 25 and passport uploads never count as met, and the Documents step can't be passed
301
- for them.
302
-
303
- ### Removed in 1.0
304
-
305
- - **Documents and the contract** are no longer in the wizard. They need the booking's token, so
306
- they're in the booking view now. The contract preview before booking, and filling the details
307
- form from a scanned document, are gone.
308
- - **The QR code on the confirmation step.** Once the marina has a `booking_url`, OTA's emails carry
309
- the booking-page link and its QR code.
310
- - **Per-marina email branding.** OTA's emails use the property's branding.
311
- - **The old defaults.** `api-origin` used to default to `https://sidonmarine.uk`, and
312
- `currency-symbol` to `€`.
313
-
314
- ## The booking page
315
-
316
- Each site hosts one page per marina where guests follow and manage their booking. OTA's emails
317
- link to this page, and OTA's email confirm link redirects to it.
318
-
319
- ```astro
320
- ---
321
- // src/pages/berths/booking.astro: a bare page, no analytics or tag manager
322
- ---
323
- <html lang="en">
324
- <head>
325
- <meta charset="utf-8" />
326
- <meta name="referrer" content="no-referrer" />
327
- <meta name="viewport" content="width=device-width, initial-scale=1" />
328
- <title>Your berth booking</title>
329
- </head>
330
- <body>
331
- <sidon-booking-widget marina="fox-mooring" booking-id></sidon-booking-widget>
332
- <script>
333
- import '@forgebuild/sidon-booking-widget';
334
- </script>
335
- </body>
336
- </html>
337
- ```
338
-
339
- The page can be prerendered or rendered on demand. In the browser, the widget reads
340
- `?booking=&token=` from the URL and keeps both values in `sessionStorage`. It then removes `token`
341
- from the address bar with `history.replaceState`.
342
-
343
- **Language.** Leave `locale` off this page. The booking view then uses the language the booking was
344
- made in: the wizard's `locale`, or the language staff picked. That is also the language of OTA's
345
- emails. A `locale` attribute overrides it.
346
-
347
- Requirements:
348
-
349
- 1. **The marina's `booking_url`** must be exactly `<page URL>?booking={id}&token={token}`, for
350
- example `https://www.example.com/berths/booking?booking={id}&token={token}`.
351
- - Set it with `PUT /api/marina/marinas/:id` and the body `{ "booking_url": "…" }`. OTA rejects a
352
- URL that lacks either placeholder.
353
- - Until it's set, guest emails carry no booking link, and the confirm link ends on a plain OTA
354
- page.
355
- 2. **`Referrer-Policy: no-referrer`**, because the URL carries the booking token. Set it in one of
356
- two ways:
357
- - The `<meta name="referrer">` tag shown above. Put it near the top of `<head>`, so it comes
358
- before anything loads.
359
- - The response header. On an on-demand page, call
360
- `Astro.response.headers.set('Referrer-Policy', 'no-referrer')`. On Vercel, you can add a
361
- `headers` rule in `vercel.json` instead.
362
- 3. **No third-party scripts except Stripe's, and no analytics.**
363
- - Analytics and tag managers record the full URL, token included, before the widget can remove
364
- the token. For example, GTM's `page_location` keeps the query string.
365
- - On this page the widget itself loads only Stripe.js, and opens an Ably connection for live
366
- status.
367
- - It never loads Google Maps or weather on this page, even with `maps-api-key` set.
368
-
369
- The booking view shows:
370
-
371
- - the status (hold, pending, confirmed, declined, cancelled or expired) and the booking's details.
372
- "Expired" covers both a hold that lapsed and a request the marina didn't answer in time. Its
373
- wording says only that the booking expired before it was confirmed and the berth was released.
374
- - card payment to finish the booking, while it's a `hold` with a card payment
375
- - "check your email" and the hold's expiry time, for a `hold` without a card payment
376
- - an upload for each of the marina's document requirements, while the booking is pending or
377
- confirmed (not at marinas using shared sailor documents; those settle documents before booking)
378
- - the marina's contract to read and agree to, if the marina has one
379
- - a cancel action, while the booking is a hold or pending
380
-
381
- The status updates live over Ably. When Ably isn't available, the view polls every 30 seconds
382
- instead.
383
-
384
- ## `<sidon-berth-map>`
385
-
386
- ```astro
387
- <sidon-berth-map
388
- marina="fox-mooring"
389
- maps-api-key={import.meta.env.PUBLIC_GOOGLE_MAPS_API_KEY}
390
- map-style="chart"
391
- book-path="/services/book"
392
- ></sidon-berth-map>
393
-
394
- <script>
395
- import '@forgebuild/sidon-booking-widget/berth-map';
396
- </script>
397
- ```
398
-
399
- This element shows a map of the marina's berths, as buoy circles or SVG pontoon overlays. A
400
- floating quick-book card on the map has dates, boat length, the selected berth and a submit button.
401
- The element reads the marina through the same forwarder.
402
-
403
- - In either style, guests can't pick a booked berth or one under maintenance.
404
- - If a pontoon SVG has a berth label that OTA doesn't list, that berth counts as unavailable.
405
- - The card's total, and its automatic pick (the cheapest free berth that fits), come from OTA's
406
- availability route. So the price shown is the price charged.
407
- - The map zooms to fit the marina once, on its first load. Changing dates or reloading the data
408
- keeps the visitor's zoom and position.
409
-
410
- ### Attributes
411
-
412
- | Attribute | Default | Description |
413
- |---|---|---|
414
- | `marina` | required | The marina's OTA slug. |
415
- | `maps-api-key` | required | Google Maps JS API key. |
416
- | `mode` | `guest` | `guest` or `staff`; see [Staff mode](#staff-mode). |
417
- | `api-origin` | `''` | Same as for `<sidon-booking-widget>`. |
418
- | `map-style` | `chart` | `chart` (buoy circles) or `coastal` (SVG pontoon overlays). |
419
- | `map-height` | `480px` | Map height. |
420
- | `show-weather` | absent | Boolean. Adds a weather panel (Open-Meteo). |
421
- | `locale` / `currency-symbol` | `en` / `''` | Same as for `<sidon-booking-widget>`. |
422
- | `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. |
423
- | `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. |
424
- | `logo-mark` / `marina-name` | | The marina's logo pin at the map centre. |
425
- | `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. |
426
- | `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. |
427
-
428
- ### `otherMarinas` (JS property)
429
-
430
- Peer-marina logo pins. This is structured data, so set it as a JS property rather than an HTML
431
- attribute:
432
-
433
- ```ts
434
- document.querySelector('sidon-berth-map')!.otherMarinas = [
435
- { name: 'Pevero Mooring', lat: 41.117, lng: 9.394, logoMark: 'https://…/pevero-mark.svg', href: '/m/pevero-mooring/berths' },
436
- ];
437
- ```
438
-
439
- The host page always supplies `href`. The package doesn't know any site's routes, so it never
440
- builds peer-marina links itself.
441
-
442
- ### Events
443
-
444
- These events also use `bubbles: true, composed: true`:
445
-
446
- - `sidon-berth-selected` sends `{ berthId: string | null }`: the berth label, or `null` when the
447
- berth is deselected.
448
- - `sidon-loa-change` sends `{ loa: number }`. It has the same name and shape as the booking
449
- widget's event, so one listener covers both elements.
450
- - `sidon-quickbook-submit` sends `{ from, to, length, berth }` and is dispatched with
451
- `cancelable: true`.
452
-
453
- ### Staff mode
454
-
455
- `mode="staff"` turns the map into an operator's view of who is on which berth for `[from, to)`. It
456
- is meant for a dashboard whose forwarder uses `auth` (see [the forwarder route](#the-forwarder-route)).
457
-
458
- ```ts
459
- const map = document.createElement('sidon-berth-map');
460
- map.setAttribute('marina', slug);
461
- map.setAttribute('mode', 'staff');
462
- map.setAttribute('from', today); // YYYY-MM-DD, in the marina's own time zone
463
- map.setAttribute('to', tomorrow);
464
- map.setAttribute('maps-api-key', mapsApiKey);
465
- map.addEventListener('sidon-berth-selected', (e) => openPanel(e.detail.berthId)); // null = deselected
466
- host.replaceChildren(map);
467
-
468
- // After an edit, or a live booking event:
469
- map.refresh?.();
470
- ```
471
-
472
- - It shows no quick-book card and makes no availability lookup. Berths aren't greyed by boat
473
- length.
474
- - Every berth OTA lists can be clicked, booked or not. `sidon-berth-selected` works as in guest
475
- mode.
476
- - A berth that isn't available for `[from, to)` (booked, held or under maintenance) is painted as
477
- occupied: a solid navy circle in `chart` style, the usual occupied style in `coastal`. The
478
- selected berth stays highlighted.
479
- - Changing `from` or `to` reloads the map. In guest mode, they're read only once.
480
- - `refresh()` reloads the map data. Call it as `el.refresh?.()`, so a page running an older build
481
- doesn't throw.
482
-
483
- ## TypeScript
484
-
485
- Both entries ship typings for the elements:
486
-
487
- - `document.querySelector('sidon-booking-widget')` returns a `SidonBookingWidgetElement`.
488
- - `document.querySelector('sidon-berth-map')` returns a `SidonBerthMapElement`.
489
- - Listeners on these elements get typed event details.
490
-
491
- ```ts
492
- document.querySelector('sidon-berth-map')?.addEventListener('sidon-quickbook-submit', (e) => {
493
- console.log(e.detail.from, e.detail.to, e.detail.length, e.detail.berth);
494
- });
495
- ```
496
-
497
- You can also import the element and detail types:
498
-
499
- ```ts
500
- import type { SidonBerthMapElement, SidonQuickbookSubmitDetail } from '@forgebuild/sidon-booking-widget';
501
- ```
502
-
503
- ## Theming
504
-
505
- Both elements render in a shadow root with their own CSS. They don't depend on the host page's
506
- stylesheet and don't clash with it. Brand colours and fonts reach inside as inherited CSS custom
507
- properties:
508
-
509
- ```css
510
- sidon-booking-widget,
511
- sidon-berth-map {
512
- --brand-navy: #0e3a3e;
513
- --brand-gold: #d49a3c;
514
- --brand-ink: #0e3a3e; /* dark card surface */
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
- On `<sidon-booking-widget>`, the `variant` attribute switches the buttons and document rows
524
- between two styles:
525
-
526
- - `navy` (the default): gold buttons that turn navy on hover, with rounded corners.
527
- - `dark`: solid `--brand-ink` buttons that turn gold on hover, with square corners.
528
-
529
- ## Upgrading to the Documents-first wizard
530
-
531
- The wizard gained a first step, so the step numbers moved: 1 Documents, 2 Details, 3 Payment,
532
- 4 Booking. **This breaks hosts listening to `sidon-step-change`** (and anything keyed on step 3
533
- being the booking), so it ships as a major version. Update those listeners, and set
534
- `sailor-api-origin` ([Documents setup](#documents-setup)). On
535
- [the booking page](#the-booking-page), marinas on sailor documents no longer show the per-booking
536
- upload.
537
-
538
- ## Migrating from 0.4.x / 0.2.x
539
-
540
- Earlier versions talked to sidon-ai:
541
-
542
- - 0.2.x and 0.4.16 were published as `@robooko/sidon-booking-widget` on GitHub Packages.
543
- - 0.4.17 was published as `@forgebuild/sidon-booking-widget`.
544
-
545
- 1.0 talks to OTA through your own forwarder. To migrate:
546
-
547
- 1. **Package.** From `@robooko`:
548
- ```bash
549
- npm uninstall @robooko/sidon-booking-widget
550
- npm install @forgebuild/sidon-booking-widget@^1.1.0
551
- ```
552
- - Change every `@robooko/sidon-booking-widget` import, including `/berth-map`, to
553
- `@forgebuild/sidon-booking-widget`.
554
- - Delete the `@robooko:registry=https://npm.pkg.github.com` and
555
- `//npm.pkg.github.com/:_authToken=…` lines from `.npmrc`. If the file is then empty, delete
556
- it.
557
-
558
- From 0.4.17, just run `npm install @forgebuild/sidon-booking-widget@^1.1.0`.
559
- 2. **Forwarder.** Add [the forwarder route](#the-forwarder-route), and set `OTA_BASE_URL` and
560
- `OTA_API_KEY`.
561
- 3. **`marina`.** Replace the sidon-ai `ownerId` (`org_…`) with the marina's OTA slug.
562
- 4. **`api-origin`.** Remove it. If it still points at sidon-ai (`https://sidonmarine.uk`), every
563
- call goes there and the elements show a load error.
564
- 5. **`currency-symbol`.** Remove it to format amounts in the marina's currency, or keep it to force
565
- a prefix. It no longer defaults to `€`.
566
- 6. **Booking page.** Documents and the contract moved out of the wizard into the booking view.
567
- Host [the booking page](#the-booking-page) and set the marina's `booking_url`. Without that page,
568
- a guest who closes the tab has nowhere to upload documents or agree to the contract.
569
- 7. **Events.**
570
- - `sidon-booked`'s `bookingId` is now OTA's UUID string.
571
- - `sidon-booking-status` reports every OTA status: `hold`, `pending`, `confirmed`, `declined` and
572
- `cancelled`. It used to report only `confirmed` and `cancelled`, and it didn't exist in 0.2.x.
573
- It now also fires on the booking page.
574
- - `sidon-loa-change` doesn't fire in booking-page mode.
575
- - 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.
576
- - Berth ids are still berth labels, in `berth`, `sidon-berth-selected` and
577
- `sidon-quickbook-submit`.
578
- 8. **Server-side reads.** Replace direct fetches of sidon-ai's `/api/marina/public/{ownerId}` with
579
- [`createMarinaClient`](#server-side-reads-createmarinaclient).
580
- - Its payload is in OTA's snake_case (`marina.lat`, `berths[].label`, `available`, `length_m`),
581
- not sidon-ai's camelCase (`profile.lat`, `berthId`, `status`, `lengthM`).
582
- - NUMERIC values come back as strings.
583
-
584
- ## Development
585
-
586
- ```bash
587
- npm install
588
- npm run dev # vite dev server for demo/, importing the same src/ entries consumers get
589
- npm run check # svelte-check and tsc; must report 0 errors
590
- npm test # node assert scripts in scripts/ (astro-proxy and the OTA adapter)
591
- npm run build # dist/
592
- ```
593
-
594
- `npm run build` runs four steps in order:
595
-
596
- 1. The `<sidon-berth-map>` build (`vite.berth-map.config.ts`), which empties `dist/`.
597
- 2. The `<sidon-booking-widget>` build (`vite.config.ts`).
598
- 3. `scripts/write-element-types.mjs`, which copies the hand-written `src/elements.d.ts` to
599
- `dist/elements.d.ts` and writes the two entry `.d.ts` files that re-export it.
600
- 4. `tsc -p tsconfig.proxy.json`, which writes `dist/astro-proxy.js` and its `.d.ts`.
601
-
602
- The demo runs against a local OTA:
603
-
604
- 1. Create the demo marina with OTA's `node scripts/seed-marina-demo.js`, which prints the API key.
605
- 2. Put `OTA_DEMO_API_KEY=<key>` in `demo/.env.local`. If OTA isn't on `http://localhost:3000`, add
606
- `OTA_DEMO_BASE_URL` too.
607
-
608
- The dev server then acts as the forwarder.
609
-
610
- ## Publishing
611
-
612
- ```bash
613
- npm login # an npm account with publish rights on @forgebuild
614
- npm version <patch|minor|major> # bumps package.json and the lockfile, commits, tags
615
- npm pack --dry-run # README.md, package.json and 7 files in dist/
616
- npm publish
617
- git push && git push --tags
618
- ```
619
-
620
- `npm publish` runs `prepublishOnly` first, which rebuilds `dist/`, so a stale build can't ship.
621
- Version 0.4.8 shipped a stale build before this script was added.
622
-
623
- 1.1.0 was bumped with `npm version minor --no-git-tag-version`, so it has no commit or tag of its
624
- own. To release it, skip `npm version`, run `npm publish`, then `git tag v1.1.0` before
625
- `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
+ - 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
+ | `sailor-api-origin` | `''` | OTA's origin, where the Documents step uploads to (`/api/sailor/documents`). Uploads don't go through the forwarder. |
255
+ | `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. |
256
+ | `map-style` | `chart` | `chart` (buoy circles) or `coastal` (SVG pontoon overlays). |
257
+ | `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. |
258
+ | `width` | `560px` | The element's max width, read once at mount. In `overlay` layout, the card's max width. |
259
+ | `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. |
260
+ | `logo-mark` / `marina-name` | | The marina's logo pin on the map. |
261
+ | `otherMarinas` (JS property) | `[]` | Peer-marina pins; see [`otherMarinas`](#othermarinas-js-property). |
262
+
263
+ The attributes set only the starting state. The widget never rewrites the host page's URL, except
264
+ to remove `token` from the booking page's URL. If the page needs to follow the widget's state,
265
+ listen for the events.
266
+
267
+ ### Events
268
+
269
+ The element dispatches these events with `bubbles: true, composed: true`, so they cross the shadow
270
+ root and can be caught anywhere on the page:
271
+
272
+ | Event | `detail` | When |
273
+ |---|---|---|
274
+ | `sidon-step-change` | `{ step: 1 \| 2 \| 3 \| 4 }` | On mount and at every step change (1 Documents, 2 Details, 3 Payment, 4 Booking). |
275
+ | `sidon-loa-change` | `{ loa: number }` | On mount and whenever the boat length changes (wizard only; not in booking-page mode). |
276
+ | `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). |
277
+ | `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`. |
278
+
279
+ ### Documents setup
280
+
281
+ Sign-in is Sidon's, not the site's: *Sign in with Sidon* opens a popup on Sidon's `/connect` page,
282
+ and the sailor signs in (or signs up) with their Sidon account there. The popup hands the widget a
283
+ sailor token, which it keeps for the browser tab. The site has no auth setup of its own.
284
+
285
+ Per site:
286
+
287
+ 1. Ask Sidon to add the site's origin (e.g. `https://www.example.com`) to `CONNECT_ORIGINS`. The
288
+ popup refuses any other origin.
289
+ 2. Upgrade the widget and the forwarder, then set `sailor-api-origin` on the element. From then
290
+ on, any marina with document requirements collects them in the Documents step.
291
+
292
+ `sidon-origin` points the popup at another Sidon (a local or staging one); leave it unset in
293
+ production. Sidon's Clerk instance needs the `ota` JWT template (claims `email` and `name`), and
294
+ OTA's `SAILOR_CLERK_SECRET_KEYS` must hold that instance's secret key.
295
+
296
+ The forwarder forwards the `X-Sailor-Token` header, so update `@forgebuild/sidon-booking-widget`
297
+ in the forwarder's project too.
298
+
299
+ **OTA prerequisite:** OTA must save the expiry it extracts from an uploaded document. Until it does,
300
+ insurance, ACORD 25 and passport uploads never count as met, and the Documents step can't be passed
301
+ for them.
302
+
303
+ ### Removed in 1.0
304
+
305
+ - **Documents and the contract** are no longer in the wizard. They need the booking's token, so
306
+ they're in the booking view now. The contract preview before booking, and filling the details
307
+ form from a scanned document, are gone.
308
+ - **The QR code on the confirmation step.** Once the marina has a `booking_url`, OTA's emails carry
309
+ the booking-page link and its QR code.
310
+ - **Per-marina email branding.** OTA's emails use the property's branding.
311
+ - **The old defaults.** `api-origin` used to default to `https://sidonmarine.uk`, and
312
+ `currency-symbol` to `€`.
313
+
314
+ ## The booking page
315
+
316
+ Each site hosts one page per marina where guests follow and manage their booking. OTA's emails
317
+ link to this page, and OTA's email confirm link redirects to it.
318
+
319
+ ```astro
320
+ ---
321
+ // src/pages/berths/booking.astro: a bare page, no analytics or tag manager
322
+ ---
323
+ <html lang="en">
324
+ <head>
325
+ <meta charset="utf-8" />
326
+ <meta name="referrer" content="no-referrer" />
327
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
328
+ <title>Your berth booking</title>
329
+ </head>
330
+ <body>
331
+ <sidon-booking-widget marina="fox-mooring" booking-id></sidon-booking-widget>
332
+ <script>
333
+ import '@forgebuild/sidon-booking-widget';
334
+ </script>
335
+ </body>
336
+ </html>
337
+ ```
338
+
339
+ The page can be prerendered or rendered on demand. In the browser, the widget reads
340
+ `?booking=&token=` from the URL and keeps both values in `sessionStorage`. It then removes `token`
341
+ from the address bar with `history.replaceState`.
342
+
343
+ **Language.** Leave `locale` off this page. The booking view then uses the language the booking was
344
+ made in: the wizard's `locale`, or the language staff picked. That is also the language of OTA's
345
+ emails. A `locale` attribute overrides it.
346
+
347
+ Requirements:
348
+
349
+ 1. **The marina's `booking_url`** must be exactly `<page URL>?booking={id}&token={token}`, for
350
+ example `https://www.example.com/berths/booking?booking={id}&token={token}`.
351
+ - Set it with `PUT /api/marina/marinas/:id` and the body `{ "booking_url": "…" }`. OTA rejects a
352
+ URL that lacks either placeholder.
353
+ - Until it's set, guest emails carry no booking link, and the confirm link ends on a plain OTA
354
+ page.
355
+ 2. **`Referrer-Policy: no-referrer`**, because the URL carries the booking token. Set it in one of
356
+ two ways:
357
+ - The `<meta name="referrer">` tag shown above. Put it near the top of `<head>`, so it comes
358
+ before anything loads.
359
+ - The response header. On an on-demand page, call
360
+ `Astro.response.headers.set('Referrer-Policy', 'no-referrer')`. On Vercel, you can add a
361
+ `headers` rule in `vercel.json` instead.
362
+ 3. **No third-party scripts except Stripe's, and no analytics.**
363
+ - Analytics and tag managers record the full URL, token included, before the widget can remove
364
+ the token. For example, GTM's `page_location` keeps the query string.
365
+ - On this page the widget itself loads only Stripe.js, and opens an Ably connection for live
366
+ status.
367
+ - It never loads Google Maps or weather on this page, even with `maps-api-key` set.
368
+
369
+ The booking view shows:
370
+
371
+ - the status (hold, pending, confirmed, declined, cancelled or expired) and the booking's details.
372
+ "Expired" covers both a hold that lapsed and a request the marina didn't answer in time. Its
373
+ wording says only that the booking expired before it was confirmed and the berth was released.
374
+ - card payment to finish the booking, while it's a `hold` with a card payment
375
+ - "check your email" and the hold's expiry time, for a `hold` without a card payment
376
+ - the marina's contract to read and agree to, if the marina has one
377
+ - a cancel action, while the booking is a hold or pending
378
+
379
+ The status updates live over Ably. When Ably isn't available, the view polls every 30 seconds
380
+ instead.
381
+
382
+ ## `<sidon-berth-map>`
383
+
384
+ ```astro
385
+ <sidon-berth-map
386
+ marina="fox-mooring"
387
+ maps-api-key={import.meta.env.PUBLIC_GOOGLE_MAPS_API_KEY}
388
+ map-style="chart"
389
+ book-path="/services/book"
390
+ ></sidon-berth-map>
391
+
392
+ <script>
393
+ import '@forgebuild/sidon-booking-widget/berth-map';
394
+ </script>
395
+ ```
396
+
397
+ This element shows a map of the marina's berths, as buoy circles or SVG pontoon overlays. A
398
+ floating quick-book card on the map has dates, boat length, the selected berth and a submit button.
399
+ The element reads the marina through the same forwarder.
400
+
401
+ - In either style, guests can't pick a booked berth or one under maintenance.
402
+ - If a pontoon SVG has a berth label that OTA doesn't list, that berth counts as unavailable.
403
+ - The card's total, and its automatic pick (the cheapest free berth that fits), come from OTA's
404
+ availability route. So the price shown is the price charged.
405
+ - The map zooms to fit the marina once, on its first load. Changing dates or reloading the data
406
+ keeps the visitor's zoom and position.
407
+
408
+ ### Attributes
409
+
410
+ | Attribute | Default | Description |
411
+ |---|---|---|
412
+ | `marina` | required | The marina's OTA slug. |
413
+ | `maps-api-key` | required | Google Maps JS API key. |
414
+ | `mode` | `guest` | `guest` or `staff`; see [Staff mode](#staff-mode). |
415
+ | `api-origin` | `''` | Same as for `<sidon-booking-widget>`. |
416
+ | `map-style` | `chart` | `chart` (buoy circles) or `coastal` (SVG pontoon overlays). |
417
+ | `map-height` | `480px` | Map height. |
418
+ | `show-weather` | absent | Boolean. Adds a weather panel (Open-Meteo). |
419
+ | `locale` / `currency-symbol` | `en` / `''` | Same as for `<sidon-booking-widget>`. |
420
+ | `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. |
421
+ | `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. |
422
+ | `logo-mark` / `marina-name` | | The marina's logo pin at the map centre. |
423
+ | `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. |
424
+ | `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. |
425
+
426
+ ### `otherMarinas` (JS property)
427
+
428
+ Peer-marina logo pins. This is structured data, so set it as a JS property rather than an HTML
429
+ attribute:
430
+
431
+ ```ts
432
+ document.querySelector('sidon-berth-map')!.otherMarinas = [
433
+ { name: 'Pevero Mooring', lat: 41.117, lng: 9.394, logoMark: 'https://…/pevero-mark.svg', href: '/m/pevero-mooring/berths' },
434
+ ];
435
+ ```
436
+
437
+ The host page always supplies `href`. The package doesn't know any site's routes, so it never
438
+ builds peer-marina links itself.
439
+
440
+ ### Events
441
+
442
+ These events also use `bubbles: true, composed: true`:
443
+
444
+ - `sidon-berth-selected` sends `{ berthId: string | null }`: the berth label, or `null` when the
445
+ berth is deselected.
446
+ - `sidon-loa-change` sends `{ loa: number }`. It has the same name and shape as the booking
447
+ widget's event, so one listener covers both elements.
448
+ - `sidon-quickbook-submit` sends `{ from, to, length, berth }` and is dispatched with
449
+ `cancelable: true`.
450
+
451
+ ### Staff mode
452
+
453
+ `mode="staff"` turns the map into an operator's view of who is on which berth for `[from, to)`. It
454
+ is meant for a dashboard whose forwarder uses `auth` (see [the forwarder route](#the-forwarder-route)).
455
+
456
+ ```ts
457
+ const map = document.createElement('sidon-berth-map');
458
+ map.setAttribute('marina', slug);
459
+ map.setAttribute('mode', 'staff');
460
+ map.setAttribute('from', today); // YYYY-MM-DD, in the marina's own time zone
461
+ map.setAttribute('to', tomorrow);
462
+ map.setAttribute('maps-api-key', mapsApiKey);
463
+ map.addEventListener('sidon-berth-selected', (e) => openPanel(e.detail.berthId)); // null = deselected
464
+ host.replaceChildren(map);
465
+
466
+ // After an edit, or a live booking event:
467
+ map.refresh?.();
468
+ ```
469
+
470
+ - It shows no quick-book card and makes no availability lookup. Berths aren't greyed by boat
471
+ length.
472
+ - Every berth OTA lists can be clicked, booked or not. `sidon-berth-selected` works as in guest
473
+ mode.
474
+ - A berth that isn't available for `[from, to)` (booked, held or under maintenance) is painted as
475
+ occupied: a solid navy circle in `chart` style, the usual occupied style in `coastal`. The
476
+ selected berth stays highlighted.
477
+ - Changing `from` or `to` reloads the map. In guest mode, they're read only once.
478
+ - `refresh()` reloads the map data. Call it as `el.refresh?.()`, so a page running an older build
479
+ doesn't throw.
480
+
481
+ ## TypeScript
482
+
483
+ Both entries ship typings for the elements:
484
+
485
+ - `document.querySelector('sidon-booking-widget')` returns a `SidonBookingWidgetElement`.
486
+ - `document.querySelector('sidon-berth-map')` returns a `SidonBerthMapElement`.
487
+ - Listeners on these elements get typed event details.
488
+
489
+ ```ts
490
+ document.querySelector('sidon-berth-map')?.addEventListener('sidon-quickbook-submit', (e) => {
491
+ console.log(e.detail.from, e.detail.to, e.detail.length, e.detail.berth);
492
+ });
493
+ ```
494
+
495
+ You can also import the element and detail types:
496
+
497
+ ```ts
498
+ import type { SidonBerthMapElement, SidonQuickbookSubmitDetail } from '@forgebuild/sidon-booking-widget';
499
+ ```
500
+
501
+ ## Theming
502
+
503
+ Both elements render in a shadow root with their own CSS. They don't depend on the host page's
504
+ stylesheet and don't clash with it. Brand colours and fonts reach inside as inherited CSS custom
505
+ properties:
506
+
507
+ ```css
508
+ sidon-booking-widget,
509
+ sidon-berth-map {
510
+ --brand-navy: #0e3a3e;
511
+ --brand-gold: #d49a3c;
512
+ --brand-ink: #0e3a3e; /* dark card surface */
513
+ --brand-font-title: 'Teko', sans-serif; /* berth-map card heading */
514
+ --brand-font-body: 'Hind', sans-serif; /* berth-map card and weather panel */
515
+ }
516
+ ```
517
+
518
+ When these are unset, the elements use `#16365b` navy, `#c9a86b` gold, `#252525` ink and a system
519
+ sans-serif font.
520
+
521
+ On `<sidon-booking-widget>`, the `variant` attribute switches the buttons and document rows
522
+ between two styles:
523
+
524
+ - `navy` (the default): gold buttons that turn navy on hover, with rounded corners.
525
+ - `dark`: solid `--brand-ink` buttons that turn gold on hover, with square corners.
526
+
527
+ ## Upgrading to the Documents-first wizard
528
+
529
+ The wizard gained a first step, so the step numbers moved: 1 Documents, 2 Details, 3 Payment,
530
+ 4 Booking. **This breaks hosts listening to `sidon-step-change`** (and anything keyed on step 3
531
+ being the booking), so it ships as a major version. Update those listeners, and set
532
+ `sailor-api-origin` ([Documents setup](#documents-setup)). Documents are collected only in the
533
+ wizard now; [the booking page](#the-booking-page) no longer has a per-booking upload.
534
+
535
+ ## Migrating from 0.4.x / 0.2.x
536
+
537
+ Earlier versions talked to sidon-ai:
538
+
539
+ - 0.2.x and 0.4.16 were published as `@robooko/sidon-booking-widget` on GitHub Packages.
540
+ - 0.4.17 was published as `@forgebuild/sidon-booking-widget`.
541
+
542
+ 1.0 talks to OTA through your own forwarder. To migrate:
543
+
544
+ 1. **Package.** From `@robooko`:
545
+ ```bash
546
+ npm uninstall @robooko/sidon-booking-widget
547
+ npm install @forgebuild/sidon-booking-widget@^1.1.0
548
+ ```
549
+ - Change every `@robooko/sidon-booking-widget` import, including `/berth-map`, to
550
+ `@forgebuild/sidon-booking-widget`.
551
+ - Delete the `@robooko:registry=https://npm.pkg.github.com` and
552
+ `//npm.pkg.github.com/:_authToken=…` lines from `.npmrc`. If the file is then empty, delete
553
+ it.
554
+
555
+ From 0.4.17, just run `npm install @forgebuild/sidon-booking-widget@^1.1.0`.
556
+ 2. **Forwarder.** Add [the forwarder route](#the-forwarder-route), and set `OTA_BASE_URL` and
557
+ `OTA_API_KEY`.
558
+ 3. **`marina`.** Replace the sidon-ai `ownerId` (`org_…`) with the marina's OTA slug.
559
+ 4. **`api-origin`.** Remove it. If it still points at sidon-ai (`https://sidonmarine.uk`), every
560
+ call goes there and the elements show a load error.
561
+ 5. **`currency-symbol`.** Remove it to format amounts in the marina's currency, or keep it to force
562
+ a prefix. It no longer defaults to `€`.
563
+ 6. **Booking page.** Documents and the contract moved out of the wizard into the booking view.
564
+ Host [the booking page](#the-booking-page) and set the marina's `booking_url`. Without that page,
565
+ a guest who closes the tab has nowhere to upload documents or agree to the contract.
566
+ 7. **Events.**
567
+ - `sidon-booked`'s `bookingId` is now OTA's UUID string.
568
+ - `sidon-booking-status` reports every OTA status: `hold`, `pending`, `confirmed`, `declined` and
569
+ `cancelled`. It used to report only `confirmed` and `cancelled`, and it didn't exist in 0.2.x.
570
+ It now also fires on the booking page.
571
+ - `sidon-loa-change` doesn't fire in booking-page mode.
572
+ - 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.
573
+ - Berth ids are still berth labels, in `berth`, `sidon-berth-selected` and
574
+ `sidon-quickbook-submit`.
575
+ 8. **Server-side reads.** Replace direct fetches of sidon-ai's `/api/marina/public/{ownerId}` with
576
+ [`createMarinaClient`](#server-side-reads-createmarinaclient).
577
+ - Its payload is in OTA's snake_case (`marina.lat`, `berths[].label`, `available`, `length_m`),
578
+ not sidon-ai's camelCase (`profile.lat`, `berthId`, `status`, `lengthM`).
579
+ - NUMERIC values come back as strings.
580
+
581
+ ## Development
582
+
583
+ ```bash
584
+ npm install
585
+ npm run dev # vite dev server for demo/, importing the same src/ entries consumers get
586
+ npm run check # svelte-check and tsc; must report 0 errors
587
+ npm test # node assert scripts in scripts/ (astro-proxy and the OTA adapter)
588
+ npm run build # dist/
589
+ ```
590
+
591
+ `npm run build` runs four steps in order:
592
+
593
+ 1. The `<sidon-berth-map>` build (`vite.berth-map.config.ts`), which empties `dist/`.
594
+ 2. The `<sidon-booking-widget>` build (`vite.config.ts`).
595
+ 3. `scripts/write-element-types.mjs`, which copies the hand-written `src/elements.d.ts` to
596
+ `dist/elements.d.ts` and writes the two entry `.d.ts` files that re-export it.
597
+ 4. `tsc -p tsconfig.proxy.json`, which writes `dist/astro-proxy.js` and its `.d.ts`.
598
+
599
+ The demo runs against a local OTA:
600
+
601
+ 1. Create the demo marina with OTA's `node scripts/seed-marina-demo.js`, which prints the API key.
602
+ 2. Put `OTA_DEMO_API_KEY=<key>` in `demo/.env.local`. If OTA isn't on `http://localhost:3000`, add
603
+ `OTA_DEMO_BASE_URL` too.
604
+
605
+ The dev server then acts as the forwarder.
606
+
607
+ ## Publishing
608
+
609
+ ```bash
610
+ npm login # an npm account with publish rights on @forgebuild
611
+ npm version <patch|minor|major> # bumps package.json and the lockfile, commits, tags
612
+ npm pack --dry-run # README.md, package.json and 7 files in dist/
613
+ npm publish
614
+ git push && git push --tags
615
+ ```
616
+
617
+ `npm publish` runs `prepublishOnly` first, which rebuilds `dist/`, so a stale build can't ship.
618
+ Version 0.4.8 shipped a stale build before this script was added.
619
+
620
+ 1.1.0 was bumped with `npm version minor --no-git-tag-version`, so it has no commit or tag of its
621
+ own. To release it, skip `npm version`, run `npm publish`, then `git tag v1.1.0` before
622
+ `git push && git push --tags`.