booking-ui
Version:
React booking widget component for ZvenBook booking system
985 lines (978 loc) • 35.8 kB
JavaScript
// src/ServicePicker.tsx
import { jsx, jsxs } from "react/jsx-runtime";
function ServicePicker({
services,
value,
onChange,
className
}) {
return /* @__PURE__ */ jsxs(
"select",
{
value,
onChange: (e) => onChange(e.target.value),
className: "border rounded px-3 py-2 bg-white dark:bg-zinc-900 " + (className ?? ""),
children: [
/* @__PURE__ */ jsx("option", { value: "", children: "Select service" }),
services.map((s) => /* @__PURE__ */ jsx("option", { value: s.id, children: s.name }, s.id))
]
}
);
}
// src/ProviderPicker.tsx
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
function ProviderPicker({
providers,
value,
onChange,
className
}) {
return /* @__PURE__ */ jsxs2(
"select",
{
value,
onChange: (e) => onChange(e.target.value),
className: "border rounded px-3 py-2 bg-white dark:bg-zinc-900 " + (className ?? ""),
children: [
/* @__PURE__ */ jsx2("option", { value: "", children: "Select provider" }),
providers.map((p) => /* @__PURE__ */ jsx2("option", { value: p.id, children: p.name }, p.id))
]
}
);
}
// src/SlotList.tsx
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
function SlotList({
slots,
onSelect,
className
}) {
if (!slots.length) {
return /* @__PURE__ */ jsx3("div", { className, children: "No slots in range." });
}
return /* @__PURE__ */ jsx3("div", { className: "grid gap-2 sm:grid-cols-2 " + (className ?? ""), children: slots.map((s) => /* @__PURE__ */ jsxs3(
"button",
{
className: "px-3 py-2 rounded-md bg-black text-white",
onClick: () => onSelect(s),
children: [
new Date(s.start).toLocaleTimeString(),
" \u2013",
" ",
new Date(s.end).toLocaleTimeString()
]
},
s.start
)) });
}
// src/BookingSteps.tsx
import * as React from "react";
import { cva } from "class-variance-authority";
// src/lib/cn.ts
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs) {
return twMerge(clsx(inputs));
}
// src/BookingSteps.tsx
import { createSdk } from "booking-api-sdk";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCheck,
faChevronLeft,
faChevronRight
} from "@fortawesome/pro-regular-svg-icons";
import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
var bookingStepsVariants = cva("relative flex", {
variants: {
layout: {
horizontal: "flex-row items-stretch",
vertical: "flex-col",
wizard: "flex-col"
},
spacing: {
compact: "gap-2",
normal: "gap-4",
cozy: "gap-6"
},
radius: {
none: "rounded-none",
sm: "rounded-sm",
md: "rounded-md",
lg: "rounded-lg",
full: "rounded-full"
},
color: {
accent: "bg-accent text-accent-foreground",
primary: "bg-primary text-primary-foreground",
muted: "bg-muted text-muted-foreground",
transparent: ""
}
},
defaultVariants: {
layout: "horizontal",
spacing: "normal",
radius: "md",
color: "accent"
}
});
function BookingSteps({
layout,
spacing,
radius,
color,
className,
children
}) {
return /* @__PURE__ */ jsx4(
"div",
{
className: cn(
bookingStepsVariants({ layout, spacing, radius, color }),
className
),
children
}
);
}
function InlineCalendar({
value,
onChange,
daysWithAvailability = [],
weekStartsOn = "sunday",
dayLabels,
className
}) {
const today = /* @__PURE__ */ new Date();
const initial = value ? new Date(value) : today;
const [cursor, setCursor] = React.useState(
new Date(initial.getFullYear(), initial.getMonth(), 1)
);
const year = cursor.getFullYear();
const month = cursor.getMonth();
const firstDay = new Date(year, month, 1);
const startWeekday = firstDay.getDay();
const adjustedStartWeekday = weekStartsOn === "monday" ? startWeekday === 0 ? 6 : startWeekday - 1 : startWeekday;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const weeks = [];
let day = 1 - adjustedStartWeekday;
while (day <= daysInMonth) {
const week = [];
for (let i = 0; i < 7; i++) {
if (day < 1 || day > daysInMonth) {
week.push(null);
} else {
week.push(new Date(year, month, day));
}
day++;
}
weeks.push(week);
}
function fmt(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${dd}`;
}
const selectedStr = value;
const dayLabelsArray = weekStartsOn === "monday" ? [
dayLabels.monday,
dayLabels.tuesday,
dayLabels.wednesday,
dayLabels.thursday,
dayLabels.friday,
dayLabels.saturday,
dayLabels.sunday
] : [
dayLabels.sunday,
dayLabels.monday,
dayLabels.tuesday,
dayLabels.wednesday,
dayLabels.thursday,
dayLabels.friday,
dayLabels.saturday
];
return /* @__PURE__ */ jsxs4("div", { className: cn("inline-block border rounded-md p-3", className), children: [
/* @__PURE__ */ jsxs4("div", { className: "flex items-center justify-between mb-2", children: [
/* @__PURE__ */ jsx4(
"button",
{
type: "button",
className: "px-2 py-1 text-sm border rounded",
onClick: () => setCursor(new Date(year, month - 1, 1)),
children: /* @__PURE__ */ jsx4(FontAwesomeIcon, { icon: faChevronLeft })
}
),
/* @__PURE__ */ jsx4("div", { className: "text-sm font-medium", children: cursor.toLocaleString(void 0, { month: "long", year: "numeric" }) }),
/* @__PURE__ */ jsx4(
"button",
{
type: "button",
className: "px-2 py-1 text-sm border rounded",
onClick: () => setCursor(new Date(year, month + 1, 1)),
children: /* @__PURE__ */ jsx4(FontAwesomeIcon, { icon: faChevronRight })
}
)
] }),
/* @__PURE__ */ jsx4("div", { className: "grid grid-cols-7 text-xs text-muted-foreground mb-1", children: dayLabelsArray.map((d) => /* @__PURE__ */ jsx4("div", { className: "text-center", children: d }, d)) }),
/* @__PURE__ */ jsx4("div", { className: "grid grid-cols-7", children: weeks.map(
(week, wi) => week.map((d, di) => {
if (!d) return /* @__PURE__ */ jsx4("div", { className: "h-8" }, `${wi}-${di}`);
const valueStr = fmt(d);
const isSelected = selectedStr === valueStr;
const hasAvailability = daysWithAvailability.includes(valueStr);
return /* @__PURE__ */ jsxs4(
"button",
{
type: "button",
onClick: () => onChange(valueStr),
className: cn(
"h-8 rounded text-sm border hover:bg-muted relative",
isSelected && "bg-accent text-accent-foreground border-accent"
),
children: [
d.getDate(),
hasAvailability && /* @__PURE__ */ jsx4("span", { className: "absolute top-2 right-2 -translate-x-1/2 w-1 h-1 bg-green-500 rounded-full" })
]
},
`${wi}-${di}`
);
})
) })
] });
}
var defaultLabels = {
stepService: "Service",
stepDateTime: "Date & time",
stepDetails: "Your details",
chooseService: "Choose a service",
selectDate: "Select date",
loadingTimes: "Loading available times...",
noSlotsAvailable: "No slots available.",
dayLabels: {
sunday: "Su",
monday: "Mo",
tuesday: "Tu",
wednesday: "We",
thursday: "Th",
friday: "Fr",
saturday: "Sa"
},
yourDetails: "Your details",
firstNamePlaceholder: "First name",
lastNamePlaceholder: "Last name",
emailPlaceholder: "Email",
phonePlaceholder: "Phone (optional)",
back: "Back",
next: "Next",
bookAppointment: "Book appointment",
booking: "Booking\u2026",
bookingConfirmed: "Booking confirmed",
changeService: "Service selected \u2022 change",
changeDate: "{date} \u2022 change",
changeTime: "{time} \u2022 change"
};
var swedishLabels = {
stepService: "Tj\xE4nst",
stepDateTime: "Datum & tid",
stepDetails: "Dina uppgifter",
chooseService: "V\xE4lj en tj\xE4nst",
selectDate: "V\xE4lj datum",
loadingTimes: "Laddar tillg\xE4ngliga tider...",
noSlotsAvailable: "Inga lediga tider.",
dayLabels: {
sunday: "S\xF6",
monday: "M\xE5",
tuesday: "Ti",
wednesday: "On",
thursday: "To",
friday: "Fr",
saturday: "L\xF6"
},
yourDetails: "Dina uppgifter",
firstNamePlaceholder: "F\xF6rnamn",
lastNamePlaceholder: "Efternamn",
emailPlaceholder: "E-post",
phonePlaceholder: "Telefon (valfritt)",
back: "Tillbaka",
next: "N\xE4sta",
bookAppointment: "Boka tid",
booking: "Bokar\u2026",
bookingConfirmed: "Bokning bekr\xE4ftad",
changeService: "Tj\xE4nst vald \u2022 \xE4ndra",
changeDate: "{date} \u2022 \xE4ndra",
changeTime: "{time} \u2022 \xE4ndra"
};
function BookingStepsWidget({
baseUrl,
tenantId,
className,
layout,
spacing,
radius,
color,
weekStartsOn = "sunday",
labels = defaultLabels,
styles
}) {
const sdk = React.useMemo(() => createSdk({ baseUrl }), [baseUrl]);
const [services, setServices] = React.useState([]);
const [serviceId, setServiceId] = React.useState("");
const [date, setDate] = React.useState("");
const [slots, setSlots] = React.useState([]);
const [slot, setSlot] = React.useState(null);
const [contact, setContact] = React.useState({ firstName: "", lastName: "", email: "" });
const [step, setStep] = React.useState(0);
const [submitting, setSubmitting] = React.useState(false);
const [success, setSuccess] = React.useState(null);
const [error, setError] = React.useState(null);
const [daysWithAvailability, setDaysWithAvailability] = React.useState([]);
const [loadingSlots, setLoadingSlots] = React.useState(false);
const [loadingServices, setLoadingServices] = React.useState(true);
const [validationErrors, setValidationErrors] = React.useState({});
React.useEffect(() => {
let cancelled = false;
(async () => {
setLoadingServices(true);
try {
const { services: services2 } = await sdk.listServices({ tenantId });
if (!cancelled) {
setServices(services2);
}
} catch (e) {
if (!cancelled) setError(e?.message ?? "Failed to load data");
} finally {
if (!cancelled) setLoadingServices(false);
}
})();
return () => {
cancelled = true;
};
}, [sdk, tenantId]);
React.useEffect(() => {
if (serviceId && step === 0) setStep(1);
}, [serviceId, step]);
React.useEffect(() => {
if (slot && step < 2) setStep(2);
}, [slot, step]);
React.useEffect(() => {
(async () => {
if (!serviceId) {
setDaysWithAvailability([]);
return;
}
const today = /* @__PURE__ */ new Date();
const start = new Date(today.getFullYear(), today.getMonth(), 1);
const end = new Date(today.getFullYear(), today.getMonth() + 2, 0);
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const { serviceAvailability } = await sdk.serviceAvailability({
tenantId,
start: start.toISOString(),
end: end.toISOString(),
timezone: tz
});
const current = (serviceAvailability || []).find(
(s) => s.serviceId === serviceId
);
const datesSet = /* @__PURE__ */ new Set();
(current?.slots || []).forEach((s) => {
const slotDate = new Date(s.start);
const yyyy = slotDate.getFullYear();
const mm = String(slotDate.getMonth() + 1).padStart(2, "0");
const dd = String(slotDate.getDate()).padStart(2, "0");
datesSet.add(`${yyyy}-${mm}-${dd}`);
});
setDaysWithAvailability(Array.from(datesSet));
} catch (e) {
setDaysWithAvailability([]);
}
})();
}, [sdk, tenantId, serviceId]);
React.useEffect(() => {
(async () => {
if (!serviceId || !date) {
setSlots([]);
setLoadingSlots(false);
return;
}
setLoadingSlots(true);
const day = new Date(date);
const start = new Date(day);
start.setHours(0, 0, 0, 0);
const end = new Date(day);
end.setHours(23, 59, 59, 999);
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const { serviceAvailability } = await sdk.serviceAvailability({
tenantId,
start: start.toISOString(),
end: end.toISOString(),
timezone: tz
});
const current = (serviceAvailability || []).find(
(s) => s.serviceId === serviceId
);
const daySlots = (current?.slots || []).map((s) => ({
start: s.start,
end: s.end,
providerId: s.providerId,
providerName: s.providerName
}));
setSlots(daySlots);
} catch (e) {
setError(e?.message ?? "Failed to load availability");
} finally {
setLoadingSlots(false);
}
})();
}, [sdk, tenantId, serviceId, date]);
async function resolveProviderForSlot(desired) {
if (slot?.providerId) return slot.providerId;
try {
const { providers } = await sdk.listProviders({ tenantId });
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
for (const p of providers) {
try {
const { slots: slots2 } = await sdk.availability({
tenantId,
serviceId,
providerId: p._id,
start: desired.start,
end: desired.end,
timezone: tz
});
const found = slots2.some(
(s) => s.start === desired.start && s.end === desired.end
);
if (found) return p._id;
} catch (_) {
}
}
} catch (_) {
}
return null;
}
function isContactValid() {
if (!contact.firstName.trim() || !contact.lastName.trim() || !contact.email.trim()) {
return false;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(contact.email);
}
function validateContact() {
const errors = {};
if (!contact.firstName.trim()) {
errors.firstName = "First name is required";
}
if (!contact.lastName.trim()) {
errors.lastName = "Last name is required";
}
if (!contact.email.trim()) {
errors.email = "Email is required";
} else {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(contact.email)) {
errors.email = "Please enter a valid email address";
}
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
}
async function submitBooking() {
if (!slot || !serviceId) return;
if (!validateContact()) {
return;
}
setSubmitting(true);
setError(null);
try {
const providerId = await resolveProviderForSlot({
start: slot.start,
end: slot.end
});
if (!providerId) throw new Error("No provider available for this time");
const res = await sdk.createBooking({
tenantId,
serviceId,
providerId,
start: slot.start,
end: slot.end,
contactFirstName: contact.firstName,
contactLastName: contact.lastName,
contactEmail: contact.email,
contactPhone: contact.phone
});
if ("ok" in res || "_id" in res) {
setSuccess(labels.bookingConfirmed);
}
} catch (e) {
setError(e?.message ?? "Booking failed");
} finally {
setSubmitting(false);
}
}
if (success) {
return /* @__PURE__ */ jsx4(
"div",
{
className: cn(
bookingStepsVariants({ layout, spacing, radius, color }),
className
),
children: /* @__PURE__ */ jsxs4("div", { className: "p-8 rounded-lg bg-white dark:bg-zinc-900 border text-center animate-in fade-in duration-500", children: [
/* @__PURE__ */ jsx4("div", { className: "mx-auto w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-4 animate-in zoom-in duration-700", children: /* @__PURE__ */ jsx4(
"svg",
{
className: "w-8 h-8 text-green-600 dark:text-green-400",
fill: "none",
viewBox: "0 0 24 24",
stroke: "currentColor",
children: /* @__PURE__ */ jsx4(
"path",
{
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: 2,
d: "M5 13l4 4L19 7"
}
)
}
) }),
/* @__PURE__ */ jsx4("h3", { className: "text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2", children: success }),
/* @__PURE__ */ jsx4("p", { className: "text-sm text-muted-foreground", children: "You will receive a confirmation email shortly." })
] })
}
);
}
const cssVars = React.useMemo(() => {
const vars = {};
if (styles?.primaryColor) vars["--booking-primary"] = styles.primaryColor;
if (styles?.accentColor) vars["--booking-accent"] = styles.accentColor;
if (styles?.borderColor) vars["--booking-border"] = styles.borderColor;
if (styles?.textColor) vars["--booking-text"] = styles.textColor;
if (styles?.backgroundColor) vars["--booking-bg"] = styles.backgroundColor;
return vars;
}, [styles]);
return /* @__PURE__ */ jsxs4(
"div",
{
className: cn(
bookingStepsVariants({ layout, spacing, radius, color }),
className
),
style: cssVars,
children: [
/* @__PURE__ */ jsx4("div", { className: cn("w-full", styles?.stepperClassName), children: /* @__PURE__ */ jsx4("div", { className: "mb-6 flex items-center", children: [labels.stepService, labels.stepDateTime, labels.stepDetails].map(
(label, i) => {
const isActive = step === i;
const isDone = step > i;
return /* @__PURE__ */ jsxs4(React.Fragment, { children: [
/* @__PURE__ */ jsxs4("div", { className: "flex flex-col items-center gap-2", children: [
/* @__PURE__ */ jsx4(
"div",
{
className: cn(
"w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border-2 transition-all duration-300",
isActive && "bg-accent text-accent-foreground border-accent scale-100",
!isActive && !isDone && "bg-muted text-foreground/70 border-border",
isDone && "bg-primary text-primary-foreground border-primary"
),
children: isDone ? /* @__PURE__ */ jsx4(FontAwesomeIcon, { icon: faCheck }) : i + 1
}
),
/* @__PURE__ */ jsx4(
"div",
{
className: cn(
"text-xs whitespace-nowrap transition-all duration-300",
isActive ? "font-semibold text-foreground" : "text-muted-foreground"
),
children: label
}
)
] }),
i < 2 && /* @__PURE__ */ jsx4("div", { className: "flex-1 h-0.5 mx-2 bg-border relative overflow-hidden", children: /* @__PURE__ */ jsx4(
"div",
{
className: cn(
"absolute inset-0 bg-white transition-all duration-500 ease-in-out",
isDone ? "translate-x-0" : "-translate-x-[105%]"
)
}
) })
] }, label);
}
) }) }),
/* @__PURE__ */ jsxs4("div", { className: "w-full mb-2 flex flex-wrap items-center gap-2 text-xs", children: [
serviceId && /* @__PURE__ */ jsx4(
"button",
{
type: "button",
className: "px-2 py-1 rounded border bg-muted hover:bg-muted/70",
onClick: () => {
setServiceId("");
setDate("");
setSlots([]);
setSlot(null);
setStep(0);
},
children: labels.changeService
}
),
date && /* @__PURE__ */ jsx4(
"button",
{
type: "button",
className: "px-2 py-1 rounded border bg-muted hover:bg-muted/70",
onClick: () => {
setSlot(null);
setStep(1);
},
children: labels.changeDate.replace(
"{date}",
new Date(date).toLocaleDateString()
)
}
),
slot && /* @__PURE__ */ jsx4(
"button",
{
type: "button",
className: "px-2 py-1 rounded border bg-muted hover:bg-muted/70",
onClick: () => {
setSlot(null);
setStep(1);
},
children: labels.changeTime.replace(
"{time}",
`${new Date(slot.start).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit"
})}\u2013${new Date(slot.end).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit"
})}`
)
}
)
] }),
/* @__PURE__ */ jsxs4("div", { className: "w-full border rounded-lg p-4 bg-white dark:bg-zinc-900", children: [
step === 0 && /* @__PURE__ */ jsxs4("div", { className: "flex flex-col gap-3", children: [
/* @__PURE__ */ jsx4("label", { className: "text-sm font-medium", children: labels.chooseService }),
loadingServices ? /* @__PURE__ */ jsxs4("div", { className: "flex flex-col items-center justify-center py-12", children: [
/* @__PURE__ */ jsxs4(
"svg",
{
className: "animate-spin h-8 w-8 text-gray-400",
xmlns: "http://www.w3.org/2000/svg",
fill: "none",
viewBox: "0 0 24 24",
children: [
/* @__PURE__ */ jsx4(
"circle",
{
className: "opacity-25",
cx: "12",
cy: "12",
r: "10",
stroke: "currentColor",
strokeWidth: "4"
}
),
/* @__PURE__ */ jsx4(
"path",
{
className: "opacity-75",
fill: "currentColor",
d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
}
)
]
}
),
/* @__PURE__ */ jsx4("p", { className: "text-sm text-muted-foreground mt-3", children: "Loading services..." })
] }) : services.length === 0 ? /* @__PURE__ */ jsxs4("div", { className: "flex flex-col items-center justify-center py-12 text-center", children: [
/* @__PURE__ */ jsx4(
"svg",
{
className: "h-12 w-12 text-gray-300 dark:text-gray-600 mb-3",
fill: "none",
viewBox: "0 0 24 24",
stroke: "currentColor",
children: /* @__PURE__ */ jsx4(
"path",
{
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: 2,
d: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
}
)
}
),
/* @__PURE__ */ jsx4("p", { className: "text-sm font-medium text-gray-900 dark:text-gray-100", children: "No services available" }),
/* @__PURE__ */ jsx4("p", { className: "text-xs text-muted-foreground mt-1", children: "Please check back later or contact support." })
] }) : /* @__PURE__ */ jsx4("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-3", children: services.map((s) => /* @__PURE__ */ jsxs4(
"button",
{
onClick: () => {
setServiceId(s._id);
setStep(1);
},
className: cn(
"border rounded-md p-3 text-left hover:border-ring transition-colors",
serviceId === s._id && "ring-2 ring-accent",
styles?.serviceCardClassName
),
children: [
/* @__PURE__ */ jsx4("div", { className: "font-medium", children: s.name }),
/* @__PURE__ */ jsxs4("div", { className: "text-xs text-muted-foreground", children: [
s.durationMin,
" min"
] })
]
},
s._id
)) })
] }),
step === 1 && serviceId && /* @__PURE__ */ jsxs4("div", { className: "flex flex-col gap-3 mt-4", children: [
/* @__PURE__ */ jsx4("label", { className: "text-sm font-medium", children: labels.selectDate }),
/* @__PURE__ */ jsx4(
InlineCalendar,
{
value: date,
onChange: (d) => {
setDate(d);
setSlot(null);
},
daysWithAvailability,
weekStartsOn,
dayLabels: labels.dayLabels,
className: styles?.calendarClassName
}
),
date && /* @__PURE__ */ jsxs4(Fragment2, { children: [
/* @__PURE__ */ jsx4("div", { className: "mt-4 mb-2", children: /* @__PURE__ */ jsxs4("p", { className: "text-sm font-medium text-gray-900 dark:text-gray-100", children: [
"Available times for",
" ",
/* @__PURE__ */ jsx4("span", { className: "text-accent", children: new Date(date).toLocaleDateString(void 0, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric"
}) })
] }) }),
/* @__PURE__ */ jsx4("div", { className: "grid gap-2 sm:grid-cols-2", children: loadingSlots ? /* @__PURE__ */ jsxs4("div", { className: "text-sm text-muted-foreground flex items-center gap-2", children: [
/* @__PURE__ */ jsxs4(
"svg",
{
className: "animate-spin h-4 w-4",
xmlns: "http://www.w3.org/2000/svg",
fill: "none",
viewBox: "0 0 24 24",
children: [
/* @__PURE__ */ jsx4(
"circle",
{
className: "opacity-25",
cx: "12",
cy: "12",
r: "10",
stroke: "currentColor",
strokeWidth: "4"
}
),
/* @__PURE__ */ jsx4(
"path",
{
className: "opacity-75",
fill: "currentColor",
d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
}
)
]
}
),
labels.loadingTimes
] }) : slots.length === 0 ? /* @__PURE__ */ jsx4("div", { className: "text-sm text-muted-foreground", children: labels.noSlotsAvailable }) : slots.map((s) => /* @__PURE__ */ jsxs4(
"button",
{
className: cn(
"px-3 py-2 rounded-md bg-black text-white",
slot?.start === s.start && "ring-2 ring-accent",
styles?.timeSlotClassName
),
onClick: () => {
setSlot(s);
setStep(2);
},
children: [
new Date(s.start).toLocaleTimeString(),
" \u2013",
" ",
new Date(s.end).toLocaleTimeString()
]
},
s.start
)) })
] })
] }),
step === 2 && slot && /* @__PURE__ */ jsxs4("div", { className: "flex flex-col gap-3 mt-4", children: [
/* @__PURE__ */ jsx4("label", { className: "text-sm font-medium", children: labels.yourDetails }),
/* @__PURE__ */ jsxs4("div", { children: [
/* @__PURE__ */ jsx4(
"input",
{
placeholder: labels.firstNamePlaceholder,
className: cn(
"w-full border rounded px-3 py-2 bg-white dark:bg-zinc-900 transition-colors",
validationErrors.firstName ? "border-red-500 focus:ring-red-500 focus:border-red-500" : "border-gray-300 dark:border-gray-600",
styles?.inputClassName
),
value: contact.firstName,
onChange: (e) => {
setContact({ ...contact, firstName: e.target.value });
setValidationErrors({
...validationErrors,
firstName: void 0
});
}
}
),
validationErrors.firstName && /* @__PURE__ */ jsx4("p", { className: "text-xs text-red-600 mt-1", children: validationErrors.firstName })
] }),
/* @__PURE__ */ jsxs4("div", { children: [
/* @__PURE__ */ jsx4(
"input",
{
placeholder: labels.lastNamePlaceholder,
className: cn(
"w-full border rounded px-3 py-2 bg-white dark:bg-zinc-900 transition-colors",
validationErrors.lastName ? "border-red-500 focus:ring-red-500 focus:border-red-500" : "border-gray-300 dark:border-gray-600",
styles?.inputClassName
),
value: contact.lastName,
onChange: (e) => {
setContact({ ...contact, lastName: e.target.value });
setValidationErrors({
...validationErrors,
lastName: void 0
});
}
}
),
validationErrors.lastName && /* @__PURE__ */ jsx4("p", { className: "text-xs text-red-600 mt-1", children: validationErrors.lastName })
] }),
/* @__PURE__ */ jsxs4("div", { children: [
/* @__PURE__ */ jsx4(
"input",
{
type: "email",
placeholder: labels.emailPlaceholder,
className: cn(
"w-full border rounded px-3 py-2 bg-white dark:bg-zinc-900 transition-colors",
validationErrors.email ? "border-red-500 focus:ring-red-500 focus:border-red-500" : "border-gray-300 dark:border-gray-600",
styles?.inputClassName
),
value: contact.email,
onChange: (e) => {
setContact({ ...contact, email: e.target.value });
setValidationErrors({
...validationErrors,
email: void 0
});
}
}
),
validationErrors.email && /* @__PURE__ */ jsx4("p", { className: "text-xs text-red-600 mt-1", children: validationErrors.email })
] }),
/* @__PURE__ */ jsx4("div", { children: /* @__PURE__ */ jsx4(
"input",
{
placeholder: labels.phonePlaceholder,
className: cn(
"w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 bg-white dark:bg-zinc-900",
styles?.inputClassName
),
value: contact.phone ?? "",
onChange: (e) => setContact({ ...contact, phone: e.target.value })
}
) }),
error && /* @__PURE__ */ jsx4("div", { className: "p-3 rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800", children: /* @__PURE__ */ jsx4("p", { className: "text-sm text-red-600 dark:text-red-400", children: error }) })
] }),
/* @__PURE__ */ jsxs4("div", { className: "mt-4 flex items-center justify-between", children: [
/* @__PURE__ */ jsx4(
"button",
{
className: cn(
"px-3 py-2 rounded-md border text-sm",
styles?.buttonClassName
),
onClick: () => {
if (step === 2) {
setSlot(null);
setStep(1);
} else if (step === 1) {
setDate("");
setSlots([]);
setServiceId("");
setStep(0);
}
},
disabled: step === 0,
children: labels.back
}
),
step < 2 ? /* @__PURE__ */ jsx4(
"button",
{
className: cn(
"px-4 py-2 rounded-md bg-accent text-accent-foreground text-sm disabled:opacity-50",
styles?.buttonClassName
),
onClick: () => setStep((s) => Math.min(2, s + 1)),
disabled: step === 0 && !serviceId || step === 1 && !slot,
children: labels.next
}
) : /* @__PURE__ */ jsx4(
"button",
{
disabled: submitting || !slot || !isContactValid(),
onClick: submitBooking,
className: cn(
"px-4 py-2 rounded-md bg-primary border border-primary text-primary-foreground disabled:opacity-50 shadow hover:opacity-90",
styles?.submitButtonClassName || styles?.buttonClassName
),
children: submitting ? labels.booking : labels.bookAppointment
}
)
] })
] })
]
}
);
}
// src/index.tsx
import { jsx as jsx5 } from "react/jsx-runtime";
function Button(props) {
return /* @__PURE__ */ jsx5(
"button",
{
...props,
className: "px-3 py-2 rounded-md bg-black text-white " + (props.className ?? "")
}
);
}
export {
BookingSteps,
BookingStepsWidget,
Button,
ProviderPicker,
ServicePicker,
SlotList,
bookingStepsVariants,
cn,
defaultLabels,
swedishLabels
};