@seekora-ai/ui-sdk-react 0.2.13 → 0.2.15

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 (117) hide show
  1. package/dist/components/CurrentRefinements.d.ts +22 -2
  2. package/dist/components/CurrentRefinements.d.ts.map +1 -1
  3. package/dist/components/CurrentRefinements.js +259 -47
  4. package/dist/components/FacetDropdown.d.ts +92 -0
  5. package/dist/components/FacetDropdown.d.ts.map +1 -0
  6. package/dist/components/FacetDropdown.js +374 -0
  7. package/dist/components/Facets.d.ts +56 -1
  8. package/dist/components/Facets.d.ts.map +1 -1
  9. package/dist/components/Facets.js +602 -41
  10. package/dist/components/FederatedDropdown.d.ts.map +1 -1
  11. package/dist/components/FederatedDropdown.js +45 -31
  12. package/dist/components/HierarchicalMenu.d.ts.map +1 -1
  13. package/dist/components/HierarchicalMenu.js +112 -4
  14. package/dist/components/Pagination.d.ts +47 -1
  15. package/dist/components/Pagination.d.ts.map +1 -1
  16. package/dist/components/Pagination.js +166 -28
  17. package/dist/components/QuerySuggestionsDropdown.d.ts.map +1 -1
  18. package/dist/components/QuerySuggestionsDropdown.js +32 -18
  19. package/dist/components/RangeInput.d.ts.map +1 -1
  20. package/dist/components/RangeInput.js +6 -6
  21. package/dist/components/RangeSlider.d.ts.map +1 -1
  22. package/dist/components/RangeSlider.js +101 -32
  23. package/dist/components/RichQuerySuggestions.d.ts +7 -0
  24. package/dist/components/RichQuerySuggestions.d.ts.map +1 -1
  25. package/dist/components/RichQuerySuggestions.js +40 -26
  26. package/dist/components/SearchBar.d.ts +16 -0
  27. package/dist/components/SearchBar.d.ts.map +1 -1
  28. package/dist/components/SearchBar.js +139 -17
  29. package/dist/components/SearchBarWithSuggestions.js +3 -3
  30. package/dist/components/SearchLayout.d.ts.map +1 -1
  31. package/dist/components/SearchLayout.js +10 -1
  32. package/dist/components/SearchProvider.d.ts +8 -1
  33. package/dist/components/SearchProvider.d.ts.map +1 -1
  34. package/dist/components/SearchProvider.js +16 -4
  35. package/dist/components/SearchResults.d.ts +10 -0
  36. package/dist/components/SearchResults.d.ts.map +1 -1
  37. package/dist/components/SearchResults.js +46 -30
  38. package/dist/components/SortBy.d.ts +44 -4
  39. package/dist/components/SortBy.d.ts.map +1 -1
  40. package/dist/components/SortBy.js +154 -29
  41. package/dist/components/Stats.d.ts +14 -0
  42. package/dist/components/Stats.d.ts.map +1 -1
  43. package/dist/components/Stats.js +172 -23
  44. package/dist/components/primitives/ActionButtons.d.ts.map +1 -1
  45. package/dist/components/primitives/ActionButtons.js +34 -10
  46. package/dist/components/primitives/BadgeList.d.ts.map +1 -1
  47. package/dist/components/primitives/BadgeList.js +33 -13
  48. package/dist/components/primitives/ImageDisplay.d.ts.map +1 -1
  49. package/dist/components/primitives/ImageDisplay.js +11 -8
  50. package/dist/components/primitives/ImageZoom.js +26 -26
  51. package/dist/components/primitives/VariantSelector.js +10 -10
  52. package/dist/components/primitives/VariantSwatches.js +3 -3
  53. package/dist/components/product-page/ProductGallery.d.ts +8 -1
  54. package/dist/components/product-page/ProductGallery.d.ts.map +1 -1
  55. package/dist/components/product-page/ProductGallery.js +2 -2
  56. package/dist/components/section-primitives/SectionSearchProvider.d.ts +3 -1
  57. package/dist/components/section-primitives/SectionSearchProvider.d.ts.map +1 -1
  58. package/dist/components/section-primitives/SectionSearchProvider.js +3 -2
  59. package/dist/components/suggestions/AmazonDropdown.d.ts.map +1 -1
  60. package/dist/components/suggestions/AmazonDropdown.js +2 -4
  61. package/dist/components/suggestions/GoogleDropdown.d.ts.map +1 -1
  62. package/dist/components/suggestions/GoogleDropdown.js +2 -6
  63. package/dist/components/suggestions/MinimalDropdown.d.ts.map +1 -1
  64. package/dist/components/suggestions/MinimalDropdown.js +2 -4
  65. package/dist/components/suggestions/MobileSheetDropdown.d.ts.map +1 -1
  66. package/dist/components/suggestions/MobileSheetDropdown.js +20 -22
  67. package/dist/components/suggestions/PinterestDropdown.d.ts.map +1 -1
  68. package/dist/components/suggestions/PinterestDropdown.js +2 -6
  69. package/dist/components/suggestions/ShopifyDropdown.d.ts.map +1 -1
  70. package/dist/components/suggestions/ShopifyDropdown.js +39 -41
  71. package/dist/components/suggestions/SpotlightDropdown.d.ts.map +1 -1
  72. package/dist/components/suggestions/SpotlightDropdown.js +2 -4
  73. package/dist/components/suggestions/utils.d.ts +10 -1
  74. package/dist/components/suggestions/utils.d.ts.map +1 -1
  75. package/dist/components/suggestions/utils.js +36 -0
  76. package/dist/components/suggestions-primitives/DropdownPanel.d.ts.map +1 -1
  77. package/dist/components/suggestions-primitives/DropdownPanel.js +15 -2
  78. package/dist/components/suggestions-primitives/ItemCard.d.ts.map +1 -1
  79. package/dist/components/suggestions-primitives/ItemCard.js +21 -8
  80. package/dist/components/suggestions-primitives/ItemGrid.d.ts.map +1 -1
  81. package/dist/components/suggestions-primitives/ItemGrid.js +9 -3
  82. package/dist/components/suggestions-primitives/ProductCard.d.ts.map +1 -1
  83. package/dist/components/suggestions-primitives/ProductCard.js +25 -10
  84. package/dist/components/suggestions-primitives/ProductCardLayouts.d.ts.map +1 -1
  85. package/dist/components/suggestions-primitives/ProductCardLayouts.js +24 -12
  86. package/dist/components/suggestions-primitives/SearchInput.d.ts.map +1 -1
  87. package/dist/components/suggestions-primitives/SearchInput.js +28 -9
  88. package/dist/components/suggestions-primitives/SuggestionItem.d.ts.map +1 -1
  89. package/dist/components/suggestions-primitives/SuggestionItem.js +3 -0
  90. package/dist/components/suggestions-primitives/highlightMarkup.d.ts +16 -4
  91. package/dist/components/suggestions-primitives/highlightMarkup.d.ts.map +1 -1
  92. package/dist/components/suggestions-primitives/highlightMarkup.js +42 -4
  93. package/dist/hooks/useClickTracking.d.ts +36 -0
  94. package/dist/hooks/useClickTracking.d.ts.map +1 -0
  95. package/dist/hooks/useClickTracking.js +96 -0
  96. package/dist/hooks/useExperiment.d.ts +25 -0
  97. package/dist/hooks/useExperiment.d.ts.map +1 -0
  98. package/dist/hooks/useExperiment.js +146 -0
  99. package/dist/hooks/useKeyboardNavigation.d.ts +51 -0
  100. package/dist/hooks/useKeyboardNavigation.d.ts.map +1 -0
  101. package/dist/hooks/useKeyboardNavigation.js +113 -0
  102. package/dist/hooks/useQuerySuggestions.d.ts.map +1 -1
  103. package/dist/hooks/useQuerySuggestions.js +19 -3
  104. package/dist/hooks/useQuerySuggestionsEnhanced.d.ts.map +1 -1
  105. package/dist/hooks/useQuerySuggestionsEnhanced.js +23 -6
  106. package/dist/hooks/useSuggestionsAnalytics.d.ts.map +1 -1
  107. package/dist/hooks/useSuggestionsAnalytics.js +6 -1
  108. package/dist/index.d.ts +6 -1
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +1 -0
  111. package/dist/index.umd.js +1 -1
  112. package/dist/src/index.d.ts +345 -19
  113. package/dist/src/index.esm.js +2869 -787
  114. package/dist/src/index.esm.js.map +1 -1
  115. package/dist/src/index.js +2868 -785
  116. package/dist/src/index.js.map +1 -1
  117. package/package.json +6 -6
@@ -11,17 +11,25 @@ import { BadgeList } from '../primitives/BadgeList';
11
11
  import { RatingDisplay } from '../primitives/RatingDisplay';
12
12
  import { VariantSwatches } from '../primitives/VariantSwatches';
13
13
  import { ActionButtons } from '../primitives/ActionButtons';
14
+ const BORDER_RADIUS = {
15
+ sm: 4,
16
+ md: 6,
17
+ lg: 8,
18
+ full: 9999,
19
+ };
14
20
  const imgPlaceholderStyle = {
15
21
  width: '100%',
16
22
  aspectRatio: '1',
17
23
  objectFit: 'cover',
18
- borderRadius: 4,
24
+ borderRadius: BORDER_RADIUS.sm,
19
25
  backgroundColor: 'var(--seekora-bg-secondary, #f3f4f6)',
20
26
  };
21
27
  function ImageBlock({ images, title, imageVariant, aspectRatio, enableZoom, zoomMode, zoomLevel }) {
22
- const ar = aspectRatio ? aspectRatio.replace(':', '/') : '1';
28
+ const ar = aspectRatio
29
+ ? (aspectRatio.includes(':') ? aspectRatio.replace(':', '/') : aspectRatio)
30
+ : '1';
23
31
  if (images.length > 0) {
24
- return (React.createElement("div", { className: "seekora-product-card__image", style: { position: 'relative', overflow: 'hidden', borderRadius: 4 } },
32
+ return (React.createElement("div", { className: "seekora-product-card__image", style: { position: 'relative', overflow: 'hidden', borderRadius: BORDER_RADIUS.sm } },
25
33
  React.createElement(ImageDisplay, { images: images, variant: images.length > 1 ? imageVariant : 'single', alt: title, className: "seekora-suggestions-product-card-image", style: { aspectRatio: ar }, enableZoom: enableZoom, zoomMode: zoomMode, zoomLevel: zoomLevel })));
26
34
  }
27
35
  return React.createElement("div", { className: "seekora-product-card__image seekora-suggestions-product-card-placeholder", style: { ...imgPlaceholderStyle, aspectRatio: ar }, "aria-hidden": true });
@@ -30,7 +38,7 @@ function ImageBlock({ images, title, imageVariant, aspectRatio, enableZoom, zoom
30
38
  export function MinimalLayout({ images, title, price, product, imageVariant, displayConfig, enableImageZoom, imageZoomMode, imageZoomLevel }) {
31
39
  return (React.createElement(React.Fragment, null,
32
40
  React.createElement(ImageBlock, { images: images, title: title, imageVariant: imageVariant, aspectRatio: displayConfig.imageAspectRatio, enableZoom: enableImageZoom, zoomMode: imageZoomMode, zoomLevel: imageZoomLevel }),
33
- React.createElement("span", { className: "seekora-product-card__title", style: { fontSize: '0.875rem', fontWeight: 500 } }, title),
41
+ React.createElement("span", { className: "seekora-product-card__title", style: { fontSize: '0.875rem', fontWeight: 500, lineHeight: 1.4, overflow: 'hidden', textOverflow: 'ellipsis' } }, title),
34
42
  price != null && !Number.isNaN(price) && (React.createElement("span", { className: "seekora-product-card__price seekora-suggestions-product-card-price", style: { fontSize: '0.875rem', color: 'var(--seekora-text-secondary, #6b7280)' } },
35
43
  product.currency ?? '$',
36
44
  price.toFixed(2)))));
@@ -38,31 +46,35 @@ export function MinimalLayout({ images, title, price, product, imageVariant, dis
38
46
  /** standard: image, badges, brand, title, price + compare price, color swatches */
39
47
  export function StandardLayout({ images, title, price, comparePrice, brand, badges, options, variants, product, imageVariant, displayConfig, onVariantHover, onVariantClick, selectedVariants, actionButtons, actionButtonsPosition, showActionLabels, enableImageZoom, imageZoomMode, imageZoomLevel, }) {
40
48
  const cfg = displayConfig;
49
+ // Normalize position: 'overlay-*' positions render over image, everything else renders inline
50
+ const isOverlayPosition = actionButtonsPosition?.startsWith('overlay');
41
51
  return (React.createElement(React.Fragment, null,
42
52
  React.createElement("div", { style: { position: 'relative' } },
43
53
  React.createElement(ImageBlock, { images: images, title: title, imageVariant: imageVariant, aspectRatio: cfg.imageAspectRatio, enableZoom: enableImageZoom, zoomMode: imageZoomMode, zoomLevel: imageZoomLevel }),
44
54
  cfg.showBadges !== false && badges.length > 0 && (React.createElement(BadgeList, { badges: badges, position: "top-left", maxBadges: 2 })),
45
- actionButtons && actionButtons.length > 0 && actionButtonsPosition?.startsWith('overlay') && (React.createElement(ActionButtons, { buttons: actionButtons, position: actionButtonsPosition === 'overlay-top-right' ? 'top-right' : 'bottom-center', showLabels: showActionLabels, size: "small" }))),
55
+ actionButtons && actionButtons.length > 0 && isOverlayPosition && (React.createElement(ActionButtons, { buttons: actionButtons, position: actionButtonsPosition === 'overlay-top-right' ? 'top-right' : 'bottom-center', showLabels: showActionLabels, size: "small" }))),
46
56
  React.createElement("div", { className: "seekora-product-card__body", style: { display: 'flex', flexDirection: 'column', gap: 4 } },
47
57
  cfg.showBrand !== false && brand && (React.createElement("span", { className: "seekora-product-card__brand", style: { fontSize: '0.75rem', color: 'var(--seekora-text-secondary, #6b7280)', textTransform: 'uppercase', letterSpacing: '0.02em' } }, brand)),
48
- React.createElement("span", { className: "seekora-product-card__title", style: { fontSize: '0.875rem', fontWeight: 500 } }, title),
58
+ React.createElement("span", { className: "seekora-product-card__title", style: { fontSize: '0.875rem', fontWeight: 500, lineHeight: 1.4, overflow: 'hidden', textOverflow: 'ellipsis' } }, title),
49
59
  React.createElement("div", { className: "seekora-product-card__price" },
50
60
  React.createElement(PriceDisplay, { price: price ?? undefined, comparePrice: comparePrice ?? undefined, currency: cfg.currency ?? product.currency, currencyPosition: cfg.currencyPosition, priceDecimals: cfg.priceDecimals, showDiscount: cfg.showDiscount, style: { fontSize: '0.875rem' } })),
51
61
  cfg.showVariants !== false && options.length > 0 && (React.createElement(VariantSwatches, { options: options, visibleOptions: cfg.variantOptionsToShow, maxValues: cfg.maxVariantValues, selectedValues: selectedVariants, variants: variants, onSwatchHover: onVariantHover, onSwatchClick: onVariantClick })),
52
- actionButtons && actionButtons.length > 0 && actionButtonsPosition === 'inline' && (React.createElement(ActionButtons, { buttons: actionButtons, position: "inline", showLabels: showActionLabels, size: "small", layout: "horizontal" })))));
62
+ actionButtons && actionButtons.length > 0 && !isOverlayPosition && (React.createElement(ActionButtons, { buttons: actionButtons, position: "inline", showLabels: showActionLabels, size: "small", layout: "horizontal" })))));
53
63
  }
54
64
  /** detailed: image, badges, brand, title, price + compare + discount, rating, all swatches, stock */
55
65
  export function DetailedLayout({ images, title, price, comparePrice, brand, badges, priceRange, options, variants, product, imageVariant, displayConfig, onVariantHover, onVariantClick, selectedVariants, actionButtons, actionButtonsPosition, showActionLabels, enableImageZoom, imageZoomMode, imageZoomLevel, }) {
56
66
  const cfg = displayConfig;
57
67
  const available = product.available;
68
+ // Normalize position: 'overlay-*' positions render over image, everything else renders inline
69
+ const isOverlayPosition = actionButtonsPosition?.startsWith('overlay');
58
70
  return (React.createElement(React.Fragment, null,
59
71
  React.createElement("div", { style: { position: 'relative' } },
60
72
  React.createElement(ImageBlock, { images: images, title: title, imageVariant: imageVariant, aspectRatio: cfg.imageAspectRatio, enableZoom: enableImageZoom, zoomMode: imageZoomMode, zoomLevel: imageZoomLevel }),
61
73
  cfg.showBadges !== false && badges.length > 0 && (React.createElement(BadgeList, { badges: badges, position: "top-left" })),
62
- actionButtons && actionButtons.length > 0 && actionButtonsPosition?.startsWith('overlay') && (React.createElement(ActionButtons, { buttons: actionButtons, position: actionButtonsPosition === 'overlay-top-right' ? 'top-right' : 'bottom-center', showLabels: showActionLabels, size: "small" }))),
74
+ actionButtons && actionButtons.length > 0 && isOverlayPosition && (React.createElement(ActionButtons, { buttons: actionButtons, position: actionButtonsPosition === 'overlay-top-right' ? 'top-right' : 'bottom-center', showLabels: showActionLabels, size: "small" }))),
63
75
  React.createElement("div", { className: "seekora-product-card__body", style: { display: 'flex', flexDirection: 'column', gap: 4 } },
64
76
  cfg.showBrand !== false && brand && (React.createElement("span", { className: "seekora-product-card__brand", style: { fontSize: '0.75rem', color: 'var(--seekora-text-secondary, #6b7280)', textTransform: 'uppercase', letterSpacing: '0.02em' } }, brand)),
65
- React.createElement("span", { className: "seekora-product-card__title", style: { fontSize: '0.875rem', fontWeight: 500 } }, title),
77
+ React.createElement("span", { className: "seekora-product-card__title", style: { fontSize: '0.875rem', fontWeight: 500, lineHeight: 1.4, overflow: 'hidden', textOverflow: 'ellipsis' } }, title),
66
78
  cfg.showRating !== false && product.rating != null && (React.createElement(RatingDisplay, { rating: product.rating, reviewCount: product.reviewCount, variant: "compact", size: "small", showNumeric: false, showReviewCount: true, className: "seekora-product-card__rating" })),
67
79
  React.createElement("div", { className: "seekora-product-card__price" }, cfg.showPriceRange && priceRange ? (React.createElement(PriceDisplay, { priceRange: priceRange, currency: cfg.currency ?? product.currency, currencyPosition: cfg.currencyPosition, priceDecimals: cfg.priceDecimals, style: { fontSize: '0.875rem' } })) : (React.createElement(PriceDisplay, { price: price ?? undefined, comparePrice: comparePrice ?? undefined, currency: cfg.currency ?? product.currency, currencyPosition: cfg.currencyPosition, priceDecimals: cfg.priceDecimals, showDiscount: cfg.showDiscount, style: { fontSize: '0.875rem' } }))),
68
80
  cfg.showVariants !== false && options.length > 0 && (React.createElement(VariantSwatches, { options: options, visibleOptions: cfg.variantOptionsToShow, maxValues: cfg.maxVariantValues, selectedValues: selectedVariants, variants: variants, onSwatchHover: onVariantHover, onSwatchClick: onVariantClick })),
@@ -70,7 +82,7 @@ export function DetailedLayout({ images, title, price, comparePrice, brand, badg
70
82
  fontSize: '0.75rem',
71
83
  color: available ? 'var(--seekora-success, #22c55e)' : 'var(--seekora-error, #ef4444)',
72
84
  } }, available ? 'In Stock' : 'Out of Stock')),
73
- actionButtons && actionButtons.length > 0 && actionButtonsPosition === 'inline' && (React.createElement(ActionButtons, { buttons: actionButtons, position: "inline", showLabels: showActionLabels, size: "small", layout: "horizontal" })))));
85
+ actionButtons && actionButtons.length > 0 && !isOverlayPosition && (React.createElement(ActionButtons, { buttons: actionButtons, position: "inline", showLabels: showActionLabels, size: "small", layout: "horizontal" })))));
74
86
  }
75
87
  /** compact: smaller image, 1-line title, price */
76
88
  export function CompactLayout({ images, title, price, product, imageVariant, displayConfig, enableImageZoom, imageZoomMode, imageZoomLevel }) {
@@ -91,13 +103,13 @@ export function CompactLayout({ images, title, price, product, imageVariant, dis
91
103
  export function HorizontalLayout({ images, title, price, comparePrice, brand, badges, options, variants, product, imageVariant, displayConfig, onVariantHover, onVariantClick, selectedVariants, actionButtons, actionButtonsPosition, showActionLabels, enableImageZoom, imageZoomMode, imageZoomLevel, }) {
92
104
  const cfg = displayConfig;
93
105
  return (React.createElement("div", { style: { display: 'flex', gap: 12, alignItems: 'flex-start' } },
94
- React.createElement("div", { style: { position: 'relative', width: 80, flexShrink: 0 } },
106
+ React.createElement("div", { style: { position: 'relative', minWidth: 80, flexBasis: '25%', maxWidth: 120, flexShrink: 0 } },
95
107
  React.createElement(ImageBlock, { images: images, title: title, imageVariant: imageVariant, aspectRatio: "1:1", enableZoom: enableImageZoom, zoomMode: imageZoomMode, zoomLevel: imageZoomLevel }),
96
108
  cfg.showBadges !== false && badges.length > 0 && (React.createElement(BadgeList, { badges: badges, position: "top-left", maxBadges: 1 })),
97
109
  actionButtons && actionButtons.length > 0 && actionButtonsPosition?.startsWith('overlay') && (React.createElement(ActionButtons, { buttons: actionButtons, position: actionButtonsPosition === 'overlay-top-right' ? 'top-right' : 'bottom-center', showLabels: showActionLabels, size: "small" }))),
98
110
  React.createElement("div", { className: "seekora-product-card__body", style: { display: 'flex', flexDirection: 'column', gap: 4, flex: 1, minWidth: 0 } },
99
111
  cfg.showBrand !== false && brand && (React.createElement("span", { className: "seekora-product-card__brand", style: { fontSize: '0.75rem', color: 'var(--seekora-text-secondary, #6b7280)', textTransform: 'uppercase' } }, brand)),
100
- React.createElement("span", { className: "seekora-product-card__title", style: { fontSize: '0.875rem', fontWeight: 500 } }, title),
112
+ React.createElement("span", { className: "seekora-product-card__title", style: { fontSize: '0.875rem', fontWeight: 500, lineHeight: 1.4, overflow: 'hidden', textOverflow: 'ellipsis' } }, title),
101
113
  React.createElement("div", { className: "seekora-product-card__price" },
102
114
  React.createElement(PriceDisplay, { price: price ?? undefined, comparePrice: comparePrice ?? undefined, currency: cfg.currency ?? product.currency, currencyPosition: cfg.currencyPosition, priceDecimals: cfg.priceDecimals, showDiscount: cfg.showDiscount, style: { fontSize: '0.875rem' } })),
103
115
  cfg.showVariants !== false && options.length > 0 && (React.createElement(VariantSwatches, { options: options, visibleOptions: cfg.variantOptionsToShow, maxValues: cfg.maxVariantValues ?? 3, selectedValues: selectedVariants, variants: variants, onSwatchHover: onVariantHover, onSwatchClick: onVariantClick })),
@@ -1 +1 @@
1
- {"version":3,"file":"SearchInput.d.ts","sourceRoot":"","sources":["../../../src/components/suggestions-primitives/SearchInput.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAA8B,MAAM,OAAO,CAAC;AAInD,MAAM,WAAW,gBAAgB;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,0FAA0F;IAC1F,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IAC5B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA4BD,wBAAgB,WAAW,CAAC,EAC1B,WAAyB,EACzB,SAAiB,EACjB,eAAsB,EACtB,WAAkB,EAClB,QAAQ,EACR,SAAS,EACT,KAAK,EACL,cAAc,EACd,UAAU,EACV,SAAoB,GACrB,EAAE,gBAAgB,qBA+GlB"}
1
+ {"version":3,"file":"SearchInput.d.ts","sourceRoot":"","sources":["../../../src/components/suggestions-primitives/SearchInput.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAA8B,MAAM,OAAO,CAAC;AAyBnD,MAAM,WAAW,gBAAgB;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,0FAA0F;IAC1F,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IAC5B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA6BD,wBAAgB,WAAW,CAAC,EAC1B,WAAyB,EACzB,SAAiB,EACjB,eAAsB,EACtB,WAAkB,EAClB,QAAQ,EACR,SAAS,EACT,KAAK,EACL,cAAc,EACd,UAAU,EACV,SAAoB,GACrB,EAAE,gBAAgB,qBA+GlB"}
@@ -7,6 +7,24 @@
7
7
  import React, { useRef, useCallback } from 'react';
8
8
  import { useSuggestionsContext } from './SuggestionsContext';
9
9
  import { clsx } from 'clsx';
10
+ const SPACING = {
11
+ xs: 4,
12
+ sm: 8,
13
+ md: 12,
14
+ lg: 16,
15
+ xl: 24,
16
+ };
17
+ const TRANSITIONS = {
18
+ fast: '150ms ease-in-out',
19
+ normal: '200ms ease-in-out',
20
+ slow: '300ms ease-in-out',
21
+ };
22
+ const BORDER_RADIUS = {
23
+ sm: 4,
24
+ md: 6,
25
+ lg: 8,
26
+ full: 9999,
27
+ };
10
28
  const defaultStyles = {
11
29
  position: 'relative',
12
30
  width: '100%',
@@ -14,22 +32,23 @@ const defaultStyles = {
14
32
  const inputWrapperStyles = {
15
33
  display: 'flex',
16
34
  alignItems: 'center',
17
- gap: '8px',
18
- padding: '8px 12px',
19
- border: '1px solid var(--seekora-border-color, #e5e7eb)',
20
- borderRadius: 'var(--seekora-border-radius, 6px)',
21
- backgroundColor: 'var(--seekora-bg-surface, #fff)',
22
- transition: 'border-color 150ms ease, box-shadow 150ms ease',
35
+ gap: `${SPACING.sm}px`,
36
+ padding: `${SPACING.sm}px ${SPACING.md}px`,
37
+ border: '1px solid var(--seekora-border-color, rgba(0,0,0,0.1))',
38
+ borderRadius: `var(--seekora-border-radius, ${BORDER_RADIUS.md}px)`,
39
+ backgroundColor: 'var(--seekora-bg-surface, transparent)',
40
+ transition: `border-color ${TRANSITIONS.fast}, box-shadow ${TRANSITIONS.fast}`,
41
+ boxSizing: 'border-box',
23
42
  };
24
43
  const inputStyles = {
25
44
  flex: 1,
26
45
  minWidth: 0,
27
- padding: '8px 0',
46
+ padding: `${SPACING.sm}px 0`,
28
47
  border: 'none',
29
48
  outline: 'none',
30
49
  backgroundColor: 'transparent',
31
50
  fontSize: 'inherit',
32
- color: 'var(--seekora-text-primary, #111827)',
51
+ color: 'var(--seekora-text-primary, inherit)',
33
52
  fontFamily: 'inherit',
34
53
  };
35
54
  export function SearchInput({ placeholder = 'Search...', autoFocus = false, showClearButton = true, closeOnBlur = true, leftIcon, className, style, inputClassName, inputStyle, ariaLabel = 'Search', }) {
@@ -78,7 +97,7 @@ export function SearchInput({ placeholder = 'Search...', autoFocus = false, show
78
97
  leftIcon ? (React.createElement("span", { className: "seekora-suggestions-input-left-icon", style: { display: 'flex', flexShrink: 0, color: 'var(--seekora-text-secondary, #6b7280)' } }, leftIcon)) : null,
79
98
  React.createElement("input", { ref: inputRef, type: "text", value: query, onChange: handleChange, onFocus: handleFocus, onBlur: handleBlur, onKeyDown: handleKeyDown, placeholder: placeholder, autoFocus: autoFocus, autoComplete: "off", autoCorrect: "off", autoCapitalize: "off", spellCheck: false, "aria-label": ariaLabel, "aria-expanded": isOpen, "aria-haspopup": "listbox", "aria-autocomplete": "list", role: "combobox", className: clsx('seekora-suggestions-input', inputClassName), style: { ...inputStyles, ...inputStyle } }),
80
99
  showClearButton && query ? (React.createElement("button", { type: "button", onClick: handleClear, className: "seekora-suggestions-input-clear", "aria-label": "Clear search", style: {
81
- padding: 4,
100
+ padding: 8,
82
101
  border: 'none',
83
102
  background: 'transparent',
84
103
  cursor: 'pointer',
@@ -1 +1 @@
1
- {"version":3,"file":"SuggestionItem.d.ts","sourceRoot":"","sources":["../../../src/components/suggestions-primitives/SuggestionItem.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,KAAK,EAAE,cAAc,IAAI,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAErF,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AAEhE,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,kBAAkB,CAAC;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IAC5B;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,2FAA2F;IAC3F,sBAAsB,CAAC,EAAE,sBAAsB,CAAC;IAChD,8FAA8F;IAC9F,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,KAAK,CAAC,SAAS,CAAC;CACrD;AAgBD,wBAAgB,cAAc,CAAC,EAC7B,UAAU,EACV,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,KAAK,EACL,qBAA4B,EAC5B,sBAAsB,EACtB,eAAe,GAChB,EAAE,mBAAmB,qBAoCrB"}
1
+ {"version":3,"file":"SuggestionItem.d.ts","sourceRoot":"","sources":["../../../src/components/suggestions-primitives/SuggestionItem.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,KAAK,EAAE,cAAc,IAAI,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAErF,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AAEhE,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,kBAAkB,CAAC;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IAC5B;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,2FAA2F;IAC3F,sBAAsB,CAAC,EAAE,sBAAsB,CAAC;IAChD,8FAA8F;IAC9F,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,KAAK,CAAC,SAAS,CAAC;CACrD;AAmBD,wBAAgB,cAAc,CAAC,EAC7B,UAAU,EACV,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,KAAK,EACL,qBAA4B,EAC5B,sBAAsB,EACtB,eAAe,GAChB,EAAE,mBAAmB,qBAoCrB"}
@@ -21,6 +21,9 @@ const defaultItemStyle = {
21
21
  backgroundColor: 'transparent',
22
22
  color: 'var(--seekora-text-primary, #111827)',
23
23
  transition: 'background-color 120ms ease',
24
+ overflow: 'hidden',
25
+ textOverflow: 'ellipsis',
26
+ whiteSpace: 'nowrap',
24
27
  };
25
28
  export function SuggestionItem({ suggestion, index, isActive, onSelect, className, style, enableHighlightMarkup = true, highlightMarkupOptions, renderHighlight, }) {
26
29
  const displayText = suggestion.highlightedQuery ?? suggestion.query;
@@ -1,18 +1,30 @@
1
1
  /**
2
2
  * Parses suggestion text containing <mark>...</mark> and returns React nodes
3
- * with the marked segments rendered as <mark> elements. Safe: inner content
3
+ * with the marked segments rendered as styled elements. Safe: inner content
4
4
  * is rendered as text, not HTML.
5
5
  */
6
6
  import React from 'react';
7
+ /** Visual highlight styles */
8
+ export type HighlightStyle = 'background' | 'underline' | 'bold' | 'color-only';
7
9
  export interface HighlightMarkupOptions {
8
- /** Class name for the <mark> element. */
10
+ /** Class name for the highlight element. */
9
11
  markClassName?: string;
10
- /** Inline style for the <mark> element. */
12
+ /** Inline style for the highlight element (merged with computed styles). */
11
13
  markStyle?: React.CSSProperties;
14
+ /** Override background color for highlighted segments */
15
+ highlightColor?: string;
16
+ /** Text color for highlighted segments */
17
+ highlightTextColor?: string;
18
+ /** Font weight for highlighted segments */
19
+ highlightFontWeight?: string | number;
20
+ /** Visual highlight style (default: 'background') */
21
+ highlightStyle?: HighlightStyle;
22
+ /** Which HTML element to render for highlights (default: 'mark') */
23
+ highlightTag?: keyof JSX.IntrinsicElements;
12
24
  }
13
25
  /**
14
26
  * Converts a string like "lined <mark>blue</mark>" into React nodes with
15
- * the marked part rendered as a <mark> element. When no <mark> tags are
27
+ * the marked part rendered as a styled element. When no <mark> tags are
16
28
  * present, returns the string as-is.
17
29
  */
18
30
  export declare function parseHighlightMarkup(text: string, options?: HighlightMarkupOptions): React.ReactNode;
@@ -1 +1 @@
1
- {"version":3,"file":"highlightMarkup.d.ts","sourceRoot":"","sources":["../../../src/components/suggestions-primitives/highlightMarkup.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,MAAM,WAAW,sBAAsB;IACrC,yCAAyC;IACzC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,2CAA2C;IAC3C,SAAS,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;CACjC;AASD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,sBAA2B,GACnC,KAAK,CAAC,SAAS,CAyBjB"}
1
+ {"version":3,"file":"highlightMarkup.d.ts","sourceRoot":"","sources":["../../../src/components/suggestions-primitives/highlightMarkup.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,8BAA8B;AAC9B,MAAM,MAAM,cAAc,GAAG,YAAY,GAAG,WAAW,GAAG,MAAM,GAAG,YAAY,CAAC;AAEhF,MAAM,WAAW,sBAAsB;IACrC,4CAA4C;IAC5C,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,4EAA4E;IAC5E,SAAS,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IAChC,yDAAyD;IACzD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,0CAA0C;IAC1C,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,2CAA2C;IAC3C,mBAAmB,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACtC,qDAAqD;IACrD,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,oEAAoE;IACpE,YAAY,CAAC,EAAE,MAAM,GAAG,CAAC,iBAAiB,CAAC;CAC5C;AA4CD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,sBAA2B,GACnC,KAAK,CAAC,SAAS,CAkCjB"}
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Parses suggestion text containing <mark>...</mark> and returns React nodes
3
- * with the marked segments rendered as <mark> elements. Safe: inner content
3
+ * with the marked segments rendered as styled elements. Safe: inner content
4
4
  * is rendered as text, not HTML.
5
5
  */
6
6
  import React from 'react';
@@ -10,9 +10,40 @@ const defaultMarkStyle = {
10
10
  borderRadius: '2px',
11
11
  padding: '0 2px',
12
12
  };
13
+ /** Compute styles based on highlight options */
14
+ function computeHighlightStyles(options) {
15
+ const style = options.highlightStyle || 'background';
16
+ const base = {};
17
+ switch (style) {
18
+ case 'background':
19
+ base.backgroundColor = options.highlightColor || 'var(--seekora-highlight-bg, rgba(251, 191, 36, 0.4))';
20
+ base.borderRadius = '2px';
21
+ base.padding = '0 2px';
22
+ break;
23
+ case 'underline':
24
+ base.textDecoration = 'underline';
25
+ base.textDecorationColor = options.highlightColor || 'var(--seekora-highlight-bg, rgba(251, 191, 36, 0.8))';
26
+ base.textUnderlineOffset = '2px';
27
+ break;
28
+ case 'bold':
29
+ // Only bold, no background
30
+ break;
31
+ case 'color-only':
32
+ // Only color, no background
33
+ break;
34
+ }
35
+ if (options.highlightTextColor) {
36
+ base.color = options.highlightTextColor;
37
+ }
38
+ else {
39
+ base.color = 'var(--seekora-highlight-color, inherit)';
40
+ }
41
+ base.fontWeight = options.highlightFontWeight || 'var(--seekora-highlight-weight, 500)';
42
+ return base;
43
+ }
13
44
  /**
14
45
  * Converts a string like "lined <mark>blue</mark>" into React nodes with
15
- * the marked part rendered as a <mark> element. When no <mark> tags are
46
+ * the marked part rendered as a styled element. When no <mark> tags are
16
47
  * present, returns the string as-is.
17
48
  */
18
49
  export function parseHighlightMarkup(text, options = {}) {
@@ -21,11 +52,18 @@ export function parseHighlightMarkup(text, options = {}) {
21
52
  const parts = text.split(/(<mark>[\s\S]*?<\/mark>)/g);
22
53
  if (parts.length <= 1)
23
54
  return text;
24
- const { markClassName, markStyle } = options;
55
+ const { markClassName, markStyle, highlightTag } = options;
56
+ const Tag = (highlightTag || 'mark');
57
+ // Compute styles: if no custom options provided, use legacy defaults
58
+ const hasCustomOptions = options.highlightColor || options.highlightTextColor
59
+ || options.highlightFontWeight || options.highlightStyle;
60
+ const computedStyle = hasCustomOptions
61
+ ? computeHighlightStyles(options)
62
+ : defaultMarkStyle;
25
63
  return (React.createElement(React.Fragment, null, parts.map((part, i) => {
26
64
  const m = part.match(/^<mark>([\s\S]*)<\/mark>$/);
27
65
  if (m) {
28
- return (React.createElement("mark", { key: i, className: markClassName, style: { ...defaultMarkStyle, ...markStyle } }, m[1]));
66
+ return (React.createElement(Tag, { key: i, className: markClassName, style: { ...computedStyle, ...markStyle } }, m[1]));
29
67
  }
30
68
  return part;
31
69
  })));
@@ -0,0 +1,36 @@
1
+ /**
2
+ * useClickTracking Hook
3
+ *
4
+ * Wraps result link clicks with analytics fire-before-navigate.
5
+ * - For target="_blank" links: fires analytics normally (page stays)
6
+ * - For same-page navigation: uses sendBeacon + small delay before navigation
7
+ * - Tracks click_target: 'new_tab' | 'same_page' | 'in_page'
8
+ * - Includes destination_url and source_url in click event payload
9
+ */
10
+ export type ClickTarget = 'new_tab' | 'same_page' | 'in_page';
11
+ export interface ClickTrackingOptions {
12
+ /** The destination URL the user is navigating to */
13
+ destinationUrl?: string;
14
+ /** The clicked item ID */
15
+ itemId: string;
16
+ /** Position in search results (1-based) */
17
+ position: number;
18
+ /** Click target type */
19
+ clickTarget?: ClickTarget;
20
+ /** Additional metadata */
21
+ metadata?: Record<string, unknown>;
22
+ }
23
+ export interface UseClickTrackingReturn {
24
+ /**
25
+ * Track a click event and optionally delay navigation.
26
+ * Returns a promise that resolves when tracking is complete.
27
+ */
28
+ trackClick: (options: ClickTrackingOptions) => Promise<void>;
29
+ /**
30
+ * Create an onClick handler that tracks before navigating.
31
+ * Use this to wrap <a> tag clicks.
32
+ */
33
+ createClickHandler: (options: ClickTrackingOptions, onComplete?: () => void) => (e: React.MouseEvent<HTMLAnchorElement>) => void;
34
+ }
35
+ export declare const useClickTracking: () => UseClickTrackingReturn;
36
+ //# sourceMappingURL=useClickTracking.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useClickTracking.d.ts","sourceRoot":"","sources":["../../src/hooks/useClickTracking.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAOH,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,WAAW,GAAG,SAAS,CAAC;AAE9D,MAAM,WAAW,oBAAoB;IACnC,oDAAoD;IACpD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,0BAA0B;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,2CAA2C;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,wBAAwB;IACxB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,0BAA0B;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,UAAU,EAAE,CAAC,OAAO,EAAE,oBAAoB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7D;;;OAGG;IACH,kBAAkB,EAAE,CAClB,OAAO,EAAE,oBAAoB,EAC7B,UAAU,CAAC,EAAE,MAAM,IAAI,KACpB,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,CAAC,iBAAiB,CAAC,KAAK,IAAI,CAAC;CACvD;AAED,eAAO,MAAM,gBAAgB,QAAO,sBAwFnC,CAAC"}
@@ -0,0 +1,96 @@
1
+ /**
2
+ * useClickTracking Hook
3
+ *
4
+ * Wraps result link clicks with analytics fire-before-navigate.
5
+ * - For target="_blank" links: fires analytics normally (page stays)
6
+ * - For same-page navigation: uses sendBeacon + small delay before navigation
7
+ * - Tracks click_target: 'new_tab' | 'same_page' | 'in_page'
8
+ * - Includes destination_url and source_url in click event payload
9
+ */
10
+ import { useCallback, useRef } from 'react';
11
+ import { useSearchContext } from '../components/SearchProvider';
12
+ import { useSearchState } from './useSearchState';
13
+ import { sendAnalyticsBeacon } from '@seekora-ai/ui-sdk-core';
14
+ export const useClickTracking = () => {
15
+ const { client, enableAnalytics, stateManager } = useSearchContext();
16
+ const { results } = useSearchState();
17
+ const pendingRef = useRef(false);
18
+ const trackClick = useCallback(async (options) => {
19
+ if (!enableAnalytics || pendingRef.current)
20
+ return;
21
+ pendingRef.current = true;
22
+ try {
23
+ const sourceUrl = typeof window !== 'undefined' ? window.location.href : undefined;
24
+ const searchContext = results?.context;
25
+ // Include A/B test fields from state manager (beacon payloads bypass client.trackEvent)
26
+ const abTestId = stateManager.getAbTestId?.();
27
+ const abVariant = stateManager.getAbVariant?.();
28
+ const eventPayload = {
29
+ event_name: 'product_click',
30
+ clicked_item_id: options.itemId,
31
+ position: options.position,
32
+ destination_url: options.destinationUrl || '',
33
+ source_url: sourceUrl || '',
34
+ click_target: options.clickTarget || 'in_page',
35
+ ...(abTestId && { ab_test_id: abTestId }),
36
+ ...(abVariant && { ab_variant: abVariant }),
37
+ ...options.metadata,
38
+ };
39
+ // For same_page navigation, use sendBeacon for reliability
40
+ if (options.clickTarget === 'same_page') {
41
+ if (client.trackClick) {
42
+ // Try sendBeacon approach
43
+ const baseUrl = client.baseUrl || client.apiUrl || '';
44
+ if (baseUrl) {
45
+ sendAnalyticsBeacon(`${baseUrl}/api/analytics/event`, {
46
+ ...eventPayload,
47
+ ...(searchContext || {}),
48
+ });
49
+ }
50
+ }
51
+ }
52
+ else {
53
+ // Normal tracking via client SDK
54
+ if (client.trackClick) {
55
+ await client.trackClick(options.itemId, options.position, searchContext);
56
+ }
57
+ else if (client.trackEvent) {
58
+ await client.trackEvent(eventPayload, searchContext);
59
+ }
60
+ }
61
+ }
62
+ catch {
63
+ // Silently fail — analytics should never break navigation
64
+ }
65
+ finally {
66
+ pendingRef.current = false;
67
+ }
68
+ }, [client, enableAnalytics, results, stateManager]);
69
+ const createClickHandler = useCallback((options, onComplete) => {
70
+ return (e) => {
71
+ const anchor = e.currentTarget;
72
+ const href = anchor.href;
73
+ const target = anchor.target;
74
+ const isNewTab = target === '_blank' || e.metaKey || e.ctrlKey;
75
+ const clickTarget = isNewTab ? 'new_tab' : 'same_page';
76
+ const trackOptions = { ...options, destinationUrl: href, clickTarget };
77
+ if (isNewTab) {
78
+ // New tab: page stays, track normally
79
+ trackClick(trackOptions);
80
+ onComplete?.();
81
+ }
82
+ else {
83
+ // Same page: prevent default, track with beacon, then navigate
84
+ e.preventDefault();
85
+ trackClick(trackOptions).then(() => {
86
+ // Small delay to ensure beacon fires
87
+ setTimeout(() => {
88
+ onComplete?.();
89
+ window.location.href = href;
90
+ }, 50);
91
+ });
92
+ }
93
+ };
94
+ }, [trackClick]);
95
+ return { trackClick, createClickHandler };
96
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * useExperiment Hook
3
+ *
4
+ * Fetches experiment variant assignments from the backend and caches them.
5
+ * Auto-injects ab_test_id and ab_variant into SearchProvider context.
6
+ *
7
+ * Usage:
8
+ * const { experimentId, variantId, variantConfig, isLoading } = useExperiment('search-ranking-v2');
9
+ */
10
+ export interface ExperimentAssignment {
11
+ experiment_id: string;
12
+ variant_id: string;
13
+ variant_config?: Record<string, unknown>;
14
+ }
15
+ export interface UseExperimentReturn {
16
+ experimentId: string | null;
17
+ variantId: string | null;
18
+ variantConfig: Record<string, unknown> | null;
19
+ isLoading: boolean;
20
+ error: Error | null;
21
+ /** All active assignments for the current user */
22
+ allAssignments: ExperimentAssignment[];
23
+ }
24
+ export declare const useExperiment: (experimentId?: string) => UseExperimentReturn;
25
+ //# sourceMappingURL=useExperiment.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useExperiment.d.ts","sourceRoot":"","sources":["../../src/hooks/useExperiment.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,MAAM,WAAW,oBAAoB;IACnC,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC1C;AAED,MAAM,WAAW,mBAAmB;IAClC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC9C,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB,kDAAkD;IAClD,cAAc,EAAE,oBAAoB,EAAE,CAAC;CACxC;AAsCD,eAAO,MAAM,aAAa,GAAI,eAAe,MAAM,KAAG,mBAkHrD,CAAC"}
@@ -0,0 +1,146 @@
1
+ /**
2
+ * useExperiment Hook
3
+ *
4
+ * Fetches experiment variant assignments from the backend and caches them.
5
+ * Auto-injects ab_test_id and ab_variant into SearchProvider context.
6
+ *
7
+ * Usage:
8
+ * const { experimentId, variantId, variantConfig, isLoading } = useExperiment('search-ranking-v2');
9
+ */
10
+ import { useState, useEffect, useCallback, useRef } from 'react';
11
+ import { useSearchContext } from '../components/SearchProvider';
12
+ const CACHE_PREFIX = 'seekora_experiment_';
13
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
14
+ function getCachedAssignment(userKey, experimentId) {
15
+ if (typeof localStorage === 'undefined')
16
+ return null;
17
+ try {
18
+ const key = `${CACHE_PREFIX}${userKey}_${experimentId}`;
19
+ const raw = localStorage.getItem(key);
20
+ if (!raw)
21
+ return null;
22
+ const cached = JSON.parse(raw);
23
+ if (Date.now() - cached.timestamp > CACHE_TTL_MS) {
24
+ localStorage.removeItem(key);
25
+ return null;
26
+ }
27
+ return cached.assignment;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ function setCachedAssignment(userKey, assignment) {
34
+ if (typeof localStorage === 'undefined')
35
+ return;
36
+ try {
37
+ const key = `${CACHE_PREFIX}${userKey}_${assignment.experiment_id}`;
38
+ const cached = { assignment, timestamp: Date.now() };
39
+ localStorage.setItem(key, JSON.stringify(cached));
40
+ }
41
+ catch {
42
+ // Silently fail — localStorage may be full or unavailable
43
+ }
44
+ }
45
+ export const useExperiment = (experimentId) => {
46
+ const { client, stateManager } = useSearchContext();
47
+ const [allAssignments, setAllAssignments] = useState([]);
48
+ const [isLoading, setIsLoading] = useState(false);
49
+ const [error, setError] = useState(null);
50
+ const fetchedRef = useRef(false);
51
+ const fetchAssignments = useCallback(async () => {
52
+ if (fetchedRef.current)
53
+ return;
54
+ fetchedRef.current = true;
55
+ setIsLoading(true);
56
+ try {
57
+ // Get user key from client config or generate anonymous one
58
+ const clientAny = client;
59
+ const baseUrl = clientAny.baseUrl || clientAny.apiUrl || '';
60
+ const storeId = clientAny.storeId || clientAny.xStoreId || '';
61
+ if (!baseUrl || !storeId) {
62
+ setIsLoading(false);
63
+ return;
64
+ }
65
+ // Try to determine user key
66
+ const userKey = clientAny.userId || clientAny.anonymousId || clientAny.anonId || '';
67
+ if (!userKey) {
68
+ setIsLoading(false);
69
+ return;
70
+ }
71
+ // Check cache first for specific experiment
72
+ if (experimentId) {
73
+ const cached = getCachedAssignment(userKey, experimentId);
74
+ if (cached) {
75
+ setAllAssignments([cached]);
76
+ // Update state manager and SDK client with cached assignment
77
+ stateManager.setAbTest(cached.experiment_id, cached.variant_id);
78
+ if (typeof client.setAbTest === 'function') {
79
+ client.setAbTest(cached.experiment_id, cached.variant_id);
80
+ }
81
+ setIsLoading(false);
82
+ return;
83
+ }
84
+ }
85
+ // Fetch from backend (requires store authentication for orgID resolution)
86
+ const readSecret = clientAny.readSecret || clientAny.storeSecret || '';
87
+ const headers = {
88
+ 'x-storeid': storeId,
89
+ 'x-user-id': userKey,
90
+ };
91
+ if (readSecret) {
92
+ headers['x-storesecret'] = readSecret;
93
+ }
94
+ const response = await fetch(`${baseUrl}/api/v1/experiments/assignment`, {
95
+ headers,
96
+ });
97
+ if (!response.ok) {
98
+ throw new Error(`Failed to fetch experiment assignments: ${response.status}`);
99
+ }
100
+ const data = await response.json();
101
+ const assignments = data.assignments || [];
102
+ // Cache all assignments
103
+ assignments.forEach(a => setCachedAssignment(userKey, a));
104
+ setAllAssignments(assignments);
105
+ // If a specific experiment was requested, set it on the state manager and SDK client
106
+ const setAbFields = (expId, varId) => {
107
+ stateManager.setAbTest(expId, varId);
108
+ if (typeof client.setAbTest === 'function') {
109
+ client.setAbTest(expId, varId);
110
+ }
111
+ };
112
+ if (experimentId) {
113
+ const match = assignments.find(a => a.experiment_id === experimentId);
114
+ if (match) {
115
+ setAbFields(match.experiment_id, match.variant_id);
116
+ }
117
+ }
118
+ else if (assignments.length > 0) {
119
+ // Use the first assignment by default
120
+ setAbFields(assignments[0].experiment_id, assignments[0].variant_id);
121
+ }
122
+ setError(null);
123
+ }
124
+ catch (err) {
125
+ setError(err instanceof Error ? err : new Error(String(err)));
126
+ }
127
+ finally {
128
+ setIsLoading(false);
129
+ }
130
+ }, [client, stateManager, experimentId]);
131
+ useEffect(() => {
132
+ fetchAssignments();
133
+ }, [fetchAssignments]);
134
+ // Find the specific experiment assignment
135
+ const assignment = experimentId
136
+ ? allAssignments.find(a => a.experiment_id === experimentId)
137
+ : allAssignments[0] || null;
138
+ return {
139
+ experimentId: assignment?.experiment_id || null,
140
+ variantId: assignment?.variant_id || null,
141
+ variantConfig: assignment?.variant_config || null,
142
+ isLoading,
143
+ error,
144
+ allAssignments,
145
+ };
146
+ };