@everybooking/widget-sdk

Everybooking SDK Documentation

React components, hooks, and an API client for building booking funnels against the Everybooking backend. Install with pnpm add @everybooking/widget-sdk and import everything you need from a single package.

Getting Started

The Everybooking SDK is available globally as `window.__EverybookingAPI`. It auto-initializes on page load, creates a session, and exposes high-level methods for building booking funnels.

Quick Start

Minimal full-flow example showing the complete booking lifecycle.

Example

import InvoiceWidget from 'widget-sdk/InvoiceWidget';

export default function QuickBooking() {
  const api = window.__EverybookingAPI;
  const [products, setProducts] = useState([]);
  const [sdkReady, setSdkReady] = useState(false);

  useEffect(() => {
    async function init() {
      const [data] = await Promise.all([
        api.getProducts(),
        api.ready()
      ]);
      setProducts(data);
      setSdkReady(true);
    }
    init();
  }, []);

  // 1. Select product → 2. syncBooking → 3. generateInvoice
  // → 4. InvoiceWidget displays → 5. completeBooking
}

Initialization

The SDK auto-initializes on page load. Use these methods to wait for readiness and access session identifiers.

api.ready()

Returns a Promise that resolves when the SDK session is initialized. Call this on mount before using write methods. Read methods (getProducts, getCategories) work immediately without waiting.

Returns: Promise<SDK>

Example

useEffect(() => {
  api.ready().then(() => {
    console.log('SDK is ready, session:', api.getSessionId());
  });
}, []);

api.onSessionReady(callback)

Registers a callback that fires when the session is ready. If already ready, fires immediately.

Parameters

Name Type Required Description
callback function(sdk) yes Called with the SDK instance when ready

Returns: void

Example

api.onSessionReady((sdk) => {
  console.log('Session ID:', sdk.getSessionId());
});

api.getResponseId()

Returns the internal response ID for the current session.

Returns: string

Example

const responseId = api.getResponseId();

api.getSessionId()

Returns the session ID. Required for InvoiceWidget's sessionId prop. Available after ready() resolves.

Returns: string

Example

<InvoiceWidget sessionId={api.getSessionId()} />

Read Methods

These methods fetch data from the API and work immediately — no need to await ready().

api.getProducts(params?)

Fetches all products, optionally filtered by category.

Parameters

Name Type Required Description
params object no Optional filter: { category_id: number }

Returns: Promise<Array<Product>>

Example

// Fetch all products
const products = await api.getProducts();

// Fetch products in a specific category
const filtered = await api.getProducts({ category_id: 5 });

api.getCategories()

Fetches all categories with their nested products.

Returns: Promise<Array<Category>>

Example

const categories = await api.getCategories();
categories.forEach(cat => {
  console.log(cat.name, cat.products.length, 'products');
});

api.getAvailability(productId, opts)

Checks availability for a product over a date range.

Parameters

Name Type Required Description
productId number yes The product ID
opts.startDate string yes Start date as 'YYYY-MM-DD'
opts.endDate string no End date as 'YYYY-MM-DD' (defaults to startDate)

Returns: Promise<{ available, price_for_dates, total_available, next_available_date, ... }>

Example

const avail = await api.getAvailability(42, {
  startDate: '2026-04-01',
  endDate: '2026-04-05'
});
if (avail.available) {
  console.log('Price:', avail.price_for_dates);
}

api.getTimeslots(productId, date)

Fetches available timeslots for a product on a specific date. REQUIRED for timeslot products — without a timeslot in syncBooking, the product is silently omitted from the invoice.

Parameters

Name Type Required Description
productId number yes The product ID
date string yes Date as 'YYYY-MM-DD'

Returns: Promise<Array<{ id, sku, available_slots: [[{ ts, start, end }]], dates: [...] }>>

Example

// Check if a product needs a timeslot:
const needsTimeslot = product.timeslots_enabled || product.booking_unit === 'Timeslots' || product.booking_unit === 'TS';

if (needsTimeslot) {
  const data = await api.getTimeslots(product.id, '2026-04-01');
  // Response: [{ id: 42, sku: "...", available_slots: [[{ ts: "0900-1200", start: "9:00 AM", end: "12:00 PM" }]], dates: [...] }]
  const entry = Array.isArray(data) ? data.find(d => String(d.id) === String(product.id)) : null;
  const slots = entry?.available_slots?.[0] || [];

  // Render a dropdown:
  // <select value={timeslot} onChange={e => setTimeslot(e.target.value)}>
  //   <option value="">Select a timeslot...</option>
  //   {slots.map(s => <option key={s.ts} value={s.ts}>{s.start} - {s.end}</option>)}
  // </select>

  // Pass to syncBooking:
  // { sku: product.sku, startDate, quantities: {...}, timeslot: selectedSlot.ts }
}

Write Methods

These methods modify server state. They auto-await the SDK session — no need to manually await ready() before calling them (though it's best practice to call ready() on mount).

api.syncBooking(opts)

High-level method that syncs booking selections and customer info to the server. The SDK handles SKU keys, date expansion, parameter report_ids, and key normalization automatically.

Parameters

Name Type Required Description
opts.products Array<object> yes [{ sku, startDate, endDate?, quantities: { [reportId]: number }, timeslot? }]
opts.customerInfo object no { first_name, last_name, email, phone, ... } — keys auto-normalized
opts.stepId string no Optional step identifier

Returns: Promise<{ success, synced_at }>

Example

await api.syncBooking({
  products: [{
    sku: selectedProduct.sku,
    startDate: '2026-04-01',
    endDate: '2026-04-03',
    quantities: { adults: 2, children: 1 } // Use report_id values!
  }],
  customerInfo: {
    first_name: 'Jane',
    last_name: 'Doe',
    email: '[email protected]',
    phone: '555-0123'
  }
});

api.generateInvoice(options?)

Generates the invoice on the server. Call AFTER syncBooking(). Uses the stored session internally.

Parameters

Name Type Required Description
options.status string no Status ID to assign to the new booking
options.invoiceTemplate string no Booking ID of a template to clone for line items
options.paymentSetting string no DepositSetting record ID for payment terms
options.liquidVariable string no Prefix for liquid variables (e.g. 'master')
options.parentBookingId string no Parent booking ID for nested/group bookings
options.masterBooking string no Booking ID to use as master (copies line items)
options.redirect boolean no Whether to redirect to invoice dashboard (default false)

Returns: Promise<{ success, booking_id?, ... }>

Example

// Simplest form — no options needed
await api.generateInvoice();

// With options
await api.generateInvoice({
  status: '5',
  paymentSetting: '12'
});

api.completeBooking(extraData?)

Finalizes the booking. Call as the LAST step. Marks the session as complete.

Parameters

Name Type Required Description
extraData object no Optional extra data to include

Returns: Promise<{ success, booking_id, booking_reference }>

Example

const result = await api.completeBooking();
console.log('Booking ref:', result.booking_reference);

api.processPayment(bookingId, paymentData?)

Processes payment for a booking.

Parameters

Name Type Required Description
bookingId string yes The booking ID
paymentData object no Payment details

Returns: Promise<{ success, transaction_id? }>

Example

const payment = await api.processPayment(bookingId, {
  // payment details
});

api.renderLiquid(template)

Renders a Liquid template server-side and returns the result.

Parameters

Name Type Required Description
template string yes Liquid template string

Returns: Promise<{ result: string }>

Example

const { result } = await api.renderLiquid(
  'Hello {{ customer.first_name }}, your total is {{ booking.total }}'
);

Session Persistence & Utilities

The SDK automatically persists sessions to localStorage. On page reload, the previous session is restored. If the stored session has expired, a fresh one is created automatically.

api.getSessionData()

Returns the full response data from session initialization, including step_id and form values. Use on mount to restore widget state after a page refresh.

Returns: object|null

Example

// Restore step on mount
useEffect(() => {
  api.ready().then(() => {
    const data = api.getSessionData();
    if (data?.response?.data?.step_id) {
      setStep(data.response.data.step_id);
    }
  });
}, []);

api.isRestoredSession()

Returns true if the current session was restored from localStorage (i.e., the page was refreshed and the previous session was reused).

Returns: boolean

Example

api.ready().then(() => {
  if (api.isRestoredSession()) {
    console.log('Welcome back! Resuming your session.');
  }
});

api.startOver(opts)

Clears the stored session from localStorage and reloads the page, creating a brand-new session. Shows a built-in styled confirmation modal first — call it directly; NEVER wrap it in window.confirm() or any native browser dialog.

Parameters

Name Type Required Description
opts.skipConfirm boolean no Restart immediately without the built-in modal (use only when your own UI already confirmed)
opts.title string no Modal title (default 'Start over?')
opts.message string no Modal body copy
opts.confirmLabel string no Confirm button text (default 'Start over')
opts.cancelLabel string no Cancel button text (default 'Cancel')

Returns: Promise<boolean> — true if restarting (page reloads), false if cancelled

Example

api.startOver(); // shows styled confirm modal, then clears session + reloads

api.submitSupportTicket(opts)

Sends a support ticket to the business. The session ID and page URL are attached automatically.

Parameters

Name Type Required Description
opts.name string yes Customer's name
opts.email string yes Customer's email
opts.message string yes Support message

Returns: Promise<{ success: boolean, id: number }>

Example

await api.submitSupportTicket({
  name: 'Jane Doe',
  email: '[email protected]',
  message: 'I need help with my booking'
});

Pre-Built SDK Components

Ready-to-use React components that can be imported from widget-sdk/. All styles are inline — no CSS or Tailwind dependency needed for external embeds.

StartOverButton

Import with: `import StartOverButton from 'widget-sdk/StartOverButton';` A button that restarts the funnel when clicked. Uses api.startOver(), which shows the SDK's built-in styled confirmation modal before clearing the session and reloading — no native confirm() dialog.

Parameters

Name Type Required Description
label string no Button text (default: 'Start Over')
style object no Inline style overrides
className string no CSS/Tailwind classes
onClick function no Additional click handler (called before startOver)

Returns: JSX.Element

Example

import StartOverButton from 'widget-sdk/StartOverButton';

<StartOverButton />
<StartOverButton label="Reset" className="text-red-500" />

SupportButton

Import with: `import SupportButton from 'widget-sdk/SupportButton';` (or `import { SupportButton } from '@everybooking/widget-sdk/authoring';` inside funnel-local components) A button that opens a styled modal with a contact form (name, email, message). Submits a support ticket to the business via api.submitSupportTicket() — session id and page URL are attached automatically. Every funnel must expose this control, normally in the shell footer.

Parameters

Name Type Required Description
label string no Button text (default: 'Email Support: I am having trouble')
title string no Modal heading (default: 'Contact Support')
accentColor string no Submit/Close button color (default: 'var(--eb-primary, #2563eb)') — pass the brand primary
style object no Inline style overrides for the trigger button
className string no CSS/Tailwind classes for the trigger button
onSuccess function no Called with the API response on success
onError function no Called with the error on failure

Returns: JSX.Element

Example

import SupportButton from 'widget-sdk/SupportButton';

<SupportButton />
<SupportButton
  label="Need help?"
  accentColor="#7c3aed"
  onSuccess={() => console.log('Ticket sent!')}
/>

Helper & Backward Compat Methods

Utility methods and backward-compatible aliases.

api.buildBookingData(selections)

Builds the nested booking_widget object the server expects. Used internally by syncBooking — you rarely need to call this directly.

Parameters

Name Type Required Description
selections Array<object> yes [{ sku, startDate, endDate?, quantities: { [reportId]: number }, timeslot? }]

Returns: object — booking_widget structure

Example

const bookingWidget = api.buildBookingData([{
  sku: 'kayak-1hr',
  startDate: '2026-04-01',
  quantities: { adults: 2 }
}]);
// Result: { "kayak-1hr": { "20260401": { "0": { "adults": "2" } } } }

api.syncFormData(formData, stepId?)

Low-level sync for arbitrary form data. Prefer syncBooking() for booking flows.

Parameters

Name Type Required Description
formData object yes Raw form data to sync
stepId string no Optional step identifier

Returns: Promise<object>

Example

await api.syncFormData({ 'custom-field': 'value' });

api.executeAction(actionType, config?)

Low-level action trigger. Used internally by generateInvoice.

Parameters

Name Type Required Description
actionType string yes Action type (e.g., 'invoice')
config object no Action configuration

Returns: Promise<object>

Example

await api.executeAction('invoice', { data: { action: 'generate_invoice' } });

api.createSession()

No-op that returns cached session data. The SDK auto-creates sessions on load.

Returns: Promise<{ survey, response }>

Example

const session = await api.createSession();

Booking Data Format

Understanding how quantities map to booking line items is critical for correct invoice generation.

Quantity Keys (report_id)

The `quantities` object passed to syncBooking MUST use `report_id` values from `product.parameters` as keys. If the keys don't match any report_id, the booking will have ZERO line items (empty invoice).

Example

// product.parameters returns:
// [{ id: 14, name: "qty", report_id: "qty", default_value: 1, controls_inventory: true },
//  { id: 31, name: "Adults", report_id: "adultnames", default_value: 2, controls_inventory: false }]

// ✅ CORRECT — iterate ALL parameters, use report_id as keys, respect controls_inventory
const quantities = {};
product.parameters.forEach(p => {
  if (p.controls_inventory) {
    // Inventory parameter: start at 0 (product not selected). User sets > 0 to add.
    quantities[p.report_id] = 0;
  } else {
    // Non-inventory parameter: use default with min/max clamping
    const min = p.min || 0;
    const max = p.max || Infinity;
    const def = p.default_value || min || 1;
    quantities[p.report_id] = Math.max(min, Math.min(def, max));
  }
});
// Result: { "adults": 1, "children": 0 }

// ✅ On number inputs, enforce min/max:
// <input type="number" min={p.min || 0} max={p.max || undefined} ... />

// ❌ WRONG — never invent keys; always read report_id from product.parameters
// { quantity: 1 }      — invented key, not from product.parameters
// { guests: 2 }        — invented key, not from product.parameters
// { "Adults": 1 }      — case-sensitive, use report_id not parameter name

Internal Mapping

How syncBooking transforms your selections into the server's booking_widget format.

Example

// Given: product.sku = "kayak-1hr"
//        product.parameters = [{ report_id: "adults" }, { report_id: "children" }]

// Your call — ALWAYS include bookingUnit: product.booking_unit:
syncBooking({
  products: [{
    sku: "kayak-1hr",
    startDate: "2026-03-20",
    endDate: "2026-03-21",
    quantities: { "adults": 2, "children": 1 },
    bookingUnit: product.booking_unit
  }]
});

// SDK internally builds:
// booking_widget["kayak-1hr"]["20260320"]["0"]["adults"] = "2"
// booking_widget["kayak-1hr"]["20260320"]["0"]["children"] = "1"
// booking_widget["kayak-1hr"]["20260321"]["0"]["adults"] = "2"
// booking_widget["kayak-1hr"]["20260321"]["0"]["children"] = "1"

// Server creates: one booking item per date per resource

InvoiceWidget Component

A pre-built real-time invoice component that subscribes to ActionCable and auto-updates when the invoice is generated.

InvoiceWidget

Import with: `import InvoiceWidget from 'widget-sdk/InvoiceWidget';` A React component that renders invoice data in real-time via ActionCable WebSocket subscription.

Parameters

Name Type Required Description
sessionId string yes Use api.getSessionId() after ready()
bookingId string no If booking already exists
currencyId string no Currency code (default 'USD')
className string no Tailwind classes for wrapper div
itemClassName string no Tailwind classes for each item row
loadingClassName string no Tailwind classes for loading state
renderItem function no (item, index) => JSX for custom item rendering
onBookingReceived function no callback(bookingData) when booking data arrives

Returns: JSX.Element

Example

import InvoiceWidget from 'widget-sdk/InvoiceWidget';

// Basic usage
<InvoiceWidget
  sessionId={api.getSessionId()}
  currencyId="USD"
  className="bg-white rounded-xl shadow-lg p-6"
  onBookingReceived={(booking) => setBookingId(booking.id)}
/>

// Custom item rendering
<InvoiceWidget
  sessionId={api.getSessionId()}
  renderItem={(item, index) => (
    <div key={index} className="flex justify-between py-2">
      <span>{item.resource_name}</span>
      <span>${item.total}</span>
    </div>
  )}
/>

Data Structures

Reference for the shapes of objects returned by API methods.

Product

Shape of a product object returned by getProducts().

Parameters

Name Type Required Description
id number yes Product ID
name string yes Product name (safe to render)
sku string yes Unique SKU identifier (safe to render)
stock number yes Available stock count (safe to render)
booking_unit string yes Unit for booking (e.g., 'night', 'hour')
pricing_unit string yes Unit for pricing display
category_name string yes Name of the parent category
details string no Short description (HTML from server — render with dangerouslySetInnerHTML)
more_details string no Extended description (HTML from server — render with dangerouslySetInnerHTML)
available_stock number no Currently available stock
price object yes OBJECT — never render directly. Access: Object.values(product.price)?.[0]?.base_price
rules object no OBJECT — booking rules
stock_level_status object no OBJECT — never render directly
tags Array<object> no ARRAY — render with: product.tags?.map(t => t.name).join(', ')
images Array<object> no ARRAY — use: product.images?.map(img => img.url)
parameters Array<object> yes [{ id, name, report_id, default_value, controls_inventory, parent_id, min, max }] — min/max are the allowed quantity range for each parameter

Example

// Safe rendering patterns:
<h3>{product.name}</h3>
{/* details and more_details are HTML — use dangerouslySetInnerHTML */}
<div dangerouslySetInnerHTML={{ __html: product.details || '' }} />
<div dangerouslySetInnerHTML={{ __html: product.more_details || '' }} />
<p>From {Object.values(product.price || {})?.[0]?.base_price || 'N/A'} per {product.pricing_unit}</p>
<p>Tags: {product.tags?.map(t => t.name).join(', ')}</p>
{product.images?.map(img => <img key={img.url} src={img.url} alt={product.name} />)}

Category

Shape of a category object returned by getCategories().

Parameters

Name Type Required Description
id number yes Category ID
name string yes Category name
products Array<Product> yes Array of products in this category

Example

const categories = await api.getCategories();
categories.map(cat => (
  <div key={cat.id}>
    <h2>{cat.name}</h2>
    {cat.products.map(p => <p key={p.id}>{p.name}</p>)}
  </div>
))

CustomerInfo Key Normalization

The SDK normalizes customer info keys to the hyphenated format the backend expects.

Parameters

Name Type Required Description
email / emailAddress / email_address string no → email-address
firstName / first_name string no → first-name
lastName / last_name string no → last-name
phone string no → phone
phoneNumber / phone_number string no → phone-number
address string no → address
company string no → company
city string no → city
region string no → region
country string no → country
postalZip / postal_zip string no → postal-zip

Example

// All of these are equivalent:
customerInfo: { first_name: 'Jane' }
customerInfo: { firstName: 'Jane' }
// Both become: { "first-name": "Jane" }

ActionCable Channels

Real-time WebSocket channels used for live updates.

SurveyInvoiceChannel

Used internally by InvoiceWidget to receive real-time invoice updates. Subscribes using the session ID and broadcasts booking data when an invoice is generated.

Parameters

Name Type Required Description
session_id string yes The session ID from api.getSessionId()

Example

// You don't need to subscribe manually — InvoiceWidget handles this.
// But if you need custom real-time behavior:
// The channel broadcasts booking data in the same shape as
// onBookingReceived callback.

BookingsChannel

Used for real-time booking status updates across the admin interface.

Common Mistakes

Pitfalls to avoid when building booking funnels with the SDK.

Empty Bookings / Missing Line Items

The most common issue is producing empty invoices with zero line items.

Example

// ❌ #1 BUG: Using parameters[0] — ONLY gets the first parameter, misses the rest!
const param = (product.parameters || [])[0];   // WRONG — products have MULTIPLE parameters
quantities: { [param.report_id]: qty }         // WRONG — missing all other parameters

// ❌ Using product.id or product.name as quantity keys
quantities: { [product.id]: 1 }              // WRONG — id is not a report_id
quantities: { [product.name]: 1 }             // WRONG — name is not a report_id
quantities: { "quantity": 1 }                 // WRONG — invented key, not from product.parameters

// ✅ Always iterate ALL parameters and use EVERY report_id
// Products typically have 2–3 parameters (e.g., "qty" + "adultnames" + "childname")
const quantities = {};
(product.parameters || []).forEach(p => {
  if (p.controls_inventory) {
    quantities[p.report_id] = 0; // Inventory param: 0 = not selected, > 0 = add to booking
  } else {
    const min = p.min || 0;
    const max = p.max || Infinity;
    quantities[p.report_id] = Math.max(min, Math.min(p.default_value || min || 1, max));
  }
});

// ❌ Calling generateInvoice without syncBooking
await api.generateInvoice();  // Empty booking!

// ✅ Always sync first
await api.syncBooking({ products: [...], customerInfo: {...} });
await api.generateInvoice();

// ❌ Missing startDate
{ sku: "kayak", quantities: { adults: 2 } }  // Skipped!

// ✅ Always include startDate
{ sku: "kayak", startDate: "2026-04-01", quantities: { adults: 2 } }

// ❌ All quantities set to zero
{ sku: "kayak", startDate: "2026-04-01", quantities: { adults: 0 } }

// ✅ At least one quantity > 0
{ sku: "kayak", startDate: "2026-04-01", quantities: { adults: 1 } }

// ❌ Ignoring parameter min/max constraints
// param.min = 2, param.max = 8, but user sets 15 → server rejects

// ✅ Always enforce min/max on inputs and clamp values
// <input type="number" min={p.min || 0} max={p.max || undefined} />
// Math.max(p.min || 0, Math.min(value, p.max || Infinity))

Rendering Objects as JSX

Never render product objects directly — always access specific string/number fields.

Example

// ❌ "Objects are not valid as a React child" errors
<p>{product.price}</p>              // price is an object!
<p>{product.stock_level_status}</p> // object!
<p>{product.tags}</p>               // array of objects!

// ✅ Access specific fields
<p>{Object.values(product.price || {})?.[0]?.base_price || 'N/A'}</p>
<p>{product.tags?.map(t => t.name).join(', ')}</p>
{product.images?.map(img => <img src={img.url} />)}

Complete Working Example

A full annotated booking funnel showing the complete flow from product selection to booking confirmation.

Full Booking Funnel

This example demonstrates the complete booking lifecycle: initialization, product selection, date/quantity selection, invoice generation, and booking completion.

Example

import InvoiceWidget from 'widget-sdk/InvoiceWidget';
import StartOverButton from 'widget-sdk/StartOverButton';
import SupportButton from 'widget-sdk/SupportButton';
import { buildParameterTree, initQuantities, clampQuantity } from 'widget-sdk/utils';

// ─── Date Range Picker Component ───
function DateRangePicker({ startDate, endDate, onStartChange, onEndChange }) {
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  const [viewDate, setViewDate] = useState(() => {
    if (startDate) { const d = new Date(startDate + "T00:00:00"); return { year: d.getFullYear(), month: d.getMonth() }; }
    return { year: today.getFullYear(), month: today.getMonth() };
  });
  const [hoverDate, setHoverDate] = useState(null);
  const [selectingEnd, setSelectingEnd] = useState(false);

  const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
  const MONTHS = ["January", "February", "March", "April", "May", "June",
    "July", "August", "September", "October", "November", "December"];

  function toStr(d) {
    return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
  }
  function fromStr(s) { return s ? new Date(s + "T00:00:00") : null; }

  function daysInMonth(y, m) { return new Date(y, m + 1, 0).getDate(); }
  function firstDayOfWeek(y, m) { return new Date(y, m, 1).getDay(); }

  function prevMonth() {
    setViewDate(v => v.month === 0 ? { year: v.year - 1, month: 11 } : { year: v.year, month: v.month - 1 });
  }
  function nextMonth() {
    setViewDate(v => v.month === 11 ? { year: v.year + 1, month: 0 } : { year: v.year, month: v.month + 1 });
  }

  function handleDayClick(date) {
    const str = toStr(date);
    if (!selectingEnd || !startDate) {
      onStartChange(str);
      onEndChange("");
      setSelectingEnd(true);
    } else {
      if (date < fromStr(startDate)) {
        onStartChange(str);
        onEndChange("");
      } else {
        onEndChange(str);
        setSelectingEnd(false);
      }
    }
  }

  function isInRange(date) {
    const s = fromStr(startDate);
    const e = fromStr(endDate) || (selectingEnd && hoverDate ? hoverDate : null);
    if (!s || !e) return false;
    const lo = s < e ? s : e;
    const hi = s < e ? e : s;
    return date > lo && date < hi;
  }

  function isStart(date) { return startDate && toStr(date) === startDate; }
  function isEnd(date) {
    if (endDate) return toStr(date) === endDate;
    if (selectingEnd && hoverDate) return toStr(date) === toStr(hoverDate);
    return false;
  }

  function renderMonth(year, month) {
    const total = daysInMonth(year, month);
    const offset = firstDayOfWeek(year, month);
    const cells = [];
    for (let i = 0; i < offset; i++) cells.push(<div key={"e" + i} />);
    for (let d = 1; d <= total; d++) {
      const date = new Date(year, month, d);
      const isPast = date < today;
      const start = isStart(date);
      const end = isEnd(date);
      const inRange = isInRange(date);
      const isToday = toStr(date) === toStr(today);

      let cls = "relative w-10 h-10 flex items-center justify-center text-sm rounded-full transition-all ";
      if (isPast) {
        cls += "text-gray-300 cursor-not-allowed";
      } else if (start || end) {
        cls += "bg-blue-600 text-white font-semibold shadow-sm";
      } else if (inRange) {
        cls += "bg-blue-100 text-blue-800 hover:bg-blue-200 cursor-pointer";
      } else {
        cls += "hover:bg-gray-100 cursor-pointer " + (isToday ? "font-bold text-blue-600 ring-1 ring-blue-300" : "text-gray-700");
      }

      cells.push(
        <button key={d} disabled={isPast} className={cls}
          onClick={() => !isPast && handleDayClick(date)}
          onMouseEnter={() => !isPast && selectingEnd && setHoverDate(date)}
        >
          {d}
        </button>
      );
    }
    return cells;
  }

  const m1 = viewDate;
  const m2 = m1.month === 11 ? { year: m1.year + 1, month: 0 } : { year: m1.year, month: m1.month + 1 };
  const canGoPrev = m1.year > today.getFullYear() || (m1.year === today.getFullYear() && m1.month > today.getMonth());

  const sDate = fromStr(startDate);
  const eDate = fromStr(endDate);
  function formatDisplay(d) {
    if (!d) return "—";
    return MONTHS[d.getMonth()].slice(0, 3) + " " + d.getDate() + ", " + d.getFullYear();
  }

  return (
    <div>
      {/* Selected range display */}
      <div className="flex items-center gap-3 mb-4">
        <div className={"flex-1 p-3 rounded-lg border-2 text-center transition-colors " +
          (!selectingEnd || !startDate ? "border-blue-500 bg-blue-50" : "border-gray-200")}>
          <div className="text-xs text-gray-500 mb-0.5">Check-in</div>
          <div className={"font-semibold " + (startDate ? "text-gray-900" : "text-gray-400")}>{formatDisplay(sDate)}</div>
        </div>
        <svg className="w-5 h-5 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
        </svg>
        <div className={"flex-1 p-3 rounded-lg border-2 text-center transition-colors " +
          (selectingEnd && startDate ? "border-blue-500 bg-blue-50" : "border-gray-200")}>
          <div className="text-xs text-gray-500 mb-0.5">Check-out</div>
          <div className={"font-semibold " + (endDate ? "text-gray-900" : "text-gray-400")}>{formatDisplay(eDate)}</div>
        </div>
      </div>

      {/* Calendar grid — two months side by side */}
      <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
        {[m1, m2].map((m, mi) => (
          <div key={mi}>
            <div className="flex items-center justify-between mb-3">
              {mi === 0 ? (
                <button onClick={prevMonth} disabled={!canGoPrev}
                  className={"w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 " + (!canGoPrev ? "invisible" : "")}>
                  &#8249;
                </button>
              ) : <div className="w-8" />}
              <span className="font-semibold text-gray-800">{MONTHS[m.month]} {m.year}</span>
              {mi === 1 ? (
                <button onClick={nextMonth}
                  className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100">
                  &#8250;
                </button>
              ) : <div className="w-8" />}
            </div>
            <div className="grid grid-cols-7 gap-y-1 mb-1">
              {DAYS.map(d => (
                <div key={d} className="w-10 h-8 flex items-center justify-center text-xs font-medium text-gray-400">{d}</div>
              ))}
            </div>
            <div className="grid grid-cols-7 gap-y-1" onMouseLeave={() => setHoverDate(null)}>
              {renderMonth(m.year, m.month)}
            </div>
          </div>
        ))}
      </div>

      {startDate && endDate && (
        <div className="mt-4 text-center text-sm text-gray-500">
          {Math.round((eDate - sDate) / 86400000)} night{Math.round((eDate - sDate) / 86400000) !== 1 ? "s" : ""} selected
        </div>
      )}
    </div>
  );
}

// ─── Image Slider Component ───
function ImageSlider({ images }) {
  const [current, setCurrent] = useState(0);
  const imgs = images || [];
  if (imgs.length === 0) return null;
  if (imgs.length === 1) return (
    <img src={imgs[0].url} alt="" className="w-full h-56 object-cover rounded-lg" />
  );
  return (
    <div className="relative w-full h-56 overflow-hidden rounded-lg group">
      <img src={imgs[current].url} alt="" className="w-full h-full object-cover transition-opacity duration-300" />
      <button onClick={(e) => { e.stopPropagation(); setCurrent((current - 1 + imgs.length) % imgs.length); }}
        className="absolute left-2 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full w-8 h-8 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
        &#8249;
      </button>
      <button onClick={(e) => { e.stopPropagation(); setCurrent((current + 1) % imgs.length); }}
        className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full w-8 h-8 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
        &#8250;
      </button>
      <div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1.5">
        {imgs.map((_, i) => (
          <button key={i} onClick={(e) => { e.stopPropagation(); setCurrent(i); }}
            className={"w-2 h-2 rounded-full transition-colors " + (i === current ? "bg-white" : "bg-white/50")} />
        ))}
      </div>
    </div>
  );
}

// ─── HTML Description Component ───
function HtmlContent({ html, className }) {
  if (!html) return null;
  return <div className={className} dangerouslySetInnerHTML={{ __html: html }} />;
}

// ─── Product Card (toggleable — click to add/remove) ───
function ProductCard({ product, selected, onToggle, stockLabel }) {
  return (
    <button onClick={() => onToggle(product)}
      className={"flex flex-col border rounded-xl overflow-hidden text-left transition-all " +
        (selected ? "border-blue-500 ring-2 ring-blue-200" : "border-gray-200 hover:border-gray-400 hover:shadow-md")}>
      <ImageSlider images={product.images} />
      <div className="p-4 space-y-2">
        <div className="flex items-start justify-between gap-2">
          <h3 className="font-semibold text-lg">{product.name}</h3>
          <div className={"shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs mt-0.5 " +
            (selected ? "bg-blue-600 border-blue-600 text-white" : "border-gray-300")}>
            {selected && "✓"}
          </div>
        </div>
        <HtmlContent html={product.details} className="text-sm text-gray-600 line-clamp-2 [&>p]:m-0" />
        <div className="flex items-center justify-between gap-2">
          <p className="text-sm font-medium text-blue-700">
            From {Object.values(product.price || {})?.[0]?.base_price || "N/A"} per {product.pricing_unit || product.booking_unit}
          </p>
          {stockLabel && (
            <span className="text-xs text-gray-500 shrink-0">{stockLabel}</span>
          )}
        </div>
      </div>
    </button>
  );
}

// ─── Helper: does this product require a timeslot? ───
function needsTimeslot(product) {
  return product.timeslots_enabled || product.booking_unit === 'Timeslots' || product.booking_unit === 'TS';
}

// ─── Configuration panel for a selected product ───
// Uses buildParameterTree() to render nested parent-child parameters correctly
function ProductConfig({ product, config, onUpdate, onRemove, startDate }) {
  const api = window.__EverybookingAPI;
  const [slots, setSlots] = useState([]);
  const [loadingSlots, setLoadingSlots] = useState(false);

  // Build the parameter tree once — separates root params from children
  const tree = buildParameterTree(product);

  // Fetch timeslots when the product needs them and a date is selected
  useEffect(() => {
    if (!needsTimeslot(product) || !startDate) return;
    setLoadingSlots(true);
    api.getTimeslots(product.id, startDate).then(data => {
      // API returns array with product data; extract slots for this product
      const entry = Array.isArray(data) ? data.find(d => String(d.id) === String(product.id)) : null;
      const available = entry?.available_slots?.[0] || [];
      setSlots(available);
    }).catch(() => setSlots([])).finally(() => setLoadingSlots(false));
  }, [product.id, startDate]);

  function setQuantity(reportId, value, param) {
    const clamped = clampQuantity(parseInt(value) || 0, param);
    onUpdate(product.id, {
      ...config,
      quantities: { ...config.quantities, [reportId]: clamped }
    });
  }

  function setTimeslot(ts) {
    onUpdate(product.id, { ...config, timeslot: ts });
  }

  // Renders a single parameter input row
  function renderParamInput(param, indent) {
    return (
      <div key={param.report_id} className={indent ? "ml-4" : ""}>
        <label className="block text-xs font-medium text-gray-500 mb-1">
          {param.name}
          {(param.min != null || param.max != null) && (
            <span className="text-gray-400 font-normal ml-1">
              ({param.min != null ? `Min: ${param.min}` : ''}{param.min != null && param.max != null ? ', ' : ''}{param.max != null ? `Max: ${param.max}` : ''})
            </span>
          )}
        </label>
        <input type="number" min={param.min || 0} max={param.max || undefined}
          value={config.quantities[param.report_id] || 0}
          onChange={e => setQuantity(param.report_id, e.target.value, param)}
          className="w-full border rounded-lg px-3 py-2 text-sm" />
      </div>
    );
  }

  return (
    <div className="border rounded-xl p-4 space-y-3 bg-gray-50">
      <div className="flex items-center justify-between">
        <h4 className="font-semibold">{product.name}</h4>
        <button onClick={() => onRemove(product.id)}
          className="text-sm text-red-500 hover:text-red-700">Remove</button>
      </div>
      <HtmlContent html={product.more_details}
        className="text-sm text-gray-600 prose prose-sm max-w-none [&>p]:my-1" />

      {/* Timeslot selector for timeslot-enabled products */}
      {needsTimeslot(product) && (
        <div>
          <label className="block text-xs font-medium text-gray-500 mb-1">Timeslot</label>
          {loadingSlots ? (
            <div className="text-xs text-gray-400 py-2">Loading timeslots...</div>
          ) : slots.length === 0 ? (
            <div className="text-xs text-amber-600 py-2">
              {startDate ? "No timeslots available for this date" : "Select a date first"}
            </div>
          ) : (
            <select value={config.timeslot || ""}
              onChange={e => setTimeslot(e.target.value)}
              className="w-full border rounded-lg px-3 py-2 text-sm">
              <option value="">Select a timeslot...</option>
              {slots.map(slot => (
                <option key={slot.ts} value={slot.ts}>
                  {slot.start} - {slot.end}
                </option>
              ))}
            </select>
          )}
        </div>
      )}

      {/* Render parameters using the tree structure — root params with nested children */}
      {tree.roots.map(root => (
        <div key={root.report_id} className="space-y-2">
          {renderParamInput(root, false)}

          {/* Child parameters rendered nested under their parent */}
          {(tree.childrenOf[root.id] || []).map(child => (
            <div key={child.report_id} className="ml-4 pl-3 border-l-2 border-gray-200">
              <div className="text-xs text-gray-400 mb-0.5">per {root.name}</div>
              {renderParamInput(child, false)}
            </div>
          ))}
        </div>
      ))}
    </div>
  );
}

export default function BookingFunnel() {
  const api = window.__EverybookingAPI;
  const [step, setStep] = useState(0);
  const [categories, setCategories] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [submitting, setSubmitting] = useState(false);
  const [bookingRef, setBookingRef] = useState(null);
  const [sdkReady, setSdkReady] = useState(false);

  // Shared booking dates — set once on step 0, used by all products
  const [startDate, setStartDate] = useState("");
  const [endDate, setEndDate] = useState("");

  // Availability state — checked per category when entering that step
  // Shape: { [productId]: { available, available_quantity, total_available, unlimited, next_available_date } }
  const [availability, setAvailability] = useState({});
  const [checkingAvailability, setCheckingAvailability] = useState(false);
  const [checkedCategories, setCheckedCategories] = useState(new Set()); // track which categories have been checked

  // Selection state — multiple products per category (quantities only, no per-product dates)
  // Shape: { [categoryId]: { [productId]: { quantities } } }
  const [selections, setSelections] = useState({});

  const [customerInfo, setCustomerInfo] = useState({
    first_name: "", last_name: "", email: "", phone: ""
  });

  // ─── Initialize: load categories + wait for SDK session + restore state ───
  useEffect(() => {
    async function init() {
      try {
        const [cats] = await Promise.all([
          api.getCategories(),
          api.ready()
        ]);
        setCategories(cats);
        setSdkReady(true);

        // Restore widget state on page refresh
        const saved = api.getState();
        if (saved) {
          setStep(saved.step || 0);
          setStartDate(saved.startDate || "");
          setEndDate(saved.endDate || "");
          setSelections(saved.selections || {});
          setCustomerInfo(saved.customerInfo || { first_name: "", last_name: "", email: "", phone: "" });
        }
      } catch (err) {
        setError("Failed to load. Please refresh.");
      } finally {
        setLoading(false);
      }
    }
    init();
  }, []);

  // ─── Persist widget state on every change ───
  useEffect(() => {
    if (!sdkReady) return; // don't save initial empty state
    api.saveState({ step, startDate, endDate, selections, customerInfo });
  }, [step, startDate, endDate, selections, customerInfo, sdkReady]);

  // ─── Toggle product selection (add/remove from category) ───
  // Uses initQuantities() from widget-sdk/utils for correct default values,
  // then overrides controls_inventory params to 1 (since user explicitly selected).
  function toggleProduct(catId, product) {
    setSelections(prev => {
      const catSelections = { ...(prev[catId] || {}) };
      if (catSelections[product.id]) {
        delete catSelections[product.id];
      } else {
        // initQuantities sets controls_inventory params to 0 (not pre-selected),
        // but since the user is explicitly clicking to add, override to min or 1.
        const defaultQty = initQuantities(product);
        (product.parameters || []).forEach(p => {
          if (p.controls_inventory) {
            defaultQty[p.report_id] = Math.max(p.min || 1, 1);
          }
        });
        catSelections[product.id] = { quantities: defaultQty, timeslot: null };
      }
      return { ...prev, [catId]: catSelections };
    });
  }

  // ─── Update config for a specific product ───
  function updateProductConfig(catId, productId, config) {
    setSelections(prev => ({
      ...prev,
      [catId]: { ...(prev[catId] || {}), [productId]: config }
    }));
  }

  // ─── Remove a product from selection ───
  function removeProduct(catId, productId) {
    setSelections(prev => {
      const catSelections = { ...(prev[catId] || {}) };
      delete catSelections[productId];
      return { ...prev, [catId]: catSelections };
    });
  }

  // ─── Check availability for products in a specific category ───
  // Called when entering a category step — only fetches for that category's products
  async function checkCategoryAvailability(category) {
    if (!startDate || !category) return;
    const catId = category.id;
    if (checkedCategories.has(catId)) return; // already checked this category for current dates
    setCheckingAvailability(true);
    try {
      const results = { ...availability };
      await Promise.all((category.products || []).map(async (product) => {
        try {
          const res = await api.getAvailability(product.id, { startDate, endDate: endDate || startDate });
          results[product.id] = res;
        } catch {
          results[product.id] = { available: true }; // fail open
        }
      }));
      setAvailability(results);
      setCheckedCategories(prev => new Set([...prev, catId]));
    } catch {
      // fail open — don't block the flow
    } finally {
      setCheckingAvailability(false);
    }
  }

  function isProductAvailable(productId) {
    const result = availability[productId];
    if (!result) return true; // not checked yet or no data
    return result.available !== false;
  }

  function stockLabel(productId) {
    const result = availability[productId];
    if (!result) return null;
    if (result.unlimited) return "Available";
    if (!result.available) return "Unavailable";
    return `${result.available_quantity} of ${result.total_available} available`;
  }

  // Steps: 0 = dates, 1..N = one per category, N+1 = customer details, N+2 = invoice, N+3 = confirmation
  const catCount = categories.length;
  const isDateStep = step === 0;
  const catStepIndex = step - 1; // which category (0-based) when step >= 1
  const currentCategory = (step >= 1 && catStepIndex < catCount) ? categories[catStepIndex] : null;
  const currentCatSelections = currentCategory ? (selections[currentCategory.id] || {}) : {};
  const selectedProductIds = Object.keys(currentCatSelections);

  const isCustomerStep = step === catCount + 1;
  const isInvoiceStep = step === catCount + 2;
  const isConfirmStep = step === catCount + 3;

  // Count total selected products across all categories
  const totalSelected = Object.values(selections)
    .reduce((sum, catSel) => sum + Object.keys(catSel).length, 0);

  // ─── Sync booking + generate invoice ───
  async function handleGenerateInvoice() {
    setSubmitting(true);
    setError(null);
    try {
      // Build flat products array — all share the same dates
      const products = [];
      for (const [catId, catSel] of Object.entries(selections)) {
        const cat = categories.find(c => String(c.id) === String(catId));
        if (!cat) continue;
        for (const [productId, config] of Object.entries(catSel)) {
          const product = cat.products.find(p => String(p.id) === String(productId));
          if (!product) continue;
          const entry = {
            sku: product.sku,
            startDate,
            endDate: endDate || startDate,
            quantities: config.quantities,
            bookingUnit: product.booking_unit
          };
          if (config.timeslot) entry.timeslot = config.timeslot;
          products.push(entry);
        }
      }

      if (products.length === 0) {
        setError("Please select at least one product.");
        setSubmitting(false);
        return;
      }

      await api.syncBooking({ products, customerInfo });
      await api.generateInvoice();
      setStep(catCount + 2);
    } catch (err) {
      setError("Failed to generate invoice. Please try again.");
    } finally {
      setSubmitting(false);
    }
  }

  // ─── Complete booking ───
  async function handleComplete() {
    setSubmitting(true);
    try {
      const result = await api.completeBooking();
      setBookingRef(result.booking_reference);
      setStep(catCount + 3);
    } catch (err) {
      setError("Failed to complete booking. Please try again.");
    } finally {
      setSubmitting(false);
    }
  }

  if (loading) return (
    <div className="flex justify-center p-12">
      <div className="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full" />
    </div>
  );
  if (error && !sdkReady) return <div className="p-6 text-red-600">{error}</div>;

  // Step labels for the indicator
  const stepLabels = ["Dates", ...categories.map(c => c.name), "Details"];

  return (
    <div className="max-w-3xl mx-auto p-6 pb-20">
      {/* Step indicator */}
      <div className="flex items-center gap-2 mb-8 overflow-x-auto">
        {stepLabels.map((label, i) => (
          <div key={i} className="flex items-center gap-2 shrink-0">
            <button onClick={() => i < step ? setStep(i) : undefined}
              className={"w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors " +
                (step === i ? "bg-blue-600 text-white" : step > i ? "bg-green-500 text-white cursor-pointer hover:bg-green-600" : "bg-gray-200 text-gray-500")}>
              {step > i ? "✓" : i + 1}
            </button>
            <span className={"text-sm hidden sm:inline " + (step === i ? "text-blue-700 font-medium" : "text-gray-500")}>{label}</span>
            {i < stepLabels.length - 1 && <div className="w-8 h-px bg-gray-300" />}
          </div>
        ))}
      </div>

      {error && <div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>}

      {/* Persistent date banner (shown on all steps after step 0) */}
      {!isDateStep && startDate && (
        <div className="mb-6 flex items-center gap-3 p-3 bg-blue-50 rounded-lg text-sm">
          <span className="font-medium text-blue-800">Booking dates:</span>
          <span className="text-blue-700">{startDate}{endDate && endDate !== startDate ? " → " + endDate : ""}</span>
          <button onClick={() => setStep(0)} className="ml-auto text-blue-600 hover:text-blue-800 text-xs font-medium">Change</button>
        </div>
      )}

      {/* ─── Step 0: Booking date range ─── */}
      {isDateStep && (
        <div>
          <h2 className="text-2xl font-bold mb-2">When are you booking?</h2>
          <p className="text-gray-500 mb-6">Select your check-in and check-out dates.</p>
          <DateRangePicker
            startDate={startDate} endDate={endDate}
            onStartChange={setStartDate} onEndChange={setEndDate}
          />
          <button onClick={() => { setAvailability({}); setCheckedCategories(new Set()); setStep(1); }}
            disabled={!startDate}
            className="w-full mt-6 py-3 bg-blue-600 text-white rounded-lg font-medium disabled:opacity-50 hover:bg-blue-700">
            Continue
          </button>
        </div>
      )}

      {/* ─── Category product selection steps ─── */}
      {currentCategory && (
        <div>
          {/* Check availability for this category's products on first view */}
          {(() => { if (currentCategory && !checkedCategories.has(currentCategory.id) && !checkingAvailability) { checkCategoryAvailability(currentCategory); } return null; })()}

          <h2 className="text-2xl font-bold mb-2">{currentCategory.name}</h2>
          <p className="text-gray-500 mb-6">
            Select one or more products ({selectedProductIds.length} selected)
          </p>

          {/* Loading state while checking availability */}
          {checkingAvailability && (
            <div className="mb-4 p-3 bg-blue-50 rounded-lg text-sm text-blue-700 flex items-center gap-2">
              <div className="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full" />
              Checking availability for your dates...
            </div>
          )}

          {/* Availability warning */}
          {!checkingAvailability && checkedCategories.has(currentCategory.id) &&
            (currentCategory.products || []).some(p => !isProductAvailable(p.id)) && (
            <div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800">
              Some products are not available for your selected dates. Unavailable items are marked below.
            </div>
          )}

          {/* Product grid — click to toggle selection */}
          <div className="grid sm:grid-cols-2 gap-4 mb-6">
            {(currentCategory.products || []).map(product => {
              const available = isProductAvailable(product.id);
              const availData = availability[product.id];
              const stock = stockLabel(product.id);
              return (
                <div key={product.id} className={!available ? "relative opacity-60" : ""}>
                  {!available && (
                    <div className="absolute inset-0 z-10 rounded-xl bg-white/70 flex flex-col items-center justify-center text-center p-4">
                      <span className="text-red-600 font-semibold text-sm">Not available for selected dates</span>
                      {availData?.next_available_date && (
                        <span className="text-xs text-gray-500 mt-1">Next available: {availData.next_available_date}</span>
                      )}
                    </div>
                  )}
                  <ProductCard product={product}
                    selected={!!currentCatSelections[product.id]}
                    onToggle={(p) => available && toggleProduct(currentCategory.id, p)}
                    stockLabel={stock} />
                </div>
              );
            })}
          </div>

          {/* Configuration panels for each selected product */}
          {selectedProductIds.length > 0 && (
            <div className="space-y-4 mb-6">
              <h3 className="font-semibold text-gray-700">Configure your selections</h3>
              {selectedProductIds.map(productId => {
                const product = currentCategory.products.find(p => String(p.id) === String(productId));
                if (!product) return null;
                return (
                  <ProductConfig key={productId} product={product}
                    config={currentCatSelections[productId]}
                    onUpdate={(pid, config) => updateProductConfig(currentCategory.id, pid, config)}
                    onRemove={(pid) => removeProduct(currentCategory.id, pid)}
                    startDate={startDate} />
                );
              })}
            </div>
          )}

          <div className="flex gap-3">
            <button onClick={() => setStep(step - 1)}
              className="px-6 py-3 border border-gray-300 rounded-lg font-medium hover:bg-gray-100">
              Back
            </button>
            <button onClick={() => setStep(step + 1)}
              className="flex-1 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700">
              {catStepIndex < catCount - 1 ? "Next Category" : "Continue to Details"}
            </button>
          </div>
        </div>
      )}

      {/* ─── Customer details step ─── */}
      {isCustomerStep && (
        <div>
          <h2 className="text-2xl font-bold mb-6">Your Details</h2>

          {/* Summary of selected products */}
          {totalSelected > 0 && (
            <div className="mb-6 p-4 bg-blue-50 rounded-xl">
              <h3 className="text-sm font-medium text-blue-800 mb-2">Selected items ({totalSelected})</h3>
              {Object.entries(selections).map(([catId, catSel]) => {
                const cat = categories.find(c => String(c.id) === String(catId));
                return Object.entries(catSel).map(([productId]) => {
                  const product = cat?.products?.find(p => String(p.id) === String(productId));
                  if (!product) return null;
                  return (
                    <div key={productId} className="text-sm py-1">{product.name}</div>
                  );
                });
              })}
            </div>
          )}

          <div className="space-y-4">
            <div className="grid grid-cols-2 gap-4">
              <input placeholder="First Name" value={customerInfo.first_name}
                onChange={e => setCustomerInfo(prev => ({ ...prev, first_name: e.target.value }))}
                className="w-full border rounded-lg px-3 py-2" />
              <input placeholder="Last Name" value={customerInfo.last_name}
                onChange={e => setCustomerInfo(prev => ({ ...prev, last_name: e.target.value }))}
                className="w-full border rounded-lg px-3 py-2" />
            </div>
            <input placeholder="Email" type="email" value={customerInfo.email}
              onChange={e => setCustomerInfo(prev => ({ ...prev, email: e.target.value }))}
              className="w-full border rounded-lg px-3 py-2" />
            <input placeholder="Phone" type="tel" value={customerInfo.phone}
              onChange={e => setCustomerInfo(prev => ({ ...prev, phone: e.target.value }))}
              className="w-full border rounded-lg px-3 py-2" />
            <div className="flex gap-3">
              <button onClick={() => setStep(step - 1)}
                className="px-6 py-3 border border-gray-300 rounded-lg font-medium hover:bg-gray-100">
                Back
              </button>
              <button onClick={handleGenerateInvoice}
                disabled={submitting || !customerInfo.first_name || !customerInfo.email || totalSelected === 0}
                className="flex-1 py-3 bg-blue-600 text-white rounded-lg font-medium disabled:opacity-50 hover:bg-blue-700">
                {submitting ? "Generating Invoice..." : "Review Invoice"}
              </button>
            </div>
          </div>
        </div>
      )}

      {/* ─── Invoice review step ─── */}
      {isInvoiceStep && (
        <div>
          <h2 className="text-2xl font-bold mb-6">Review Your Invoice</h2>
          <InvoiceWidget sessionId={api.getSessionId()} currencyId="USD"
            className="mb-6" />
          <button onClick={handleComplete} disabled={submitting}
            className="w-full py-3 bg-green-600 text-white rounded-lg font-medium disabled:opacity-50 hover:bg-green-700">
            {submitting ? "Completing..." : "Confirm & Book"}
          </button>
        </div>
      )}

      {/* ─── Confirmation step ─── */}
      {isConfirmStep && (
        <div className="text-center py-12">
          <div className="text-5xl mb-4">✓</div>
          <h2 className="text-2xl font-bold mb-2">Booking Confirmed!</h2>
          <p className="text-gray-600 mb-6">Reference: {bookingRef}</p>
          <StartOverButton label="Book Again" className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700" />
        </div>
      )}

      {/* ─── Sticky footer — Start Over & Support ─── */}
      <div className="fixed bottom-0 left-0 right-0 z-40 bg-white/90 backdrop-blur border-t border-gray-200">
        <div className="max-w-3xl mx-auto px-6 py-2.5 flex items-center justify-between">
          <div>
            {step > 0 && !isConfirmStep && (
              <StartOverButton className="text-xs text-gray-400 hover:text-gray-600 transition-colors" />
            )}
          </div>
          <SupportButton label="Need help?" className="text-xs text-gray-400 hover:text-gray-600 transition-colors" />
        </div>
      </div>
    </div>
  );
}

Meals / Timeslot Widget Example

A focused example showing timeslot-based single-date booking with quantity selection — ideal for meal packages, activity bookings, or any product that requires a timeslot.

Meals Widget

This example demonstrates timeslot-driven booking: single date selection, timeslot fetching per product, quantity-based selection, and the critical timeslot field in syncBooking.

Example

import InvoiceWidget from 'widget-sdk/InvoiceWidget';
import StartOverButton from 'widget-sdk/StartOverButton';
import SupportButton from 'widget-sdk/SupportButton';
import { buildParameterTree, initQuantities, clampQuantity } from 'widget-sdk/utils';

// ─── Timeslot Detection Helper ───
// A product "needs a timeslot" when it has booking_unit set to a time-based
// value (e.g. "per_timeslot", "timeslot"). The SDK returns this on product objects.
// Products without timeslots use date-range availability instead.
function needsTimeslot(product) {
  return product.booking_unit === 'Timeslot' || product.timeslots_enabled;
}

// ─── Single Date Picker Component ───
// Unlike DateRangePicker (used for check-in/check-out), this picks ONE date.
// Timeslot bookings only need a single date — no endDate required.
function SingleDatePicker({ selectedDate, onDateChange }) {
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  const [viewMonth, setViewMonth] = useState(today.getMonth());
  const [viewYear, setViewYear] = useState(today.getFullYear());

  const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
  const firstDayOfWeek = new Date(viewYear, viewMonth, 1).getDay();
  const dayNames = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];

  function prevMonth() {
    if (viewMonth === 0) { setViewMonth(11); setViewYear(viewYear - 1); }
    else { setViewMonth(viewMonth - 1); }
  }

  function nextMonth() {
    if (viewMonth === 11) { setViewMonth(0); setViewYear(viewYear + 1); }
    else { setViewMonth(viewMonth + 1); }
  }

  function handleDayClick(day) {
    const d = new Date(viewYear, viewMonth, day);
    if (d < today) return;
    const yyyy = d.getFullYear();
    const mm = String(d.getMonth() + 1).padStart(2, '0');
    const dd = String(d.getDate()).padStart(2, '0');
    onDateChange(`${yyyy}-${mm}-${dd}`);
  }

  const canGoPrev = viewYear > today.getFullYear() || (viewYear === today.getFullYear() && viewMonth > today.getMonth());
  const monthLabel = new Date(viewYear, viewMonth).toLocaleString('default', { month: 'long', year: 'numeric' });

  return (
    <div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
      <div className="flex items-center justify-between mb-3">
        <button onClick={prevMonth} disabled={!canGoPrev}
          className={`p-1 rounded ${canGoPrev ? 'hover:bg-gray-100 text-gray-700' : 'text-gray-300 cursor-not-allowed'}`}>◀</button>
        <span className="font-semibold text-gray-800">{monthLabel}</span>
        <button onClick={nextMonth} className="p-1 rounded hover:bg-gray-100 text-gray-700">▶</button>
      </div>
      <div className="grid grid-cols-7 gap-1 text-center text-xs mb-1">
        {dayNames.map(d => <div key={d} className="text-gray-400 font-medium py-1">{d}</div>)}
      </div>
      <div className="grid grid-cols-7 gap-1 text-center text-sm">
        {Array.from({ length: firstDayOfWeek }).map((_, i) => <div key={`e-${i}`} />)}
        {Array.from({ length: daysInMonth }).map((_, i) => {
          const day = i + 1;
          const d = new Date(viewYear, viewMonth, day);
          const isPast = d < today;
          const dateStr = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
          const isSelected = selectedDate === dateStr;
          return (
            <button key={day} onClick={() => handleDayClick(day)} disabled={isPast}
              className={`py-1.5 rounded-lg transition-colors ${
                isSelected ? 'bg-blue-600 text-white font-bold' :
                isPast ? 'text-gray-300 cursor-not-allowed' :
                'hover:bg-blue-50 text-gray-700'
              }`}>{day}</button>
          );
        })}
      </div>
    </div>
  );
}

// ─── Timeslot Picker Component ───
// Fetches available timeslots for a specific product + date.
// Slot extraction pattern: the API returns an array of date objects;
// find the one matching the product ID, then grab available_slots[0].
function TimeslotPicker({ product, selectedDate, selectedSlot, onSlotChange }) {
  const api = window.__EverybookingAPI;
  const [slots, setSlots] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!selectedDate) { setSlots([]); return; }
    let cancelled = false;
    setLoading(true);
    api.getTimeslots(product.id, selectedDate).then(data => {
      if (cancelled) return;
      // ⚠️ Slot extraction: find the entry for THIS product, then get available_slots[0]
      const entry = data.find(d => String(d.id) === String(product.id));
      const available = entry?.available_slots?.[0] || [];
      setSlots(available);
      setLoading(false);
    }).catch(() => { if (!cancelled) { setSlots([]); setLoading(false); } });
    return () => { cancelled = true; };
  }, [selectedDate, product.id]);

  if (!selectedDate) return <p className="text-sm text-gray-400 italic">Pick a date first</p>;
  if (loading) return <p className="text-sm text-gray-400">Loading timeslots…</p>;
  if (slots.length === 0) return <p className="text-sm text-red-400">No timeslots available</p>;

  return (
    <div className="flex flex-wrap gap-2 mt-2">
      {slots.map(slot => {
        const label = `${slot.start} – ${slot.end}`;
        // ⚠️ Use slot.ts as the value — this is the timeslot key the server expects
        const isActive = selectedSlot === slot.ts;
        return (
          <button key={slot.ts} onClick={() => onSlotChange(slot.ts)}
            className={`px-3 py-1.5 rounded-full text-sm border transition-colors ${
              isActive ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-gray-700 border-gray-300 hover:border-blue-400'
            }`}>{label}</button>
        );
      })}
    </div>
  );
}

// ─── Meal Card Component ───
// Displays a single product with its timeslot picker and quantity controls.
// Uses buildParameterTree() for nested parameter rendering and clampQuantity() for input validation.
// A product is considered "in the order" when any quantity parameter > 0.
function MealCard({ product, selectedDate, config, onConfigChange }) {
  const quantities = config?.quantities || {};
  const selectedSlot = config?.timeslot || null;
  const hasQty = Object.values(quantities).some(v => v > 0);

  // Build the parameter tree — separates root params from child (nested) params
  const tree = buildParameterTree(product);

  function handleSlotChange(slot) {
    onConfigChange({ ...config, quantities, timeslot: slot });
  }

  function handleQtyChange(param, value) {
    // ⚠️ CRITICAL: Always use report_id as the quantity key, never id or name.
    // clampQuantity() handles min/max enforcement automatically.
    const clamped = clampQuantity(value, param);
    const newQty = { ...quantities, [param.report_id]: clamped };
    onConfigChange({ ...config, quantities: newQty, timeslot: selectedSlot });
  }

  // Renders +/- stepper controls for a single parameter
  function renderStepper(param, indent) {
    const currentQty = quantities[param.report_id] ?? 0;
    return (
      <div key={param.report_id} className={"flex items-center justify-between" + (indent ? " ml-4 pl-3 border-l-2 border-gray-200" : "")}>
        <div>
          <span className="text-sm text-gray-700">{param.name}</span>
          {indent && <span className="text-xs text-gray-400 ml-1">(per unit)</span>}
        </div>
        <div className="flex items-center gap-2">
          <button onClick={() => handleQtyChange(param, currentQty - 1)}
            className="w-8 h-8 rounded-full border border-gray-300 flex items-center justify-center text-gray-600 hover:bg-gray-100 disabled:opacity-40"
            disabled={currentQty <= (param.min ?? 0)}>−</button>
          <span className="w-8 text-center font-medium">{currentQty}</span>
          <button onClick={() => handleQtyChange(param, currentQty + 1)}
            className="w-8 h-8 rounded-full border border-gray-300 flex items-center justify-center text-gray-600 hover:bg-gray-100 disabled:opacity-40"
            disabled={currentQty >= (param.max ?? 99)}>+</button>
        </div>
      </div>
    );
  }

  const imgUrl = product.images?.[0]?.url;

  return (
    <div className={`rounded-xl border transition-all ${hasQty ? 'border-blue-400 shadow-md bg-blue-50/30' : 'border-gray-200 bg-white'} p-4 mb-4`}>
      <div className="flex gap-4">
        {imgUrl && <img src={imgUrl} alt={product.name} className="w-24 h-24 object-cover rounded-lg flex-shrink-0" />}
        <div className="flex-1 min-w-0">
          <h3 className="font-semibold text-gray-900 text-lg">{product.name}</h3>
          {product.description && (
            <div className="text-sm text-gray-500 mt-1 line-clamp-2" dangerouslySetInnerHTML={{ __html: product.description }} />
          )}
        </div>
      </div>

      {/* Timeslot selection */}
      <div className="mt-3">
        <p className="text-sm font-medium text-gray-700 mb-1">Select a timeslot:</p>
        <TimeslotPicker product={product} selectedDate={selectedDate} selectedSlot={selectedSlot} onSlotChange={handleSlotChange} />
      </div>

      {/* Quantity controls using parameter tree — renders root params with nested children */}
      <div className="mt-3 space-y-2">
        {tree.roots.map(root => (
          <div key={root.report_id} className="space-y-1">
            {renderStepper(root, false)}
            {/* Child parameters rendered nested under their parent */}
            {(tree.childrenOf[root.id] || []).map(child =>
              renderStepper(child, true)
            )}
          </div>
        ))}
      </div>
    </div>
  );
}

// ─── Main Meals Widget ───
// 4-step flow: Browse → Customer Details → Invoice → Confirmation
// Demonstrates timeslot-based single-date booking with quantity selection.
export default function MealsWidget() {
  const api = window.__EverybookingAPI;

  const [step, setStep] = useState(0);
  const [products, setProducts] = useState([]);
  const [selectedDate, setSelectedDate] = useState(null);
  // configs shape: { [productId]: { quantities: { [reportId]: number }, timeslot: string|null } }
  const [configs, setConfigs] = useState({});
  const [customerInfo, setCustomerInfo] = useState({ first_name: '', last_name: '', email: '', phone: '' });
  const [bookingRef, setBookingRef] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [submitting, setSubmitting] = useState(false);
  const [sdkReady, setSdkReady] = useState(false);

  // Initialize: fetch products + wait for SDK + restore state
  useEffect(() => {
    async function init() {
      try {
        const [allProducts] = await Promise.all([api.getProducts(), api.ready()]);
        // Filter to only timeslot-enabled products
        const timeslotProducts = allProducts.filter(needsTimeslot);
        setProducts(timeslotProducts);
        setSdkReady(true);

        // Restore widget state on page refresh
        const saved = api.getState();
        if (saved) {
          setStep(saved.step || 0);
          setSelectedDate(saved.selectedDate || null);
          setConfigs(saved.configs || {});
          setCustomerInfo(saved.customerInfo || { first_name: '', last_name: '', email: '', phone: '' });
        }
      } catch (e) {
        setError('Failed to load products. Please refresh.');
      } finally {
        setLoading(false);
      }
    }
    init();
  }, []);

  // Persist widget state on every change
  useEffect(() => {
    if (!sdkReady) return;
    api.saveState({ step, selectedDate, configs, customerInfo });
  }, [step, selectedDate, configs, customerInfo, sdkReady]);

  function updateConfig(productId, newConfig) {
    setConfigs(prev => ({ ...prev, [productId]: newConfig }));
  }

  // A product is "selected" when it has a timeslot AND either:
  // - at least one quantity parameter > 0, or
  // - the product has no parameters (selection is implicit via timeslot)
  const selectedProducts = products.filter(p => {
    const cfg = configs[p.id];
    if (!cfg?.timeslot) return false;
    const hasParams = p.parameters && p.parameters.length > 0;
    const hasQty = Object.values(cfg.quantities || {}).some(v => v > 0);
    return !hasParams || hasQty;
  });

  // Can continue from step 0: date set + at least 1 product selected (timeslot is guaranteed by filter above)
  const canContinue = selectedDate && selectedProducts.length > 0;

  // ─── Step 1 → 2: Sync booking and generate invoice ───
  async function handleSubmitDetails() {
    setSubmitting(true);
    setError(null);
    try {
      // Build products array for syncBooking
      const bookingProducts = selectedProducts.map(p => {
        const cfg = configs[p.id];
        return {
          sku: p.sku,            // ⚠️ Must use product.sku, NOT product.id
          startDate: selectedDate, // ⚠️ camelCase — SDK expects startDate not start_date
          // endDate is optional for timeslot bookings — if provided, the SDK creates entries for each day in the range
          quantities: cfg.quantities,
          // ⚠️ CRITICAL: timeslot MUST be included here.
          // Omitting timeslot silently drops this product from the booking!
          // The SDK will not raise an error — the product simply won't appear.
          timeslot: cfg.timeslot,
          bookingUnit: p.booking_unit // Required for correct Nights date handling
        };
      });

      await api.syncBooking({
        products: bookingProducts,
        customerInfo  // ⚠️ Key must be "customerInfo", not "customer"
      });

      await api.generateInvoice();
      setStep(2);
    } catch (e) {
      setError(e.message || 'Failed to sync booking.');
    } finally {
      setSubmitting(false);
    }
  }

  // ─── Step 2 → 3: Complete the booking ───
  async function handleComplete() {
    setSubmitting(true);
    setError(null);
    try {
      const result = await api.completeBooking();
      setBookingRef(result.booking_reference);
      setStep(3);
    } catch (e) {
      setError(e.message || 'Failed to complete booking.');
    } finally {
      setSubmitting(false);
    }
  }

  if (loading) return <div className="flex justify-center py-20"><div className="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full" /></div>;

  return (
    <div className="max-w-2xl mx-auto px-4 py-8 pb-20 font-sans">
      <h1 className="text-2xl font-bold text-gray-900 mb-6">
        {step === 0 && 'Choose Your Meals'}
        {step === 1 && 'Your Details'}
        {step === 2 && 'Review & Pay'}
        {step === 3 && 'Booking Confirmed!'}
      </h1>

      {error && (
        <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
      )}

      {/* ─── Step 0: Browse ─── */}
      {step === 0 && (
        <div>
          <SingleDatePicker selectedDate={selectedDate} onDateChange={setSelectedDate} />

          {products.length === 0 && (
            <p className="text-gray-500 text-center py-8">No meal products available.</p>
          )}

          {products.map(product => (
            <MealCard
              key={product.id}
              product={product}
              selectedDate={selectedDate}
              config={configs[product.id] || { quantities: initQuantities(product), timeslot: null }}
              onConfigChange={(cfg) => updateConfig(product.id, cfg)}
            />
          ))}

          <button onClick={() => setStep(1)} disabled={!canContinue}
            className={`w-full mt-4 py-3 rounded-xl font-semibold text-white transition-colors ${
              canContinue ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-300 cursor-not-allowed'
            }`}>Continue</button>
        </div>
      )}

      {/* ─── Step 1: Customer Details ─── */}
      {step === 1 && (
        <div>
          {/* Order summary */}
          <div className="bg-gray-50 rounded-xl p-4 mb-6">
            <h3 className="font-semibold text-gray-700 mb-2">Order Summary</h3>
            <p className="text-sm text-gray-500 mb-2">Date: {selectedDate}</p>
            {selectedProducts.map(p => (
              <div key={p.id} className="flex justify-between text-sm py-1">
                <span>{p.name} ({configs[p.id]?.timeslot})</span>
                <span className="text-gray-500">
                  {Object.entries(configs[p.id]?.quantities || {}).filter(([,v]) => v > 0).map(([k,v]) => `${v}x`).join(', ')}
                </span>
              </div>
            ))}
          </div>

          {/* Customer form */}
          <div className="space-y-3">
            <div className="grid grid-cols-2 gap-3">
              <input placeholder="First Name" value={customerInfo.first_name}
                onChange={e => setCustomerInfo(prev => ({ ...prev, first_name: e.target.value }))}
                className="px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
              <input placeholder="Last Name" value={customerInfo.last_name}
                onChange={e => setCustomerInfo(prev => ({ ...prev, last_name: e.target.value }))}
                className="px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
            </div>
            <input placeholder="Email" type="email" value={customerInfo.email}
              onChange={e => setCustomerInfo(prev => ({ ...prev, email: e.target.value }))}
              className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
            <input placeholder="Phone" type="tel" value={customerInfo.phone}
              onChange={e => setCustomerInfo(prev => ({ ...prev, phone: e.target.value }))}
              className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
          </div>

          <div className="flex gap-3 mt-6">
            <button onClick={() => setStep(0)}
              className="flex-1 py-3 rounded-xl font-semibold border border-gray-300 text-gray-700 hover:bg-gray-50">Back</button>
            <button onClick={handleSubmitDetails} disabled={submitting || !customerInfo.email}
              className={`flex-1 py-3 rounded-xl font-semibold text-white transition-colors ${
                submitting || !customerInfo.email ? 'bg-gray-300 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'
              }`}>{submitting ? 'Processing…' : 'Review Invoice'}</button>
          </div>
        </div>
      )}

      {/* ─── Step 2: Invoice ─── */}
      {step === 2 && sdkReady && (
        <div>
          <InvoiceWidget sessionId={api.getSessionId()} />
          <button onClick={handleComplete} disabled={submitting}
            className={`w-full mt-6 py-3 rounded-xl font-semibold text-white transition-colors ${
              submitting ? 'bg-gray-300 cursor-not-allowed' : 'bg-green-600 hover:bg-green-700'
            }`}>{submitting ? 'Completing…' : 'Confirm & Book'}</button>
        </div>
      )}

      {/* ─── Step 3: Confirmation ─── */}
      {step === 3 && (
        <div className="text-center py-10">
          <div className="text-5xl mb-4">✓</div>
          <h2 className="text-xl font-bold text-gray-900 mb-2">Booking Confirmed</h2>
          {bookingRef && <p className="text-gray-600 mb-6">Reference: <span className="font-mono font-bold">{bookingRef}</span></p>}
          <StartOverButton label="Book Again" className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700" />
        </div>
      )}

      {/* ─── Sticky footer ─── */}
      <div className="fixed bottom-0 left-0 right-0 z-40 bg-white/90 backdrop-blur border-t border-gray-200">
        <div className="max-w-2xl mx-auto px-4 py-2.5 flex items-center justify-between">
          <div>
            {step > 0 && step < 3 && (
              <StartOverButton className="text-xs text-gray-400 hover:text-gray-600 transition-colors" />
            )}
          </div>
          <SupportButton label="Need help?" className="text-xs text-gray-400 hover:text-gray-600 transition-colors" />
        </div>
      </div>
    </div>
  );
}