BookingPanel

A contained booking card with date and guest selection, pricing display, and a reserve CTA.

Installation

pnpm add @wandercom/design-system-web

Usage

The BookingPanel block provides a self-contained booking interface with check-in/check-out date inputs, a guest selector, dynamic pricing header, and a submit button. It adapts its header and CTA label based on whether dates are selected.

When dateRange and onDateChange are provided, the PropertyCalendar is composed directly into the date inputs as a popover (desktop) or drawer (mobile bar). Clicking the date inputs opens the calendar automatically.

52 lines
import {
  BookingPanel,
  BookingPanelMobileBar,
  BookingPanelRoot,
  formatGuestLabel,
} from '@wandercom/design-system-web/blocks/booking-panel';
import type { AvailabilityDay, DateRange } from '@wandercom/design-system-web';
import { format } from 'date-fns';
import { useState } from 'react';

export function Example() {
  const [dateRange, setDateRange] = useState<DateRange>({ start: null, end: null });
  const [guests, setGuests] = useState<number | null>(null);
  const [pets, setPets] = useState<number | null>(null);

  return (
    <BookingPanelRoot
      desktop={
        <BookingPanel
          dateRange={dateRange}
          onDateChange={setDateRange}
          availability={availabilityData}
          stayRequirements={{ minNights: 3 }}
          checkinDate={dateRange.start ? format(dateRange.start, "MM/dd/yyyy") : null}
          checkoutDate={dateRange.end ? format(dateRange.end, "MM/dd/yyyy") : null}
          guests={guests}
          onGuestsChange={setGuests}
          pets={pets}
          onPetsChange={setPets}
          petsAllowed
          guestLabel={formatGuestLabel(guests, pets) || "1 guest"}
          pricing={dateRange.start && dateRange.end ? { totalPrice: "$12,500", nightCount: 5 } : null}
          showTotalBeforeTaxesCopy
          onSubmit={() => handleReserve()}
        />
      }
      mobile={
        <BookingPanelMobileBar
          dateRange={dateRange}
          onDateChange={setDateRange}
          availability={availabilityData}
          stayRequirements={{ minNights: 3 }}
          checkinDate={dateRange.start ? format(dateRange.start, "MM/dd/yyyy") : null}
          checkoutDate={dateRange.end ? format(dateRange.end, "MM/dd/yyyy") : null}
          nightlyRate="$2,500"
          showTotalBeforeTaxesCopy
          onPress={handlePress}
        />
      }
    />
  );
}

Example

The primary preview places the composed block in a minimal property detail layout: property content on the left, a sticky desktop booking sidebar on the right, and the fixed mobile booking bar on narrow viewports.

Loading example...

States

The panel adapts based on the props provided:

No dates selected -- The header displays "Select dates for pricing" and the CTA reads "Add dates". When calendar props are provided, clicking the CTA opens the calendar popover.

Dates selected with pricing -- The header shows the total price and night count, and the CTA reads "Reserve". By default the summary stays as for N nights.

Loading example...

Dates selected with pricing + total-before-taxes copy -- Pass showTotalBeforeTaxesCopy to append "(before taxes)" to the stay summary when your pricing excludes taxes.

Loading example...

Pricing loading -- When pricingLoading is true, the header shows skeleton shimmer placeholders.

Loading example...

Request to book -- When bookingMode="request", the panel heading changes to "Request to book" and the button defaults to "Request to book". The submitLabel prop still overrides the button label.

Loading example...

Error -- When error is set, the date input borders turn destructive red and the message is displayed between the inputs and the CTA.

Loading example...

Submitting -- When submitting is true, the CTA button shows a loading spinner.

Responsive layout

BookingPanelRoot switches between desktop and mobile content at the lg viewport breakpoint (65rem). Pass separate desktop and mobile render props to show the appropriate layout for the available viewport width. If either prop is omitted, children is used as the fallback.

10 lines
import {
  BookingPanel,
  BookingPanelMobileBar,
  BookingPanelRoot,
} from '@wandercom/design-system-web/blocks/booking-panel';

<BookingPanelRoot
  desktop={<BookingPanel {...desktopProps} />}
  mobile={<BookingPanelMobileBar {...mobileProps} />}
/>

This is the recommended pattern for property detail pages where the desktop sidebar panel and the mobile fixed bottom bar share the same booking state.

Guest selection

The BookingPanel supports two patterns for guest management.

Integrated popover -- When guests and onGuestsChange are provided, the guests input becomes a Popover with NumberInput rows for guests and pets. Use formatGuestLabel to derive the display string from the current values.

16 lines
import { BookingPanel, formatGuestLabel } from '@wandercom/design-system-web/blocks/booking-panel';

const [guests, setGuests] = useState<number | null>(null);
const [pets, setPets] = useState<number | null>(null);

<BookingPanel
  guests={guests}
  onGuestsChange={setGuests}
  pets={pets}
  onPetsChange={setPets}
  petsAllowed
  maxOccupancy={12}
  maxPets={3}
  guestLabel={formatGuestLabel(guests, pets) || "1 guest"}
  // ...other props
/>

When petsAllowed is false, the pets row is rendered but disabled with a value of 0.

Loading example...

External callback -- When guests/onGuestsChange are not provided, the panel falls back to the onGuestsClick callback so the host application can manage the guest picker externally.

5 lines
<BookingPanel
  guestLabel="3 guests, 1 pet"
  onGuestsClick={() => openGuestPicker()}
  // ...other props
/>

Calendar integration

Pass dateRange, onDateChange, and optionally availability, stayRequirements, rules, calendarAlert, pricePerNight, and onDatesSelected to compose the PropertyCalendar directly into the panel. The calendar opens as a Popover on desktop when date inputs or the "Add dates" CTA are clicked.

Use onDatesSelected to trigger a pricing API request once the user commits both dates — it fires after both check-in and check-out are set.

12 lines
<BookingPanel
  dateRange={dateRange}
  onDateChange={setDateRange}
  availability={availability}
  stayRequirements={{ minNights: 3, checkInDays: ['Saturday'] }}
  checkinDate={dateRange.start ? format(dateRange.start, "MM/dd/yyyy") : null}
  checkoutDate={dateRange.end ? format(dateRange.end, "MM/dd/yyyy") : null}
  pricePerNight="$787.86/night pre-tax"
  showTotalBeforeTaxesCopy
  onDatesSelected={(range) => fetchPricing(range)}
  onSubmit={handleReserve}
/>

Controllable calendar open state

Pass calendarOpen + onCalendarOpenChange to control the calendar popover (desktop) or drawer (mobile) from outside the panel — useful when a sibling component needs to open the calendar (e.g. an "Add dates" CTA elsewhere on the page). Leave both undefined for the default uncontrolled behavior.

When the viewport crosses the lg breakpoint, an open composed calendar closes before the desktop popover or mobile drawer becomes active.

12 lines
const [calendarOpen, setCalendarOpen] = useState(false);

<BookingPanelComposed
  calendarOpen={calendarOpen}
  onCalendarOpenChange={setCalendarOpen}
  dateRange={dateRange}
  onDateChange={setDateRange}
  /* ...rest */
/>

// Open from anywhere else on the page:
<button onClick={() => setCalendarOpen(true)}>Add dates</button>

Mobile bar

BookingPanelMobileBar is the fixed bottom bar used in the narrow viewport layout. It supports the same calendar integration props and opens a vertically-scrolling 12-month PropertyCalendarMobile drawer when tapped.

13 lines
import { BookingPanelMobileBar } from '@wandercom/design-system-web/blocks/booking-panel';

<BookingPanelMobileBar
  dateRange={dateRange}
  onDateChange={setDateRange}
  availability={availability}
  stayRequirements={{ minNights: 3 }}
  checkinDate={dateRange.start ? format(dateRange.start, "MM/dd/yyyy") : null}
  checkoutDate={dateRange.end ? format(dateRange.end, "MM/dd/yyyy") : null}
  nightlyRate="$2,500"
  showTotalBeforeTaxesCopy
  onPress={handlePress}
/>

When rating is provided with stars > 4 and no dates are selected, the star rating replaces the nightly rate in the subtitle. Use a narrow viewport to see the bar.

Loading example...

Children slot

Use the children prop to inject additional content between the guest selector and the CTA, such as a coupon input or price breakdown.

10 lines
<BookingPanel
  dateRange={dateRange}
  onDateChange={setDateRange}
  checkinDate={dateRange.start ? format(dateRange.start, "MM/dd/yyyy") : null}
  checkoutDate={dateRange.end ? format(dateRange.end, "MM/dd/yyyy") : null}
  pricing={{ totalPrice: "$12,500", nightCount: 5 }}
  onSubmit={() => handleReserve()}
>
  <PriceBreakdown items={lineItems} />
</BookingPanel>

Props

BookingPanelRoot

desktop?:

ReactNode
Content rendered at the lg viewport breakpoint (65rem) and above. Falls back to children if omitted.

mobile?:

ReactNode
Content rendered below the lg viewport breakpoint (65rem). Falls back to children if omitted.

children?:

React.ReactNode
Fallback content used when desktop or mobile props are not provided.

className?:

string
Additional CSS classes for the root element.

BookingPanel

dateRange?:

DateRange
Current date range selection. When provided with onDateChange, enables the composed calendar.

onDateChange?:

(range: DateRange) => void
Called when dates change in the integrated calendar.

availability?:

AvailabilityDay[]
Per-day availability data passed to the PropertyCalendar.

availabilityLoading?:

boolean
Whether availability data is loading.

rules?:

BookingRule[]
Booking rules passed to the PropertyCalendar.

stayRequirements?:

StayRequirements
Stay requirement constraints shown in the calendar (e.g. min nights, check-in days).

calendarAlert?:

ReactNode
Alert content rendered inside the calendar.

pricePerNight?:

string
Pre-formatted price per night string, e.g. "$787.86/night pre-tax". Replaces the "Select dates for pricing" header and "Select dates" calendar title when no dates are selected.

onDatesSelected?:

(range: DateRange) => void
Called when both check-in and check-out dates are committed. Use this to trigger a pricing API request.

checkinDate?:

string | null
Formatted check-in date string, e.g. "Jan 23, 2026". Null or undefined shows placeholder.

checkoutDate?:

string | null
Formatted check-out date string, e.g. "Jan 28, 2026". Null or undefined shows placeholder.

guests?:

number | null
Current guest count. When provided with onGuestsChange, enables the integrated guests popover.

onGuestsChange?:

(value: number | null) => void
Called when the guest count changes in the integrated popover.

pets?:

number | null
Current pet count for the integrated guests popover.

onPetsChange?:

(value: number | null) => void
Called when the pet count changes in the integrated popover.

petsAllowed?:

boolean
Whether pets are allowed. When false, the pets row is rendered disabled with a value of 0.

maxOccupancy?:

number
Maximum number of guests in the popover NumberInput. Defaults to 16.

maxPets?:

number
Maximum number of pets in the popover NumberInput. Defaults to 5.

guestLabel?:

string
Display label for guests, e.g. "3 guests, 1 pet".

pricing?:

BookingPanelPricing | null
Pricing info with formatted total price and night count. Null hides pricing.

pricingLoading?:

boolean
When true, shows a skeleton shimmer in the pricing area. Defaults to false.

showTotalBeforeTaxesCopy?:

boolean
When true, appends "(before taxes)" to the selected-stay pricing summary. Defaults to false.

error?:

string
Error message displayed between the inputs and the CTA. When set, date input borders turn destructive red.

submitLabel?:

string
Label for the submit button. Defaults to "Reserve" (instant) or "Request to book" (request) when dates are selected, "Add dates" otherwise.

submitDisabled?:

boolean
Disables the submit button. Defaults to false.

submitting?:

boolean
Shows loading state on the submit button. Defaults to false.

disclaimer?:

string
Disclaimer text below the CTA. Defaults to "You won't be charged yet."

bookingMode?:

"instant" | "request"
When "request", the panel shows "Request to book" as the heading and defaults the button label to "Request to book". submitLabel still overrides the label.

onCheckinClick?:

() => void
Called when the check-in date input is clicked.

onCheckoutClick?:

() => void
Called when the check-out date input is clicked.

onGuestsClick?:

() => void
Called when the guests selector is clicked. Used when the integrated popover is not enabled.

onSubmit?:

() => void
Called when the submit button is clicked.

children?:

React.ReactNode
Additional content rendered between the guests selector and the CTA.

className?:

string
Additional CSS classes for the root element.

BookingPanelMobileBar

dateRange?:

DateRange
Current date range selection. When provided with onDateChange, enables the composed calendar drawer.

onDateChange?:

(range: DateRange) => void
Called when dates change in the integrated calendar drawer.

availability?:

AvailabilityDay[]
Per-day availability data passed to the PropertyCalendarMobile.

availabilityLoading?:

boolean
Whether availability data is loading.

rules?:

BookingRule[]
Booking rules passed to the PropertyCalendarMobile.

stayRequirements?:

StayRequirements
Stay requirement constraints shown in the calendar drawer.

calendarAlert?:

ReactNode
Alert content rendered inside the calendar drawer.

checkinDate?:

string | null
Formatted check-in date string.

checkoutDate?:

string | null
Formatted check-out date string.

pricing?:

BookingPanelPricing | null
Pricing info. When present with dates, shows total and night count.

showTotalBeforeTaxesCopy?:

boolean
When true, appends "(before taxes)" to the selected-stay pricing summary. Defaults to false.

nightlyRate?:

string
Nightly rate string shown when no dates are selected, e.g. "$2,500".

rating?:

BookingPanelRating | null
Rating info shown when no dates are selected.

onPress?:

() => void
Called when the bar button is pressed.

submitLabel?:

string
Label for the button. Defaults to "Reserve" (instant) or "Request to book" (request) when dates are selected, "Select dates" otherwise.

pricingLoading?:

boolean
When true, shows a skeleton shimmer in the pricing area. Defaults to false.

submitDisabled?:

boolean
Disables the submit button. Defaults to false.

submitting?:

boolean
Shows loading state on the button. Defaults to false.

bookingMode?:

"instant" | "request"
When "request", defaults the CTA label to "Request to book" when dates are selected. submitLabel still overrides.

className?:

string
Additional CSS classes for the root element.

BookingPanelComposed

dateRange?:

DateRange
Current date range selection. When provided with onDateChange, enables the composed calendar on both desktop and mobile.

onDateChange?:

(range: DateRange) => void
Called when dates change in the integrated calendar.

availability?:

AvailabilityDay[]
Per-day availability data passed to the calendar.

availabilityLoading?:

boolean
Whether availability data is loading.

rules?:

BookingRule[]
Booking rules passed to the calendar.

stayRequirements?:

StayRequirements
Stay requirement constraints shown in the calendar (e.g. min nights, check-in days).

calendarAlert?:

ReactNode
Alert content rendered inside the calendar.

onDatesSelected?:

(range: DateRange) => void
Called when both check-in and check-out dates are committed. Use this to trigger a pricing API request.

checkinDate?:

string | null
Formatted check-in date string. Null or undefined shows placeholder.

checkoutDate?:

string | null
Formatted check-out date string. Null or undefined shows placeholder.

guests?:

number | null
Current guest count. When provided with onGuestsChange, enables the integrated guests popover on desktop.

onGuestsChange?:

(value: number | null) => void
Called when the guest count changes in the integrated popover.

pets?:

number | null
Current pet count for the integrated guests popover.

onPetsChange?:

(value: number | null) => void
Called when the pet count changes in the integrated popover.

petsAllowed?:

boolean
Whether pets are allowed. When false, the pets row is rendered disabled with a value of 0.

maxOccupancy?:

number
Maximum number of guests in the popover NumberInput. Defaults to 16.

maxPets?:

number
Maximum number of pets in the popover NumberInput. Defaults to 5.

pricing?:

BookingPanelPricing | null
Pricing info with formatted total price and night count. Null hides pricing.

pricingLoading?:

boolean
When true, shows a skeleton shimmer in the pricing area.

showTotalBeforeTaxesCopy?:

boolean
When true, appends "(before taxes)" to selected-stay pricing summaries on desktop and mobile. Defaults to false.

guestLabel?:

string
Guest display label. Auto-computed from guests/pets via formatGuestLabel if omitted.

pricePerNight?:

string
Pre-formatted price per night shown in the desktop calendar header.

submitLabel?:

string
Submit button label. Overrides defaults ("Reserve"/"Add dates" on desktop, "Save"/"Select dates" on mobile).

submitDisabled?:

boolean
Disables the submit button on both desktop and mobile.

submitting?:

boolean
Shows loading state on both desktop and mobile submit buttons.

disclaimer?:

string
Disclaimer text below the desktop CTA. Defaults to "You won't be charged yet."

error?:

string
Error message displayed between the inputs and the CTA, with destructive styling on date inputs.

bookingMode?:

"instant" | "request"
When "request", shows "Request to book" heading on desktop and defaults the CTA to "Request to book".

onSubmit?:

() => void
Called when the desktop reserve button is clicked.

onPress?:

() => void
Called when the mobile bar CTA is pressed after dates are selected.

nightlyRate?:

string
Pre-formatted nightly rate shown in the mobile bar before dates are selected.

rating?:

BookingPanelRating | null
Rating shown in the mobile bar when no dates are selected.

children?:

React.ReactNode
Additional content rendered between the desktop guests selector and the CTA.

className?:

string
Additional CSS classes for the root element.

Utilities

formatGuestLabel

4 lines
function formatGuestLabel(
  guests: number | null | undefined,
  pets: number | null | undefined
): string

Returns a formatted string like "3 guests, 1 pet" or "2 guests". Returns an empty string when both values are null/undefined. Handles singular/plural forms automatically.

BookingPanelCalendarProps

The shared calendar integration interface extended by both BookingPanel and BookingPanelMobileBar.

13 lines
interface BookingPanelCalendarProps {
  dateRange?: DateRange;
  onDateChange?: (range: DateRange) => void;
  availability?: AvailabilityDay[];
  availabilityLoading?: boolean;
  rules?: BookingRule[];
  stayRequirements?: StayRequirements;
  calendarAlert?: ReactNode;
  onDatesSelected?: (range: DateRange) => void;
  onError?: () => void;
  calendarOpen?: boolean;
  onCalendarOpenChange?: (open: boolean) => void;
}

BookingPanelGuestsProps

The guest management interface extended by BookingPanel.

9 lines
interface BookingPanelGuestsProps {
  guests?: number | null;
  onGuestsChange?: (value: number | null) => void;
  pets?: number | null;
  onPetsChange?: (value: number | null) => void;
  petsAllowed?: boolean;
  maxOccupancy?: number; // default 16
  maxPets?: number; // default 5
}

BookingPanelPricing

4 lines
type BookingPanelPricing = {
  totalPrice: string;
  nightCount: number;
};

BookingPanelRating

4 lines
type BookingPanelRating = {
  stars: number;
  reviewCount: number;
};
BookingPanel