import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import { __ } from '@wordpress/i18n'
import classNames from 'classnames'
import { useDispatch } from '@wordpress/data'
import { store_name } from '../../../../store/frontend'
import { useBookingContext } from '../../../providers/BookingFormProvider/BookingFormProvider'
import { LocationDropdown } from '../../../components/LocationDropdown/LocationDropdown'
import { ILocationOption } from '../../../components/LocationDropdown/types'
import { getServiceAssociatedLocations } from '../../../lib/locationFilter/getServiceAssociatedLocations'
import { getPlainTextFromHtml } from '../../../lib/utils'
import { Button } from '../../../components/Button/Button'
import { ReactComponent as GoNextIcon } from '../../../../../public/images/icon-go-next.svg'
import {
CategoryIcon,
ServiceIcon,
StaffIcon,
UnitIcon,
WaterfallDropdown,
} from './WaterfallDropdown'
import { getStaffOptionsForService } from '../../../components/StaffSelector/utils'
import { STAFF_ANY_AVAILABLE } from '../../../components/StaffSelector/StaffSelector'
import { IStaffOption } from '../../../components/StaffSelector/types'
import { WaterfallAvailabilityCalendar } from './WaterfallAvailabilityCalendar'
import { WaterfallUnitAvailabilityCalendar } from './WaterfallUnitAvailabilityCalendar'
import {
WaterfallCheckoutFields,
useWaterfallCheckoutValidation,
} from './WaterfallCheckoutFields'
import { WaterfallSummary } from './WaterfallSummary'
import { WaterfallSelectedItem } from './WaterfallSelectedItem'
import { FormProvider } from '../../../components/Form/FormProvider'
import { PaymentStep } from '../steps/PaymentStep'
import { ThankYou } from '../ThankYou/ThankYou'
import { useProcessBooking } from '../../../hooks/useProcessBooking'
import { stripeMethods } from '../PaymentHandler/payments/Stripe/StripeMethods'
import { useWording } from '../../../hooks/useWording'
import './WaterfallForm.scss'
const isUnsetAttr = (value: unknown) =>
value == null || String(value).trim() === '' || String(value) === '0'
const WATERFALL_SCROLL_EXTRA_PX = 220
const getScrollableParent = (element: HTMLElement) => {
let container = element.parentElement
while (container) {
const style = window.getComputedStyle(container)
const canScroll =
(style.overflowY === 'auto' || style.overflowY === 'scroll') &&
container.scrollHeight > container.clientHeight + 1
if (canScroll) {
return container
}
container = container.parentElement
}
return null
}
const scrollWaterfallSection = (element: HTMLElement | null) => {
if (!element) {
return
}
const elRect = element.getBoundingClientRect()
const container = getScrollableParent(element)
if (container) {
const containerRect = container.getBoundingClientRect()
const overflowBelow = elRect.bottom - containerRect.bottom
container.scrollTo({
top: container.scrollTop + overflowBelow + WATERFALL_SCROLL_EXTRA_PX,
behavior: 'smooth',
})
return
}
const overflowBelow = elRect.bottom - window.innerHeight
window.scrollBy({
top: overflowBelow + WATERFALL_SCROLL_EXTRA_PX,
behavior: 'smooth',
})
}
const WaterfallAppear = ({ children }: { children: ReactNode }) => {
const [visible, setVisible] = useState(false)
useEffect(() => {
let innerFrame = 0
const outerFrame = window.requestAnimationFrame(() => {
innerFrame = window.requestAnimationFrame(() => {
setVisible(true)
})
})
return () => {
window.cancelAnimationFrame(outerFrame)
window.cancelAnimationFrame(innerFrame)
}
}, [])
return (
{children}
)
}
export const WaterfallForm = ({
onBookingComplete,
}: {
onBookingComplete?: (response?: any) => void
} = {}) => {
const {
attrService,
attrHideCategory,
bookingMode,
categories,
services,
units,
formData,
preset,
setFormObj,
setFormData,
onLocationSelect,
onStaffSelect,
extractedAttrCats,
extractedAttrLocations,
extractedAttrStaff,
loading,
fields,
setFields,
paymentMethods,
stripeObj,
amountData,
} = useBookingContext()
const { fetchBookingFields, fetchBookingAmounts } = useDispatch(store_name)
const processBooking = useProcessBooking()
const checkoutWording = useWording()
const { canSubmit: canSubmitFields, submitError: fieldsSubmitError } =
useWaterfallCheckoutValidation()
const wording = preset?.wording ?? {}
const calendarRef = useRef(null)
const checkoutButtonRef = useRef(null)
const checkoutFieldsRef = useRef(null)
const [showCheckoutFields, setShowCheckoutFields] = useState(false)
const [staffChoice, setStaffChoice] = useState<{
serviceId: number
staffId: string
} | null>(null)
const [completedBooking, setCompletedBooking] = useState(null)
const hasPresetService = !isUnsetAttr(attrService)
const hasPresetCategory = extractedAttrCats.length > 0
const hasPresetLocation = extractedAttrLocations.length > 0
const bookableItems = bookingMode === 'units' ? units || [] : services || []
const locations = useMemo(
() =>
getServiceAssociatedLocations(
(preset?.locations ?? []) as ILocationOption[],
bookableItems as Array<{ locations?: Array }>
),
[bookableItems, preset?.locations]
)
const hasLocations = locations.length > 0
const hasServices =
bookingMode === 'units'
? (preset?.units ?? []).length > 0
: (preset?.services ?? []).length > 0
const locationId =
formData?.location != null ? String(formData.location) : ''
const locationSelected = locationId !== ''
const selectedCategoryFromUi = (categories || []).find(
({ selected }) => selected
)
const presetCategories = useMemo(
() =>
(categories || []).filter((category) =>
extractedAttrCats.includes(Number(category.id))
),
[categories, extractedAttrCats]
)
const selectedCategory =
selectedCategoryFromUi ||
(presetCategories.length === 1 ? presetCategories[0] : undefined)
const categorySelected = !!selectedCategory || hasPresetCategory
const categoryId = selectedCategory ? String(selectedCategory.id) : ''
const clearCategorySelection = () => {
setFormObj(
'categories',
(categories || []).map((category) => ({
...category,
selected: false,
}))
)
setFormData('category', null)
}
const clearBookableSelection = () => {
if (bookingMode === 'units') {
setFormObj(
'units',
(units || []).map((unit) => ({
...unit,
selected: false,
selectedAt: null,
}))
)
setFormData('units', [])
return
}
setFormObj(
'services',
(services || []).map((service) => ({
...service,
selected: false,
selectedAt: null,
}))
)
setFormData('services', [])
}
const serviceIdsInSelectedLocation = useMemo(() => {
if (!locationId) {
return new Set(bookableItems.map((item) => String(item.id)))
}
const inLocation = bookableItems.filter((item) => {
const locs = item.locations
if (!locs || !Array.isArray(locs) || locs.length === 0) {
return false
}
return locs.some((lid) => String(lid) === locationId)
})
return new Set(inLocation.map((item) => String(item.id)))
}, [bookableItems, locationId])
const categoryOptions = useMemo(() => {
return (categories || [])
.filter((category) => {
const linkedIds =
bookingMode === 'units'
? category.units || []
: category.services || []
return linkedIds.some((id) =>
serviceIdsInSelectedLocation.has(String(id))
)
})
.map((category) => ({
id: String(category.id),
label: category.name,
}))
}, [bookingMode, categories, serviceIdsInSelectedLocation])
const hasCategories =
attrHideCategory !== 'yes' && categoryOptions.length > 0
const serviceOptions = useMemo(() => {
let filtered = bookableItems
if (locationSelected) {
filtered = filtered.filter((item) =>
serviceIdsInSelectedLocation.has(String(item.id))
)
}
const categoryFilterSource = selectedCategory
? [selectedCategory]
: presetCategories
if (categoryFilterSource.length > 0) {
const linkedIds = new Set(
categoryFilterSource.flatMap((category) =>
(
bookingMode === 'units'
? category.units || []
: category.services || []
).map((id) => String(id))
)
)
filtered = filtered.filter((item) => linkedIds.has(String(item.id)))
}
return filtered.map((item) => ({
id: String(item.id),
label: item.label,
description: item.description
? getPlainTextFromHtml(item.description)
: undefined,
}))
}, [
bookableItems,
bookingMode,
locationSelected,
presetCategories,
selectedCategory,
serviceIdsInSelectedLocation,
])
// Reveal each dropdown under the previous one once the prior selection is made
const showLocation =
!hasPresetService &&
!hasPresetCategory &&
!hasPresetLocation &&
hasLocations
const showCategory =
!hasPresetService &&
!hasPresetCategory &&
hasCategories &&
(!hasLocations || locationSelected)
const showService =
!hasPresetService &&
hasServices &&
(!hasLocations || locationSelected || hasPresetCategory) &&
(!hasCategories || categorySelected)
const selectedService =
bookingMode === 'services'
? (services || []).find(({ selected }) => selected)
: undefined
const selectedUnit =
bookingMode === 'units'
? (units || []).find(({ selected }) => selected)
: undefined
const selectedServiceId = selectedService
? String(selectedService.id)
: selectedUnit
? String(selectedUnit.id)
: ''
const locationIdForStaff = locationId !== '' ? locationId : null
const staffOptions = useMemo(
() =>
selectedService
? getStaffOptionsForService(
preset?.staff_members as IStaffOption[] | undefined,
selectedService.id,
locationIdForStaff,
extractedAttrStaff
)
: [],
[
extractedAttrStaff,
locationIdForStaff,
preset?.staff_members,
selectedService?.id,
]
)
const staffChoiceForService =
selectedService && staffChoice?.serviceId === selectedService.id
? staffChoice.staffId
: null
const showStaffDropdown =
bookingMode === 'services' &&
!!selectedService &&
extractedAttrStaff.length !== 1 &&
staffOptions.length > 1
const staffIdForCalendar = useMemo(() => {
if (bookingMode !== 'services' || !selectedService) {
return null
}
if (staffOptions.length === 0) {
return null
}
if (staffOptions.length === 1 || extractedAttrStaff.length === 1) {
return staffOptions[0]?.id ?? extractedAttrStaff[0] ?? null
}
return staffChoiceForService
}, [
bookingMode,
extractedAttrStaff,
selectedService,
staffChoiceForService,
staffOptions,
])
const showServiceCalendar =
!!selectedService &&
(staffOptions.length === 0 || staffIdForCalendar != null)
const staffDropdownOptions = useMemo(
() => [
{
id: STAFF_ANY_AVAILABLE,
label:
wording.any_available ||
__('Any available', 'webba-booking-lite'),
description:
wording.best_available_time ||
__('Best available time.', 'webba-booking-lite'),
},
...staffOptions.map((staff) => ({
id: String(staff.id),
label: staff.label,
})),
],
[staffOptions, wording.any_available, wording.best_available_time]
)
const hasSelectedTimeslot =
bookingMode === 'units'
? (() => {
if (!selectedUnit) {
return false
}
const placesMap = formData?.places as
| Record
| undefined
const unitPlaces = placesMap?.[String(selectedUnit.id)]
return Array.isArray(unitPlaces) && unitPlaces.length > 0
})()
: !!selectedService &&
Array.isArray(selectedService.places) &&
selectedService.places.length > 0
const handleLocationChange = (id: string | number) => {
const nextLocationId = String(id)
if (nextLocationId === locationId) {
return
}
if (locationSelected) {
clearCategorySelection()
clearBookableSelection()
}
onLocationSelect(id)
}
const handleCategoryChange = (id: string) => {
const category = (categories || []).find(
(item) => String(item.id) === id
)
if (!category || category.selected) {
return
}
// Changing category rebuilds the service dropdown below it
clearBookableSelection()
category.onSelect()
}
const handleServiceChange = (id: string) => {
const item = bookableItems.find((entry) => String(entry.id) === id)
if (!item || item.selected) {
return
}
clearBookableSelection()
item.onUpdate({ selected: true })
}
const handleStaffChange = (id: string) => {
if (!selectedService || staffChoiceForService === id) {
return
}
selectedService.onUpdate({ places: [] })
setStaffChoice({ serviceId: selectedService.id, staffId: id })
onStaffSelect(selectedService.id, id)
}
const handleProceedToCheckout = async () => {
const selectedIds =
bookingMode === 'units'
? (units || [])
.filter((item) => item.selected)
.map((item) => item.id)
: (services || [])
.filter((item) => item.selected)
.map((item) => item.id)
if (selectedIds.length > 0) {
await fetchBookingFields(selectedIds)
}
setShowCheckoutFields(true)
}
const showPaymentStep = useMemo(() => {
if (!paymentMethods || paymentMethods.length === 0) {
return false
}
const onlyMethod = paymentMethods[0]?.id
const hostedCheckoutMethods = ['paypal', 'woocommerce']
// Keep the payment UI for hosted gateways even when it is the only method.
// Bank / arrival can still skip the selector when coupons are off.
if (
paymentMethods.length === 1 &&
preset?.settings?.coupons_enabled === false &&
!stripeMethods.includes(onlyMethod) &&
!hostedCheckoutMethods.includes(onlyMethod)
) {
return false
}
return true
}, [paymentMethods, preset])
const { canSubmit, submitError } = useMemo(() => {
if (!canSubmitFields) {
return { canSubmit: false, submitError: fieldsSubmitError }
}
if (
preset?.settings?.coupons_enabled &&
String(formData?.coupon || '').length > 0 &&
Number(amountData?.to_pay_total) <= 0
) {
return { canSubmit: true, submitError: null as string | null }
}
if (!showPaymentStep) {
if (
Number(amountData?.to_pay_total) > 0 &&
(!paymentMethods || paymentMethods.length === 0)
) {
return {
canSubmit: false,
submitError:
checkoutWording?.please_select_payment_method ||
__('Please select a payment method', 'webba-booking-lite'),
}
}
return { canSubmit: true, submitError: null as string | null }
}
const paymentMethod = formData?.payment_method as string
const acceptedMethods = [
'stripe',
'paypal',
'mollie',
'bank',
'arrival',
'woocommerce',
'google_pay',
'apple_pay',
'stripe_others',
]
if (
paymentMethod &&
stripeMethods.includes(paymentMethod) &&
!stripeObj?.isValidated &&
Number(amountData?.to_pay_total) > 0
) {
return {
canSubmit: false,
submitError:
checkoutWording?.please_fill_out_payment_form ||
__('Please fill out payment form', 'webba-booking-lite'),
}
}
if (!paymentMethod || !acceptedMethods.includes(paymentMethod)) {
return {
canSubmit: false,
submitError:
checkoutWording?.please_select_payment_method ||
__('Please select a payment method', 'webba-booking-lite'),
}
}
return { canSubmit: true, submitError: null as string | null }
}, [
canSubmitFields,
fieldsSubmitError,
showPaymentStep,
formData?.payment_method,
formData?.coupon,
preset,
stripeObj?.isValidated,
amountData?.to_pay_total,
checkoutWording,
paymentMethods,
])
const isSubmitting = Boolean(loading?.createBooking)
const handleSubmit = async () => {
if (!canSubmit || isSubmitting) {
return
}
try {
const response = await processBooking()
const method = response?.payment_method
const needsHostedCheckout =
response?.payment_required === true &&
(method === 'paypal' || method === 'woocommerce')
if (needsHostedCheckout) {
onBookingComplete?.(response)
return
}
setCompletedBooking(response || { success: true })
onBookingComplete?.(response)
} catch {
// Error toast is handled in useProcessBooking
}
}
const placesKey = JSON.stringify(formData?.places)
useEffect(() => {
if (!selectedService || bookingMode !== 'services') {
return
}
if (staffOptions.length === 1) {
const singleStaffId = String(staffOptions[0].id)
setStaffChoice({
serviceId: selectedService.id,
staffId: singleStaffId,
})
onStaffSelect(selectedService.id, singleStaffId)
return
}
if (extractedAttrStaff.length === 1 && extractedAttrStaff[0]) {
const singleStaffId = extractedAttrStaff[0]
setStaffChoice({
serviceId: selectedService.id,
staffId: singleStaffId,
})
onStaffSelect(selectedService.id, singleStaffId)
}
}, [
bookingMode,
extractedAttrStaff,
onStaffSelect,
selectedService?.id,
staffOptions,
])
useEffect(() => {
if (!hasSelectedTimeslot || bookingMode === 'units') {
return
}
if (
!formData?.places ||
Object.keys(formData.places as Record).length === 0
) {
return
}
fetchBookingAmounts({
...formData,
generate_stripe_intent: false,
})
}, [placesKey, hasSelectedTimeslot, bookingMode])
useEffect(() => {
if (!selectedUnit?.id) {
return
}
const timeoutId = window.setTimeout(() => {
scrollWaterfallSection(calendarRef.current)
}, 400)
return () => window.clearTimeout(timeoutId)
}, [selectedUnit?.id])
useEffect(() => {
if (!hasSelectedTimeslot) {
return
}
const timeoutId = window.setTimeout(() => {
scrollWaterfallSection(checkoutButtonRef.current)
}, 400)
return () => window.clearTimeout(timeoutId)
}, [hasSelectedTimeslot])
useEffect(() => {
if (!hasSelectedTimeslot) {
setShowCheckoutFields(false)
}
}, [hasSelectedTimeslot])
useEffect(() => {
if (!showCheckoutFields || loading?.bookingFields) {
return
}
const timeoutId = window.setTimeout(() => {
scrollWaterfallSection(checkoutFieldsRef.current)
}, 400)
return () => window.clearTimeout(timeoutId)
}, [showCheckoutFields, loading?.bookingFields])
if (completedBooking) {
return
}
return (
{showLocation && (
)}
{showCategory && (
}
label={
wording.category ||
__('Category', 'webba-booking-lite')
}
placeholder={
wording.select_category ||
wording.category_label ||
__('Select category', 'webba-booking-lite')
}
options={categoryOptions}
value={categoryId}
onChange={handleCategoryChange}
/>
)}
{showService && (
) : (
)
}
label={
bookingMode === 'units'
? wording.unit ||
__('Unit', 'webba-booking-lite')
: wording.service ||
__('Service', 'webba-booking-lite')
}
placeholder={
bookingMode === 'units'
? wording.select_unit ||
wording.select_units ||
__('Select unit', 'webba-booking-lite')
: wording.select_service ||
wording.service_label ||
wording.select_services ||
__('Select service', 'webba-booking-lite')
}
options={serviceOptions}
value={selectedServiceId}
onChange={handleServiceChange}
/>
)}
{!showService && (selectedService || selectedUnit) && (
)}
{showStaffDropdown && selectedService && (
}
label={
wording.staff_member ||
wording.staff ||
__('Staff member', 'webba-booking-lite')
}
placeholder={
wording.select_staff_member ||
__('Select staff member', 'webba-booking-lite')
}
options={staffDropdownOptions}
value={staffChoiceForService || ''}
onChange={handleStaffChange}
/>
)}
{showServiceCalendar && selectedService && (
)}
{selectedUnit && (
)}
{hasSelectedTimeslot && !showCheckoutFields && (
)}
{showCheckoutFields && (
{showPaymentStep && !loading?.bookingFields && (
)}
)}
)
}