@wix/headless-bookings 1.0.9 → 1.0.10

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 (35) hide show
  1. package/cjs/dist/__mocks__/services/services-mocks.d.ts +17 -1
  2. package/cjs/dist/__mocks__/services/services-mocks.js +35 -1
  3. package/cjs/dist/api/index.d.ts +1 -1
  4. package/cjs/dist/api/index.js +2 -1
  5. package/cjs/dist/api/query-services/index.d.ts +60 -15
  6. package/cjs/dist/api/query-services/index.js +86 -10
  7. package/cjs/dist/react/core/service-list/ServiceList.d.ts +2 -3
  8. package/cjs/dist/react/service-list/ServiceList.d.ts +3 -3
  9. package/cjs/dist/react/service-list/ServiceList.js +1 -1
  10. package/cjs/dist/react/service-list/ServiceList.stories.d.ts +29 -0
  11. package/cjs/dist/react/service-list/ServiceList.stories.js +125 -0
  12. package/cjs/dist/services/constants.d.ts +2 -0
  13. package/cjs/dist/services/constants.js +3 -1
  14. package/cjs/dist/services/index.d.ts +2 -2
  15. package/cjs/dist/services/index.js +2 -1
  16. package/cjs/dist/services/service-list/service-list.d.ts +45 -36
  17. package/cjs/dist/services/service-list/service-list.js +90 -112
  18. package/dist/__mocks__/services/services-mocks.d.ts +17 -1
  19. package/dist/__mocks__/services/services-mocks.js +34 -0
  20. package/dist/api/index.d.ts +1 -1
  21. package/dist/api/index.js +1 -1
  22. package/dist/api/query-services/index.d.ts +60 -15
  23. package/dist/api/query-services/index.js +89 -10
  24. package/dist/react/core/service-list/ServiceList.d.ts +2 -3
  25. package/dist/react/service-list/ServiceList.d.ts +3 -3
  26. package/dist/react/service-list/ServiceList.js +1 -1
  27. package/dist/react/service-list/ServiceList.stories.d.ts +29 -0
  28. package/dist/react/service-list/ServiceList.stories.js +89 -0
  29. package/dist/services/constants.d.ts +2 -0
  30. package/dist/services/constants.js +2 -0
  31. package/dist/services/index.d.ts +2 -2
  32. package/dist/services/index.js +1 -1
  33. package/dist/services/service-list/service-list.d.ts +45 -36
  34. package/dist/services/service-list/service-list.js +90 -112
  35. package/package.json +17 -2
@@ -1,17 +1,19 @@
1
1
  /**
2
2
  * ServiceList Service - Reactive service list data management
3
- * Handles querying and pagination of services list (NO selection management)
3
+ * Handles querying and pagination of services list (NO selection management).
4
+ *
5
+ * Availability is just part of the query: when the query options carry an
6
+ * `availabilityFilter`, the API layer queries services by real bookable
7
+ * availability (and each service gets an `available` flag); otherwise it runs the
8
+ * plain offset query. The service does not know or care which endpoint is used —
9
+ * that decision lives in {@link queryServices}.
4
10
  */
5
11
  import { defineService, implementService } from '@wix/services-definitions';
6
12
  import { SignalsServiceDefinition, } from '@wix/services-definitions/core-services/signals';
7
- import { queryServices } from '../../api/query-services/index.js';
13
+ import { queryServices, hasActiveAvailabilityFilter, } from '../../api/query-services/index.js';
8
14
  import { queryCategories, } from '../../api/query-categories/index.js';
9
15
  import { BookingServiceDefinition } from '../booking/booking.js';
10
- import { BOOKING_APP_ID } from '../constants.js';
11
- /**
12
- * Default query limit
13
- */
14
- const DEFAULT_QUERY_LIMIT = 100;
16
+ import { BOOKING_APP_ID, DEFAULT_QUERY_LIMIT } from '../constants.js';
15
17
  /**
16
18
  * Service definition for the ServiceList service
17
19
  */
@@ -42,7 +44,6 @@ export const ServiceListServiceDefinition = defineService('service-list');
42
44
  * const services = serviceListService.servicesSignal.get();
43
45
  * const isLoading = serviceListService.isLoadingSignal.get();
44
46
  * const error = serviceListService.errorSignal.get();
45
- * const pagingMetadata = serviceListService.pagingMetadataSignal.get();
46
47
  * const hasMore = serviceListService.hasMoreServicesSignal.get();
47
48
  *
48
49
  * if (isLoading) return <div>Loading services...</div>;
@@ -61,9 +62,6 @@ export const ServiceListServiceDefinition = defineService('service-list');
61
62
  * Load More
62
63
  * </button>
63
64
  * )}
64
- * {pagingMetadata.hasNext && (
65
- * <span>More services available</span>
66
- * )}
67
65
  * </div>
68
66
  * );
69
67
  * }
@@ -81,17 +79,19 @@ export const ServiceListService = implementService.withConfig()(ServiceListServi
81
79
  // Initialize services signal - use empty array if not provided
82
80
  const initialServices = config.services || [];
83
81
  const servicesSignal = signalsService.signal(initialServices);
84
- // Initialize query options based on mode:
85
- // - If services provided: reset to defaults (appId only) - showcase mode
86
- // - If no services: use full queryOptions from config - query mode
87
- const initialQueryOptions = hasInitialServices
88
- ? { appId } // Reset to defaults when services are provided
89
- : {
90
- appId,
91
- filter: config.options?.filter,
92
- sort: config.options?.sort,
93
- };
94
- // Initialize paging metadata separately (response data - doesn't trigger effect)
82
+ // Build query options from config. The filter/availabilityFilter/sort must
83
+ // be preserved even when services are prefetched (e.g. SSR), so loadMore
84
+ // keeps querying the same way and stays in availability mode. A pure
85
+ // showcase config (services without options) naturally yields just
86
+ // { appId } since these fields are absent.
87
+ const initialQueryOptions = {
88
+ appId,
89
+ filter: config.options?.filter,
90
+ availabilityFilter: config.options?.availabilityFilter,
91
+ sort: config.options?.sort,
92
+ };
93
+ // Initialize paging metadata separately (response data - doesn't trigger effect).
94
+ // Carries either offset (plain mode) or cursor (availability mode).
95
95
  const initialPagingMetadata = config.options
96
96
  ?.pagingMetadata || {
97
97
  limit: DEFAULT_QUERY_LIMIT,
@@ -155,6 +155,10 @@ export const ServiceListService = implementService.withConfig()(ServiceListServi
155
155
  return true;
156
156
  });
157
157
  });
158
+ // Computed signal indicating whether an availability filter is currently
159
+ // applied. Reuses the same predicate as the API dispatch so the two can't
160
+ // drift. Exposed for the availability filter UI (follow-up PR).
161
+ const hasAvailabilityFilterSignal = signalsService.computed(() => hasActiveAvailabilityFilter(queryOptionsSignal.get().availabilityFilter));
158
162
  // Computed signal for effective filter - combines queryOptions.filter with location from BookingService
159
163
  const effectiveFilterSignal = signalsService.computed(() => {
160
164
  const filter = queryOptionsSignal.get().filter;
@@ -171,23 +175,21 @@ export const ServiceListService = implementService.withConfig()(ServiceListServi
171
175
  : {}),
172
176
  };
173
177
  });
174
- // Initial fetch - runs once on client when no prefetched data
175
- // Fetches both services AND categories in parallel
178
+ // Initial fetch - runs once on client when no prefetched data.
179
+ // Fetches both services AND categories in parallel.
176
180
  if (typeof window !== 'undefined' && !hasInitialServices) {
177
- const initialQueryOptions = queryOptionsSignal.peek();
178
- const currentPaging = pagingMetadataSignal.peek();
179
- // Use effectiveFilterSignal.peek() to get the computed filter value
180
- const effectiveFilter = effectiveFilterSignal.peek();
181
+ const options = queryOptionsSignal.peek();
181
182
  isLoadingSignal.set(true);
182
183
  Promise.allSettled([
183
184
  queryServices({
184
- appId: initialQueryOptions.appId,
185
- filter: effectiveFilter,
185
+ appId: options.appId,
186
+ filter: effectiveFilterSignal.peek(),
187
+ availabilityFilter: options.availabilityFilter,
186
188
  pagingMetadata: {
187
- limit: currentPaging.limit,
189
+ limit: pagingMetadataSignal.peek().limit,
188
190
  offset: 0,
189
191
  },
190
- sort: initialQueryOptions.sort,
192
+ sort: options.sort,
191
193
  }),
192
194
  !hasInitialCategories ? queryCategories() : Promise.resolve(null),
193
195
  ])
@@ -211,37 +213,34 @@ export const ServiceListService = implementService.withConfig()(ServiceListServi
211
213
  isLoadingSignal.set(false);
212
214
  });
213
215
  }
214
- // Reactive effect - only handles query/filter changes (not initial fetch)
215
- // IMPORTANT: Subscribes to effectiveFilterSignal which includes queryOptions.filter + location
216
+ // Re-fetch when query options or the effective filter (filter + location) change.
216
217
  if (typeof window !== 'undefined') {
217
218
  let isFirstEffectRun = true;
218
219
  signalsService.effect(async () => {
219
- // CRITICAL: Read queryOptionsSignal and effectiveFilterSignal to establish dependencies
220
- // effectiveFilterSignal already subscribes to bookingService.location internally
221
- const queryOptions = queryOptionsSignal.get();
220
+ // Read both signals to establish dependencies.
221
+ const options = queryOptionsSignal.get();
222
222
  const effectiveFilter = effectiveFilterSignal.get();
223
- // Skip first run - initial fetch is handled separately above
223
+ // Skip first run - initial fetch is handled separately above.
224
224
  if (isFirstEffectRun) {
225
225
  isFirstEffectRun = false;
226
226
  return;
227
227
  }
228
- // Read paging metadata outside of dependency tracking for the fetch
229
- const currentPaging = pagingMetadataSignal.peek();
230
228
  try {
231
229
  isLoadingSignal.set(true);
232
230
  servicesSignal.set([]);
233
231
  errorSignal.set(null);
232
+ // Reset to the first page when query options change.
234
233
  const result = await queryServices({
235
- appId: queryOptions.appId,
234
+ appId: options.appId,
236
235
  filter: effectiveFilter,
236
+ availabilityFilter: options.availabilityFilter,
237
237
  pagingMetadata: {
238
- limit: currentPaging.limit,
239
- offset: 0, // Reset to first page when query options change
238
+ limit: pagingMetadataSignal.peek().limit,
239
+ offset: 0,
240
240
  },
241
- sort: queryOptions.sort,
241
+ sort: options.sort,
242
242
  });
243
243
  servicesSignal.set(result.services);
244
- // Update ONLY pagingMetadataSignal (not queryOptionsSignal) to avoid re-triggering effect
245
244
  pagingMetadataSignal.set(result.pagingMetadata);
246
245
  }
247
246
  catch (error) {
@@ -252,29 +251,25 @@ export const ServiceListService = implementService.withConfig()(ServiceListServi
252
251
  }
253
252
  });
254
253
  }
255
- const loadMoreCursor = async (count) => {
256
- const currentQueryOptions = queryOptionsSignal.get();
257
- // Calculate next page offset based on current services count
258
- const currentServicesCount = servicesSignal.get().length;
259
- const nextOffset = currentServicesCount;
260
- const nextPaging = {
261
- limit: count || DEFAULT_QUERY_LIMIT,
262
- offset: nextOffset,
263
- };
264
- // Get effective filter from computed signal (includes location from BookingService)
265
- const effectiveFilter = effectiveFilterSignal.get();
254
+ const loadMore = async (count) => {
255
+ const options = queryOptionsSignal.peek();
256
+ const currentPaging = pagingMetadataSignal.peek();
266
257
  try {
267
258
  isLoadingSignal.set(true);
268
259
  errorSignal.set(null);
269
260
  const result = await queryServices({
270
- appId: currentQueryOptions.appId,
271
- filter: effectiveFilter,
272
- pagingMetadata: nextPaging,
273
- sort: currentQueryOptions.sort,
261
+ appId: options.appId,
262
+ filter: effectiveFilterSignal.peek(),
263
+ availabilityFilter: options.availabilityFilter,
264
+ pagingMetadata: {
265
+ limit: count ?? currentPaging.limit ?? DEFAULT_QUERY_LIMIT,
266
+ // Offset (plain mode) and cursor (availability mode) — queryServices uses what it needs.
267
+ offset: servicesSignal.peek().length,
268
+ cursor: currentPaging.cursor,
269
+ },
270
+ sort: options.sort,
274
271
  });
275
- // Append new services to existing ones
276
272
  servicesSignal.set([...servicesSignal.get(), ...result.services]);
277
- // Update ONLY pagingMetadataSignal (not queryOptionsSignal)
278
273
  pagingMetadataSignal.set(result.pagingMetadata);
279
274
  }
280
275
  catch (error) {
@@ -293,101 +288,81 @@ export const ServiceListService = implementService.withConfig()(ServiceListServi
293
288
  categoriesSignal,
294
289
  filterOptionsSignal,
295
290
  isFilteredSignal,
291
+ hasAvailabilityFilterSignal,
296
292
  effectiveFilterSignal,
297
293
  actions: {
298
- loadMore: async (count) => {
299
- await loadMoreCursor(count);
300
- },
294
+ loadMore,
301
295
  setQueryOptions: (options) => {
302
- // Reset services and paging when query options change
296
+ // Reset services and paging when query options change.
303
297
  servicesSignal.set([]);
304
298
  pagingMetadataSignal.set({
305
299
  limit: pagingMetadataSignal.get().limit || DEFAULT_QUERY_LIMIT,
306
300
  offset: 0,
307
301
  count: 0,
308
302
  hasNext: false,
303
+ cursor: undefined,
309
304
  });
310
305
  queryOptionsSignal.set(options);
311
306
  },
312
307
  setFilter: (filter) => {
313
- // Update query options with new filter (triggers re-fetch via effectiveFilterSignal)
314
- const currentOptions = queryOptionsSignal.get();
315
- queryOptionsSignal.set({
316
- ...currentOptions,
317
- filter,
318
- });
308
+ queryOptionsSignal.set({ ...queryOptionsSignal.get(), filter });
319
309
  },
320
310
  resetFilter: () => {
321
- // Clear filter from query options (triggers re-fetch)
322
- const currentOptions = queryOptionsSignal.get();
323
311
  queryOptionsSignal.set({
324
- ...currentOptions,
312
+ ...queryOptionsSignal.get(),
325
313
  filter: undefined,
326
314
  });
327
315
  },
328
316
  resetFilterField: (fieldName) => {
329
- // Reset a specific filter field
330
- const currentFilter = queryOptionsSignal.get().filter || {};
331
- const newFilter = { ...currentFilter };
332
- // Remove the specific field
317
+ const newFilter = { ...(queryOptionsSignal.get().filter || {}) };
333
318
  delete newFilter[fieldName];
334
- // Update query options with new filter (triggers re-fetch)
335
- const currentOptions = queryOptionsSignal.get();
336
319
  queryOptionsSignal.set({
337
- ...currentOptions,
320
+ ...queryOptionsSignal.get(),
338
321
  filter: Object.keys(newFilter).length > 0 ? newFilter : undefined,
339
322
  });
340
323
  },
341
324
  },
342
- hasMoreServicesSignal: signalsService.computed(() => {
343
- const paging = pagingMetadataSignal.get();
344
- // Use hasNext flag from pagingMetadata to determine if more services are available
345
- return paging.hasNext ?? false;
346
- }),
325
+ hasMoreServicesSignal: signalsService.computed(() => pagingMetadataSignal.get().hasNext ?? false),
347
326
  };
348
327
  });
349
328
  /**
350
- * Load service list configuration for Server-Side Rendering (SSR).
351
- * This function is designed to be called during the build or server-side rendering phase
352
- * to pre-fetch services data before the React components are hydrated on the client.
353
- * The returned config includes paging metadata with hasNext flag indicating if more services are available.
329
+ * Loads service list configuration for SSR, pre-fetching services and categories.
330
+ * Pass an `availabilityFilter` to query availability-aware services.
354
331
  *
355
- * @param options - Query options following QueryServicesRequest type, or a URL string (to be implemented)
356
- * @returns A promise that resolves to either a success result with the config or an error result
332
+ * @param options - Query options.
333
+ * @returns A success result with the config, or an error result.
357
334
  *
358
335
  * @example
359
336
  * ```ts
360
- * // In an Astro page or API route
361
- * import { loadServiceListServiceInitialData } from '@wix/bookings/services';
362
- *
363
- * // Load with query options
364
- * const serviceListResult = await loadServiceListServiceInitialData({
337
+ * // Plain offset query
338
+ * const result = await loadServiceListServiceInitialData({
365
339
  * appId: '13d21c63-b5ec-5912-8397-c3a5ddb27a97',
366
340
  * pagingMetadata: { limit: 20, offset: 0 },
367
341
  * });
368
342
  *
369
- * if (serviceListResult.type === 'error') {
370
- * console.error('Failed to load services:', serviceListResult.error);
371
- * return Astro.redirect('/500');
372
- * }
373
- *
374
- * const serviceListConfig = serviceListResult.config;
375
- *
376
- * // Check if more services are available
377
- * if (serviceListConfig.options?.pagingMetadata?.hasNext) {
378
- * // More services can be loaded
379
- * }
343
+ * // Availability-aware query (cursor paging + `available` flag)
344
+ * const availabilityResult = await loadServiceListServiceInitialData({
345
+ * availabilityFilter: {
346
+ * localStartDate: '2026-06-01T09:00:00',
347
+ * localEndDate: '2026-06-07T17:00:00',
348
+ * timeZone: 'America/New_York',
349
+ * },
350
+ * pagingMetadata: { limit: 20 },
351
+ * });
380
352
  * ```
381
353
  */
382
354
  export async function loadServiceListServiceInitialData(options) {
383
355
  try {
384
- // Fetch services and categories in parallel; categories failure is non-fatal
356
+ const appId = options.appId ?? BOOKING_APP_ID;
357
+ // Fetch services and categories in parallel; categories failure is non-fatal.
358
+ // queryServices picks the endpoint by the presence of availabilityFilter.
385
359
  const [servicesResult, categoriesResult] = await Promise.all([
386
360
  queryServices({
387
- appId: options.appId,
361
+ appId,
388
362
  filter: options.filter,
389
363
  pagingMetadata: options.pagingMetadata,
390
364
  sort: options.sort,
365
+ availabilityFilter: options.availabilityFilter,
391
366
  }),
392
367
  queryCategories().catch(() => null),
393
368
  ]);
@@ -397,7 +372,10 @@ export async function loadServiceListServiceInitialData(options) {
397
372
  services: servicesResult.services,
398
373
  categories: categoriesResult?.categories ?? [],
399
374
  options: {
400
- ...options,
375
+ appId,
376
+ filter: options.filter,
377
+ sort: options.sort,
378
+ availabilityFilter: options.availabilityFilter,
401
379
  pagingMetadata: servicesResult.pagingMetadata,
402
380
  },
403
381
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/headless-bookings",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "scripts": {
@@ -8,6 +8,8 @@
8
8
  "build": "npm run build:esm && npm run build:cjs",
9
9
  "build:esm": "tsc -p tsconfig.json",
10
10
  "build:cjs": "tsc -p tsconfig.cjs.json",
11
+ "storybook": "storybook dev -p 6006",
12
+ "storybook:build": "storybook build",
11
13
  "test": "vitest",
12
14
  "lint:fix": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,md}\"",
13
15
  "lint:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,md}\"",
@@ -36,6 +38,7 @@
36
38
  "dependencies": {
37
39
  "@wix/auto_sdk_bookings_availability-time-slots": "^1.0.244",
38
40
  "@wix/auto_sdk_bookings_bookings": "^1.0.192",
41
+ "@wix/auto_sdk_bookings_catalog-search": "^1.0.0",
39
42
  "@wix/auto_sdk_bookings_event-time-slots": "^1.0.112",
40
43
  "@wix/auto_sdk_bookings_pricing": "^1.0.87",
41
44
  "@wix/auto_sdk_bookings_services": "^1.0.226",
@@ -43,23 +46,35 @@
43
46
  "@wix/forms": "^1.0.384",
44
47
  "@wix/headless-media": "0.0.25",
45
48
  "@wix/headless-utils": "0.0.12",
49
+ "@wix/sdk-runtime": "^1.0.12",
50
+ "@wix/sdk-types": "^1.17.7",
46
51
  "@wix/services-definitions": "^1.0.1",
47
52
  "@wix/services-manager-react": "^1.0.3",
48
53
  "date-fns": "^4.1.0"
49
54
  },
50
55
  "devDependencies": {
56
+ "@storybook/addon-essentials": "^8.6.0",
57
+ "@storybook/addon-interactions": "^8.6.0",
58
+ "@storybook/addon-links": "^8.6.0",
59
+ "@storybook/blocks": "^8.6.0",
60
+ "@storybook/react": "^8.6.0",
61
+ "@storybook/react-vite": "^8.6.0",
62
+ "@storybook/test": "^8.6.0",
51
63
  "@testing-library/dom": "^10.4.0",
52
64
  "@testing-library/jest-dom": "^6.6.3",
53
65
  "@testing-library/react": "^16.3.0",
54
66
  "@types/node": "^20.9.0",
55
67
  "@types/react": "^18.2.0",
56
68
  "@types/react-dom": "^18.2.0",
69
+ "@vitejs/plugin-react": "^4.3.0",
57
70
  "@vitest/ui": "^3.1.4",
58
71
  "@wix/cjs-esm-build-verifier": "1.0.0",
59
72
  "@wix/headless-components": "0.0.0",
60
73
  "jsdom": "^26.1.0",
61
74
  "prettier": "^3.4.2",
75
+ "storybook": "^8.6.0",
62
76
  "typescript": "^5.8.3",
77
+ "vite": "^6.3.1",
63
78
  "vitest": "^3.1.4"
64
79
  },
65
80
  "peerDependencies": {
@@ -76,5 +91,5 @@
76
91
  "groupId": "com.wixpress.headless-components"
77
92
  }
78
93
  },
79
- "falconPackageHash": "1d7f35a0513a0780cb412d80a034e1ed5b5e3bccbbc1bb20f4ad1481"
94
+ "falconPackageHash": "982db1d1e4f6337441d55feaf920c8b04f62f86edf8adc4b7d268a60"
80
95
  }