@forgebuild/sidon-booking-widget 0.4.17 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +501 -164
- package/dist/astro-proxy.d.ts +44 -0
- package/dist/astro-proxy.js +205 -0
- package/dist/custom-element-berth-map.d.ts +1 -1
- package/dist/custom-element.d.ts +1 -1
- package/dist/elements.d.ts +118 -0
- package/dist/sidon-berth-map.js +1646 -1435
- package/dist/sidon-booking-widget.js +4497 -4580
- package/package.json +50 -49
- package/dist/favicon.svg +0 -1
package/README.md
CHANGED
|
@@ -1,242 +1,579 @@
|
|
|
1
1
|
# @forgebuild/sidon-booking-widget
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
api
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
31
|
+
## Install
|
|
30
32
|
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
+
```bash
|
|
34
|
+
npm install @forgebuild/sidon-booking-widget@^1.1.0
|
|
33
35
|
```
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
The package is public on npm, so you need no registry config or token. It has no runtime
|
|
38
|
+
dependencies.
|
|
37
39
|
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
49
|
+
## The forwarder route
|
|
56
50
|
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
62
|
+
const proxy = createMarinaProxy({
|
|
63
|
+
otaBaseUrl: import.meta.env.OTA_BASE_URL,
|
|
64
|
+
keys: import.meta.env.OTA_API_KEY,
|
|
65
|
+
});
|
|
73
66
|
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
```
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
"
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
146
|
+
## Server-side reads: `createMarinaClient`
|
|
127
147
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
143
|
-
maps-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 |
|
|
375
|
+
| Attribute | Default | Description |
|
|
154
376
|
|---|---|---|
|
|
155
|
-
| `marina` |
|
|
156
|
-
| `maps-api-key` |
|
|
157
|
-
| `
|
|
158
|
-
| `
|
|
159
|
-
| `
|
|
160
|
-
| `
|
|
161
|
-
| `
|
|
162
|
-
| `
|
|
163
|
-
| `
|
|
164
|
-
| `
|
|
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
|
|
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')
|
|
172
|
-
{ name: 'Pevero Mooring', lat: 41.117, lng: 9.394, logoMark: 'https
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
460
|
+
You can also import the element and detail types:
|
|
189
461
|
|
|
190
|
-
|
|
191
|
-
|
|
462
|
+
```ts
|
|
463
|
+
import type { SidonBerthMapElement, SidonQuickbookSubmitDetail } from '@forgebuild/sidon-booking-widget';
|
|
464
|
+
```
|
|
192
465
|
|
|
193
|
-
|
|
466
|
+
## Theming
|
|
194
467
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
-
|
|
202
|
-
|
|
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
|
|
209
|
-
npm run
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
`
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
562
|
+
The dev server then acts as the forwarder.
|
|
226
563
|
|
|
227
|
-
|
|
228
|
-
`@forgebuild/sidon-booking-widget`. To publish a new version:
|
|
564
|
+
## Publishing
|
|
229
565
|
|
|
230
566
|
```bash
|
|
231
|
-
npm
|
|
232
|
-
npm
|
|
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`
|
|
237
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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`.
|