@harizanov/booking-widget
v1.0.2
Published
Reusable React booking widget — one package, infinite configs
Maintainers
Readme
@harizanov/booking-widget
A reusable React booking widget for client websites. One package — infinite configs. Never fork, always configure.
Install
npm install @harizanov/booking-widget
# or
pnpm add @harizanov/booking-widgetPeer dependencies
| Package | Version |
| ----------- | ------- |
| react | ≥ 18 |
| react-dom | ≥ 18 |
Quick start
1 — Import the CSS
The widget ships a single CSS file. Import it once at the top of your app entry:
import "@harizanov/booking-widget/style.css";Works out of the box with Vite, Next.js, or any modern bundler.
2 — Create a config file
Create src/booking/config.ts in your project:
import type { BookingConfig } from "@harizanov/booking-widget";
export const config: BookingConfig = {
// A unique slug that identifies this business in your backend
businessId: "my-business-name",
// Which steps to show (remove 'staff' if you have no staff selection)
steps: ["service", "staff", "dateTime", "details"],
// Your services — static array or async loader
services: [
{ id: "haircut", name: "Haircut", price: "$30", duration: "45 min" },
{ id: "beard", name: "Beard Trim", price: "$15", duration: "20 min" },
],
// Your staff — static array or async loader
staff: [{ id: "alex", name: "Alex", initials: "AJ", specialty: "All cuts" }],
// Called when the user picks a date — return which slots are taken
fetchSlots: async (date, staffId, serviceId) => {
const res = await fetch(
`https://your-api.com/slots?date=${date}&staff=${staffId}&service=${serviceId}`,
);
return res.json(); // Array of { date, time, taken }
},
// Called when the user confirms — send the booking to your backend
onSubmit: async (payload) => {
const res = await fetch("https://your-api.com/bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
return { success: true, confirmationId: data.id };
},
// Colours, fonts, radii — override only what you need
theme: {
colorPrimary: "#D6F84A",
colorBackground: "#0A0A0A",
colorSurface: "#1C1C1C",
colorTextPrimary: "#F7F7F2",
colorTextOnPrimary: "#0A0A0A",
fontFamily: "'DM Sans', sans-serif",
radiusButton: "9999px",
},
};3 — Mount the widget
import { BookingWidget } from "@harizanov/booking-widget";
import { config } from "./booking/config";
export function BookingSection() {
return (
<section id="booking">
<BookingWidget config={config} />
</section>
);
}That's it. The widget handles all state, step navigation, slot caching, validation, and submission internally.
Config options
| Option | Type | Default | Description |
| --------------------- | ------------------------- | ------------- | -------------------------------------------------- |
| businessId | string | required | Kebab-case slug forwarded in every payload |
| services | array or async fn | required | Services to show in step 1 |
| staff | array or async fn | — | Staff for step 2; omit when staff step is excluded |
| steps | StepId[] | all 4 steps | Which steps to show and in what order |
| bookingWindowDays | number | 14 | How many days ahead the calendar shows |
| closedDays | number[] | [0] | Day-of-week numbers to hide (0 = Sunday) |
| slotIntervalMinutes | number | 30 | Spacing between time slots |
| businessHours | { start, end } | 09:00–19:00 | Hours window for slot generation |
| fetchSlots | async fn | required | Returns available/taken slots for a given date |
| onSubmit | async fn | required | Handles confirmed booking payload |
| onStepChange | fn | — | Analytics hook — fires on every step transition |
| extraFields | ExtraField[] | — | Additional form fields on the details step |
| theme | Partial<BookingTheme> | — | Token overrides (see Theme reference below) |
| labels | Partial<BookingLabels> | English | UI string overrides |
| locale | BCP 47 string | en-GB | Date/number formatting locale |
| meta | Record<string, unknown> | — | Forwarded verbatim in every BookingPayload |
Theme reference
Every colour, radius, and shadow is a CSS custom property scoped to the widget root — your page styles are never affected.
| Config key | CSS variable | Default |
| -------------------- | ---------------------------- | ---------------------- |
| colorPrimary | --bw-color-primary | #0066CC |
| colorBackground | --bw-color-background | #FFFFFF |
| colorSurface | --bw-color-surface | #F8FAFC |
| colorSurfaceAlt | --bw-color-surface-alt | #F1F5F9 |
| colorBorder | --bw-color-border | #E2E8F0 |
| colorBorderActive | --bw-color-border-active | #0066CC |
| colorTextPrimary | --bw-color-text-primary | #0F172A |
| colorTextMuted | --bw-color-text-muted | #64748B |
| colorTextOnPrimary | --bw-color-text-on-primary | #FFFFFF |
| colorSuccess | --bw-color-success | #22C55E |
| colorError | --bw-color-error | #EF4444 |
| fontFamily | --bw-font-family | system-ui |
| fontFamilyDisplay | --bw-font-family-display | (same as fontFamily) |
| fontSizeBase | --bw-font-size-base | 16px |
| radiusCard | --bw-radius-card | 12px |
| radiusButton | --bw-radius-button | 8px |
| radiusInput | --bw-radius-input | 8px |
| transitionSpeed | --bw-transition-speed | 200ms |
| shadowCard | --bw-shadow-card | subtle lift |
| shadowSelected | --bw-shadow-selected | selected glow |
Pass a partial BookingTheme to config.theme — unset tokens fall back to the defaults above.
i18n — Bulgarian / English
Two locale packs are built in. Import and pass to labels:
import { bg } from "@harizanov/booking-widget/i18n"; // Bulgarian
import { en } from "@harizanov/booking-widget/i18n"; // English (default)
const config: BookingConfig = {
labels: bg, // full Bulgarian UI
locale: "bg-BG", // date formatting locale
// ...
};Override individual strings without replacing the whole pack:
labels: {
...bg,
buttonConfirm: 'Запази час',
stepServiceTitle: 'Избери',
},Testing with mock data (no backend needed)
To test without a live API, replace fetchSlots and onSubmit with mock functions:
import type { BookingConfig } from "@harizanov/booking-widget";
export const config: BookingConfig = {
businessId: "test-site",
steps: ["service", "dateTime", "details"],
services: [
{ id: "svc-1", name: "Service A", price: "$40", duration: "60 min" },
{ id: "svc-2", name: "Service B", price: "$25", duration: "30 min" },
],
// Simulates network latency; randomly marks some slots taken
fetchSlots: async (date) => {
await new Promise((r) => setTimeout(r, 500));
return [
{ date, time: "10:00", taken: true },
{ date, time: "10:30", taken: false },
{ date, time: "11:00", taken: false },
{ date, time: "14:00", taken: true },
];
},
// Simulates a successful booking
onSubmit: async (payload) => {
console.log("Booking payload:", payload);
await new Promise((r) => setTimeout(r, 1000));
return { success: true, confirmationId: `TEST-${Date.now()}` };
},
};onSubmit payload shape
{
"businessId": "my-business-name",
"service": {
"id": "haircut",
"name": "Haircut",
"price": "$30",
"duration": "45 min"
},
"staffMember": {
"id": "alex",
"name": "Alex",
"initials": "AJ",
"specialty": "All cuts"
},
"date": "2025-06-15",
"time": "10:30",
"customer": {
"name": "John Smith",
"phone": "+1 555 000 0000",
"email": "[email protected]",
"extra": { "notes": "allergic to mint products" }
},
"submittedAt": "2025-06-10T08:14:33.000Z",
"meta": { "source": "website" }
}Your backend receives this and is responsible for saving it, checking for race conditions, and firing notifications (email, SMS, Telegram, etc.).
License
MIT © Boris Harizanov
