@forgebuild/hotal-ui
Reusable Web Components for booking and reservation experiences.
Install
npm install @forgebuild/hotal-ui
No peer dependencies — the Svelte runtime is bundled in. Works via a plain <script type="module"> tag too:
<script type="module" src="https://esm.sh/@forgebuild/hotal-ui"></script>
Components
<reservation-bar>
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.
<script type="module">
import '@forgebuild/hotal-ui';
const bar = document.querySelector('reservation-bar');
// Function-valued and explicitly-false-when-default-is-true props must be
// set as JS properties, not HTML attributes (attributes are strings only,
// and HTML boolean-attribute semantics can't express "false" when the
// prop's own default is true).
bar.showReserveButton = false;
bar.fetchAvailableDates = async (partySize) => {
const res = await fetch(`/api/available-dates?party_size=${partySize}`);
const { dates } = await res.json();
return dates;
};
bar.addEventListener('reservationchange', (e) => console.log(e.detail));
</script>
<reservation-bar reserve-href="/reservations" sync-with-url closed-weekdays="[0]"></reservation-bar>
Attributes / properties
| Name | Attribute | Type | Default | Description |
|---|---|---|---|---|
minGuests | min-guests | number | 1 | Minimum selectable party size |
maxGuests | max-guests | number | 20 | Maximum selectable party size |
defaultGuests | default-guests | number | 2 | Initial party size |
timeSlots | time-slots (JSON array) | string[] | '7:00 PM'–'11:00 PM' in 30-min steps | Selectable time options |
defaultTime | default-time | string | '7:00 PM' | Initial time |
showReserveButton | show-reserve-button | boolean | true | Whether the Reserve link is rendered. Set as a JS property to get false — see note above. |
reserveHref | reserve-href | string | '/reservations' | Base URL the built query string is appended to |
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. |
syncWithUrl | sync-with-url | boolean | false | Read initial date/guests/time from location.search on mount |
closedWeekdays | closed-weekdays (JSON array) | number[] | [] | Weekdays (0=Sun–6=Sat) to disable in the calendar |
fetchAvailableDates | — (property only) | (partySize: number) => Promise<string[]> | undefined | Drives which calendar dates are selectable |
Events
reservationchange — CustomEvent<{ date: string; guests: number; time: string }>, fires whenever the selection changes.
Theming
reservation-bar {
--rb-bar-bg: rgba(14, 58, 62, 0.9);
--rb-bar-border: rgba(212, 154, 60, 0.4);
--rb-text-color: #fff;
--rb-accent-bg: #d49a3c;
--rb-accent-hover-bg: #e0ac57;
--rb-trigger-hover-bg: rgba(212, 154, 60, 0.15);
--rb-panel-bg: rgba(10, 10, 12, 0.92);
--rb-radius: 2px;
}
<booking-flow>
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.
<script type="module">
import '@forgebuild/hotal-ui';
const flow = document.querySelector('booking-flow');
flow.closedWeekdays = [0];
flow.fetchAvailableDates = async (partySize) => {
const res = await fetch(`/api/available-dates?party_size=${partySize}`);
const { dates } = await res.json();
return dates;
};
flow.searchSlots = async (date, guests) => {
const res = await fetch(`/api/search?date=${date}&party_size=${guests}`);
const { slots } = await res.json();
return slots;
};
flow.stripePublishableKey = 'pk_test_...';
flow.stripeAppearance = { theme: 'stripe' };
flow.createPaymentIntent = async (date, guests) => {
const res = await fetch('/api/create-payment-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date, party_size: guests }),
});
return res.json();
};
flow.book = async (details) => {
const res = await fetch('/api/book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(details),
});
return res.status === 201 ? { ok: true } : { ok: false, status: res.status, message: 'Something went wrong.' };
};
flow.date = '2026-08-20';
flow.guests = 2;
flow.addEventListener('booking-confirmed', (e) => console.log(e.detail));
</script>
<booking-flow></booking-flow>
Properties (all JS-only — no HTML attributes, since every property here is either dynamic state or a callback)
| Name | Type | Description |
|---|---|---|
date | string | Current search date (ISO) |
guests | number | Current party size |
preferredTime | string | Used to pre-select the closest available slot |
closedWeekdays | number[] | Same convention as reservation-bar: 0=Sun–6=Sat, for the empty-state calendar |
fetchAvailableDates | (partySize: number) => Promise<string[]> | Drives the empty-state date picker: up to 14 open dates show as pills (e.g. a stay's days), more as a calendar |
searchSlots | (date: string, guests: number) => Promise<BookingSlot[]> | Returns available time slots |
stripePublishableKey | string | Stripe publishable key |
stripeAppearance | object | Passed straight through to Stripe's elements({ appearance }) |
createPaymentIntent | (date: string, guests: number) => Promise<PaymentIntentResult> | Creates the Stripe PaymentIntent to hold |
book | (details: BookingDetails) => Promise<BookResult> | Submits the reservation |
When stripePublishableKey / createPaymentIntent are not set, the payment section is not rendered, no card hold is taken, and stripePaymentIntentId is omitted from BookingDetails.
interface BookingSlot { time: string; availableTables: number; }
interface PaymentIntentResult { clientSecret: string; paymentIntentId: string; amountCents: number; }
interface BookingDetails {
date: string; startTime: string; timeDisplay: string; partySize: number;
contactName: string; contactEmail?: string; contactPhone?: string; notes?: string;
stripePaymentIntentId: string; clerkUserId?: string;
}
type BookResult = { ok: true } | { ok: false; status: number; message: string };
Events
booking-confirmed — CustomEvent<{ date: string; time: string; guests: number }>, fires once a booking succeeds.
Theming
booking-flow {
--bf-text-color: #1a1a1a;
--bf-muted-text: rgba(0, 0, 0, 0.6);
--bf-border-color: rgba(0, 0, 0, 0.15);
--bf-accent-bg: #1a1a1a;
--bf-accent-text: #fff;
--bf-spinner-track: rgba(0, 0, 0, 0.15);
--bf-spinner-active: #1a1a1a;
}
<menu-section>
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.
<script type="module">import '@forgebuild/hotal-ui';</script>
<menu-section
variant="panel"
title="On the menu"
eyebrow="ZUMA"
categories='[{"label":"Bites","items":[{"name":"BBQ Chicken Sliders","description":"Jerk-glazed, brioche bun","price":"$17"}]}]'
info='[{"label":"Hours","value":"12:00 pm – late"}]'
></menu-section>
Attributes / properties
| Name | Attribute | Type | Default | Description |
|---|---|---|---|---|
title | title | string | 'MENU' | Main heading |
eyebrow | eyebrow | string | '' | Secondary line (above the heading in panel, below it otherwise) |
categories | categories (JSON) | MenuCategory[] | [] | { label, aside?, note?, items: [{ name, description?, price?, tagline?, group?, country?, allergens?, badges?: [{ label, tone?: 'accent' }], notes?: string[] }] } |
info | info (JSON) | {label, value}[] | [] | Info rows (hours, dress code, …) |
allergens | allergens (JSON) | {id, label, icon}[] | [] | Legend resolving item allergens ids to icons |
pdfUrl / pdfLabel | pdf-url / pdf-label | string | — | Optional PDF menu opened in a dialog |
headingAlign | heading-align | 'center' | 'left' | 'center' | Classic variant only |
layout | layout | 'stacked' | 'split' | 'stacked' | Classic variant only |
variant | variant | 'default' | 'panel' | 'default' | Visual variant |
theme | theme (JSON) | object | {} | Color overrides, applied as CSS custom properties |
groupFilter | group-filter | boolean | false | Pill row filtering the active category by item group (e.g. country on a wine list) |
countryFilter | country-filter | boolean | false | Pill row filtering by item country (e.g. region), narrowed to the active group |
groupFilterAllLabel / countryFilterAllLabel | group-filter-all-label / country-filter-all-label | string | 'All' | Label of the "show everything" pill |
footnote | footnote | string | '' | Small print under the panels |
headingLevel | heading-level | 1 | 2 | 3 | h1 (classic), h2 (panel) | Heading level; set 2 on a page with its own h1 |
prerendered | prerendered | boolean | false | Set by renderMenuSection(); skips entrance motion |
Slots: heading-title, eyebrow, info, and intro (light-DOM content under the heading, above the tabs).
Prerendering
@forgebuild/hotal-ui/server renders the element at build time into a declarative shadow root, so the menu is in the page's HTML and does not move when the script arrives (the script then only adds tab switching):
import { renderMenuSection } from '@forgebuild/hotal-ui/server';
const { attributes, shadowRoot } = renderMenuSection({ title: 'Menu', headingLevel: 2, categories });
// <menu-section {...attributes}>{shadowRoot}<span slot="heading-title">Menu</span></menu-section>
attributes are unescaped (let the template escape them) and include prerendered. title also becomes the host's native title attribute, which browsers show as a tooltip over the whole element; a host that slots its own heading-title can leave it unset (the tab list is named by the heading either way). Load the element's script (@forgebuild/hotal-ui/menu-section) whenever suits; the prerendered content stands on its own until then. Set colours with the --ms-* custom properties in CSS, not the theme property: theme is applied by script, so a prerendered menu would flash from the defaults. A page swapped in by script (e.g. Astro's ClientRouter) does not parse declarative shadow roots; do a full page load.
The panel variant expects the host page to load the Syne and Inter fonts.
<room-grid>
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.
<script type="module">
import '@forgebuild/hotal-ui';
const grid = document.querySelector('room-grid');
// Sync dates with storage / other pages
grid.checkIn = localStorage.getItem('checkin') ?? '';
grid.addEventListener('dates-change', (e) => console.log(e.detail)); // { checkIn, checkOut }
// Pair with <island-map>
grid.addEventListener('room-hover', (e) => map.highlightPin(e.detail.slug));
grid.addEventListener('room-leave', (e) => map.unhighlightPin(e.detail.slug));
</script>
<room-grid
rooms='[{"slug":"beachfront","name":"Beach Front Room","description":"Steps from the shore.","price":1200,"image":"/img/beachfront.jpg","tag":"Beach Front","href":"/accommodations/beachfront"}]'
filter-tabs='[{"label":"Beach Front","slug":"beachfront"}]'
currency="USD"
></room-grid>
Attributes / properties
| Name | Attribute | Type | Default | Description |
|---|---|---|---|---|
rooms | rooms (JSON) | RoomGridRoom[] | [] | { slug, name, description?, price?, image?, tag?, href? } |
filterTabs | filter-tabs (JSON) | {label, slug}[] | derived from tags | Explicit tab labels, each matched to one room by slug |
checkIn / checkOut | check-in / check-out | string (ISO) | '' | Selected dates; settable as JS properties |
currency | currency | string | 'USD' | Suffix on the from-price |
allLabel | all-label | string | 'All' | First tab's label |
Events
dates-change—CustomEvent<{ checkIn: string; checkOut: string }>on every calendar pickroom-hover/room-leave—CustomEvent<{ slug: string }>on card pointer enter/leave
Expects the host page to load the Syne and Inter fonts.
<island-map>
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.
<script type="module">import '@forgebuild/hotal-ui';</script>
<island-map
class="h-[480px] w-full"
pins='[{"label":"Beach Front Room","sub":"From USD 1,200","lat":18.3465,"lng":-64.5905,"slug":"beachfront"}]'
center="18.352,-64.591"
zoom="14"
map-id="YOUR_CLOUD_MAP_ID"
api-key="YOUR_MAPS_KEY"
></island-map>
Attributes / properties
| Name | Attribute | Type | Default | Description |
|---|---|---|---|---|
pins | pins (JSON) | IslandMapPin[] | [] | { label, sub?, lat, lng, slug? } |
center | center | string | '0,0' | "lat,lng" |
zoom | zoom | number | 14 | Initial zoom |
mapId | map-id | string | '' | Google Cloud map id (styling) |
apiKey | api-key | string | '' | Used only when no Maps loader is already on the page |
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 |
fitPins | fit-pins | boolean | true | Fit the viewport to the pins on load |
fitPadding / panY | fit-padding / pan-y | number | -50 / -100 | Fit-bounds padding and post-fit vertical pan |
interactionLock | interaction-lock | boolean | true | Swallow map gestures until the visitor clicks once |
pinsVisible | pins-visible | boolean | false | Show all pin cards permanently |
lazy | lazy | boolean | false | Defer map creation until the element nears the viewport |
zoomControl | zoom-control | boolean | false | Show the +/− zoom buttons |
Methods
highlightPin(slug) / unhighlightPin(slug) — pop or hide the pin cards registered under that slug.
Default height is 480px via :host; override with a class or inline style on the element.
<floor-plan-hero>
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.
<script type="module">
import '@forgebuild/hotal-ui';
const fp = document.querySelector('floor-plan-hero');
layoutButton.addEventListener('click', () => fp.toggle());
fp.addEventListener('zone-hover', (e) => { if (e.detail.img) heroImg.src = e.detail.img; });
fp.addEventListener('zone-leave', () => (heroImg.src = defaultSrc));
</script>
<floor-plan-hero
class="absolute right-[120px] bottom-[200px] w-[520px]"
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}]}'
></floor-plan-hero>
<!-- Chromeless, on a light surface -->
<floor-plan-hero theme="light" bare plan="..."></floor-plan-hero>
Attributes / properties
| Name | Attribute | Type | Default | Description |
|---|---|---|---|---|
plan | plan (JSON) | FloorPlan | {} | { rooms: [{id, type?, label?, x, y, w, h, sqm?, imgUrl?}], items: [{id, asset, x, y, w, h}], w?, h? } |
bgUrl | bg-url | string | — | Architectural background image, rendered at 30% opacity |
assetBase | asset-base | string | '/SVG/' | Item assets resolve to ${assetBase}Asset {n}.svg (asset key "asset4" → Asset 4.svg) |
label / closeLabel | label / close-label | string | 'Floor plan' / 'Close ×' | Panel header copy |
theme | theme | 'dark' | 'light' | 'dark' | Zone/label palette |
bare | bare | boolean | false | Plan SVG only — no header, chrome, or open/close state |
Methods & events
open() / close() / toggle() — panel visibility (no-ops in bare mode, which is always visible).
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.
<stay-search>
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.
<script type="module">
import '@forgebuild/hotal-ui';
const search = document.querySelector('stay-search');
search.checkIn = localStorage.getItem('checkin') ?? '';
search.searchStays = async (checkIn, checkOut, guests) => {
const res = await fetch(`/api/ota/availability/search?check_in=${checkIn}&check_out=${checkOut}&guests=${guests}`);
return res.json(); // [{ room_type_id, from_rate, ... }]
};
search.addEventListener('dates-change', (e) => console.log(e.detail));
</script>
<stay-search
class="block w-full max-w-[680px]"
rooms='[{"roomTypeId":"92fc4ca8-…","name":"Beach Front Room","description":"Steps from the shore.","image":"/img/beachfront.jpg","href":"/accommodations/beachfront"}]'
currency="USD"
></stay-search>
Attributes / properties
| Name | Attribute | Type | Default | Description |
|---|---|---|---|---|
rooms | rooms (JSON) | StaySearchRoom[] | [] | { roomTypeId, name, description?, image?, href? } — the join filter for results |
checkIn / checkOut | check-in / check-out | string (ISO) | '' | Selected dates; settable as JS properties |
guests | guests | number | 2 | Passed to searchStays |
currency | currency | string | 'USD' | ISO 4217 code; card totals are formatted in it |
locale | locale | string | browser | Dates, month/weekday names and price formatting |
strings | strings (JSON) | object | English | Interface text: checkIn, checkOut, addDate, guests, fewerGuests, moreGuests, chooseDates, previousMonth, nextMonth, loadError, none, availableOne, availableMany, totalOne, totalMany ({n} = nights) |
appendDatesToHref | append-dates-to-href | boolean | true | Append ?check_in=…&check_out=… to card hrefs |
showGuests | show-guests | boolean | false | Show a guests stepper; changing it re-runs the search |
showRooms | show-rooms | boolean | false | Show a "Guests & rooms" field instead: adults and children per room, add / remove room (up to max-rooms, default 4) |
searchStays | — (property only) | (checkIn, checkOut, guests, rooms) => Promise<StaySearchResult[]> | — | Runs when a complete range is picked; results need room_type_id and from_rate. rooms is {adults, children}[], one per room |
Events
dates-change — CustomEvent<{ checkIn: string; checkOut: string }> on every pick.
search-results — CustomEvent<{ checkIn, checkOut, guests, count }> after each search renders.
Expects the host page to load the Syne and Inter fonts.
<price-grid>
A price-finder table: one row per hotel or room type, one column per night, the nightly price in each cell. Arrows step the window (never before today), a native date input jumps to any date, and every column header sorts the rows. Each price links to the row's href with the one-night stay handoff appended (?room=<id>&check_in=…&check_out=…&guests=…), or is a button when the row has no href. On narrow screens the table scrolls sideways with the name column pinned.
<price-grid currency="GBP" days="7">
<strong slot="heading">Best prices this week</strong>
</price-grid>
<script type="module">
import '@forgebuild/hotal-ui/price-grid';
const grid = document.querySelector('price-grid');
grid.rows = [{ id: '92fc4ca8-…', name: 'Beach Front Room', meta: 'Sleeps 4', href: '/book' }];
grid.fetchRates = async (from, to) => (await fetch(`/api/rates?from=${from}&to=${to}`)).json(); // [{ id, date, rate, available? }]
grid.addEventListener('rate-select', (e) => console.log(e.detail)); // { id, date, rate }
</script>
Attributes / properties
| Name | Attribute | Type | Default | Description |
|---|---|---|---|---|
rows | rows (JSON) | PriceGridRow[] | [] | { id, name, meta?, href? } in display order; meta fills an optional second column |
startDate | start-date | string (ISO) | today | First night shown; clamped to today |
days | days | number | 7 | Nights shown at once and the step of the arrows (1-31) |
guests | guests | number | 2 | Carried into the handoff on price links |
currency | currency | string | 'USD' | ISO 4217 code |
locale | locale | string | browser | Date and price formatting |
strings | strings (JSON) | object | English | Interface text: caption, name, meta, from, previous, next, sortBy ({x} = column), unavailable, loadError, retry |
fetchRates | — (property only) | (fromISO, toISO) => Promise<PriceGridRate[]> | — | Both dates inclusive; called once per window shown. A night that is absent, has no rate, or has available 0/false shows as unavailable. room replaces the row id in the handoff when the row aggregates several things (a hotel's cheapest room that night) |
Events
rate-select — CustomEvent<{ id: string; date: string; rate: number; room: string }> when a price is clicked (room = the rate's room, else the row id).
window-change — CustomEvent<{ from: string; to: string }> when the window moves.
Slot heading sits left of the date controls. Themed with --pg-* custom properties (full list in llms.txt); inherits the host page's font.
<availability-calendar>
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.
<script type="module">
import '@forgebuild/hotal-ui';
const cal = document.querySelector('availability-calendar');
cal.fetchAvailability = async (from, to) => {
const res = await fetch(`/api/availability?room_type_id=…&from=${from}&to=${to}`);
return res.json(); // [{ date, available_rooms, min_rate }]
};
cal.addEventListener('dates-change', (e) => console.log(e.detail)); // { checkIn, checkOut }
</script>
<availability-calendar></availability-calendar>
| Name | Attribute | Type | Default | Description |
|---|---|---|---|---|
checkIn / checkOut | check-in / check-out | string (ISO) | '' | Selected range; settable as JS properties |
horizonMonths | horizon-months | number | 12 | Availability window requested from the loader |
fetchAvailability | — (property only) | (fromISO, toISO) => Promise<AvailabilityDay[]> | — | Dates absent from the result render as unavailable |
Emits dates-change on every pick.
<booking-card>
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.
<script type="module">
import '@forgebuild/hotal-ui';
const card = document.querySelector('booking-card');
card.fetchAvailability = async (from, to) => (await fetch(`/api/availability?…`)).json();
card.addEventListener('dates-change', (e) => console.log(e.detail));
card.addEventListener('reserve', (e) => {
const { checkIn, checkOut, guests } = e.detail;
location.href = `/book?checkin=${checkIn}&checkout=${checkOut}&guests=${guests}`;
});
</script>
<booking-card class="sticky top-[100px] block" price="3800" max-guests="4" currency="USD"></booking-card>
| Name | Attribute | Type | Default | Description |
|---|---|---|---|---|
price | price | number | 0 | Nightly from-rate; the breakdown prices every night at this rate |
maxGuests | max-guests | number | 4 | Guest stepper ceiling |
resortFeeRate | resort-fee-rate | number | 0.03 | Fee as a fraction of the subtotal |
ratingText / finePrint | rating-text / fine-print | string | — | Header and footer copy |
conciergeTitle / conciergeSub / conciergeCtaLabel / conciergeCtaHref | kebab-case | string | Island Concierge defaults | Footer block |
checkIn / checkOut / guests | check-in / check-out / guests | ISO / number | '' / 2 | Current stay; settable as JS properties |
fetchAvailability | — (property only) | as above | — | Disables unavailable dates in the popup |
Emits dates-change, guests-change, and reserve ({ checkIn, checkOut, guests } — navigation is the host's job).
<stay-booking-flow>
A stay booking page for one room, or several booked together (&rooms=<room>:<adults>:<children>,… in the URL, passed to your callbacks as selection.rooms): optional Extras step, then details & payment, then confirmation. Reads room, check_in, check_out, guests from the URL (or the matching attributes, which win) and shows a start-again link to start-href if they're missing, invalid, or room isn't in rooms. All prices come from your quote callback, re-called (debounced, latest response wins) when guests or extras change.
<stay-booking-flow currency="USD" start-href="/rooms"></stay-booking-flow>
<script type="module">
import '@forgebuild/hotal-ui';
const flow = document.querySelector('stay-booking-flow');
flow.rooms = [{ id: 'junior-suite', name: 'Junior Suite', image: '/img/junior.jpg', maxGuests: 3 }];
flow.extras = [{ id: 'spa-1', name: 'Couples Massage', category: 'Wellness', price: 220 }];
flow.quote = async (sel) => (await fetch('/api/quote', { method: 'POST', body: JSON.stringify(sel) })).json(); // { lines, total, currency }
flow.stripePublishableKey = 'pk_live_…';
// Server: recalculate the amount from `sel` (never trust a client total) and create the intent with
// automatic_payment_methods: { enabled: true, allow_redirects: 'never' }.
flow.createPaymentIntent = async (sel) => (await fetch('/api/stay-intent', { method: 'POST', body: JSON.stringify(sel) })).json(); // { clientSecret }
flow.submitBooking = async (payload) => (await fetch('/api/stay-book', { method: 'POST', body: JSON.stringify(payload) })).json(); // { ok: true, ref }
flow.addEventListener('booking-confirmed', (e) => console.log(e.detail)); // { ref, selection, total }
</script>
Props: room, checkIn/check-in, checkOut/check-out, guests, rooms, extras, categories, fallbackCategory, currency, startHref/start-href, quote, createPaymentIntent, submitBooking (JS properties only), strings, plus booking-checkout pass-throughs (stripePublishableKey, stripeAppearance, termsHref, cancellationPolicy, locations, allowBookingForOther, showBookingFor, prefill, resolveInitialLocation), and branding pass-throughs to the nested summary and progress bar (resortName, logoSrc, logoHref, secureLabel, needHelpHref). createPaymentIntent may also return the intent's total; a mismatch with the quote re-quotes. Emits step-change ({ step }), quote-change ({ quote }) and booking-confirmed ({ ref, selection, total }). Browser back moves between steps; the step and extras survive a refresh. After confirmation, back can't reopen payment and a refresh shows the confirmed screen. If payment succeeds but submitBooking fails, the flow stays locked on a "Try again" that re-sends the same payload (so make submitBooking idempotent on paymentIntentId), surviving a refresh. Full reference in llms.txt.
room-grid rows & feature variants
<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.
<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>.
<dining-teaser>
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.
<script type="module">
import '@forgebuild/hotal-ui';
const teaser = document.querySelector('dining-teaser');
teaser.addEventListener('venue-change', (e) => console.log(e.detail)); // { index, key }
</script>
<dining-teaser
class="mx-auto max-w-[1530px]"
heading="Restaurants."
heading-accent="No compromise."
body="From open-fire dry-aged steaks to fresh catch and rum cocktails — dining here is never an afterthought."
cta-label="Explore dining"
cta-href="/dining"
venues='[{"key":"beefbar","name":"beefbar","tag":"Dinner · Fine Dining","image":"/img/beefbar.jpg"}]'
></dining-teaser>
Attributes / properties
| Name | Attribute | Type | Default | Description |
|---|---|---|---|---|
venues | venues (JSON) | {key, name, tag?, image}[] | [] | Rendered in order; hovering, focusing, or clicking a row makes it active |
heading / headingAccent | heading / heading-accent | string | 'Restaurants.' / 'No compromise.' | Two-line heading, second line in the accent colour |
body | body | string | — | Paragraph under the heading |
ctaLabel / ctaHref | cta-label / cta-href | string | 'Explore dining' / '/dining' | The outlined link below the venue list |
imageLabel | image-label | string | — | Fixed photo caption. Omit to have it track the active venue's name instead |
activeIndex | active-index | number | 0 | Active venue index; settable as a JS property, kept in sync as the visitor interacts |
Events
venue-change — CustomEvent<{ index: number; key: string }>, fires once on mount and again whenever the active venue changes.
Theming
dining-teaser {
--dt-bg: #f5f2ec;
--dt-heading-color: #0a0814;
--dt-accent-color: rgb(180, 20, 80);
--dt-body-color: rgba(10, 8, 20, 0.65);
--dt-venue-bg: rgba(10, 8, 20, 0.04);
--dt-venue-bg-hover: rgba(10, 8, 20, 0.07);
--dt-venue-border: rgba(10, 8, 20, 0.08);
--dt-venue-border-hover: rgba(10, 8, 20, 0.14);
--dt-venue-name-color: #0a0814;
--dt-venue-type-color: rgba(10, 8, 20, 0.35);
--dt-dot-color: rgb(155, 93, 229);
--dt-dot-active-color: rgb(247, 37, 133);
--dt-cta-border: rgba(10, 8, 20, 0.15);
--dt-cta-text: #0a0814;
--dt-cta-hover-bg: #1a1a18;
--dt-cta-hover-text: #f0eeff;
--dt-image-height: 480px;
}
Expects the host page to load the Syne, Inter, and Cormorant Garamond fonts.
<booking-checkout>
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.
<script type="module">
import '@forgebuild/hotal-ui';
const checkout = document.querySelector('booking-checkout');
checkout.resolveInitialLocation = async () => {
const res = await fetch('https://api.geoapify.com/v1/ipinfo?apiKey=...');
const data = await res.json();
return data?.country?.name ? { countryName: data.country.name, iso2: data.country.iso_code } : null;
};
checkout.beforeSubmit = async () => {
// e.g. re-price the PaymentIntent for extras added after it was created
};
checkout.submitBooking = async (payload) => {
const res = await fetch('/api/book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
return data.success ? { ok: true, ref: data.ref } : { ok: false, message: data.error ?? 'Something went wrong.' };
};
checkout.addEventListener('checkout-confirmed', (e) => {
window.location.href = `/book/confirm?ref=${e.detail.ref}`;
});
</script>
<booking-checkout
total="3800"
currency="USD"
client-secret="pi_..._secret_..."
stripe-publishable-key="pk_test_..."
cancellation-policy="Full refund up to 14 days before check-in."
locations='[{"iso2":"GB","countryName":"United Kingdom","dialCode":"+44"}]'
default-phone-code="+44"
></booking-checkout>
Attributes / properties
| Name | Attribute | Type | Default | Description |
|---|---|---|---|---|
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 |
currency | currency | string | 'USD' | Used for the amount label |
clientSecret | client-secret | string | — | Stripe PaymentIntent client secret, created server-side by the host |
stripePublishableKey | stripe-publishable-key | string | — | Stripe publishable key |
stripeAppearance | stripe-appearance (JSON) | object | {} | Passed to stripe.elements({ appearance }) |
cancellationPolicy | cancellation-policy | string | — | Shown in a dedicated section when present; omit to hide it entirely |
locations | locations (JSON) | {iso2, countryName, dialCode}[] | every country | Source for both selects (empty = every country, named in the browser's language) — country options keep every entry, phone-code options are deduplicated by dialCode |
defaultPhoneCode | default-phone-code | string | '+1' | Initial value for both phone-code selects |
allowBookingForOther | allow-booking-for-other | boolean | true | Hides the toggle and booker sub-form when false |
termsHref | terms-href | string | '/terms' | Terms link and "full policy details" link target |
prefill | — (property only) | {firstName?, lastName?, email?} | — | Settable post-mount (e.g. once an async auth check resolves); only fills fields still empty |
resolveInitialLocation | — (property only) | () => Promise<{countryName, iso2?, dialCode?} | null> | — | Called once on mount to seed the country/phone-code selects |
beforeSubmit | — (property only) | () => Promise<void> | — | Awaited right before the card is charged |
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 |
strings | strings (JSON) | object | — | Label overrides — see the component source for the full key list |
Events
checkout-confirmed — CustomEvent<{ ref?: string }>, fires once submitBooking resolves { ok: true }. Navigation is the host's job. submitting-change — CustomEvent<{ submitting: boolean }>, true from the pay click (after validation) until the attempt finishes.
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.
Theming
booking-checkout {
--bc-text-color: #05040f;
--bc-muted-text: rgba(5, 4, 15, 0.4);
--bc-muted-text-strong: rgba(5, 4, 15, 0.55);
--bc-border-color: rgba(5, 4, 15, 0.15);
--bc-input-bg: #f5f2ec;
--bc-input-border: rgba(5, 4, 15, 0.12);
--bc-input-border-focus: rgba(5, 4, 15, 0.4);
--bc-accent-bg: #05040f;
--bc-accent-text: #f5f2ec;
}
Expects the host page to load the Inter font, and needs <searchable-select> registered (it is, automatically, by importing this package).
<confirmation-card>
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.
<script type="module">import '@forgebuild/hotal-ui';</script>
<confirmation-card
eyebrow="Booking Confirmed"
heading="You're all set."
body="Your reservation at HOTAL has been confirmed. A confirmation email is on its way to you."
reference="d82cdc93-54fe-4f6a-ae77-8fb2dc7ad1a8"
primary-label="View My Bookings"
primary-href="/account"
secondary-label="Back to Home"
secondary-href="/"
note-prefix="Questions about your stay?"
note-link-label="Contact our concierge team"
note-link-href="/contact"
note-suffix="— we're here to help with anything you need before you arrive."
></confirmation-card>
Attributes / properties
| Name | Attribute | Type | Default | Description |
|---|---|---|---|---|
eyebrow | eyebrow | string | 'Booking Confirmed' | Line above the heading; omit to hide |
heading | heading | string | "You're all set." | |
body | body | string | — | |
reference / referenceLabel | reference / reference-label | string | '' / 'Booking Reference' | The boxed reference number; the box is omitted entirely when reference is empty |
primaryLabel / primaryHref | primary-label / primary-href | string | — | Filled button; omitted when primaryLabel is empty |
secondaryLabel / secondaryHref | secondary-label / secondary-href | string | — | Outlined button; both must be set to render |
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 |
Theming
confirmation-card {
--cc-card-bg: #fff;
--cc-border-color: rgba(5, 4, 15, 0.08);
--cc-icon-bg: linear-gradient(135deg, rgb(0, 245, 212) 0%, rgb(79, 142, 247) 100%);
--cc-text-color: #05040f;
--cc-muted-text: rgba(5, 4, 15, 0.4);
--cc-muted-text-strong: rgba(5, 4, 15, 0.55);
--cc-ref-bg: rgba(5, 4, 15, 0.03);
--cc-accent-bg: #05040f;
--cc-accent-text: #fff;
--cc-note-border: rgba(5, 4, 15, 0.06);
--cc-max-width: 560px;
}
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.
<menu-items-editor>
Staff-side editor for a restaurant's menu items, grouped by category. Each field saves on its own through saveItem, which is applied optimistically and undone if it rejects. Allergen and language chips expand the row for allergen toggles and per-language name/description. Each row's "On" switch saves { sold_out }. All the callbacks are JS properties only.
<menu-items-editor></menu-items-editor>
<script type="module">
const el = document.querySelector('menu-items-editor');
el.items = menuItems; // [{ id, name, description, category, price, allergens, translations, sold_out }]
el.saveItem = (item, patch) => api.put(`/menu/${item.id}`, patch); // reject to undo + flag
el.createItem = (fields) => api.post('/menu', fields); // omit to hide add buttons
el.deleteItem = async (item) => (confirm(`Delete ${item.name}?`) ? api.delete(`/menu/${item.id}`) : false);
el.reorderItems = (items) => api.put('/menu/reorder', { ids: items.map((i) => i.id) }); // one category, first to last
el.reorderCategories = (categories) => api.put('/menu/category-order', { categories }); // omit either to hide its drag handles and move buttons
</script>
Theme with --mie-* (see llms.txt). The defaults derive from the inherited text color, so it works on light and dark pages as-is.
Development
npm install
npm run dev # serves demo/
npm run check # type-check
npm run build # library build → dist/
Publishing
npm run build
npm publish
Requires being logged in to npm (npm whoami) as an account with publish rights to the @forgebuild scope.