@forgebuild/hotal-ui
v0.23.205
Published
Reusable Svelte UI components for booking and reservation experiences.
Readme
@forgebuild/hotal-ui
Reusable Web Components for booking and reservation experiences.
Install
npm install @forgebuild/hotal-uiNo 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 calendar's selectable dates |
| 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, items: [{ name, description?, price?, tagline?, group?, country?, allergens? }] } |
| 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 |
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' | Shown in the card rate subline |
| appendDatesToHref | append-dates-to-href | boolean | true | Append ?check_in=…&check_out=… to card hrefs |
| searchStays | — (property only) | (checkIn, checkOut, guests) => Promise<StaySearchResult[]> | — | Runs when a complete range is picked; results need room_type_id and from_rate |
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.
<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).
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}[] | [] | Source for both selects — 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.
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.
Development
npm install
npm run dev # serves demo/
npm run check # type-check
npm run build # library build → dist/Publishing
npm run build
npm publishRequires being logged in to npm (npm whoami) as an account with publish rights to the @forgebuild scope.
