@wix/headless-bookings 0.0.54 → 0.0.56

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.
@@ -2,11 +2,16 @@
2
2
  * Query Services API
3
3
  * Fetches single or multiple services with pagination and sorting
4
4
  */
5
- import { queryServices as queryServicesApi, } from '@wix/auto_sdk_bookings_services';
5
+ import { queryServices as queryServicesApi, LocationType, } from '@wix/auto_sdk_bookings_services';
6
6
  /**
7
7
  * Booking app ID constant
8
8
  */
9
9
  const BOOKING_APP_ID = '13d21c63-b5ec-5912-8397-c3a5ddb27a97';
10
+ /**
11
+ * Synthetic location ID constants for non-business locations
12
+ */
13
+ const SYNTHETIC_CUSTOM_ID = '__synthetic_custom__';
14
+ const SYNTHETIC_CUSTOMER_ID = '__synthetic_customer__';
10
15
  /**
11
16
  * Fetches a service by its ID
12
17
  * @param id - The service ID
@@ -80,7 +85,28 @@ const buildServicesQueryRequest = ({ appId, filters, paging, sort, }) => {
80
85
  }
81
86
  // Add locationIds filter if provided
82
87
  if (filters?.locationIds && filters.locationIds.length > 0) {
83
- filter['locations.business.id'] = { $hasSome: filters.locationIds };
88
+ // Separate synthetic IDs from business location IDs
89
+ const syntheticTypes = [];
90
+ const businessLocationIds = [];
91
+ for (const id of filters.locationIds) {
92
+ if (id === SYNTHETIC_CUSTOM_ID) {
93
+ syntheticTypes.push(LocationType.CUSTOM);
94
+ }
95
+ else if (id === SYNTHETIC_CUSTOMER_ID) {
96
+ syntheticTypes.push(LocationType.CUSTOMER);
97
+ }
98
+ else {
99
+ businessLocationIds.push(id);
100
+ }
101
+ }
102
+ // Filter by location type for synthetic locations
103
+ if (syntheticTypes.length > 0) {
104
+ filter['locations.type'] = { $hasSome: syntheticTypes };
105
+ }
106
+ // Filter by business ID for business locations
107
+ if (businessLocationIds.length > 0) {
108
+ filter['locations.business.id'] = { $hasSome: businessLocationIds };
109
+ }
84
110
  }
85
111
  // staffMemberIds to be added later
86
112
  const query = {
@@ -25,8 +25,8 @@ export interface SyntheticLocation {
25
25
  locationType: LocationType.CUSTOMER | LocationType.CUSTOM;
26
26
  /** Marker to identify synthetic locations */
27
27
  synthetic: true;
28
- /** Optional ID for tracking */
29
- id?: string;
28
+ /** Optional ID for tracking (matches ServiceLocation._id) */
29
+ _id?: string;
30
30
  }
31
31
  /**
32
32
  * Union type - location prop accepts Service or Synthetic location
@@ -110,7 +110,7 @@ function computeLocationRenderProps(rawLocation) {
110
110
  locationType,
111
111
  address: null,
112
112
  name: undefined,
113
- locationId: syntheticLoc.id,
113
+ locationId: syntheticLoc._id,
114
114
  isCustomerLocation: locationType === LocationType.CUSTOMER,
115
115
  isCustomLocation: locationType === LocationType.CUSTOM,
116
116
  isSynthetic: true,
@@ -92,7 +92,7 @@ export const LocationRepeater = React.forwardRef((props, ref) => {
92
92
  const location = displayLocation.location ?? {
93
93
  locationType: displayLocation.type,
94
94
  synthetic: true,
95
- id: displayLocation.id,
95
+ _id: displayLocation.id,
96
96
  };
97
97
  return (_jsx(Location.Root, { location: location, "data-testid": TestIds.locationListLocation, "data-location-id": displayLocation.id, "data-location-type": displayLocation.type, "data-item-id": displayLocation.id, children: itemChildren }, displayLocation.id));
98
98
  };
@@ -1133,6 +1133,41 @@ export interface DefaultCapacityProps {
1133
1133
  * ```
1134
1134
  */
1135
1135
  export declare const DefaultCapacity: React.ForwardRefExoticComponent<DefaultCapacityProps & React.RefAttributes<HTMLElement>>;
1136
+ /**
1137
+ * Props for Service AvailableOnline component
1138
+ */
1139
+ export interface AvailableOnlineProps {
1140
+ asChild?: boolean;
1141
+ children?: AsChildChildren<{
1142
+ availableOnline: boolean;
1143
+ }>;
1144
+ className?: string;
1145
+ label?: string;
1146
+ }
1147
+ /**
1148
+ * Displays whether the service is available online (conferencing enabled).
1149
+ * Headless component - contains zero formatting logic.
1150
+ *
1151
+ * Maps to: service.conferencing.enabled
1152
+ *
1153
+ * @component
1154
+ * @example
1155
+ * ```tsx
1156
+ * // Default usage - displays label only when available online
1157
+ * <Service.AvailableOnline label="Available Online" className="text-sm text-secondary-foreground" />
1158
+ * ```
1159
+ *
1160
+ * @example
1161
+ * ```tsx
1162
+ * // asChild with custom component
1163
+ * <Service.AvailableOnline asChild>
1164
+ * {({ availableOnline }) => availableOnline ? (
1165
+ * <span className="badge bg-green-500">Online</span>
1166
+ * ) : null}
1167
+ * </Service.AvailableOnline>
1168
+ * ```
1169
+ */
1170
+ export declare const AvailableOnline: React.ForwardRefExoticComponent<AvailableOnlineProps & React.RefAttributes<HTMLElement>>;
1136
1171
  export declare const Locations: typeof LocationsBase & {
1137
1172
  List: typeof LocationsList;
1138
1173
  LocationRepeater: typeof LocationRepeater;
@@ -49,6 +49,8 @@ var TestIds;
49
49
  TestIds["serviceScheduleFirstSessionStart"] = "service-schedule-first-session-start";
50
50
  TestIds["serviceScheduleLastSessionEnd"] = "service-schedule-last-session-end";
51
51
  TestIds["serviceScheduleSessionDuration"] = "service-schedule-session-duration";
52
+ // Conferencing component TestIds
53
+ TestIds["serviceAvailableOnline"] = "service-available-online";
52
54
  })(TestIds || (TestIds = {}));
53
55
  /**
54
56
  * Root component that provides service context to the entire app.
@@ -1083,6 +1085,37 @@ export const DefaultCapacity = React.forwardRef((props, ref) => {
1083
1085
  } }));
1084
1086
  });
1085
1087
  DefaultCapacity.displayName = 'Service.DefaultCapacity';
1088
+ /**
1089
+ * Displays whether the service is available online (conferencing enabled).
1090
+ * Headless component - contains zero formatting logic.
1091
+ *
1092
+ * Maps to: service.conferencing.enabled
1093
+ *
1094
+ * @component
1095
+ * @example
1096
+ * ```tsx
1097
+ * // Default usage - displays label only when available online
1098
+ * <Service.AvailableOnline label="Available Online" className="text-sm text-secondary-foreground" />
1099
+ * ```
1100
+ *
1101
+ * @example
1102
+ * ```tsx
1103
+ * // asChild with custom component
1104
+ * <Service.AvailableOnline asChild>
1105
+ * {({ availableOnline }) => availableOnline ? (
1106
+ * <span className="badge bg-green-500">Online</span>
1107
+ * ) : null}
1108
+ * </Service.AvailableOnline>
1109
+ * ```
1110
+ */
1111
+ export const AvailableOnline = React.forwardRef((props, ref) => {
1112
+ const { asChild, children, className, label } = props;
1113
+ return (_jsx(CoreService.Service, { children: ({ service }) => {
1114
+ const availableOnline = service.conferencing?.enabled ?? false;
1115
+ return (_jsx(AsChildSlot, { ref: ref, asChild: asChild, className: className, "data-testid": TestIds.serviceAvailableOnline, customElement: children, customElementProps: { availableOnline }, children: availableOnline && label ? _jsx("span", { children: label }) : null }));
1116
+ } }));
1117
+ });
1118
+ AvailableOnline.displayName = 'Service.AvailableOnline';
1086
1119
  // Create Locations with nested components
1087
1120
  export const Locations = Object.assign(LocationsBase, {
1088
1121
  List: LocationsList,
@@ -8,6 +8,7 @@
8
8
  import React from 'react';
9
9
  import { AsChildChildren } from '@wix/headless-utils/react';
10
10
  import type { TimeSlot as TimeSlotType } from '@wix/auto_sdk_bookings_availability-time-slots';
11
+ import * as CoreTimeSlot from '../core/time-slot-list/TimeSlot.js';
11
12
  export type { StaffMemberData } from '../core/time-slot-list/TimeSlot.js';
12
13
  /**
13
14
  * Props for TimeSlot.Root component
@@ -131,6 +132,7 @@ export interface SelectProps {
131
132
  }) => React.ReactNode);
132
133
  className?: string;
133
134
  label?: string;
135
+ onClicked?: (timeSlot: TimeSlotType) => void;
134
136
  }
135
137
  /**
136
138
  * Button to select this time slot.
@@ -150,6 +152,13 @@ export interface SelectProps {
150
152
  * <button className="btn-primary">Book this slot</button>
151
153
  * </TimeSlot.Actions.Select>
152
154
  *
155
+ * // With onClicked callback for navigation
156
+ * <TimeSlot.Actions.Select
157
+ * onClicked={(timeSlot) => {
158
+ * router.push(`/booking/confirm/${timeSlot.localStartDate}`);
159
+ * }}
160
+ * />
161
+ *
153
162
  * // Using render prop pattern
154
163
  * <TimeSlot.Actions.Select>
155
164
  * {({ isSelected, bookable, onClick }) => (
@@ -171,6 +180,7 @@ export interface ClearStaffSelectionProps {
171
180
  }) => React.ReactNode);
172
181
  className?: string;
173
182
  label?: string;
183
+ onClicked?: (timeSlot: TimeSlotType) => void;
174
184
  }
175
185
  /**
176
186
  * Button to clear staff selection while keeping the slot selected.
@@ -191,6 +201,13 @@ export interface ClearStaffSelectionProps {
191
201
  * <button className="btn-secondary">Clear staff</button>
192
202
  * </TimeSlot.Actions.ClearStaffSelection>
193
203
  *
204
+ * // With onClicked callback
205
+ * <TimeSlot.Actions.ClearStaffSelection
206
+ * onClicked={(timeSlot) => {
207
+ * console.log('Staff selection cleared for', timeSlot.localStartDate);
208
+ * }}
209
+ * />
210
+ *
194
211
  * // Using render prop pattern
195
212
  * <TimeSlot.Actions.ClearStaffSelection>
196
213
  * {({ onClick }) => <button onClick={onClick}>Change selection</button>}
@@ -324,6 +341,7 @@ export interface SelectStaffMemberProps {
324
341
  }) => React.ReactNode);
325
342
  className?: string;
326
343
  label?: string;
344
+ onClicked?: (staffMember: CoreTimeSlot.StaffMemberData) => void;
327
345
  }
328
346
  /**
329
347
  * Button to select this staff member.
@@ -343,6 +361,13 @@ export interface SelectStaffMemberProps {
343
361
  * <button className="btn-primary">Choose Staff</button>
344
362
  * </TimeSlot.StaffMember.Actions.Select>
345
363
  *
364
+ * // With onClicked callback
365
+ * <TimeSlot.StaffMember.Actions.Select
366
+ * onClicked={(staffMember) => {
367
+ * console.log('Selected staff:', staffMember.name);
368
+ * }}
369
+ * />
370
+ *
346
371
  * // Using render prop pattern
347
372
  * <TimeSlot.StaffMember.Actions.Select>
348
373
  * {({ isSelected, onClick }) => (
@@ -139,6 +139,13 @@ Duration.displayName = 'TimeSlot.Duration';
139
139
  * <button className="btn-primary">Book this slot</button>
140
140
  * </TimeSlot.Actions.Select>
141
141
  *
142
+ * // With onClicked callback for navigation
143
+ * <TimeSlot.Actions.Select
144
+ * onClicked={(timeSlot) => {
145
+ * router.push(`/booking/confirm/${timeSlot.localStartDate}`);
146
+ * }}
147
+ * />
148
+ *
142
149
  * // Using render prop pattern
143
150
  * <TimeSlot.Actions.Select>
144
151
  * {({ isSelected, bookable, onClick }) => (
@@ -150,14 +157,18 @@ Duration.displayName = 'TimeSlot.Duration';
150
157
  * ```
151
158
  */
152
159
  export const Select = React.forwardRef((props, ref) => {
153
- const { asChild, children, className, label = 'Select' } = props;
154
- return (_jsx(CoreTimeSlot.Actions, { children: ({ selectTimeSlot, isSelected, bookable }) => {
160
+ const { asChild, children, className, label = 'Select', onClicked } = props;
161
+ return (_jsx(CoreTimeSlot.Actions, { children: ({ selectTimeSlot, isSelected, bookable, timeSlot }) => {
162
+ const handleClick = () => {
163
+ selectTimeSlot();
164
+ onClicked?.(timeSlot);
165
+ };
155
166
  return (_jsx(AsChildSlot, { ref: ref, asChild: asChild, className: className, "data-testid": TestIds.timeSlotActionSelect, "data-selected": isSelected, "data-bookable": bookable, customElement: children, customElementProps: {
156
- onClick: selectTimeSlot,
167
+ onClick: handleClick,
157
168
  disabled: !bookable,
158
169
  isSelected,
159
170
  bookable,
160
- }, children: _jsx("button", { onClick: selectTimeSlot, disabled: !bookable, children: label }) }));
171
+ }, children: _jsx("button", { onClick: handleClick, disabled: !bookable, children: label }) }));
161
172
  } }));
162
173
  });
163
174
  Select.displayName = 'TimeSlot.Actions.Select';
@@ -180,6 +191,13 @@ Select.displayName = 'TimeSlot.Actions.Select';
180
191
  * <button className="btn-secondary">Clear staff</button>
181
192
  * </TimeSlot.Actions.ClearStaffSelection>
182
193
  *
194
+ * // With onClicked callback
195
+ * <TimeSlot.Actions.ClearStaffSelection
196
+ * onClicked={(timeSlot) => {
197
+ * console.log('Staff selection cleared for', timeSlot.localStartDate);
198
+ * }}
199
+ * />
200
+ *
183
201
  * // Using render prop pattern
184
202
  * <TimeSlot.Actions.ClearStaffSelection>
185
203
  * {({ onClick }) => <button onClick={onClick}>Change selection</button>}
@@ -187,14 +205,18 @@ Select.displayName = 'TimeSlot.Actions.Select';
187
205
  * ```
188
206
  */
189
207
  export const ClearStaffSelection = React.forwardRef((props, ref) => {
190
- const { asChild, children, className, label = 'Change' } = props;
191
- return (_jsx(CoreTimeSlot.Actions, { children: ({ clearStaffSelection }) => {
208
+ const { asChild, children, className, label = 'Change', onClicked } = props;
209
+ return (_jsx(CoreTimeSlot.Actions, { children: ({ clearStaffSelection, timeSlot }) => {
192
210
  if (!clearStaffSelection) {
193
211
  return null;
194
212
  }
213
+ const handleClick = () => {
214
+ clearStaffSelection();
215
+ onClicked?.(timeSlot);
216
+ };
195
217
  return (_jsx(AsChildSlot, { ref: ref, asChild: asChild, className: className, "data-testid": TestIds.timeSlotActionClearStaffSelection, customElement: children, customElementProps: {
196
- onClick: clearStaffSelection,
197
- }, children: _jsx("button", { onClick: clearStaffSelection, children: label }) }));
218
+ onClick: handleClick,
219
+ }, children: _jsx("button", { onClick: handleClick, children: label }) }));
198
220
  } }));
199
221
  });
200
222
  ClearStaffSelection.displayName = 'TimeSlot.Actions.ClearStaffSelection';
@@ -324,6 +346,13 @@ StaffMemberName.displayName = 'TimeSlot.StaffMember.Name';
324
346
  * <button className="btn-primary">Choose Staff</button>
325
347
  * </TimeSlot.StaffMember.Actions.Select>
326
348
  *
349
+ * // With onClicked callback
350
+ * <TimeSlot.StaffMember.Actions.Select
351
+ * onClicked={(staffMember) => {
352
+ * console.log('Selected staff:', staffMember.name);
353
+ * }}
354
+ * />
355
+ *
327
356
  * // Using render prop pattern
328
357
  * <TimeSlot.StaffMember.Actions.Select>
329
358
  * {({ isSelected, onClick }) => (
@@ -335,11 +364,17 @@ StaffMemberName.displayName = 'TimeSlot.StaffMember.Name';
335
364
  * ```
336
365
  */
337
366
  export const SelectStaffMember = React.forwardRef((props, ref) => {
338
- const { asChild, children, className, label = 'Select' } = props;
339
- return (_jsx(CoreTimeSlot.StaffMemberActions, { children: ({ selectStaffMember, isSelected }) => (_jsx(AsChildSlot, { ref: ref, asChild: asChild, className: className, "data-testid": TestIds.timeSlotStaffMemberActionSelect, "data-selected": isSelected, customElement: children, customElementProps: {
340
- onClick: selectStaffMember,
341
- isSelected,
342
- }, children: _jsx("button", { onClick: selectStaffMember, children: label }) })) }));
367
+ const { asChild, children, className, label = 'Select', onClicked } = props;
368
+ return (_jsx(CoreTimeSlot.StaffMemberActions, { children: ({ selectStaffMember, isSelected, staffMember }) => {
369
+ const handleClick = () => {
370
+ selectStaffMember();
371
+ onClicked?.(staffMember);
372
+ };
373
+ return (_jsx(AsChildSlot, { ref: ref, asChild: asChild, className: className, "data-testid": TestIds.timeSlotStaffMemberActionSelect, "data-selected": isSelected, customElement: children, customElementProps: {
374
+ onClick: handleClick,
375
+ isSelected,
376
+ }, children: _jsx("button", { onClick: handleClick, children: label }) }));
377
+ } }));
343
378
  });
344
379
  SelectStaffMember.displayName = 'TimeSlot.StaffMember.Actions.Select';
345
380
  /**
@@ -73,13 +73,14 @@ export const ServiceListServiceDefinition = defineService('service-list');
73
73
  * ```
74
74
  */
75
75
  export const ServiceListService = implementService.withConfig()(ServiceListServiceDefinition, ({ getService, config }) => {
76
- let firstRun = true;
77
76
  const signalsService = getService(SignalsServiceDefinition);
78
77
  const bookingService = getService(BookingServiceDefinition);
79
78
  // Default appId to BOOKING_APP_ID if not provided
80
79
  const appId = config.options?.appId ?? BOOKING_APP_ID;
81
80
  // Check if services are provided (showcase mode vs query mode)
82
81
  const hasInitialServices = config.services && config.services.length > 0;
82
+ // Check if categories are provided (from SSR prefetch)
83
+ const hasInitialCategories = config.categories && config.categories.length > 0;
83
84
  // Initialize services signal - use empty array if not provided
84
85
  const initialServices = config.services || [];
85
86
  const servicesSignal = signalsService.signal(initialServices);
@@ -157,9 +158,43 @@ export const ServiceListService = implementService.withConfig()(ServiceListServi
157
158
  ...(locationId ? { locationIds: [locationId] } : {}),
158
159
  };
159
160
  };
160
- // Auto-query on queryOptions changes (client-side only)
161
+ // Initial fetch - runs once on client when no prefetched data
162
+ // Fetches both services AND categories in parallel
163
+ if (typeof window !== 'undefined' && !hasInitialServices) {
164
+ const initialQueryOptions = queryOptionsSignal.peek();
165
+ const currentPaging = pagingMetadataSignal.peek();
166
+ const effectiveFilters = getEffectiveFilters(initialQueryOptions.filters);
167
+ isLoadingSignal.set(true);
168
+ Promise.all([
169
+ queryServices({
170
+ appId: initialQueryOptions.appId,
171
+ filters: effectiveFilters,
172
+ pagingMetadata: {
173
+ limit: currentPaging.limit,
174
+ offset: 0,
175
+ },
176
+ sort: initialQueryOptions.sort,
177
+ }),
178
+ !hasInitialCategories ? queryCategories() : Promise.resolve(null),
179
+ ])
180
+ .then(([servicesResult, categoriesResult]) => {
181
+ servicesSignal.set(servicesResult.services);
182
+ pagingMetadataSignal.set(servicesResult.pagingMetadata);
183
+ if (categoriesResult) {
184
+ categoriesSignal.set(categoriesResult.categories);
185
+ }
186
+ })
187
+ .catch((error) => {
188
+ errorSignal.set(error instanceof Error ? error.message : 'Unknown error');
189
+ })
190
+ .finally(() => {
191
+ isLoadingSignal.set(false);
192
+ });
193
+ }
194
+ // Reactive effect - only handles query/filter changes (not initial fetch)
161
195
  // IMPORTANT: Only subscribes to queryOptionsSignal and bookingService.location
162
196
  if (typeof window !== 'undefined') {
197
+ let isFirstEffectRun = true;
163
198
  signalsService.effect(async () => {
164
199
  // CRITICAL: Read queryOptionsSignal to establish dependency
165
200
  // Do NOT read pagingMetadataSignal here to avoid infinite loop
@@ -167,12 +202,10 @@ export const ServiceListService = implementService.withConfig()(ServiceListServi
167
202
  // Also subscribe to location changes from BookingService
168
203
  // Reading the signal here establishes the dependency
169
204
  bookingService.location.get();
170
- if (firstRun) {
171
- firstRun = false;
172
- // If we have initial services (showcase mode), don't query on first run
173
- if (hasInitialServices) {
174
- return;
175
- }
205
+ // Skip first run - initial fetch is handled separately above
206
+ if (isFirstEffectRun) {
207
+ isFirstEffectRun = false;
208
+ return;
176
209
  }
177
210
  // Read paging metadata outside of dependency tracking for the fetch
178
211
  const currentPaging = pagingMetadataSignal.peek();
@@ -202,7 +235,6 @@ export const ServiceListService = implementService.withConfig()(ServiceListServi
202
235
  }
203
236
  });
204
237
  }
205
- firstRun = false;
206
238
  const loadMoreCursor = async (count) => {
207
239
  const currentQueryOptions = queryOptionsSignal.get();
208
240
  // Calculate next page offset based on current services count
@@ -2,11 +2,16 @@
2
2
  * Query Services API
3
3
  * Fetches single or multiple services with pagination and sorting
4
4
  */
5
- import { queryServices as queryServicesApi, } from '@wix/auto_sdk_bookings_services';
5
+ import { queryServices as queryServicesApi, LocationType, } from '@wix/auto_sdk_bookings_services';
6
6
  /**
7
7
  * Booking app ID constant
8
8
  */
9
9
  const BOOKING_APP_ID = '13d21c63-b5ec-5912-8397-c3a5ddb27a97';
10
+ /**
11
+ * Synthetic location ID constants for non-business locations
12
+ */
13
+ const SYNTHETIC_CUSTOM_ID = '__synthetic_custom__';
14
+ const SYNTHETIC_CUSTOMER_ID = '__synthetic_customer__';
10
15
  /**
11
16
  * Fetches a service by its ID
12
17
  * @param id - The service ID
@@ -80,7 +85,28 @@ const buildServicesQueryRequest = ({ appId, filters, paging, sort, }) => {
80
85
  }
81
86
  // Add locationIds filter if provided
82
87
  if (filters?.locationIds && filters.locationIds.length > 0) {
83
- filter['locations.business.id'] = { $hasSome: filters.locationIds };
88
+ // Separate synthetic IDs from business location IDs
89
+ const syntheticTypes = [];
90
+ const businessLocationIds = [];
91
+ for (const id of filters.locationIds) {
92
+ if (id === SYNTHETIC_CUSTOM_ID) {
93
+ syntheticTypes.push(LocationType.CUSTOM);
94
+ }
95
+ else if (id === SYNTHETIC_CUSTOMER_ID) {
96
+ syntheticTypes.push(LocationType.CUSTOMER);
97
+ }
98
+ else {
99
+ businessLocationIds.push(id);
100
+ }
101
+ }
102
+ // Filter by location type for synthetic locations
103
+ if (syntheticTypes.length > 0) {
104
+ filter['locations.type'] = { $hasSome: syntheticTypes };
105
+ }
106
+ // Filter by business ID for business locations
107
+ if (businessLocationIds.length > 0) {
108
+ filter['locations.business.id'] = { $hasSome: businessLocationIds };
109
+ }
84
110
  }
85
111
  // staffMemberIds to be added later
86
112
  const query = {
@@ -25,8 +25,8 @@ export interface SyntheticLocation {
25
25
  locationType: LocationType.CUSTOMER | LocationType.CUSTOM;
26
26
  /** Marker to identify synthetic locations */
27
27
  synthetic: true;
28
- /** Optional ID for tracking */
29
- id?: string;
28
+ /** Optional ID for tracking (matches ServiceLocation._id) */
29
+ _id?: string;
30
30
  }
31
31
  /**
32
32
  * Union type - location prop accepts Service or Synthetic location
@@ -110,7 +110,7 @@ function computeLocationRenderProps(rawLocation) {
110
110
  locationType,
111
111
  address: null,
112
112
  name: undefined,
113
- locationId: syntheticLoc.id,
113
+ locationId: syntheticLoc._id,
114
114
  isCustomerLocation: locationType === LocationType.CUSTOMER,
115
115
  isCustomLocation: locationType === LocationType.CUSTOM,
116
116
  isSynthetic: true,
@@ -92,7 +92,7 @@ export const LocationRepeater = React.forwardRef((props, ref) => {
92
92
  const location = displayLocation.location ?? {
93
93
  locationType: displayLocation.type,
94
94
  synthetic: true,
95
- id: displayLocation.id,
95
+ _id: displayLocation.id,
96
96
  };
97
97
  return (_jsx(Location.Root, { location: location, "data-testid": TestIds.locationListLocation, "data-location-id": displayLocation.id, "data-location-type": displayLocation.type, "data-item-id": displayLocation.id, children: itemChildren }, displayLocation.id));
98
98
  };
@@ -1133,6 +1133,41 @@ export interface DefaultCapacityProps {
1133
1133
  * ```
1134
1134
  */
1135
1135
  export declare const DefaultCapacity: React.ForwardRefExoticComponent<DefaultCapacityProps & React.RefAttributes<HTMLElement>>;
1136
+ /**
1137
+ * Props for Service AvailableOnline component
1138
+ */
1139
+ export interface AvailableOnlineProps {
1140
+ asChild?: boolean;
1141
+ children?: AsChildChildren<{
1142
+ availableOnline: boolean;
1143
+ }>;
1144
+ className?: string;
1145
+ label?: string;
1146
+ }
1147
+ /**
1148
+ * Displays whether the service is available online (conferencing enabled).
1149
+ * Headless component - contains zero formatting logic.
1150
+ *
1151
+ * Maps to: service.conferencing.enabled
1152
+ *
1153
+ * @component
1154
+ * @example
1155
+ * ```tsx
1156
+ * // Default usage - displays label only when available online
1157
+ * <Service.AvailableOnline label="Available Online" className="text-sm text-secondary-foreground" />
1158
+ * ```
1159
+ *
1160
+ * @example
1161
+ * ```tsx
1162
+ * // asChild with custom component
1163
+ * <Service.AvailableOnline asChild>
1164
+ * {({ availableOnline }) => availableOnline ? (
1165
+ * <span className="badge bg-green-500">Online</span>
1166
+ * ) : null}
1167
+ * </Service.AvailableOnline>
1168
+ * ```
1169
+ */
1170
+ export declare const AvailableOnline: React.ForwardRefExoticComponent<AvailableOnlineProps & React.RefAttributes<HTMLElement>>;
1136
1171
  export declare const Locations: typeof LocationsBase & {
1137
1172
  List: typeof LocationsList;
1138
1173
  LocationRepeater: typeof LocationRepeater;
@@ -49,6 +49,8 @@ var TestIds;
49
49
  TestIds["serviceScheduleFirstSessionStart"] = "service-schedule-first-session-start";
50
50
  TestIds["serviceScheduleLastSessionEnd"] = "service-schedule-last-session-end";
51
51
  TestIds["serviceScheduleSessionDuration"] = "service-schedule-session-duration";
52
+ // Conferencing component TestIds
53
+ TestIds["serviceAvailableOnline"] = "service-available-online";
52
54
  })(TestIds || (TestIds = {}));
53
55
  /**
54
56
  * Root component that provides service context to the entire app.
@@ -1083,6 +1085,37 @@ export const DefaultCapacity = React.forwardRef((props, ref) => {
1083
1085
  } }));
1084
1086
  });
1085
1087
  DefaultCapacity.displayName = 'Service.DefaultCapacity';
1088
+ /**
1089
+ * Displays whether the service is available online (conferencing enabled).
1090
+ * Headless component - contains zero formatting logic.
1091
+ *
1092
+ * Maps to: service.conferencing.enabled
1093
+ *
1094
+ * @component
1095
+ * @example
1096
+ * ```tsx
1097
+ * // Default usage - displays label only when available online
1098
+ * <Service.AvailableOnline label="Available Online" className="text-sm text-secondary-foreground" />
1099
+ * ```
1100
+ *
1101
+ * @example
1102
+ * ```tsx
1103
+ * // asChild with custom component
1104
+ * <Service.AvailableOnline asChild>
1105
+ * {({ availableOnline }) => availableOnline ? (
1106
+ * <span className="badge bg-green-500">Online</span>
1107
+ * ) : null}
1108
+ * </Service.AvailableOnline>
1109
+ * ```
1110
+ */
1111
+ export const AvailableOnline = React.forwardRef((props, ref) => {
1112
+ const { asChild, children, className, label } = props;
1113
+ return (_jsx(CoreService.Service, { children: ({ service }) => {
1114
+ const availableOnline = service.conferencing?.enabled ?? false;
1115
+ return (_jsx(AsChildSlot, { ref: ref, asChild: asChild, className: className, "data-testid": TestIds.serviceAvailableOnline, customElement: children, customElementProps: { availableOnline }, children: availableOnline && label ? _jsx("span", { children: label }) : null }));
1116
+ } }));
1117
+ });
1118
+ AvailableOnline.displayName = 'Service.AvailableOnline';
1086
1119
  // Create Locations with nested components
1087
1120
  export const Locations = Object.assign(LocationsBase, {
1088
1121
  List: LocationsList,
@@ -8,6 +8,7 @@
8
8
  import React from 'react';
9
9
  import { AsChildChildren } from '@wix/headless-utils/react';
10
10
  import type { TimeSlot as TimeSlotType } from '@wix/auto_sdk_bookings_availability-time-slots';
11
+ import * as CoreTimeSlot from '../core/time-slot-list/TimeSlot.js';
11
12
  export type { StaffMemberData } from '../core/time-slot-list/TimeSlot.js';
12
13
  /**
13
14
  * Props for TimeSlot.Root component
@@ -131,6 +132,7 @@ export interface SelectProps {
131
132
  }) => React.ReactNode);
132
133
  className?: string;
133
134
  label?: string;
135
+ onClicked?: (timeSlot: TimeSlotType) => void;
134
136
  }
135
137
  /**
136
138
  * Button to select this time slot.
@@ -150,6 +152,13 @@ export interface SelectProps {
150
152
  * <button className="btn-primary">Book this slot</button>
151
153
  * </TimeSlot.Actions.Select>
152
154
  *
155
+ * // With onClicked callback for navigation
156
+ * <TimeSlot.Actions.Select
157
+ * onClicked={(timeSlot) => {
158
+ * router.push(`/booking/confirm/${timeSlot.localStartDate}`);
159
+ * }}
160
+ * />
161
+ *
153
162
  * // Using render prop pattern
154
163
  * <TimeSlot.Actions.Select>
155
164
  * {({ isSelected, bookable, onClick }) => (
@@ -171,6 +180,7 @@ export interface ClearStaffSelectionProps {
171
180
  }) => React.ReactNode);
172
181
  className?: string;
173
182
  label?: string;
183
+ onClicked?: (timeSlot: TimeSlotType) => void;
174
184
  }
175
185
  /**
176
186
  * Button to clear staff selection while keeping the slot selected.
@@ -191,6 +201,13 @@ export interface ClearStaffSelectionProps {
191
201
  * <button className="btn-secondary">Clear staff</button>
192
202
  * </TimeSlot.Actions.ClearStaffSelection>
193
203
  *
204
+ * // With onClicked callback
205
+ * <TimeSlot.Actions.ClearStaffSelection
206
+ * onClicked={(timeSlot) => {
207
+ * console.log('Staff selection cleared for', timeSlot.localStartDate);
208
+ * }}
209
+ * />
210
+ *
194
211
  * // Using render prop pattern
195
212
  * <TimeSlot.Actions.ClearStaffSelection>
196
213
  * {({ onClick }) => <button onClick={onClick}>Change selection</button>}
@@ -324,6 +341,7 @@ export interface SelectStaffMemberProps {
324
341
  }) => React.ReactNode);
325
342
  className?: string;
326
343
  label?: string;
344
+ onClicked?: (staffMember: CoreTimeSlot.StaffMemberData) => void;
327
345
  }
328
346
  /**
329
347
  * Button to select this staff member.
@@ -343,6 +361,13 @@ export interface SelectStaffMemberProps {
343
361
  * <button className="btn-primary">Choose Staff</button>
344
362
  * </TimeSlot.StaffMember.Actions.Select>
345
363
  *
364
+ * // With onClicked callback
365
+ * <TimeSlot.StaffMember.Actions.Select
366
+ * onClicked={(staffMember) => {
367
+ * console.log('Selected staff:', staffMember.name);
368
+ * }}
369
+ * />
370
+ *
346
371
  * // Using render prop pattern
347
372
  * <TimeSlot.StaffMember.Actions.Select>
348
373
  * {({ isSelected, onClick }) => (
@@ -139,6 +139,13 @@ Duration.displayName = 'TimeSlot.Duration';
139
139
  * <button className="btn-primary">Book this slot</button>
140
140
  * </TimeSlot.Actions.Select>
141
141
  *
142
+ * // With onClicked callback for navigation
143
+ * <TimeSlot.Actions.Select
144
+ * onClicked={(timeSlot) => {
145
+ * router.push(`/booking/confirm/${timeSlot.localStartDate}`);
146
+ * }}
147
+ * />
148
+ *
142
149
  * // Using render prop pattern
143
150
  * <TimeSlot.Actions.Select>
144
151
  * {({ isSelected, bookable, onClick }) => (
@@ -150,14 +157,18 @@ Duration.displayName = 'TimeSlot.Duration';
150
157
  * ```
151
158
  */
152
159
  export const Select = React.forwardRef((props, ref) => {
153
- const { asChild, children, className, label = 'Select' } = props;
154
- return (_jsx(CoreTimeSlot.Actions, { children: ({ selectTimeSlot, isSelected, bookable }) => {
160
+ const { asChild, children, className, label = 'Select', onClicked } = props;
161
+ return (_jsx(CoreTimeSlot.Actions, { children: ({ selectTimeSlot, isSelected, bookable, timeSlot }) => {
162
+ const handleClick = () => {
163
+ selectTimeSlot();
164
+ onClicked?.(timeSlot);
165
+ };
155
166
  return (_jsx(AsChildSlot, { ref: ref, asChild: asChild, className: className, "data-testid": TestIds.timeSlotActionSelect, "data-selected": isSelected, "data-bookable": bookable, customElement: children, customElementProps: {
156
- onClick: selectTimeSlot,
167
+ onClick: handleClick,
157
168
  disabled: !bookable,
158
169
  isSelected,
159
170
  bookable,
160
- }, children: _jsx("button", { onClick: selectTimeSlot, disabled: !bookable, children: label }) }));
171
+ }, children: _jsx("button", { onClick: handleClick, disabled: !bookable, children: label }) }));
161
172
  } }));
162
173
  });
163
174
  Select.displayName = 'TimeSlot.Actions.Select';
@@ -180,6 +191,13 @@ Select.displayName = 'TimeSlot.Actions.Select';
180
191
  * <button className="btn-secondary">Clear staff</button>
181
192
  * </TimeSlot.Actions.ClearStaffSelection>
182
193
  *
194
+ * // With onClicked callback
195
+ * <TimeSlot.Actions.ClearStaffSelection
196
+ * onClicked={(timeSlot) => {
197
+ * console.log('Staff selection cleared for', timeSlot.localStartDate);
198
+ * }}
199
+ * />
200
+ *
183
201
  * // Using render prop pattern
184
202
  * <TimeSlot.Actions.ClearStaffSelection>
185
203
  * {({ onClick }) => <button onClick={onClick}>Change selection</button>}
@@ -187,14 +205,18 @@ Select.displayName = 'TimeSlot.Actions.Select';
187
205
  * ```
188
206
  */
189
207
  export const ClearStaffSelection = React.forwardRef((props, ref) => {
190
- const { asChild, children, className, label = 'Change' } = props;
191
- return (_jsx(CoreTimeSlot.Actions, { children: ({ clearStaffSelection }) => {
208
+ const { asChild, children, className, label = 'Change', onClicked } = props;
209
+ return (_jsx(CoreTimeSlot.Actions, { children: ({ clearStaffSelection, timeSlot }) => {
192
210
  if (!clearStaffSelection) {
193
211
  return null;
194
212
  }
213
+ const handleClick = () => {
214
+ clearStaffSelection();
215
+ onClicked?.(timeSlot);
216
+ };
195
217
  return (_jsx(AsChildSlot, { ref: ref, asChild: asChild, className: className, "data-testid": TestIds.timeSlotActionClearStaffSelection, customElement: children, customElementProps: {
196
- onClick: clearStaffSelection,
197
- }, children: _jsx("button", { onClick: clearStaffSelection, children: label }) }));
218
+ onClick: handleClick,
219
+ }, children: _jsx("button", { onClick: handleClick, children: label }) }));
198
220
  } }));
199
221
  });
200
222
  ClearStaffSelection.displayName = 'TimeSlot.Actions.ClearStaffSelection';
@@ -324,6 +346,13 @@ StaffMemberName.displayName = 'TimeSlot.StaffMember.Name';
324
346
  * <button className="btn-primary">Choose Staff</button>
325
347
  * </TimeSlot.StaffMember.Actions.Select>
326
348
  *
349
+ * // With onClicked callback
350
+ * <TimeSlot.StaffMember.Actions.Select
351
+ * onClicked={(staffMember) => {
352
+ * console.log('Selected staff:', staffMember.name);
353
+ * }}
354
+ * />
355
+ *
327
356
  * // Using render prop pattern
328
357
  * <TimeSlot.StaffMember.Actions.Select>
329
358
  * {({ isSelected, onClick }) => (
@@ -335,11 +364,17 @@ StaffMemberName.displayName = 'TimeSlot.StaffMember.Name';
335
364
  * ```
336
365
  */
337
366
  export const SelectStaffMember = React.forwardRef((props, ref) => {
338
- const { asChild, children, className, label = 'Select' } = props;
339
- return (_jsx(CoreTimeSlot.StaffMemberActions, { children: ({ selectStaffMember, isSelected }) => (_jsx(AsChildSlot, { ref: ref, asChild: asChild, className: className, "data-testid": TestIds.timeSlotStaffMemberActionSelect, "data-selected": isSelected, customElement: children, customElementProps: {
340
- onClick: selectStaffMember,
341
- isSelected,
342
- }, children: _jsx("button", { onClick: selectStaffMember, children: label }) })) }));
367
+ const { asChild, children, className, label = 'Select', onClicked } = props;
368
+ return (_jsx(CoreTimeSlot.StaffMemberActions, { children: ({ selectStaffMember, isSelected, staffMember }) => {
369
+ const handleClick = () => {
370
+ selectStaffMember();
371
+ onClicked?.(staffMember);
372
+ };
373
+ return (_jsx(AsChildSlot, { ref: ref, asChild: asChild, className: className, "data-testid": TestIds.timeSlotStaffMemberActionSelect, "data-selected": isSelected, customElement: children, customElementProps: {
374
+ onClick: handleClick,
375
+ isSelected,
376
+ }, children: _jsx("button", { onClick: handleClick, children: label }) }));
377
+ } }));
343
378
  });
344
379
  SelectStaffMember.displayName = 'TimeSlot.StaffMember.Actions.Select';
345
380
  /**
@@ -73,13 +73,14 @@ export const ServiceListServiceDefinition = defineService('service-list');
73
73
  * ```
74
74
  */
75
75
  export const ServiceListService = implementService.withConfig()(ServiceListServiceDefinition, ({ getService, config }) => {
76
- let firstRun = true;
77
76
  const signalsService = getService(SignalsServiceDefinition);
78
77
  const bookingService = getService(BookingServiceDefinition);
79
78
  // Default appId to BOOKING_APP_ID if not provided
80
79
  const appId = config.options?.appId ?? BOOKING_APP_ID;
81
80
  // Check if services are provided (showcase mode vs query mode)
82
81
  const hasInitialServices = config.services && config.services.length > 0;
82
+ // Check if categories are provided (from SSR prefetch)
83
+ const hasInitialCategories = config.categories && config.categories.length > 0;
83
84
  // Initialize services signal - use empty array if not provided
84
85
  const initialServices = config.services || [];
85
86
  const servicesSignal = signalsService.signal(initialServices);
@@ -157,9 +158,43 @@ export const ServiceListService = implementService.withConfig()(ServiceListServi
157
158
  ...(locationId ? { locationIds: [locationId] } : {}),
158
159
  };
159
160
  };
160
- // Auto-query on queryOptions changes (client-side only)
161
+ // Initial fetch - runs once on client when no prefetched data
162
+ // Fetches both services AND categories in parallel
163
+ if (typeof window !== 'undefined' && !hasInitialServices) {
164
+ const initialQueryOptions = queryOptionsSignal.peek();
165
+ const currentPaging = pagingMetadataSignal.peek();
166
+ const effectiveFilters = getEffectiveFilters(initialQueryOptions.filters);
167
+ isLoadingSignal.set(true);
168
+ Promise.all([
169
+ queryServices({
170
+ appId: initialQueryOptions.appId,
171
+ filters: effectiveFilters,
172
+ pagingMetadata: {
173
+ limit: currentPaging.limit,
174
+ offset: 0,
175
+ },
176
+ sort: initialQueryOptions.sort,
177
+ }),
178
+ !hasInitialCategories ? queryCategories() : Promise.resolve(null),
179
+ ])
180
+ .then(([servicesResult, categoriesResult]) => {
181
+ servicesSignal.set(servicesResult.services);
182
+ pagingMetadataSignal.set(servicesResult.pagingMetadata);
183
+ if (categoriesResult) {
184
+ categoriesSignal.set(categoriesResult.categories);
185
+ }
186
+ })
187
+ .catch((error) => {
188
+ errorSignal.set(error instanceof Error ? error.message : 'Unknown error');
189
+ })
190
+ .finally(() => {
191
+ isLoadingSignal.set(false);
192
+ });
193
+ }
194
+ // Reactive effect - only handles query/filter changes (not initial fetch)
161
195
  // IMPORTANT: Only subscribes to queryOptionsSignal and bookingService.location
162
196
  if (typeof window !== 'undefined') {
197
+ let isFirstEffectRun = true;
163
198
  signalsService.effect(async () => {
164
199
  // CRITICAL: Read queryOptionsSignal to establish dependency
165
200
  // Do NOT read pagingMetadataSignal here to avoid infinite loop
@@ -167,12 +202,10 @@ export const ServiceListService = implementService.withConfig()(ServiceListServi
167
202
  // Also subscribe to location changes from BookingService
168
203
  // Reading the signal here establishes the dependency
169
204
  bookingService.location.get();
170
- if (firstRun) {
171
- firstRun = false;
172
- // If we have initial services (showcase mode), don't query on first run
173
- if (hasInitialServices) {
174
- return;
175
- }
205
+ // Skip first run - initial fetch is handled separately above
206
+ if (isFirstEffectRun) {
207
+ isFirstEffectRun = false;
208
+ return;
176
209
  }
177
210
  // Read paging metadata outside of dependency tracking for the fetch
178
211
  const currentPaging = pagingMetadataSignal.peek();
@@ -202,7 +235,6 @@ export const ServiceListService = implementService.withConfig()(ServiceListServi
202
235
  }
203
236
  });
204
237
  }
205
- firstRun = false;
206
238
  const loadMoreCursor = async (count) => {
207
239
  const currentQueryOptions = queryOptionsSignal.get();
208
240
  // Calculate next page offset based on current services count
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/headless-bookings",
3
- "version": "0.0.54",
3
+ "version": "0.0.56",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -72,5 +72,5 @@
72
72
  "groupId": "com.wixpress.headless-components"
73
73
  }
74
74
  },
75
- "falconPackageHash": "079a06fd7da27790c8fe3e4d05005528c07424a69a125f7f49da76e4"
75
+ "falconPackageHash": "8792da90af7c78ece12ac4244e507751fbe186381decf6b266bee92a"
76
76
  }