@forgebuild/hotal-ui

中文文档
0.48.0发布于 2026/11/13MITTypeScriptES Module近 30 天下载 5023

Reusable Svelte UI components for booking and reservation experiences.

原始使用文档
文档目录20 节
  1. Install
  2. Components
  3. <reservation-bar>
  4. <booking-flow>
  5. <menu-section>
  6. <room-grid>
  7. <island-map>
  8. <floor-plan-hero>
  9. <stay-search>
  10. <price-grid>
  11. <availability-calendar>
  12. <booking-card>
  13. <stay-booking-flow>
  14. room-grid rows & feature variants
  15. <dining-teaser>
  16. <booking-checkout>
  17. <confirmation-card>
  18. <menu-items-editor>
  19. Development
  20. Publishing

@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

NameAttributeTypeDefaultDescription
minGuestsmin-guestsnumber1Minimum selectable party size
maxGuestsmax-guestsnumber20Maximum selectable party size
defaultGuestsdefault-guestsnumber2Initial party size
timeSlotstime-slots (JSON array)string[]'7:00 PM'–'11:00 PM' in 30-min stepsSelectable time options
defaultTimedefault-timestring'7:00 PM'Initial time
showReserveButtonshow-reserve-buttonbooleantrueWhether the Reserve link is rendered. Set as a JS property to get false — see note above.
reserveHrefreserve-hrefstring'/reservations'Base URL the built query string is appended to
applyOnSelectapply-on-selectboolean!showReserveButtonWhether 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.
syncWithUrlsync-with-urlbooleanfalseRead initial date/guests/time from location.search on mount
closedWeekdaysclosed-weekdays (JSON array)number[][]Weekdays (0=Sun–6=Sat) to disable in the calendar
fetchAvailableDates— (property only)(partySize: number) => Promise<string[]>undefinedDrives 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)

NameTypeDescription
datestringCurrent search date (ISO)
guestsnumberCurrent party size
preferredTimestringUsed to pre-select the closest available slot
closedWeekdaysnumber[]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
stripePublishableKeystringStripe publishable key
stripeAppearanceobjectPassed 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

NameAttributeTypeDefaultDescription
titletitlestring'MENU'Main heading
eyebroweyebrowstring''Secondary line (above the heading in panel, below it otherwise)
categoriescategories (JSON)MenuCategory[][]{ label, aside?, note?, items: [{ name, description?, price?, tagline?, group?, country?, allergens?, badges?: [{ label, tone?: 'accent' }], notes?: string[] }] }
infoinfo (JSON){label, value}[][]Info rows (hours, dress code, …)
allergensallergens (JSON){id, label, icon}[][]Legend resolving item allergens ids to icons
pdfUrl / pdfLabelpdf-url / pdf-labelstring—Optional PDF menu opened in a dialog
headingAlignheading-align'center' | 'left''center'Classic variant only
layoutlayout'stacked' | 'split''stacked'Classic variant only
variantvariant'default' | 'panel''default'Visual variant
themetheme (JSON)object{}Color overrides, applied as CSS custom properties
groupFiltergroup-filterbooleanfalsePill row filtering the active category by item group (e.g. country on a wine list)
countryFiltercountry-filterbooleanfalsePill row filtering by item country (e.g. region), narrowed to the active group
groupFilterAllLabel / countryFilterAllLabelgroup-filter-all-label / country-filter-all-labelstring'All'Label of the "show everything" pill
footnotefootnotestring''Small print under the panels
headingLevelheading-level1 | 2 | 3h1 (classic), h2 (panel)Heading level; set 2 on a page with its own h1
prerenderedprerenderedbooleanfalseSet 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

NameAttributeTypeDefaultDescription
roomsrooms (JSON)RoomGridRoom[][]{ slug, name, description?, price?, image?, tag?, href? }
filterTabsfilter-tabs (JSON){label, slug}[]derived from tagsExplicit tab labels, each matched to one room by slug
checkIn / checkOutcheck-in / check-outstring (ISO)''Selected dates; settable as JS properties
currencycurrencystring'USD'Suffix on the from-price
allLabelall-labelstring'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

NameAttributeTypeDefaultDescription
pinspins (JSON)IslandMapPin[][]{ label, sub?, lat, lng, slug? }
centercenterstring'0,0'"lat,lng"
zoomzoomnumber14Initial zoom
mapIdmap-idstring''Google Cloud map id (styling)
apiKeyapi-keystring''Used only when no Maps loader is already on the page
colorSchemecolor-scheme'' | 'DARK' | 'LIGHT' | 'FOLLOW_SYSTEM'''Leave unset with a styled map-id — setting it overrides the cloud style with Google's base theme
fitPinsfit-pinsbooleantrueFit the viewport to the pins on load
fitPadding / panYfit-padding / pan-ynumber-50 / -100Fit-bounds padding and post-fit vertical pan
interactionLockinteraction-lockbooleantrueSwallow map gestures until the visitor clicks once
pinsVisiblepins-visiblebooleanfalseShow all pin cards permanently
lazylazybooleanfalseDefer map creation until the element nears the viewport
zoomControlzoom-controlbooleanfalseShow 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

NameAttributeTypeDefaultDescription
planplan (JSON)FloorPlan{}{ rooms: [{id, type?, label?, x, y, w, h, sqm?, imgUrl?}], items: [{id, asset, x, y, w, h}], w?, h? }
bgUrlbg-urlstring—Architectural background image, rendered at 30% opacity
assetBaseasset-basestring'/SVG/'Item assets resolve to ${assetBase}Asset {n}.svg (asset key "asset4" → Asset 4.svg)
label / closeLabellabel / close-labelstring'Floor plan' / 'Close ×'Panel header copy
themetheme'dark' | 'light''dark'Zone/label palette
barebarebooleanfalsePlan 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.

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

NameAttributeTypeDefaultDescription
roomsrooms (JSON)StaySearchRoom[][]{ roomTypeId, name, description?, image?, href? } — the join filter for results
checkIn / checkOutcheck-in / check-outstring (ISO)''Selected dates; settable as JS properties
guestsguestsnumber2Passed to searchStays
currencycurrencystring'USD'ISO 4217 code; card totals are formatted in it
localelocalestringbrowserDates, month/weekday names and price formatting
stringsstrings (JSON)objectEnglishInterface text: checkIn, checkOut, addDate, guests, fewerGuests, moreGuests, chooseDates, previousMonth, nextMonth, loadError, none, availableOne, availableMany, totalOne, totalMany ({n} = nights)
appendDatesToHrefappend-dates-to-hrefbooleantrueAppend ?check_in=…&check_out=… to card hrefs
showGuestsshow-guestsbooleanfalseShow a guests stepper; changing it re-runs the search
showRoomsshow-roomsbooleanfalseShow 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

NameAttributeTypeDefaultDescription
rowsrows (JSON)PriceGridRow[][]{ id, name, meta?, href? } in display order; meta fills an optional second column
startDatestart-datestring (ISO)todayFirst night shown; clamped to today
daysdaysnumber7Nights shown at once and the step of the arrows (1-31)
guestsguestsnumber2Carried into the handoff on price links
currencycurrencystring'USD'ISO 4217 code
localelocalestringbrowserDate and price formatting
stringsstrings (JSON)objectEnglishInterface 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>
NameAttributeTypeDefaultDescription
checkIn / checkOutcheck-in / check-outstring (ISO)''Selected range; settable as JS properties
horizonMonthshorizon-monthsnumber12Availability 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>
NameAttributeTypeDefaultDescription
pricepricenumber0Nightly from-rate; the breakdown prices every night at this rate
maxGuestsmax-guestsnumber4Guest stepper ceiling
resortFeeRateresort-fee-ratenumber0.03Fee as a fraction of the subtotal
ratingText / finePrintrating-text / fine-printstring—Header and footer copy
conciergeTitle / conciergeSub / conciergeCtaLabel / conciergeCtaHrefkebab-casestringIsland Concierge defaultsFooter block
checkIn / checkOut / guestscheck-in / check-out / guestsISO / number'' / 2Current 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

NameAttributeTypeDefaultDescription
venuesvenues (JSON){key, name, tag?, image}[][]Rendered in order; hovering, focusing, or clicking a row makes it active
heading / headingAccentheading / heading-accentstring'Restaurants.' / 'No compromise.'Two-line heading, second line in the accent colour
bodybodystring—Paragraph under the heading
ctaLabel / ctaHrefcta-label / cta-hrefstring'Explore dining' / '/dining'The outlined link below the venue list
imageLabelimage-labelstring—Fixed photo caption. Omit to have it track the active venue's name instead
activeIndexactive-indexnumber0Active 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

NameAttributeTypeDefaultDescription
totaltotalnumber0Drives the submit button's amount label; settable as a JS property, so the host can update it after extras change
currencycurrencystring'USD'Used for the amount label
clientSecretclient-secretstring—Stripe PaymentIntent client secret, created server-side by the host
stripePublishableKeystripe-publishable-keystring—Stripe publishable key
stripeAppearancestripe-appearance (JSON)object{}Passed to stripe.elements({ appearance })
cancellationPolicycancellation-policystring—Shown in a dedicated section when present; omit to hide it entirely
locationslocations (JSON){iso2, countryName, dialCode}[]every countrySource for both selects (empty = every country, named in the browser's language) — country options keep every entry, phone-code options are deduplicated by dialCode
defaultPhoneCodedefault-phone-codestring'+1'Initial value for both phone-code selects
allowBookingForOtherallow-booking-for-otherbooleantrueHides the toggle and booker sub-form when false
termsHrefterms-hrefstring'/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
stringsstrings (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

NameAttributeTypeDefaultDescription
eyebroweyebrowstring'Booking Confirmed'Line above the heading; omit to hide
headingheadingstring"You're all set."
bodybodystring—
reference / referenceLabelreference / reference-labelstring'' / 'Booking Reference'The boxed reference number; the box is omitted entirely when reference is empty
primaryLabel / primaryHrefprimary-label / primary-hrefstring—Filled button; omitted when primaryLabel is empty
secondaryLabel / secondaryHrefsecondary-label / secondary-hrefstring—Outlined button; both must be set to render
notePrefix / noteLinkLabel / noteLinkHref / noteSuffixkebab-casestring—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.

版本与发布记录

@forgebuild/hotal-ui 共发布 111 个版本,首次发布于 2026/08/12;过去 12 个月发布了 111 个版本。

版本发布日期距今
0.48.0latest2026/11/132 天前
0.46.12026/11/105 天前
0.46.02026/11/106 天前
0.45.72026/11/078 天前
0.45.52026/11/078 天前
0.45.42026/11/078 天前

关于 @forgebuild/hotal-ui 的常见问题

@forgebuild/hotal-ui 是什么?

Reusable Svelte UI components for booking and reservation experiences.

如何安装 @forgebuild/hotal-ui?

在项目目录运行 npm install @forgebuild/hotal-ui,或使用 pnpm add / yarn add / bun add。

@forgebuild/hotal-ui 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@forgebuild/hotal-ui 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@forgebuild/hotal-ui 可以商用吗?

@forgebuild/hotal-ui 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@forgebuild/hotal-ui 的下载量有多少?

根据 npm 官方下载统计,@forgebuild/hotal-ui 在 2026/10/16 – 2026/11/14 这 30 天内被下载 5,023 次,日均约 167 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@forgebuild/hotal-ui 还在维护吗?

最新版本 0.48.0 发布于 2026/11/13(2 天前);至今共发布 111 个版本,其中过去 12 个月发布了 111 个;首次发布于 2026/08/12。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。