@forgebuild/hotal-ui 0.23.15 → 0.23.19

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.
Files changed (4) hide show
  1. package/README.md +660 -659
  2. package/dist/hotal-ui.js +6444 -5727
  3. package/dist/index.d.ts +1191 -1099
  4. package/package.json +45 -45
package/README.md CHANGED
@@ -1,659 +1,660 @@
1
- # @forgebuild/hotal-ui
2
-
3
- Reusable Web Components for booking and reservation experiences.
4
-
5
- ## Install
6
-
7
- ```
8
- npm install @forgebuild/hotal-ui
9
- ```
10
-
11
- No peer dependencies — the Svelte runtime is bundled in. Works via a plain `<script type="module">` tag too:
12
-
13
- ```html
14
- <script type="module" src="https://esm.sh/@forgebuild/hotal-ui"></script>
15
- ```
16
-
17
- ## Components
18
-
19
- ### `<reservation-bar>`
20
-
21
- A date / guests / time picker bar with a Reserve link, popovers positioned via Floating UI, and calendar/list panels rendered with the native Popover API inside an open shadow root.
22
-
23
- ```html
24
- <script type="module">
25
- import '@forgebuild/hotal-ui';
26
-
27
- const bar = document.querySelector('reservation-bar');
28
-
29
- // Function-valued and explicitly-false-when-default-is-true props must be
30
- // set as JS properties, not HTML attributes (attributes are strings only,
31
- // and HTML boolean-attribute semantics can't express "false" when the
32
- // prop's own default is true).
33
- bar.showReserveButton = false;
34
- bar.fetchAvailableDates = async (partySize) => {
35
- const res = await fetch(`/api/available-dates?party_size=${partySize}`);
36
- const { dates } = await res.json();
37
- return dates;
38
- };
39
-
40
- bar.addEventListener('reservationchange', (e) => console.log(e.detail));
41
- </script>
42
-
43
- <reservation-bar reserve-href="/reservations" sync-with-url closed-weekdays="[0]"></reservation-bar>
44
- ```
45
-
46
- #### Attributes / properties
47
-
48
- | Name | Attribute | Type | Default | Description |
49
- | --- | --- | --- | --- | --- |
50
- | `minGuests` | `min-guests` | `number` | `1` | Minimum selectable party size |
51
- | `maxGuests` | `max-guests` | `number` | `20` | Maximum selectable party size |
52
- | `defaultGuests` | `default-guests` | `number` | `2` | Initial party size |
53
- | `timeSlots` | `time-slots` (JSON array) | `string[]` | `'7:00 PM'`–`'11:00 PM'` in 30-min steps | Selectable time options |
54
- | `defaultTime` | `default-time` | `string` | `'7:00 PM'` | Initial time |
55
- | `showReserveButton` | `show-reserve-button` | `boolean` | `true` | When `false`, selecting any value auto-navigates instead of showing a Reserve link. **Set as a JS property to get `false`** — see note above. |
56
- | `reserveHref` | `reserve-href` | `string` | `'/reservations'` | Base URL the built query string is appended to |
57
- | `syncWithUrl` | `sync-with-url` | `boolean` | `false` | Read initial date/guests/time from `location.search` on mount |
58
- | `closedWeekdays` | `closed-weekdays` (JSON array) | `number[]` | `[]` | Weekdays (`0`=Sun–`6`=Sat) to disable in the calendar |
59
- | `fetchAvailableDates` | — (property only) | `(partySize: number) => Promise<string[]>` | `undefined` | Drives which calendar dates are selectable |
60
-
61
- #### Events
62
-
63
- `reservationchange` — `CustomEvent<{ date: string; guests: number; time: string }>`, fires whenever the selection changes.
64
-
65
- #### Theming
66
-
67
- ```css
68
- reservation-bar {
69
- --rb-bar-bg: rgba(14, 58, 62, 0.9);
70
- --rb-bar-border: rgba(212, 154, 60, 0.4);
71
- --rb-text-color: #fff;
72
- --rb-accent-bg: #d49a3c;
73
- --rb-accent-hover-bg: #e0ac57;
74
- --rb-trigger-hover-bg: rgba(212, 154, 60, 0.15);
75
- --rb-panel-bg: rgba(10, 10, 12, 0.92);
76
- --rb-radius: 2px;
77
- }
78
- ```
79
-
80
- ### `<booking-flow>`
81
-
82
- Search results, a Stripe payment form, booking submission, and confirmation — plus an empty-state calendar when a date has no availability. Decoupled from `<reservation-bar>`: the page mirrors `date`/`guests`/`time` into `<booking-flow>` itself, typically from a `reservation-bar`'s `reservationchange` event or from the page's own URL.
83
-
84
- ```html
85
- <script type="module">
86
- import '@forgebuild/hotal-ui';
87
-
88
- const flow = document.querySelector('booking-flow');
89
-
90
- flow.closedWeekdays = [0];
91
- flow.fetchAvailableDates = async (partySize) => {
92
- const res = await fetch(`/api/available-dates?party_size=${partySize}`);
93
- const { dates } = await res.json();
94
- return dates;
95
- };
96
- flow.searchSlots = async (date, guests) => {
97
- const res = await fetch(`/api/search?date=${date}&party_size=${guests}`);
98
- const { slots } = await res.json();
99
- return slots;
100
- };
101
- flow.stripePublishableKey = 'pk_test_...';
102
- flow.stripeAppearance = { theme: 'stripe' };
103
- flow.createPaymentIntent = async (date, guests) => {
104
- const res = await fetch('/api/create-payment-intent', {
105
- method: 'POST',
106
- headers: { 'Content-Type': 'application/json' },
107
- body: JSON.stringify({ date, party_size: guests }),
108
- });
109
- return res.json();
110
- };
111
- flow.book = async (details) => {
112
- const res = await fetch('/api/book', {
113
- method: 'POST',
114
- headers: { 'Content-Type': 'application/json' },
115
- body: JSON.stringify(details),
116
- });
117
- return res.status === 201 ? { ok: true } : { ok: false, status: res.status, message: 'Something went wrong.' };
118
- };
119
-
120
- flow.date = '2026-08-20';
121
- flow.guests = 2;
122
-
123
- flow.addEventListener('booking-confirmed', (e) => console.log(e.detail));
124
- </script>
125
-
126
- <booking-flow></booking-flow>
127
- ```
128
-
129
- #### Properties (all JS-only — no HTML attributes, since every property here is either dynamic state or a callback)
130
-
131
- | Name | Type | Description |
132
- | --- | --- | --- |
133
- | `date` | `string` | Current search date (ISO) |
134
- | `guests` | `number` | Current party size |
135
- | `preferredTime` | `string` | Used to pre-select the closest available slot |
136
- | `closedWeekdays` | `number[]` | Same convention as `reservation-bar`: `0`=Sun–`6`=Sat, for the empty-state calendar |
137
- | `fetchAvailableDates` | `(partySize: number) => Promise<string[]>` | Drives the empty-state calendar's selectable dates |
138
- | `searchSlots` | `(date: string, guests: number) => Promise<BookingSlot[]>` | Returns available time slots |
139
- | `stripePublishableKey` | `string` | Stripe publishable key |
140
- | `stripeAppearance` | `object` | Passed straight through to Stripe's `elements({ appearance })` |
141
- | `createPaymentIntent` | `(date: string, guests: number) => Promise<PaymentIntentResult>` | Creates the Stripe PaymentIntent to hold |
142
- | `book` | `(details: BookingDetails) => Promise<BookResult>` | Submits the reservation |
143
-
144
- When `stripePublishableKey` / `createPaymentIntent` are not set, the payment section is not rendered, no card hold is taken, and `stripePaymentIntentId` is omitted from `BookingDetails`.
145
-
146
- ```ts
147
- interface BookingSlot { time: string; availableTables: number; }
148
- interface PaymentIntentResult { clientSecret: string; paymentIntentId: string; amountCents: number; }
149
- interface BookingDetails {
150
- date: string; startTime: string; timeDisplay: string; partySize: number;
151
- contactName: string; contactEmail?: string; contactPhone?: string; notes?: string;
152
- stripePaymentIntentId: string; clerkUserId?: string;
153
- }
154
- type BookResult = { ok: true } | { ok: false; status: number; message: string };
155
- ```
156
-
157
- #### Events
158
-
159
- `booking-confirmed` — `CustomEvent<{ date: string; time: string; guests: number }>`, fires once a booking succeeds.
160
-
161
- #### Theming
162
-
163
- ```css
164
- booking-flow {
165
- --bf-text-color: #1a1a1a;
166
- --bf-muted-text: rgba(0, 0, 0, 0.6);
167
- --bf-border-color: rgba(0, 0, 0, 0.15);
168
- --bf-accent-bg: #1a1a1a;
169
- --bf-accent-text: #fff;
170
- --bf-spinner-track: rgba(0, 0, 0, 0.15);
171
- --bf-spinner-active: #1a1a1a;
172
- }
173
- ```
174
-
175
- ### `<menu-section>`
176
-
177
- A restaurant menu with category tabs (animated sliding indicator), an info grid, per-item allergen icons with tooltips, and an optional PDF-menu dialog. Two visual variants: the classic centered design, and `variant="panel"` — an eyebrow-above-heading header row with tabs on the right, a full-width boxed info grid, and a bordered item-card grid, rendered statically with instant category switching.
178
-
179
- ```html
180
- <script type="module">import '@forgebuild/hotal-ui';</script>
181
-
182
- <menu-section
183
- variant="panel"
184
- title="On the menu"
185
- eyebrow="ZUMA"
186
- categories='[{"label":"Bites","items":[{"name":"BBQ Chicken Sliders","description":"Jerk-glazed, brioche bun","price":"$17"}]}]'
187
- info='[{"label":"Hours","value":"12:00 pm – late"}]'
188
- ></menu-section>
189
- ```
190
-
191
- #### Attributes / properties
192
-
193
- | Name | Attribute | Type | Default | Description |
194
- | --- | --- | --- | --- | --- |
195
- | `title` | `title` | `string` | `'MENU'` | Main heading |
196
- | `eyebrow` | `eyebrow` | `string` | `''` | Secondary line (above the heading in `panel`, below it otherwise) |
197
- | `categories` | `categories` (JSON) | `MenuCategory[]` | `[]` | `{ label, items: [{ name, description?, price?, tagline?, group?, country?, allergens? }] }` |
198
- | `info` | `info` (JSON) | `{label, value}[]` | `[]` | Info rows (hours, dress code, …) |
199
- | `allergens` | `allergens` (JSON) | `{id, label, icon}[]` | `[]` | Legend resolving item `allergens` ids to icons |
200
- | `pdfUrl` / `pdfLabel` | `pdf-url` / `pdf-label` | `string` | — | Optional PDF menu opened in a dialog |
201
- | `headingAlign` | `heading-align` | `'center' \| 'left'` | `'center'` | Classic variant only |
202
- | `layout` | `layout` | `'stacked' \| 'split'` | `'stacked'` | Classic variant only |
203
- | `variant` | `variant` | `'default' \| 'panel'` | `'default'` | Visual variant |
204
- | `theme` | `theme` (JSON) | `object` | `{}` | Color overrides, applied as CSS custom properties |
205
- | `groupFilter` | `group-filter` | `boolean` | `false` | Pill row filtering the active category by item `group` (e.g. country on a wine list) |
206
- | `countryFilter` | `country-filter` | `boolean` | `false` | Pill row filtering by item `country` (e.g. region), narrowed to the active group |
207
- | `groupFilterAllLabel` / `countryFilterAllLabel` | `group-filter-all-label` / `country-filter-all-label` | `string` | `'All'` | Label of the "show everything" pill |
208
-
209
- The `panel` variant expects the host page to load the Syne and Inter fonts.
210
-
211
- ### `<room-grid>`
212
-
213
- A room-type listing: filter tabs, a check-in/check-out range-calendar picker, and room cards (image, name, description, from-price, CTA) with a pile-and-spread entrance animation. Backend-agnostic — the host resolves image URLs and prices and passes plain data.
214
-
215
- ```html
216
- <script type="module">
217
- import '@forgebuild/hotal-ui';
218
-
219
- const grid = document.querySelector('room-grid');
220
- // Sync dates with storage / other pages
221
- grid.checkIn = localStorage.getItem('checkin') ?? '';
222
- grid.addEventListener('dates-change', (e) => console.log(e.detail)); // { checkIn, checkOut }
223
- // Pair with <island-map>
224
- grid.addEventListener('room-hover', (e) => map.highlightPin(e.detail.slug));
225
- grid.addEventListener('room-leave', (e) => map.unhighlightPin(e.detail.slug));
226
- </script>
227
-
228
- <room-grid
229
- rooms='[{"slug":"beachfront","name":"Beach Front Room","description":"Steps from the shore.","price":1200,"image":"/img/beachfront.jpg","tag":"Beach Front","href":"/accommodations/beachfront"}]'
230
- filter-tabs='[{"label":"Beach Front","slug":"beachfront"}]'
231
- currency="USD"
232
- ></room-grid>
233
- ```
234
-
235
- #### Attributes / properties
236
-
237
- | Name | Attribute | Type | Default | Description |
238
- | --- | --- | --- | --- | --- |
239
- | `rooms` | `rooms` (JSON) | `RoomGridRoom[]` | `[]` | `{ slug, name, description?, price?, image?, tag?, href? }` |
240
- | `filterTabs` | `filter-tabs` (JSON) | `{label, slug}[]` | derived from `tag`s | Explicit tab labels, each matched to one room by slug |
241
- | `checkIn` / `checkOut` | `check-in` / `check-out` | `string` (ISO) | `''` | Selected dates; settable as JS properties |
242
- | `currency` | `currency` | `string` | `'USD'` | Suffix on the from-price |
243
- | `allLabel` | `all-label` | `string` | `'All'` | First tab's label |
244
-
245
- #### Events
246
-
247
- - `dates-change` — `CustomEvent<{ checkIn: string; checkOut: string }>` on every calendar pick
248
- - `room-hover` / `room-leave` — `CustomEvent<{ slug: string }>` on card pointer enter/leave
249
-
250
- Expects the host page to load the Syne and Inter fonts.
251
-
252
- ### `<island-map>`
253
-
254
- A Google Map with branded pop-up pin cards. Self-bootstrapping: reuses whatever Google Maps loader the page already has (`gmpx-api-loader`, `@googlemaps/js-api-loader`, another `island-map`), and injects the official bootstrap itself with `api-key` otherwise. Pins are hidden until highlighted (elastic pop) — pair with `<room-grid>`'s hover events — or shown permanently with `pins-visible`.
255
-
256
- ```html
257
- <script type="module">import '@forgebuild/hotal-ui';</script>
258
-
259
- <island-map
260
- class="h-[480px] w-full"
261
- pins='[{"label":"Beach Front Room","sub":"From USD 1,200","lat":18.3465,"lng":-64.5905,"slug":"beachfront"}]'
262
- center="18.352,-64.591"
263
- zoom="14"
264
- map-id="YOUR_CLOUD_MAP_ID"
265
- api-key="YOUR_MAPS_KEY"
266
- ></island-map>
267
- ```
268
-
269
- #### Attributes / properties
270
-
271
- | Name | Attribute | Type | Default | Description |
272
- | --- | --- | --- | --- | --- |
273
- | `pins` | `pins` (JSON) | `IslandMapPin[]` | `[]` | `{ label, sub?, lat, lng, slug? }` |
274
- | `center` | `center` | `string` | `'0,0'` | `"lat,lng"` |
275
- | `zoom` | `zoom` | `number` | `14` | Initial zoom |
276
- | `mapId` | `map-id` | `string` | `''` | Google Cloud map id (styling) |
277
- | `apiKey` | `api-key` | `string` | `''` | Used only when no Maps loader is already on the page |
278
- | `colorScheme` | `color-scheme` | `'' \| 'DARK' \| 'LIGHT' \| 'FOLLOW_SYSTEM'` | `''` | Leave unset with a styled `map-id` — setting it overrides the cloud style with Google's base theme |
279
- | `fitPins` | `fit-pins` | `boolean` | `true` | Fit the viewport to the pins on load |
280
- | `fitPadding` / `panY` | `fit-padding` / `pan-y` | `number` | `-50` / `-100` | Fit-bounds padding and post-fit vertical pan |
281
- | `interactionLock` | `interaction-lock` | `boolean` | `true` | Swallow map gestures until the visitor clicks once |
282
- | `pinsVisible` | `pins-visible` | `boolean` | `false` | Show all pin cards permanently |
283
- | `lazy` | `lazy` | `boolean` | `false` | Defer map creation until the element nears the viewport |
284
- | `zoomControl` | `zoom-control` | `boolean` | `false` | Show the +/− zoom buttons |
285
-
286
- #### Methods
287
-
288
- `highlightPin(slug)` / `unhighlightPin(slug)` — pop or hide the pin cards registered under that slug.
289
-
290
- Default height is `480px` via `:host`; override with a class or inline style on the element.
291
-
292
- ### `<floor-plan-hero>`
293
-
294
- Renders a PMS floor plan (`room_type.floor_plan`: `{ rooms, items, w, h }`) as an SVG — zone rectangles with labels/m², furniture assets, optional architectural background. Default form is a hero overlay panel (header row, close button, slide-up transition, starts hidden — position it with host classes and open it from your own trigger); `bare` renders just the plan for embedding in a host-styled container. Zone hover emits events the host can use to swap a hero image.
295
-
296
- ```html
297
- <script type="module">
298
- import '@forgebuild/hotal-ui';
299
-
300
- const fp = document.querySelector('floor-plan-hero');
301
- layoutButton.addEventListener('click', () => fp.toggle());
302
- fp.addEventListener('zone-hover', (e) => { if (e.detail.img) heroImg.src = e.detail.img; });
303
- fp.addEventListener('zone-leave', () => (heroImg.src = defaultSrc));
304
- </script>
305
-
306
- <floor-plan-hero
307
- class="absolute right-[120px] bottom-[200px] w-[520px]"
308
- plan='{"w":760,"h":480,"rooms":[{"id":"r1","type":"bedroom","label":"Bedroom","x":60,"y":60,"w":180,"h":140}],"items":[{"id":"i1","asset":"asset4","x":0,"y":0,"w":71,"h":80}]}'
309
- ></floor-plan-hero>
310
-
311
- <!-- Chromeless, on a light surface -->
312
- <floor-plan-hero theme="light" bare plan="..."></floor-plan-hero>
313
- ```
314
-
315
- #### Attributes / properties
316
-
317
- | Name | Attribute | Type | Default | Description |
318
- | --- | --- | --- | --- | --- |
319
- | `plan` | `plan` (JSON) | `FloorPlan` | `{}` | `{ rooms: [{id, type?, label?, x, y, w, h, sqm?, imgUrl?}], items: [{id, asset, x, y, w, h}], w?, h? }` |
320
- | `bgUrl` | `bg-url` | `string` | — | Architectural background image, rendered at 30% opacity |
321
- | `assetBase` | `asset-base` | `string` | `'/SVG/'` | Item assets resolve to `${assetBase}Asset {n}.svg` (asset key `"asset4"` → `Asset 4.svg`) |
322
- | `label` / `closeLabel` | `label` / `close-label` | `string` | `'Floor plan'` / `'Close ×'` | Panel header copy |
323
- | `theme` | `theme` | `'dark' \| 'light'` | `'dark'` | Zone/label palette |
324
- | `bare` | `bare` | `boolean` | `false` | Plan SVG only — no header, chrome, or open/close state |
325
-
326
- #### Methods & events
327
-
328
- `open()` / `close()` / `toggle()` — panel visibility (no-ops in `bare` mode, which is always visible).
329
- `zone-hover` / `zone-leave` — `CustomEvent<{ img?: string; label?: string; type?: string }>` on zone pointer enter/leave. `panel-close` fires when the internal close button is used.
330
-
331
- ### `<stay-search>`
332
-
333
- A hero availability search: a glass check-in/check-out bar, a two-month range calendar, a search status spinner/tick, and a results dropdown of room cards (image, name, description, total for the stay). Results are **joined against the `rooms` prop by PMS room-type id** — anything the search returns that isn't a listed room is dropped, so PMS-side test data never reaches guests. Card imagery and copy come from the host (CMS), rates from the search.
334
-
335
- ```html
336
- <script type="module">
337
- import '@forgebuild/hotal-ui';
338
-
339
- const search = document.querySelector('stay-search');
340
- search.checkIn = localStorage.getItem('checkin') ?? '';
341
- search.searchStays = async (checkIn, checkOut, guests) => {
342
- const res = await fetch(`/api/ota/availability/search?check_in=${checkIn}&check_out=${checkOut}&guests=${guests}`);
343
- return res.json(); // [{ room_type_id, from_rate, ... }]
344
- };
345
- search.addEventListener('dates-change', (e) => console.log(e.detail));
346
- </script>
347
-
348
- <stay-search
349
- class="block w-full max-w-[680px]"
350
- rooms='[{"roomTypeId":"92fc4ca8-…","name":"Beach Front Room","description":"Steps from the shore.","image":"/img/beachfront.jpg","href":"/accommodations/beachfront"}]'
351
- currency="USD"
352
- ></stay-search>
353
- ```
354
-
355
- #### Attributes / properties
356
-
357
- | Name | Attribute | Type | Default | Description |
358
- | --- | --- | --- | --- | --- |
359
- | `rooms` | `rooms` (JSON) | `StaySearchRoom[]` | `[]` | `{ roomTypeId, name, description?, image?, href? }` — the join filter for results |
360
- | `checkIn` / `checkOut` | `check-in` / `check-out` | `string` (ISO) | `''` | Selected dates; settable as JS properties |
361
- | `guests` | `guests` | `number` | `2` | Passed to `searchStays` |
362
- | `currency` | `currency` | `string` | `'USD'` | Shown in the card rate subline |
363
- | `appendDatesToHref` | `append-dates-to-href` | `boolean` | `true` | Append `?check_in=…&check_out=…` to card hrefs |
364
- | `searchStays` | — (property only) | `(checkIn, checkOut, guests) => Promise<StaySearchResult[]>` | — | Runs when a complete range is picked; results need `room_type_id` and `from_rate` |
365
-
366
- #### Events
367
-
368
- `dates-change` — `CustomEvent<{ checkIn: string; checkOut: string }>` on every pick.
369
- `search-results` — `CustomEvent<{ checkIn, checkOut, guests, count }>` after each search renders.
370
-
371
- Expects the host page to load the Syne and Inter fonts.
372
-
373
- ### `<availability-calendar>`
374
-
375
- An inline range calendar for a stay: per-day nightly rates under each date (PMS `min_rate`), dates with no availability disabled, teal-gradient range selection. Two months side by side on desktop, one on mobile.
376
-
377
- ```html
378
- <script type="module">
379
- import '@forgebuild/hotal-ui';
380
-
381
- const cal = document.querySelector('availability-calendar');
382
- cal.fetchAvailability = async (from, to) => {
383
- const res = await fetch(`/api/availability?room_type_id=…&from=${from}&to=${to}`);
384
- return res.json(); // [{ date, available_rooms, min_rate }]
385
- };
386
- cal.addEventListener('dates-change', (e) => console.log(e.detail)); // { checkIn, checkOut }
387
- </script>
388
-
389
- <availability-calendar></availability-calendar>
390
- ```
391
-
392
- | Name | Attribute | Type | Default | Description |
393
- | --- | --- | --- | --- | --- |
394
- | `checkIn` / `checkOut` | `check-in` / `check-out` | `string` (ISO) | `''` | Selected range; settable as JS properties |
395
- | `horizonMonths` | `horizon-months` | `number` | `12` | Availability window requested from the loader |
396
- | `fetchAvailability` | — (property only) | `(fromISO, toISO) => Promise<AvailabilityDay[]>` | — | Dates absent from the result render as unavailable |
397
-
398
- Emits `dates-change` on every pick.
399
-
400
- ### `<booking-card>`
401
-
402
- The reserve card for a room-type page: from-price header, check-in/check-out fields with a white/teal popup calendar (availability-aware via the same loader contract as `<availability-calendar>`), guest stepper, live price breakdown (subtotal, resort fee, total), Reserve CTA with the green wipe hover, fine print, and a concierge footer.
403
-
404
- ```html
405
- <script type="module">
406
- import '@forgebuild/hotal-ui';
407
-
408
- const card = document.querySelector('booking-card');
409
- card.fetchAvailability = async (from, to) => (await fetch(`/api/availability?…`)).json();
410
- card.addEventListener('dates-change', (e) => console.log(e.detail));
411
- card.addEventListener('reserve', (e) => {
412
- const { checkIn, checkOut, guests } = e.detail;
413
- location.href = `/book?checkin=${checkIn}&checkout=${checkOut}&guests=${guests}`;
414
- });
415
- </script>
416
-
417
- <booking-card class="sticky top-[100px] block" price="3800" max-guests="4" currency="USD"></booking-card>
418
- ```
419
-
420
- | Name | Attribute | Type | Default | Description |
421
- | --- | --- | --- | --- | --- |
422
- | `price` | `price` | `number` | `0` | Nightly from-rate; the breakdown prices every night at this rate |
423
- | `maxGuests` | `max-guests` | `number` | `4` | Guest stepper ceiling |
424
- | `resortFeeRate` | `resort-fee-rate` | `number` | `0.03` | Fee as a fraction of the subtotal |
425
- | `ratingText` / `finePrint` | `rating-text` / `fine-print` | `string` | — | Header and footer copy |
426
- | `conciergeTitle` / `conciergeSub` / `conciergeCtaLabel` / `conciergeCtaHref` | kebab-case | `string` | Island Concierge defaults | Footer block |
427
- | `checkIn` / `checkOut` / `guests` | `check-in` / `check-out` / `guests` | ISO / number | `''` / `2` | Current stay; settable as JS properties |
428
- | `fetchAvailability` | — (property only) | as above | — | Disables unavailable dates in the popup |
429
-
430
- Emits `dates-change`, `guests-change`, and `reserve` (`{ checkIn, checkOut, guests }` — navigation is the host's job).
431
-
432
- ### `room-grid` rows & feature variants
433
-
434
- `<room-grid variant="rows" rooms='…' currency="USD">` renders the same room data as compact horizontal cards (image left, tag/name/description, price per night right) on a light surface — two columns on desktop, no filter bar, dates, or entrance motion. For "more accommodations" style listings.
435
-
436
- `<room-grid variant="feature" rooms='…' currency="USD">` renders homepage feature cards: tag badge over the image, whole-card links, a scroll-snap carousel on mobile that becomes a 3-column grid on desktop, and a scroll-triggered fade-up entrance. Emits the same `room-hover`/`room-leave` events — pair with `<island-map>`.
437
-
438
- ### `<dining-teaser>`
439
-
440
- A two-column homepage teaser: heading + body copy, a hoverable/focusable list of venues with a colour-coded status dot, and a CTA — beside a cross-fading photo that tracks whichever venue is active. Scroll-triggered fade/slide entrance, skipped under `prefers-reduced-motion`.
441
-
442
- ```html
443
- <script type="module">
444
- import '@forgebuild/hotal-ui';
445
-
446
- const teaser = document.querySelector('dining-teaser');
447
- teaser.addEventListener('venue-change', (e) => console.log(e.detail)); // { index, key }
448
- </script>
449
-
450
- <dining-teaser
451
- class="mx-auto max-w-[1530px]"
452
- heading="Restaurants."
453
- heading-accent="No compromise."
454
- body="From open-fire dry-aged steaks to fresh catch and rum cocktails — dining here is never an afterthought."
455
- cta-label="Explore dining"
456
- cta-href="/dining"
457
- venues='[{"key":"beefbar","name":"beefbar","tag":"Dinner · Fine Dining","image":"/img/beefbar.jpg"}]'
458
- ></dining-teaser>
459
- ```
460
-
461
- #### Attributes / properties
462
-
463
- | Name | Attribute | Type | Default | Description |
464
- | --- | --- | --- | --- | --- |
465
- | `venues` | `venues` (JSON) | `{key, name, tag?, image}[]` | `[]` | Rendered in order; hovering, focusing, or clicking a row makes it active |
466
- | `heading` / `headingAccent` | `heading` / `heading-accent` | `string` | `'Restaurants.'` / `'No compromise.'` | Two-line heading, second line in the accent colour |
467
- | `body` | `body` | `string` | — | Paragraph under the heading |
468
- | `ctaLabel` / `ctaHref` | `cta-label` / `cta-href` | `string` | `'Explore dining'` / `'/dining'` | The outlined link below the venue list |
469
- | `imageLabel` | `image-label` | `string` | — | Fixed photo caption. Omit to have it track the active venue's `name` instead |
470
- | `activeIndex` | `active-index` | `number` | `0` | Active venue index; settable as a JS property, kept in sync as the visitor interacts |
471
-
472
- #### Events
473
-
474
- `venue-change` — `CustomEvent<{ index: number; key: string }>`, fires once on mount and again whenever the active venue changes.
475
-
476
- #### Theming
477
-
478
- ```css
479
- dining-teaser {
480
- --dt-bg: #f5f2ec;
481
- --dt-heading-color: #0a0814;
482
- --dt-accent-color: rgb(180, 20, 80);
483
- --dt-body-color: rgba(10, 8, 20, 0.65);
484
- --dt-venue-bg: rgba(10, 8, 20, 0.04);
485
- --dt-venue-bg-hover: rgba(10, 8, 20, 0.07);
486
- --dt-venue-border: rgba(10, 8, 20, 0.08);
487
- --dt-venue-border-hover: rgba(10, 8, 20, 0.14);
488
- --dt-venue-name-color: #0a0814;
489
- --dt-venue-type-color: rgba(10, 8, 20, 0.35);
490
- --dt-dot-color: rgb(155, 93, 229);
491
- --dt-dot-active-color: rgb(247, 37, 133);
492
- --dt-cta-border: rgba(10, 8, 20, 0.15);
493
- --dt-cta-text: #0a0814;
494
- --dt-cta-hover-bg: #1a1a18;
495
- --dt-cta-hover-text: #f0eeff;
496
- --dt-image-height: 480px;
497
- }
498
- ```
499
-
500
- Expects the host page to load the Syne, Inter, and Cormorant Garamond fonts.
501
-
502
- ### `<booking-checkout>`
503
-
504
- A checkout form: "booking for myself / someone else" toggle (with a booker sub-form), primary guest details, a country + phone-code pair (built on `<searchable-select>`, cross-synced both ways, plus phone-typed country inference), a Stripe Payment Element, the submit button, and an optional cancellation-policy block. Backend-agnostic — the host creates the PaymentIntent and does the actual booking POST; this component only calls `stripe.confirmPayment` and hands the result to `submitBooking`.
505
-
506
- ```html
507
- <script type="module">
508
- import '@forgebuild/hotal-ui';
509
-
510
- const checkout = document.querySelector('booking-checkout');
511
- checkout.resolveInitialLocation = async () => {
512
- const res = await fetch('https://api.geoapify.com/v1/ipinfo?apiKey=...');
513
- const data = await res.json();
514
- return data?.country?.name ? { countryName: data.country.name, iso2: data.country.iso_code } : null;
515
- };
516
- checkout.beforeSubmit = async () => {
517
- // e.g. re-price the PaymentIntent for extras added after it was created
518
- };
519
- checkout.submitBooking = async (payload) => {
520
- const res = await fetch('/api/book', {
521
- method: 'POST',
522
- headers: { 'Content-Type': 'application/json' },
523
- body: JSON.stringify(payload),
524
- });
525
- const data = await res.json();
526
- return data.success ? { ok: true, ref: data.ref } : { ok: false, message: data.error ?? 'Something went wrong.' };
527
- };
528
- checkout.addEventListener('checkout-confirmed', (e) => {
529
- window.location.href = `/book/confirm?ref=${e.detail.ref}`;
530
- });
531
- </script>
532
-
533
- <booking-checkout
534
- total="3800"
535
- currency="USD"
536
- client-secret="pi_..._secret_..."
537
- stripe-publishable-key="pk_test_..."
538
- cancellation-policy="Full refund up to 14 days before check-in."
539
- locations='[{"iso2":"GB","countryName":"United Kingdom","dialCode":"+44"}]'
540
- default-phone-code="+44"
541
- ></booking-checkout>
542
- ```
543
-
544
- #### Attributes / properties
545
-
546
- | Name | Attribute | Type | Default | Description |
547
- | --- | --- | --- | --- | --- |
548
- | `total` | `total` | `number` | `0` | Drives the submit button's amount label; settable as a JS property, so the host can update it after extras change |
549
- | `currency` | `currency` | `string` | `'USD'` | Used for the amount label |
550
- | `clientSecret` | `client-secret` | `string` | — | Stripe PaymentIntent client secret, created server-side by the host |
551
- | `stripePublishableKey` | `stripe-publishable-key` | `string` | — | Stripe publishable key |
552
- | `stripeAppearance` | `stripe-appearance` (JSON) | `object` | `{}` | Passed to `stripe.elements({ appearance })` |
553
- | `cancellationPolicy` | `cancellation-policy` | `string` | — | Shown in a dedicated section when present; omit to hide it entirely |
554
- | `locations` | `locations` (JSON) | `{iso2, countryName, dialCode}[]` | `[]` | Source for both selects — country options keep every entry, phone-code options are deduplicated by `dialCode` |
555
- | `defaultPhoneCode` | `default-phone-code` | `string` | `'+1'` | Initial value for both phone-code selects |
556
- | `allowBookingForOther` | `allow-booking-for-other` | `boolean` | `true` | Hides the toggle and booker sub-form when `false` |
557
- | `termsHref` | `terms-href` | `string` | `'/terms'` | Terms link and "full policy details" link target |
558
- | `prefill` | — (property only) | `{firstName?, lastName?, email?}` | — | Settable post-mount (e.g. once an async auth check resolves); only fills fields still empty |
559
- | `resolveInitialLocation` | — (property only) | `() => Promise<{countryName, iso2?, dialCode?} \| null>` | — | Called once on mount to seed the country/phone-code selects |
560
- | `beforeSubmit` | — (property only) | `() => Promise<void>` | — | Awaited right before the card is charged |
561
- | `submitBooking` | — (property only) | `(payload) => Promise<{ok:true,ref?} \| {ok:false,message}>` | — | Called after a successful charge; the component never talks to the booking API itself |
562
- | `strings` | `strings` (JSON) | `object` | — | Label overrides — see the component source for the full key list |
563
-
564
- #### Events
565
-
566
- `checkout-confirmed` — `CustomEvent<{ ref?: string }>`, fires once `submitBooking` resolves `{ ok: true }`. Navigation is the host's job.
567
-
568
- `ready` — fires once after the initial mount attempt (Stripe configured or not, succeeded or failed) — a hook for dismissing a page-level loading state without racing the Payment Element's own mount.
569
-
570
- #### Theming
571
-
572
- ```css
573
- booking-checkout {
574
- --bc-text-color: #05040f;
575
- --bc-muted-text: rgba(5, 4, 15, 0.4);
576
- --bc-muted-text-strong: rgba(5, 4, 15, 0.55);
577
- --bc-border-color: rgba(5, 4, 15, 0.15);
578
- --bc-input-bg: #f5f2ec;
579
- --bc-input-border: rgba(5, 4, 15, 0.12);
580
- --bc-input-border-focus: rgba(5, 4, 15, 0.4);
581
- --bc-accent-bg: #05040f;
582
- --bc-accent-text: #f5f2ec;
583
- }
584
- ```
585
-
586
- Expects the host page to load the Inter font, and needs `<searchable-select>` registered (it is, automatically, by importing this package).
587
-
588
- ### `<confirmation-card>`
589
-
590
- A centered confirmation card: gradient check icon, eyebrow, heading, body copy, an optional booking-reference box, up to two action links, and an optional footer note built around one embedded link. Static — no props are callbacks, no events are emitted. The host owns full-page centering (`display: flex; align-items: center; justify-content: center`) around it.
591
-
592
- ```html
593
- <script type="module">import '@forgebuild/hotal-ui';</script>
594
-
595
- <confirmation-card
596
- eyebrow="Booking Confirmed"
597
- heading="You're all set."
598
- body="Your reservation at HOTAL has been confirmed. A confirmation email is on its way to you."
599
- reference="d82cdc93-54fe-4f6a-ae77-8fb2dc7ad1a8"
600
- primary-label="View My Bookings"
601
- primary-href="/account"
602
- secondary-label="Back to Home"
603
- secondary-href="/"
604
- note-prefix="Questions about your stay?"
605
- note-link-label="Contact our concierge team"
606
- note-link-href="/contact"
607
- note-suffix="— we're here to help with anything you need before you arrive."
608
- ></confirmation-card>
609
- ```
610
-
611
- #### Attributes / properties
612
-
613
- | Name | Attribute | Type | Default | Description |
614
- | --- | --- | --- | --- | --- |
615
- | `eyebrow` | `eyebrow` | `string` | `'Booking Confirmed'` | Line above the heading; omit to hide |
616
- | `heading` | `heading` | `string` | `"You're all set."` | |
617
- | `body` | `body` | `string` | — | |
618
- | `reference` / `referenceLabel` | `reference` / `reference-label` | `string` | `''` / `'Booking Reference'` | The boxed reference number; the box is omitted entirely when `reference` is empty |
619
- | `primaryLabel` / `primaryHref` | `primary-label` / `primary-href` | `string` | — | Filled button; omitted when `primaryLabel` is empty |
620
- | `secondaryLabel` / `secondaryHref` | `secondary-label` / `secondary-href` | `string` | — | Outlined button; both must be set to render |
621
- | `notePrefix` / `noteLinkLabel` / `noteLinkHref` / `noteSuffix` | kebab-case | `string` | — | Footer note text split around one embedded link; omitted entirely when both `notePrefix` and the link are empty |
622
-
623
- #### Theming
624
-
625
- ```css
626
- confirmation-card {
627
- --cc-card-bg: #fff;
628
- --cc-border-color: rgba(5, 4, 15, 0.08);
629
- --cc-icon-bg: linear-gradient(135deg, rgb(0, 245, 212) 0%, rgb(79, 142, 247) 100%);
630
- --cc-text-color: #05040f;
631
- --cc-muted-text: rgba(5, 4, 15, 0.4);
632
- --cc-muted-text-strong: rgba(5, 4, 15, 0.55);
633
- --cc-ref-bg: rgba(5, 4, 15, 0.03);
634
- --cc-accent-bg: #05040f;
635
- --cc-accent-text: #fff;
636
- --cc-note-border: rgba(5, 4, 15, 0.06);
637
- --cc-max-width: 560px;
638
- }
639
- ```
640
-
641
- Expects the host page to load the Syne and Inter fonts. Not to be confused with `<booking-confirmed>` above — that one is a compact inline step meant for a checkout *modal* (icon + heading + a "Done" button that emits an event back to the host); this one is a standalone full-page card with no host callback, built for a dedicated `/confirm`-style route.
642
-
643
- ## Development
644
-
645
- ```
646
- npm install
647
- npm run dev # serves demo/
648
- npm run check # type-check
649
- npm run build # library build → dist/
650
- ```
651
-
652
- ## Publishing
653
-
654
- ```
655
- npm run build
656
- npm publish
657
- ```
658
-
659
- Requires being logged in to npm (`npm whoami`) as an account with publish rights to the `@forgebuild` scope.
1
+ # @forgebuild/hotal-ui
2
+
3
+ Reusable Web Components for booking and reservation experiences.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ npm install @forgebuild/hotal-ui
9
+ ```
10
+
11
+ No peer dependencies — the Svelte runtime is bundled in. Works via a plain `<script type="module">` tag too:
12
+
13
+ ```html
14
+ <script type="module" src="https://esm.sh/@forgebuild/hotal-ui"></script>
15
+ ```
16
+
17
+ ## Components
18
+
19
+ ### `<reservation-bar>`
20
+
21
+ A date / guests / time picker bar with a Reserve link, popovers positioned via Floating UI, and calendar/list panels rendered with the native Popover API inside an open shadow root.
22
+
23
+ ```html
24
+ <script type="module">
25
+ import '@forgebuild/hotal-ui';
26
+
27
+ const bar = document.querySelector('reservation-bar');
28
+
29
+ // Function-valued and explicitly-false-when-default-is-true props must be
30
+ // set as JS properties, not HTML attributes (attributes are strings only,
31
+ // and HTML boolean-attribute semantics can't express "false" when the
32
+ // prop's own default is true).
33
+ bar.showReserveButton = false;
34
+ bar.fetchAvailableDates = async (partySize) => {
35
+ const res = await fetch(`/api/available-dates?party_size=${partySize}`);
36
+ const { dates } = await res.json();
37
+ return dates;
38
+ };
39
+
40
+ bar.addEventListener('reservationchange', (e) => console.log(e.detail));
41
+ </script>
42
+
43
+ <reservation-bar reserve-href="/reservations" sync-with-url closed-weekdays="[0]"></reservation-bar>
44
+ ```
45
+
46
+ #### Attributes / properties
47
+
48
+ | Name | Attribute | Type | Default | Description |
49
+ | --- | --- | --- | --- | --- |
50
+ | `minGuests` | `min-guests` | `number` | `1` | Minimum selectable party size |
51
+ | `maxGuests` | `max-guests` | `number` | `20` | Maximum selectable party size |
52
+ | `defaultGuests` | `default-guests` | `number` | `2` | Initial party size |
53
+ | `timeSlots` | `time-slots` (JSON array) | `string[]` | `'7:00 PM'`–`'11:00 PM'` in 30-min steps | Selectable time options |
54
+ | `defaultTime` | `default-time` | `string` | `'7:00 PM'` | Initial time |
55
+ | `showReserveButton` | `show-reserve-button` | `boolean` | `true` | Whether the Reserve link is rendered. **Set as a JS property to get `false`** — see note above. |
56
+ | `reserveHref` | `reserve-href` | `string` | `'/reservations'` | Base URL the built query string is appended to |
57
+ | `applyOnSelect` | `apply-on-select` | `boolean` | `!showReserveButton` | Whether selecting a date/guests/time navigates to `reserveHref` immediately. Defaults to the inverse of `showReserveButton` (hiding the button makes the bar auto-navigate, its historical behavior); set explicitly to decouple the two — e.g. hide the button but keep `applyOnSelect={false}` when a host page syncs an inline booking flow itself off `reservationchange` and no navigation should happen. **Set as a JS property to get `false`.** |
58
+ | `syncWithUrl` | `sync-with-url` | `boolean` | `false` | Read initial date/guests/time from `location.search` on mount |
59
+ | `closedWeekdays` | `closed-weekdays` (JSON array) | `number[]` | `[]` | Weekdays (`0`=Sun–`6`=Sat) to disable in the calendar |
60
+ | `fetchAvailableDates` | — (property only) | `(partySize: number) => Promise<string[]>` | `undefined` | Drives which calendar dates are selectable |
61
+
62
+ #### Events
63
+
64
+ `reservationchange` — `CustomEvent<{ date: string; guests: number; time: string }>`, fires whenever the selection changes.
65
+
66
+ #### Theming
67
+
68
+ ```css
69
+ reservation-bar {
70
+ --rb-bar-bg: rgba(14, 58, 62, 0.9);
71
+ --rb-bar-border: rgba(212, 154, 60, 0.4);
72
+ --rb-text-color: #fff;
73
+ --rb-accent-bg: #d49a3c;
74
+ --rb-accent-hover-bg: #e0ac57;
75
+ --rb-trigger-hover-bg: rgba(212, 154, 60, 0.15);
76
+ --rb-panel-bg: rgba(10, 10, 12, 0.92);
77
+ --rb-radius: 2px;
78
+ }
79
+ ```
80
+
81
+ ### `<booking-flow>`
82
+
83
+ Search results, a Stripe payment form, booking submission, and confirmation — plus an empty-state calendar when a date has no availability. Decoupled from `<reservation-bar>`: the page mirrors `date`/`guests`/`time` into `<booking-flow>` itself, typically from a `reservation-bar`'s `reservationchange` event or from the page's own URL.
84
+
85
+ ```html
86
+ <script type="module">
87
+ import '@forgebuild/hotal-ui';
88
+
89
+ const flow = document.querySelector('booking-flow');
90
+
91
+ flow.closedWeekdays = [0];
92
+ flow.fetchAvailableDates = async (partySize) => {
93
+ const res = await fetch(`/api/available-dates?party_size=${partySize}`);
94
+ const { dates } = await res.json();
95
+ return dates;
96
+ };
97
+ flow.searchSlots = async (date, guests) => {
98
+ const res = await fetch(`/api/search?date=${date}&party_size=${guests}`);
99
+ const { slots } = await res.json();
100
+ return slots;
101
+ };
102
+ flow.stripePublishableKey = 'pk_test_...';
103
+ flow.stripeAppearance = { theme: 'stripe' };
104
+ flow.createPaymentIntent = async (date, guests) => {
105
+ const res = await fetch('/api/create-payment-intent', {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json' },
108
+ body: JSON.stringify({ date, party_size: guests }),
109
+ });
110
+ return res.json();
111
+ };
112
+ flow.book = async (details) => {
113
+ const res = await fetch('/api/book', {
114
+ method: 'POST',
115
+ headers: { 'Content-Type': 'application/json' },
116
+ body: JSON.stringify(details),
117
+ });
118
+ return res.status === 201 ? { ok: true } : { ok: false, status: res.status, message: 'Something went wrong.' };
119
+ };
120
+
121
+ flow.date = '2026-08-20';
122
+ flow.guests = 2;
123
+
124
+ flow.addEventListener('booking-confirmed', (e) => console.log(e.detail));
125
+ </script>
126
+
127
+ <booking-flow></booking-flow>
128
+ ```
129
+
130
+ #### Properties (all JS-only — no HTML attributes, since every property here is either dynamic state or a callback)
131
+
132
+ | Name | Type | Description |
133
+ | --- | --- | --- |
134
+ | `date` | `string` | Current search date (ISO) |
135
+ | `guests` | `number` | Current party size |
136
+ | `preferredTime` | `string` | Used to pre-select the closest available slot |
137
+ | `closedWeekdays` | `number[]` | Same convention as `reservation-bar`: `0`=Sun–`6`=Sat, for the empty-state calendar |
138
+ | `fetchAvailableDates` | `(partySize: number) => Promise<string[]>` | Drives the empty-state calendar's selectable dates |
139
+ | `searchSlots` | `(date: string, guests: number) => Promise<BookingSlot[]>` | Returns available time slots |
140
+ | `stripePublishableKey` | `string` | Stripe publishable key |
141
+ | `stripeAppearance` | `object` | Passed straight through to Stripe's `elements({ appearance })` |
142
+ | `createPaymentIntent` | `(date: string, guests: number) => Promise<PaymentIntentResult>` | Creates the Stripe PaymentIntent to hold |
143
+ | `book` | `(details: BookingDetails) => Promise<BookResult>` | Submits the reservation |
144
+
145
+ When `stripePublishableKey` / `createPaymentIntent` are not set, the payment section is not rendered, no card hold is taken, and `stripePaymentIntentId` is omitted from `BookingDetails`.
146
+
147
+ ```ts
148
+ interface BookingSlot { time: string; availableTables: number; }
149
+ interface PaymentIntentResult { clientSecret: string; paymentIntentId: string; amountCents: number; }
150
+ interface BookingDetails {
151
+ date: string; startTime: string; timeDisplay: string; partySize: number;
152
+ contactName: string; contactEmail?: string; contactPhone?: string; notes?: string;
153
+ stripePaymentIntentId: string; clerkUserId?: string;
154
+ }
155
+ type BookResult = { ok: true } | { ok: false; status: number; message: string };
156
+ ```
157
+
158
+ #### Events
159
+
160
+ `booking-confirmed` — `CustomEvent<{ date: string; time: string; guests: number }>`, fires once a booking succeeds.
161
+
162
+ #### Theming
163
+
164
+ ```css
165
+ booking-flow {
166
+ --bf-text-color: #1a1a1a;
167
+ --bf-muted-text: rgba(0, 0, 0, 0.6);
168
+ --bf-border-color: rgba(0, 0, 0, 0.15);
169
+ --bf-accent-bg: #1a1a1a;
170
+ --bf-accent-text: #fff;
171
+ --bf-spinner-track: rgba(0, 0, 0, 0.15);
172
+ --bf-spinner-active: #1a1a1a;
173
+ }
174
+ ```
175
+
176
+ ### `<menu-section>`
177
+
178
+ A restaurant menu with category tabs (animated sliding indicator), an info grid, per-item allergen icons with tooltips, and an optional PDF-menu dialog. Two visual variants: the classic centered design, and `variant="panel"` — an eyebrow-above-heading header row with tabs on the right, a full-width boxed info grid, and a bordered item-card grid, rendered statically with instant category switching.
179
+
180
+ ```html
181
+ <script type="module">import '@forgebuild/hotal-ui';</script>
182
+
183
+ <menu-section
184
+ variant="panel"
185
+ title="On the menu"
186
+ eyebrow="ZUMA"
187
+ categories='[{"label":"Bites","items":[{"name":"BBQ Chicken Sliders","description":"Jerk-glazed, brioche bun","price":"$17"}]}]'
188
+ info='[{"label":"Hours","value":"12:00 pm – late"}]'
189
+ ></menu-section>
190
+ ```
191
+
192
+ #### Attributes / properties
193
+
194
+ | Name | Attribute | Type | Default | Description |
195
+ | --- | --- | --- | --- | --- |
196
+ | `title` | `title` | `string` | `'MENU'` | Main heading |
197
+ | `eyebrow` | `eyebrow` | `string` | `''` | Secondary line (above the heading in `panel`, below it otherwise) |
198
+ | `categories` | `categories` (JSON) | `MenuCategory[]` | `[]` | `{ label, items: [{ name, description?, price?, tagline?, group?, country?, allergens? }] }` |
199
+ | `info` | `info` (JSON) | `{label, value}[]` | `[]` | Info rows (hours, dress code, …) |
200
+ | `allergens` | `allergens` (JSON) | `{id, label, icon}[]` | `[]` | Legend resolving item `allergens` ids to icons |
201
+ | `pdfUrl` / `pdfLabel` | `pdf-url` / `pdf-label` | `string` | — | Optional PDF menu opened in a dialog |
202
+ | `headingAlign` | `heading-align` | `'center' \| 'left'` | `'center'` | Classic variant only |
203
+ | `layout` | `layout` | `'stacked' \| 'split'` | `'stacked'` | Classic variant only |
204
+ | `variant` | `variant` | `'default' \| 'panel'` | `'default'` | Visual variant |
205
+ | `theme` | `theme` (JSON) | `object` | `{}` | Color overrides, applied as CSS custom properties |
206
+ | `groupFilter` | `group-filter` | `boolean` | `false` | Pill row filtering the active category by item `group` (e.g. country on a wine list) |
207
+ | `countryFilter` | `country-filter` | `boolean` | `false` | Pill row filtering by item `country` (e.g. region), narrowed to the active group |
208
+ | `groupFilterAllLabel` / `countryFilterAllLabel` | `group-filter-all-label` / `country-filter-all-label` | `string` | `'All'` | Label of the "show everything" pill |
209
+
210
+ The `panel` variant expects the host page to load the Syne and Inter fonts.
211
+
212
+ ### `<room-grid>`
213
+
214
+ A room-type listing: filter tabs, a check-in/check-out range-calendar picker, and room cards (image, name, description, from-price, CTA) with a pile-and-spread entrance animation. Backend-agnostic — the host resolves image URLs and prices and passes plain data.
215
+
216
+ ```html
217
+ <script type="module">
218
+ import '@forgebuild/hotal-ui';
219
+
220
+ const grid = document.querySelector('room-grid');
221
+ // Sync dates with storage / other pages
222
+ grid.checkIn = localStorage.getItem('checkin') ?? '';
223
+ grid.addEventListener('dates-change', (e) => console.log(e.detail)); // { checkIn, checkOut }
224
+ // Pair with <island-map>
225
+ grid.addEventListener('room-hover', (e) => map.highlightPin(e.detail.slug));
226
+ grid.addEventListener('room-leave', (e) => map.unhighlightPin(e.detail.slug));
227
+ </script>
228
+
229
+ <room-grid
230
+ rooms='[{"slug":"beachfront","name":"Beach Front Room","description":"Steps from the shore.","price":1200,"image":"/img/beachfront.jpg","tag":"Beach Front","href":"/accommodations/beachfront"}]'
231
+ filter-tabs='[{"label":"Beach Front","slug":"beachfront"}]'
232
+ currency="USD"
233
+ ></room-grid>
234
+ ```
235
+
236
+ #### Attributes / properties
237
+
238
+ | Name | Attribute | Type | Default | Description |
239
+ | --- | --- | --- | --- | --- |
240
+ | `rooms` | `rooms` (JSON) | `RoomGridRoom[]` | `[]` | `{ slug, name, description?, price?, image?, tag?, href? }` |
241
+ | `filterTabs` | `filter-tabs` (JSON) | `{label, slug}[]` | derived from `tag`s | Explicit tab labels, each matched to one room by slug |
242
+ | `checkIn` / `checkOut` | `check-in` / `check-out` | `string` (ISO) | `''` | Selected dates; settable as JS properties |
243
+ | `currency` | `currency` | `string` | `'USD'` | Suffix on the from-price |
244
+ | `allLabel` | `all-label` | `string` | `'All'` | First tab's label |
245
+
246
+ #### Events
247
+
248
+ - `dates-change` — `CustomEvent<{ checkIn: string; checkOut: string }>` on every calendar pick
249
+ - `room-hover` / `room-leave` — `CustomEvent<{ slug: string }>` on card pointer enter/leave
250
+
251
+ Expects the host page to load the Syne and Inter fonts.
252
+
253
+ ### `<island-map>`
254
+
255
+ A Google Map with branded pop-up pin cards. Self-bootstrapping: reuses whatever Google Maps loader the page already has (`gmpx-api-loader`, `@googlemaps/js-api-loader`, another `island-map`), and injects the official bootstrap itself with `api-key` otherwise. Pins are hidden until highlighted (elastic pop) — pair with `<room-grid>`'s hover events — or shown permanently with `pins-visible`.
256
+
257
+ ```html
258
+ <script type="module">import '@forgebuild/hotal-ui';</script>
259
+
260
+ <island-map
261
+ class="h-[480px] w-full"
262
+ pins='[{"label":"Beach Front Room","sub":"From USD 1,200","lat":18.3465,"lng":-64.5905,"slug":"beachfront"}]'
263
+ center="18.352,-64.591"
264
+ zoom="14"
265
+ map-id="YOUR_CLOUD_MAP_ID"
266
+ api-key="YOUR_MAPS_KEY"
267
+ ></island-map>
268
+ ```
269
+
270
+ #### Attributes / properties
271
+
272
+ | Name | Attribute | Type | Default | Description |
273
+ | --- | --- | --- | --- | --- |
274
+ | `pins` | `pins` (JSON) | `IslandMapPin[]` | `[]` | `{ label, sub?, lat, lng, slug? }` |
275
+ | `center` | `center` | `string` | `'0,0'` | `"lat,lng"` |
276
+ | `zoom` | `zoom` | `number` | `14` | Initial zoom |
277
+ | `mapId` | `map-id` | `string` | `''` | Google Cloud map id (styling) |
278
+ | `apiKey` | `api-key` | `string` | `''` | Used only when no Maps loader is already on the page |
279
+ | `colorScheme` | `color-scheme` | `'' \| 'DARK' \| 'LIGHT' \| 'FOLLOW_SYSTEM'` | `''` | Leave unset with a styled `map-id` — setting it overrides the cloud style with Google's base theme |
280
+ | `fitPins` | `fit-pins` | `boolean` | `true` | Fit the viewport to the pins on load |
281
+ | `fitPadding` / `panY` | `fit-padding` / `pan-y` | `number` | `-50` / `-100` | Fit-bounds padding and post-fit vertical pan |
282
+ | `interactionLock` | `interaction-lock` | `boolean` | `true` | Swallow map gestures until the visitor clicks once |
283
+ | `pinsVisible` | `pins-visible` | `boolean` | `false` | Show all pin cards permanently |
284
+ | `lazy` | `lazy` | `boolean` | `false` | Defer map creation until the element nears the viewport |
285
+ | `zoomControl` | `zoom-control` | `boolean` | `false` | Show the +/− zoom buttons |
286
+
287
+ #### Methods
288
+
289
+ `highlightPin(slug)` / `unhighlightPin(slug)` — pop or hide the pin cards registered under that slug.
290
+
291
+ Default height is `480px` via `:host`; override with a class or inline style on the element.
292
+
293
+ ### `<floor-plan-hero>`
294
+
295
+ Renders a PMS floor plan (`room_type.floor_plan`: `{ rooms, items, w, h }`) as an SVG — zone rectangles with labels/m², furniture assets, optional architectural background. Default form is a hero overlay panel (header row, close button, slide-up transition, starts hidden — position it with host classes and open it from your own trigger); `bare` renders just the plan for embedding in a host-styled container. Zone hover emits events the host can use to swap a hero image.
296
+
297
+ ```html
298
+ <script type="module">
299
+ import '@forgebuild/hotal-ui';
300
+
301
+ const fp = document.querySelector('floor-plan-hero');
302
+ layoutButton.addEventListener('click', () => fp.toggle());
303
+ fp.addEventListener('zone-hover', (e) => { if (e.detail.img) heroImg.src = e.detail.img; });
304
+ fp.addEventListener('zone-leave', () => (heroImg.src = defaultSrc));
305
+ </script>
306
+
307
+ <floor-plan-hero
308
+ class="absolute right-[120px] bottom-[200px] w-[520px]"
309
+ plan='{"w":760,"h":480,"rooms":[{"id":"r1","type":"bedroom","label":"Bedroom","x":60,"y":60,"w":180,"h":140}],"items":[{"id":"i1","asset":"asset4","x":0,"y":0,"w":71,"h":80}]}'
310
+ ></floor-plan-hero>
311
+
312
+ <!-- Chromeless, on a light surface -->
313
+ <floor-plan-hero theme="light" bare plan="..."></floor-plan-hero>
314
+ ```
315
+
316
+ #### Attributes / properties
317
+
318
+ | Name | Attribute | Type | Default | Description |
319
+ | --- | --- | --- | --- | --- |
320
+ | `plan` | `plan` (JSON) | `FloorPlan` | `{}` | `{ rooms: [{id, type?, label?, x, y, w, h, sqm?, imgUrl?}], items: [{id, asset, x, y, w, h}], w?, h? }` |
321
+ | `bgUrl` | `bg-url` | `string` | — | Architectural background image, rendered at 30% opacity |
322
+ | `assetBase` | `asset-base` | `string` | `'/SVG/'` | Item assets resolve to `${assetBase}Asset {n}.svg` (asset key `"asset4"` → `Asset 4.svg`) |
323
+ | `label` / `closeLabel` | `label` / `close-label` | `string` | `'Floor plan'` / `'Close ×'` | Panel header copy |
324
+ | `theme` | `theme` | `'dark' \| 'light'` | `'dark'` | Zone/label palette |
325
+ | `bare` | `bare` | `boolean` | `false` | Plan SVG only — no header, chrome, or open/close state |
326
+
327
+ #### Methods & events
328
+
329
+ `open()` / `close()` / `toggle()` — panel visibility (no-ops in `bare` mode, which is always visible).
330
+ `zone-hover` / `zone-leave` — `CustomEvent<{ img?: string; label?: string; type?: string }>` on zone pointer enter/leave. `panel-close` fires when the internal close button is used.
331
+
332
+ ### `<stay-search>`
333
+
334
+ A hero availability search: a glass check-in/check-out bar, a two-month range calendar, a search status spinner/tick, and a results dropdown of room cards (image, name, description, total for the stay). Results are **joined against the `rooms` prop by PMS room-type id** — anything the search returns that isn't a listed room is dropped, so PMS-side test data never reaches guests. Card imagery and copy come from the host (CMS), rates from the search.
335
+
336
+ ```html
337
+ <script type="module">
338
+ import '@forgebuild/hotal-ui';
339
+
340
+ const search = document.querySelector('stay-search');
341
+ search.checkIn = localStorage.getItem('checkin') ?? '';
342
+ search.searchStays = async (checkIn, checkOut, guests) => {
343
+ const res = await fetch(`/api/ota/availability/search?check_in=${checkIn}&check_out=${checkOut}&guests=${guests}`);
344
+ return res.json(); // [{ room_type_id, from_rate, ... }]
345
+ };
346
+ search.addEventListener('dates-change', (e) => console.log(e.detail));
347
+ </script>
348
+
349
+ <stay-search
350
+ class="block w-full max-w-[680px]"
351
+ rooms='[{"roomTypeId":"92fc4ca8-…","name":"Beach Front Room","description":"Steps from the shore.","image":"/img/beachfront.jpg","href":"/accommodations/beachfront"}]'
352
+ currency="USD"
353
+ ></stay-search>
354
+ ```
355
+
356
+ #### Attributes / properties
357
+
358
+ | Name | Attribute | Type | Default | Description |
359
+ | --- | --- | --- | --- | --- |
360
+ | `rooms` | `rooms` (JSON) | `StaySearchRoom[]` | `[]` | `{ roomTypeId, name, description?, image?, href? }` — the join filter for results |
361
+ | `checkIn` / `checkOut` | `check-in` / `check-out` | `string` (ISO) | `''` | Selected dates; settable as JS properties |
362
+ | `guests` | `guests` | `number` | `2` | Passed to `searchStays` |
363
+ | `currency` | `currency` | `string` | `'USD'` | Shown in the card rate subline |
364
+ | `appendDatesToHref` | `append-dates-to-href` | `boolean` | `true` | Append `?check_in=…&check_out=…` to card hrefs |
365
+ | `searchStays` | — (property only) | `(checkIn, checkOut, guests) => Promise<StaySearchResult[]>` | — | Runs when a complete range is picked; results need `room_type_id` and `from_rate` |
366
+
367
+ #### Events
368
+
369
+ `dates-change` — `CustomEvent<{ checkIn: string; checkOut: string }>` on every pick.
370
+ `search-results` — `CustomEvent<{ checkIn, checkOut, guests, count }>` after each search renders.
371
+
372
+ Expects the host page to load the Syne and Inter fonts.
373
+
374
+ ### `<availability-calendar>`
375
+
376
+ An inline range calendar for a stay: per-day nightly rates under each date (PMS `min_rate`), dates with no availability disabled, teal-gradient range selection. Two months side by side on desktop, one on mobile.
377
+
378
+ ```html
379
+ <script type="module">
380
+ import '@forgebuild/hotal-ui';
381
+
382
+ const cal = document.querySelector('availability-calendar');
383
+ cal.fetchAvailability = async (from, to) => {
384
+ const res = await fetch(`/api/availability?room_type_id=…&from=${from}&to=${to}`);
385
+ return res.json(); // [{ date, available_rooms, min_rate }]
386
+ };
387
+ cal.addEventListener('dates-change', (e) => console.log(e.detail)); // { checkIn, checkOut }
388
+ </script>
389
+
390
+ <availability-calendar></availability-calendar>
391
+ ```
392
+
393
+ | Name | Attribute | Type | Default | Description |
394
+ | --- | --- | --- | --- | --- |
395
+ | `checkIn` / `checkOut` | `check-in` / `check-out` | `string` (ISO) | `''` | Selected range; settable as JS properties |
396
+ | `horizonMonths` | `horizon-months` | `number` | `12` | Availability window requested from the loader |
397
+ | `fetchAvailability` | — (property only) | `(fromISO, toISO) => Promise<AvailabilityDay[]>` | — | Dates absent from the result render as unavailable |
398
+
399
+ Emits `dates-change` on every pick.
400
+
401
+ ### `<booking-card>`
402
+
403
+ The reserve card for a room-type page: from-price header, check-in/check-out fields with a white/teal popup calendar (availability-aware via the same loader contract as `<availability-calendar>`), guest stepper, live price breakdown (subtotal, resort fee, total), Reserve CTA with the green wipe hover, fine print, and a concierge footer.
404
+
405
+ ```html
406
+ <script type="module">
407
+ import '@forgebuild/hotal-ui';
408
+
409
+ const card = document.querySelector('booking-card');
410
+ card.fetchAvailability = async (from, to) => (await fetch(`/api/availability?…`)).json();
411
+ card.addEventListener('dates-change', (e) => console.log(e.detail));
412
+ card.addEventListener('reserve', (e) => {
413
+ const { checkIn, checkOut, guests } = e.detail;
414
+ location.href = `/book?checkin=${checkIn}&checkout=${checkOut}&guests=${guests}`;
415
+ });
416
+ </script>
417
+
418
+ <booking-card class="sticky top-[100px] block" price="3800" max-guests="4" currency="USD"></booking-card>
419
+ ```
420
+
421
+ | Name | Attribute | Type | Default | Description |
422
+ | --- | --- | --- | --- | --- |
423
+ | `price` | `price` | `number` | `0` | Nightly from-rate; the breakdown prices every night at this rate |
424
+ | `maxGuests` | `max-guests` | `number` | `4` | Guest stepper ceiling |
425
+ | `resortFeeRate` | `resort-fee-rate` | `number` | `0.03` | Fee as a fraction of the subtotal |
426
+ | `ratingText` / `finePrint` | `rating-text` / `fine-print` | `string` | — | Header and footer copy |
427
+ | `conciergeTitle` / `conciergeSub` / `conciergeCtaLabel` / `conciergeCtaHref` | kebab-case | `string` | Island Concierge defaults | Footer block |
428
+ | `checkIn` / `checkOut` / `guests` | `check-in` / `check-out` / `guests` | ISO / number | `''` / `2` | Current stay; settable as JS properties |
429
+ | `fetchAvailability` | — (property only) | as above | — | Disables unavailable dates in the popup |
430
+
431
+ Emits `dates-change`, `guests-change`, and `reserve` (`{ checkIn, checkOut, guests }` — navigation is the host's job).
432
+
433
+ ### `room-grid` rows & feature variants
434
+
435
+ `<room-grid variant="rows" rooms='…' currency="USD">` renders the same room data as compact horizontal cards (image left, tag/name/description, price per night right) on a light surface — two columns on desktop, no filter bar, dates, or entrance motion. For "more accommodations" style listings.
436
+
437
+ `<room-grid variant="feature" rooms='…' currency="USD">` renders homepage feature cards: tag badge over the image, whole-card links, a scroll-snap carousel on mobile that becomes a 3-column grid on desktop, and a scroll-triggered fade-up entrance. Emits the same `room-hover`/`room-leave` events — pair with `<island-map>`.
438
+
439
+ ### `<dining-teaser>`
440
+
441
+ A two-column homepage teaser: heading + body copy, a hoverable/focusable list of venues with a colour-coded status dot, and a CTA — beside a cross-fading photo that tracks whichever venue is active. Scroll-triggered fade/slide entrance, skipped under `prefers-reduced-motion`.
442
+
443
+ ```html
444
+ <script type="module">
445
+ import '@forgebuild/hotal-ui';
446
+
447
+ const teaser = document.querySelector('dining-teaser');
448
+ teaser.addEventListener('venue-change', (e) => console.log(e.detail)); // { index, key }
449
+ </script>
450
+
451
+ <dining-teaser
452
+ class="mx-auto max-w-[1530px]"
453
+ heading="Restaurants."
454
+ heading-accent="No compromise."
455
+ body="From open-fire dry-aged steaks to fresh catch and rum cocktails — dining here is never an afterthought."
456
+ cta-label="Explore dining"
457
+ cta-href="/dining"
458
+ venues='[{"key":"beefbar","name":"beefbar","tag":"Dinner · Fine Dining","image":"/img/beefbar.jpg"}]'
459
+ ></dining-teaser>
460
+ ```
461
+
462
+ #### Attributes / properties
463
+
464
+ | Name | Attribute | Type | Default | Description |
465
+ | --- | --- | --- | --- | --- |
466
+ | `venues` | `venues` (JSON) | `{key, name, tag?, image}[]` | `[]` | Rendered in order; hovering, focusing, or clicking a row makes it active |
467
+ | `heading` / `headingAccent` | `heading` / `heading-accent` | `string` | `'Restaurants.'` / `'No compromise.'` | Two-line heading, second line in the accent colour |
468
+ | `body` | `body` | `string` | — | Paragraph under the heading |
469
+ | `ctaLabel` / `ctaHref` | `cta-label` / `cta-href` | `string` | `'Explore dining'` / `'/dining'` | The outlined link below the venue list |
470
+ | `imageLabel` | `image-label` | `string` | — | Fixed photo caption. Omit to have it track the active venue's `name` instead |
471
+ | `activeIndex` | `active-index` | `number` | `0` | Active venue index; settable as a JS property, kept in sync as the visitor interacts |
472
+
473
+ #### Events
474
+
475
+ `venue-change` — `CustomEvent<{ index: number; key: string }>`, fires once on mount and again whenever the active venue changes.
476
+
477
+ #### Theming
478
+
479
+ ```css
480
+ dining-teaser {
481
+ --dt-bg: #f5f2ec;
482
+ --dt-heading-color: #0a0814;
483
+ --dt-accent-color: rgb(180, 20, 80);
484
+ --dt-body-color: rgba(10, 8, 20, 0.65);
485
+ --dt-venue-bg: rgba(10, 8, 20, 0.04);
486
+ --dt-venue-bg-hover: rgba(10, 8, 20, 0.07);
487
+ --dt-venue-border: rgba(10, 8, 20, 0.08);
488
+ --dt-venue-border-hover: rgba(10, 8, 20, 0.14);
489
+ --dt-venue-name-color: #0a0814;
490
+ --dt-venue-type-color: rgba(10, 8, 20, 0.35);
491
+ --dt-dot-color: rgb(155, 93, 229);
492
+ --dt-dot-active-color: rgb(247, 37, 133);
493
+ --dt-cta-border: rgba(10, 8, 20, 0.15);
494
+ --dt-cta-text: #0a0814;
495
+ --dt-cta-hover-bg: #1a1a18;
496
+ --dt-cta-hover-text: #f0eeff;
497
+ --dt-image-height: 480px;
498
+ }
499
+ ```
500
+
501
+ Expects the host page to load the Syne, Inter, and Cormorant Garamond fonts.
502
+
503
+ ### `<booking-checkout>`
504
+
505
+ A checkout form: "booking for myself / someone else" toggle (with a booker sub-form), primary guest details, a country + phone-code pair (built on `<searchable-select>`, cross-synced both ways, plus phone-typed country inference), a Stripe Payment Element, the submit button, and an optional cancellation-policy block. Backend-agnostic — the host creates the PaymentIntent and does the actual booking POST; this component only calls `stripe.confirmPayment` and hands the result to `submitBooking`.
506
+
507
+ ```html
508
+ <script type="module">
509
+ import '@forgebuild/hotal-ui';
510
+
511
+ const checkout = document.querySelector('booking-checkout');
512
+ checkout.resolveInitialLocation = async () => {
513
+ const res = await fetch('https://api.geoapify.com/v1/ipinfo?apiKey=...');
514
+ const data = await res.json();
515
+ return data?.country?.name ? { countryName: data.country.name, iso2: data.country.iso_code } : null;
516
+ };
517
+ checkout.beforeSubmit = async () => {
518
+ // e.g. re-price the PaymentIntent for extras added after it was created
519
+ };
520
+ checkout.submitBooking = async (payload) => {
521
+ const res = await fetch('/api/book', {
522
+ method: 'POST',
523
+ headers: { 'Content-Type': 'application/json' },
524
+ body: JSON.stringify(payload),
525
+ });
526
+ const data = await res.json();
527
+ return data.success ? { ok: true, ref: data.ref } : { ok: false, message: data.error ?? 'Something went wrong.' };
528
+ };
529
+ checkout.addEventListener('checkout-confirmed', (e) => {
530
+ window.location.href = `/book/confirm?ref=${e.detail.ref}`;
531
+ });
532
+ </script>
533
+
534
+ <booking-checkout
535
+ total="3800"
536
+ currency="USD"
537
+ client-secret="pi_..._secret_..."
538
+ stripe-publishable-key="pk_test_..."
539
+ cancellation-policy="Full refund up to 14 days before check-in."
540
+ locations='[{"iso2":"GB","countryName":"United Kingdom","dialCode":"+44"}]'
541
+ default-phone-code="+44"
542
+ ></booking-checkout>
543
+ ```
544
+
545
+ #### Attributes / properties
546
+
547
+ | Name | Attribute | Type | Default | Description |
548
+ | --- | --- | --- | --- | --- |
549
+ | `total` | `total` | `number` | `0` | Drives the submit button's amount label; settable as a JS property, so the host can update it after extras change |
550
+ | `currency` | `currency` | `string` | `'USD'` | Used for the amount label |
551
+ | `clientSecret` | `client-secret` | `string` | — | Stripe PaymentIntent client secret, created server-side by the host |
552
+ | `stripePublishableKey` | `stripe-publishable-key` | `string` | — | Stripe publishable key |
553
+ | `stripeAppearance` | `stripe-appearance` (JSON) | `object` | `{}` | Passed to `stripe.elements({ appearance })` |
554
+ | `cancellationPolicy` | `cancellation-policy` | `string` | — | Shown in a dedicated section when present; omit to hide it entirely |
555
+ | `locations` | `locations` (JSON) | `{iso2, countryName, dialCode}[]` | `[]` | Source for both selects — country options keep every entry, phone-code options are deduplicated by `dialCode` |
556
+ | `defaultPhoneCode` | `default-phone-code` | `string` | `'+1'` | Initial value for both phone-code selects |
557
+ | `allowBookingForOther` | `allow-booking-for-other` | `boolean` | `true` | Hides the toggle and booker sub-form when `false` |
558
+ | `termsHref` | `terms-href` | `string` | `'/terms'` | Terms link and "full policy details" link target |
559
+ | `prefill` | — (property only) | `{firstName?, lastName?, email?}` | — | Settable post-mount (e.g. once an async auth check resolves); only fills fields still empty |
560
+ | `resolveInitialLocation` | — (property only) | `() => Promise<{countryName, iso2?, dialCode?} \| null>` | — | Called once on mount to seed the country/phone-code selects |
561
+ | `beforeSubmit` | — (property only) | `() => Promise<void>` | — | Awaited right before the card is charged |
562
+ | `submitBooking` | — (property only) | `(payload) => Promise<{ok:true,ref?} \| {ok:false,message}>` | — | Called after a successful charge; the component never talks to the booking API itself |
563
+ | `strings` | `strings` (JSON) | `object` | — | Label overrides — see the component source for the full key list |
564
+
565
+ #### Events
566
+
567
+ `checkout-confirmed` — `CustomEvent<{ ref?: string }>`, fires once `submitBooking` resolves `{ ok: true }`. Navigation is the host's job.
568
+
569
+ `ready` — fires once after the initial mount attempt (Stripe configured or not, succeeded or failed) — a hook for dismissing a page-level loading state without racing the Payment Element's own mount.
570
+
571
+ #### Theming
572
+
573
+ ```css
574
+ booking-checkout {
575
+ --bc-text-color: #05040f;
576
+ --bc-muted-text: rgba(5, 4, 15, 0.4);
577
+ --bc-muted-text-strong: rgba(5, 4, 15, 0.55);
578
+ --bc-border-color: rgba(5, 4, 15, 0.15);
579
+ --bc-input-bg: #f5f2ec;
580
+ --bc-input-border: rgba(5, 4, 15, 0.12);
581
+ --bc-input-border-focus: rgba(5, 4, 15, 0.4);
582
+ --bc-accent-bg: #05040f;
583
+ --bc-accent-text: #f5f2ec;
584
+ }
585
+ ```
586
+
587
+ Expects the host page to load the Inter font, and needs `<searchable-select>` registered (it is, automatically, by importing this package).
588
+
589
+ ### `<confirmation-card>`
590
+
591
+ A centered confirmation card: gradient check icon, eyebrow, heading, body copy, an optional booking-reference box, up to two action links, and an optional footer note built around one embedded link. Static — no props are callbacks, no events are emitted. The host owns full-page centering (`display: flex; align-items: center; justify-content: center`) around it.
592
+
593
+ ```html
594
+ <script type="module">import '@forgebuild/hotal-ui';</script>
595
+
596
+ <confirmation-card
597
+ eyebrow="Booking Confirmed"
598
+ heading="You're all set."
599
+ body="Your reservation at HOTAL has been confirmed. A confirmation email is on its way to you."
600
+ reference="d82cdc93-54fe-4f6a-ae77-8fb2dc7ad1a8"
601
+ primary-label="View My Bookings"
602
+ primary-href="/account"
603
+ secondary-label="Back to Home"
604
+ secondary-href="/"
605
+ note-prefix="Questions about your stay?"
606
+ note-link-label="Contact our concierge team"
607
+ note-link-href="/contact"
608
+ note-suffix="— we're here to help with anything you need before you arrive."
609
+ ></confirmation-card>
610
+ ```
611
+
612
+ #### Attributes / properties
613
+
614
+ | Name | Attribute | Type | Default | Description |
615
+ | --- | --- | --- | --- | --- |
616
+ | `eyebrow` | `eyebrow` | `string` | `'Booking Confirmed'` | Line above the heading; omit to hide |
617
+ | `heading` | `heading` | `string` | `"You're all set."` | |
618
+ | `body` | `body` | `string` | — | |
619
+ | `reference` / `referenceLabel` | `reference` / `reference-label` | `string` | `''` / `'Booking Reference'` | The boxed reference number; the box is omitted entirely when `reference` is empty |
620
+ | `primaryLabel` / `primaryHref` | `primary-label` / `primary-href` | `string` | — | Filled button; omitted when `primaryLabel` is empty |
621
+ | `secondaryLabel` / `secondaryHref` | `secondary-label` / `secondary-href` | `string` | — | Outlined button; both must be set to render |
622
+ | `notePrefix` / `noteLinkLabel` / `noteLinkHref` / `noteSuffix` | kebab-case | `string` | — | Footer note text split around one embedded link; omitted entirely when both `notePrefix` and the link are empty |
623
+
624
+ #### Theming
625
+
626
+ ```css
627
+ confirmation-card {
628
+ --cc-card-bg: #fff;
629
+ --cc-border-color: rgba(5, 4, 15, 0.08);
630
+ --cc-icon-bg: linear-gradient(135deg, rgb(0, 245, 212) 0%, rgb(79, 142, 247) 100%);
631
+ --cc-text-color: #05040f;
632
+ --cc-muted-text: rgba(5, 4, 15, 0.4);
633
+ --cc-muted-text-strong: rgba(5, 4, 15, 0.55);
634
+ --cc-ref-bg: rgba(5, 4, 15, 0.03);
635
+ --cc-accent-bg: #05040f;
636
+ --cc-accent-text: #fff;
637
+ --cc-note-border: rgba(5, 4, 15, 0.06);
638
+ --cc-max-width: 560px;
639
+ }
640
+ ```
641
+
642
+ Expects the host page to load the Syne and Inter fonts. Not to be confused with `<booking-confirmed>` above — that one is a compact inline step meant for a checkout *modal* (icon + heading + a "Done" button that emits an event back to the host); this one is a standalone full-page card with no host callback, built for a dedicated `/confirm`-style route.
643
+
644
+ ## Development
645
+
646
+ ```
647
+ npm install
648
+ npm run dev # serves demo/
649
+ npm run check # type-check
650
+ npm run build # library build → dist/
651
+ ```
652
+
653
+ ## Publishing
654
+
655
+ ```
656
+ npm run build
657
+ npm publish
658
+ ```
659
+
660
+ Requires being logged in to npm (`npm whoami`) as an account with publish rights to the `@forgebuild` scope.