@seekora-ai/ui-sdk-react 0.2.25 → 0.2.27

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.
@@ -485,6 +485,8 @@ interface FacetsProps {
485
485
  hideEmptyFacets?: boolean;
486
486
  /** Fields that should start collapsed (collapsible variant only). Overrides defaultCollapsed for listed fields. */
487
487
  defaultCollapsedFields?: string[];
488
+ /** Callback fired when facet availability changes. Receives true when facets are available, false when empty. */
489
+ onFacetsAvailable?: (available: boolean) => void;
488
490
  }
489
491
  declare const Facets: React__default.FC<FacetsProps>;
490
492
 
@@ -2185,6 +2187,8 @@ interface ProductDisplayConfig {
2185
2187
  showImage?: boolean;
2186
2188
  /** Image aspect ratio */
2187
2189
  imageAspectRatio?: '1:1' | '4:3' | '3:4' | '16:9' | 'auto';
2190
+ /** Image object-fit: 'cover' crops to fill (default), 'contain' fits entire image */
2191
+ imageObjectFit?: 'cover' | 'contain';
2188
2192
  /** Show price */
2189
2193
  showPrice?: boolean;
2190
2194
  /** Show compare/original price */
@@ -2709,11 +2709,8 @@ function getCurrentBreakpoint(width) {
2709
2709
  * Hook to get current breakpoint
2710
2710
  */
2711
2711
  function useBreakpoint() {
2712
- const [breakpoint, setBreakpoint] = useState(() => {
2713
- if (typeof window === 'undefined')
2714
- return 'lg';
2715
- return getCurrentBreakpoint(window.innerWidth);
2716
- });
2712
+ // Always start with 'lg' to avoid hydration mismatch between server and client
2713
+ const [breakpoint, setBreakpoint] = useState('lg');
2717
2714
  useEffect(() => {
2718
2715
  const handleResize = () => {
2719
2716
  setBreakpoint(getCurrentBreakpoint(window.innerWidth));
@@ -2728,11 +2725,8 @@ function useBreakpoint() {
2728
2725
  * Hook to check if current viewport matches breakpoint
2729
2726
  */
2730
2727
  function useMediaQuery(query) {
2731
- const [matches, setMatches] = useState(() => {
2732
- if (typeof window === 'undefined')
2733
- return false;
2734
- return window.matchMedia(query).matches;
2735
- });
2728
+ // Always start with false to avoid hydration mismatch between server and client
2729
+ const [matches, setMatches] = useState(false);
2736
2730
  useEffect(() => {
2737
2731
  const mediaQuery = window.matchMedia(query);
2738
2732
  const handleChange = () => setMatches(mediaQuery.matches);
@@ -3528,15 +3522,22 @@ const useFilters = (options) => {
3528
3522
  const [error, setError] = useState(null);
3529
3523
  const mountedRef = useRef(true);
3530
3524
  const autoFetch = options?.autoFetch !== false;
3525
+ // Track whether we've completed the first fetch (to avoid skeleton flash on refetches)
3526
+ const hasDataRef = useRef(false);
3531
3527
  // Extract non-autoFetch options to pass to fetchFilters
3532
3528
  const fetchFilters = useCallback(async () => {
3533
- setLoading(true);
3529
+ // Only show loading spinner on the very first fetch; subsequent refetches
3530
+ // keep previous data visible to avoid skeleton/flash on facet changes.
3531
+ if (!hasDataRef.current) {
3532
+ setLoading(true);
3533
+ }
3534
3534
  setError(null);
3535
3535
  try {
3536
3536
  const { autoFetch: _, ...filterOptions } = options || {};
3537
3537
  const response = await stateManager.fetchFilters(filterOptions);
3538
3538
  if (mountedRef.current) {
3539
3539
  setFilters(response?.filters || []);
3540
+ hasDataRef.current = true;
3540
3541
  setLoading(false);
3541
3542
  }
3542
3543
  }
@@ -3547,14 +3548,18 @@ const useFilters = (options) => {
3547
3548
  }
3548
3549
  }
3549
3550
  }, [stateManager, options?.facetBy, options?.maxFacetValues, options?.disjunctiveFacets?.join(',')]);
3550
- // Track query + refinements to only refetch when they actually change
3551
- const prevKeyRef = useRef('');
3551
+ // Track query to only refetch filters when query actually changes.
3552
+ // Sentinel ensures the first subscribe callback always triggers a fetch.
3553
+ const prevKeyRef = useRef(null);
3552
3554
  // Refetch when query or refinements change (not on every state update)
3553
3555
  useEffect(() => {
3554
3556
  if (!autoFetch)
3555
3557
  return;
3556
3558
  const unsubscribe = stateManager.subscribe((state) => {
3557
- const key = `${state.query}|${state.refinements.map(r => `${r.field}:${r.value}`).sort().join(',')}`;
3559
+ // Only track query changes — refinements are NOT passed to the Filters API
3560
+ // (facets are generated from search query only, not narrowed by active filters).
3561
+ // This prevents redundant filters refetches on every facet toggle.
3562
+ const key = state.query;
3558
3563
  if (key === prevKeyRef.current)
3559
3564
  return;
3560
3565
  prevKeyRef.current = key;
@@ -3904,7 +3909,7 @@ const CSS_VAR_DEFAULTS = {
3904
3909
  // ---------------------------------------------------------------------------
3905
3910
  // Component
3906
3911
  // ---------------------------------------------------------------------------
3907
- const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange, renderFacet, renderFacetItem, maxItems = 10, showMore = true, className, style, theme: customTheme, variant = 'checkbox', searchable = false, showCounts = true, colorMap, defaultCollapsed = false, size = 'medium', facetRanges, useFiltersApi = false, disjunctiveFacets, hideEmptyFacets = true, defaultCollapsedFields, }) => {
3912
+ const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange, renderFacet, renderFacetItem, maxItems = 10, showMore = true, className, style, theme: customTheme, variant = 'checkbox', searchable = false, showCounts = true, colorMap, defaultCollapsed = false, size = 'medium', facetRanges, useFiltersApi = false, disjunctiveFacets, hideEmptyFacets = true, defaultCollapsedFields, onFacetsAvailable, }) => {
3908
3913
  const { theme } = useSearchContext();
3909
3914
  const { results: stateResults, refinements, addRefinement, removeRefinement } = useSearchState();
3910
3915
  const facetsTheme = customTheme || {};
@@ -3982,9 +3987,22 @@ const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange, rende
3982
3987
  return extracted;
3983
3988
  };
3984
3989
  const rawFacetList = extractFacets();
3990
+ const hasStats = (stats) => stats != null && (stats.min != null || stats.max != null);
3985
3991
  const facets = hideEmptyFacets
3986
- ? rawFacetList.filter(f => f.items.length > 0 || f.stats != null)
3992
+ ? rawFacetList.filter(f => f.items.length > 0 || hasStats(f.stats))
3987
3993
  : rawFacetList;
3994
+ // Notify parent about facet availability
3995
+ const facetCount = facets.length;
3996
+ const prevFacetAvailableRef = useRef(null);
3997
+ useEffect(() => {
3998
+ if (!onFacetsAvailable)
3999
+ return;
4000
+ const available = facetCount > 0;
4001
+ if (prevFacetAvailableRef.current !== available) {
4002
+ prevFacetAvailableRef.current = available;
4003
+ onFacetsAvailable(available);
4004
+ }
4005
+ }, [facetCount, onFacetsAvailable]);
3988
4006
  // -------------------------------------------------------------------
3989
4007
  // Handlers
3990
4008
  // -------------------------------------------------------------------
@@ -10327,7 +10345,7 @@ function ImageDisplay({ images, variant = 'single', alt = '', className, style,
10327
10345
  if (variant === 'hover') {
10328
10346
  const showSecond = safeImages.length > 1 && hovering;
10329
10347
  const src = showSecond ? safeImages[1] : safeImages[0];
10330
- const hoverImgStyle = style?.aspectRatio ? { ...imgBaseStyle, aspectRatio: style.aspectRatio } : imgBaseStyle;
10348
+ const hoverImgStyle = style?.aspectRatio || style?.objectFit ? { ...imgBaseStyle, ...(style.aspectRatio ? { aspectRatio: style.aspectRatio } : {}), ...(style.objectFit ? { objectFit: style.objectFit } : {}) } : imgBaseStyle;
10331
10349
  if (enableZoom) {
10332
10350
  return (React.createElement("div", { className: clsx('seekora-img-display', 'seekora-img-hover', className), style: { position: 'relative', ...style }, onMouseEnter: () => setHovering(true), onMouseLeave: () => setHovering(false) },
10333
10351
  React.createElement(ImageZoom, { src: src, alt: alt, mode: zoomMode, zoomLevel: zoomLevel, images: safeImages, currentIndex: showSecond ? 1 : 0, className: "seekora-img-hover-img", style: hoverImgStyle })));
@@ -10346,7 +10364,7 @@ function ImageDisplay({ images, variant = 'single', alt = '', className, style,
10346
10364
  return next;
10347
10365
  });
10348
10366
  };
10349
- const carouselImgStyle = style?.aspectRatio ? { ...imgBaseStyle, aspectRatio: style.aspectRatio } : imgBaseStyle;
10367
+ const carouselImgStyle = style?.aspectRatio || style?.objectFit ? { ...imgBaseStyle, ...(style.aspectRatio ? { aspectRatio: style.aspectRatio } : {}), ...(style.objectFit ? { objectFit: style.objectFit } : {}) } : imgBaseStyle;
10350
10368
  const mainImage = enableZoom ? (React.createElement(ImageZoom, { src: current, alt: alt, mode: zoomMode, zoomLevel: zoomLevel, images: safeImages, currentIndex: index, className: "seekora-img-carousel-main", style: carouselImgStyle })) : (React.createElement("img", { src: current, alt: alt, className: "seekora-img-carousel-main", style: carouselImgStyle, loading: "lazy" }));
10351
10369
  return (React.createElement("div", { className: clsx('seekora-img-display', 'seekora-img-carousel', className), style: { position: 'relative', ...style } },
10352
10370
  mainImage,
@@ -10378,7 +10396,7 @@ function ImageDisplay({ images, variant = 'single', alt = '', className, style,
10378
10396
  } })))))))));
10379
10397
  }
10380
10398
  if (variant === 'thumbStrip' || variant === 'thumbList') {
10381
- const thumbMainStyle = style?.aspectRatio ? { ...imgBaseStyle, aspectRatio: style.aspectRatio } : imgBaseStyle;
10399
+ const thumbMainStyle = style?.aspectRatio || style?.objectFit ? { ...imgBaseStyle, ...(style.aspectRatio ? { aspectRatio: style.aspectRatio } : {}), ...(style.objectFit ? { objectFit: style.objectFit } : {}) } : imgBaseStyle;
10382
10400
  const mainImage = enableZoom ? (React.createElement(ImageZoom, { src: current, alt: alt, mode: zoomMode, zoomLevel: zoomLevel, images: safeImages, currentIndex: index, className: "seekora-img-thumb-main", style: thumbMainStyle })) : (React.createElement("img", { src: current, alt: alt, className: "seekora-img-thumb-main", style: thumbMainStyle, loading: "lazy" }));
10383
10401
  return (React.createElement("div", { className: clsx('seekora-img-display', 'seekora-img-thumbstrip', className), style: { display: 'flex', flexDirection: 'column', gap: responsiveGap, ...style } },
10384
10402
  mainImage,
@@ -11561,15 +11579,15 @@ const imgPlaceholderStyle = {
11561
11579
  borderRadius: BORDER_RADIUS$1.sm,
11562
11580
  backgroundColor: 'var(--seekora-bg-secondary, rgba(0,0,0,0.04))',
11563
11581
  };
11564
- function ImageBlock({ images, title, imageVariant, aspectRatio, enableZoom, zoomMode, zoomLevel }) {
11582
+ function ImageBlock({ images, title, imageVariant, aspectRatio, objectFit, enableZoom, zoomMode, zoomLevel }) {
11565
11583
  const ar = aspectRatio
11566
11584
  ? (aspectRatio.includes(':') ? aspectRatio.replace(':', '/') : aspectRatio)
11567
11585
  : '1';
11568
11586
  if (images.length > 0) {
11569
11587
  return (React.createElement("div", { className: "seekora-product-card__image", style: { position: 'relative', overflow: 'hidden', borderRadius: BORDER_RADIUS$1.sm } },
11570
- 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 })));
11588
+ React.createElement(ImageDisplay, { images: images, variant: images.length > 1 ? imageVariant : 'single', alt: title, className: "seekora-suggestions-product-card-image", style: { aspectRatio: ar, ...(objectFit ? { objectFit } : {}) }, enableZoom: enableZoom, zoomMode: zoomMode, zoomLevel: zoomLevel })));
11571
11589
  }
11572
- return React.createElement("div", { className: "seekora-product-card__image seekora-suggestions-product-card-placeholder", style: { ...imgPlaceholderStyle, aspectRatio: ar }, "aria-hidden": true });
11590
+ return React.createElement("div", { className: "seekora-product-card__image seekora-suggestions-product-card-placeholder", style: { ...imgPlaceholderStyle, aspectRatio: ar, ...(objectFit ? { objectFit } : {}) }, "aria-hidden": true });
11573
11591
  }
11574
11592
  /** minimal: image, title, price (current default behavior) */
11575
11593
  function MinimalLayout({ images, title, price, product, imageVariant, displayConfig, enableImageZoom, imageZoomMode, imageZoomLevel }) {
@@ -11577,7 +11595,7 @@ function MinimalLayout({ images, title, price, product, imageVariant, displayCon
11577
11595
  const titleFontSize = isMobile ? '0.9375rem' : '0.875rem'; // Slightly larger on mobile
11578
11596
  const priceFontSize = isMobile ? '0.9375rem' : '0.875rem';
11579
11597
  return (React.createElement(React.Fragment, null,
11580
- React.createElement(ImageBlock, { images: images, title: title, imageVariant: imageVariant, aspectRatio: displayConfig.imageAspectRatio, enableZoom: enableImageZoom, zoomMode: imageZoomMode, zoomLevel: imageZoomLevel }),
11598
+ React.createElement(ImageBlock, { images: images, title: title, imageVariant: imageVariant, aspectRatio: displayConfig.imageAspectRatio, objectFit: displayConfig.imageObjectFit, enableZoom: enableImageZoom, zoomMode: imageZoomMode, zoomLevel: imageZoomLevel }),
11581
11599
  React.createElement("span", { className: "seekora-product-card__title", style: { fontSize: titleFontSize, fontWeight: 500, lineHeight: 1.4, overflow: 'hidden', textOverflow: 'ellipsis', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' } }, title),
11582
11600
  price != null && !Number.isNaN(price) && (React.createElement("span", { className: "seekora-product-card__price seekora-suggestions-product-card-price", style: { fontSize: priceFontSize, color: 'inherit', opacity: 0.6 } },
11583
11601
  product.currency ?? '$',
@@ -11595,7 +11613,7 @@ function StandardLayout({ images, title, price, comparePrice, brand, badges, opt
11595
11613
  const isOverlayPosition = actionButtonsPosition?.startsWith('overlay');
11596
11614
  return (React.createElement(React.Fragment, null,
11597
11615
  React.createElement("div", { style: { position: 'relative' } },
11598
- React.createElement(ImageBlock, { images: images, title: title, imageVariant: imageVariant, aspectRatio: cfg.imageAspectRatio, enableZoom: enableImageZoom, zoomMode: imageZoomMode, zoomLevel: imageZoomLevel }),
11616
+ React.createElement(ImageBlock, { images: images, title: title, imageVariant: imageVariant, aspectRatio: cfg.imageAspectRatio, objectFit: cfg.imageObjectFit, enableZoom: enableImageZoom, zoomMode: imageZoomMode, zoomLevel: imageZoomLevel }),
11599
11617
  cfg.showBadges !== false && badges.length > 0 && (React.createElement(BadgeList, { badges: badges, position: "top-left", maxBadges: 2 })),
11600
11618
  actionButtons && actionButtons.length > 0 && isOverlayPosition && (React.createElement(ActionButtons, { buttons: actionButtons, position: actionButtonsPosition === 'overlay-top-right' ? 'top-right' : 'bottom-center', showLabels: showActionLabels, size: "small" }))),
11601
11619
  React.createElement("div", { className: "seekora-product-card__body", style: { display: 'flex', flexDirection: 'column', gap: bodyGap } },
@@ -11614,7 +11632,7 @@ function DetailedLayout({ images, title, price, comparePrice, brand, badges, pri
11614
11632
  const isOverlayPosition = actionButtonsPosition?.startsWith('overlay');
11615
11633
  return (React.createElement(React.Fragment, null,
11616
11634
  React.createElement("div", { style: { position: 'relative' } },
11617
- React.createElement(ImageBlock, { images: images, title: title, imageVariant: imageVariant, aspectRatio: cfg.imageAspectRatio, enableZoom: enableImageZoom, zoomMode: imageZoomMode, zoomLevel: imageZoomLevel }),
11635
+ React.createElement(ImageBlock, { images: images, title: title, imageVariant: imageVariant, aspectRatio: cfg.imageAspectRatio, objectFit: cfg.imageObjectFit, enableZoom: enableImageZoom, zoomMode: imageZoomMode, zoomLevel: imageZoomLevel }),
11618
11636
  cfg.showBadges !== false && badges.length > 0 && (React.createElement(BadgeList, { badges: badges, position: "top-left" })),
11619
11637
  actionButtons && actionButtons.length > 0 && isOverlayPosition && (React.createElement(ActionButtons, { buttons: actionButtons, position: actionButtonsPosition === 'overlay-top-right' ? 'top-right' : 'bottom-center', showLabels: showActionLabels, size: "small" }))),
11620
11638
  React.createElement("div", { className: "seekora-product-card__body", style: { display: 'flex', flexDirection: 'column', gap: 4 } },