@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" : "")}>
‹
</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">
›
</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">
‹
</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">
›
</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>
);
}