npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-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 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 pick
  • room-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 publish

Requires being logged in to npm (npm whoami) as an account with publish rights to the @forgebuild scope.