@forgebuild/sidon-booking-widget 0.4.17 → 1.1.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,242 +1,579 @@
1
1
  # @forgebuild/sidon-booking-widget
2
2
 
3
- A drop-in berth booking widget for sidon-ai marinas. Ships as `<sidon-booking-widget>` — a
4
- self-contained [custom element](https://developer.mozilla.org/en-US/docs/Web/API/Web_components)
5
- compiled from Svelte, so it works on any site (Astro, plain HTML, React, WordPress — anything)
6
- with one script include and one HTML tag. No framework integration required in the consumer.
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.
7
6
 
8
- It ports sidon-ai's `Book*.astro` wizard (document upload with AI-extracted autofill → sailor/boat
9
- details + contract + Stripe payment hold → confirmation) into a portable package, talking directly
10
- to sidon-ai's public marina API (the same cross-origin pattern `portals` already uses via
11
- `SIDON_ORIGIN`).
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
12
 
13
- ## Usage
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.
14
15
 
15
- ```html
16
- <script type="module" src="/path/to/sidon-booking-widget.js"></script>
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.
17
19
 
18
- <sidon-booking-widget
19
- marina="org_3D8ZaTo2ZvmMVRjcHgNeMBC7bIq"
20
- api-origin="https://sidonmarine.uk"
21
- from="2026-08-02"
22
- to="2026-08-03"
23
- length="25"
24
- berth="B32"
25
- locale="it"
26
- ></sidon-booking-widget>
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
27
29
  ```
28
30
 
29
- Or as an npm dependency (once published to npm — see below):
31
+ ## Install
30
32
 
31
- ```ts
32
- import '@forgebuild/sidon-booking-widget';
33
+ ```bash
34
+ npm install @forgebuild/sidon-booking-widget@^1.1.0
33
35
  ```
34
36
 
35
- then use `<sidon-booking-widget>` directly in markup — in Astro this needs no
36
- `@astrojs/svelte` integration, since a custom element is just an HTML tag.
37
+ The package is public on npm, so you need no registry config or token. It has no runtime
38
+ dependencies.
37
39
 
38
- ### Attributes
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).
39
43
 
40
- | Attribute | Required | Description |
41
- |---|---|---|
42
- | `marina` | yes | The marina's `ownerId` (e.g. `org_...`) — **not** the URL slug. Read it off the live booking page's network requests if you don't already have it (content markdown copies of this value have gone stale before — verify against a live request, don't trust it blindly). |
43
- | `api-origin` | no | Defaults to `https://sidonmarine.uk`. Override for a staging/dev sidon-ai instance. |
44
- | `from` / `to` | no | Initial `YYYY-MM-DD` date range. Falls back to today → +3 nights. |
45
- | `length` | no | Initial boat length in metres. Defaults to `15`. |
46
- | `berth` | no | Pre-selected berth id. If omitted (or no longer fits/available), the widget auto-picks the smallest available berth that fits the current length. |
47
- | `locale` | no | `it` / `en` / `fr` / `es`. Defaults to `en`. |
48
- | `currency-symbol` | no | Defaults to `€`. |
49
- | `variant` | no | `navy` / `dark`. Defaults to `navy`. See [Theming](#theming). |
50
-
51
- The widget does **not** rewrite the host page's URL to stay in sync with its internal state
52
- (unlike sidon-ai's own book.astro) — it can't assume it owns the page it's embedded in. Listen for
53
- the events below if the host page wants to reflect state itself.
44
+ ```
45
+ OTA_BASE_URL=https://ota.example.com # OTA's API origin
46
+ OTA_API_KEY=… # the OTA property's API key
47
+ ```
54
48
 
55
- ### Events
49
+ ## The forwarder route
56
50
 
57
- Dispatched on the `<sidon-booking-widget>` element (`bubbles: true, composed: true`, so they cross
58
- the shadow boundary and can be caught anywhere on the page):
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}/…`.
59
53
 
60
- - `sidon-step-change` — `{ step: 1 | 2 | 3 }`, fired whenever the active wizard step changes.
61
- - `sidon-loa-change` — `{ loa: number }`, fired on mount and whenever the sailor adjusts boat length in
62
- Summary's +/- control. Lets a host page (e.g. a map) grey out berths that no longer fit.
63
- - `sidon-booked` — `{ bookingId, marina }`, fired once a booking is successfully submitted.
64
- - `sidon-booking-status` — `{ bookingId, status: 'confirmed' | 'cancelled' }`, fired on step 3 if the
65
- marina confirms/declines the booking while the sailor still has the widget open (live, via Ably —
66
- see the caveat below). Never fires if the tab is closed before the marina acts.
54
+ ```ts
55
+ // src/pages/api/marina/guest/[...path].ts
56
+ import { createMarinaProxy } from '@forgebuild/sidon-booking-widget/astro-proxy';
67
57
 
68
- ### Theming
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;
69
61
 
70
- The widget renders inside a Shadow DOM with its own compiled CSS, so it never depends on or
71
- collides with the host page's stylesheet. Brand colours cross the shadow boundary via inherited
72
- CSS custom properties — the same convention sidon-ai already uses to re-skin Pirates Bight:
62
+ const proxy = createMarinaProxy({
63
+ otaBaseUrl: import.meta.env.OTA_BASE_URL,
64
+ keys: import.meta.env.OTA_API_KEY,
65
+ });
73
66
 
74
- ```css
75
- sidon-booking-widget {
76
- --brand-navy: #0e3a3e;
77
- --brand-gold: #d49a3c;
78
- --brand-ink: #0e3a3e; /* dark card surface */
79
- }
67
+ export const GET = proxy;
68
+ export const POST = proxy;
80
69
  ```
81
70
 
82
- Falls back to sidon-ai's defaults (`#16365b` navy, `#c9a86b` gold) when unset.
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.
83
73
 
84
- Colour custom properties re-skin the palette; the `variant` attribute switches the Next button and
85
- document-upload rows between sidon-ai's two button *conventions* (same mechanism as
86
- `BookStepNav`/`BookStep1Documents`'s own `variant` prop — kept in sync with those on purpose):
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.
87
77
 
88
- ```html
89
- <sidon-booking-widget variant="dark" ...></sidon-booking-widget>
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
+ });
90
87
  ```
91
88
 
92
- - `navy` (default, Positano) — gold Next button, navy-on-hover, rounded corners.
93
- - `dark` (Fox/Pevero) — solid `--brand-ink` Next button, gold-on-hover, flush corners.
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.
94
112
 
95
- ### Live booking status (Ably)
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
+ ```
96
131
 
97
- Step 3 subscribes to sidon-ai's `booking:<id>` Ably channel (same mechanism as
98
- `book-success-live.ts`) and repaints the panel in place — gold "pending" → green "confirmed" / red
99
- "cancelled" — the moment the marina acts, without a page reload. `ably` is dynamically imported only
100
- when step 3 is reached, but note the current build inlines all dynamic imports into the single
101
- output file (see `vite.config.ts`), so it does add to every consumer's download regardless of
102
- whether they ever reach step 3. Fails open on any error — the sailor just keeps the static "pending"
103
- state, exactly like before this existed.
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.
104
137
 
105
- **Known gap:** sidon-ai's `/api/marina/public/[marinaId]/*` endpoints have CORS enabled
106
- (`src/lib/cors.ts`) so this widget can call them from any embedding origin, but
107
- `/api/ably/token` currently does not — a cross-origin token request 404s on preflight. Live status
108
- updates work today only when the widget is embedded on a sidon-ai-served page; on a marina's own
109
- domain (the actual npm/script-tag use case) the token fetch fails, is swallowed by the fail-open
110
- error handling, and the panel silently stays in the static "pending" state. Needs a fix on the
111
- sidon-ai side (add `preflight`/`corsJson` from `lib/cors.ts` to `src/pages/api/ably/token.ts`,
112
- matching the other public endpoints) before this is live for real embeds.
138
+ **OTA's limits**, per visitor IP:
113
139
 
114
- ## What's deliberately different from sidon-ai's own wizard (v1 scope)
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
115
143
 
116
- - **No resend-confirmation-email** on the confirmation step.
117
- - **No season-gated date picker / berth map integration** — this widget doesn't ship a map. Berth
118
- auto-pick is length + availability only, same math as sidon-ai's `recalc()`, minus the
119
- map-driven manual-pick override.
120
- - **Plain CSS, not Tailwind** — kept the build dependency-free; visuals are a close but not
121
- pixel-exact port (no Teko/Hind webfonts bundled — system font stack instead).
122
- - **UI scope is the full-page wizard only** (`Book*.astro`, matches the `/services/book` flow) —
123
- not `portals`' map-embedded `BerthsSizeBar` popover, which was a different, more tightly-coupled
124
- UI left for a future package version. That version now exists as `<sidon-berth-map>` — see below.
144
+ Over a limit, OTA answers 429 and the elements show its message.
125
145
 
126
- ## `<sidon-berth-map>`
146
+ ## Server-side reads: `createMarinaClient`
127
147
 
128
- A second custom element in this package: a self-contained berth map (buoy circles or SVG pontoon
129
- overlays) with a floating quick-book card (dates, boat length, selected berth, submit). Ports
130
- sea-company's `BerthsGoogleMap.astro`/`berth-map-google.ts` + `BerthsBookingCard.astro`/
131
- `berths-booking-card.ts` — previously coordinated via a `window.sidonBerthMap` global — into one
132
- globals-free widget, same "one drop-in tag" model as `<sidon-booking-widget>`.
148
+ ```ts
149
+ // src/lib/marina.ts (server only)
150
+ import { createMarinaClient } from '@forgebuild/sidon-booking-widget/astro-proxy';
133
151
 
134
- ### Usage
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:
135
193
 
136
194
  ```ts
137
- import '@forgebuild/sidon-booking-widget/berth-map';
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
+ }
138
200
  ```
139
201
 
140
- ```html
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. |
247
+ | `logo-mark` / `marina-name` | | The marina's logo pin on the map. |
248
+ | `otherMarinas` (JS property) | `[]` | Peer-marina pins; see [`otherMarinas`](#othermarinas-js-property). |
249
+
250
+ The attributes set only the starting state. The widget never rewrites the host page's URL, except
251
+ to remove `token` from the booking page's URL. If the page needs to follow the widget's state,
252
+ listen for the events.
253
+
254
+ ### Events
255
+
256
+ The element dispatches these events with `bubbles: true, composed: true`, so they cross the shadow
257
+ root and can be caught anywhere on the page:
258
+
259
+ | Event | `detail` | When |
260
+ |---|---|---|
261
+ | `sidon-step-change` | `{ step: 1 \| 2 \| 3 }` | On mount and at every step change (1 Details, 2 Payment, 3 Booking). |
262
+ | `sidon-loa-change` | `{ loa: number }` | On mount and whenever the boat length changes (wizard only; not in booking-page mode). |
263
+ | `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). |
264
+ | `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`. |
265
+
266
+ ### Removed in 1.0
267
+
268
+ - **Documents and the contract** are no longer in the wizard. They need the booking's token, so
269
+ they're in the booking view now. The contract preview before booking, and filling the details
270
+ form from a scanned document, are gone.
271
+ - **The QR code on the confirmation step.** Once the marina has a `booking_url`, OTA's emails carry
272
+ the booking-page link and its QR code.
273
+ - **Per-marina email branding.** OTA's emails use the property's branding.
274
+ - **The old defaults.** `api-origin` used to default to `https://sidonmarine.uk`, and
275
+ `currency-symbol` to `€`.
276
+
277
+ ## The booking page
278
+
279
+ Each site hosts one page per marina where guests follow and manage their booking. OTA's emails
280
+ link to this page, and OTA's email confirm link redirects to it.
281
+
282
+ ```astro
283
+ ---
284
+ // src/pages/berths/booking.astro: a bare page, no analytics or tag manager
285
+ ---
286
+ <html lang="en">
287
+ <head>
288
+ <meta charset="utf-8" />
289
+ <meta name="referrer" content="no-referrer" />
290
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
291
+ <title>Your berth booking</title>
292
+ </head>
293
+ <body>
294
+ <sidon-booking-widget marina="fox-mooring" booking-id></sidon-booking-widget>
295
+ <script>
296
+ import '@forgebuild/sidon-booking-widget';
297
+ </script>
298
+ </body>
299
+ </html>
300
+ ```
301
+
302
+ The page can be prerendered or rendered on demand. In the browser, the widget reads
303
+ `?booking=&token=` from the URL and keeps both values in `sessionStorage`. It then removes `token`
304
+ from the address bar with `history.replaceState`.
305
+
306
+ **Language.** Leave `locale` off this page. The booking view then uses the language the booking was
307
+ made in: the wizard's `locale`, or the language staff picked. That is also the language of OTA's
308
+ emails. A `locale` attribute overrides it.
309
+
310
+ Requirements:
311
+
312
+ 1. **The marina's `booking_url`** must be exactly `<page URL>?booking={id}&token={token}`, for
313
+ example `https://www.example.com/berths/booking?booking={id}&token={token}`.
314
+ - Set it with `PUT /api/marina/marinas/:id` and the body `{ "booking_url": "…" }`. OTA rejects a
315
+ URL that lacks either placeholder.
316
+ - Until it's set, guest emails carry no booking link, and the confirm link ends on a plain OTA
317
+ page.
318
+ 2. **`Referrer-Policy: no-referrer`**, because the URL carries the booking token. Set it in one of
319
+ two ways:
320
+ - The `<meta name="referrer">` tag shown above. Put it near the top of `<head>`, so it comes
321
+ before anything loads.
322
+ - The response header. On an on-demand page, call
323
+ `Astro.response.headers.set('Referrer-Policy', 'no-referrer')`. On Vercel, you can add a
324
+ `headers` rule in `vercel.json` instead.
325
+ 3. **No third-party scripts except Stripe's, and no analytics.**
326
+ - Analytics and tag managers record the full URL, token included, before the widget can remove
327
+ the token. For example, GTM's `page_location` keeps the query string.
328
+ - On this page the widget itself loads only Stripe.js, and opens an Ably connection for live
329
+ status.
330
+ - It never loads Google Maps or weather on this page, even with `maps-api-key` set.
331
+
332
+ The booking view shows:
333
+
334
+ - the status (hold, pending, confirmed, declined, cancelled or expired) and the booking's details.
335
+ "Expired" covers both a hold that lapsed and a request the marina didn't answer in time. Its
336
+ wording says only that the booking expired before it was confirmed and the berth was released.
337
+ - card payment to finish the booking, while it's a `hold` with a card payment
338
+ - "check your email" and the hold's expiry time, for a `hold` without a card payment
339
+ - an upload for each of the marina's document requirements, while the booking is pending or
340
+ confirmed
341
+ - the marina's contract to read and agree to, if the marina has one
342
+ - a cancel action, while the booking is a hold or pending
343
+
344
+ The status updates live over Ably. When Ably isn't available, the view polls every 30 seconds
345
+ instead.
346
+
347
+ ## `<sidon-berth-map>`
348
+
349
+ ```astro
141
350
  <sidon-berth-map
142
- marina="org_3D8ZaTo2ZvmMVRjcHgNeMBC7bIq"
143
- maps-api-key="YOUR_GOOGLE_MAPS_JS_API_KEY"
144
- api-origin="https://sidonmarine.uk"
351
+ marina="fox-mooring"
352
+ maps-api-key={import.meta.env.PUBLIC_GOOGLE_MAPS_API_KEY}
145
353
  map-style="chart"
146
- locale="en"
147
354
  book-path="/services/book"
148
355
  ></sidon-berth-map>
356
+
357
+ <script>
358
+ import '@forgebuild/sidon-booking-widget/berth-map';
359
+ </script>
149
360
  ```
150
361
 
362
+ This element shows a map of the marina's berths, as buoy circles or SVG pontoon overlays. A
363
+ floating quick-book card on the map has dates, boat length, the selected berth and a submit button.
364
+ The element reads the marina through the same forwarder.
365
+
366
+ - In either style, guests can't pick a booked berth or one under maintenance.
367
+ - If a pontoon SVG has a berth label that OTA doesn't list, that berth counts as unavailable.
368
+ - The card's total, and its automatic pick (the cheapest free berth that fits), come from OTA's
369
+ availability route. So the price shown is the price charged.
370
+ - The map zooms to fit the marina once, on its first load. Changing dates or reloading the data
371
+ keeps the visitor's zoom and position.
372
+
151
373
  ### Attributes
152
374
 
153
- | Attribute | Required | Description |
375
+ | Attribute | Default | Description |
154
376
  |---|---|---|
155
- | `marina` | yes | Same `ownerId` convention as `<sidon-booking-widget>` |
156
- | `maps-api-key` | yes | Google Maps JS API key — this element's only extra credential |
157
- | `api-origin` | no | Defaults to `https://sidonmarine.uk` |
158
- | `map-style` | no | `chart` (buoy circles, default) or `coastal` (SVG pontoon overlays) |
159
- | `locale` / `currency-symbol` | no | Same as `<sidon-booking-widget>` |
160
- | `from` / `to` / `length` / `berth` | no | Same initial-state convention as `<sidon-booking-widget>` |
161
- | `book-path` | no | If set, auto-navigates here with `from`/`to`/`length`/`berth` query params on submit — unless a `sidon-quickbook-submit` listener calls `preventDefault()`. Omit it and the element only fires the event; the host handles routing itself. |
162
- | `logo-mark` / `marina-name` | no | Centre-pin logo for this widget's own marina (symmetric with `otherMarinas` below) |
163
- | `eyebrow` / `card-title` / `intro` | no | Optional heading copy rendered above the quick-book card's form fields (eyebrow line, title, intro paragraph). Omit any of the three to skip that line — there's no default text, unlike the labels below. `card-title` (not `title`) to avoid colliding with the native HTML `title` attribute's browser tooltip. |
164
- | `range-label` / `range-placeholder` / `length-label` / `selected-label` / `selected-placeholder` / `cta-label` | no | Override the quick-book card's copy (defaults: "Arrival – Departure", "", "Boat length (m)", "Selected buoy", "Tap a buoy", "Book a mooring") |
377
+ | `marina` | required | The marina's OTA slug. |
378
+ | `maps-api-key` | required | Google Maps JS API key. |
379
+ | `mode` | `guest` | `guest` or `staff`; see [Staff mode](#staff-mode). |
380
+ | `api-origin` | `''` | Same as for `<sidon-booking-widget>`. |
381
+ | `map-style` | `chart` | `chart` (buoy circles) or `coastal` (SVG pontoon overlays). |
382
+ | `map-height` | `480px` | Map height. |
383
+ | `show-weather` | absent | Boolean. Adds a weather panel (Open-Meteo). |
384
+ | `locale` / `currency-symbol` | `en` / `''` | Same as for `<sidon-booking-widget>`. |
385
+ | `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. |
386
+ | `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. |
387
+ | `logo-mark` / `marina-name` | | The marina's logo pin at the map centre. |
388
+ | `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. |
389
+ | `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. |
165
390
 
166
391
  ### `otherMarinas` (JS property)
167
392
 
168
- Peer-marina logo pins — set as a JS property, not a flat HTML attribute, since it's structured data:
393
+ Peer-marina logo pins. This is structured data, so set it as a JS property rather than an HTML
394
+ attribute:
169
395
 
170
396
  ```ts
171
- document.querySelector('sidon-berth-map').otherMarinas = [
172
- { name: 'Pevero Mooring', lat: 41.117, lng: 9.394, logoMark: 'https://.../pevero-mark.svg', href: '/m/pevero-mooring/berths' },
397
+ document.querySelector('sidon-berth-map')!.otherMarinas = [
398
+ { name: 'Pevero Mooring', lat: 41.117, lng: 9.394, logoMark: 'https://…/pevero-mark.svg', href: '/m/pevero-mooring/berths' },
173
399
  ];
174
400
  ```
175
401
 
176
- `href` is always host-supplied — the package owns no site's routing convention, so it never builds
177
- peer-marina links itself.
402
+ The host page always supplies `href`. The package doesn't know any site's routes, so it never
403
+ builds peer-marina links itself.
178
404
 
179
405
  ### Events
180
406
 
181
- Same `sidon-*`, `{ bubbles: true, composed: true }` convention as `<sidon-booking-widget>`:
407
+ These events also use `bubbles: true, composed: true`:
408
+
409
+ - `sidon-berth-selected` sends `{ berthId: string | null }`: the berth label, or `null` when the
410
+ berth is deselected.
411
+ - `sidon-loa-change` sends `{ loa: number }`. It has the same name and shape as the booking
412
+ widget's event, so one listener covers both elements.
413
+ - `sidon-quickbook-submit` sends `{ from, to, length, berth }` and is dispatched with
414
+ `cancelable: true`.
415
+
416
+ ### Staff mode
417
+
418
+ `mode="staff"` turns the map into an operator's view of who is on which berth for `[from, to)`. It
419
+ is meant for a dashboard whose forwarder uses `auth` (see [the forwarder route](#the-forwarder-route)).
420
+
421
+ ```ts
422
+ const map = document.createElement('sidon-berth-map');
423
+ map.setAttribute('marina', slug);
424
+ map.setAttribute('mode', 'staff');
425
+ map.setAttribute('from', today); // YYYY-MM-DD, in the marina's own time zone
426
+ map.setAttribute('to', tomorrow);
427
+ map.setAttribute('maps-api-key', mapsApiKey);
428
+ map.addEventListener('sidon-berth-selected', (e) => openPanel(e.detail.berthId)); // null = deselected
429
+ host.replaceChildren(map);
430
+
431
+ // After an edit, or a live booking event:
432
+ map.refresh?.();
433
+ ```
434
+
435
+ - It shows no quick-book card and makes no availability lookup. Berths aren't greyed by boat
436
+ length.
437
+ - Every berth OTA lists can be clicked, booked or not. `sidon-berth-selected` works as in guest
438
+ mode.
439
+ - A berth that isn't available for `[from, to)` (booked, held or under maintenance) is painted as
440
+ occupied: a solid navy circle in `chart` style, the usual occupied style in `coastal`. The
441
+ selected berth stays highlighted.
442
+ - Changing `from` or `to` reloads the map. In guest mode, they're read only once.
443
+ - `refresh()` reloads the map data. Call it as `el.refresh?.()`, so a page running an older build
444
+ doesn't throw.
445
+
446
+ ## TypeScript
447
+
448
+ Both entries ship typings for the elements:
449
+
450
+ - `document.querySelector('sidon-booking-widget')` returns a `SidonBookingWidgetElement`.
451
+ - `document.querySelector('sidon-berth-map')` returns a `SidonBerthMapElement`.
452
+ - Listeners on these elements get typed event details.
182
453
 
183
- - `sidon-berth-selected` — `{ berthId: string | null }`
184
- - `sidon-loa-change` — `{ loa: number }` (same name/shape `<sidon-booking-widget>` already uses, so
185
- one listener on a host page covers either element)
186
- - `sidon-quickbook-submit` — `{ from, to, length, berth }`, dispatched `cancelable: true`
454
+ ```ts
455
+ document.querySelector('sidon-berth-map')?.addEventListener('sidon-quickbook-submit', (e) => {
456
+ console.log(e.detail.from, e.detail.to, e.detail.length, e.detail.berth);
457
+ });
458
+ ```
187
459
 
188
- ### Theming
460
+ You can also import the element and detail types:
189
461
 
190
- Same `--brand-navy` / `--brand-gold` / `--brand-ink` CSS custom properties as `<sidon-booking-widget>`
191
- — one theme block styles both elements consistently.
462
+ ```ts
463
+ import type { SidonBerthMapElement, SidonQuickbookSubmitDetail } from '@forgebuild/sidon-booking-widget';
464
+ ```
192
465
 
193
- ### What's different from sea-company's original map + card
466
+ ## Theming
194
467
 
195
- - **No shared globals** — doesn't read or write `window.sidonBerthMap`; all state lives in the
196
- element itself, exposed only via attributes/properties/events.
197
- - **No `#book-stepper` selection lock** — that assumed a shared page-level stepper from
198
- sea-company's `/book` page layout, which has no equivalent in a self-contained widget.
199
- - **Plain CSS, not Tailwind**, system font stack — not Teko/Hind — same dependency-free trade-off
200
- as `<sidon-booking-widget>`.
201
- - **Submit navigates via URL params, not an in-place wizard hand-off** — clicking "Book a mooring"
202
- does not mount `<sidon-booking-widget>` itself; it fires an event and/or navigates to `book-path`.
468
+ Both elements render in a shadow root with their own CSS. They don't depend on the host page's
469
+ stylesheet and don't clash with it. Brand colours and fonts reach inside as inherited CSS custom
470
+ properties:
471
+
472
+ ```css
473
+ sidon-booking-widget,
474
+ sidon-berth-map {
475
+ --brand-navy: #0e3a3e;
476
+ --brand-gold: #d49a3c;
477
+ --brand-ink: #0e3a3e; /* dark card surface */
478
+ --brand-font-title: 'Teko', sans-serif; /* berth-map card heading */
479
+ --brand-font-body: 'Hind', sans-serif; /* berth-map card and weather panel */
480
+ }
481
+ ```
482
+
483
+ When these are unset, the elements use `#16365b` navy, `#c9a86b` gold, `#252525` ink and a system
484
+ sans-serif font.
485
+
486
+ On `<sidon-booking-widget>`, the `variant` attribute switches the buttons and document rows
487
+ between two styles:
488
+
489
+ - `navy` (the default): gold buttons that turn navy on hover, with rounded corners.
490
+ - `dark`: solid `--brand-ink` buttons that turn gold on hover, with square corners.
491
+
492
+ ## Migrating from 0.4.x / 0.2.x
493
+
494
+ Earlier versions talked to sidon-ai:
495
+
496
+ - 0.2.x and 0.4.16 were published as `@robooko/sidon-booking-widget` on GitHub Packages.
497
+ - 0.4.17 was published as `@forgebuild/sidon-booking-widget`.
498
+
499
+ 1.0 talks to OTA through your own forwarder. To migrate:
500
+
501
+ 1. **Package.** From `@robooko`:
502
+ ```bash
503
+ npm uninstall @robooko/sidon-booking-widget
504
+ npm install @forgebuild/sidon-booking-widget@^1.1.0
505
+ ```
506
+ - Change every `@robooko/sidon-booking-widget` import, including `/berth-map`, to
507
+ `@forgebuild/sidon-booking-widget`.
508
+ - Delete the `@robooko:registry=https://npm.pkg.github.com` and
509
+ `//npm.pkg.github.com/:_authToken=…` lines from `.npmrc`. If the file is then empty, delete
510
+ it.
511
+
512
+ From 0.4.17, just run `npm install @forgebuild/sidon-booking-widget@^1.1.0`.
513
+ 2. **Forwarder.** Add [the forwarder route](#the-forwarder-route), and set `OTA_BASE_URL` and
514
+ `OTA_API_KEY`.
515
+ 3. **`marina`.** Replace the sidon-ai `ownerId` (`org_…`) with the marina's OTA slug.
516
+ 4. **`api-origin`.** Remove it. If it still points at sidon-ai (`https://sidonmarine.uk`), every
517
+ call goes there and the elements show a load error.
518
+ 5. **`currency-symbol`.** Remove it to format amounts in the marina's currency, or keep it to force
519
+ a prefix. It no longer defaults to `€`.
520
+ 6. **Booking page.** Documents and the contract moved out of the wizard into the booking view.
521
+ Host [the booking page](#the-booking-page) and set the marina's `booking_url`. Without that page,
522
+ a guest who closes the tab has nowhere to upload documents or agree to the contract.
523
+ 7. **Events.**
524
+ - `sidon-booked`'s `bookingId` is now OTA's UUID string.
525
+ - `sidon-booking-status` reports every OTA status: `hold`, `pending`, `confirmed`, `declined` and
526
+ `cancelled`. It used to report only `confirmed` and `cancelled`, and it didn't exist in 0.2.x.
527
+ It now also fires on the booking page.
528
+ - `sidon-loa-change` doesn't fire in booking-page mode.
529
+ - The wizard steps are now 1 Details, 2 Payment, 3 Booking.
530
+ - Berth ids are still berth labels, in `berth`, `sidon-berth-selected` and
531
+ `sidon-quickbook-submit`.
532
+ 8. **Server-side reads.** Replace direct fetches of sidon-ai's `/api/marina/public/{ownerId}` with
533
+ [`createMarinaClient`](#server-side-reads-createmarinaclient).
534
+ - Its payload is in OTA's snake_case (`marina.lat`, `berths[].label`, `available`, `length_m`),
535
+ not sidon-ai's camelCase (`profile.lat`, `berthId`, `status`, `lengthM`).
536
+ - NUMERIC values come back as strings.
203
537
 
204
538
  ## Development
205
539
 
206
540
  ```bash
207
541
  npm install
208
- npm run dev # demo/ harness — hits sidon-ai's live public API directly
209
- npm run build # library build → dist/sidon-booking-widget.js + dist/sidon-berth-map.js
210
- # (each a single self-contained ES module, Svelte runtime bundled in —
211
- # zero peer deps for consumers) + matching dist/*.d.ts for each
542
+ npm run dev # vite dev server for demo/, importing the same src/ entries consumers get
543
+ npm run check # svelte-check and tsc; must report 0 errors
544
+ npm test # node assert scripts in scripts/ (astro-proxy and the OTA adapter)
545
+ npm run build # dist/
212
546
  ```
213
547
 
214
- `demo/index.html` renders three `<sidon-booking-widget>` instances (default theme, re-themed, and
215
- `variant="dark"`) plus one `<sidon-berth-map>` instance, all against Fox Mooring's real marina
216
- data — useful for a quick visual/functional check without touching any consumer repo. The
217
- berth-map instance needs a real Google Maps JS API key substituted in for local testing (don't
218
- commit one).
548
+ `npm run build` runs four steps in order:
549
+
550
+ 1. The `<sidon-berth-map>` build (`vite.berth-map.config.ts`), which empties `dist/`.
551
+ 2. The `<sidon-booking-widget>` build (`vite.config.ts`).
552
+ 3. `scripts/write-element-types.mjs`, which copies the hand-written `src/elements.d.ts` to
553
+ `dist/elements.d.ts` and writes the two entry `.d.ts` files that re-export it.
554
+ 4. `tsc -p tsconfig.proxy.json`, which writes `dist/astro-proxy.js` and its `.d.ts`.
555
+
556
+ The demo runs against a local OTA:
219
557
 
220
- **Verified live** (2026-08-02) against sidon-ai's production API: document upload, contract preview
221
- with live placeholder substitution, and Stripe's Payment Element mounting and initializing inside
222
- the widget's Shadow DOM all work — confirmed with Fox Mooring (real marina, test-mode Stripe key).
223
- Did not complete an actual booking submission, to avoid leaving a test record in a live dashboard.
558
+ 1. Create the demo marina with OTA's `node scripts/seed-marina-demo.js`, which prints the API key.
559
+ 2. Put `OTA_DEMO_API_KEY=<key>` in `demo/.env.local`. If OTA isn't on `http://localhost:3000`, add
560
+ `OTA_DEMO_BASE_URL` too.
224
561
 
225
- ## Publishing (public npm)
562
+ The dev server then acts as the forwarder.
226
563
 
227
- Published to `github.com/robooko/sidon-booking-widget` (private repo) and the public npm registry as
228
- `@forgebuild/sidon-booking-widget`. To publish a new version:
564
+ ## Publishing
229
565
 
230
566
  ```bash
231
- npm version <patch|minor|major>
232
- npm publish # requires npm login as an account with publish rights on the @forgebuild scope
567
+ npm login # an npm account with publish rights on @forgebuild
568
+ npm version <patch|minor|major> # bumps package.json and the lockfile, commits, tags
569
+ npm pack --dry-run # README.md, package.json and 7 files in dist/
570
+ npm publish
233
571
  git push && git push --tags
234
572
  ```
235
573
 
236
- `npm publish` always rebuilds `dist/` first via a `prepublishOnly` script — no need to run
237
- `npm run build` separately, and no way to accidentally publish a stale build from an earlier
238
- version (0.4.8 shipped this way before `prepublishOnly` existed: identical `dist/` output to
239
- 0.4.7, silently missing that release's actual changes).
574
+ `npm publish` runs `prepublishOnly` first, which rebuilds `dist/`, so a stale build can't ship.
575
+ Version 0.4.8 shipped a stale build before this script was added.
240
576
 
241
- To install it as a dependency elsewhere: `npm install @forgebuild/sidon-booking-widget`. No registry
242
- config or auth token needed — it's a public package.
577
+ 1.1.0 was bumped with `npm version minor --no-git-tag-version`, so it has no commit or tag of its
578
+ own. To release it, skip `npm version`, run `npm publish`, then `git tag v1.1.0` before
579
+ `git push && git push --tags`.