@awayz/web-components
v0.1.2
Published
Framework agnostic headless web components for embedding Awayz travel search and booking into your own webapps.
Readme
Awayz Web Components
A collection of framework agnostic headless web components built with Lit for developers who want to embed Awayz into their own travel webapps. The components handle authentication, API calls and state; you keep complete control over markup and styling. First-class React wrappers ship alongside the custom elements.
npm install @awayz/web-componentsImporting the package registers the custom elements as a side effect:
import '@awayz/web-components';In a React app, import the wrapper components instead (React is an optional peer dependency, >=18):
import {
AwayzProvider,
AwayzHotelContext,
AwayzHotelDetails,
AwayzHotelRooms,
AwayzLocationSearch,
AwayzHotelResults,
AwayzHotelBooking,
AwayzCardCapture
} from '@awayz/web-components';Quick start
<awayz-provider client-id="..." id-token="..." refresh-token="...">
<awayz-hotel-context
check-in-date="2026-07-01"
check-out-date="2026-07-05"
guests="2"
rooms="1"
hotel-groups="HYATT,MARRIOTT,HILTON"
auto-search
>
<awayz-location-search>
<input type="search" placeholder="Where are you going?" />
<template>
<div class="suggestion">{{description}}</div>
</template>
</awayz-location-search>
<awayz-hotel-results>
<p slot="loading">Searching…</p>
<p slot="empty">No hotels found.</p>
<template>
<article class="card">
<img src="{{images.0}}" alt="{{name}}" />
<h3>{{name}}</h3>
<p>{{city}}, {{country}} — {{cash_label}}</p>
<a href="{{booking_link}}">Book</a>
</article>
</template>
</awayz-hotel-results>
</awayz-hotel-context>
</awayz-provider>React
Each component is also exported as a React wrapper (built with @lit/react). The wrappers accept the same options as camelCased props, expose the custom events as on* callbacks, and render the same headless <template> children.
function HotelSearch() {
return (
<AwayzProvider
clientId='...'
idToken='...'
refreshToken='...'
onAuthenticationSuccess={(e) => console.log(e.detail.user)}
>
<AwayzHotelContext
checkInDate='2026-07-01'
checkOutDate='2026-07-05'
guests={2}
rooms={1}
hotelGroups={['HYATT', 'MARRIOTT', 'HILTON']}
autoSearch
onHotelsResults={(e) => console.log(e.detail)}
>
<AwayzLocationSearch
onLocationSelect={(e) => console.log(e.detail.suggestion)}
>
<input type='search' placeholder='Where are you going?' />
<template>
<div className='suggestion'>{'{{description}}'}</div>
</template>
</AwayzLocationSearch>
<AwayzHotelResults onHotelsSelect={(e) => console.log(e.detail)}>
<p slot='loading'>Searching…</p>
<p slot='empty'>No hotels found.</p>
<template>
<article className='card'>
<img src='{{images.0}}' alt='{{name}}' />
<h3>{'{{name}}'}</h3>
<p>{'{{city}}, {{country}}'}</p>
<a href='{{booking_link}}'>Book</a>
</article>
</template>
</AwayzHotelResults>
</AwayzHotelContext>
</AwayzProvider>
);
}Notes for React:
- Props that map to comma-separated attributes (
hotelGroups) are set as element properties, so pass them as arrays rather than strings. {{dot.path}}bindings work in quoted attribute values as-is (src="{{images.0}}"), but in JSX text nodes wrap them in a string so JSX doesn't treat the braces as an expression:{'{{name}}'}.event.detailcarries the same payload documented for each component's events below.
Template bindings
Headless rendering uses plain <template> children. {{dot.path}} placeholders in text and attributes are replaced with values from the current item (a hotel result or a location suggestion). Arrays are joined with ", ", and indexes are supported ({{images.0}}). Missing values render as empty strings. Templates stamp correctly whether the browser's HTML parser populated them or a virtual-DOM library (React) built them with createElement.
Components
<awayz-provider>
Wraps all other components. Holds credentials, exchanges them for an access token and provides an authenticated axios instance plus a TanStack Query client (@tanstack/query-core) to descendants via context. Every API call made through it — including all hotel calls — carries the client-id header and, once authenticated, Authorization: Bearer <accessToken>.
| Attribute | Description |
| --------------- | ----------------------------------------------------------------------------------------------------- |
| client-id | Awayz client id (also sent as the client-id header on every request) |
| id-token | OIDC id token |
| refresh-token | Refresh token |
| test-mode | Boolean. When present, requests go to https://appdev.odynn.info; otherwise https://app.odynn.info |
When client-id, id-token and refresh-token are all populated the provider calls POST /user/exchange-token. Any later API request that receives a 401 triggers a new exchange with the stored refresh token and a single retry (handled by an axios response interceptor).
Descendant components (and your own code, via the awayzContext @lit/context) get access to:
api— the authenticatedAxiosInstancequeryClient— a sharedQueryClient; hotel searches are cached for 60 s and city lookups for 5 min, with concurrent identical requests deduplicated
Events:
awayz:authentication:success—detail: { user, accessToken }awayz:authentication:failed—detail: { error }
<awayz-hotel-context>
Holds all parameters for a hotel search, executes POST /hotels/search-by-coordinates, and shares parameters + results with descendant components.
| Attribute | Default | Maps to |
| -------------------- | ------- | ------------------------------------------------------------------- |
| type | cash | cash_or_points — cash or points (selects the pricing dimension this context searches) |
| check-in-date | — | check_in_date |
| check-out-date | — | check_out_date |
| guests | 2 | guests |
| rooms | 1 | rooms |
| lat / lon | — | lat / lon |
| search-range | 10 | search_range |
| range-type | km | range_type |
| hotel-groups | major chains | hotel_groups (comma separated). Defaults to the major chains: marriott, choice, hilton, ihg, hyatt, accor, wyndham |
| location-type | city | type |
| external-inventory | false | external_inventory |
| auto-search | false | run a search automatically whenever all required params are present |
use_duffel is no longer an attribute — it is derived from type: true for a cash context, false for a points context.
region_code is no longer an attribute — it is taken from the authenticated user's region (user.user_region), falling back to US.
Results from both type contexts are normalized to the same HotelResult shape. For cash results backed by Duffel inventory, the accommodation_id becomes the hotel_id and the cash_booking_offers object supplies the latest pricing, images, review score and description.
Methods / events:
search(): Promise<HotelResult[]>— runs the search imperativelysetLocation({ lat, lon, type })— used by<awayz-location-search>on selectionawayz:hotels:loading/awayz:hotels:results/awayz:hotels:error
<awayz-location-search>
Headless autocomplete backed by GET /hotels/search/cities?search=…. Place an <input> inside it; typing is debounced (debounce, default 300 ms, after min-length characters, default 2) and suggestions are stamped from your <template> into a [data-awayz-suggestions] element (auto-created if omitted). Clicking a suggestion fills the input, pushes lat/lon/type into the surrounding <awayz-hotel-context> and emits awayz:location:select.
Events: awayz:location:results, awayz:location:select, awayz:location:error.
<awayz-hotel-results>
Headless renderer for the results held by the surrounding <awayz-hotel-context>. Your <template> is stamped once per hotel into a [data-awayz-results] element (auto-created if omitted). The current state is reflected as state="idle|loading|ready|empty|error" for CSS hooks, and slot="loading" / slot="empty" / slot="error" children are shown for the matching state. Clicking a stamped item emits awayz:hotels:select with the hotel in detail.
| Attribute | Default | Description |
| --------- | ------- | ------------------------------------------------------------------------------------------------- |
| type | cash | Which pricing context to read — cash or points. Binds this element to the matching <awayz-hotel-context type="…">. |
Pairing two <awayz-hotel-context> elements (one type="cash", one type="points") with two <awayz-hotel-results type="…"> elements lets you render cash and points results side-by-side without either context shadowing the other.
Derived pricing fields
The backend returns -1 for a cash amount or points cost to mean "no availability for this option". To keep that sentinel out of your markup, each result template is stamped with extra computed bindings alongside the raw HotelResult fields:
| Binding | Example | Description |
| ------------------------ | ---------------- | -------------------------------------------------------------------------------------------- |
| {{cash_label}} | USD 1234 | Cash price, N/A when cash is unavailable, or "" when neither option is available |
| {{points_label}} | 45000 pts | Points price, N/A when points are unavailable, or "" when neither option is available |
| {{availability_state}} | both | One of none / cash / points / both — handy on a data-* attribute to drive CSS |
| {{availability_label}} | No availability| No availability when neither option is bookable, otherwise "" |
<template>
<article class="card" data-availability="{{availability_state}}">
<h3>{{name}}</h3>
<p class="cash">{{cash_label}}</p>
<p class="points">{{points_label}}</p>
<p class="sold-out">{{availability_label}}</p>
</article>
</template>Results can be filtered and sorted client-side without re-querying, both via host-supplied callbacks set as properties (callbacks can't be expressed as HTML attributes; React: pass them as props).
<awayz-hotel-details>
Fetches and renders the full detail page for a single hotel. Calls different endpoints depending on type and normalises both responses into the same HotelDetails shape, so your templates and event handlers work identically for cash and points.
| Attribute | Required | Description |
| ---------------- | -------- | ---------------------------------------------------------------------------------------------- |
| hotel-id | Yes | Duffel accommodation ID (cash) or awayz hotel_id (points) |
| hotel-group | Yes | Hotel group / programme (e.g. hyatt). Required by the points endpoint. |
| type | Yes | cash or points — selects the pricing endpoint |
| check-in-date | No† | Overrides the date from an enclosing <awayz-hotel-context> |
| check-out-date | No† | Overrides the date from an enclosing <awayz-hotel-context> |
| rooms | No | Number of rooms (default 1); overrides context |
| guests | No | Number of guests (default 2); overrides context |
| region-code | No | ISO country code for pricing (default: user region → US); overrides context |
| auto-fetch | No | Boolean. Fetches automatically when all required params are present and authentication is ready |
† Required when the component is not nested inside a <awayz-hotel-context>.
Endpoint mapping:
| type | Endpoint | Identifier used |
| -------- | ------------------------------------- | ----------------- |
| cash | POST /duffel/hotels/search-accommodation | hotel-id as Duffel accommodation_id |
| points | POST /hotels/search-by-id | hotel-id as awayz hotel_id |
State is reflected on state="idle|loading|ready|error" for CSS hooks.
Events:
awayz:hotel-details:loading— fetch startedawayz:hotel-details:ready—detail: { details: HotelDetails }awayz:hotel-details:error—detail: { error: string }
fetch(): Promise<HotelDetails> — call programmatically to trigger a fetch.
Template contract
Main template (<template> with no attributes) — stamped once into a [data-awayz-container] child with all HotelDetails fields as {{dot.path}} bindings:
| Binding | Source | Notes |
| ------------------------- | ---------- | --------------------------------------------------------------- |
| {{name}} | both | |
| {{description}} | both | |
| {{address}} | both | |
| {{city}} / {{country_code}} | both | |
| {{telephone}} | both | |
| {{rating}} | both | Star rating (1–5) |
| {{review_score}} | both | User score — scale varies by source |
| {{review_count}} | both | |
| {{images.0}} | both | First image URL |
| {{amenities}} | both | Joined as ", " |
| {{check_in_date}} / {{check_out_date}} | both | |
| {{check_in_time}} / {{check_out_time}} | cash | null for points |
| {{booking_link_cash}} / {{booking_link_points}} | points | "" for cash |
| {{award_points}} | points | null for cash |
| {{award_category}} | points | null for cash |
| {{cheapest_rate_amount}} / {{cheapest_rate_currency}} | cash | null for points |
| {{hotel_program.program}} / {{hotel_program.logo}} | points | null for cash |
| {{brand.name}} / {{chain.name}} | cash | null for points |
Rooms are rendered by a nested <awayz-hotel-rooms> component — see below.
Each room's rates array is available on the HotelDetails object via the awayz:hotel-details:ready event. Every rate has a category field — 'cash', 'points', or 'points_cash' — so consumers never need to branch on type.
HTML example
<awayz-hotel-details
hotel-id="vieph"
hotel-group="hyatt"
type="points"
check-in-date="2026-07-21"
check-out-date="2026-07-22"
auto-fetch
>
<p slot="loading">Loading…</p>
<p slot="error">Failed to load hotel.</p>
<template>
<div class="hotel">
<img src="{{images.0}}" alt="{{name}}" />
<h1>{{name}}</h1>
<p>{{description}}</p>
<p>{{award_points}} pts · Category {{award_category}}</p>
</div>
</template>
<awayz-hotel-rooms>
<p slot="empty">No rooms available.</p>
<template>
<article class="room">
<img src="{{image}}" alt="{{name}}" />
<h3>{{name}}</h3>
<p>From {{lowest_points}} pts</p>
</article>
</template>
</awayz-hotel-rooms>
</awayz-hotel-details>React example
<AwayzHotelDetails
hotelId="vieph"
hotelGroup="hyatt"
type="points"
checkInDate="2026-07-21"
checkOutDate="2026-07-22"
autoFetch
onHotelDetailsReady={(e) => console.log(e.detail.details)}
onHotelDetailsError={(e) => console.error(e.detail.error)}
>
<p slot="loading">Loading…</p>
<template>
<div className="hotel">
<h1>{'{{name}}'}</h1>
<p>{'{{award_points}}'} pts · Category {'{{award_category}}'}</p>
</div>
</template>
<AwayzHotelRooms onRoomSelect={(e) => console.log(e.detail.room)}>
<p slot="empty">No rooms available.</p>
<template>
<article className="room">
<h3>{'{{name}}'}</h3>
<p>{'{{lowest_points}}'} pts</p>
</article>
</template>
</AwayzHotelRooms>
</AwayzHotelDetails>Nested inside <awayz-hotel-context>
When nested, check-in-date, check-out-date, rooms, guests and region-code are automatically inherited from the surrounding context. Provide hotel-id, hotel-group and type — typically from a awayz:hotels:select event — and call fetch() (or set auto-fetch):
document.querySelector('awayz-hotel-results').addEventListener('awayz:hotels:select', (e) => {
const details = document.querySelector('awayz-hotel-details');
details.setAttribute('hotel-id', e.detail.hotel.hotel_id);
details.setAttribute('hotel-group', e.detail.hotel.hotel_group);
details.fetch();
});<awayz-hotel-rooms>
Headless renderer for the rooms inside the surrounding <awayz-hotel-details>. Works identically to <awayz-hotel-results> but consumes the hotel details context instead of a search context. Must be nested inside <awayz-hotel-details>.
Your <template> is stamped once per room into a [data-awayz-rooms] element (auto-created if omitted). The current state is reflected as state="idle|loading|ready|empty|error" for CSS hooks, and slot="loading" / slot="empty" / slot="error" children are shown for the matching state. Clicking a stamped item emits awayz:hotel-details:room:select with the room in detail.
Room template bindings
| Binding | Source | Notes |
| --------------------- | ------ | --------------------------------------------------- |
| {{name}} | both | Room name |
| {{description}} | both | null for cash |
| {{image}} | both | First room photo URL |
| {{type}} | points | null for cash |
| {{category}} | points | null for cash |
| {{lowest_points}} | points | null for cash |
| {{lowest_cash_amount}} / {{lowest_cash_currency}} | cash | null for points |
| {{beds}} | cash | Array joined as ", "; [] for points |
Filtering and sorting
Set filter and sort as properties (not attributes). They mirror Array.prototype.filter and Array.prototype.sort respectively and receive full HotelDetailsRoom objects. Both callbacks are typed as HotelRoomFilter and HotelRoomSort.
const rooms = document.querySelector('awayz-hotel-rooms');
rooms.filter = (room) => (room.lowest_points ?? 0) <= 50000;
rooms.sort = (a, b) => (a.lowest_points ?? 0) - (b.lowest_points ?? 0);<AwayzHotelRooms
filter={(room) => room.lowest_points !== null}
sort={(a, b) => (a.lowest_cash_amount ?? 0) - (b.lowest_cash_amount ?? 0)}
onRoomSelect={(e) => console.log(e.detail.room)}
>
<template>…</template>
</AwayzHotelRooms><awayz-hotel-room-rates>
Headless renderer for the rates of the room the user just selected. When a room is clicked in <awayz-hotel-rooms>, its rates are automatically made available to this component via context — no manual wiring required. Place it anywhere inside <awayz-hotel-details>.
Your <template> is stamped once per rate into a [data-awayz-rates] element (auto-created if omitted). State is reflected as state="idle|ready|empty" for CSS hooks — idle means no room has been selected yet. A slot="empty" child is shown for both idle and empty states. Clicking a stamped rate emits awayz:hotel-details:rate:select with the rate in detail.
Rate template bindings
| Binding | Type | Notes |
| -------------------------- | ----------------- | --------------------------------------------------- |
| {{category}} | cash | points | points_cash | Always present |
| {{name}} | string | null | |
| {{description}} | string | null | |
| {{points}} | number | null | Populated for points and points_cash rates |
| {{total_amount}} | number | null | Populated for cash rates |
| {{total_currency}} | string | null | |
| {{base_amount}} | number | null | |
| {{base_currency}} | string | null | |
| {{board_type}} | string | null | e.g. room_only, breakfast |
| {{payment_type}} | string | null | e.g. guarantee, deposit |
| {{points_cash_amount}} | number | null | Cash portion for points_cash rates |
| {{points_cash_currency}} | string | null | |
| {{expires_at}} | string | null | ISO timestamp |
| {{cancellation_timeline}}| array | Joined as ", " |
| {{conditions}} | array | Joined as ", " |
Filtering and sorting
Set filter and sort as properties. Typed as HotelRateFilter and HotelRateSort.
HTML example
<awayz-hotel-details hotel-id="vieph" hotel-group="hyatt" type="points" auto-fetch>
<awayz-hotel-rooms>
<p slot="empty">No rooms available.</p>
<template>
<article class="room">
<h3>{{name}}</h3>
<p>From {{lowest_points}} pts</p>
</article>
</template>
</awayz-hotel-rooms>
<awayz-hotel-room-rates>
<p slot="empty">Select a room to see rates.</p>
<template>
<div class="rate" data-category="{{category}}">
<strong>{{name}}</strong>
<span>{{total_amount}} {{total_currency}}</span>
<span>{{points}} pts</span>
</div>
</template>
</awayz-hotel-room-rates>
</awayz-hotel-details>React example
<AwayzHotelDetails hotelId="vieph" hotelGroup="hyatt" type="points" autoFetch>
<AwayzHotelRooms onRoomSelect={(e) => console.log('room', e.detail.room)}>
<p slot="empty">No rooms available.</p>
<template>
<article className="room">
<h3>{'{{name}}'}</h3>
<p>{'{{lowest_points}}'} pts</p>
</article>
</template>
</AwayzHotelRooms>
<AwayzHotelRoomRates onRateSelect={(e) => console.log('rate', e.detail.rate)}>
<p slot="empty">Select a room to see rates.</p>
<template>
<div className="rate">
<strong>{'{{name}}'}</strong>
<span>{'{{total_amount}}'} {'{{total_currency}}'}</span>
</div>
</template>
</AwayzHotelRoomRates>
</AwayzHotelDetails>Filtering
Set the filter property. It mirrors Array.prototype.filter — return true to keep a hotel — and receives the full HotelResult so you can filter on anything in it:
const results = document.querySelector('awayz-hotel-results');
// Only hotels with availability, under a price ceiling.
results.filter = (hotel) =>
hotel.cash_available && hotel.cash_value.amount <= 300;<AwayzHotelResults
filter={(hotel, index, results) => hotel.review_rating !== null}
>
{/* template … */}
</AwayzHotelResults>The filter signature is (hotel: HotelResult, index: number, results: HotelResult[]) => boolean, exported as the HotelFilter type. It runs before the sort, and index / results refer to the unfiltered results in API order. Reassign the property to re-filter; clear it (set to undefined) to render everything again. When the filter removes every hotel the host reflects state="empty" and shows your slot="empty". A callback that throws is caught and logged, leaving the results unfiltered so a single bad call can't wedge rendering.
Sorting
Set the sort property. It mirrors Array.prototype.sort's compare function — return a negative number to place a before b, a positive number for after, or 0 to keep their order — and receives two full HotelResult objects so you can order on any field:
const results = document.querySelector('awayz-hotel-results');
// Cheapest cash price first.
results.sort = (a, b) => a.cash_value.amount - b.cash_value.amount;<AwayzHotelResults sort={(a, b) => b.points - a.points}>
{/* template … */}
</AwayzHotelResults>The sort signature is (a: HotelResult, b: HotelResult) => number, exported as the HotelSort type. It runs after the filter, on the filtered results. Reassign the property to re-sort; clear it (set to undefined) to restore the order returned by the API. A comparator that throws is caught and logged, leaving the results unsorted so a single bad call can't wedge rendering.
<awayz-card-capture>
Hosts Evervault's PCI-compliant card iframe — the one piece of UI this library renders itself, because raw card details must never touch the host page. The iframe mounts into a light-DOM child marked data-awayz-card (auto-created if omitted); everything around it stays yours to build and style.
<awayz-card-capture>
<p slot="loading">Loading card form…</p>
<p slot="error">The card form failed to load.</p>
<div data-awayz-card></div>
</awayz-card-capture>Evervault credentials resolve automatically from GET /clients/embed-data once the ancestor <awayz-provider> authenticates, or set them explicitly with the team-id and app-id attributes (no provider needed in that case).
| Attribute / property | Type | Notes |
| -------------------- | ----------------- | ---------------------------------------------------------------- |
| team-id / app-id | string | Optional overrides; skip the embed-data lookup |
| options | AwayzCardOptions (property) | Forwarded verbatim to Evervault's ui.card(options) — theme, fields, translations, acceptedBrands, … Changing it calls card.update() |
The iframe's inner styling is customised through options.theme (any Evervault theme definition). State is reflected as state="idle|loading|ready|error".
| Event | detail |
| --------------------- | ------------------------------------------- |
| awayz:card:ready | {} — iframe mounted |
| awayz:card:change | AwayzCardData — fires on every keystroke; card.number/card.cvc are already-encrypted Evervault tokens, plus lastFour, brand, bin, isComplete, isValid, errors |
| awayz:card:complete | AwayzCardData — all fields valid |
| awayz:card:error | { error } |
Methods: mount(), unmount(), and reset() (clears the fields by remounting a fresh iframe — call it after a failed payment). The latest payload is also available as the card property.
Use detail.isComplete (together with your own billing-address validation) to enable your pay button.
<awayz-hotel-booking>
Headless orchestrator for booking a hotel rate with a card ("cash" checkout). It runs the full production flow:
- Quote —
createQuote()(or theauto-quoteattribute) locks the price ofrate-idviaPOST /duffel/hotels/quote. - Pay —
pay()prepares the payment (POST /v2/payment/prepare) from the encrypted card + billing address, runs the provider's 3-D Secure challenge when required (via@duffel/components, loaded lazily), and completes the booking (POST /v2/hotel/booking).
The final booking call is never retried — a blind retry after a card charge could double-book. Repeated pay() calls while one is in flight return the same promise, so a double-clicked button can't double-book either.
| Attribute / property | Type | Notes |
| --------------------- | ----------------------------------- | ----------------------------------------------------------- |
| rate-id | string | The rate to quote — take it from awayz:hotel-details:rate:select |
| auto-quote | boolean attribute | Quote automatically once authenticated and rate-id is set (re-quotes when rate-id changes) |
| email | string | Contact email for the booking |
| phone-number | string | Contact phone number |
| guests | HotelBookingGuest[] (property) | [{ given_name, family_name }, …], main guest first |
| billingAddress | BillingAddress (property) | Cardholder name + address from your own form |
| card | AwayzCardData (property) | Auto-captured from a nested <awayz-card-capture>; set manually if the card form lives elsewhere |
| verificationHandler | VerificationHandler (property) | Optional replacement for the built-in Duffel 3-D Secure step |
All of card, billingAddress, guests, email and phoneNumber can also be passed per call: pay({ guests, email, phoneNumber, billingAddress, card }). If no quote exists yet, pay() quotes rate-id first.
State is reflected as state="idle|quoting|quoted|preparing|verifying|booking|booked|error". Children with slot="quoting", slot="processing" (covers preparing/verifying/booking), slot="error" and slot="success" show for the matching states.
Templates
<template>(no slot) is stamped with the quote into[data-awayz-container]when the quote is ready. Bindings:{{total_amount}},{{total_currency}},{{base_amount}},{{tax_amount}},{{fee_amount}},{{due_at_accommodation_amount}},{{check_in_date}},{{check_out_date}},{{number_of_guests}},{{number_of_rooms}},{{accommodation.name}},{{accommodation.image}},{{accommodation.address}},{{accommodation.rooms.0.name}}, …<template data-awayz-confirmation>is stamped with the confirmed booking into[data-awayz-confirmation-container]on success. Bindings:{{awayzRef}},{{providerRef}},{{status}},{{booking.reference}},{{booking.totalAmount}},{{booking.totalCurrency}},{{booking.checkInDate}}, …
Events
| Event | detail |
| ------------------------------------ | --------------------------------------------------------------- |
| awayz:hotel-booking:quote:loading | { rate_id } |
| awayz:hotel-booking:quote:ready | { quote: HotelBookingQuote } |
| awayz:hotel-booking:preparing | { quote } — payment prepare started |
| awayz:hotel-booking:verifying | { action, provider } — action is 3ds_challenge or none |
| awayz:hotel-booking:submitting | { paymentSessionId } — final booking call started |
| awayz:hotel-booking:success | { booking: HotelBookingConfirmation } |
| awayz:hotel-booking:error | { stage, error, errorSource?, errorCode?, awayzRef?, providerRef? } — stage is quote/prepare/verify/book; awayzRef is the support reference to show travellers |
HTML example
<awayz-provider client-id="..." id-token="..." refresh-token="...">
<awayz-hotel-booking id="booking" auto-quote>
<p slot="quoting">Locking in your price…</p>
<p slot="processing">Processing payment — don't close this page.</p>
<div slot="error" class="error"></div>
<template>
<h2>{{accommodation.name}}</h2>
<p>{{check_in_date}} → {{check_out_date}}</p>
<p class="price">{{total_amount}} {{total_currency}}</p>
</template>
<awayz-card-capture></awayz-card-capture>
<template data-awayz-confirmation>
<h2>Booking confirmed 🎉</h2>
<p>Reference: {{awayzRef}}</p>
</template>
</awayz-hotel-booking>
</awayz-provider>
<script type="module">
const booking = document.querySelector('#booking');
// 1. Wire the selected rate into the booking (from your details page).
document.addEventListener('awayz:hotel-details:rate:select', (e) => {
booking.rateId = e.detail.rate.id; // auto-quote re-quotes on change
});
// 2. Enable your pay button when the card is complete.
booking.addEventListener('awayz:card:change', (e) => {
payButton.disabled = !e.detail.isComplete;
});
// 3. Collect guests + billing from your own forms and pay.
payButton.addEventListener('click', () => {
booking.pay({
guests: [{ given_name: 'Ada', family_name: 'Lovelace' }],
email: '[email protected]',
phoneNumber: '+15550001111',
billingAddress: {
name: 'Ada Lovelace',
addressLine1: '1 Main St',
addressCity: 'Austin',
addressRegion: 'TX',
addressPostalCode: '78701',
addressCountryCode: 'US'
}
});
});
booking.addEventListener('awayz:hotel-booking:error', (e) => {
// e.detail.stage tells you where it failed; card fields must be
// re-entered after a payment failure:
document.querySelector('awayz-card-capture').reset();
});
</script>React example
function Checkout({ rateId }) {
const bookingRef = useRef(null);
const [cardComplete, setCardComplete] = useState(false);
return (
<AwayzHotelBooking
ref={bookingRef}
rateId={rateId}
autoQuote
onQuoteReady={(e) => console.log('total', e.detail.quote.total_amount)}
onBookingSuccess={(e) => navigate(`/confirmed/${e.detail.booking.awayzRef}`)}
onBookingError={(e) => setError(e.detail)}
>
<template>
<p>{'{{accommodation.name}}'} — {'{{total_amount}}'} {'{{total_currency}}'}</p>
</template>
<AwayzCardCapture
onCardChange={(e) => setCardComplete(e.detail.isComplete)}
/>
<button
disabled={!cardComplete}
onClick={() =>
bookingRef.current.pay({
guests: [{ given_name: 'Ada', family_name: 'Lovelace' }],
email: '[email protected]',
phoneNumber: '+15550001111',
billingAddress: { /* from your billing form */ }
})
}
>
Pay now
</button>
</AwayzHotelBooking>
);
}3-D Secure
When POST /v2/payment/prepare answers action: "3ds_challenge", the built-in handler runs Duffel's in-browser challenge (createThreeDSecureSession from @duffel/components, imported on demand) against the quote and attaches the resulting threeDSecureSessionId to the booking. Replace it by setting the verificationHandler property — it receives { provider, action, providerData, bookingId, services } and must resolve with { provider, data }.
