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