@wix/headless-restaurants-olo 0.0.45 → 0.0.47

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.
@@ -381,6 +381,8 @@ interface AddressPickerProps extends Omit<React.ComponentPropsWithoutRef<'div'>,
381
381
  predictionSecondaryTextClassName?: string;
382
382
  /** Optional class name for the prediction description */
383
383
  predictionDescriptionClassName?: string;
384
+ /** Optional class name for the error message */
385
+ errorClassName?: string;
384
386
  /** Minimum input length before showing predictions (default: 2) */
385
387
  minInputLength?: number;
386
388
  /** Children render prop that receives the address picker props */
@@ -392,6 +394,7 @@ interface AddressPickerProps extends Omit<React.ComponentPropsWithoutRef<'div'>,
392
394
  onPredictionSelect: (prediction: AddressPrediction) => Promise<void>;
393
395
  isDelivery: boolean;
394
396
  dispatchType: DispatchType | null;
397
+ error: string | null;
395
398
  }> | React.ReactNode;
396
399
  }
397
400
  export declare const AddressPicker: React.ForwardRefExoticComponent<AddressPickerProps & React.RefAttributes<HTMLDivElement>>;
@@ -441,4 +444,74 @@ interface SaveButtonProps extends Omit<React.ComponentPropsWithoutRef<'button'>,
441
444
  * ```
442
445
  */
443
446
  export declare const SaveButton: React.ForwardRefExoticComponent<SaveButtonProps & React.RefAttributes<HTMLButtonElement>>;
447
+ interface DispatchTypeSelectorProps extends Omit<React.ComponentPropsWithoutRef<'div'>, 'children'> {
448
+ /** Child components that will have access to dispatch type context */
449
+ children: React.ReactNode;
450
+ }
451
+ /**
452
+ * Container component for dispatch type selection (pickup or delivery)
453
+ * Provides context for child components to access dispatch type data
454
+ * Does not render if no dispatch types are available
455
+ *
456
+ * @example
457
+ * ```tsx
458
+ * <FulfillmentDetails.DispatchTypeSelector>
459
+ * <FulfillmentDetails.DispatchTypeOptions emptyState={<div>No options</div>}>
460
+ * <FulfillmentDetails.DispatchTypeOptionRepeater>
461
+ * {({ type, isSelected, selectDispatchType }) => (
462
+ * <button onClick={() => selectDispatchType(type)}>
463
+ * {type} {isSelected && '✓'}
464
+ * </button>
465
+ * )}
466
+ * </FulfillmentDetails.DispatchTypeOptionRepeater>
467
+ * </FulfillmentDetails.DispatchTypeOptions>
468
+ * </FulfillmentDetails.DispatchTypeSelector>
469
+ * ```
470
+ */
471
+ export declare const DispatchTypeSelector: React.ForwardRefExoticComponent<DispatchTypeSelectorProps & React.RefAttributes<HTMLDivElement>>;
472
+ interface DispatchTypeOptionsProps extends Omit<React.ComponentPropsWithoutRef<'div'>, 'children'> {
473
+ /** Child components to render when dispatch types are available */
474
+ children: React.ReactNode;
475
+ /** Optional content to display when no dispatch types are available */
476
+ emptyState?: React.ReactNode;
477
+ }
478
+ /**
479
+ * Container for dispatch type options list with empty state support
480
+ *
481
+ * @example
482
+ * ```tsx
483
+ * <FulfillmentDetails.DispatchTypeOptions emptyState={<div>No options available</div>}>
484
+ * <FulfillmentDetails.DispatchTypeOptionRepeater>
485
+ * {({ type }) => <div>{type}</div>}
486
+ * </FulfillmentDetails.DispatchTypeOptionRepeater>
487
+ * </FulfillmentDetails.DispatchTypeOptions>
488
+ * ```
489
+ */
490
+ export declare const DispatchTypeOptions: React.ForwardRefExoticComponent<DispatchTypeOptionsProps & React.RefAttributes<HTMLDivElement>>;
491
+ interface DispatchTypeOptionRepeaterProps {
492
+ /** Render prop that receives each dispatch type option data */
493
+ children: (props: {
494
+ type: DispatchType;
495
+ isSelected: boolean;
496
+ selectDispatchType: (type: DispatchType) => void;
497
+ }) => React.ReactNode;
498
+ }
499
+ /**
500
+ * Repeater component that renders children for each available dispatch type
501
+ *
502
+ * @example
503
+ * ```tsx
504
+ * <FulfillmentDetails.DispatchTypeOptionRepeater>
505
+ * {({ type, isSelected, selectDispatchType }) => (
506
+ * <button
507
+ * onClick={() => selectDispatchType(type)}
508
+ * data-selected={isSelected}
509
+ * >
510
+ * {type}
511
+ * </button>
512
+ * )}
513
+ * </FulfillmentDetails.DispatchTypeOptionRepeater>
514
+ * ```
515
+ */
516
+ export declare const DispatchTypeOptionRepeater: React.FC<DispatchTypeOptionRepeaterProps>;
444
517
  export {};
@@ -13,6 +13,14 @@ function useTimeSlotContext() {
13
13
  }
14
14
  return context;
15
15
  }
16
+ const DispatchTypeContext = React.createContext(null);
17
+ function useDispatchTypeContext() {
18
+ const context = React.useContext(DispatchTypeContext);
19
+ if (!context) {
20
+ throw new Error('useDispatchTypeContext must be used within a FulfillmentDetails.DispatchTypeSelector component');
21
+ }
22
+ return context;
23
+ }
16
24
  // ========================================
17
25
  // FULFILLMENT DETAILS HEADLESS COMPONENTS
18
26
  // ========================================
@@ -32,6 +40,9 @@ var TestIds;
32
40
  TestIds["addressPickerPredictions"] = "fulfillment-address-picker-predictions";
33
41
  TestIds["addressPickerPrediction"] = "fulfillment-address-picker-prediction";
34
42
  TestIds["saveButton"] = "fulfillment-save-button";
43
+ TestIds["dispatchTypeSelector"] = "fulfillment-dispatch-type-selector";
44
+ TestIds["dispatchTypeOptions"] = "fulfillment-dispatch-type-options";
45
+ TestIds["dispatchTypeOption"] = "fulfillment-dispatch-type-option";
35
46
  })(TestIds || (TestIds = {}));
36
47
  /**
37
48
  * Root headless component for Fulfillment Details
@@ -482,17 +493,14 @@ const AddressPickerInput = React.memo(({ inputValue, onInputChange, predictions,
482
493
  prevProps.onPredictionSelect === nextProps.onPredictionSelect &&
483
494
  !predictionsChanged);
484
495
  });
485
- export const AddressPicker = React.forwardRef(({ children, asChild, className, placeholder = 'Enter delivery address', inputClassName, predictionsClassName, predictionClassName, predictionMainTextClassName, predictionSecondaryTextClassName, predictionDescriptionClassName, minInputLength = 2, ...rest }, ref) => {
486
- return (_jsx(CoreFulfillmentDetails.AddressPicker, { children: ({ inputValue, onInputChange, predictions, isLoading, onPredictionSelect, isDelivery, dispatchType, }) => {
487
- // Don't render anything if not delivery
488
- if (!isDelivery) {
489
- return null;
490
- }
496
+ export const AddressPicker = React.forwardRef(({ children, asChild, className, placeholder = 'Enter delivery address', inputClassName, predictionsClassName, predictionClassName, predictionMainTextClassName, predictionSecondaryTextClassName, predictionDescriptionClassName, errorClassName, minInputLength = 2, ...rest }, ref) => {
497
+ return (_jsx(CoreFulfillmentDetails.AddressPicker, { children: ({ inputValue, onInputChange, predictions, isLoading, onPredictionSelect, isDelivery, dispatchType, error, }) => {
491
498
  // Memoize defaultContent to prevent recreation when only predictions change
492
499
  // This ensures the input element maintains focus
493
500
  // Note: predictions is passed to AddressPickerInput which handles it internally,
494
501
  // so we don't need to recreate defaultContent when only predictions change
495
- const defaultContent = useMemo(() => (_jsx("div", { ref: ref, className: className, "data-testid": TestIds.addressPicker, ...rest, children: _jsx(AddressPickerInput, { inputValue: inputValue, onInputChange: onInputChange, predictions: predictions, isLoading: isLoading, placeholder: placeholder, inputClassName: inputClassName, predictionsClassName: predictionsClassName, predictionClassName: predictionClassName, predictionMainTextClassName: predictionMainTextClassName, predictionSecondaryTextClassName: predictionSecondaryTextClassName, predictionDescriptionClassName: predictionDescriptionClassName, onPredictionSelect: onPredictionSelect }) })),
502
+ // IMPORTANT: useMemo must be called before any conditional returns to follow Rules of Hooks
503
+ const defaultContent = useMemo(() => (_jsxs("div", { ref: ref, className: className, "data-testid": TestIds.addressPicker, ...rest, children: [_jsx(AddressPickerInput, { inputValue: inputValue, onInputChange: onInputChange, predictions: predictions, isLoading: isLoading, placeholder: placeholder, inputClassName: inputClassName, predictionsClassName: predictionsClassName, predictionClassName: predictionClassName, predictionMainTextClassName: predictionMainTextClassName, predictionSecondaryTextClassName: predictionSecondaryTextClassName, predictionDescriptionClassName: predictionDescriptionClassName, onPredictionSelect: onPredictionSelect }), error && (_jsx("div", { className: errorClassName, "data-testid": `${TestIds.addressPicker}-error`, children: error }))] })),
496
504
  // Include predictions in deps so AddressPickerInput receives updates
497
505
  // AddressPickerInput is memoized and will only re-render when necessary
498
506
  [
@@ -508,9 +516,15 @@ export const AddressPicker = React.forwardRef(({ children, asChild, className, p
508
516
  predictionMainTextClassName,
509
517
  predictionSecondaryTextClassName,
510
518
  predictionDescriptionClassName,
519
+ errorClassName,
520
+ error,
511
521
  className,
512
522
  // Note: rest and ref are intentionally excluded as they should be stable
513
523
  ]);
524
+ // Don't render anything if not delivery
525
+ if (!isDelivery) {
526
+ return null;
527
+ }
514
528
  return (_jsx(AsChildSlot, { asChild: asChild, customElement: children, customElementProps: {
515
529
  inputValue,
516
530
  onInputChange,
@@ -519,6 +533,7 @@ export const AddressPicker = React.forwardRef(({ children, asChild, className, p
519
533
  onPredictionSelect,
520
534
  isDelivery,
521
535
  dispatchType,
536
+ error,
522
537
  }, content: defaultContent, children: defaultContent }));
523
538
  } }));
524
539
  });
@@ -562,3 +577,89 @@ export const SaveButton = React.forwardRef(({ children, asChild, className, savi
562
577
  } }));
563
578
  });
564
579
  SaveButton.displayName = 'FulfillmentDetails.SaveButton';
580
+ /**
581
+ * Container component for dispatch type selection (pickup or delivery)
582
+ * Provides context for child components to access dispatch type data
583
+ * Does not render if no dispatch types are available
584
+ *
585
+ * @example
586
+ * ```tsx
587
+ * <FulfillmentDetails.DispatchTypeSelector>
588
+ * <FulfillmentDetails.DispatchTypeOptions emptyState={<div>No options</div>}>
589
+ * <FulfillmentDetails.DispatchTypeOptionRepeater>
590
+ * {({ type, isSelected, selectDispatchType }) => (
591
+ * <button onClick={() => selectDispatchType(type)}>
592
+ * {type} {isSelected && '✓'}
593
+ * </button>
594
+ * )}
595
+ * </FulfillmentDetails.DispatchTypeOptionRepeater>
596
+ * </FulfillmentDetails.DispatchTypeOptions>
597
+ * </FulfillmentDetails.DispatchTypeSelector>
598
+ * ```
599
+ */
600
+ export const DispatchTypeSelector = React.forwardRef(({ children, className, ...rest }, ref) => {
601
+ return (_jsx(CoreFulfillmentDetails.DispatchTypeSelector, { children: ({ availableTypes, selectedType, selectDispatchType, hasTypes }) => {
602
+ if (!hasTypes)
603
+ return null;
604
+ const contextValue = {
605
+ availableTypes: availableTypes.map(({ type, isSelected }) => ({
606
+ type,
607
+ isSelected,
608
+ })),
609
+ selectedType,
610
+ selectDispatchType,
611
+ hasTypes,
612
+ };
613
+ return (_jsx(DispatchTypeContext.Provider, { value: contextValue, children: _jsx("div", { ref: ref, className: className, "data-testid": TestIds.dispatchTypeSelector, ...rest, children: children }) }));
614
+ } }));
615
+ });
616
+ DispatchTypeSelector.displayName = 'FulfillmentDetails.DispatchTypeSelector';
617
+ /**
618
+ * Container for dispatch type options list with empty state support
619
+ *
620
+ * @example
621
+ * ```tsx
622
+ * <FulfillmentDetails.DispatchTypeOptions emptyState={<div>No options available</div>}>
623
+ * <FulfillmentDetails.DispatchTypeOptionRepeater>
624
+ * {({ type }) => <div>{type}</div>}
625
+ * </FulfillmentDetails.DispatchTypeOptionRepeater>
626
+ * </FulfillmentDetails.DispatchTypeOptions>
627
+ * ```
628
+ */
629
+ export const DispatchTypeOptions = React.forwardRef(({ children, emptyState, className, ...rest }, ref) => {
630
+ const { hasTypes } = useDispatchTypeContext();
631
+ if (!hasTypes) {
632
+ return emptyState || null;
633
+ }
634
+ return (_jsx("div", { ref: ref, className: className, "data-testid": TestIds.dispatchTypeOptions, ...rest, children: children }));
635
+ });
636
+ DispatchTypeOptions.displayName = 'FulfillmentDetails.DispatchTypeOptions';
637
+ /**
638
+ * Repeater component that renders children for each available dispatch type
639
+ *
640
+ * @example
641
+ * ```tsx
642
+ * <FulfillmentDetails.DispatchTypeOptionRepeater>
643
+ * {({ type, isSelected, selectDispatchType }) => (
644
+ * <button
645
+ * onClick={() => selectDispatchType(type)}
646
+ * data-selected={isSelected}
647
+ * >
648
+ * {type}
649
+ * </button>
650
+ * )}
651
+ * </FulfillmentDetails.DispatchTypeOptionRepeater>
652
+ * ```
653
+ */
654
+ export const DispatchTypeOptionRepeater = ({ children }) => {
655
+ const { availableTypes, selectDispatchType, hasTypes } = useDispatchTypeContext();
656
+ if (!hasTypes)
657
+ return null;
658
+ return (_jsx(_Fragment, { children: availableTypes.map(({ type, isSelected }) => (_jsx(React.Fragment, { children: children({
659
+ type,
660
+ isSelected,
661
+ selectDispatchType,
662
+ }) }, type))) }));
663
+ };
664
+ DispatchTypeOptionRepeater.displayName =
665
+ 'FulfillmentDetails.DispatchTypeOptionRepeater';
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { type FulfillmentDetailsServiceConfig } from '../../services/fulfillment-details-service.js';
3
3
  import { DispatchType, FulfillmentTypeEnum, TimeSlot } from '../../types/fulfillments-types.js';
4
+ import { StreetAddress } from '../../types/operation.js';
4
5
  export interface RootProps {
5
6
  children: React.ReactNode;
6
7
  fulfillmentDetailsServiceConfig?: FulfillmentDetailsServiceConfig;
@@ -97,6 +98,8 @@ export interface DeliveryAddressFields {
97
98
  country?: string;
98
99
  /** Formatted full address string */
99
100
  formattedAddress?: string;
101
+ /** Street address */
102
+ streetAddress?: StreetAddress;
100
103
  }
101
104
  export interface DeliveryAddressProps {
102
105
  children: (props: {
@@ -168,6 +171,8 @@ export interface AddressPickerProps {
168
171
  isDelivery: boolean;
169
172
  /** Current dispatch type */
170
173
  dispatchType: DispatchType | null;
174
+ /** Error message from the service */
175
+ error: string | null;
171
176
  }) => React.ReactNode;
172
177
  }
173
178
  /**
@@ -237,3 +242,38 @@ export interface SaveButtonProps {
237
242
  * ```
238
243
  */
239
244
  export declare const SaveButton: React.FC<SaveButtonProps>;
245
+ export interface DispatchTypeSelectorProps {
246
+ children: (props: {
247
+ availableTypes: Array<{
248
+ type: DispatchType;
249
+ isSelected: boolean;
250
+ }>;
251
+ selectedType: DispatchType | null;
252
+ selectDispatchType: (type: DispatchType) => void;
253
+ hasTypes: boolean;
254
+ }) => React.ReactNode;
255
+ }
256
+ /**
257
+ * Component that provides dispatch type selection (pickup or delivery)
258
+ * Uses FulfillmentDetailsService to manage dispatch type state
259
+ *
260
+ * @example
261
+ * ```tsx
262
+ * <CoreFulfillmentDetails.DispatchTypeSelector>
263
+ * {({ availableTypes, selectedType, selectDispatchType }) => (
264
+ * <div>
265
+ * {availableTypes.map(({ type, isSelected }) => (
266
+ * <button
267
+ * key={type}
268
+ * onClick={() => selectDispatchType(type)}
269
+ * disabled={isSelected}
270
+ * >
271
+ * {type} {isSelected && '(selected)'}
272
+ * </button>
273
+ * ))}
274
+ * </div>
275
+ * )}
276
+ * </CoreFulfillmentDetails.DispatchTypeSelector>
277
+ * ```
278
+ */
279
+ export declare const DispatchTypeSelector: React.FC<DispatchTypeSelectorProps>;
@@ -22,12 +22,19 @@ export const Root = ({ children, fulfillmentDetailsServiceConfig, }) => {
22
22
  if (!fulfillmentDetailsServiceConfig &&
23
23
  oloSettingsService.operation?.get()) {
24
24
  const operation = oloSettingsService.operation.get();
25
+ const availableDispatchTypes = fulfillmentsService.availableTypes?.get();
25
26
  if (operation) {
26
- const loadedConfig = loadFulfillmentDetailsServiceConfig(operation);
27
+ const loadedConfig = loadFulfillmentDetailsServiceConfig(operation, {
28
+ availableDispatchTypes,
29
+ });
27
30
  setConfig(loadedConfig);
28
31
  }
29
32
  }
30
- }, [fulfillmentDetailsServiceConfig, oloSettingsService.operation]);
33
+ }, [
34
+ fulfillmentDetailsServiceConfig,
35
+ oloSettingsService.operation,
36
+ fulfillmentsService.availableTypes,
37
+ ]);
31
38
  if (!config || isFulfillmentsLoading) {
32
39
  return null;
33
40
  }
@@ -41,7 +48,7 @@ export const Root = ({ children, fulfillmentDetailsServiceConfig, }) => {
41
48
  export const AddressName = ({ children }) => {
42
49
  const fulfillmentDetailsService = useService(FulfillmentDetailsServiceDefinition);
43
50
  const selectedAddress = fulfillmentDetailsService?.address?.get();
44
- console.log('selectedAddress', selectedAddress);
51
+ const error = fulfillmentDetailsService.error?.get() ?? null;
45
52
  const dispatchType = fulfillmentDetailsService.dispatchType?.get();
46
53
  const addressName = selectedAddress?.formatted;
47
54
  const fullAddress = selectedAddress
@@ -58,7 +65,9 @@ export const AddressName = ({ children }) => {
58
65
  .filter(Boolean)
59
66
  .join(', ')
60
67
  : null;
61
- const hasAddress = Boolean(selectedAddress);
68
+ console.log('error', error);
69
+ console.log('selectedAddress', selectedAddress);
70
+ const hasAddress = Boolean(selectedAddress && !error);
62
71
  return children({
63
72
  addressName,
64
73
  fullAddress,
@@ -209,6 +218,7 @@ export const DeliveryAddress = ({ children, }) => {
209
218
  : null;
210
219
  const hasAddress = Boolean(currentAddress);
211
220
  const onAddressChange = (newAddress) => {
221
+ console.log('onAddressChange', newAddress);
212
222
  fulfillmentDetailsService.setAddress(newAddress);
213
223
  };
214
224
  const onAddressClear = () => {
@@ -379,7 +389,9 @@ export const AddressPicker = ({ children }) => {
379
389
  subdivision: address.subdivision || '',
380
390
  postalCode: address.postalCode || '',
381
391
  country: address.country || '',
382
- formattedAddress,
392
+ // @ts-expect-error
393
+ formattedAddress: address.formattedAddress || '',
394
+ streetAddress: address.streetAddress,
383
395
  };
384
396
  // TODO - check first available time slot & available dates
385
397
  fulfillmentDetailsService.setAddress(addressFields);
@@ -402,6 +414,7 @@ export const AddressPicker = ({ children }) => {
402
414
  setIsLoading(false);
403
415
  }
404
416
  };
417
+ const error = fulfillmentDetailsService.error?.get() ?? null;
405
418
  return children({
406
419
  inputValue,
407
420
  onInputChange,
@@ -410,6 +423,7 @@ export const AddressPicker = ({ children }) => {
410
423
  onPredictionSelect,
411
424
  isDelivery,
412
425
  dispatchType,
426
+ error,
413
427
  });
414
428
  };
415
429
  /**
@@ -461,3 +475,48 @@ export const SaveButton = ({ children }) => {
461
475
  dispatchType,
462
476
  });
463
477
  };
478
+ /**
479
+ * Component that provides dispatch type selection (pickup or delivery)
480
+ * Uses FulfillmentDetailsService to manage dispatch type state
481
+ *
482
+ * @example
483
+ * ```tsx
484
+ * <CoreFulfillmentDetails.DispatchTypeSelector>
485
+ * {({ availableTypes, selectedType, selectDispatchType }) => (
486
+ * <div>
487
+ * {availableTypes.map(({ type, isSelected }) => (
488
+ * <button
489
+ * key={type}
490
+ * onClick={() => selectDispatchType(type)}
491
+ * disabled={isSelected}
492
+ * >
493
+ * {type} {isSelected && '(selected)'}
494
+ * </button>
495
+ * ))}
496
+ * </div>
497
+ * )}
498
+ * </CoreFulfillmentDetails.DispatchTypeSelector>
499
+ * ```
500
+ */
501
+ export const DispatchTypeSelector = ({ children, }) => {
502
+ const fulfillmentDetailsService = useService(FulfillmentDetailsServiceDefinition);
503
+ const selectedType = fulfillmentDetailsService.dispatchType?.get() ?? null;
504
+ const availableDispatchTypes = fulfillmentDetailsService.availableDispatchTypes?.get() ?? [];
505
+ // Map available dispatch types to the format needed
506
+ const availableTypes = availableDispatchTypes.map((type) => ({
507
+ type,
508
+ isSelected: type === selectedType,
509
+ }));
510
+ console.log('availableTypes', availableTypes);
511
+ const selectDispatchType = (type) => {
512
+ console.log('selectDispatchType', type);
513
+ fulfillmentDetailsService.setDispatchType(type);
514
+ };
515
+ const hasTypes = availableTypes.length > 0;
516
+ return children({
517
+ availableTypes,
518
+ selectedType,
519
+ selectDispatchType,
520
+ hasTypes,
521
+ });
522
+ };
@@ -36,6 +36,8 @@ export interface FulfillmentDetailsServiceAPI {
36
36
  availableTimeSlotsForDate: Signal<TimeSlot[]>;
37
37
  /** Available time slots filtered by current dispatch type */
38
38
  availableTimeSlots: ReadOnlySignal<TimeSlot[]>;
39
+ /** Available dispatch types (PICKUP and/or DELIVERY) based on available time slots */
40
+ availableDispatchTypes: ReadOnlySignal<DispatchType[]>;
39
41
  /** Selected scheduling type (ASAP or PREORDER) */
40
42
  schedulingType: Signal<FulfillmentTypeEnum | undefined>;
41
43
  /**
@@ -101,6 +103,8 @@ export interface FulfillmentDetailsServiceConfig {
101
103
  initialAddress?: Address;
102
104
  /** Number of days ahead to calculate available dates (default: 30) */
103
105
  daysAhead?: number;
106
+ /** Available dispatch types from fulfillments service */
107
+ availableDispatchTypes?: DispatchType[];
104
108
  }
105
109
  export declare const FulfillmentDetailsServiceDefinition: string & {
106
110
  __api: FulfillmentDetailsServiceAPI;
@@ -124,4 +128,5 @@ export declare function loadFulfillmentDetailsServiceConfig(operation: Operation
124
128
  initialDispatchType?: DispatchType;
125
129
  initialAddress?: Address;
126
130
  daysAhead?: number;
131
+ availableDispatchTypes?: DispatchType[];
127
132
  }): FulfillmentDetailsServiceConfig;
@@ -44,6 +44,24 @@ export const FulfillmentDetailsService = implementService.withConfig()(Fulfillme
44
44
  const availableTimeSlotsForDate = signalsService.signal([]);
45
45
  const schedulingType = signalsService.signal(fulfillmentsService.schedulingType?.get());
46
46
  // ========================================
47
+ // Sync selectedFulfillment from FulfillmentsService to dispatchType
48
+ // ========================================
49
+ // Sync dispatchType when selectedFulfillment changes in FulfillmentsService
50
+ // Use peek() to read dispatchType without creating a dependency, so the effect
51
+ // only runs when selectedFulfillment changes, not when dispatchType changes
52
+ signalsService.effect(() => {
53
+ const selectedFulfillment = fulfillmentsService.selectedFulfillment?.get();
54
+ const newDispatchType = selectedFulfillment?.type
55
+ ? selectedFulfillment.type
56
+ : null;
57
+ // Use peek() to read current value without subscribing to changes
58
+ const currentDispatchType = dispatchType.peek();
59
+ // Only update if different to avoid unnecessary updates and circular updates
60
+ if (currentDispatchType !== newDispatchType) {
61
+ dispatchType.set(newDispatchType);
62
+ }
63
+ });
64
+ // ========================================
47
65
  // Helper Functions
48
66
  // ========================================
49
67
  /**
@@ -115,8 +133,78 @@ export const FulfillmentDetailsService = implementService.withConfig()(Fulfillme
115
133
  asapTimeSlot.set(slot);
116
134
  }
117
135
  };
136
+ const convertAddressInputToAddress = (address) => {
137
+ if (!address)
138
+ return null;
139
+ const {
140
+ // @ts-expect-error
141
+ addressLine,
142
+ // @ts-expect-error
143
+ streetAddress,
144
+ // @ts-expect-error
145
+ formattedAddress,
146
+ // @ts-expect-error
147
+ location, ...rest } = address;
148
+ return {
149
+ streetAddress: streetAddress,
150
+ addressLine1: formattedAddress,
151
+ ...rest,
152
+ };
153
+ };
154
+ const initAddress = async (addr) => {
155
+ isLoading.set(true);
156
+ error.set(null);
157
+ try {
158
+ const commonAddress = convertAddressInputToAddress(addr);
159
+ // 1. Get first available time slot per operation with the selected address
160
+ const firstTimeSlotsResponse = await operationsSDK.calculateFirstAvailableTimeSlotPerFulfillmentType(operation.id, {
161
+ deliveryAddress: commonAddress,
162
+ });
163
+ // Find the time slots for this operation
164
+ const deliveryTimeSlot = firstTimeSlotsResponse.timeslotsPerFulfillmentType?.find((ts) => ts.fulfilmentType === DispatchType.DELIVERY);
165
+ if (!deliveryTimeSlot?.timeSlot) {
166
+ isLoading.set(false);
167
+ // TODO - localize this message
168
+ error.set('No available time slots found for the selected address');
169
+ return;
170
+ }
171
+ const firstTimeSlot = processTimeSlots([deliveryTimeSlot], DispatchType.DELIVERY)[0];
172
+ // 2. Fetch time slots for the selected address and date (using firstTimeSlot.startTime)
173
+ const targetDate = firstTimeSlot.startTime;
174
+ if (!targetDate) {
175
+ isLoading.set(false);
176
+ return;
177
+ }
178
+ await calculateAvailableTimeSlotsForDateFn(targetDate);
179
+ // Set the date and time slot
180
+ date.set(targetDate);
181
+ setTimeslot(firstTimeSlot);
182
+ // 3. Fetch available dates for the selected address
183
+ const startDate = new Date();
184
+ startDate.setHours(0, 0, 0, 0);
185
+ const endDate = new Date();
186
+ endDate.setDate(endDate.getDate() + daysAhead);
187
+ endDate.setHours(23, 59, 59, 999);
188
+ await calculateAvailableDatesInRangeFn(startDate, endDate);
189
+ }
190
+ catch (err) {
191
+ const errorMessage = err instanceof Error ? err.message : 'Failed to initialize address';
192
+ error.set(errorMessage);
193
+ console.error('Error initializing address:', err);
194
+ }
195
+ finally {
196
+ isLoading.set(false);
197
+ }
198
+ };
118
199
  const setAddress = (addr) => {
119
200
  address.set(addr);
201
+ // Initialize address: fetch first time slot, time slots for date, and available dates
202
+ if (addr) {
203
+ initAddress(addr).catch((err) => {
204
+ console.error('Error initializing address:', err);
205
+ error.set('Failed to initialize address');
206
+ });
207
+ }
120
208
  };
121
209
  const setDate = async (selectedDate) => {
122
210
  date.set(selectedDate);
@@ -129,7 +217,6 @@ export const FulfillmentDetailsService = implementService.withConfig()(Fulfillme
129
217
  }
130
218
  };
131
219
  const setDispatchType = (type) => {
132
- console.log('setDispatchType', type);
133
220
  dispatchType.set(type);
134
221
  // Re-filter available time slots based on new dispatch type
135
222
  const currentDate = date.get();
@@ -244,6 +331,7 @@ export const FulfillmentDetailsService = implementService.withConfig()(Fulfillme
244
331
  });
245
332
  const { timeslotsPerFulfillmentType } = response;
246
333
  const currentDispatchType = dispatchType.get();
334
+ // Process slots filtered by current dispatch type
247
335
  const slots = processTimeSlots(timeslotsPerFulfillmentType, currentDispatchType).reverse();
248
336
  availableTimeSlotsForDate.set(slots);
249
337
  // Auto-select first available slot if none selected
@@ -308,6 +396,12 @@ export const FulfillmentDetailsService = implementService.withConfig()(Fulfillme
308
396
  }
309
397
  return allTimeSlots.filter((slot) => slot.dispatchType === currentDispatchType);
310
398
  });
399
+ /**
400
+ * Available dispatch types from config
401
+ */
402
+ const availableDispatchTypes = signalsService.computed(() => {
403
+ return config.availableDispatchTypes ?? [];
404
+ });
311
405
  // ========================================
312
406
  // Return Service API
313
407
  // ========================================
@@ -323,6 +417,7 @@ export const FulfillmentDetailsService = implementService.withConfig()(Fulfillme
323
417
  availableDates,
324
418
  availableTimeSlotsForDate,
325
419
  availableTimeSlots,
420
+ availableDispatchTypes,
326
421
  schedulingType,
327
422
  // Actions
328
423
  getTimeslot,
@@ -355,5 +450,6 @@ export function loadFulfillmentDetailsServiceConfig(operation, options) {
355
450
  initialDispatchType: options?.initialDispatchType,
356
451
  initialAddress: options?.initialAddress,
357
452
  daysAhead: options?.daysAhead ?? 30,
453
+ availableDispatchTypes: options?.availableDispatchTypes,
358
454
  };
359
455
  }
@@ -381,6 +381,8 @@ interface AddressPickerProps extends Omit<React.ComponentPropsWithoutRef<'div'>,
381
381
  predictionSecondaryTextClassName?: string;
382
382
  /** Optional class name for the prediction description */
383
383
  predictionDescriptionClassName?: string;
384
+ /** Optional class name for the error message */
385
+ errorClassName?: string;
384
386
  /** Minimum input length before showing predictions (default: 2) */
385
387
  minInputLength?: number;
386
388
  /** Children render prop that receives the address picker props */
@@ -392,6 +394,7 @@ interface AddressPickerProps extends Omit<React.ComponentPropsWithoutRef<'div'>,
392
394
  onPredictionSelect: (prediction: AddressPrediction) => Promise<void>;
393
395
  isDelivery: boolean;
394
396
  dispatchType: DispatchType | null;
397
+ error: string | null;
395
398
  }> | React.ReactNode;
396
399
  }
397
400
  export declare const AddressPicker: React.ForwardRefExoticComponent<AddressPickerProps & React.RefAttributes<HTMLDivElement>>;
@@ -441,4 +444,74 @@ interface SaveButtonProps extends Omit<React.ComponentPropsWithoutRef<'button'>,
441
444
  * ```
442
445
  */
443
446
  export declare const SaveButton: React.ForwardRefExoticComponent<SaveButtonProps & React.RefAttributes<HTMLButtonElement>>;
447
+ interface DispatchTypeSelectorProps extends Omit<React.ComponentPropsWithoutRef<'div'>, 'children'> {
448
+ /** Child components that will have access to dispatch type context */
449
+ children: React.ReactNode;
450
+ }
451
+ /**
452
+ * Container component for dispatch type selection (pickup or delivery)
453
+ * Provides context for child components to access dispatch type data
454
+ * Does not render if no dispatch types are available
455
+ *
456
+ * @example
457
+ * ```tsx
458
+ * <FulfillmentDetails.DispatchTypeSelector>
459
+ * <FulfillmentDetails.DispatchTypeOptions emptyState={<div>No options</div>}>
460
+ * <FulfillmentDetails.DispatchTypeOptionRepeater>
461
+ * {({ type, isSelected, selectDispatchType }) => (
462
+ * <button onClick={() => selectDispatchType(type)}>
463
+ * {type} {isSelected && '✓'}
464
+ * </button>
465
+ * )}
466
+ * </FulfillmentDetails.DispatchTypeOptionRepeater>
467
+ * </FulfillmentDetails.DispatchTypeOptions>
468
+ * </FulfillmentDetails.DispatchTypeSelector>
469
+ * ```
470
+ */
471
+ export declare const DispatchTypeSelector: React.ForwardRefExoticComponent<DispatchTypeSelectorProps & React.RefAttributes<HTMLDivElement>>;
472
+ interface DispatchTypeOptionsProps extends Omit<React.ComponentPropsWithoutRef<'div'>, 'children'> {
473
+ /** Child components to render when dispatch types are available */
474
+ children: React.ReactNode;
475
+ /** Optional content to display when no dispatch types are available */
476
+ emptyState?: React.ReactNode;
477
+ }
478
+ /**
479
+ * Container for dispatch type options list with empty state support
480
+ *
481
+ * @example
482
+ * ```tsx
483
+ * <FulfillmentDetails.DispatchTypeOptions emptyState={<div>No options available</div>}>
484
+ * <FulfillmentDetails.DispatchTypeOptionRepeater>
485
+ * {({ type }) => <div>{type}</div>}
486
+ * </FulfillmentDetails.DispatchTypeOptionRepeater>
487
+ * </FulfillmentDetails.DispatchTypeOptions>
488
+ * ```
489
+ */
490
+ export declare const DispatchTypeOptions: React.ForwardRefExoticComponent<DispatchTypeOptionsProps & React.RefAttributes<HTMLDivElement>>;
491
+ interface DispatchTypeOptionRepeaterProps {
492
+ /** Render prop that receives each dispatch type option data */
493
+ children: (props: {
494
+ type: DispatchType;
495
+ isSelected: boolean;
496
+ selectDispatchType: (type: DispatchType) => void;
497
+ }) => React.ReactNode;
498
+ }
499
+ /**
500
+ * Repeater component that renders children for each available dispatch type
501
+ *
502
+ * @example
503
+ * ```tsx
504
+ * <FulfillmentDetails.DispatchTypeOptionRepeater>
505
+ * {({ type, isSelected, selectDispatchType }) => (
506
+ * <button
507
+ * onClick={() => selectDispatchType(type)}
508
+ * data-selected={isSelected}
509
+ * >
510
+ * {type}
511
+ * </button>
512
+ * )}
513
+ * </FulfillmentDetails.DispatchTypeOptionRepeater>
514
+ * ```
515
+ */
516
+ export declare const DispatchTypeOptionRepeater: React.FC<DispatchTypeOptionRepeaterProps>;
444
517
  export {};
@@ -13,6 +13,14 @@ function useTimeSlotContext() {
13
13
  }
14
14
  return context;
15
15
  }
16
+ const DispatchTypeContext = React.createContext(null);
17
+ function useDispatchTypeContext() {
18
+ const context = React.useContext(DispatchTypeContext);
19
+ if (!context) {
20
+ throw new Error('useDispatchTypeContext must be used within a FulfillmentDetails.DispatchTypeSelector component');
21
+ }
22
+ return context;
23
+ }
16
24
  // ========================================
17
25
  // FULFILLMENT DETAILS HEADLESS COMPONENTS
18
26
  // ========================================
@@ -32,6 +40,9 @@ var TestIds;
32
40
  TestIds["addressPickerPredictions"] = "fulfillment-address-picker-predictions";
33
41
  TestIds["addressPickerPrediction"] = "fulfillment-address-picker-prediction";
34
42
  TestIds["saveButton"] = "fulfillment-save-button";
43
+ TestIds["dispatchTypeSelector"] = "fulfillment-dispatch-type-selector";
44
+ TestIds["dispatchTypeOptions"] = "fulfillment-dispatch-type-options";
45
+ TestIds["dispatchTypeOption"] = "fulfillment-dispatch-type-option";
35
46
  })(TestIds || (TestIds = {}));
36
47
  /**
37
48
  * Root headless component for Fulfillment Details
@@ -482,17 +493,14 @@ const AddressPickerInput = React.memo(({ inputValue, onInputChange, predictions,
482
493
  prevProps.onPredictionSelect === nextProps.onPredictionSelect &&
483
494
  !predictionsChanged);
484
495
  });
485
- export const AddressPicker = React.forwardRef(({ children, asChild, className, placeholder = 'Enter delivery address', inputClassName, predictionsClassName, predictionClassName, predictionMainTextClassName, predictionSecondaryTextClassName, predictionDescriptionClassName, minInputLength = 2, ...rest }, ref) => {
486
- return (_jsx(CoreFulfillmentDetails.AddressPicker, { children: ({ inputValue, onInputChange, predictions, isLoading, onPredictionSelect, isDelivery, dispatchType, }) => {
487
- // Don't render anything if not delivery
488
- if (!isDelivery) {
489
- return null;
490
- }
496
+ export const AddressPicker = React.forwardRef(({ children, asChild, className, placeholder = 'Enter delivery address', inputClassName, predictionsClassName, predictionClassName, predictionMainTextClassName, predictionSecondaryTextClassName, predictionDescriptionClassName, errorClassName, minInputLength = 2, ...rest }, ref) => {
497
+ return (_jsx(CoreFulfillmentDetails.AddressPicker, { children: ({ inputValue, onInputChange, predictions, isLoading, onPredictionSelect, isDelivery, dispatchType, error, }) => {
491
498
  // Memoize defaultContent to prevent recreation when only predictions change
492
499
  // This ensures the input element maintains focus
493
500
  // Note: predictions is passed to AddressPickerInput which handles it internally,
494
501
  // so we don't need to recreate defaultContent when only predictions change
495
- const defaultContent = useMemo(() => (_jsx("div", { ref: ref, className: className, "data-testid": TestIds.addressPicker, ...rest, children: _jsx(AddressPickerInput, { inputValue: inputValue, onInputChange: onInputChange, predictions: predictions, isLoading: isLoading, placeholder: placeholder, inputClassName: inputClassName, predictionsClassName: predictionsClassName, predictionClassName: predictionClassName, predictionMainTextClassName: predictionMainTextClassName, predictionSecondaryTextClassName: predictionSecondaryTextClassName, predictionDescriptionClassName: predictionDescriptionClassName, onPredictionSelect: onPredictionSelect }) })),
502
+ // IMPORTANT: useMemo must be called before any conditional returns to follow Rules of Hooks
503
+ const defaultContent = useMemo(() => (_jsxs("div", { ref: ref, className: className, "data-testid": TestIds.addressPicker, ...rest, children: [_jsx(AddressPickerInput, { inputValue: inputValue, onInputChange: onInputChange, predictions: predictions, isLoading: isLoading, placeholder: placeholder, inputClassName: inputClassName, predictionsClassName: predictionsClassName, predictionClassName: predictionClassName, predictionMainTextClassName: predictionMainTextClassName, predictionSecondaryTextClassName: predictionSecondaryTextClassName, predictionDescriptionClassName: predictionDescriptionClassName, onPredictionSelect: onPredictionSelect }), error && (_jsx("div", { className: errorClassName, "data-testid": `${TestIds.addressPicker}-error`, children: error }))] })),
496
504
  // Include predictions in deps so AddressPickerInput receives updates
497
505
  // AddressPickerInput is memoized and will only re-render when necessary
498
506
  [
@@ -508,9 +516,15 @@ export const AddressPicker = React.forwardRef(({ children, asChild, className, p
508
516
  predictionMainTextClassName,
509
517
  predictionSecondaryTextClassName,
510
518
  predictionDescriptionClassName,
519
+ errorClassName,
520
+ error,
511
521
  className,
512
522
  // Note: rest and ref are intentionally excluded as they should be stable
513
523
  ]);
524
+ // Don't render anything if not delivery
525
+ if (!isDelivery) {
526
+ return null;
527
+ }
514
528
  return (_jsx(AsChildSlot, { asChild: asChild, customElement: children, customElementProps: {
515
529
  inputValue,
516
530
  onInputChange,
@@ -519,6 +533,7 @@ export const AddressPicker = React.forwardRef(({ children, asChild, className, p
519
533
  onPredictionSelect,
520
534
  isDelivery,
521
535
  dispatchType,
536
+ error,
522
537
  }, content: defaultContent, children: defaultContent }));
523
538
  } }));
524
539
  });
@@ -562,3 +577,89 @@ export const SaveButton = React.forwardRef(({ children, asChild, className, savi
562
577
  } }));
563
578
  });
564
579
  SaveButton.displayName = 'FulfillmentDetails.SaveButton';
580
+ /**
581
+ * Container component for dispatch type selection (pickup or delivery)
582
+ * Provides context for child components to access dispatch type data
583
+ * Does not render if no dispatch types are available
584
+ *
585
+ * @example
586
+ * ```tsx
587
+ * <FulfillmentDetails.DispatchTypeSelector>
588
+ * <FulfillmentDetails.DispatchTypeOptions emptyState={<div>No options</div>}>
589
+ * <FulfillmentDetails.DispatchTypeOptionRepeater>
590
+ * {({ type, isSelected, selectDispatchType }) => (
591
+ * <button onClick={() => selectDispatchType(type)}>
592
+ * {type} {isSelected && '✓'}
593
+ * </button>
594
+ * )}
595
+ * </FulfillmentDetails.DispatchTypeOptionRepeater>
596
+ * </FulfillmentDetails.DispatchTypeOptions>
597
+ * </FulfillmentDetails.DispatchTypeSelector>
598
+ * ```
599
+ */
600
+ export const DispatchTypeSelector = React.forwardRef(({ children, className, ...rest }, ref) => {
601
+ return (_jsx(CoreFulfillmentDetails.DispatchTypeSelector, { children: ({ availableTypes, selectedType, selectDispatchType, hasTypes }) => {
602
+ if (!hasTypes)
603
+ return null;
604
+ const contextValue = {
605
+ availableTypes: availableTypes.map(({ type, isSelected }) => ({
606
+ type,
607
+ isSelected,
608
+ })),
609
+ selectedType,
610
+ selectDispatchType,
611
+ hasTypes,
612
+ };
613
+ return (_jsx(DispatchTypeContext.Provider, { value: contextValue, children: _jsx("div", { ref: ref, className: className, "data-testid": TestIds.dispatchTypeSelector, ...rest, children: children }) }));
614
+ } }));
615
+ });
616
+ DispatchTypeSelector.displayName = 'FulfillmentDetails.DispatchTypeSelector';
617
+ /**
618
+ * Container for dispatch type options list with empty state support
619
+ *
620
+ * @example
621
+ * ```tsx
622
+ * <FulfillmentDetails.DispatchTypeOptions emptyState={<div>No options available</div>}>
623
+ * <FulfillmentDetails.DispatchTypeOptionRepeater>
624
+ * {({ type }) => <div>{type}</div>}
625
+ * </FulfillmentDetails.DispatchTypeOptionRepeater>
626
+ * </FulfillmentDetails.DispatchTypeOptions>
627
+ * ```
628
+ */
629
+ export const DispatchTypeOptions = React.forwardRef(({ children, emptyState, className, ...rest }, ref) => {
630
+ const { hasTypes } = useDispatchTypeContext();
631
+ if (!hasTypes) {
632
+ return emptyState || null;
633
+ }
634
+ return (_jsx("div", { ref: ref, className: className, "data-testid": TestIds.dispatchTypeOptions, ...rest, children: children }));
635
+ });
636
+ DispatchTypeOptions.displayName = 'FulfillmentDetails.DispatchTypeOptions';
637
+ /**
638
+ * Repeater component that renders children for each available dispatch type
639
+ *
640
+ * @example
641
+ * ```tsx
642
+ * <FulfillmentDetails.DispatchTypeOptionRepeater>
643
+ * {({ type, isSelected, selectDispatchType }) => (
644
+ * <button
645
+ * onClick={() => selectDispatchType(type)}
646
+ * data-selected={isSelected}
647
+ * >
648
+ * {type}
649
+ * </button>
650
+ * )}
651
+ * </FulfillmentDetails.DispatchTypeOptionRepeater>
652
+ * ```
653
+ */
654
+ export const DispatchTypeOptionRepeater = ({ children }) => {
655
+ const { availableTypes, selectDispatchType, hasTypes } = useDispatchTypeContext();
656
+ if (!hasTypes)
657
+ return null;
658
+ return (_jsx(_Fragment, { children: availableTypes.map(({ type, isSelected }) => (_jsx(React.Fragment, { children: children({
659
+ type,
660
+ isSelected,
661
+ selectDispatchType,
662
+ }) }, type))) }));
663
+ };
664
+ DispatchTypeOptionRepeater.displayName =
665
+ 'FulfillmentDetails.DispatchTypeOptionRepeater';
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { type FulfillmentDetailsServiceConfig } from '../../services/fulfillment-details-service.js';
3
3
  import { DispatchType, FulfillmentTypeEnum, TimeSlot } from '../../types/fulfillments-types.js';
4
+ import { StreetAddress } from '../../types/operation.js';
4
5
  export interface RootProps {
5
6
  children: React.ReactNode;
6
7
  fulfillmentDetailsServiceConfig?: FulfillmentDetailsServiceConfig;
@@ -97,6 +98,8 @@ export interface DeliveryAddressFields {
97
98
  country?: string;
98
99
  /** Formatted full address string */
99
100
  formattedAddress?: string;
101
+ /** Street address */
102
+ streetAddress?: StreetAddress;
100
103
  }
101
104
  export interface DeliveryAddressProps {
102
105
  children: (props: {
@@ -168,6 +171,8 @@ export interface AddressPickerProps {
168
171
  isDelivery: boolean;
169
172
  /** Current dispatch type */
170
173
  dispatchType: DispatchType | null;
174
+ /** Error message from the service */
175
+ error: string | null;
171
176
  }) => React.ReactNode;
172
177
  }
173
178
  /**
@@ -237,3 +242,38 @@ export interface SaveButtonProps {
237
242
  * ```
238
243
  */
239
244
  export declare const SaveButton: React.FC<SaveButtonProps>;
245
+ export interface DispatchTypeSelectorProps {
246
+ children: (props: {
247
+ availableTypes: Array<{
248
+ type: DispatchType;
249
+ isSelected: boolean;
250
+ }>;
251
+ selectedType: DispatchType | null;
252
+ selectDispatchType: (type: DispatchType) => void;
253
+ hasTypes: boolean;
254
+ }) => React.ReactNode;
255
+ }
256
+ /**
257
+ * Component that provides dispatch type selection (pickup or delivery)
258
+ * Uses FulfillmentDetailsService to manage dispatch type state
259
+ *
260
+ * @example
261
+ * ```tsx
262
+ * <CoreFulfillmentDetails.DispatchTypeSelector>
263
+ * {({ availableTypes, selectedType, selectDispatchType }) => (
264
+ * <div>
265
+ * {availableTypes.map(({ type, isSelected }) => (
266
+ * <button
267
+ * key={type}
268
+ * onClick={() => selectDispatchType(type)}
269
+ * disabled={isSelected}
270
+ * >
271
+ * {type} {isSelected && '(selected)'}
272
+ * </button>
273
+ * ))}
274
+ * </div>
275
+ * )}
276
+ * </CoreFulfillmentDetails.DispatchTypeSelector>
277
+ * ```
278
+ */
279
+ export declare const DispatchTypeSelector: React.FC<DispatchTypeSelectorProps>;
@@ -22,12 +22,19 @@ export const Root = ({ children, fulfillmentDetailsServiceConfig, }) => {
22
22
  if (!fulfillmentDetailsServiceConfig &&
23
23
  oloSettingsService.operation?.get()) {
24
24
  const operation = oloSettingsService.operation.get();
25
+ const availableDispatchTypes = fulfillmentsService.availableTypes?.get();
25
26
  if (operation) {
26
- const loadedConfig = loadFulfillmentDetailsServiceConfig(operation);
27
+ const loadedConfig = loadFulfillmentDetailsServiceConfig(operation, {
28
+ availableDispatchTypes,
29
+ });
27
30
  setConfig(loadedConfig);
28
31
  }
29
32
  }
30
- }, [fulfillmentDetailsServiceConfig, oloSettingsService.operation]);
33
+ }, [
34
+ fulfillmentDetailsServiceConfig,
35
+ oloSettingsService.operation,
36
+ fulfillmentsService.availableTypes,
37
+ ]);
31
38
  if (!config || isFulfillmentsLoading) {
32
39
  return null;
33
40
  }
@@ -41,7 +48,7 @@ export const Root = ({ children, fulfillmentDetailsServiceConfig, }) => {
41
48
  export const AddressName = ({ children }) => {
42
49
  const fulfillmentDetailsService = useService(FulfillmentDetailsServiceDefinition);
43
50
  const selectedAddress = fulfillmentDetailsService?.address?.get();
44
- console.log('selectedAddress', selectedAddress);
51
+ const error = fulfillmentDetailsService.error?.get() ?? null;
45
52
  const dispatchType = fulfillmentDetailsService.dispatchType?.get();
46
53
  const addressName = selectedAddress?.formatted;
47
54
  const fullAddress = selectedAddress
@@ -58,7 +65,9 @@ export const AddressName = ({ children }) => {
58
65
  .filter(Boolean)
59
66
  .join(', ')
60
67
  : null;
61
- const hasAddress = Boolean(selectedAddress);
68
+ console.log('error', error);
69
+ console.log('selectedAddress', selectedAddress);
70
+ const hasAddress = Boolean(selectedAddress && !error);
62
71
  return children({
63
72
  addressName,
64
73
  fullAddress,
@@ -209,6 +218,7 @@ export const DeliveryAddress = ({ children, }) => {
209
218
  : null;
210
219
  const hasAddress = Boolean(currentAddress);
211
220
  const onAddressChange = (newAddress) => {
221
+ console.log('onAddressChange', newAddress);
212
222
  fulfillmentDetailsService.setAddress(newAddress);
213
223
  };
214
224
  const onAddressClear = () => {
@@ -379,7 +389,9 @@ export const AddressPicker = ({ children }) => {
379
389
  subdivision: address.subdivision || '',
380
390
  postalCode: address.postalCode || '',
381
391
  country: address.country || '',
382
- formattedAddress,
392
+ // @ts-expect-error
393
+ formattedAddress: address.formattedAddress || '',
394
+ streetAddress: address.streetAddress,
383
395
  };
384
396
  // TODO - check first available time slot & available dates
385
397
  fulfillmentDetailsService.setAddress(addressFields);
@@ -402,6 +414,7 @@ export const AddressPicker = ({ children }) => {
402
414
  setIsLoading(false);
403
415
  }
404
416
  };
417
+ const error = fulfillmentDetailsService.error?.get() ?? null;
405
418
  return children({
406
419
  inputValue,
407
420
  onInputChange,
@@ -410,6 +423,7 @@ export const AddressPicker = ({ children }) => {
410
423
  onPredictionSelect,
411
424
  isDelivery,
412
425
  dispatchType,
426
+ error,
413
427
  });
414
428
  };
415
429
  /**
@@ -461,3 +475,48 @@ export const SaveButton = ({ children }) => {
461
475
  dispatchType,
462
476
  });
463
477
  };
478
+ /**
479
+ * Component that provides dispatch type selection (pickup or delivery)
480
+ * Uses FulfillmentDetailsService to manage dispatch type state
481
+ *
482
+ * @example
483
+ * ```tsx
484
+ * <CoreFulfillmentDetails.DispatchTypeSelector>
485
+ * {({ availableTypes, selectedType, selectDispatchType }) => (
486
+ * <div>
487
+ * {availableTypes.map(({ type, isSelected }) => (
488
+ * <button
489
+ * key={type}
490
+ * onClick={() => selectDispatchType(type)}
491
+ * disabled={isSelected}
492
+ * >
493
+ * {type} {isSelected && '(selected)'}
494
+ * </button>
495
+ * ))}
496
+ * </div>
497
+ * )}
498
+ * </CoreFulfillmentDetails.DispatchTypeSelector>
499
+ * ```
500
+ */
501
+ export const DispatchTypeSelector = ({ children, }) => {
502
+ const fulfillmentDetailsService = useService(FulfillmentDetailsServiceDefinition);
503
+ const selectedType = fulfillmentDetailsService.dispatchType?.get() ?? null;
504
+ const availableDispatchTypes = fulfillmentDetailsService.availableDispatchTypes?.get() ?? [];
505
+ // Map available dispatch types to the format needed
506
+ const availableTypes = availableDispatchTypes.map((type) => ({
507
+ type,
508
+ isSelected: type === selectedType,
509
+ }));
510
+ console.log('availableTypes', availableTypes);
511
+ const selectDispatchType = (type) => {
512
+ console.log('selectDispatchType', type);
513
+ fulfillmentDetailsService.setDispatchType(type);
514
+ };
515
+ const hasTypes = availableTypes.length > 0;
516
+ return children({
517
+ availableTypes,
518
+ selectedType,
519
+ selectDispatchType,
520
+ hasTypes,
521
+ });
522
+ };
@@ -36,6 +36,8 @@ export interface FulfillmentDetailsServiceAPI {
36
36
  availableTimeSlotsForDate: Signal<TimeSlot[]>;
37
37
  /** Available time slots filtered by current dispatch type */
38
38
  availableTimeSlots: ReadOnlySignal<TimeSlot[]>;
39
+ /** Available dispatch types (PICKUP and/or DELIVERY) based on available time slots */
40
+ availableDispatchTypes: ReadOnlySignal<DispatchType[]>;
39
41
  /** Selected scheduling type (ASAP or PREORDER) */
40
42
  schedulingType: Signal<FulfillmentTypeEnum | undefined>;
41
43
  /**
@@ -101,6 +103,8 @@ export interface FulfillmentDetailsServiceConfig {
101
103
  initialAddress?: Address;
102
104
  /** Number of days ahead to calculate available dates (default: 30) */
103
105
  daysAhead?: number;
106
+ /** Available dispatch types from fulfillments service */
107
+ availableDispatchTypes?: DispatchType[];
104
108
  }
105
109
  export declare const FulfillmentDetailsServiceDefinition: string & {
106
110
  __api: FulfillmentDetailsServiceAPI;
@@ -124,4 +128,5 @@ export declare function loadFulfillmentDetailsServiceConfig(operation: Operation
124
128
  initialDispatchType?: DispatchType;
125
129
  initialAddress?: Address;
126
130
  daysAhead?: number;
131
+ availableDispatchTypes?: DispatchType[];
127
132
  }): FulfillmentDetailsServiceConfig;
@@ -44,6 +44,24 @@ export const FulfillmentDetailsService = implementService.withConfig()(Fulfillme
44
44
  const availableTimeSlotsForDate = signalsService.signal([]);
45
45
  const schedulingType = signalsService.signal(fulfillmentsService.schedulingType?.get());
46
46
  // ========================================
47
+ // Sync selectedFulfillment from FulfillmentsService to dispatchType
48
+ // ========================================
49
+ // Sync dispatchType when selectedFulfillment changes in FulfillmentsService
50
+ // Use peek() to read dispatchType without creating a dependency, so the effect
51
+ // only runs when selectedFulfillment changes, not when dispatchType changes
52
+ signalsService.effect(() => {
53
+ const selectedFulfillment = fulfillmentsService.selectedFulfillment?.get();
54
+ const newDispatchType = selectedFulfillment?.type
55
+ ? selectedFulfillment.type
56
+ : null;
57
+ // Use peek() to read current value without subscribing to changes
58
+ const currentDispatchType = dispatchType.peek();
59
+ // Only update if different to avoid unnecessary updates and circular updates
60
+ if (currentDispatchType !== newDispatchType) {
61
+ dispatchType.set(newDispatchType);
62
+ }
63
+ });
64
+ // ========================================
47
65
  // Helper Functions
48
66
  // ========================================
49
67
  /**
@@ -115,8 +133,78 @@ export const FulfillmentDetailsService = implementService.withConfig()(Fulfillme
115
133
  asapTimeSlot.set(slot);
116
134
  }
117
135
  };
136
+ const convertAddressInputToAddress = (address) => {
137
+ if (!address)
138
+ return null;
139
+ const {
140
+ // @ts-expect-error
141
+ addressLine,
142
+ // @ts-expect-error
143
+ streetAddress,
144
+ // @ts-expect-error
145
+ formattedAddress,
146
+ // @ts-expect-error
147
+ location, ...rest } = address;
148
+ return {
149
+ streetAddress: streetAddress,
150
+ addressLine1: formattedAddress,
151
+ ...rest,
152
+ };
153
+ };
154
+ const initAddress = async (addr) => {
155
+ isLoading.set(true);
156
+ error.set(null);
157
+ try {
158
+ const commonAddress = convertAddressInputToAddress(addr);
159
+ // 1. Get first available time slot per operation with the selected address
160
+ const firstTimeSlotsResponse = await operationsSDK.calculateFirstAvailableTimeSlotPerFulfillmentType(operation.id, {
161
+ deliveryAddress: commonAddress,
162
+ });
163
+ // Find the time slots for this operation
164
+ const deliveryTimeSlot = firstTimeSlotsResponse.timeslotsPerFulfillmentType?.find((ts) => ts.fulfilmentType === DispatchType.DELIVERY);
165
+ if (!deliveryTimeSlot?.timeSlot) {
166
+ isLoading.set(false);
167
+ // TODO - localize this message
168
+ error.set('No available time slots found for the selected address');
169
+ return;
170
+ }
171
+ const firstTimeSlot = processTimeSlots([deliveryTimeSlot], DispatchType.DELIVERY)[0];
172
+ // 2. Fetch time slots for the selected address and date (using firstTimeSlot.startTime)
173
+ const targetDate = firstTimeSlot.startTime;
174
+ if (!targetDate) {
175
+ isLoading.set(false);
176
+ return;
177
+ }
178
+ await calculateAvailableTimeSlotsForDateFn(targetDate);
179
+ // Set the date and time slot
180
+ date.set(targetDate);
181
+ setTimeslot(firstTimeSlot);
182
+ // 3. Fetch available dates for the selected address
183
+ const startDate = new Date();
184
+ startDate.setHours(0, 0, 0, 0);
185
+ const endDate = new Date();
186
+ endDate.setDate(endDate.getDate() + daysAhead);
187
+ endDate.setHours(23, 59, 59, 999);
188
+ await calculateAvailableDatesInRangeFn(startDate, endDate);
189
+ }
190
+ catch (err) {
191
+ const errorMessage = err instanceof Error ? err.message : 'Failed to initialize address';
192
+ error.set(errorMessage);
193
+ console.error('Error initializing address:', err);
194
+ }
195
+ finally {
196
+ isLoading.set(false);
197
+ }
198
+ };
118
199
  const setAddress = (addr) => {
119
200
  address.set(addr);
201
+ // Initialize address: fetch first time slot, time slots for date, and available dates
202
+ if (addr) {
203
+ initAddress(addr).catch((err) => {
204
+ console.error('Error initializing address:', err);
205
+ error.set('Failed to initialize address');
206
+ });
207
+ }
120
208
  };
121
209
  const setDate = async (selectedDate) => {
122
210
  date.set(selectedDate);
@@ -129,7 +217,6 @@ export const FulfillmentDetailsService = implementService.withConfig()(Fulfillme
129
217
  }
130
218
  };
131
219
  const setDispatchType = (type) => {
132
- console.log('setDispatchType', type);
133
220
  dispatchType.set(type);
134
221
  // Re-filter available time slots based on new dispatch type
135
222
  const currentDate = date.get();
@@ -244,6 +331,7 @@ export const FulfillmentDetailsService = implementService.withConfig()(Fulfillme
244
331
  });
245
332
  const { timeslotsPerFulfillmentType } = response;
246
333
  const currentDispatchType = dispatchType.get();
334
+ // Process slots filtered by current dispatch type
247
335
  const slots = processTimeSlots(timeslotsPerFulfillmentType, currentDispatchType).reverse();
248
336
  availableTimeSlotsForDate.set(slots);
249
337
  // Auto-select first available slot if none selected
@@ -308,6 +396,12 @@ export const FulfillmentDetailsService = implementService.withConfig()(Fulfillme
308
396
  }
309
397
  return allTimeSlots.filter((slot) => slot.dispatchType === currentDispatchType);
310
398
  });
399
+ /**
400
+ * Available dispatch types from config
401
+ */
402
+ const availableDispatchTypes = signalsService.computed(() => {
403
+ return config.availableDispatchTypes ?? [];
404
+ });
311
405
  // ========================================
312
406
  // Return Service API
313
407
  // ========================================
@@ -323,6 +417,7 @@ export const FulfillmentDetailsService = implementService.withConfig()(Fulfillme
323
417
  availableDates,
324
418
  availableTimeSlotsForDate,
325
419
  availableTimeSlots,
420
+ availableDispatchTypes,
326
421
  schedulingType,
327
422
  // Actions
328
423
  getTimeslot,
@@ -355,5 +450,6 @@ export function loadFulfillmentDetailsServiceConfig(operation, options) {
355
450
  initialDispatchType: options?.initialDispatchType,
356
451
  initialAddress: options?.initialAddress,
357
452
  daysAhead: options?.daysAhead ?? 30,
453
+ availableDispatchTypes: options?.availableDispatchTypes,
358
454
  };
359
455
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/headless-restaurants-olo",
3
- "version": "0.0.45",
3
+ "version": "0.0.47",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -59,14 +59,14 @@
59
59
  "@wix/ecom": "^1.0.1560",
60
60
  "@wix/essentials": "^1.0.0",
61
61
  "@wix/headless-components": "0.0.35",
62
- "@wix/headless-media": "0.0.18",
63
- "@wix/headless-restaurants-menus": "0.0.22",
62
+ "@wix/headless-media": "0.0.19",
63
+ "@wix/headless-restaurants-menus": "0.0.23",
64
64
  "@wix/headless-utils": "0.0.8",
65
65
  "@wix/redirects": "^1.0.0",
66
66
  "@wix/restaurants": "^1.0.396",
67
67
  "@wix/sdk": "^1.15.24",
68
- "@wix/services-definitions": "^0.1.4",
69
- "@wix/services-manager-react": "^0.1.26"
68
+ "@wix/services-definitions": "^1.0.1",
69
+ "@wix/services-manager-react": "^1.0.2"
70
70
  },
71
71
  "publishConfig": {
72
72
  "registry": "https://registry.npmjs.org/",
@@ -78,5 +78,5 @@
78
78
  "groupId": "com.wixpress.headless-components"
79
79
  }
80
80
  },
81
- "falconPackageHash": "fc2b5a7894052822fdee845ca019b9b8bb662c3414177a0262d70454"
81
+ "falconPackageHash": "1353a228f4ad617d9b95f1e571f68b1e8c9791b4420c948df62df81f"
82
82
  }