@balby/booking-search 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,199 @@
1
+ import * as React from "react"
2
+ import { Search } from "lucide-react"
3
+ import { cn } from "../../lib/utils"
4
+ import type { BookingSearchProps, SearchLocation, GuestData } from "../../types/booking"
5
+ import { LocationCombobox } from "./location-combobox"
6
+ import { DateRangePicker } from "./date-range-picker"
7
+ import { GuestSelector } from "./guest-selector"
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ DialogTrigger,
14
+ } from "./ui/dialog"
15
+ import type {JSX} from "react"
16
+
17
+ /**
18
+ * A component that provides a booking search interface. Users can select a destination, date range, and number of guests.
19
+ * It supports both desktop and mobile layouts.
20
+ *
21
+ * @param {Object} props - The properties object.
22
+ * @param {Array} props.availability - The data specifying available dates for booking.
23
+ * @param {Array} props.locations - The list of available locations for selection.
24
+ * @param {Function} props.onSearch - Callback function triggered when the search is submitted. Receives selected search criteria as an argument.
25
+ * @param {Object} [props.defaultValues] - Default values for location, check-in and check-out dates, and guest count.
26
+ * @param {string} [props.searchButtonText='Cerca'] - Text for the search button.
27
+ * @param {string} [props.locationPlaceholder='Dove vuoi andare?'] - Placeholder text for the location input field.
28
+ * @param {number} [props.minNights=1] - Minimum number of nights for the date range picker.
29
+ * @param {number} [props.maxAdults=30] - Maximum number of adult guests allowed.
30
+ * @param {number} [props.maxChildren=10] - Maximum number of child guests allowed.
31
+ * @param {string} [props.className] - Additional CSS class for customizing the component style.
32
+ *
33
+ * @return {JSX.Element} A booking search component containing inputs for location, date range, and guest selection, along with a search button.
34
+ */
35
+ export function BookingSearch({
36
+ availability,
37
+ locations,
38
+ onSearch,
39
+ defaultValues,
40
+ searchButtonText = "Search",
41
+ locationPlaceholder = "Where do you want to go?",
42
+ minNights = 1,
43
+ maxAdults = 30,
44
+ maxChildren = 10,
45
+ className,
46
+ }: BookingSearchProps): JSX.Element {
47
+ const [location, setLocation] = React.useState<SearchLocation | null>(
48
+ defaultValues?.location ?? null
49
+ )
50
+ const [dateRange, setDateRange] = React.useState<{
51
+ from: Date | null
52
+ to: Date | null
53
+ }>({
54
+ from: defaultValues?.checkIn ?? null,
55
+ to: defaultValues?.checkOut ?? null,
56
+ })
57
+ const [guests, setGuests] = React.useState<GuestData>({
58
+ adults: defaultValues?.adults ?? 2,
59
+ children: defaultValues?.children ?? 0,
60
+ })
61
+
62
+ const [mobileOpen, setMobileOpen] = React.useState(false)
63
+
64
+ const [isMobile, setIsMobile] = React.useState(false)
65
+
66
+ React.useEffect(() => {
67
+ const checkMobile = () => {
68
+ setIsMobile(window.innerWidth < 768)
69
+ }
70
+ checkMobile()
71
+ window.addEventListener("resize", checkMobile)
72
+ return () => window.removeEventListener("resize", checkMobile)
73
+ }, [])
74
+
75
+ const handleSearch = () => {
76
+ onSearch({
77
+ location,
78
+ checkIn: dateRange.from,
79
+ checkOut: dateRange.to,
80
+ adults: guests.adults,
81
+ children: guests.children,
82
+ })
83
+ if (isMobile) {
84
+ setMobileOpen(false)
85
+ }
86
+ }
87
+
88
+ const isSearchEnabled = location && dateRange.from && dateRange.to
89
+
90
+ const SearchContent = () => (
91
+ <div className="flex flex-col gap-3 md:flex-row md:gap-2 md:items-center">
92
+ <div className="flex-1 relative">
93
+ <LocationCombobox
94
+ title="Destination"
95
+ locations={locations}
96
+ value={location}
97
+ onChange={setLocation}
98
+ placeholder={locationPlaceholder}
99
+ className="w-full border-0 md:rounded-l-lg md:rounded-r-none focus-visible:ring-0"
100
+ tabIndex={1}
101
+ />
102
+ </div>
103
+
104
+ <div className="flex-1 relative">
105
+ <DateRangePicker
106
+ availability={availability}
107
+ value={dateRange}
108
+ onChange={setDateRange}
109
+ minNights={minNights}
110
+ className="w-full border-0 md:rounded-none focus-visible:ring-0"
111
+ tabIndex={2}
112
+ />
113
+ </div>
114
+
115
+ <div className="flex-1 relative">
116
+ <GuestSelector
117
+ value={guests}
118
+ onChange={setGuests}
119
+ maxAdults={maxAdults}
120
+ maxChildren={maxChildren}
121
+ className="w-full border-0 md:rounded-none focus-visible:ring-0"
122
+ tabIndex={3}
123
+ />
124
+ </div>
125
+
126
+ <div className="md:pl-2">
127
+ <button
128
+ type="button"
129
+ onClick={handleSearch}
130
+ disabled={!isSearchEnabled}
131
+ aria-label={searchButtonText}
132
+ tabIndex={4}
133
+ className={cn(
134
+ "flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-6 text-sm font-bold text-white transition-all hover:bg-blue-700 disabled:bg-slate-300 md:w-auto",
135
+ )}
136
+ >
137
+ <Search className="h-4 w-4" />
138
+ <span className="whitespace-nowrap">{searchButtonText}</span>
139
+ </button>
140
+ </div>
141
+ </div>
142
+ )
143
+
144
+ if (!isMobile) {
145
+ return (
146
+ <div
147
+ className={cn(
148
+ "w-full rounded-2xl border border-slate-200 bg-white p-3 shadow-xl",
149
+ className
150
+ )}
151
+ role="search"
152
+ aria-label="Booking search"
153
+ >
154
+ <SearchContent />
155
+ </div>
156
+ )
157
+ }
158
+
159
+ return (
160
+ <>
161
+ <Dialog open={mobileOpen} onOpenChange={setMobileOpen}>
162
+ <DialogTrigger asChild>
163
+ <button
164
+ type="button"
165
+ tabIndex={0}
166
+ className={cn(
167
+ "flex h-14 w-full items-center gap-3 rounded-lg border border-slate-300 bg-white px-4 shadow-md",
168
+ className
169
+ )}
170
+ aria-label="Open booking search"
171
+ >
172
+ <Search className="h-5 w-5 text-slate-500" aria-hidden="true" />
173
+ <div className="flex flex-col items-start text-left">
174
+ <span className="text-sm font-semibold text-slate-900">
175
+ Dove vuoi andare?
176
+ </span>
177
+ <span className="text-xs text-slate-500">
178
+ {location?.name ?? "Destination"} • {guests.adults + guests.children}{" "}
179
+ {guests.adults + guests.children === 1 ? "ospite" : "ospiti"}
180
+ </span>
181
+ </div>
182
+ </button>
183
+ </DialogTrigger>
184
+ <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[600px]">
185
+ <DialogHeader>
186
+ <DialogTitle>Search your accommodation</DialogTitle>
187
+ </DialogHeader>
188
+ <div className="mt-4">
189
+ <SearchContent />
190
+ </div>
191
+ </DialogContent>
192
+ </Dialog>
193
+ </>
194
+ )
195
+ }
196
+
197
+ export { LocationCombobox } from "./location-combobox"
198
+ export { DateRangePicker } from "./date-range-picker"
199
+ export { GuestSelector } from "./guest-selector"
@@ -0,0 +1,93 @@
1
+ import * as React from "react"
2
+ import {Check, MapPin} from "lucide-react"
3
+ import {cn} from "../../lib/utils"
4
+ import type {LocationComboboxProps} from "../../types/booking"
5
+ import {
6
+ Command,
7
+ CommandEmpty,
8
+ CommandGroup,
9
+ CommandInput,
10
+ CommandItem,
11
+ CommandList,
12
+ } from "./ui/command"
13
+ import {Popover, PopoverContent, PopoverTrigger} from "./ui/popover"
14
+
15
+ export function LocationCombobox({
16
+ locations,
17
+ value,
18
+ onChange,
19
+ placeholder = "Where do you want to stay?",
20
+ disabled = false,
21
+ className,
22
+ tabIndex,
23
+ title = "Destination"
24
+ }: LocationComboboxProps) {
25
+ const [open, setOpen] = React.useState(false)
26
+
27
+ return (
28
+ <Popover open={open} onOpenChange={setOpen}>
29
+ <PopoverTrigger asChild>
30
+ <button
31
+ type="button"
32
+ role="combobox"
33
+ aria-expanded={open}
34
+ aria-label="Select location"
35
+ disabled={disabled}
36
+ tabIndex={tabIndex}
37
+ className={cn(
38
+ "flex h-14 w-full items-center gap-3 rounded-lg border border-slate-300 bg-white px-4 py-3 text-left text-sm transition-colors hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
39
+ className
40
+ )}
41
+ >
42
+ <MapPin className="h-5 w-5 text-slate-500 flex-shrink-0" aria-hidden="true"/>
43
+ <div className="flex flex-col min-w-0 flex-1">
44
+ <span className="text-xs font-medium text-slate-500">{title}</span>
45
+ {value ? (
46
+ <span className="font-medium text-slate-900 truncate">{value.name}</span>
47
+ ) : (
48
+ <span className="text-slate-400 truncate">{placeholder}</span>
49
+ )}
50
+ </div>
51
+ </button>
52
+ </PopoverTrigger>
53
+ <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
54
+ <Command>
55
+ <CommandInput placeholder="Search a destination..."/>
56
+ <CommandList>
57
+ <CommandEmpty>No location found.</CommandEmpty>
58
+ <CommandGroup>
59
+ {locations.map((location) => (
60
+ <CommandItem
61
+ key={location.id}
62
+ value={location.name}
63
+ onSelect={() => {
64
+ onChange(location.id === value?.id ? null : location)
65
+ setOpen(false)
66
+ }}
67
+ className="flex items-center justify-between"
68
+ >
69
+ <div className="flex items-center gap-2">
70
+ <MapPin className="h-4 w-4 text-slate-500" aria-hidden="true"/>
71
+ <div className="flex flex-col">
72
+ <span className="font-medium">{location.name}</span>
73
+ {location.type && (
74
+ <span className="text-xs text-slate-500">{location.type}</span>
75
+ )}
76
+ </div>
77
+ </div>
78
+ <Check
79
+ className={cn(
80
+ "h-4 w-4",
81
+ value?.id === location.id ? "opacity-100" : "opacity-0"
82
+ )}
83
+ aria-hidden="true"
84
+ />
85
+ </CommandItem>
86
+ ))}
87
+ </CommandGroup>
88
+ </CommandList>
89
+ </Command>
90
+ </PopoverContent>
91
+ </Popover>
92
+ )
93
+ }
@@ -0,0 +1,308 @@
1
+ import { test, expect, describe, beforeEach } from "bun:test"
2
+ import React from "react"
3
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react"
4
+ import { DateRangePicker } from "../date-range-picker"
5
+ import type { AvailabilityDay } from "../../../types/booking"
6
+ import { format, addDays } from "date-fns"
7
+
8
+ describe("DateRangePicker", () => {
9
+ const today = new Date()
10
+ const tomorrow = addDays(today, 1)
11
+ const nextWeek = addDays(today, 7)
12
+
13
+ const mockAvailability: AvailabilityDay[] = Array.from({ length: 30 }, (_, i) => {
14
+ const date = addDays(today, i)
15
+ return {
16
+ date: format(date, "yyyy-MM-dd"),
17
+ isAvailable: true,
18
+ price: 100 + i * 5,
19
+ }
20
+ })
21
+
22
+ const mockValue = {
23
+ from: null as Date | null,
24
+ to: null as Date | null,
25
+ }
26
+
27
+ const mockOnChange = (range: { from: Date | null; to: Date | null }) => {}
28
+
29
+ beforeEach(() => {
30
+ // Reset any state between tests if needed
31
+ })
32
+
33
+ test("renders with placeholder when no dates are selected", () => {
34
+ render(
35
+ <DateRangePicker
36
+ availability={mockAvailability}
37
+ value={mockValue}
38
+ onChange={mockOnChange}
39
+ />
40
+ )
41
+
42
+ expect(screen.getByText("Select Date Range")).toBeDefined()
43
+ expect(screen.getByText("Check-in - Check-out")).toBeDefined()
44
+ })
45
+
46
+ test("displays selected date range", () => {
47
+ render(
48
+ <DateRangePicker
49
+ availability={mockAvailability}
50
+ value={{ from: today, to: nextWeek }}
51
+ onChange={mockOnChange}
52
+ />
53
+ )
54
+
55
+ const dateText = screen.getByText(/\d{2} \w{3} - \d{2} \w{3}/)
56
+ expect(dateText).toBeDefined()
57
+ })
58
+
59
+ test("displays only check-in date when check-out is not selected", () => {
60
+ render(
61
+ <DateRangePicker
62
+ availability={mockAvailability}
63
+ value={{ from: today, to: null }}
64
+ onChange={mockOnChange}
65
+ />
66
+ )
67
+
68
+ const dateText = screen.getByText(/\d{2} \w{3} \d{4}/)
69
+ expect(dateText).toBeDefined()
70
+ })
71
+
72
+ test("opens popover when button is clicked", async () => {
73
+ render(
74
+ <DateRangePicker
75
+ availability={mockAvailability}
76
+ value={mockValue}
77
+ onChange={mockOnChange}
78
+ />
79
+ )
80
+
81
+ const button = screen.getByRole("button", { name: "Select Date Range" })
82
+ fireEvent.click(button)
83
+
84
+ await waitFor(() => {
85
+ expect(screen.getByText("Confirm")).toBeDefined()
86
+ expect(screen.getByText("Cancel")).toBeDefined()
87
+ })
88
+ })
89
+
90
+ test("selects check-in date on first click", async () => {
91
+ let currentValue = { from: null as Date | null, to: null as Date | null }
92
+ const handleChange = (range: { from: Date | null; to: Date | null }) => {
93
+ currentValue = range
94
+ }
95
+
96
+ render(
97
+ <DateRangePicker
98
+ availability={mockAvailability}
99
+ value={currentValue}
100
+ onChange={handleChange}
101
+ />
102
+ )
103
+
104
+ const button = screen.getByRole("button", { name: "Select Date Range" })
105
+ fireEvent.click(button)
106
+
107
+ await waitFor(() => {
108
+ expect(screen.getByText("Confirm")).toBeDefined()
109
+ })
110
+ })
111
+
112
+ test("calls onChange with selected date range when confirm is clicked", async () => {
113
+ let currentValue = { from: null as Date | null, to: null as Date | null }
114
+ const handleChange = (range: { from: Date | null; to: Date | null }) => {
115
+ currentValue = range
116
+ }
117
+
118
+ render(
119
+ <DateRangePicker
120
+ availability={mockAvailability}
121
+ value={currentValue}
122
+ onChange={handleChange}
123
+ />
124
+ )
125
+
126
+ const button = screen.getByRole("button", { name: "Select Date Range" })
127
+ fireEvent.click(button)
128
+
129
+ await waitFor(() => {
130
+ const confirmButton = screen.getByText("Confirm")
131
+ expect(confirmButton).toBeDefined()
132
+ // Confirm button should be disabled when no dates are selected
133
+ expect(confirmButton.hasAttribute("disabled")).toBe(true)
134
+ })
135
+ })
136
+
137
+ test("does not call onChange until confirm is clicked", async () => {
138
+ let changeCallCount = 0
139
+ const handleChange = (range: { from: Date | null; to: Date | null }) => {
140
+ changeCallCount++
141
+ }
142
+
143
+ render(
144
+ <DateRangePicker
145
+ availability={mockAvailability}
146
+ value={{ from: null, to: null }}
147
+ onChange={handleChange}
148
+ />
149
+ )
150
+
151
+ const button = screen.getByRole("button", { name: "Select Date Range" })
152
+ fireEvent.click(button)
153
+
154
+ await waitFor(() => {
155
+ expect(screen.getByText("Confirm")).toBeDefined()
156
+ })
157
+
158
+ // Clicking on calendar dates would happen here, but that requires more complex interaction
159
+ // onChange should not have been called yet
160
+ expect(changeCallCount).toBe(0)
161
+ })
162
+
163
+ test("resets to original value when cancel is clicked", async () => {
164
+ const { rerender } = render(
165
+ <DateRangePicker
166
+ availability={mockAvailability}
167
+ value={{ from: today, to: nextWeek }}
168
+ onChange={mockOnChange}
169
+ />
170
+ )
171
+
172
+ const button = screen.getByRole("button", { name: "Select Date Range" })
173
+ fireEvent.click(button)
174
+
175
+ await waitFor(() => {
176
+ const cancelButton = screen.getByText("Cancel")
177
+ fireEvent.click(cancelButton)
178
+ })
179
+
180
+ // Popover should close
181
+ await waitFor(() => {
182
+ expect(screen.queryByText("Cancel")).toBeNull()
183
+ })
184
+ })
185
+
186
+ test("displays night count when both dates are selected", async () => {
187
+ render(
188
+ <DateRangePicker
189
+ availability={mockAvailability}
190
+ value={{ from: today, to: nextWeek }}
191
+ onChange={mockOnChange}
192
+ />
193
+ )
194
+
195
+ const button = screen.getByRole("button", { name: "Select Date Range" })
196
+ fireEvent.click(button)
197
+
198
+ await waitFor(() => {
199
+ expect(screen.getByText("7 nights")).toBeDefined()
200
+ })
201
+ })
202
+
203
+ test("displays singular form for one night", async () => {
204
+ render(
205
+ <DateRangePicker
206
+ availability={mockAvailability}
207
+ value={{ from: today, to: tomorrow }}
208
+ onChange={mockOnChange}
209
+ />
210
+ )
211
+
212
+ const button = screen.getByRole("button", { name: "Select Date Range" })
213
+ fireEvent.click(button)
214
+
215
+ await waitFor(() => {
216
+ expect(screen.getByText("1 night")).toBeDefined()
217
+ })
218
+ })
219
+
220
+ test("disables the button when disabled prop is true", () => {
221
+ render(
222
+ <DateRangePicker
223
+ availability={mockAvailability}
224
+ value={mockValue}
225
+ onChange={mockOnChange}
226
+ disabled={true}
227
+ />
228
+ )
229
+
230
+ const button = screen.getByRole("button", { name: "Select Date Range" })
231
+ expect(button.hasAttribute("disabled")).toBe(true)
232
+ })
233
+
234
+ test("applies custom className", () => {
235
+ const { container } = render(
236
+ <DateRangePicker
237
+ availability={mockAvailability}
238
+ value={mockValue}
239
+ onChange={mockOnChange}
240
+ className="custom-class"
241
+ />
242
+ )
243
+
244
+ const button = container.querySelector("button")
245
+ expect(button?.classList.contains("custom-class")).toBe(true)
246
+ })
247
+
248
+ test("closes popover after confirming selection", async () => {
249
+ render(
250
+ <DateRangePicker
251
+ availability={mockAvailability}
252
+ value={{ from: today, to: tomorrow }}
253
+ onChange={mockOnChange}
254
+ />
255
+ )
256
+
257
+ const button = screen.getByRole("button", { name: "Select Date Range" })
258
+ fireEvent.click(button)
259
+
260
+ await waitFor(() => {
261
+ expect(screen.getByText("Confirm")).toBeDefined()
262
+ })
263
+
264
+ const confirmButton = screen.getByText("Confirm")
265
+ fireEvent.click(confirmButton)
266
+
267
+ await waitFor(() => {
268
+ expect(screen.queryByText("Confirm")).toBeNull()
269
+ })
270
+ })
271
+
272
+ test("shows correct message when only check-in is selected", async () => {
273
+ render(
274
+ <DateRangePicker
275
+ availability={mockAvailability}
276
+ value={{ from: today, to: null }}
277
+ onChange={mockOnChange}
278
+ />
279
+ )
280
+
281
+ const button = screen.getByRole("button", { name: "Select Date Range" })
282
+ fireEvent.click(button)
283
+
284
+ await waitFor(() => {
285
+ expect(screen.getByText("Select check-out")).toBeDefined()
286
+ })
287
+ })
288
+
289
+ test("prevents interaction outside from closing popover", async () => {
290
+ render(
291
+ <DateRangePicker
292
+ availability={mockAvailability}
293
+ value={mockValue}
294
+ onChange={mockOnChange}
295
+ />
296
+ )
297
+
298
+ const button = screen.getByRole("button", { name: "Select Date Range" })
299
+ fireEvent.click(button)
300
+
301
+ await waitFor(() => {
302
+ expect(screen.getByText("Confirm")).toBeDefined()
303
+ })
304
+
305
+ // Try to close by clicking outside - this is prevented by onInteractOutside
306
+ // The popover should remain open
307
+ })
308
+ })