mautourco-components 0.1.1 → 0.2.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.
Files changed (133) hide show
  1. package/README.md +9 -9
  2. package/dist/components/atoms/Checkbox/Checkbox.js +7 -1
  3. package/dist/components/atoms/Icon/Icon.d.ts +1 -1
  4. package/dist/components/atoms/Icon/Icon.js +22 -1
  5. package/dist/components/atoms/Icon/icons/BuildingIcon.d.ts +8 -0
  6. package/dist/components/atoms/Icon/icons/BuildingIcon.js +36 -0
  7. package/dist/components/atoms/Icon/icons/CalendarOutlineIcon.d.ts +8 -0
  8. package/dist/components/atoms/Icon/icons/CalendarOutlineIcon.js +36 -0
  9. package/dist/components/atoms/Icon/icons/HomeIcon.d.ts +8 -0
  10. package/dist/components/atoms/Icon/icons/HomeIcon.js +25 -0
  11. package/dist/components/atoms/Icon/icons/MinusIcon.d.ts +8 -0
  12. package/dist/components/atoms/Icon/icons/MinusIcon.js +25 -0
  13. package/dist/components/atoms/Icon/icons/PlaneIcon.d.ts +8 -0
  14. package/dist/components/atoms/Icon/icons/PlaneIcon.js +36 -0
  15. package/dist/components/atoms/Icon/icons/PlusIcon.d.ts +8 -0
  16. package/dist/components/atoms/Icon/icons/PlusIcon.js +25 -0
  17. package/dist/components/atoms/Icon/icons/ShipIcon.d.ts +8 -0
  18. package/dist/components/atoms/Icon/icons/ShipIcon.js +36 -0
  19. package/dist/components/atoms/Illustration/Illustration.d.ts +14 -0
  20. package/dist/components/atoms/Illustration/Illustration.js +33 -0
  21. package/dist/components/atoms/Illustration/illustrations.d.ts +51 -0
  22. package/dist/components/atoms/Illustration/illustrations.js +97 -0
  23. package/dist/components/atoms/RatingStar/RatingStar.d.ts +40 -0
  24. package/dist/components/atoms/RatingStar/RatingStar.js +54 -0
  25. package/dist/components/atoms/SegmentedButton/SegmentedButton.d.ts +27 -0
  26. package/dist/components/atoms/SegmentedButton/SegmentedButton.js +49 -0
  27. package/dist/components/atoms/Slider/Slider.d.ts +52 -0
  28. package/dist/components/atoms/Slider/Slider.js +30 -0
  29. package/dist/components/molecules/Calendar/CalendarInput.d.ts +34 -0
  30. package/dist/components/molecules/Calendar/CalendarInput.js +49 -0
  31. package/dist/components/molecules/Calendar/DateTime.d.ts +25 -0
  32. package/dist/components/molecules/Calendar/DateTime.js +106 -0
  33. package/dist/components/molecules/Calendar/TimePicker.d.ts +16 -0
  34. package/dist/components/molecules/Calendar/TimePicker.js +91 -0
  35. package/dist/components/molecules/LocationDropdown/LocationDropdown.d.ts +34 -0
  36. package/dist/components/molecules/LocationDropdown/LocationDropdown.js +120 -0
  37. package/dist/components/molecules/LocationDropdown/index.d.ts +2 -0
  38. package/dist/components/molecules/LocationDropdown/index.js +1 -0
  39. package/dist/components/molecules/RatingTab/RatingTab.d.ts +39 -0
  40. package/dist/components/molecules/RatingTab/RatingTab.js +41 -0
  41. package/dist/components/molecules/TabGroup/TabGroup.d.ts +17 -0
  42. package/dist/components/molecules/TabGroup/TabGroup.js +30 -0
  43. package/dist/components/organisms/CardContainer/CardContainer.d.ts +37 -0
  44. package/dist/components/organisms/CardContainer/CardContainer.js +27 -0
  45. package/dist/components/organisms/DateTimePicker/DateTimePicker.d.ts +15 -0
  46. package/dist/components/organisms/DateTimePicker/DateTimePicker.js +66 -0
  47. package/dist/components/organisms/Dialog/Dialog.d.ts +103 -0
  48. package/dist/components/organisms/Dialog/Dialog.js +162 -0
  49. package/dist/components/organisms/PaxSelector/PaxSelector.d.ts +63 -0
  50. package/dist/components/organisms/PaxSelector/PaxSelector.js +402 -0
  51. package/dist/components/organisms/RoundTrip/RoundTrip.d.ts +54 -0
  52. package/dist/components/organisms/RoundTrip/RoundTrip.js +179 -0
  53. package/dist/components/organisms/RoundTrip/index.d.ts +2 -0
  54. package/dist/components/organisms/RoundTrip/index.js +1 -0
  55. package/dist/components/organisms/SearchBarTransfer/SearchBarTransfer.d.ts +35 -0
  56. package/dist/components/organisms/SearchBarTransfer/SearchBarTransfer.js +192 -0
  57. package/dist/components/organisms/SearchBarTransfer/index.d.ts +2 -0
  58. package/dist/components/organisms/SearchBarTransfer/index.js +1 -0
  59. package/dist/components/organisms/TransferLine/TransferLine.d.ts +53 -0
  60. package/dist/components/organisms/TransferLine/TransferLine.js +179 -0
  61. package/dist/components/ui/button.d.ts +10 -0
  62. package/dist/components/ui/button.js +56 -0
  63. package/dist/components/ui/calendar.d.ts +8 -0
  64. package/dist/components/ui/calendar.js +87 -0
  65. package/dist/components/ui/popover.d.ts +7 -0
  66. package/dist/components/ui/popover.js +42 -0
  67. package/dist/index.d.ts +26 -0
  68. package/dist/index.js +18 -0
  69. package/dist/lib/utils.d.ts +7 -0
  70. package/dist/lib/utils.js +13 -0
  71. package/package.json +21 -12
  72. package/src/components/atoms/Button/Button.css +34 -34
  73. package/src/components/atoms/Checkbox/Checkbox.tsx +83 -69
  74. package/src/components/atoms/Icon/Icon.tsx +30 -2
  75. package/src/components/atoms/Icon/icons/BuildingIcon.tsx +50 -0
  76. package/src/components/atoms/Icon/icons/CalendarOutlineIcon.tsx +50 -0
  77. package/src/components/atoms/Icon/icons/HomeIcon.tsx +52 -0
  78. package/src/components/atoms/Icon/icons/MinusIcon.tsx +45 -0
  79. package/src/components/atoms/Icon/icons/PlaneIcon.tsx +50 -0
  80. package/src/components/atoms/Icon/icons/PlusIcon.tsx +45 -0
  81. package/src/components/atoms/Icon/icons/ShipIcon.tsx +50 -0
  82. package/src/components/atoms/Illustration/Illustration.tsx +28 -0
  83. package/src/components/atoms/Illustration/illustrations.ts +116 -0
  84. package/src/components/atoms/RatingStar/RatingStar.tsx +114 -0
  85. package/src/components/atoms/SegmentedButton/SegmentedButton.tsx +94 -0
  86. package/src/components/atoms/Slider/Slider.tsx +95 -0
  87. package/src/components/molecules/Calendar/CalendarInput.tsx +135 -0
  88. package/src/components/molecules/Calendar/DateTime.tsx +172 -0
  89. package/src/components/molecules/Calendar/TimePicker.tsx +174 -0
  90. package/src/components/molecules/LocationDropdown/LocationDropdown.tsx +234 -0
  91. package/src/components/molecules/LocationDropdown/index.ts +2 -0
  92. package/src/components/molecules/RatingTab/RatingTab.tsx +96 -0
  93. package/src/components/molecules/TabGroup/TabGroup.tsx +60 -0
  94. package/src/components/molecules/UserCard/UserCard.stories.tsx +2 -2
  95. package/src/components/organisms/CardContainer/CardContainer.tsx +66 -0
  96. package/src/components/organisms/DateTimePicker/DateTimePicker.tsx +110 -0
  97. package/src/components/organisms/Dialog/Dialog.tsx +352 -0
  98. package/src/components/organisms/PaxSelector/PaxSelector.tsx +979 -0
  99. package/src/components/organisms/RoundTrip/RoundTrip.tsx +335 -0
  100. package/src/components/organisms/RoundTrip/index.ts +2 -0
  101. package/src/components/organisms/SearchBarTransfer/SearchBarTransfer.tsx +388 -0
  102. package/src/components/organisms/SearchBarTransfer/index.ts +2 -0
  103. package/src/components/organisms/TransferLine/TransferLine.tsx +369 -0
  104. package/src/components/ui/button.tsx +60 -0
  105. package/src/components/ui/calendar.tsx +246 -0
  106. package/src/components/ui/popover.tsx +46 -0
  107. package/src/styles/components/calendar.css +86 -0
  108. package/src/styles/components/checkbox.css +130 -132
  109. package/src/styles/components/dropdown.css +40 -40
  110. package/src/styles/components/forms.css +51 -51
  111. package/src/styles/components/illustration.css +7 -0
  112. package/src/styles/components/molecule/calendarInput.css +157 -0
  113. package/src/styles/components/molecule/dateTime.css +14 -0
  114. package/src/styles/components/molecule/location-dropdown.css +204 -0
  115. package/src/styles/components/molecule/timePicker.css +79 -0
  116. package/src/styles/components/multiselect-dropdown.css +230 -231
  117. package/src/styles/components/organism/card-container.css +49 -0
  118. package/src/styles/components/organism/dialog.css +241 -0
  119. package/src/styles/components/organism/pax-selector.css +702 -0
  120. package/src/styles/components/organism/round-trip.css +55 -0
  121. package/src/styles/components/organism/search-bar-transfer.css +128 -0
  122. package/src/styles/components/organism/transfer-line.css +86 -0
  123. package/src/styles/components/rating-star.css +39 -0
  124. package/src/styles/components/rating-tab.css +83 -0
  125. package/src/styles/components/segmented-button.css +134 -0
  126. package/src/styles/components/selected-value.css +16 -16
  127. package/src/styles/components/slider.css +86 -0
  128. package/src/styles/components/typography.css +36 -36
  129. package/src/styles/tokens/tokens.css +1093 -1093
  130. package/dist/components/atoms/Typography/Heading/Heading.d.ts +0 -9
  131. package/dist/components/atoms/Typography/Heading/Heading.js +0 -25
  132. package/dist/components/atoms/Typography/Text/Text.d.ts +0 -10
  133. package/dist/components/atoms/Typography/Text/Text.js +0 -77
@@ -0,0 +1,979 @@
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import Icon from '../../atoms/Icon/Icon';
3
+ import { Text } from '../../atoms/Typography/Typography';
4
+
5
+ export type ClientType = 'Standard client' | 'VIP' | 'VVIP' | 'Honeymooners';
6
+
7
+ /**
8
+ * Individual passenger/pax data for a single room
9
+ */
10
+ export interface PaxData {
11
+ adults: number;
12
+ teens: number;
13
+ children: number;
14
+ infants: number;
15
+ /** Ages for teens (1-18 years) */
16
+ teenAges?: number[];
17
+ /** Ages for children (1-18 years) */
18
+ childAges?: number[];
19
+ /** Ages for infants (1-18 years) */
20
+ infantAges?: number[];
21
+ clientType: ClientType;
22
+ }
23
+
24
+ /**
25
+ * Room data for multiple room mode
26
+ */
27
+ export interface RoomData extends PaxData {
28
+ roomId: string;
29
+ }
30
+
31
+ export interface PaxSelectorProps {
32
+ /** Label for the selector */
33
+ label?: string;
34
+ /** Current pax data (single room mode) */
35
+ value?: PaxData;
36
+ /** Callback when pax data changes (single room mode) */
37
+ onChange?: (data: PaxData) => void;
38
+ /** Callback when "Add a room" is clicked */
39
+ onAddRoom?: () => void;
40
+ /** Callback when Done button is clicked */
41
+ onDone?: (data: PaxData | RoomData[]) => void;
42
+ /** Placeholder text */
43
+ placeholder?: string;
44
+ /** Additional CSS classes */
45
+ className?: string;
46
+ /** Maximum number of adults */
47
+ maxAdults?: number;
48
+ /** Maximum number of teens */
49
+ maxTeens?: number;
50
+ /** Maximum number of children */
51
+ maxChildren?: number;
52
+ /** Maximum number of infants */
53
+ maxInfants?: number;
54
+ /** Show add room link */
55
+ showAddRoom?: boolean;
56
+ /** Enable multiple room mode */
57
+ multipleRooms?: boolean;
58
+ /** Default rooms for multiple room mode */
59
+ defaultRooms?: RoomData[];
60
+ /** Callback when rooms change in multiple room mode */
61
+ onRoomsChange?: (rooms: RoomData[]) => void;
62
+ /** Callback when a room is removed */
63
+ onRemoveRoom?: (roomId: string) => void;
64
+ /** Default pax data for single room mode (will trigger onChange on mount) */
65
+ defaultPaxData?: PaxData;
66
+ }
67
+
68
+ const DEFAULT_PAX_DATA: PaxData = {
69
+ adults: 0,
70
+ teens: 0,
71
+ children: 0,
72
+ infants: 0,
73
+ teenAges: [],
74
+ childAges: [],
75
+ infantAges: [],
76
+ clientType: 'Standard client',
77
+ };
78
+
79
+ export const DEFAULT_PAX_DATA_WITH_ADULTS: PaxData = {
80
+ adults: 2,
81
+ teens: 0,
82
+ children: 0,
83
+ infants: 0,
84
+ teenAges: [],
85
+ childAges: [],
86
+ infantAges: [],
87
+ clientType: 'Standard client',
88
+ };
89
+
90
+ const CLIENT_TYPES: ClientType[] = ['Standard client', 'VIP', 'VVIP', 'Honeymooners'];
91
+
92
+ // Age range for all child categories (teens, children, infants)
93
+ const CHILD_CATEGORY_AGES = Array.from({ length: 18 }, (_, i) => i + 1); // 1-18 years
94
+
95
+ interface AgeSelectorProps {
96
+ label: string;
97
+ value: number | undefined;
98
+ onChange: (age: number) => void;
99
+ ageRange: number[];
100
+ required?: boolean;
101
+ }
102
+
103
+ const AgeSelector: React.FC<AgeSelectorProps> = ({ label, value, onChange, ageRange, required }) => {
104
+ const [isOpen, setIsOpen] = useState(false);
105
+ const [inputValue, setInputValue] = useState<string>(value !== undefined ? value.toString() : '');
106
+ const containerRef = useRef<HTMLDivElement>(null);
107
+ const inputRef = useRef<HTMLInputElement>(null);
108
+
109
+ // Sync input value when prop value changes
110
+ useEffect(() => {
111
+ setInputValue(value !== undefined ? value.toString() : '');
112
+ }, [value]);
113
+
114
+ useEffect(() => {
115
+ const handleClickOutside = (event: MouseEvent) => {
116
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
117
+ setIsOpen(false);
118
+ }
119
+ };
120
+
121
+ if (isOpen) {
122
+ document.addEventListener('mousedown', handleClickOutside);
123
+ }
124
+
125
+ return () => {
126
+ document.removeEventListener('mousedown', handleClickOutside);
127
+ };
128
+ }, [isOpen]);
129
+
130
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
131
+ const newValue = e.target.value;
132
+
133
+ // Only allow numeric input or empty string
134
+ if (newValue === '' || /^\d+$/.test(newValue)) {
135
+ setInputValue(newValue);
136
+
137
+ // Only update if it's a valid number within range
138
+ const numValue = parseInt(newValue, 10);
139
+ if (newValue === '') {
140
+ // Allow empty input - don't update onChange
141
+ return;
142
+ } else if (!isNaN(numValue) && ageRange.includes(numValue)) {
143
+ onChange(numValue);
144
+ }
145
+ }
146
+ };
147
+
148
+ const handleInputBlur = () => {
149
+ // Validate and set to valid value or clear if invalid
150
+ const numValue = parseInt(inputValue, 10);
151
+ if (inputValue === '') {
152
+ // Keep empty if user cleared it
153
+ return;
154
+ } else if (isNaN(numValue) || !ageRange.includes(numValue)) {
155
+ // Reset to current value if invalid
156
+ setInputValue(value !== undefined ? value.toString() : '');
157
+ } else {
158
+ // Ensure the input value matches the validated value
159
+ setInputValue(numValue.toString());
160
+ }
161
+ };
162
+
163
+ const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
164
+ if (e.key === 'Enter') {
165
+ e.preventDefault();
166
+ inputRef.current?.blur();
167
+ }
168
+ };
169
+
170
+ const handleSelect = (age: string) => {
171
+ const numAge = parseInt(age, 10);
172
+ onChange(numAge);
173
+ setIsOpen(false);
174
+ setInputValue(age);
175
+ };
176
+
177
+ const handleDropdownToggle = () => {
178
+ setIsOpen(!isOpen);
179
+ };
180
+
181
+ const ageOptions = ageRange.map(age => age.toString());
182
+ const displayValue = inputValue || undefined;
183
+
184
+ return (
185
+ <div className="pax-selector__age-selector" ref={containerRef}>
186
+ <Text size="sm" variant="regular" className="pax-selector__age-label">
187
+ {label}
188
+ {required && <span className="pax-selector__age-required"> *</span>}
189
+ </Text>
190
+ <div className="dropdown-container pax-selector__age-dropdown-container">
191
+ <div className={`dropdown-input ${isOpen ? 'dropdown-input--open pax-selector__age-dropdown-input--open' : ''} ${value !== undefined ? 'dropdown-input--selected' : 'dropdown-input--default'} pax-selector__age-dropdown-input`}>
192
+ <input
193
+ ref={inputRef}
194
+ type="text"
195
+ inputMode="numeric"
196
+ className="dropdown-input__text pax-selector__age-input-text"
197
+ value={inputValue}
198
+ onChange={handleInputChange}
199
+ onBlur={handleInputBlur}
200
+ onKeyDown={handleInputKeyDown}
201
+ onFocus={() => setIsOpen(false)}
202
+ placeholder="--"
203
+ aria-label={`${label} age`}
204
+ style={{
205
+ background: 'transparent',
206
+ border: 'none',
207
+ outline: 'none',
208
+ width: '100%',
209
+ cursor: 'text'
210
+ }}
211
+ />
212
+ <button
213
+ type="button"
214
+ className="pax-selector__age-dropdown-btn"
215
+ onClick={(e) => {
216
+ e.preventDefault();
217
+ e.stopPropagation();
218
+ handleDropdownToggle();
219
+ }}
220
+ aria-expanded={isOpen}
221
+ aria-haspopup="listbox"
222
+ aria-label="Open age dropdown"
223
+ style={{
224
+ background: 'transparent',
225
+ border: 'none',
226
+ cursor: 'pointer',
227
+ padding: 0,
228
+ display: 'flex',
229
+ alignItems: 'center'
230
+ }}
231
+ >
232
+ <Icon
233
+ name="chevron-down"
234
+ size="sm"
235
+ className="dropdown-input__icon dropdown-input__icon--chevron"
236
+ />
237
+ </button>
238
+ </div>
239
+ {isOpen && (
240
+ <div className="dropdown-menu" role="listbox">
241
+ {ageOptions.map((age) => (
242
+ <div
243
+ key={age}
244
+ className={`dropdown-option ${value?.toString() === age ? 'dropdown-option--selected' : ''}`}
245
+ onClick={() => handleSelect(age)}
246
+ role="option"
247
+ aria-selected={value?.toString() === age}
248
+ >
249
+ {age}
250
+ </div>
251
+ ))}
252
+ </div>
253
+ )}
254
+ </div>
255
+ </div>
256
+ );
257
+ };
258
+
259
+ interface StepperRowProps {
260
+ label: string;
261
+ value: number;
262
+ min?: number;
263
+ max?: number;
264
+ onChange: (value: number) => void;
265
+ }
266
+
267
+ const StepperRow: React.FC<StepperRowProps> = ({
268
+ label,
269
+ value,
270
+ min = 0,
271
+ max = 99,
272
+ onChange
273
+ }) => {
274
+ const handleDecrement = () => {
275
+ if (value > min) {
276
+ onChange(value - 1);
277
+ }
278
+ };
279
+
280
+ const handleIncrement = () => {
281
+ if (value < max) {
282
+ onChange(value + 1);
283
+ }
284
+ };
285
+
286
+ return (
287
+ <div className="pax-selector__stepper">
288
+ <Text size="base" variant="bold" className="pax-selector__stepper-label">
289
+ {label}
290
+ </Text>
291
+ <div className="pax-selector__stepper-controls">
292
+ <button
293
+ type="button"
294
+ className="pax-selector__stepper-btn"
295
+ onClick={handleDecrement}
296
+ disabled={value <= min}
297
+ aria-label={`Decrease ${label}`}
298
+ >
299
+ <Icon name="minus" size="sm" />
300
+ </button>
301
+ <span className="pax-selector__stepper-count">{value}</span>
302
+ <button
303
+ type="button"
304
+ className="pax-selector__stepper-btn"
305
+ onClick={handleIncrement}
306
+ disabled={value >= max}
307
+ aria-label={`Increase ${label}`}
308
+ >
309
+ <Icon name="plus" size="sm" />
310
+ </button>
311
+ </div>
312
+ </div>
313
+ );
314
+ };
315
+
316
+ interface ClientTypeSelectorProps {
317
+ value: ClientType;
318
+ onChange: (value: ClientType) => void;
319
+ }
320
+
321
+ const ClientTypeSelector: React.FC<ClientTypeSelectorProps> = ({ value, onChange }) => {
322
+ const [isOpen, setIsOpen] = useState(false);
323
+ const containerRef = useRef<HTMLDivElement>(null);
324
+
325
+ useEffect(() => {
326
+ const handleClickOutside = (event: MouseEvent) => {
327
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
328
+ setIsOpen(false);
329
+ }
330
+ };
331
+
332
+ if (isOpen) {
333
+ document.addEventListener('mousedown', handleClickOutside);
334
+ }
335
+
336
+ return () => {
337
+ document.removeEventListener('mousedown', handleClickOutside);
338
+ };
339
+ }, [isOpen]);
340
+
341
+ const handleSelect = (type: ClientType) => {
342
+ onChange(type);
343
+ setIsOpen(false);
344
+ };
345
+
346
+ return (
347
+ <div className="pax-selector__client-type">
348
+ <Text size="sm" variant="regular" className="pax-selector__client-type-label">
349
+ Client type
350
+ </Text>
351
+ <div className="pax-selector__client-type-select" ref={containerRef}>
352
+ <button
353
+ type="button"
354
+ className="pax-selector__client-type-trigger"
355
+ onClick={() => setIsOpen(!isOpen)}
356
+ aria-expanded={isOpen}
357
+ aria-haspopup="listbox"
358
+ >
359
+ <div className="pax-selector__client-type-content">
360
+ <Icon name="user-icon" size="sm" className="pax-selector__client-type-icon" />
361
+ <span className="pax-selector__client-type-text">{value}</span>
362
+ </div>
363
+ <Icon
364
+ name="chevron-down"
365
+ size="sm"
366
+ className={`pax-selector__client-type-chevron ${isOpen ? 'pax-selector__client-type-chevron--open' : ''}`}
367
+ />
368
+ </button>
369
+
370
+ {isOpen && (
371
+ <div className="pax-selector__client-type-dropdown" role="listbox">
372
+ {CLIENT_TYPES.map((type) => (
373
+ <button
374
+ key={type}
375
+ type="button"
376
+ className={`pax-selector__client-type-option ${value === type ? 'pax-selector__client-type-option--selected' : ''}`}
377
+ onClick={() => handleSelect(type)}
378
+ role="option"
379
+ aria-selected={value === type}
380
+ >
381
+ <Icon name="user-icon" size="sm" />
382
+ {type}
383
+ </button>
384
+ ))}
385
+ </div>
386
+ )}
387
+ </div>
388
+ </div>
389
+ );
390
+ };
391
+
392
+ interface RoomEditorProps {
393
+ room: RoomData;
394
+ roomNumber: number;
395
+ showRemove: boolean;
396
+ maxAdults: number;
397
+ maxTeens: number;
398
+ maxChildren: number;
399
+ maxInfants: number;
400
+ onChange: (room: RoomData) => void;
401
+ onRemove: () => void;
402
+ }
403
+
404
+ const RoomEditor: React.FC<RoomEditorProps> = ({
405
+ room,
406
+ roomNumber,
407
+ showRemove,
408
+ maxAdults,
409
+ maxTeens,
410
+ maxChildren,
411
+ maxInfants,
412
+ onChange,
413
+ onRemove,
414
+ }) => {
415
+ const handleFieldChange = (field: keyof PaxData, value: number | ClientType | number[]) => {
416
+ onChange({ ...room, [field]: value });
417
+ };
418
+
419
+ const handleAgeChange = (category: 'teenAges' | 'childAges' | 'infantAges', index: number, age: number) => {
420
+ const ages = [...(room[category] || [])];
421
+ ages[index] = age;
422
+ handleFieldChange(category, ages);
423
+ };
424
+
425
+ // Generate age arrays based on counts
426
+ useEffect(() => {
427
+ const teenAges = room.teenAges || [];
428
+ const childAges = room.childAges || [];
429
+ const infantAges = room.infantAges || [];
430
+
431
+ // Adjust teen ages array
432
+ if (teenAges.length < room.teens) {
433
+ handleFieldChange('teenAges', [...teenAges, ...Array(room.teens - teenAges.length).fill(undefined)]);
434
+ } else if (teenAges.length > room.teens) {
435
+ handleFieldChange('teenAges', teenAges.slice(0, room.teens));
436
+ }
437
+
438
+ // Adjust child ages array
439
+ if (childAges.length < room.children) {
440
+ handleFieldChange('childAges', [...childAges, ...Array(room.children - childAges.length).fill(undefined)]);
441
+ } else if (childAges.length > room.children) {
442
+ handleFieldChange('childAges', childAges.slice(0, room.children));
443
+ }
444
+
445
+ // Adjust infant ages array
446
+ if (infantAges.length < room.infants) {
447
+ handleFieldChange('infantAges', [...infantAges, ...Array(room.infants - infantAges.length).fill(undefined)]);
448
+ } else if (infantAges.length > room.infants) {
449
+ handleFieldChange('infantAges', infantAges.slice(0, room.infants));
450
+ }
451
+ }, [room.teens, room.children, room.infants]);
452
+
453
+ // Chunk ages into groups of 2 for layout
454
+ const chunkAges = (ages: (number | undefined)[], category: string) => {
455
+ const chunks: (number | undefined)[][] = [];
456
+ for (let i = 0; i < ages.length; i += 2) {
457
+ chunks.push(ages.slice(i, i + 2));
458
+ }
459
+ return chunks;
460
+ };
461
+
462
+ const teenAgeChunks = chunkAges(room.teenAges || [], 'Teen');
463
+ const childAgeChunks = chunkAges(room.childAges || [], 'Child');
464
+ const infantAgeChunks = chunkAges(room.infantAges || [], 'Infant');
465
+
466
+ return (
467
+ <div className="pax-selector__room-container">
468
+ <div className="pax-selector__room-header">
469
+ <div className="pax-selector__room-title">
470
+ <Text size="lg" variant="bold" className="pax-selector__room-name">
471
+ Room {roomNumber}
472
+ </Text>
473
+ <Icon name="home" size="md" className="pax-selector__room-icon" />
474
+ </div>
475
+ {showRemove && (
476
+ <button
477
+ type="button"
478
+ className="pax-selector__room-remove"
479
+ onClick={onRemove}
480
+ aria-label={`Remove room ${roomNumber}`}
481
+ >
482
+ <Icon name="close" size="sm" className="pax-selector__room-remove-icon" />
483
+ <span className="pax-selector__room-remove-text">Remove</span>
484
+ </button>
485
+ )}
486
+ </div>
487
+
488
+ <div className="pax-selector__room-content">
489
+ <div className="pax-selector__steppers">
490
+ <StepperRow
491
+ label="Adult"
492
+ value={room.adults}
493
+ max={maxAdults}
494
+ onChange={(val) => handleFieldChange('adults', val)}
495
+ />
496
+ <StepperRow
497
+ label="Teen"
498
+ value={room.teens}
499
+ max={maxTeens}
500
+ onChange={(val) => handleFieldChange('teens', val)}
501
+ />
502
+ <StepperRow
503
+ label="Child"
504
+ value={room.children}
505
+ max={maxChildren}
506
+ onChange={(val) => handleFieldChange('children', val)}
507
+ />
508
+ <StepperRow
509
+ label="Infant"
510
+ value={room.infants}
511
+ max={maxInfants}
512
+ onChange={(val) => handleFieldChange('infants', val)}
513
+ />
514
+ </div>
515
+
516
+ {/* Age specification section */}
517
+ {((room.teens > 0) || (room.children > 0) || (room.infants > 0)) && (
518
+ <div className="pax-selector__age-section">
519
+ <Text size="base" variant="bold" className="pax-selector__age-section-title">
520
+ Please specify the age :
521
+ </Text>
522
+
523
+ <div className="pax-selector__age-groups">
524
+ {/* Teen ages */}
525
+ {room.teens > 0 && teenAgeChunks.map((chunk, chunkIndex) => (
526
+ <div key={`teen-chunk-${chunkIndex}`} className="pax-selector__age-row">
527
+ {chunk.map((age, ageIndex) => {
528
+ const actualIndex = chunkIndex * 2 + ageIndex;
529
+ return (
530
+ <AgeSelector
531
+ key={`teen-${actualIndex}`}
532
+ label="Teen"
533
+ value={age}
534
+ onChange={(selectedAge) => handleAgeChange('teenAges', actualIndex, selectedAge)}
535
+ ageRange={CHILD_CATEGORY_AGES}
536
+ required
537
+ />
538
+ );
539
+ })}
540
+ </div>
541
+ ))}
542
+
543
+ {/* Child ages */}
544
+ {room.children > 0 && childAgeChunks.map((chunk, chunkIndex) => (
545
+ <div key={`child-chunk-${chunkIndex}`} className="pax-selector__age-row">
546
+ {chunk.map((age, ageIndex) => {
547
+ const actualIndex = chunkIndex * 2 + ageIndex;
548
+ return (
549
+ <AgeSelector
550
+ key={`child-${actualIndex}`}
551
+ label="Child"
552
+ value={age}
553
+ onChange={(selectedAge) => handleAgeChange('childAges', actualIndex, selectedAge)}
554
+ ageRange={CHILD_CATEGORY_AGES}
555
+ required
556
+ />
557
+ );
558
+ })}
559
+ </div>
560
+ ))}
561
+
562
+ {/* Infant ages */}
563
+ {room.infants > 0 && infantAgeChunks.map((chunk, chunkIndex) => (
564
+ <div key={`infant-chunk-${chunkIndex}`} className="pax-selector__age-row">
565
+ {chunk.map((age, ageIndex) => {
566
+ const actualIndex = chunkIndex * 2 + ageIndex;
567
+ return (
568
+ <AgeSelector
569
+ key={`infant-${actualIndex}`}
570
+ label="Infant"
571
+ value={age}
572
+ onChange={(selectedAge) => handleAgeChange('infantAges', actualIndex, selectedAge)}
573
+ ageRange={CHILD_CATEGORY_AGES}
574
+ required
575
+ />
576
+ );
577
+ })}
578
+ </div>
579
+ ))}
580
+ </div>
581
+ </div>
582
+ )}
583
+
584
+ <ClientTypeSelector
585
+ value={room.clientType}
586
+ onChange={(val) => handleFieldChange('clientType', val)}
587
+ />
588
+ </div>
589
+ </div>
590
+ );
591
+ };
592
+
593
+ const PaxSelector: React.FC<PaxSelectorProps> = ({
594
+ label = 'Number of pax',
595
+ value,
596
+ onChange,
597
+ onAddRoom,
598
+ onDone,
599
+ placeholder = 'Select pax',
600
+ className = '',
601
+ maxAdults = 10,
602
+ maxTeens = 10,
603
+ maxChildren = 10,
604
+ maxInfants = 10,
605
+ showAddRoom = true,
606
+ multipleRooms = false,
607
+ defaultRooms,
608
+ onRoomsChange,
609
+ onRemoveRoom,
610
+ defaultPaxData = DEFAULT_PAX_DATA_WITH_ADULTS,
611
+ }) => {
612
+ const [isOpen, setIsOpen] = useState(false);
613
+ const [internalData, setInternalData] = useState<PaxData>(value || defaultPaxData || DEFAULT_PAX_DATA);
614
+ const [rooms, setRooms] = useState<RoomData[]>(
615
+ defaultRooms || [{ ...DEFAULT_PAX_DATA, roomId: '1' }]
616
+ );
617
+ const containerRef = useRef<HTMLDivElement>(null);
618
+ const hasInitialized = useRef(false);
619
+
620
+ // Sync internal data with external value prop
621
+ useEffect(() => {
622
+ if (value && !multipleRooms) {
623
+ setInternalData(value);
624
+ }
625
+ }, [value, multipleRooms]);
626
+
627
+ // Initialize with default pax data and trigger onChange on mount
628
+ useEffect(() => {
629
+ if (!hasInitialized.current && !value && defaultPaxData && !multipleRooms && onChange) {
630
+ hasInitialized.current = true;
631
+ onChange(defaultPaxData);
632
+ }
633
+ }, [defaultPaxData, value, multipleRooms, onChange]);
634
+
635
+ // Manage age arrays in single mode when counts change
636
+ useEffect(() => {
637
+ if (!multipleRooms) {
638
+ const teenAges = internalData.teenAges || [];
639
+ const childAges = internalData.childAges || [];
640
+ const infantAges = internalData.infantAges || [];
641
+
642
+ let needsUpdate = false;
643
+ const updatedData = { ...internalData };
644
+
645
+ // Adjust teen ages array
646
+ if (teenAges.length < internalData.teens) {
647
+ updatedData.teenAges = [...teenAges, ...Array(internalData.teens - teenAges.length).fill(undefined)];
648
+ needsUpdate = true;
649
+ } else if (teenAges.length > internalData.teens) {
650
+ updatedData.teenAges = teenAges.slice(0, internalData.teens);
651
+ needsUpdate = true;
652
+ }
653
+
654
+ // Adjust child ages array
655
+ if (childAges.length < internalData.children) {
656
+ updatedData.childAges = [...childAges, ...Array(internalData.children - childAges.length).fill(undefined)];
657
+ needsUpdate = true;
658
+ } else if (childAges.length > internalData.children) {
659
+ updatedData.childAges = childAges.slice(0, internalData.children);
660
+ needsUpdate = true;
661
+ }
662
+
663
+ // Adjust infant ages array
664
+ if (infantAges.length < internalData.infants) {
665
+ updatedData.infantAges = [...infantAges, ...Array(internalData.infants - infantAges.length).fill(undefined)];
666
+ needsUpdate = true;
667
+ } else if (infantAges.length > internalData.infants) {
668
+ updatedData.infantAges = infantAges.slice(0, internalData.infants);
669
+ needsUpdate = true;
670
+ }
671
+
672
+ if (needsUpdate) {
673
+ setInternalData(updatedData);
674
+ onChange?.(updatedData);
675
+ }
676
+ }
677
+ // eslint-disable-next-line react-hooks/exhaustive-deps
678
+ }, [internalData.teens, internalData.children, internalData.infants, multipleRooms]);
679
+
680
+ // Handle clicks outside the dropdown
681
+ useEffect(() => {
682
+ const handleClickOutside = (event: MouseEvent) => {
683
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
684
+ setIsOpen(false);
685
+ }
686
+ };
687
+
688
+ if (isOpen) {
689
+ document.addEventListener('mousedown', handleClickOutside);
690
+ }
691
+
692
+ return () => {
693
+ document.removeEventListener('mousedown', handleClickOutside);
694
+ };
695
+ }, [isOpen]);
696
+
697
+ const handleDataChange = (field: keyof PaxData, newValue: number | ClientType | number[]) => {
698
+ const newData = { ...internalData, [field]: newValue };
699
+ setInternalData(newData);
700
+ onChange?.(newData);
701
+ };
702
+
703
+ const handleClear = () => {
704
+ if (multipleRooms) {
705
+ const clearedRooms = rooms.map(room => ({ ...DEFAULT_PAX_DATA, roomId: room.roomId }));
706
+ setRooms(clearedRooms);
707
+ onRoomsChange?.(clearedRooms);
708
+ } else {
709
+ const clearedData = { ...DEFAULT_PAX_DATA };
710
+ setInternalData(clearedData);
711
+ onChange?.(clearedData);
712
+ }
713
+ };
714
+
715
+ const handleDone = () => {
716
+ setIsOpen(false);
717
+ onDone?.(multipleRooms ? rooms : internalData);
718
+ };
719
+
720
+ const handleAddRoom = () => {
721
+ const newRoom: RoomData = {
722
+ ...DEFAULT_PAX_DATA,
723
+ roomId: `${rooms.length + 1}`
724
+ };
725
+ const updatedRooms = [...rooms, newRoom];
726
+ setRooms(updatedRooms);
727
+ onRoomsChange?.(updatedRooms);
728
+ onAddRoom?.();
729
+ };
730
+
731
+ const handleRemoveRoom = (roomId: string) => {
732
+ const updatedRooms = rooms.filter(room => room.roomId !== roomId);
733
+ setRooms(updatedRooms);
734
+ onRoomsChange?.(updatedRooms);
735
+ onRemoveRoom?.(roomId);
736
+ };
737
+
738
+ const handleRoomChange = (roomId: string, updatedRoom: RoomData) => {
739
+ const updatedRooms = rooms.map(room =>
740
+ room.roomId === roomId ? updatedRoom : room
741
+ );
742
+ setRooms(updatedRooms);
743
+ onRoomsChange?.(updatedRooms);
744
+ };
745
+
746
+ const getTotalPax = () => {
747
+ if (multipleRooms) {
748
+ return rooms.reduce((total, room) =>
749
+ total + room.adults + room.teens + room.children + room.infants, 0
750
+ );
751
+ }
752
+ const { adults, teens, children, infants } = internalData;
753
+ return adults + teens + children + infants;
754
+ };
755
+
756
+ const getDisplayText = () => {
757
+ const total = getTotalPax();
758
+ if (total === 0) {
759
+ return placeholder;
760
+ }
761
+ return `${total} pax`;
762
+ };
763
+
764
+ const hasPax = getTotalPax() > 0;
765
+
766
+ return (
767
+ <div className={`pax-selector ${className}`} ref={containerRef}>
768
+ <button
769
+ type="button"
770
+ className="pax-selector__trigger"
771
+ onClick={() => setIsOpen(!isOpen)}
772
+ aria-expanded={isOpen}
773
+ aria-haspopup="true"
774
+ >
775
+ <Text size="sm" variant="regular" className="pax-selector__label">
776
+ {label}
777
+ </Text>
778
+ <div className={`pax-selector__input ${isOpen ? 'pax-selector__input--active' : ''}`}>
779
+ <div className="pax-selector__input-content">
780
+ <Icon name="user-icon" size="sm" className="pax-selector__input-icon" />
781
+ <span className={`pax-selector__input-text ${!hasPax ? 'pax-selector__input-placeholder' : ''}`}>
782
+ {getDisplayText()}
783
+ </span>
784
+ </div>
785
+ <Icon
786
+ name="chevron-down"
787
+ size="sm"
788
+ className={`pax-selector__chevron ${isOpen ? 'pax-selector__chevron--open' : ''}`}
789
+ />
790
+ </div>
791
+ </button>
792
+
793
+ {isOpen && (
794
+ <div className="pax-selector__dropdown">
795
+ {/* Multiple Rooms Mode */}
796
+ {multipleRooms ? (
797
+ <>
798
+ {showAddRoom && (
799
+ <button
800
+ type="button"
801
+ className="pax-selector__add-room"
802
+ onClick={handleAddRoom}
803
+ >
804
+ <Icon name="home" size="sm" className="pax-selector__add-room-icon" />
805
+ Add a room
806
+ <Icon name="plus" size="sm" className="pax-selector__add-room-icon" />
807
+ </button>
808
+ )}
809
+
810
+ <div className="pax-selector__rooms">
811
+ {rooms.map((room, index) => (
812
+ <RoomEditor
813
+ key={room.roomId}
814
+ room={room}
815
+ roomNumber={index + 1}
816
+ showRemove={rooms.length > 1}
817
+ maxAdults={maxAdults}
818
+ maxTeens={maxTeens}
819
+ maxChildren={maxChildren}
820
+ maxInfants={maxInfants}
821
+ onChange={(updatedRoom) => handleRoomChange(room.roomId, updatedRoom)}
822
+ onRemove={() => handleRemoveRoom(room.roomId)}
823
+ />
824
+ ))}
825
+ </div>
826
+ </>
827
+ ) : (
828
+ /* Single Room Mode */
829
+ <>
830
+ <div className="pax-selector__steppers">
831
+ <StepperRow
832
+ label="Adult"
833
+ value={internalData.adults}
834
+ max={maxAdults}
835
+ onChange={(val) => handleDataChange('adults', val)}
836
+ />
837
+ <StepperRow
838
+ label="Teen"
839
+ value={internalData.teens}
840
+ max={maxTeens}
841
+ onChange={(val) => handleDataChange('teens', val)}
842
+ />
843
+ <StepperRow
844
+ label="Child"
845
+ value={internalData.children}
846
+ max={maxChildren}
847
+ onChange={(val) => handleDataChange('children', val)}
848
+ />
849
+ <StepperRow
850
+ label="Infant"
851
+ value={internalData.infants}
852
+ max={maxInfants}
853
+ onChange={(val) => handleDataChange('infants', val)}
854
+ />
855
+ </div>
856
+
857
+ {/* Age specification for single room */}
858
+ {((internalData.teens > 0) || (internalData.children > 0) || (internalData.infants > 0)) && (
859
+ <div className="pax-selector__age-section">
860
+ <Text size="base" variant="bold" className="pax-selector__age-section-title">
861
+ Please specify the age :
862
+ </Text>
863
+
864
+ <div className="pax-selector__age-groups">
865
+ {/* Helper function to chunk ages into rows of 2 */}
866
+ {(() => {
867
+ const chunkAges = (ages: (number | undefined)[], category: string) => {
868
+ const chunks: (number | undefined)[][] = [];
869
+ for (let i = 0; i < ages.length; i += 2) {
870
+ chunks.push(ages.slice(i, i + 2));
871
+ }
872
+ return chunks;
873
+ };
874
+
875
+ const teenAgeChunks = chunkAges(internalData.teenAges || [], 'Teen');
876
+ const childAgeChunks = chunkAges(internalData.childAges || [], 'Child');
877
+ const infantAgeChunks = chunkAges(internalData.infantAges || [], 'Infant');
878
+
879
+ const handleAgeChange = (category: 'teenAges' | 'childAges' | 'infantAges', index: number, age: number) => {
880
+ const ages = [...(internalData[category] || [])];
881
+ ages[index] = age;
882
+ handleDataChange(category, ages);
883
+ };
884
+
885
+ return (
886
+ <>
887
+ {/* Teen ages */}
888
+ {internalData.teens > 0 && teenAgeChunks.map((chunk, chunkIndex) => (
889
+ <div key={`teen-chunk-${chunkIndex}`} className="pax-selector__age-row">
890
+ {chunk.map((age, ageIndex) => {
891
+ const actualIndex = chunkIndex * 2 + ageIndex;
892
+ return (
893
+ <AgeSelector
894
+ key={`teen-${actualIndex}`}
895
+ label="Teen"
896
+ value={age}
897
+ onChange={(selectedAge) => handleAgeChange('teenAges', actualIndex, selectedAge)}
898
+ ageRange={CHILD_CATEGORY_AGES}
899
+ required
900
+ />
901
+ );
902
+ })}
903
+ </div>
904
+ ))}
905
+
906
+ {/* Child ages */}
907
+ {internalData.children > 0 && childAgeChunks.map((chunk, chunkIndex) => (
908
+ <div key={`child-chunk-${chunkIndex}`} className="pax-selector__age-row">
909
+ {chunk.map((age, ageIndex) => {
910
+ const actualIndex = chunkIndex * 2 + ageIndex;
911
+ return (
912
+ <AgeSelector
913
+ key={`child-${actualIndex}`}
914
+ label="Child"
915
+ value={age}
916
+ onChange={(selectedAge) => handleAgeChange('childAges', actualIndex, selectedAge)}
917
+ ageRange={CHILD_CATEGORY_AGES}
918
+ required
919
+ />
920
+ );
921
+ })}
922
+ </div>
923
+ ))}
924
+
925
+ {/* Infant ages */}
926
+ {internalData.infants > 0 && infantAgeChunks.map((chunk, chunkIndex) => (
927
+ <div key={`infant-chunk-${chunkIndex}`} className="pax-selector__age-row">
928
+ {chunk.map((age, ageIndex) => {
929
+ const actualIndex = chunkIndex * 2 + ageIndex;
930
+ return (
931
+ <AgeSelector
932
+ key={`infant-${actualIndex}`}
933
+ label="Infant"
934
+ value={age}
935
+ onChange={(selectedAge) => handleAgeChange('infantAges', actualIndex, selectedAge)}
936
+ ageRange={CHILD_CATEGORY_AGES}
937
+ required
938
+ />
939
+ );
940
+ })}
941
+ </div>
942
+ ))}
943
+ </>
944
+ );
945
+ })()}
946
+ </div>
947
+ </div>
948
+ )}
949
+
950
+ <ClientTypeSelector
951
+ value={internalData.clientType}
952
+ onChange={(val) => handleDataChange('clientType', val)}
953
+ />
954
+ </>
955
+ )}
956
+
957
+ <div className="pax-selector__actions">
958
+ <button
959
+ type="button"
960
+ className="pax-selector__clear-btn"
961
+ onClick={handleClear}
962
+ >
963
+ Clear Pax
964
+ </button>
965
+ <button
966
+ type="button"
967
+ className="pax-selector__done-btn"
968
+ onClick={handleDone}
969
+ >
970
+ Done
971
+ </button>
972
+ </div>
973
+ </div>
974
+ )}
975
+ </div>
976
+ );
977
+ };
978
+
979
+ export default PaxSelector;