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