@seekora-ai/ui-sdk-react 0.2.11 → 0.2.12
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.
- package/dist/components/primitives/ActionButtons.d.ts +27 -0
- package/dist/components/primitives/ActionButtons.d.ts.map +1 -0
- package/dist/components/primitives/ActionButtons.js +78 -0
- package/dist/components/primitives/AnalyticsProvider.d.ts +22 -0
- package/dist/components/primitives/AnalyticsProvider.d.ts.map +1 -0
- package/dist/components/primitives/AnalyticsProvider.js +87 -0
- package/dist/components/primitives/BadgeList.d.ts +14 -0
- package/dist/components/primitives/BadgeList.d.ts.map +1 -0
- package/dist/components/primitives/BadgeList.js +45 -0
- package/dist/components/primitives/ImageDisplay.d.ts +10 -1
- package/dist/components/primitives/ImageDisplay.d.ts.map +1 -1
- package/dist/components/primitives/ImageDisplay.js +49 -9
- package/dist/components/primitives/ImageZoom.d.ts +33 -0
- package/dist/components/primitives/ImageZoom.d.ts.map +1 -0
- package/dist/components/primitives/ImageZoom.js +357 -0
- package/dist/components/primitives/PriceDisplay.d.ts +21 -0
- package/dist/components/primitives/PriceDisplay.d.ts.map +1 -0
- package/dist/components/primitives/PriceDisplay.js +44 -0
- package/dist/components/primitives/RatingDisplay.d.ts +43 -0
- package/dist/components/primitives/RatingDisplay.d.ts.map +1 -0
- package/dist/components/primitives/RatingDisplay.js +114 -0
- package/dist/components/primitives/VariantSelector.d.ts +30 -0
- package/dist/components/primitives/VariantSelector.d.ts.map +1 -0
- package/dist/components/primitives/VariantSelector.js +162 -0
- package/dist/components/primitives/VariantSwatches.d.ts +28 -0
- package/dist/components/primitives/VariantSwatches.d.ts.map +1 -0
- package/dist/components/primitives/VariantSwatches.js +173 -0
- package/dist/components/primitives/index.d.ts +9 -0
- package/dist/components/primitives/index.d.ts.map +1 -1
- package/dist/components/primitives/index.js +9 -0
- package/dist/components/primitives/withAnalytics.d.ts +24 -0
- package/dist/components/primitives/withAnalytics.d.ts.map +1 -0
- package/dist/components/primitives/withAnalytics.js +73 -0
- package/dist/components/product-page/ProductInfo.d.ts +25 -2
- package/dist/components/product-page/ProductInfo.d.ts.map +1 -1
- package/dist/components/product-page/ProductInfo.js +20 -5
- package/dist/components/suggestions/types.d.ts +24 -0
- package/dist/components/suggestions/types.d.ts.map +1 -1
- package/dist/components/suggestions/utils.d.ts +37 -0
- package/dist/components/suggestions/utils.d.ts.map +1 -1
- package/dist/components/suggestions/utils.js +118 -0
- package/dist/components/suggestions-primitives/ItemCard.d.ts +10 -1
- package/dist/components/suggestions-primitives/ItemCard.d.ts.map +1 -1
- package/dist/components/suggestions-primitives/ItemCard.js +20 -6
- package/dist/components/suggestions-primitives/ProductCard.d.ts +27 -3
- package/dist/components/suggestions-primitives/ProductCard.d.ts.map +1 -1
- package/dist/components/suggestions-primitives/ProductCard.js +124 -17
- package/dist/components/suggestions-primitives/ProductCardLayouts.d.ts +44 -0
- package/dist/components/suggestions-primitives/ProductCardLayouts.d.ts.map +1 -0
- package/dist/components/suggestions-primitives/ProductCardLayouts.js +105 -0
- package/dist/components/suggestions-primitives/ProductGrid.d.ts +6 -1
- package/dist/components/suggestions-primitives/ProductGrid.d.ts.map +1 -1
- package/dist/components/suggestions-primitives/ProductGrid.js +2 -2
- package/dist/hooks/useProductAnalytics.d.ts +49 -0
- package/dist/hooks/useProductAnalytics.d.ts.map +1 -0
- package/dist/hooks/useProductAnalytics.js +116 -0
- package/dist/hooks/useSuggestionsAnalytics.d.ts.map +1 -1
- package/dist/hooks/useSuggestionsAnalytics.js +6 -0
- package/dist/hooks/useVariantSelection.d.ts +28 -0
- package/dist/hooks/useVariantSelection.d.ts.map +1 -0
- package/dist/hooks/useVariantSelection.js +44 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.umd.js +1 -1
- package/dist/src/index.d.ts +1107 -679
- package/dist/src/index.esm.js +2267 -600
- package/dist/src/index.esm.js.map +1 -1
- package/dist/src/index.js +2283 -599
- package/dist/src/index.js.map +1 -1
- package/package.json +3 -3
package/dist/src/index.js
CHANGED
|
@@ -6263,8 +6263,7 @@ const EVENTS = {
|
|
|
6263
6263
|
TRENDING_CLICK: 'suggestions.trending_click',
|
|
6264
6264
|
SEARCH_SUBMIT: 'suggestions.search_submit',
|
|
6265
6265
|
DROPDOWN_OPEN: 'suggestions.dropdown_open',
|
|
6266
|
-
DROPDOWN_CLOSE: 'suggestions.dropdown_close'
|
|
6267
|
-
};
|
|
6266
|
+
DROPDOWN_CLOSE: 'suggestions.dropdown_close'};
|
|
6268
6267
|
// ============================================================================
|
|
6269
6268
|
// Hook Implementation
|
|
6270
6269
|
// ============================================================================
|
|
@@ -7234,6 +7233,362 @@ function SuggestionList({ maxItems = 10, className, style, listClassName, enable
|
|
|
7234
7233
|
}))));
|
|
7235
7234
|
}
|
|
7236
7235
|
|
|
7236
|
+
/**
|
|
7237
|
+
* ImageZoom – zoom on hover and click (Amazon-style magnifier + lightbox)
|
|
7238
|
+
*
|
|
7239
|
+
* Supports three zoom modes:
|
|
7240
|
+
* - hover: Magnified view in a separate panel on the right (Amazon style)
|
|
7241
|
+
* - lens: Magnifying glass that follows cursor
|
|
7242
|
+
* - click: Full-screen lightbox modal
|
|
7243
|
+
*/
|
|
7244
|
+
function ImageZoom({ src, alt = '', mode = 'both', zoomLevel = 2.5, className, style, showZoomIndicator = true, lensSize = 150, zoomPanelSize = { width: 400, height: 400 }, images, currentIndex = 0, }) {
|
|
7245
|
+
const [isHovering, setIsHovering] = React.useState(false);
|
|
7246
|
+
const [isLightboxOpen, setIsLightboxOpen] = React.useState(false);
|
|
7247
|
+
const [lightboxIndex, setLightboxIndex] = React.useState(currentIndex);
|
|
7248
|
+
const [cursorPos, setCursorPos] = React.useState({ x: 0, y: 0 });
|
|
7249
|
+
const [imageLoaded, setImageLoaded] = React.useState(false);
|
|
7250
|
+
const [touchStart, setTouchStart] = React.useState(null);
|
|
7251
|
+
const [touchEnd, setTouchEnd] = React.useState(null);
|
|
7252
|
+
const imageRef = React.useRef(null);
|
|
7253
|
+
const containerRef = React.useRef(null);
|
|
7254
|
+
const allImages = images && images.length > 0 ? images : [src];
|
|
7255
|
+
const hasMultipleImages = allImages.length > 1;
|
|
7256
|
+
// Minimum swipe distance (in px) to trigger navigation
|
|
7257
|
+
const minSwipeDistance = 50;
|
|
7258
|
+
const supportsHover = mode === 'hover' || mode === 'both';
|
|
7259
|
+
const supportsClick = mode === 'click' || mode === 'both';
|
|
7260
|
+
const supportsLens = mode === 'lens';
|
|
7261
|
+
const handleMouseMove = React.useCallback((e) => {
|
|
7262
|
+
if (!containerRef.current)
|
|
7263
|
+
return;
|
|
7264
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
7265
|
+
const x = e.clientX - rect.left;
|
|
7266
|
+
const y = e.clientY - rect.top;
|
|
7267
|
+
setCursorPos({ x, y });
|
|
7268
|
+
}, []);
|
|
7269
|
+
const handleMouseEnter = React.useCallback(() => {
|
|
7270
|
+
setIsHovering(true);
|
|
7271
|
+
}, []);
|
|
7272
|
+
const handleMouseLeave = React.useCallback(() => {
|
|
7273
|
+
setIsHovering(false);
|
|
7274
|
+
}, []);
|
|
7275
|
+
const handleClick = React.useCallback((e) => {
|
|
7276
|
+
if (supportsClick) {
|
|
7277
|
+
e?.stopPropagation();
|
|
7278
|
+
setLightboxIndex(currentIndex);
|
|
7279
|
+
setIsLightboxOpen(true);
|
|
7280
|
+
}
|
|
7281
|
+
}, [supportsClick, currentIndex]);
|
|
7282
|
+
const closeLightbox = React.useCallback((e) => {
|
|
7283
|
+
e?.stopPropagation();
|
|
7284
|
+
setIsLightboxOpen(false);
|
|
7285
|
+
}, []);
|
|
7286
|
+
const goToNext = React.useCallback(() => {
|
|
7287
|
+
setLightboxIndex((i) => (i + 1) % allImages.length);
|
|
7288
|
+
}, [allImages.length]);
|
|
7289
|
+
const goToPrev = React.useCallback(() => {
|
|
7290
|
+
setLightboxIndex((i) => (i - 1 + allImages.length) % allImages.length);
|
|
7291
|
+
}, [allImages.length]);
|
|
7292
|
+
// Touch event handlers for swipe
|
|
7293
|
+
const handleTouchStart = React.useCallback((e) => {
|
|
7294
|
+
setTouchEnd(null);
|
|
7295
|
+
setTouchStart(e.targetTouches[0].clientX);
|
|
7296
|
+
}, []);
|
|
7297
|
+
const handleTouchMove = React.useCallback((e) => {
|
|
7298
|
+
setTouchEnd(e.targetTouches[0].clientX);
|
|
7299
|
+
}, []);
|
|
7300
|
+
const handleTouchEnd = React.useCallback(() => {
|
|
7301
|
+
if (!touchStart || !touchEnd)
|
|
7302
|
+
return;
|
|
7303
|
+
const distance = touchStart - touchEnd;
|
|
7304
|
+
const isLeftSwipe = distance > minSwipeDistance;
|
|
7305
|
+
const isRightSwipe = distance < -minSwipeDistance;
|
|
7306
|
+
if (isLeftSwipe) {
|
|
7307
|
+
goToNext();
|
|
7308
|
+
}
|
|
7309
|
+
else if (isRightSwipe) {
|
|
7310
|
+
goToPrev();
|
|
7311
|
+
}
|
|
7312
|
+
}, [touchStart, touchEnd, minSwipeDistance, goToNext, goToPrev]);
|
|
7313
|
+
// Close lightbox on Escape key, navigate with arrow keys
|
|
7314
|
+
React.useEffect(() => {
|
|
7315
|
+
if (!isLightboxOpen)
|
|
7316
|
+
return;
|
|
7317
|
+
const handleKeyboard = (e) => {
|
|
7318
|
+
if (e.key === 'Escape')
|
|
7319
|
+
closeLightbox();
|
|
7320
|
+
if (hasMultipleImages) {
|
|
7321
|
+
if (e.key === 'ArrowRight')
|
|
7322
|
+
goToNext();
|
|
7323
|
+
if (e.key === 'ArrowLeft')
|
|
7324
|
+
goToPrev();
|
|
7325
|
+
}
|
|
7326
|
+
};
|
|
7327
|
+
window.addEventListener('keydown', handleKeyboard);
|
|
7328
|
+
return () => window.removeEventListener('keydown', handleKeyboard);
|
|
7329
|
+
}, [isLightboxOpen, closeLightbox, hasMultipleImages, goToNext, goToPrev]);
|
|
7330
|
+
const containerStyle = {
|
|
7331
|
+
position: 'relative',
|
|
7332
|
+
display: 'inline-block',
|
|
7333
|
+
cursor: supportsClick ? 'zoom-in' : supportsHover || supportsLens ? 'crosshair' : 'default',
|
|
7334
|
+
overflow: 'hidden',
|
|
7335
|
+
...style,
|
|
7336
|
+
};
|
|
7337
|
+
const imageStyle = {
|
|
7338
|
+
width: '100%',
|
|
7339
|
+
height: '100%',
|
|
7340
|
+
objectFit: 'cover',
|
|
7341
|
+
display: 'block',
|
|
7342
|
+
};
|
|
7343
|
+
// Calculate zoom panel background position (Amazon-style)
|
|
7344
|
+
const getZoomPanelStyle = () => {
|
|
7345
|
+
if (!containerRef.current || !imageLoaded)
|
|
7346
|
+
return { display: 'none' };
|
|
7347
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
7348
|
+
const bgPosX = (cursorPos.x / rect.width) * 100;
|
|
7349
|
+
const bgPosY = (cursorPos.y / rect.height) * 100;
|
|
7350
|
+
return {
|
|
7351
|
+
position: 'fixed', // Changed from absolute to fixed for better positioning
|
|
7352
|
+
top: rect.top,
|
|
7353
|
+
left: rect.right + 16, // 16px gap from the image
|
|
7354
|
+
width: zoomPanelSize.width,
|
|
7355
|
+
height: zoomPanelSize.height,
|
|
7356
|
+
backgroundImage: `url(${src})`,
|
|
7357
|
+
backgroundSize: `${zoomLevel * 100}%`,
|
|
7358
|
+
backgroundPosition: `${bgPosX}% ${bgPosY}%`,
|
|
7359
|
+
backgroundRepeat: 'no-repeat',
|
|
7360
|
+
border: '2px solid var(--seekora-border-color, #e5e7eb)',
|
|
7361
|
+
borderRadius: 8,
|
|
7362
|
+
boxShadow: '0 8px 24px rgba(0,0,0,0.2)',
|
|
7363
|
+
backgroundColor: '#fff',
|
|
7364
|
+
pointerEvents: 'none',
|
|
7365
|
+
zIndex: 9998,
|
|
7366
|
+
};
|
|
7367
|
+
};
|
|
7368
|
+
// Calculate lens position and zoom
|
|
7369
|
+
const getLensStyle = () => {
|
|
7370
|
+
if (!containerRef.current)
|
|
7371
|
+
return { display: 'none' };
|
|
7372
|
+
return {
|
|
7373
|
+
position: 'absolute',
|
|
7374
|
+
width: lensSize,
|
|
7375
|
+
height: lensSize,
|
|
7376
|
+
left: cursorPos.x - lensSize / 2,
|
|
7377
|
+
top: cursorPos.y - lensSize / 2,
|
|
7378
|
+
border: '2px solid rgba(255,255,255,0.8)',
|
|
7379
|
+
borderRadius: '50%',
|
|
7380
|
+
boxShadow: '0 0 0 1px rgba(0,0,0,0.3), inset 0 0 0 1px rgba(0,0,0,0.3)',
|
|
7381
|
+
pointerEvents: 'none',
|
|
7382
|
+
overflow: 'hidden',
|
|
7383
|
+
zIndex: 100,
|
|
7384
|
+
};
|
|
7385
|
+
};
|
|
7386
|
+
const getLensImageStyle = () => {
|
|
7387
|
+
if (!containerRef.current || !imageRef.current)
|
|
7388
|
+
return {};
|
|
7389
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
7390
|
+
imageRef.current.getBoundingClientRect();
|
|
7391
|
+
return {
|
|
7392
|
+
position: 'absolute',
|
|
7393
|
+
width: rect.width * zoomLevel,
|
|
7394
|
+
height: rect.height * zoomLevel,
|
|
7395
|
+
left: -(cursorPos.x * zoomLevel - lensSize / 2),
|
|
7396
|
+
top: -(cursorPos.y * zoomLevel - lensSize / 2),
|
|
7397
|
+
objectFit: 'cover',
|
|
7398
|
+
};
|
|
7399
|
+
};
|
|
7400
|
+
return (React.createElement(React.Fragment, null,
|
|
7401
|
+
React.createElement("div", { ref: containerRef, className: clsx('seekora-image-zoom', className), style: containerStyle, onMouseMove: handleMouseMove, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, onClick: handleClick },
|
|
7402
|
+
React.createElement("img", { ref: imageRef, src: src, alt: alt, style: imageStyle, onLoad: () => setImageLoaded(true) }),
|
|
7403
|
+
showZoomIndicator && supportsClick && (React.createElement("div", { style: {
|
|
7404
|
+
position: 'absolute',
|
|
7405
|
+
top: 8,
|
|
7406
|
+
right: 8,
|
|
7407
|
+
width: 32,
|
|
7408
|
+
height: 32,
|
|
7409
|
+
borderRadius: '50%',
|
|
7410
|
+
backgroundColor: 'rgba(0,0,0,0.6)',
|
|
7411
|
+
color: '#fff',
|
|
7412
|
+
display: 'flex',
|
|
7413
|
+
alignItems: 'center',
|
|
7414
|
+
justifyContent: 'center',
|
|
7415
|
+
fontSize: '1.25rem',
|
|
7416
|
+
pointerEvents: 'none',
|
|
7417
|
+
opacity: isHovering ? 1 : 0.7,
|
|
7418
|
+
transition: 'opacity 150ms',
|
|
7419
|
+
} }, "\uD83D\uDD0D")),
|
|
7420
|
+
supportsLens && isHovering && imageLoaded && (React.createElement("div", { style: getLensStyle() },
|
|
7421
|
+
React.createElement("img", { src: src, alt: "", style: getLensImageStyle() }))),
|
|
7422
|
+
supportsHover && isHovering && imageLoaded && containerRef.current && (React.createElement("div", { style: {
|
|
7423
|
+
position: 'absolute',
|
|
7424
|
+
left: cursorPos.x - 75,
|
|
7425
|
+
top: cursorPos.y - 75,
|
|
7426
|
+
width: 150,
|
|
7427
|
+
height: 150,
|
|
7428
|
+
border: '2px solid rgba(0,0,0,0.3)',
|
|
7429
|
+
backgroundColor: 'rgba(255,255,255,0.1)',
|
|
7430
|
+
pointerEvents: 'none',
|
|
7431
|
+
zIndex: 50,
|
|
7432
|
+
} })),
|
|
7433
|
+
supportsHover && isHovering && imageLoaded && (React.createElement("div", { style: getZoomPanelStyle() }))),
|
|
7434
|
+
isLightboxOpen && (React.createElement("div", { className: "seekora-image-zoom-lightbox", style: {
|
|
7435
|
+
position: 'fixed',
|
|
7436
|
+
top: 0,
|
|
7437
|
+
left: 0,
|
|
7438
|
+
right: 0,
|
|
7439
|
+
bottom: 0,
|
|
7440
|
+
backgroundColor: 'rgba(0,0,0,0.95)',
|
|
7441
|
+
zIndex: 9999,
|
|
7442
|
+
display: 'flex',
|
|
7443
|
+
alignItems: 'center',
|
|
7444
|
+
justifyContent: 'center',
|
|
7445
|
+
cursor: 'zoom-out',
|
|
7446
|
+
padding: 20,
|
|
7447
|
+
}, onClick: closeLightbox, onTouchStart: hasMultipleImages ? handleTouchStart : undefined, onTouchMove: hasMultipleImages ? handleTouchMove : undefined, onTouchEnd: hasMultipleImages ? handleTouchEnd : undefined },
|
|
7448
|
+
React.createElement("button", { type: "button", "aria-label": "Close zoom", style: {
|
|
7449
|
+
position: 'absolute',
|
|
7450
|
+
top: 20,
|
|
7451
|
+
right: 20,
|
|
7452
|
+
width: 44,
|
|
7453
|
+
height: 44,
|
|
7454
|
+
borderRadius: '50%',
|
|
7455
|
+
border: 'none',
|
|
7456
|
+
backgroundColor: 'rgba(255,255,255,0.2)',
|
|
7457
|
+
color: '#fff',
|
|
7458
|
+
fontSize: '1.5rem',
|
|
7459
|
+
cursor: 'pointer',
|
|
7460
|
+
display: 'flex',
|
|
7461
|
+
alignItems: 'center',
|
|
7462
|
+
justifyContent: 'center',
|
|
7463
|
+
transition: 'background-color 150ms',
|
|
7464
|
+
zIndex: 10001,
|
|
7465
|
+
}, onClick: (e) => {
|
|
7466
|
+
e.stopPropagation();
|
|
7467
|
+
closeLightbox();
|
|
7468
|
+
}, onMouseEnter: (e) => {
|
|
7469
|
+
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.3)';
|
|
7470
|
+
}, onMouseLeave: (e) => {
|
|
7471
|
+
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.2)';
|
|
7472
|
+
} }, "\u2715"),
|
|
7473
|
+
hasMultipleImages && (React.createElement(React.Fragment, null,
|
|
7474
|
+
React.createElement("button", { type: "button", "aria-label": "Previous image", style: {
|
|
7475
|
+
position: 'absolute',
|
|
7476
|
+
left: 20,
|
|
7477
|
+
top: '50%',
|
|
7478
|
+
transform: 'translateY(-50%)',
|
|
7479
|
+
width: 56,
|
|
7480
|
+
height: 56,
|
|
7481
|
+
borderRadius: '50%',
|
|
7482
|
+
border: 'none',
|
|
7483
|
+
backgroundColor: 'rgba(255,255,255,0.2)',
|
|
7484
|
+
color: '#fff',
|
|
7485
|
+
fontSize: '2rem',
|
|
7486
|
+
fontWeight: 'bold',
|
|
7487
|
+
cursor: 'pointer',
|
|
7488
|
+
display: 'flex',
|
|
7489
|
+
alignItems: 'center',
|
|
7490
|
+
justifyContent: 'center',
|
|
7491
|
+
transition: 'background-color 150ms',
|
|
7492
|
+
zIndex: 10001,
|
|
7493
|
+
}, onClick: (e) => {
|
|
7494
|
+
e.stopPropagation();
|
|
7495
|
+
goToPrev();
|
|
7496
|
+
}, onMouseEnter: (e) => {
|
|
7497
|
+
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.3)';
|
|
7498
|
+
}, onMouseLeave: (e) => {
|
|
7499
|
+
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.2)';
|
|
7500
|
+
} }, "\u2039"),
|
|
7501
|
+
React.createElement("button", { type: "button", "aria-label": "Next image", style: {
|
|
7502
|
+
position: 'absolute',
|
|
7503
|
+
right: 20,
|
|
7504
|
+
top: '50%',
|
|
7505
|
+
transform: 'translateY(-50%)',
|
|
7506
|
+
width: 56,
|
|
7507
|
+
height: 56,
|
|
7508
|
+
borderRadius: '50%',
|
|
7509
|
+
border: 'none',
|
|
7510
|
+
backgroundColor: 'rgba(255,255,255,0.2)',
|
|
7511
|
+
color: '#fff',
|
|
7512
|
+
fontSize: '2rem',
|
|
7513
|
+
fontWeight: 'bold',
|
|
7514
|
+
cursor: 'pointer',
|
|
7515
|
+
display: 'flex',
|
|
7516
|
+
alignItems: 'center',
|
|
7517
|
+
justifyContent: 'center',
|
|
7518
|
+
transition: 'background-color 150ms',
|
|
7519
|
+
zIndex: 10001,
|
|
7520
|
+
}, onClick: (e) => {
|
|
7521
|
+
e.stopPropagation();
|
|
7522
|
+
goToNext();
|
|
7523
|
+
}, onMouseEnter: (e) => {
|
|
7524
|
+
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.3)';
|
|
7525
|
+
}, onMouseLeave: (e) => {
|
|
7526
|
+
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.2)';
|
|
7527
|
+
} }, "\u203A"))),
|
|
7528
|
+
React.createElement("img", { src: allImages[lightboxIndex], alt: alt, style: {
|
|
7529
|
+
maxWidth: '90%',
|
|
7530
|
+
maxHeight: '90%',
|
|
7531
|
+
objectFit: 'contain',
|
|
7532
|
+
borderRadius: 4,
|
|
7533
|
+
cursor: 'default',
|
|
7534
|
+
}, onClick: (e) => e.stopPropagation() }),
|
|
7535
|
+
hasMultipleImages && (React.createElement("div", { style: {
|
|
7536
|
+
position: 'absolute',
|
|
7537
|
+
bottom: 20,
|
|
7538
|
+
left: '50%',
|
|
7539
|
+
transform: 'translateX(-50%)',
|
|
7540
|
+
display: 'flex',
|
|
7541
|
+
flexDirection: 'column',
|
|
7542
|
+
alignItems: 'center',
|
|
7543
|
+
gap: 12,
|
|
7544
|
+
}, onClick: (e) => e.stopPropagation() },
|
|
7545
|
+
React.createElement("div", { style: { display: 'flex', gap: 8, overflowX: 'auto', maxWidth: '80vw', padding: '8px 0' } }, allImages.map((img, i) => (React.createElement("button", { key: i, type: "button", onClick: (e) => {
|
|
7546
|
+
e.stopPropagation();
|
|
7547
|
+
setLightboxIndex(i);
|
|
7548
|
+
}, style: {
|
|
7549
|
+
width: 60,
|
|
7550
|
+
height: 60,
|
|
7551
|
+
padding: 0,
|
|
7552
|
+
border: i === lightboxIndex ? '3px solid #fff' : '2px solid rgba(255,255,255,0.3)',
|
|
7553
|
+
borderRadius: 4,
|
|
7554
|
+
overflow: 'hidden',
|
|
7555
|
+
cursor: 'pointer',
|
|
7556
|
+
opacity: i === lightboxIndex ? 1 : 0.6,
|
|
7557
|
+
transition: 'all 150ms ease',
|
|
7558
|
+
flexShrink: 0,
|
|
7559
|
+
background: 'none',
|
|
7560
|
+
}, onMouseEnter: (e) => {
|
|
7561
|
+
e.currentTarget.style.opacity = '1';
|
|
7562
|
+
}, onMouseLeave: (e) => {
|
|
7563
|
+
if (i !== lightboxIndex)
|
|
7564
|
+
e.currentTarget.style.opacity = '0.6';
|
|
7565
|
+
} },
|
|
7566
|
+
React.createElement("img", { src: img, alt: "", style: { width: '100%', height: '100%', objectFit: 'cover' } }))))),
|
|
7567
|
+
React.createElement("div", { style: {
|
|
7568
|
+
color: 'rgba(255,255,255,0.9)',
|
|
7569
|
+
fontSize: '0.875rem',
|
|
7570
|
+
textAlign: 'center',
|
|
7571
|
+
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
7572
|
+
padding: '4px 12px',
|
|
7573
|
+
borderRadius: 12,
|
|
7574
|
+
} },
|
|
7575
|
+
lightboxIndex + 1,
|
|
7576
|
+
" / ",
|
|
7577
|
+
allImages.length))),
|
|
7578
|
+
React.createElement("div", { style: {
|
|
7579
|
+
position: 'absolute',
|
|
7580
|
+
top: 20,
|
|
7581
|
+
left: '50%',
|
|
7582
|
+
transform: 'translateX(-50%)',
|
|
7583
|
+
color: 'rgba(255,255,255,0.7)',
|
|
7584
|
+
fontSize: '0.875rem',
|
|
7585
|
+
textAlign: 'center',
|
|
7586
|
+
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
7587
|
+
padding: '8px 16px',
|
|
7588
|
+
borderRadius: 12,
|
|
7589
|
+
} }, hasMultipleImages ? 'Use arrow keys or click thumbnails to navigate • ESC to close' : 'Click outside or press ESC to close')))));
|
|
7590
|
+
}
|
|
7591
|
+
|
|
7237
7592
|
/**
|
|
7238
7593
|
* ImageDisplay – configurable multi-image display (primitive)
|
|
7239
7594
|
*
|
|
@@ -7247,7 +7602,7 @@ const imgBaseStyle = {
|
|
|
7247
7602
|
borderRadius: 4,
|
|
7248
7603
|
backgroundColor: 'var(--seekora-bg-secondary, #f3f4f6)',
|
|
7249
7604
|
};
|
|
7250
|
-
function ImageDisplay({ images, variant = 'single', alt = '', className, style, carouselAutoplay = false, carouselIntervalMs = 4000, }) {
|
|
7605
|
+
function ImageDisplay({ images, variant = 'single', alt = '', className, style, carouselAutoplay = false, carouselIntervalMs = 4000, enableZoom = false, zoomMode = 'both', zoomLevel = 2.5, showDots = true, }) {
|
|
7251
7606
|
const [index, setIndex] = React.useState(0);
|
|
7252
7607
|
const [hovering, setHovering] = React.useState(false);
|
|
7253
7608
|
const safeImages = Array.isArray(images) ? images.filter(Boolean) : [];
|
|
@@ -7256,13 +7611,21 @@ function ImageDisplay({ images, variant = 'single', alt = '', className, style,
|
|
|
7256
7611
|
return React.createElement("div", { className: clsx('seekora-img-display', 'seekora-img-placeholder', className), style: { ...imgBaseStyle, ...style }, "aria-hidden": true });
|
|
7257
7612
|
}
|
|
7258
7613
|
if (variant === 'single') {
|
|
7614
|
+
if (enableZoom) {
|
|
7615
|
+
return (React.createElement(ImageZoom, { src: safeImages[0], alt: alt, mode: zoomMode, zoomLevel: zoomLevel, images: safeImages, currentIndex: 0, className: clsx('seekora-img-display', 'seekora-img-single', className), style: { ...imgBaseStyle, ...style } }));
|
|
7616
|
+
}
|
|
7259
7617
|
return (React.createElement("img", { src: safeImages[0], alt: alt, className: clsx('seekora-img-display', 'seekora-img-single', className), style: { ...imgBaseStyle, ...style }, loading: "lazy" }));
|
|
7260
7618
|
}
|
|
7261
7619
|
if (variant === 'hover') {
|
|
7262
7620
|
const showSecond = safeImages.length > 1 && hovering;
|
|
7263
7621
|
const src = showSecond ? safeImages[1] : safeImages[0];
|
|
7622
|
+
const hoverImgStyle = style?.aspectRatio ? { ...imgBaseStyle, aspectRatio: style.aspectRatio } : imgBaseStyle;
|
|
7623
|
+
if (enableZoom) {
|
|
7624
|
+
return (React.createElement("div", { className: clsx('seekora-img-display', 'seekora-img-hover', className), style: { position: 'relative', ...style }, onMouseEnter: () => setHovering(true), onMouseLeave: () => setHovering(false) },
|
|
7625
|
+
React.createElement(ImageZoom, { src: src, alt: alt, mode: zoomMode, zoomLevel: zoomLevel, images: safeImages, currentIndex: showSecond ? 1 : 0, className: "seekora-img-hover-img", style: hoverImgStyle })));
|
|
7626
|
+
}
|
|
7264
7627
|
return (React.createElement("div", { className: clsx('seekora-img-display', 'seekora-img-hover', className), style: { position: 'relative', ...style }, onMouseEnter: () => setHovering(true), onMouseLeave: () => setHovering(false) },
|
|
7265
|
-
React.createElement("img", { src: src, alt: alt, className: "seekora-img-hover-img", style:
|
|
7628
|
+
React.createElement("img", { src: src, alt: alt, className: "seekora-img-hover-img", style: hoverImgStyle, loading: "lazy" })));
|
|
7266
7629
|
}
|
|
7267
7630
|
if (variant === 'carousel') {
|
|
7268
7631
|
const go = (delta) => {
|
|
@@ -7275,16 +7638,41 @@ function ImageDisplay({ images, variant = 'single', alt = '', className, style,
|
|
|
7275
7638
|
return next;
|
|
7276
7639
|
});
|
|
7277
7640
|
};
|
|
7641
|
+
const carouselImgStyle = style?.aspectRatio ? { ...imgBaseStyle, aspectRatio: style.aspectRatio } : imgBaseStyle;
|
|
7642
|
+
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" }));
|
|
7278
7643
|
return (React.createElement("div", { className: clsx('seekora-img-display', 'seekora-img-carousel', className), style: { position: 'relative', ...style } },
|
|
7279
|
-
|
|
7644
|
+
mainImage,
|
|
7280
7645
|
safeImages.length > 1 && (React.createElement(React.Fragment, null,
|
|
7281
|
-
React.createElement("button", { type: "button", "aria-label": "Previous", className: "seekora-img-carousel-prev", style: arrowStyle(true), onMouseDown: () => go(-1) }),
|
|
7282
|
-
React.createElement("button", { type: "button", "aria-label": "Next", className: "seekora-img-carousel-next", style: arrowStyle(false), onMouseDown: () => go(1) })))));
|
|
7646
|
+
React.createElement("button", { type: "button", "aria-label": "Previous", className: "seekora-img-carousel-prev", style: arrowStyle(true), onMouseDown: (e) => { e.stopPropagation(); e.preventDefault(); go(-1); }, onClick: (e) => e.stopPropagation(), onMouseEnter: (e) => { e.currentTarget.style.backgroundColor = 'rgba(255,255,255,1)'; }, onMouseLeave: (e) => { e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.9)'; } }, "\u2039"),
|
|
7647
|
+
React.createElement("button", { type: "button", "aria-label": "Next", className: "seekora-img-carousel-next", style: arrowStyle(false), onMouseDown: (e) => { e.stopPropagation(); e.preventDefault(); go(1); }, onClick: (e) => e.stopPropagation(), onMouseEnter: (e) => { e.currentTarget.style.backgroundColor = 'rgba(255,255,255,1)'; }, onMouseLeave: (e) => { e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.9)'; } }, "\u203A"),
|
|
7648
|
+
showDots && (React.createElement("div", { className: "seekora-img-carousel-dots", style: {
|
|
7649
|
+
position: 'absolute',
|
|
7650
|
+
bottom: 8,
|
|
7651
|
+
left: '50%',
|
|
7652
|
+
transform: 'translateX(-50%)',
|
|
7653
|
+
display: 'flex',
|
|
7654
|
+
gap: 6,
|
|
7655
|
+
padding: '6px 12px',
|
|
7656
|
+
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
7657
|
+
borderRadius: 12,
|
|
7658
|
+
zIndex: 10,
|
|
7659
|
+
} }, safeImages.map((_, i) => (React.createElement("button", { key: i, type: "button", "aria-label": `Go to image ${i + 1}`, onMouseDown: (e) => { e.stopPropagation(); e.preventDefault(); }, onClick: (e) => { e.stopPropagation(); setIndex(i); }, style: {
|
|
7660
|
+
width: 8,
|
|
7661
|
+
height: 8,
|
|
7662
|
+
borderRadius: '50%',
|
|
7663
|
+
border: 'none',
|
|
7664
|
+
padding: 0,
|
|
7665
|
+
backgroundColor: i === index ? '#fff' : 'rgba(255,255,255,0.5)',
|
|
7666
|
+
cursor: 'pointer',
|
|
7667
|
+
transition: 'all 150ms ease',
|
|
7668
|
+
} })))))))));
|
|
7283
7669
|
}
|
|
7284
7670
|
if (variant === 'thumbStrip' || variant === 'thumbList') {
|
|
7671
|
+
const thumbMainStyle = style?.aspectRatio ? { ...imgBaseStyle, aspectRatio: style.aspectRatio } : imgBaseStyle;
|
|
7672
|
+
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" }));
|
|
7285
7673
|
return (React.createElement("div", { className: clsx('seekora-img-display', 'seekora-img-thumbstrip', className), style: { display: 'flex', flexDirection: 'column', gap: 8, ...style } },
|
|
7286
|
-
|
|
7287
|
-
React.createElement("div", { className: "seekora-img-thumbs", style: { display: 'flex', gap: 4, overflowX: 'auto', paddingBottom: 4 } }, safeImages.map((src, i) => (React.createElement("button", { type: "button", key: i, className: clsx('seekora-img-thumb', i === index && 'seekora-img-thumb--active'), style: { flexShrink: 0, width: 48, height: 48, padding: 0, border: i === index ? '2px solid var(--seekora-primary)' : '1px solid transparent', borderRadius: 4, overflow: 'hidden', cursor: 'pointer', background: 'none' }, onMouseDown: () => setIndex(i) },
|
|
7674
|
+
mainImage,
|
|
7675
|
+
React.createElement("div", { className: "seekora-img-thumbs", style: { display: 'flex', gap: 4, overflowX: 'auto', paddingBottom: 4 } }, safeImages.map((src, i) => (React.createElement("button", { type: "button", key: i, className: clsx('seekora-img-thumb', i === index && 'seekora-img-thumb--active'), style: { flexShrink: 0, width: 48, height: 48, padding: 0, border: i === index ? '2px solid var(--seekora-primary)' : '1px solid transparent', borderRadius: 4, overflow: 'hidden', cursor: 'pointer', background: 'none' }, onMouseDown: (e) => { e.stopPropagation(); e.preventDefault(); setIndex(i); }, onClick: (e) => e.stopPropagation() },
|
|
7288
7676
|
React.createElement("img", { src: src, alt: "", style: { width: '100%', height: '100%', objectFit: 'cover' } })))))));
|
|
7289
7677
|
}
|
|
7290
7678
|
return React.createElement("img", { src: current, alt: alt, className: clsx('seekora-img-display', className), style: { ...imgBaseStyle, ...style }, loading: "lazy" });
|
|
@@ -7298,13 +7686,96 @@ function arrowStyle(left) {
|
|
|
7298
7686
|
width: 32,
|
|
7299
7687
|
height: 32,
|
|
7300
7688
|
borderRadius: '50%',
|
|
7301
|
-
border: '
|
|
7302
|
-
backgroundColor: '
|
|
7689
|
+
border: 'none',
|
|
7690
|
+
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
|
7691
|
+
color: '#111',
|
|
7692
|
+
fontSize: '1.25rem',
|
|
7693
|
+
fontWeight: 'bold',
|
|
7303
7694
|
cursor: 'pointer',
|
|
7304
7695
|
display: 'flex',
|
|
7305
7696
|
alignItems: 'center',
|
|
7306
7697
|
justifyContent: 'center',
|
|
7698
|
+
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
|
7699
|
+
zIndex: 10,
|
|
7700
|
+
transition: 'all 150ms ease',
|
|
7701
|
+
};
|
|
7702
|
+
}
|
|
7703
|
+
|
|
7704
|
+
/**
|
|
7705
|
+
* ActionButtons – card action buttons (add to cart, wishlist, buy now, quick view)
|
|
7706
|
+
*
|
|
7707
|
+
* Renders a set of action buttons for product cards. Can be positioned absolutely
|
|
7708
|
+
* over the image (on hover) or inline below the card content.
|
|
7709
|
+
*/
|
|
7710
|
+
const DEFAULT_ICONS = {
|
|
7711
|
+
addToCart: '🛒',
|
|
7712
|
+
wishlist: '♡',
|
|
7713
|
+
buyNow: '⚡',
|
|
7714
|
+
quickView: '👁',
|
|
7715
|
+
compare: '⚖',
|
|
7716
|
+
};
|
|
7717
|
+
const DEFAULT_LABELS = {
|
|
7718
|
+
addToCart: 'Add to Cart',
|
|
7719
|
+
wishlist: 'Wishlist',
|
|
7720
|
+
buyNow: 'Buy Now',
|
|
7721
|
+
quickView: 'Quick View',
|
|
7722
|
+
compare: 'Compare',
|
|
7723
|
+
};
|
|
7724
|
+
const BUTTON_SIZES = {
|
|
7725
|
+
small: { width: 28, height: 28, fontSize: '0.75rem', iconSize: '1rem' },
|
|
7726
|
+
medium: { width: 36, height: 36, fontSize: '0.875rem', iconSize: '1.25rem' },
|
|
7727
|
+
large: { width: 44, height: 44, fontSize: '1rem', iconSize: '1.5rem' },
|
|
7728
|
+
};
|
|
7729
|
+
function ActionButtons({ buttons, layout = 'horizontal', position = 'inline', showLabels = false, size = 'medium', className, style, }) {
|
|
7730
|
+
const sizeConfig = BUTTON_SIZES[size];
|
|
7731
|
+
const isOverlay = position !== 'inline';
|
|
7732
|
+
const containerStyle = {
|
|
7733
|
+
display: 'flex',
|
|
7734
|
+
flexDirection: layout === 'vertical' ? 'column' : 'row',
|
|
7735
|
+
gap: 6,
|
|
7736
|
+
...(isOverlay ? {
|
|
7737
|
+
position: 'absolute',
|
|
7738
|
+
...(position === 'top-right' ? { top: 8, right: 8 } : {}),
|
|
7739
|
+
...(position === 'bottom-center' ? { bottom: 8, left: '50%', transform: 'translateX(-50%)' } : {}),
|
|
7740
|
+
} : {}),
|
|
7741
|
+
...style,
|
|
7742
|
+
};
|
|
7743
|
+
const buttonBaseStyle = {
|
|
7744
|
+
display: 'flex',
|
|
7745
|
+
alignItems: 'center',
|
|
7746
|
+
justifyContent: 'center',
|
|
7747
|
+
gap: 4,
|
|
7748
|
+
padding: showLabels ? '0 12px' : 0,
|
|
7749
|
+
width: showLabels ? 'auto' : sizeConfig.width,
|
|
7750
|
+
height: sizeConfig.height,
|
|
7751
|
+
fontSize: sizeConfig.fontSize,
|
|
7752
|
+
fontWeight: 500,
|
|
7753
|
+
border: 'none',
|
|
7754
|
+
borderRadius: 6,
|
|
7755
|
+
backgroundColor: 'var(--seekora-bg-surface, #fff)',
|
|
7756
|
+
color: 'var(--seekora-text, #111827)',
|
|
7757
|
+
cursor: 'pointer',
|
|
7758
|
+
transition: 'all 150ms ease',
|
|
7759
|
+
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
|
7760
|
+
};
|
|
7761
|
+
const handleClick = (btn, e) => {
|
|
7762
|
+
e.stopPropagation();
|
|
7763
|
+
e.preventDefault();
|
|
7764
|
+
if (btn.onClick && !btn.disabled && !btn.loading) {
|
|
7765
|
+
btn.onClick(e);
|
|
7766
|
+
}
|
|
7307
7767
|
};
|
|
7768
|
+
return (React.createElement("div", { className: clsx('seekora-action-buttons', `seekora-action-buttons--${layout}`, className), style: containerStyle }, buttons.map((btn, i) => {
|
|
7769
|
+
const icon = btn.icon ?? DEFAULT_ICONS[btn.type];
|
|
7770
|
+
const label = btn.label ?? DEFAULT_LABELS[btn.type];
|
|
7771
|
+
return (React.createElement("button", { key: i, type: "button", className: clsx('seekora-action-button', `seekora-action-button--${btn.type}`, btn.disabled && 'seekora-action-button--disabled', btn.loading && 'seekora-action-button--loading'), style: {
|
|
7772
|
+
...buttonBaseStyle,
|
|
7773
|
+
opacity: btn.disabled ? 0.5 : 1,
|
|
7774
|
+
cursor: btn.disabled ? 'not-allowed' : 'pointer',
|
|
7775
|
+
}, onClick: (e) => handleClick(btn, e), disabled: btn.disabled, "aria-label": label, title: label }, btn.loading ? (React.createElement("span", { style: { fontSize: sizeConfig.iconSize } }, "\u23F3")) : (React.createElement(React.Fragment, null,
|
|
7776
|
+
React.createElement("span", { style: { fontSize: sizeConfig.iconSize } }, icon),
|
|
7777
|
+
showLabels && React.createElement("span", null, label)))));
|
|
7778
|
+
})));
|
|
7308
7779
|
}
|
|
7309
7780
|
|
|
7310
7781
|
/**
|
|
@@ -7335,18 +7806,31 @@ const imgStyle$1 = {
|
|
|
7335
7806
|
borderRadius: 4,
|
|
7336
7807
|
backgroundColor: 'var(--seekora-bg-secondary, #f3f4f6)',
|
|
7337
7808
|
};
|
|
7338
|
-
function ItemCard({ item, position, onSelect, className, style, asLink = true, imageVariant = 'single', }) {
|
|
7809
|
+
function ItemCard({ item, position, onSelect, className, style, asLink = true, imageVariant = 'single', layout = 'vertical', actionButtons, actionButtonsPosition = 'overlay-top-right', showActionLabels = false, }) {
|
|
7339
7810
|
const images = item.images?.length ? item.images : item.image ?? item.imageUrl ? [String(item.image ?? item.imageUrl)] : [];
|
|
7340
7811
|
const title = item.title ?? item.primaryText ?? '';
|
|
7341
7812
|
const description = item.description ?? item.secondaryText;
|
|
7342
7813
|
const href = item.url;
|
|
7343
|
-
const
|
|
7344
|
-
|
|
7814
|
+
const isHorizontal = layout === 'horizontal';
|
|
7815
|
+
const imageBlock = images.length > 0 ? (React.createElement("div", { style: { position: 'relative', ...(isHorizontal ? { width: 80, flexShrink: 0 } : {}) } },
|
|
7816
|
+
React.createElement(ImageDisplay, { images: images, variant: imageVariant, alt: String(title), className: "seekora-item-card-image" }),
|
|
7817
|
+
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" })))) : (React.createElement("div", { className: "seekora-item-card-placeholder", style: { ...imgStyle$1, ...(isHorizontal ? { width: 80, height: 80, flexShrink: 0 } : {}) }, "aria-hidden": true }));
|
|
7818
|
+
const textBlock = (React.createElement("div", { style: isHorizontal ? { display: 'flex', flexDirection: 'column', gap: 4, flex: 1, minWidth: 0 } : undefined },
|
|
7345
7819
|
React.createElement("span", { className: "seekora-item-card-title", style: { fontSize: '0.875rem', fontWeight: 500 } }, String(title)),
|
|
7346
|
-
description ? (React.createElement("span", { className: "seekora-item-card-description", style: { fontSize: '0.8125rem', color: 'var(--seekora-text-secondary, #6b7280)', lineHeight: 1.3 } }, String(description))) : null
|
|
7820
|
+
description ? (React.createElement("span", { className: "seekora-item-card-description", style: { fontSize: '0.8125rem', color: 'var(--seekora-text-secondary, #6b7280)', lineHeight: 1.3 } }, String(description))) : null,
|
|
7821
|
+
actionButtons && actionButtons.length > 0 && actionButtonsPosition === 'inline' && (React.createElement(ActionButtons, { buttons: actionButtons, position: "inline", showLabels: showActionLabels, size: "small", layout: "horizontal" }))));
|
|
7822
|
+
const content = isHorizontal ? (React.createElement("div", { style: { display: 'flex', gap: 12, alignItems: 'flex-start' } },
|
|
7823
|
+
imageBlock,
|
|
7824
|
+
textBlock)) : (React.createElement(React.Fragment, null,
|
|
7825
|
+
imageBlock,
|
|
7826
|
+
textBlock));
|
|
7347
7827
|
const commonProps = {
|
|
7348
|
-
className: clsx('seekora-item-card', className),
|
|
7349
|
-
style: {
|
|
7828
|
+
className: clsx('seekora-item-card', isHorizontal && 'seekora-item-card--horizontal', className),
|
|
7829
|
+
style: {
|
|
7830
|
+
...cardStyle$1,
|
|
7831
|
+
...(isHorizontal ? { flexDirection: 'row' } : {}),
|
|
7832
|
+
...style,
|
|
7833
|
+
},
|
|
7350
7834
|
'data-position': position,
|
|
7351
7835
|
onClick: onSelect,
|
|
7352
7836
|
onMouseDown: onSelect ? (e) => { e.preventDefault(); onSelect(); } : undefined,
|
|
@@ -7398,563 +7882,259 @@ function ItemGrid({ items, onItemClick, getItemId = (i) => i.id, getItemTitle =
|
|
|
7398
7882
|
}
|
|
7399
7883
|
|
|
7400
7884
|
/**
|
|
7401
|
-
*
|
|
7402
|
-
*
|
|
7403
|
-
* Minimal layout: image (via ImageDisplay when imageVariant set), title, price.
|
|
7404
|
-
* onClick calls context selectProduct. Overridable via className/style.
|
|
7885
|
+
* Utility functions for Query Suggestions components
|
|
7405
7886
|
*/
|
|
7406
|
-
|
|
7407
|
-
|
|
7408
|
-
|
|
7409
|
-
|
|
7410
|
-
|
|
7411
|
-
|
|
7412
|
-
|
|
7413
|
-
|
|
7414
|
-
|
|
7415
|
-
|
|
7416
|
-
|
|
7887
|
+
// ============================================================================
|
|
7888
|
+
// Field Extraction
|
|
7889
|
+
// ============================================================================
|
|
7890
|
+
/**
|
|
7891
|
+
* Get nested value from object using dot notation
|
|
7892
|
+
* @example getNestedValue({ a: { b: 'value' } }, 'a.b') => 'value'
|
|
7893
|
+
*/
|
|
7894
|
+
const getNestedValue = (obj, path) => {
|
|
7895
|
+
if (!obj || !path)
|
|
7896
|
+
return undefined;
|
|
7897
|
+
return path.split('.').reduce((current, key) => {
|
|
7898
|
+
if (current === null || current === undefined)
|
|
7899
|
+
return undefined;
|
|
7900
|
+
return current[key];
|
|
7901
|
+
}, obj);
|
|
7417
7902
|
};
|
|
7418
|
-
|
|
7419
|
-
|
|
7420
|
-
|
|
7421
|
-
|
|
7422
|
-
|
|
7423
|
-
|
|
7903
|
+
/**
|
|
7904
|
+
* Extract suggestion fields from raw data
|
|
7905
|
+
*/
|
|
7906
|
+
const extractSuggestion = (item, mapping = { query: 'query' }) => {
|
|
7907
|
+
return {
|
|
7908
|
+
query: getNestedValue(item, mapping.query) ?? '',
|
|
7909
|
+
count: mapping.count ? getNestedValue(item, mapping.count) : undefined,
|
|
7910
|
+
id: mapping.id ? getNestedValue(item, mapping.id) : item?.objectID || item?.id,
|
|
7911
|
+
categories: mapping.categories ? getNestedValue(item, mapping.categories) : undefined,
|
|
7912
|
+
highlighted: mapping.highlighted ? getNestedValue(item, mapping.highlighted) : undefined,
|
|
7913
|
+
_raw: item,
|
|
7914
|
+
};
|
|
7424
7915
|
};
|
|
7425
|
-
function ProductCard({ product, position, section, tabId, onSelect, className, style, imageVariant = 'single', }) {
|
|
7426
|
-
const images = product.images?.length
|
|
7427
|
-
? product.images
|
|
7428
|
-
: product.image ?? product.imageUrl
|
|
7429
|
-
? [String(product.image ?? product.imageUrl)]
|
|
7430
|
-
: [];
|
|
7431
|
-
const title = product.title ?? product.name ?? '';
|
|
7432
|
-
const price = product.price != null ? (typeof product.price === 'number' ? product.price : Number(product.price)) : null;
|
|
7433
|
-
return (React.createElement("button", { type: "button", className: clsx('seekora-suggestions-product-card', className), style: { ...cardStyle, ...style }, onMouseDown: (e) => {
|
|
7434
|
-
e.preventDefault();
|
|
7435
|
-
onSelect();
|
|
7436
|
-
}, "data-position": position, "data-section": section, "data-tab-id": tabId },
|
|
7437
|
-
images.length > 0 ? (React.createElement(ImageDisplay, { images: images, variant: imageVariant, alt: title, className: "seekora-suggestions-product-card-image" })) : (React.createElement("div", { className: "seekora-suggestions-product-card-placeholder", style: imgStyle, "aria-hidden": true })),
|
|
7438
|
-
React.createElement("span", { className: "seekora-suggestions-product-card-title", style: { fontSize: '0.875rem', fontWeight: 500 } }, title),
|
|
7439
|
-
price != null && !Number.isNaN(price) ? (React.createElement("span", { className: "seekora-suggestions-product-card-price", style: { fontSize: '0.875rem', color: 'var(--seekora-text-secondary, #6b7280)' } },
|
|
7440
|
-
product.currency ?? '$',
|
|
7441
|
-
price.toFixed(2))) : null));
|
|
7442
|
-
}
|
|
7443
|
-
|
|
7444
7916
|
/**
|
|
7445
|
-
*
|
|
7446
|
-
*
|
|
7447
|
-
* Uses trendingProducts or active tab products; each click calls context selectProduct.
|
|
7917
|
+
* Extract product fields from raw data
|
|
7448
7918
|
*/
|
|
7449
|
-
|
|
7450
|
-
|
|
7451
|
-
|
|
7452
|
-
|
|
7453
|
-
|
|
7454
|
-
|
|
7455
|
-
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
|
|
7460
|
-
|
|
7461
|
-
|
|
7462
|
-
|
|
7463
|
-
|
|
7464
|
-
|
|
7465
|
-
|
|
7466
|
-
|
|
7919
|
+
const extractProduct = (item, mapping = { id: 'id', title: 'title' }) => {
|
|
7920
|
+
return {
|
|
7921
|
+
id: getNestedValue(item, mapping.id) ?? item?.objectID ?? item?.id,
|
|
7922
|
+
title: getNestedValue(item, mapping.title) ?? '',
|
|
7923
|
+
image: mapping.image ? getNestedValue(item, mapping.image) : undefined,
|
|
7924
|
+
price: mapping.price ? getNestedValue(item, mapping.price) : undefined,
|
|
7925
|
+
comparePrice: mapping.comparePrice ? getNestedValue(item, mapping.comparePrice) : undefined,
|
|
7926
|
+
url: mapping.url ? getNestedValue(item, mapping.url) : undefined,
|
|
7927
|
+
brand: mapping.brand ? getNestedValue(item, mapping.brand) : undefined,
|
|
7928
|
+
category: mapping.category ? getNestedValue(item, mapping.category) : undefined,
|
|
7929
|
+
rating: mapping.rating ? getNestedValue(item, mapping.rating) : undefined,
|
|
7930
|
+
reviewCount: mapping.reviewCount ? getNestedValue(item, mapping.reviewCount) : undefined,
|
|
7931
|
+
discount: mapping.discount ? getNestedValue(item, mapping.discount) : undefined,
|
|
7932
|
+
inStock: mapping.inStock ? getNestedValue(item, mapping.inStock) : undefined,
|
|
7933
|
+
currency: mapping.currency ? getNestedValue(item, mapping.currency) : undefined,
|
|
7934
|
+
images: mapping.images ? getNestedValue(item, mapping.images) : item?.images,
|
|
7935
|
+
originalPrice: mapping.originalPrice ? getNestedValue(item, mapping.originalPrice) : (item?.original_price ?? item?.compare_at_price),
|
|
7936
|
+
available: mapping.available ? getNestedValue(item, mapping.available) : item?.available,
|
|
7937
|
+
options: mapping.options ? getNestedValue(item, mapping.options) : item?.options,
|
|
7938
|
+
variants: mapping.variants ? getNestedValue(item, mapping.variants) : item?.variants,
|
|
7939
|
+
tags: mapping.tags ? getNestedValue(item, mapping.tags) : item?.tags,
|
|
7940
|
+
_raw: item,
|
|
7467
7941
|
};
|
|
7468
|
-
|
|
7469
|
-
React.createElement("div", { className: clsx('seekora-suggestions-product-grid-inner', gridClassName), style: gridStyle }, items.map((product, i) => {
|
|
7470
|
-
const globalIndex = productStartIndex >= 0 ? productStartIndex + i : i;
|
|
7471
|
-
const section = source === 'trending' ? 'products' : 'filtered_tab';
|
|
7472
|
-
const tabId = source !== 'trending' ? (source === 'tab' ? activeTabId : source) : undefined;
|
|
7473
|
-
return (React.createElement(ProductCard, { key: product.id ?? product.objectID ?? i, product: product, position: globalIndex, section: section, tabId: tabId, onSelect: () => selectProduct(product, globalIndex, section, tabId) }));
|
|
7474
|
-
}))));
|
|
7475
|
-
}
|
|
7476
|
-
|
|
7942
|
+
};
|
|
7477
7943
|
/**
|
|
7478
|
-
*
|
|
7479
|
-
*
|
|
7480
|
-
* Active tab from context; on select updates context and tracks analytics.
|
|
7944
|
+
* Extract category fields from raw data
|
|
7481
7945
|
*/
|
|
7482
|
-
|
|
7483
|
-
|
|
7484
|
-
|
|
7485
|
-
|
|
7486
|
-
|
|
7487
|
-
|
|
7488
|
-
|
|
7489
|
-
|
|
7490
|
-
|
|
7491
|
-
|
|
7492
|
-
...style,
|
|
7493
|
-
}, role: "tablist" }, filteredTabs.map((tab) => {
|
|
7494
|
-
const isActive = activeTabId === tab.id;
|
|
7495
|
-
return (React.createElement("button", { key: tab.id, type: "button", role: "tab", "aria-selected": isActive, className: clsx('seekora-suggestions-tab', isActive && 'seekora-suggestions-tab--active', tabClassName), style: {
|
|
7496
|
-
padding: '8px 12px',
|
|
7497
|
-
border: 'none',
|
|
7498
|
-
borderRadius: 'var(--seekora-border-radius, 6px)',
|
|
7499
|
-
backgroundColor: isActive ? 'var(--seekora-primary-light, rgba(59, 130, 246, 0.1))' : 'transparent',
|
|
7500
|
-
color: isActive ? 'var(--seekora-primary, #3b82f6)' : 'var(--seekora-text-primary, #111827)',
|
|
7501
|
-
cursor: 'pointer',
|
|
7502
|
-
fontSize: '0.875rem',
|
|
7503
|
-
fontWeight: isActive ? 600 : 400,
|
|
7504
|
-
whiteSpace: 'nowrap',
|
|
7505
|
-
transition: 'background-color 120ms ease',
|
|
7506
|
-
}, onClick: () => setActiveTab(tab) }, tab.label));
|
|
7507
|
-
})));
|
|
7508
|
-
}
|
|
7509
|
-
|
|
7946
|
+
const extractCategory = (item, mapping = { id: 'id', label: 'label' }) => {
|
|
7947
|
+
return {
|
|
7948
|
+
id: getNestedValue(item, mapping.id) ?? item?.id,
|
|
7949
|
+
label: getNestedValue(item, mapping.label) ?? '',
|
|
7950
|
+
count: mapping.count ? getNestedValue(item, mapping.count) : undefined,
|
|
7951
|
+
icon: mapping.icon ? getNestedValue(item, mapping.icon) : undefined,
|
|
7952
|
+
image: mapping.image ? getNestedValue(item, mapping.image) : undefined,
|
|
7953
|
+
_raw: item,
|
|
7954
|
+
};
|
|
7955
|
+
};
|
|
7510
7956
|
/**
|
|
7511
|
-
*
|
|
7512
|
-
*
|
|
7513
|
-
* Reads recentSearches from context; each click calls selectRecentSearch. Optional title/render.
|
|
7957
|
+
* Extract brand fields from raw data
|
|
7514
7958
|
*/
|
|
7515
|
-
const
|
|
7516
|
-
|
|
7517
|
-
|
|
7518
|
-
|
|
7519
|
-
|
|
7520
|
-
|
|
7521
|
-
|
|
7522
|
-
|
|
7523
|
-
backgroundColor: 'transparent',
|
|
7524
|
-
color: 'var(--seekora-text-primary, #111827)',
|
|
7525
|
-
transition: 'background-color 120ms ease',
|
|
7959
|
+
const extractBrand = (item, mapping = { name: 'name' }) => {
|
|
7960
|
+
return {
|
|
7961
|
+
id: mapping.id ? getNestedValue(item, mapping.id) : item?.id,
|
|
7962
|
+
name: getNestedValue(item, mapping.name) ?? '',
|
|
7963
|
+
logo: mapping.logo ? getNestedValue(item, mapping.logo) : undefined,
|
|
7964
|
+
count: mapping.count ? getNestedValue(item, mapping.count) : undefined,
|
|
7965
|
+
_raw: item,
|
|
7966
|
+
};
|
|
7526
7967
|
};
|
|
7527
|
-
|
|
7528
|
-
|
|
7529
|
-
|
|
7530
|
-
if (items.length === 0)
|
|
7531
|
-
return null;
|
|
7532
|
-
return (React.createElement("div", { className: clsx('seekora-suggestions-recent-list', className), style: style },
|
|
7533
|
-
title ? (React.createElement("div", { className: "seekora-suggestions-recent-title", style: { padding: '8px 12px', fontSize: '0.75rem', fontWeight: 600, color: 'var(--seekora-text-secondary, #6b7280)', textTransform: 'uppercase' } }, title)) : null,
|
|
7534
|
-
React.createElement("ul", { className: clsx('seekora-suggestions-recent-ul', listClassName), style: { margin: 0, padding: 0, listStyle: 'none' } }, items.map((search, i) => {
|
|
7535
|
-
const onSelect = () => selectRecentSearch(search);
|
|
7536
|
-
if (renderItem) {
|
|
7537
|
-
return React.createElement("li", { key: `${search.query}-${search.timestamp}` }, renderItem(search, i, onSelect));
|
|
7538
|
-
}
|
|
7539
|
-
return (React.createElement("li", { key: `${search.query}-${search.timestamp}` },
|
|
7540
|
-
React.createElement("button", { type: "button", className: "seekora-suggestions-recent-item", style: itemStyle$1, onMouseDown: (e) => {
|
|
7541
|
-
e.preventDefault();
|
|
7542
|
-
onSelect();
|
|
7543
|
-
} }, search.query)));
|
|
7544
|
-
}))));
|
|
7545
|
-
}
|
|
7546
|
-
|
|
7968
|
+
// ============================================================================
|
|
7969
|
+
// Formatting
|
|
7970
|
+
// ============================================================================
|
|
7547
7971
|
/**
|
|
7548
|
-
*
|
|
7549
|
-
*
|
|
7550
|
-
* Reads trendingSearches from context; each click calls selectTrendingSearch. Optional title/render.
|
|
7972
|
+
* Format price with currency
|
|
7551
7973
|
*/
|
|
7552
|
-
const
|
|
7553
|
-
|
|
7554
|
-
|
|
7555
|
-
|
|
7556
|
-
|
|
7557
|
-
|
|
7558
|
-
|
|
7559
|
-
|
|
7560
|
-
|
|
7561
|
-
|
|
7562
|
-
|
|
7974
|
+
const formatPrice = (value, config = {}) => {
|
|
7975
|
+
if (value === undefined || value === null)
|
|
7976
|
+
return '';
|
|
7977
|
+
const { currency = '$', currencyPosition = 'before', priceDecimals = 2 } = config;
|
|
7978
|
+
const num = typeof value === 'string' ? parseFloat(value) : value;
|
|
7979
|
+
if (isNaN(num))
|
|
7980
|
+
return String(value);
|
|
7981
|
+
const formatted = num.toLocaleString(undefined, {
|
|
7982
|
+
minimumFractionDigits: priceDecimals,
|
|
7983
|
+
maximumFractionDigits: priceDecimals,
|
|
7984
|
+
});
|
|
7985
|
+
return currencyPosition === 'before'
|
|
7986
|
+
? `${currency}${formatted}`
|
|
7987
|
+
: `${formatted}${currency}`;
|
|
7563
7988
|
};
|
|
7564
|
-
function TrendingList({ title = 'Trending', maxItems = 8, className, style, listClassName, renderItem, }) {
|
|
7565
|
-
const { trendingSearches, selectTrendingSearch } = useSuggestionsContext();
|
|
7566
|
-
const items = trendingSearches.slice(0, maxItems);
|
|
7567
|
-
if (items.length === 0)
|
|
7568
|
-
return null;
|
|
7569
|
-
return (React.createElement("div", { className: clsx('seekora-suggestions-trending-list', className), style: style },
|
|
7570
|
-
title ? (React.createElement("div", { className: "seekora-suggestions-trending-title", style: { padding: '8px 12px', fontSize: '0.75rem', fontWeight: 600, color: 'var(--seekora-text-secondary, #6b7280)', textTransform: 'uppercase' } }, title)) : null,
|
|
7571
|
-
React.createElement("ul", { className: clsx('seekora-suggestions-trending-ul', listClassName), style: { margin: 0, padding: 0, listStyle: 'none' } }, items.map((trending, i) => {
|
|
7572
|
-
const onSelect = () => selectTrendingSearch(trending, i);
|
|
7573
|
-
if (renderItem) {
|
|
7574
|
-
return React.createElement("li", { key: `${trending.query}-${i}` }, renderItem(trending, i, onSelect));
|
|
7575
|
-
}
|
|
7576
|
-
return (React.createElement("li", { key: `${trending.query}-${i}` },
|
|
7577
|
-
React.createElement("button", { type: "button", className: "seekora-suggestions-trending-item", style: itemStyle, onMouseDown: (e) => {
|
|
7578
|
-
e.preventDefault();
|
|
7579
|
-
onSelect();
|
|
7580
|
-
} },
|
|
7581
|
-
trending.query,
|
|
7582
|
-
trending.count != null ? (React.createElement("span", { style: { marginLeft: 8, color: 'var(--seekora-text-secondary, #6b7280)', fontSize: '0.875em' } }, trending.count)) : null)));
|
|
7583
|
-
}))));
|
|
7584
|
-
}
|
|
7585
|
-
|
|
7586
7989
|
/**
|
|
7587
|
-
*
|
|
7990
|
+
* Calculate discount percentage
|
|
7588
7991
|
*/
|
|
7589
|
-
|
|
7590
|
-
|
|
7591
|
-
|
|
7592
|
-
|
|
7593
|
-
|
|
7594
|
-
|
|
7595
|
-
|
|
7596
|
-
|
|
7597
|
-
color: 'var(--seekora-error, #dc2626)',
|
|
7598
|
-
fontSize: '0.875rem',
|
|
7599
|
-
...style,
|
|
7600
|
-
} }, error.message));
|
|
7601
|
-
}
|
|
7602
|
-
|
|
7992
|
+
const calculateDiscount = (price, comparePrice) => {
|
|
7993
|
+
if (!price || !comparePrice || comparePrice <= price)
|
|
7994
|
+
return undefined;
|
|
7995
|
+
return Math.round(((comparePrice - price) / comparePrice) * 100);
|
|
7996
|
+
};
|
|
7997
|
+
// ============================================================================
|
|
7998
|
+
// Text Processing
|
|
7999
|
+
// ============================================================================
|
|
7603
8000
|
/**
|
|
7604
|
-
*
|
|
7605
|
-
*
|
|
7606
|
-
* Example layout built from primitives: SearchInput + DropdownPanel containing
|
|
7607
|
-
* RecentSearchesList (when query empty), SuggestionList, CategoriesTabs, ProductGrid, TrendingList.
|
|
7608
|
-
* Wrap with SearchProvider and SuggestionsProvider. Use as reference or replace
|
|
7609
|
-
* with your own arrangement of the same primitives.
|
|
8001
|
+
* Escape HTML special characters
|
|
7610
8002
|
*/
|
|
7611
|
-
|
|
7612
|
-
|
|
7613
|
-
|
|
7614
|
-
|
|
7615
|
-
|
|
7616
|
-
|
|
7617
|
-
|
|
7618
|
-
|
|
7619
|
-
|
|
7620
|
-
|
|
7621
|
-
showProducts ? React.createElement(ProductGrid, null) : null,
|
|
7622
|
-
showTrending ? React.createElement(TrendingList, null) : null))));
|
|
7623
|
-
}
|
|
7624
|
-
|
|
8003
|
+
const escapeHtml = (text) => {
|
|
8004
|
+
const map = {
|
|
8005
|
+
'&': '&',
|
|
8006
|
+
'<': '<',
|
|
8007
|
+
'>': '>',
|
|
8008
|
+
'"': '"',
|
|
8009
|
+
"'": ''',
|
|
8010
|
+
};
|
|
8011
|
+
return text.replace(/[&<>"']/g, (char) => map[char]);
|
|
8012
|
+
};
|
|
7625
8013
|
/**
|
|
7626
|
-
*
|
|
7627
|
-
*
|
|
7628
|
-
* For menus, sidebar, front-page blocks. Independent of main search state.
|
|
8014
|
+
* Highlight matching text in a string
|
|
7629
8015
|
*/
|
|
7630
|
-
const
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
|
|
7635
|
-
|
|
7636
|
-
|
|
7637
|
-
|
|
7638
|
-
|
|
7639
|
-
}
|
|
7640
|
-
|
|
8016
|
+
const highlightText = (text, query, options = {}) => {
|
|
8017
|
+
if (!query || !text)
|
|
8018
|
+
return escapeHtml(text);
|
|
8019
|
+
const { tag = 'mark', className = '', style } = options;
|
|
8020
|
+
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
8021
|
+
const styleAttr = style
|
|
8022
|
+
? ` style="${Object.entries(style).map(([k, v]) => `${k.replace(/([A-Z])/g, '-$1').toLowerCase()}:${v}`).join(';')}"`
|
|
8023
|
+
: '';
|
|
8024
|
+
const classAttr = className ? ` class="${className}"` : '';
|
|
8025
|
+
return escapeHtml(text).replace(new RegExp(`(${escapeHtml(escapedQuery)})`, 'gi'), `<${tag}${classAttr}${styleAttr}>$1</${tag}>`);
|
|
8026
|
+
};
|
|
8027
|
+
// ============================================================================
|
|
8028
|
+
// Variant Utilities
|
|
8029
|
+
// ============================================================================
|
|
7641
8030
|
/**
|
|
7642
|
-
*
|
|
7643
|
-
*
|
|
7644
|
-
* Runs client.search(query, { refinements, hitsPerPage, sortBy }) on mount and when
|
|
7645
|
-
* query/filters change. Does not use global SearchStateManager. Use for menus,
|
|
7646
|
-
* sidebar, front-page blocks (e.g. "New arrivals", "On sale").
|
|
8031
|
+
* Extract badges from product tags (Shopify convention: "badge:new", "badge:limited")
|
|
8032
|
+
* and auto-generate sale/soldOut badges from product data.
|
|
7647
8033
|
*/
|
|
7648
|
-
|
|
7649
|
-
|
|
7650
|
-
|
|
7651
|
-
if (
|
|
7652
|
-
|
|
7653
|
-
|
|
7654
|
-
|
|
7655
|
-
|
|
7656
|
-
|
|
7657
|
-
|
|
7658
|
-
|
|
7659
|
-
|
|
7660
|
-
|
|
7661
|
-
}
|
|
7662
|
-
|
|
7663
|
-
|
|
7664
|
-
|
|
7665
|
-
|
|
7666
|
-
if (typeof n === 'number')
|
|
7667
|
-
return n;
|
|
7668
|
-
const data = response.data;
|
|
7669
|
-
if (data?.total_results != null)
|
|
7670
|
-
return Number(data.total_results);
|
|
7671
|
-
if (data?.data?.total_results != null)
|
|
7672
|
-
return Number(data.data.total_results);
|
|
7673
|
-
return 0;
|
|
7674
|
-
}
|
|
7675
|
-
function SectionSearchProvider({ children, query, refinements = [], maxItems = 12, sortBy, enabled = true, sectionId, }) {
|
|
7676
|
-
const { client } = useSearchContext();
|
|
7677
|
-
const [items, setItems] = React.useState([]);
|
|
7678
|
-
const [loading, setLoading] = React.useState(true);
|
|
7679
|
-
const [error, setError] = React.useState(null);
|
|
7680
|
-
const [totalCount, setTotalCount] = React.useState(0);
|
|
7681
|
-
React.useEffect(() => {
|
|
7682
|
-
if (!enabled || !client?.search) {
|
|
7683
|
-
setItems([]);
|
|
7684
|
-
setLoading(false);
|
|
7685
|
-
setError(null);
|
|
7686
|
-
setTotalCount(0);
|
|
7687
|
-
return;
|
|
8034
|
+
const extractBadges = (tags, product) => {
|
|
8035
|
+
const badges = [];
|
|
8036
|
+
// Extract from tags (Shopify convention)
|
|
8037
|
+
if (tags) {
|
|
8038
|
+
for (const tag of tags) {
|
|
8039
|
+
const lower = tag.toLowerCase().trim();
|
|
8040
|
+
if (lower.startsWith('badge:') || lower.startsWith('badge: ')) {
|
|
8041
|
+
const text = tag.slice(tag.indexOf(':') + 1).trim();
|
|
8042
|
+
if (text) {
|
|
8043
|
+
badges.push({ text, type: 'custom' });
|
|
8044
|
+
}
|
|
8045
|
+
}
|
|
8046
|
+
else if (lower === 'new' || lower === 'new arrival') {
|
|
8047
|
+
badges.push({ text: 'New', type: 'new' });
|
|
8048
|
+
}
|
|
8049
|
+
else if (lower === 'limited' || lower === 'limited edition') {
|
|
8050
|
+
badges.push({ text: 'Limited', type: 'limited' });
|
|
8051
|
+
}
|
|
7688
8052
|
}
|
|
7689
|
-
|
|
7690
|
-
|
|
7691
|
-
|
|
7692
|
-
const
|
|
7693
|
-
|
|
7694
|
-
|
|
7695
|
-
|
|
7696
|
-
if (sortBy)
|
|
7697
|
-
options.sort_by = sortBy;
|
|
7698
|
-
if (refinements.length > 0) {
|
|
7699
|
-
options.filter_by = refinements.map((r) => `${r.field}:${r.value}`).join(',');
|
|
8053
|
+
}
|
|
8054
|
+
// Auto-generate sale badge
|
|
8055
|
+
if (product) {
|
|
8056
|
+
const comparePrice = product.original_price ?? product.compare_at_price;
|
|
8057
|
+
if (comparePrice && product.price && comparePrice > product.price) {
|
|
8058
|
+
const discount = Math.round(((comparePrice - product.price) / comparePrice) * 100);
|
|
8059
|
+
badges.push({ text: `${discount}% Off`, type: 'sale' });
|
|
7700
8060
|
}
|
|
7701
|
-
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
|
|
7705
|
-
|
|
7706
|
-
|
|
7707
|
-
|
|
7708
|
-
setLoading(false);
|
|
7709
|
-
})
|
|
7710
|
-
.catch((err) => {
|
|
7711
|
-
if (cancelled)
|
|
7712
|
-
return;
|
|
7713
|
-
setError(err instanceof Error ? err : new Error(String(err)));
|
|
7714
|
-
setItems([]);
|
|
7715
|
-
setLoading(false);
|
|
7716
|
-
});
|
|
7717
|
-
return () => {
|
|
7718
|
-
cancelled = true;
|
|
7719
|
-
};
|
|
7720
|
-
}, [client, enabled, query, maxItems, sortBy, refinements]);
|
|
7721
|
-
const trackClick = React.useCallback((item, position) => {
|
|
7722
|
-
if (!client?.trackEvent)
|
|
7723
|
-
return;
|
|
7724
|
-
const id = item?.id ?? item?.objectID;
|
|
7725
|
-
client.trackEvent({
|
|
7726
|
-
event_name: 'section_result_click',
|
|
7727
|
-
clicked_item_id: id,
|
|
7728
|
-
position,
|
|
7729
|
-
section: sectionId,
|
|
7730
|
-
metadata: { section_id: sectionId },
|
|
7731
|
-
}, undefined);
|
|
7732
|
-
}, [client, sectionId]);
|
|
7733
|
-
const value = React.useMemo(() => ({
|
|
7734
|
-
items,
|
|
7735
|
-
loading,
|
|
7736
|
-
error,
|
|
7737
|
-
totalCount,
|
|
7738
|
-
sectionId,
|
|
7739
|
-
trackClick,
|
|
7740
|
-
}), [items, loading, error, totalCount, sectionId, trackClick]);
|
|
7741
|
-
return React.createElement(SectionSearchContext.Provider, { value: value }, children);
|
|
7742
|
-
}
|
|
7743
|
-
|
|
8061
|
+
}
|
|
8062
|
+
// Auto-generate sold out badge
|
|
8063
|
+
if (product && product.available === false) {
|
|
8064
|
+
badges.push({ text: 'Sold Out', type: 'soldOut' });
|
|
8065
|
+
}
|
|
8066
|
+
return badges;
|
|
8067
|
+
};
|
|
7744
8068
|
/**
|
|
7745
|
-
*
|
|
8069
|
+
* Compute min/max price from variants. Returns null if all variants have the same price.
|
|
7746
8070
|
*/
|
|
7747
|
-
|
|
7748
|
-
|
|
7749
|
-
if (!loading)
|
|
8071
|
+
const getPriceRange = (variants) => {
|
|
8072
|
+
if (!variants || variants.length === 0)
|
|
7750
8073
|
return null;
|
|
7751
|
-
|
|
7752
|
-
|
|
7753
|
-
|
|
7754
|
-
|
|
7755
|
-
* SectionError – error state for section (primitive)
|
|
7756
|
-
*/
|
|
7757
|
-
function SectionError({ className, style, render }) {
|
|
7758
|
-
const { error } = useSectionSearchContext();
|
|
7759
|
-
if (!error)
|
|
8074
|
+
const prices = variants
|
|
8075
|
+
.map((v) => v.price)
|
|
8076
|
+
.filter((p) => p != null && !isNaN(p));
|
|
8077
|
+
if (prices.length === 0)
|
|
7760
8078
|
return null;
|
|
7761
|
-
|
|
7762
|
-
|
|
7763
|
-
|
|
7764
|
-
}
|
|
7765
|
-
|
|
7766
|
-
/**
|
|
7767
|
-
* SectionItemGrid – generic grid of items from SectionSearchProvider (primitive)
|
|
7768
|
-
*/
|
|
7769
|
-
function SectionItemGrid({ columns = 4, maxItems = 12, className, style, getItemId = (i) => i.id ?? String(i?.objectID ?? ''), getItemTitle = (i) => i.title ?? i?.title ?? '', getItemImage = (i) => i.image ?? i?.image, getItemDescription = (i) => i.description ?? i?.description, getItemUrl = (i) => i.url ?? i?.url, renderItem, }) {
|
|
7770
|
-
const { items, loading, error, trackClick } = useSectionSearchContext();
|
|
7771
|
-
if (loading)
|
|
7772
|
-
return React.createElement(SectionLoading, { className: className, style: style });
|
|
7773
|
-
if (error)
|
|
7774
|
-
return React.createElement(SectionError, { className: className, style: style });
|
|
7775
|
-
return (React.createElement(ItemGrid, { items: items, maxItems: maxItems, columns: columns, className: className, style: style, getItemId: getItemId, getItemTitle: getItemTitle, getItemImage: getItemImage, getItemDescription: getItemDescription, getItemUrl: getItemUrl, renderItem: renderItem, onItemClick: (item, index) => trackClick(item, index) }));
|
|
7776
|
-
}
|
|
7777
|
-
|
|
7778
|
-
/**
|
|
7779
|
-
* ProductGallery – product detail image gallery (primitive)
|
|
7780
|
-
*
|
|
7781
|
-
* Uses ImageDisplay with configurable variant (carousel, thumbStrip, etc.).
|
|
7782
|
-
* For use on individual product page.
|
|
7783
|
-
*/
|
|
7784
|
-
function ProductGallery({ images, variant = 'thumbStrip', alt = 'Product', className, style, carouselAutoplay, carouselIntervalMs, }) {
|
|
7785
|
-
return (React.createElement("div", { className: clsx('seekora-product-gallery', className), style: style },
|
|
7786
|
-
React.createElement(ImageDisplay, { images: images, variant: variant, alt: alt, carouselAutoplay: carouselAutoplay, carouselIntervalMs: carouselIntervalMs })));
|
|
7787
|
-
}
|
|
7788
|
-
|
|
7789
|
-
/**
|
|
7790
|
-
* ProductInfo – product detail block (primitive)
|
|
7791
|
-
*
|
|
7792
|
-
* Title, description, price, optional variant selector and CTA. Minimal layout;
|
|
7793
|
-
* override with className/style. For use on individual product page.
|
|
7794
|
-
*/
|
|
7795
|
-
function ProductInfo({ title, description, price, currency = '$', renderVariantSelector, renderCTA, className, style, }) {
|
|
7796
|
-
const priceNum = price != null ? (typeof price === 'number' ? price : parseFloat(String(price))) : null;
|
|
7797
|
-
return (React.createElement("div", { className: clsx('seekora-product-info', className), style: { display: 'flex', flexDirection: 'column', gap: 12, ...style } },
|
|
7798
|
-
React.createElement("h1", { className: "seekora-product-info-title", style: { fontSize: '1.25rem', fontWeight: 600, margin: 0 } }, title),
|
|
7799
|
-
priceNum != null && !Number.isNaN(priceNum) ? (React.createElement("span", { className: "seekora-product-info-price", style: { fontSize: '1.125rem', fontWeight: 600 } },
|
|
7800
|
-
currency,
|
|
7801
|
-
priceNum.toFixed(2))) : null,
|
|
7802
|
-
description ? (React.createElement("p", { className: "seekora-product-info-description", style: { fontSize: '0.875rem', color: 'var(--seekora-text-secondary)', margin: 0, lineHeight: 1.5 } }, description)) : null,
|
|
7803
|
-
renderVariantSelector?.(),
|
|
7804
|
-
renderCTA?.()));
|
|
7805
|
-
}
|
|
7806
|
-
|
|
7807
|
-
/**
|
|
7808
|
-
* ProductRecommendations – related / frequently bought (primitive)
|
|
7809
|
-
*
|
|
7810
|
-
* Renders a section of recommended items (generic ItemGrid or product list).
|
|
7811
|
-
* Pass items and onItemClick; or wrap SectionSearchProvider with preset query for "related".
|
|
7812
|
-
* For use on individual product page.
|
|
7813
|
-
*/
|
|
7814
|
-
function ProductRecommendations({ title = 'You may also like', items, onItemClick, maxItems = 6, columns = 3, className, style, renderItem, }) {
|
|
7815
|
-
if (!items?.length)
|
|
8079
|
+
const min = Math.min(...prices);
|
|
8080
|
+
const max = Math.max(...prices);
|
|
8081
|
+
if (min === max)
|
|
7816
8082
|
return null;
|
|
7817
|
-
return
|
|
7818
|
-
React.createElement("h2", { className: "seekora-product-recommendations-title", style: { fontSize: '1rem', fontWeight: 600, marginBottom: 12 } }, title),
|
|
7819
|
-
React.createElement(ItemGrid, { items: items, maxItems: maxItems, columns: columns, onItemClick: onItemClick, renderItem: renderItem })));
|
|
7820
|
-
}
|
|
7821
|
-
|
|
7822
|
-
/**
|
|
7823
|
-
* Utility functions for Query Suggestions components
|
|
7824
|
-
*/
|
|
7825
|
-
// ============================================================================
|
|
7826
|
-
// Field Extraction
|
|
7827
|
-
// ============================================================================
|
|
7828
|
-
/**
|
|
7829
|
-
* Get nested value from object using dot notation
|
|
7830
|
-
* @example getNestedValue({ a: { b: 'value' } }, 'a.b') => 'value'
|
|
7831
|
-
*/
|
|
7832
|
-
const getNestedValue = (obj, path) => {
|
|
7833
|
-
if (!obj || !path)
|
|
7834
|
-
return undefined;
|
|
7835
|
-
return path.split('.').reduce((current, key) => {
|
|
7836
|
-
if (current === null || current === undefined)
|
|
7837
|
-
return undefined;
|
|
7838
|
-
return current[key];
|
|
7839
|
-
}, obj);
|
|
7840
|
-
};
|
|
7841
|
-
/**
|
|
7842
|
-
* Extract suggestion fields from raw data
|
|
7843
|
-
*/
|
|
7844
|
-
const extractSuggestion = (item, mapping = { query: 'query' }) => {
|
|
7845
|
-
return {
|
|
7846
|
-
query: getNestedValue(item, mapping.query) ?? '',
|
|
7847
|
-
count: mapping.count ? getNestedValue(item, mapping.count) : undefined,
|
|
7848
|
-
id: mapping.id ? getNestedValue(item, mapping.id) : item?.objectID || item?.id,
|
|
7849
|
-
categories: mapping.categories ? getNestedValue(item, mapping.categories) : undefined,
|
|
7850
|
-
highlighted: mapping.highlighted ? getNestedValue(item, mapping.highlighted) : undefined,
|
|
7851
|
-
_raw: item,
|
|
7852
|
-
};
|
|
7853
|
-
};
|
|
7854
|
-
/**
|
|
7855
|
-
* Extract product fields from raw data
|
|
7856
|
-
*/
|
|
7857
|
-
const extractProduct = (item, mapping = { id: 'id', title: 'title' }) => {
|
|
7858
|
-
return {
|
|
7859
|
-
id: getNestedValue(item, mapping.id) ?? item?.objectID ?? item?.id,
|
|
7860
|
-
title: getNestedValue(item, mapping.title) ?? '',
|
|
7861
|
-
image: mapping.image ? getNestedValue(item, mapping.image) : undefined,
|
|
7862
|
-
price: mapping.price ? getNestedValue(item, mapping.price) : undefined,
|
|
7863
|
-
comparePrice: mapping.comparePrice ? getNestedValue(item, mapping.comparePrice) : undefined,
|
|
7864
|
-
url: mapping.url ? getNestedValue(item, mapping.url) : undefined,
|
|
7865
|
-
brand: mapping.brand ? getNestedValue(item, mapping.brand) : undefined,
|
|
7866
|
-
category: mapping.category ? getNestedValue(item, mapping.category) : undefined,
|
|
7867
|
-
rating: mapping.rating ? getNestedValue(item, mapping.rating) : undefined,
|
|
7868
|
-
reviewCount: mapping.reviewCount ? getNestedValue(item, mapping.reviewCount) : undefined,
|
|
7869
|
-
discount: mapping.discount ? getNestedValue(item, mapping.discount) : undefined,
|
|
7870
|
-
inStock: mapping.inStock ? getNestedValue(item, mapping.inStock) : undefined,
|
|
7871
|
-
currency: mapping.currency ? getNestedValue(item, mapping.currency) : undefined,
|
|
7872
|
-
_raw: item,
|
|
7873
|
-
};
|
|
7874
|
-
};
|
|
7875
|
-
/**
|
|
7876
|
-
* Extract category fields from raw data
|
|
7877
|
-
*/
|
|
7878
|
-
const extractCategory = (item, mapping = { id: 'id', label: 'label' }) => {
|
|
7879
|
-
return {
|
|
7880
|
-
id: getNestedValue(item, mapping.id) ?? item?.id,
|
|
7881
|
-
label: getNestedValue(item, mapping.label) ?? '',
|
|
7882
|
-
count: mapping.count ? getNestedValue(item, mapping.count) : undefined,
|
|
7883
|
-
icon: mapping.icon ? getNestedValue(item, mapping.icon) : undefined,
|
|
7884
|
-
image: mapping.image ? getNestedValue(item, mapping.image) : undefined,
|
|
7885
|
-
_raw: item,
|
|
7886
|
-
};
|
|
8083
|
+
return { min, max };
|
|
7887
8084
|
};
|
|
7888
8085
|
/**
|
|
7889
|
-
*
|
|
8086
|
+
* Format a price range like "$54.00 - $72.00"
|
|
7890
8087
|
*/
|
|
7891
|
-
const
|
|
7892
|
-
return {
|
|
7893
|
-
id: mapping.id ? getNestedValue(item, mapping.id) : item?.id,
|
|
7894
|
-
name: getNestedValue(item, mapping.name) ?? '',
|
|
7895
|
-
logo: mapping.logo ? getNestedValue(item, mapping.logo) : undefined,
|
|
7896
|
-
count: mapping.count ? getNestedValue(item, mapping.count) : undefined,
|
|
7897
|
-
_raw: item,
|
|
7898
|
-
};
|
|
8088
|
+
const formatPriceRange = (range, config = {}) => {
|
|
8089
|
+
return `${formatPrice(range.min, config)} - ${formatPrice(range.max, config)}`;
|
|
7899
8090
|
};
|
|
7900
|
-
// ============================================================================
|
|
7901
|
-
// Formatting
|
|
7902
|
-
// ============================================================================
|
|
7903
8091
|
/**
|
|
7904
|
-
*
|
|
8092
|
+
* Given current selections, return which values for an option are still available
|
|
8093
|
+
* based on variant availability.
|
|
7905
8094
|
*/
|
|
7906
|
-
const
|
|
7907
|
-
|
|
7908
|
-
|
|
7909
|
-
|
|
7910
|
-
const
|
|
7911
|
-
|
|
7912
|
-
|
|
7913
|
-
|
|
7914
|
-
|
|
7915
|
-
|
|
8095
|
+
const getAvailableValuesForOption = (optionName, options, variants, selections) => {
|
|
8096
|
+
const option = options.find((o) => o.name === optionName);
|
|
8097
|
+
if (!option)
|
|
8098
|
+
return [];
|
|
8099
|
+
const optionIndex = options.indexOf(option);
|
|
8100
|
+
const optionKey = `option${optionIndex + 1}`;
|
|
8101
|
+
return option.values.map((value) => {
|
|
8102
|
+
// Check if any variant with this value and current other selections is available
|
|
8103
|
+
const available = variants.some((variant) => {
|
|
8104
|
+
if (variant[optionKey] !== value)
|
|
8105
|
+
return false;
|
|
8106
|
+
if (variant.available === false)
|
|
8107
|
+
return false;
|
|
8108
|
+
// Check other selections match
|
|
8109
|
+
for (const [selName, selValue] of Object.entries(selections)) {
|
|
8110
|
+
if (selName === optionName)
|
|
8111
|
+
continue;
|
|
8112
|
+
const selOption = options.find((o) => o.name === selName);
|
|
8113
|
+
if (!selOption)
|
|
8114
|
+
continue;
|
|
8115
|
+
const selIdx = options.indexOf(selOption);
|
|
8116
|
+
const selKey = `option${selIdx + 1}`;
|
|
8117
|
+
if (variant[selKey] !== selValue)
|
|
8118
|
+
return false;
|
|
8119
|
+
}
|
|
8120
|
+
return true;
|
|
8121
|
+
});
|
|
8122
|
+
return { value, available };
|
|
7916
8123
|
});
|
|
7917
|
-
return currencyPosition === 'before'
|
|
7918
|
-
? `${currency}${formatted}`
|
|
7919
|
-
: `${formatted}${currency}`;
|
|
7920
|
-
};
|
|
7921
|
-
/**
|
|
7922
|
-
* Calculate discount percentage
|
|
7923
|
-
*/
|
|
7924
|
-
const calculateDiscount = (price, comparePrice) => {
|
|
7925
|
-
if (!price || !comparePrice || comparePrice <= price)
|
|
7926
|
-
return undefined;
|
|
7927
|
-
return Math.round(((comparePrice - price) / comparePrice) * 100);
|
|
7928
|
-
};
|
|
7929
|
-
// ============================================================================
|
|
7930
|
-
// Text Processing
|
|
7931
|
-
// ============================================================================
|
|
7932
|
-
/**
|
|
7933
|
-
* Escape HTML special characters
|
|
7934
|
-
*/
|
|
7935
|
-
const escapeHtml = (text) => {
|
|
7936
|
-
const map = {
|
|
7937
|
-
'&': '&',
|
|
7938
|
-
'<': '<',
|
|
7939
|
-
'>': '>',
|
|
7940
|
-
'"': '"',
|
|
7941
|
-
"'": ''',
|
|
7942
|
-
};
|
|
7943
|
-
return text.replace(/[&<>"']/g, (char) => map[char]);
|
|
7944
8124
|
};
|
|
7945
8125
|
/**
|
|
7946
|
-
*
|
|
8126
|
+
* Find the exact variant matching all selected options.
|
|
7947
8127
|
*/
|
|
7948
|
-
const
|
|
7949
|
-
|
|
7950
|
-
return
|
|
7951
|
-
|
|
7952
|
-
|
|
7953
|
-
|
|
7954
|
-
|
|
7955
|
-
|
|
7956
|
-
|
|
7957
|
-
|
|
8128
|
+
const findVariantBySelections = (options, variants, selections) => {
|
|
8129
|
+
return variants.find((variant) => {
|
|
8130
|
+
return options.every((option, idx) => {
|
|
8131
|
+
const key = `option${idx + 1}`;
|
|
8132
|
+
const selected = selections[option.name];
|
|
8133
|
+
if (!selected)
|
|
8134
|
+
return true; // not yet selected
|
|
8135
|
+
return variant[key] === selected;
|
|
8136
|
+
});
|
|
8137
|
+
}) ?? null;
|
|
7958
8138
|
};
|
|
7959
8139
|
// ============================================================================
|
|
7960
8140
|
// Theme & Styling
|
|
@@ -8113,80 +8293,1409 @@ class SuggestionsCache {
|
|
|
8113
8293
|
this.cache.delete(key);
|
|
8114
8294
|
return null;
|
|
8115
8295
|
}
|
|
8116
|
-
return entry.data;
|
|
8117
|
-
}
|
|
8118
|
-
/**
|
|
8119
|
-
* Store data in cache
|
|
8120
|
-
*/
|
|
8121
|
-
set(key, data, ttlMs) {
|
|
8122
|
-
// Evict oldest entries if at max size
|
|
8123
|
-
if (this.cache.size >= this.maxSize) {
|
|
8124
|
-
const oldestKey = this.cache.keys().next().value;
|
|
8125
|
-
if (oldestKey)
|
|
8126
|
-
this.cache.delete(oldestKey);
|
|
8296
|
+
return entry.data;
|
|
8297
|
+
}
|
|
8298
|
+
/**
|
|
8299
|
+
* Store data in cache
|
|
8300
|
+
*/
|
|
8301
|
+
set(key, data, ttlMs) {
|
|
8302
|
+
// Evict oldest entries if at max size
|
|
8303
|
+
if (this.cache.size >= this.maxSize) {
|
|
8304
|
+
const oldestKey = this.cache.keys().next().value;
|
|
8305
|
+
if (oldestKey)
|
|
8306
|
+
this.cache.delete(oldestKey);
|
|
8307
|
+
}
|
|
8308
|
+
this.cache.set(key, {
|
|
8309
|
+
data,
|
|
8310
|
+
timestamp: Date.now(),
|
|
8311
|
+
ttl: ttlMs ?? this.defaultTtl,
|
|
8312
|
+
});
|
|
8313
|
+
}
|
|
8314
|
+
/**
|
|
8315
|
+
* Check if key exists and is valid
|
|
8316
|
+
*/
|
|
8317
|
+
has(key) {
|
|
8318
|
+
return this.get(key) !== null;
|
|
8319
|
+
}
|
|
8320
|
+
/**
|
|
8321
|
+
* Clear all cached entries
|
|
8322
|
+
*/
|
|
8323
|
+
clear() {
|
|
8324
|
+
this.cache.clear();
|
|
8325
|
+
}
|
|
8326
|
+
/**
|
|
8327
|
+
* Clear expired entries
|
|
8328
|
+
*/
|
|
8329
|
+
cleanup() {
|
|
8330
|
+
const now = Date.now();
|
|
8331
|
+
for (const [key, entry] of this.cache.entries()) {
|
|
8332
|
+
if (now - entry.timestamp > entry.ttl) {
|
|
8333
|
+
this.cache.delete(key);
|
|
8334
|
+
}
|
|
8335
|
+
}
|
|
8336
|
+
}
|
|
8337
|
+
/**
|
|
8338
|
+
* Get cache statistics
|
|
8339
|
+
*/
|
|
8340
|
+
getStats() {
|
|
8341
|
+
return {
|
|
8342
|
+
size: this.cache.size,
|
|
8343
|
+
maxSize: this.maxSize,
|
|
8344
|
+
};
|
|
8345
|
+
}
|
|
8346
|
+
}
|
|
8347
|
+
// Global cache instance for suggestions (shared across components)
|
|
8348
|
+
let globalSuggestionsCache = null;
|
|
8349
|
+
/**
|
|
8350
|
+
* Get the global suggestions cache instance
|
|
8351
|
+
*/
|
|
8352
|
+
const getSuggestionsCache = (options) => {
|
|
8353
|
+
if (!globalSuggestionsCache) {
|
|
8354
|
+
globalSuggestionsCache = new SuggestionsCache(options);
|
|
8355
|
+
}
|
|
8356
|
+
return globalSuggestionsCache;
|
|
8357
|
+
};
|
|
8358
|
+
/**
|
|
8359
|
+
* Create a new cache instance (for isolated caching per component)
|
|
8360
|
+
*/
|
|
8361
|
+
const createSuggestionsCache = (options) => {
|
|
8362
|
+
return new SuggestionsCache(options);
|
|
8363
|
+
};
|
|
8364
|
+
/**
|
|
8365
|
+
* Clear the global cache
|
|
8366
|
+
*/
|
|
8367
|
+
const clearSuggestionsCache = () => {
|
|
8368
|
+
globalSuggestionsCache?.clear();
|
|
8369
|
+
};
|
|
8370
|
+
|
|
8371
|
+
/**
|
|
8372
|
+
* PriceDisplay – reusable price display primitive
|
|
8373
|
+
*
|
|
8374
|
+
* Handles single price, compare price with strikethrough, discount percentage,
|
|
8375
|
+
* and price range display.
|
|
8376
|
+
*/
|
|
8377
|
+
const formatNum = (value, currency, position, decimals) => {
|
|
8378
|
+
const formatted = value.toLocaleString(undefined, {
|
|
8379
|
+
minimumFractionDigits: decimals,
|
|
8380
|
+
maximumFractionDigits: decimals,
|
|
8381
|
+
});
|
|
8382
|
+
return position === 'before' ? `${currency}${formatted}` : `${formatted}${currency}`;
|
|
8383
|
+
};
|
|
8384
|
+
function PriceDisplay({ price, comparePrice, priceRange, currency = '$', currencyPosition = 'before', priceDecimals = 2, showDiscount = true, className, style, }) {
|
|
8385
|
+
const fmt = (v) => formatNum(v, currency, currencyPosition, priceDecimals);
|
|
8386
|
+
// Price range mode
|
|
8387
|
+
if (priceRange) {
|
|
8388
|
+
return (React.createElement("span", { className: clsx('seekora-price-display', 'seekora-price-range', className), style: style },
|
|
8389
|
+
fmt(priceRange.min),
|
|
8390
|
+
" \u2013 ",
|
|
8391
|
+
fmt(priceRange.max)));
|
|
8392
|
+
}
|
|
8393
|
+
if (price == null)
|
|
8394
|
+
return null;
|
|
8395
|
+
const hasCompare = comparePrice != null && comparePrice > price;
|
|
8396
|
+
const discount = hasCompare ? Math.round(((comparePrice - price) / comparePrice) * 100) : 0;
|
|
8397
|
+
return (React.createElement("span", { className: clsx('seekora-price-display', className), style: { display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', ...style } },
|
|
8398
|
+
React.createElement("span", { className: "seekora-price-current", style: { fontWeight: 600 } }, fmt(price)),
|
|
8399
|
+
hasCompare && (React.createElement("span", { className: "seekora-price-compare", style: {
|
|
8400
|
+
textDecoration: 'line-through',
|
|
8401
|
+
color: 'var(--seekora-text-secondary, #6b7280)',
|
|
8402
|
+
fontSize: '0.875em',
|
|
8403
|
+
} }, fmt(comparePrice))),
|
|
8404
|
+
hasCompare && showDiscount && discount > 0 && (React.createElement("span", { className: "seekora-price-discount", style: {
|
|
8405
|
+
color: 'var(--seekora-error, #ef4444)',
|
|
8406
|
+
fontSize: '0.8125em',
|
|
8407
|
+
fontWeight: 600,
|
|
8408
|
+
} },
|
|
8409
|
+
"-",
|
|
8410
|
+
discount,
|
|
8411
|
+
"%"))));
|
|
8412
|
+
}
|
|
8413
|
+
|
|
8414
|
+
/**
|
|
8415
|
+
* BadgeList – renders product badges (sale, new, sold out, custom)
|
|
8416
|
+
*/
|
|
8417
|
+
const positionStyles = {
|
|
8418
|
+
'top-left': { position: 'absolute', top: 6, left: 6 },
|
|
8419
|
+
'top-right': { position: 'absolute', top: 6, right: 6 },
|
|
8420
|
+
'bottom-left': { position: 'absolute', bottom: 6, left: 6 },
|
|
8421
|
+
'bottom-right': { position: 'absolute', bottom: 6, right: 6 },
|
|
8422
|
+
inline: {},
|
|
8423
|
+
};
|
|
8424
|
+
const typeColors = {
|
|
8425
|
+
sale: { bg: '#ef4444', text: '#fff' },
|
|
8426
|
+
new: { bg: '#3b82f6', text: '#fff' },
|
|
8427
|
+
soldOut: { bg: '#6b7280', text: '#fff' },
|
|
8428
|
+
limited: { bg: '#f59e0b', text: '#fff' },
|
|
8429
|
+
custom: { bg: '#111827', text: '#fff' },
|
|
8430
|
+
};
|
|
8431
|
+
function BadgeList({ badges, maxBadges, position = 'top-left', className, style, }) {
|
|
8432
|
+
if (!badges || badges.length === 0)
|
|
8433
|
+
return null;
|
|
8434
|
+
const visible = maxBadges ? badges.slice(0, maxBadges) : badges;
|
|
8435
|
+
return (React.createElement("div", { className: clsx('seekora-badge-list', className), style: {
|
|
8436
|
+
display: 'flex',
|
|
8437
|
+
flexWrap: 'wrap',
|
|
8438
|
+
gap: 4,
|
|
8439
|
+
zIndex: 1,
|
|
8440
|
+
...positionStyles[position],
|
|
8441
|
+
...style,
|
|
8442
|
+
} }, visible.map((badge, i) => {
|
|
8443
|
+
const colors = typeColors[badge.type ?? 'custom'];
|
|
8444
|
+
return (React.createElement("span", { key: `${badge.text}-${i}`, className: clsx('seekora-badge', badge.type && `seekora-badge--${badge.type === 'soldOut' ? 'sold-out' : badge.type}`), style: {
|
|
8445
|
+
display: 'inline-block',
|
|
8446
|
+
padding: '2px 8px',
|
|
8447
|
+
borderRadius: 4,
|
|
8448
|
+
fontSize: '0.6875rem',
|
|
8449
|
+
fontWeight: 600,
|
|
8450
|
+
lineHeight: 1.4,
|
|
8451
|
+
backgroundColor: badge.color ?? colors.bg,
|
|
8452
|
+
color: badge.textColor ?? colors.text,
|
|
8453
|
+
whiteSpace: 'nowrap',
|
|
8454
|
+
} }, badge.text));
|
|
8455
|
+
})));
|
|
8456
|
+
}
|
|
8457
|
+
|
|
8458
|
+
/**
|
|
8459
|
+
* RatingDisplay – star rating display with review count
|
|
8460
|
+
*
|
|
8461
|
+
* Supports multiple variants: stars-only, compact, full, inline.
|
|
8462
|
+
* Can be read-only (display) or interactive (for reviews).
|
|
8463
|
+
*/
|
|
8464
|
+
const sizeMap = {
|
|
8465
|
+
small: 14,
|
|
8466
|
+
medium: 18,
|
|
8467
|
+
large: 24,
|
|
8468
|
+
};
|
|
8469
|
+
const fontSizeMap = {
|
|
8470
|
+
small: '0.75rem',
|
|
8471
|
+
medium: '0.875rem',
|
|
8472
|
+
large: '1rem',
|
|
8473
|
+
};
|
|
8474
|
+
function StarIcon({ filled, half, size, color, emptyColor, interactive, onHover, onClick, }) {
|
|
8475
|
+
if (half) {
|
|
8476
|
+
return (React.createElement("span", { className: clsx('seekora-rating-star', 'seekora-rating-star--half', interactive && 'seekora-rating-star--interactive'), style: {
|
|
8477
|
+
position: 'relative',
|
|
8478
|
+
display: 'inline-block',
|
|
8479
|
+
width: size,
|
|
8480
|
+
height: size,
|
|
8481
|
+
cursor: interactive ? 'pointer' : 'default',
|
|
8482
|
+
}, onMouseEnter: onHover, onClick: onClick },
|
|
8483
|
+
React.createElement("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", xmlns: "http://www.w3.org/2000/svg", style: { position: 'absolute', top: 0, left: 0 } },
|
|
8484
|
+
React.createElement("defs", null,
|
|
8485
|
+
React.createElement("linearGradient", { id: "half-fill" },
|
|
8486
|
+
React.createElement("stop", { offset: "50%", stopColor: color }),
|
|
8487
|
+
React.createElement("stop", { offset: "50%", stopColor: emptyColor }))),
|
|
8488
|
+
React.createElement("path", { d: "M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z", fill: "url(#half-fill)", stroke: color, strokeWidth: "1" }))));
|
|
8489
|
+
}
|
|
8490
|
+
return (React.createElement("span", { className: clsx('seekora-rating-star', filled ? 'seekora-rating-star--filled' : 'seekora-rating-star--empty', interactive && 'seekora-rating-star--interactive'), style: {
|
|
8491
|
+
display: 'inline-block',
|
|
8492
|
+
width: size,
|
|
8493
|
+
height: size,
|
|
8494
|
+
cursor: interactive ? 'pointer' : 'default',
|
|
8495
|
+
}, onMouseEnter: onHover, onClick: onClick },
|
|
8496
|
+
React.createElement("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: filled ? color : 'none', xmlns: "http://www.w3.org/2000/svg" },
|
|
8497
|
+
React.createElement("path", { d: "M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z", stroke: filled ? color : emptyColor, strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }))));
|
|
8498
|
+
}
|
|
8499
|
+
function RatingDisplay({ rating, reviewCount, variant = 'compact', size = 'medium', maxRating = 5, showNumeric = false, showHalfStars = true, interactive = false, onRatingChange, starColor = '#f59e0b', emptyStarColor = '#d1d5db', textColor = 'var(--seekora-text-secondary, #6b7280)', showReviewCount = true, reviewCountFormat, className, style, }) {
|
|
8500
|
+
const [hoverRating, setHoverRating] = React.useState(null);
|
|
8501
|
+
const clampedRating = Math.max(0, Math.min(maxRating, rating));
|
|
8502
|
+
const displayRating = interactive && hoverRating !== null ? hoverRating : clampedRating;
|
|
8503
|
+
const starSize = sizeMap[size];
|
|
8504
|
+
const fontSize = fontSizeMap[size];
|
|
8505
|
+
const formatReviewCount = (count) => {
|
|
8506
|
+
if (reviewCountFormat)
|
|
8507
|
+
return reviewCountFormat(count);
|
|
8508
|
+
if (count >= 1000000)
|
|
8509
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
8510
|
+
if (count >= 1000)
|
|
8511
|
+
return `${(count / 1000).toFixed(1)}K`;
|
|
8512
|
+
return count.toString();
|
|
8513
|
+
};
|
|
8514
|
+
const renderStars = () => {
|
|
8515
|
+
const stars = [];
|
|
8516
|
+
for (let i = 1; i <= maxRating; i++) {
|
|
8517
|
+
const filled = i <= Math.floor(displayRating);
|
|
8518
|
+
const half = showHalfStars && i === Math.ceil(displayRating) && displayRating % 1 >= 0.25 && displayRating % 1 < 0.75;
|
|
8519
|
+
stars.push(React.createElement(StarIcon, { key: i, filled: filled, half: half, size: starSize, color: starColor, emptyColor: emptyStarColor, interactive: interactive, onHover: interactive ? () => setHoverRating(i) : undefined, onClick: interactive
|
|
8520
|
+
? () => {
|
|
8521
|
+
setHoverRating(null);
|
|
8522
|
+
onRatingChange?.(i);
|
|
8523
|
+
}
|
|
8524
|
+
: undefined }));
|
|
8525
|
+
}
|
|
8526
|
+
return stars;
|
|
8527
|
+
};
|
|
8528
|
+
const handleMouseLeave = () => {
|
|
8529
|
+
if (interactive) {
|
|
8530
|
+
setHoverRating(null);
|
|
8531
|
+
}
|
|
8532
|
+
};
|
|
8533
|
+
if (variant === 'stars-only') {
|
|
8534
|
+
return (React.createElement("div", { className: clsx('seekora-rating-display', 'seekora-rating-display--stars-only', className), style: { display: 'inline-flex', alignItems: 'center', gap: 2, ...style }, onMouseLeave: handleMouseLeave }, renderStars()));
|
|
8535
|
+
}
|
|
8536
|
+
if (variant === 'compact') {
|
|
8537
|
+
return (React.createElement("div", { className: clsx('seekora-rating-display', 'seekora-rating-display--compact', className), style: { display: 'inline-flex', alignItems: 'center', gap: 4, fontSize, ...style }, onMouseLeave: handleMouseLeave },
|
|
8538
|
+
React.createElement("div", { style: { display: 'inline-flex', alignItems: 'center', gap: 2 } }, renderStars()),
|
|
8539
|
+
showNumeric && (React.createElement("span", { className: "seekora-rating-numeric", style: { fontWeight: 600, color: textColor } }, clampedRating.toFixed(1))),
|
|
8540
|
+
showReviewCount && reviewCount != null && reviewCount > 0 && (React.createElement("span", { className: "seekora-rating-review-count", style: { color: textColor } },
|
|
8541
|
+
"(",
|
|
8542
|
+
formatReviewCount(reviewCount),
|
|
8543
|
+
")"))));
|
|
8544
|
+
}
|
|
8545
|
+
if (variant === 'full') {
|
|
8546
|
+
return (React.createElement("div", { className: clsx('seekora-rating-display', 'seekora-rating-display--full', className), style: { display: 'flex', flexDirection: 'column', gap: 4, fontSize, ...style }, onMouseLeave: handleMouseLeave },
|
|
8547
|
+
React.createElement("div", { style: { display: 'flex', alignItems: 'center', gap: 6 } },
|
|
8548
|
+
React.createElement("div", { style: { display: 'inline-flex', alignItems: 'center', gap: 2 } }, renderStars()),
|
|
8549
|
+
React.createElement("span", { className: "seekora-rating-numeric", style: { fontWeight: 600, color: 'var(--seekora-text-primary, #111827)' } }, clampedRating.toFixed(1)),
|
|
8550
|
+
React.createElement("span", { className: "seekora-rating-max", style: { color: textColor } },
|
|
8551
|
+
"/ ",
|
|
8552
|
+
maxRating)),
|
|
8553
|
+
showReviewCount && reviewCount != null && reviewCount > 0 && (React.createElement("span", { className: "seekora-rating-review-text", style: { fontSize: '0.875em', color: textColor } },
|
|
8554
|
+
"Based on ",
|
|
8555
|
+
formatReviewCount(reviewCount),
|
|
8556
|
+
" ",
|
|
8557
|
+
reviewCount === 1 ? 'review' : 'reviews'))));
|
|
8558
|
+
}
|
|
8559
|
+
if (variant === 'inline') {
|
|
8560
|
+
return (React.createElement("div", { className: clsx('seekora-rating-display', 'seekora-rating-display--inline', className), style: { display: 'inline-flex', alignItems: 'center', gap: 6, fontSize, ...style }, onMouseLeave: handleMouseLeave },
|
|
8561
|
+
React.createElement("span", { className: "seekora-rating-numeric", style: { fontWeight: 600, color: 'var(--seekora-text-primary, #111827)' } }, clampedRating.toFixed(1)),
|
|
8562
|
+
React.createElement("div", { style: { display: 'inline-flex', alignItems: 'center', gap: 2 } }, renderStars()),
|
|
8563
|
+
showReviewCount && reviewCount != null && reviewCount > 0 && (React.createElement("span", { className: "seekora-rating-review-count", style: { color: textColor } },
|
|
8564
|
+
"(",
|
|
8565
|
+
formatReviewCount(reviewCount),
|
|
8566
|
+
")"))));
|
|
8567
|
+
}
|
|
8568
|
+
return null;
|
|
8569
|
+
}
|
|
8570
|
+
|
|
8571
|
+
/**
|
|
8572
|
+
* VariantSwatches – inline variant indicators for card display
|
|
8573
|
+
*
|
|
8574
|
+
* Shows color dots, size labels, and "+N more" overflow for compact card views.
|
|
8575
|
+
*/
|
|
8576
|
+
const COLOR_NAMES$1 = {
|
|
8577
|
+
black: '#000', white: '#fff', red: '#ef4444', blue: '#3b82f6',
|
|
8578
|
+
green: '#22c55e', yellow: '#eab308', orange: '#f97316', purple: '#a855f7',
|
|
8579
|
+
pink: '#ec4899', brown: '#92400e', grey: '#6b7280', gray: '#6b7280',
|
|
8580
|
+
navy: '#1e3a5f', beige: '#d4c5a9', cream: '#fffdd0', ivory: '#fffff0',
|
|
8581
|
+
khaki: '#c3b091', olive: '#808000', teal: '#0d9488', coral: '#ff7f50',
|
|
8582
|
+
maroon: '#800000', tan: '#d2b48c', charcoal: '#36454f', burgundy: '#800020',
|
|
8583
|
+
sage: '#9caf88', lavender: '#e6e6fa', mint: '#98fb98', rust: '#b7410e',
|
|
8584
|
+
plum: '#8e4585', slate: '#708090', indigo: '#4b0082', gold: '#ffd700',
|
|
8585
|
+
silver: '#c0c0c0', rose: '#ff007f', mauve: '#e0b0ff', wine: '#722f37',
|
|
8586
|
+
raven: '#0a0a0a', natural: '#f5f0e1', bone: '#e3dac9', sand: '#c2b280',
|
|
8587
|
+
};
|
|
8588
|
+
const isColorOption$1 = (name) => {
|
|
8589
|
+
const lower = name.toLowerCase();
|
|
8590
|
+
return lower === 'color' || lower === 'colour' || lower === 'colors' || lower === 'colours';
|
|
8591
|
+
};
|
|
8592
|
+
const resolveColor$1 = (value, colorMap) => {
|
|
8593
|
+
if (colorMap?.[value])
|
|
8594
|
+
return colorMap[value];
|
|
8595
|
+
const lower = value.toLowerCase();
|
|
8596
|
+
if (colorMap?.[lower])
|
|
8597
|
+
return colorMap[lower];
|
|
8598
|
+
return COLOR_NAMES$1[lower] ?? null;
|
|
8599
|
+
};
|
|
8600
|
+
const getAvailability$1 = (optionName, value, options, variants, selectedValues) => {
|
|
8601
|
+
if (!variants || variants.length === 0)
|
|
8602
|
+
return true;
|
|
8603
|
+
const optionIndex = options.findIndex((o) => o.name === optionName);
|
|
8604
|
+
if (optionIndex === -1)
|
|
8605
|
+
return true;
|
|
8606
|
+
const optionKey = `option${optionIndex + 1}`;
|
|
8607
|
+
return variants.some((variant) => {
|
|
8608
|
+
// Must match the current option value
|
|
8609
|
+
if (variant[optionKey] !== value)
|
|
8610
|
+
return false;
|
|
8611
|
+
// Must be available
|
|
8612
|
+
if (variant.available === false)
|
|
8613
|
+
return false;
|
|
8614
|
+
// Must match all other selected values
|
|
8615
|
+
if (selectedValues) {
|
|
8616
|
+
for (const [selName, selValue] of Object.entries(selectedValues)) {
|
|
8617
|
+
if (selName === optionName)
|
|
8618
|
+
continue; // Skip current option
|
|
8619
|
+
const selIdx = options.findIndex((o) => o.name === selName);
|
|
8620
|
+
if (selIdx === -1)
|
|
8621
|
+
continue;
|
|
8622
|
+
const selKey = `option${selIdx + 1}`;
|
|
8623
|
+
if (variant[selKey] !== selValue)
|
|
8624
|
+
return false;
|
|
8625
|
+
}
|
|
8626
|
+
}
|
|
8627
|
+
return true;
|
|
8628
|
+
});
|
|
8629
|
+
};
|
|
8630
|
+
function VariantSwatches({ options, visibleOptions, maxValues = 5, colorMap, selectedValues, variants, showAvailability = true, onSwatchHover, onSwatchClick, className, style, }) {
|
|
8631
|
+
const [expandedOptions, setExpandedOptions] = React.useState(new Set());
|
|
8632
|
+
if (!options || options.length === 0)
|
|
8633
|
+
return null;
|
|
8634
|
+
const filtered = visibleOptions
|
|
8635
|
+
? options.filter((o) => visibleOptions.includes(o.name))
|
|
8636
|
+
: options;
|
|
8637
|
+
if (filtered.length === 0)
|
|
8638
|
+
return null;
|
|
8639
|
+
const toggleExpanded = (optionName, e) => {
|
|
8640
|
+
e.stopPropagation();
|
|
8641
|
+
setExpandedOptions((prev) => {
|
|
8642
|
+
const next = new Set(prev);
|
|
8643
|
+
if (next.has(optionName)) {
|
|
8644
|
+
next.delete(optionName);
|
|
8645
|
+
}
|
|
8646
|
+
else {
|
|
8647
|
+
next.add(optionName);
|
|
8648
|
+
}
|
|
8649
|
+
return next;
|
|
8650
|
+
});
|
|
8651
|
+
};
|
|
8652
|
+
return (React.createElement("div", { className: clsx('seekora-variant-swatches', className), style: { display: 'flex', flexDirection: 'column', gap: 4, ...style } }, filtered.map((option) => {
|
|
8653
|
+
const isColor = isColorOption$1(option.name);
|
|
8654
|
+
const isExpanded = expandedOptions.has(option.name);
|
|
8655
|
+
const visible = isExpanded ? option.values : option.values.slice(0, maxValues);
|
|
8656
|
+
const overflow = option.values.length - maxValues;
|
|
8657
|
+
const hasOverflow = overflow > 0;
|
|
8658
|
+
const selectedValue = selectedValues?.[option.name];
|
|
8659
|
+
return (React.createElement("div", { key: option.name, className: "seekora-variant-swatch-group", style: { display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' } },
|
|
8660
|
+
visible.map((value) => {
|
|
8661
|
+
const color = isColor ? resolveColor$1(value, colorMap) : null;
|
|
8662
|
+
const isSelected = selectedValue === value;
|
|
8663
|
+
const isAvailable = showAvailability
|
|
8664
|
+
? getAvailability$1(option.name, value, options, variants, selectedValues)
|
|
8665
|
+
: true;
|
|
8666
|
+
if (color) {
|
|
8667
|
+
return (React.createElement("span", { key: value, className: clsx('seekora-variant-swatch', 'seekora-variant-swatch--color', isSelected && 'seekora-variant-swatch--selected', !isAvailable && 'seekora-variant-swatch--unavailable'), title: `${value}${!isAvailable ? ' (Unavailable)' : ''}`, style: {
|
|
8668
|
+
display: 'inline-block',
|
|
8669
|
+
width: 14,
|
|
8670
|
+
height: 14,
|
|
8671
|
+
borderRadius: '50%',
|
|
8672
|
+
backgroundColor: color,
|
|
8673
|
+
border: isSelected
|
|
8674
|
+
? '2px solid var(--seekora-primary, #111827)'
|
|
8675
|
+
: '1px solid var(--seekora-border-color, #e5e7eb)',
|
|
8676
|
+
outline: isSelected ? '2px solid var(--seekora-primary, #111827)' : 'none',
|
|
8677
|
+
outlineOffset: 2,
|
|
8678
|
+
cursor: onSwatchClick && isAvailable ? 'pointer' : 'not-allowed',
|
|
8679
|
+
flexShrink: 0,
|
|
8680
|
+
boxShadow: isSelected ? '0 0 0 1px #fff' : 'none',
|
|
8681
|
+
opacity: isAvailable ? 1 : 0.3,
|
|
8682
|
+
position: 'relative',
|
|
8683
|
+
}, onMouseEnter: () => isAvailable && onSwatchHover?.(option.name, value), onMouseDown: (e) => {
|
|
8684
|
+
e.stopPropagation();
|
|
8685
|
+
e.preventDefault();
|
|
8686
|
+
}, onClick: (e) => {
|
|
8687
|
+
e.stopPropagation();
|
|
8688
|
+
if (isAvailable) {
|
|
8689
|
+
onSwatchClick?.(option.name, value);
|
|
8690
|
+
}
|
|
8691
|
+
} }, !isAvailable && (React.createElement("span", { style: {
|
|
8692
|
+
position: 'absolute',
|
|
8693
|
+
top: '50%',
|
|
8694
|
+
left: '-2px',
|
|
8695
|
+
right: '-2px',
|
|
8696
|
+
height: 1,
|
|
8697
|
+
backgroundColor: '#ef4444',
|
|
8698
|
+
transform: 'translateY(-50%) rotate(-45deg)',
|
|
8699
|
+
} }))));
|
|
8700
|
+
}
|
|
8701
|
+
return (React.createElement("span", { key: value, className: clsx('seekora-variant-swatch', 'seekora-variant-swatch--text', isSelected && 'seekora-variant-swatch--selected', !isAvailable && 'seekora-variant-swatch--unavailable'), style: {
|
|
8702
|
+
display: 'inline-block',
|
|
8703
|
+
padding: '1px 6px',
|
|
8704
|
+
fontSize: '0.6875rem',
|
|
8705
|
+
borderRadius: 3,
|
|
8706
|
+
border: isSelected
|
|
8707
|
+
? '1px solid var(--seekora-primary, #111827)'
|
|
8708
|
+
: '1px solid var(--seekora-border-color, #e5e7eb)',
|
|
8709
|
+
backgroundColor: isSelected
|
|
8710
|
+
? 'var(--seekora-primary, #111827)'
|
|
8711
|
+
: 'transparent',
|
|
8712
|
+
color: isSelected
|
|
8713
|
+
? '#fff'
|
|
8714
|
+
: 'var(--seekora-text-secondary, #6b7280)',
|
|
8715
|
+
cursor: onSwatchClick && isAvailable ? 'pointer' : 'not-allowed',
|
|
8716
|
+
whiteSpace: 'nowrap',
|
|
8717
|
+
fontWeight: isSelected ? 600 : 400,
|
|
8718
|
+
opacity: isAvailable ? 1 : 0.4,
|
|
8719
|
+
textDecoration: !isAvailable ? 'line-through' : 'none',
|
|
8720
|
+
}, onMouseEnter: () => isAvailable && onSwatchHover?.(option.name, value), onMouseDown: (e) => {
|
|
8721
|
+
e.stopPropagation();
|
|
8722
|
+
e.preventDefault();
|
|
8723
|
+
}, onClick: (e) => {
|
|
8724
|
+
e.stopPropagation();
|
|
8725
|
+
if (isAvailable) {
|
|
8726
|
+
onSwatchClick?.(option.name, value);
|
|
8727
|
+
}
|
|
8728
|
+
} }, value));
|
|
8729
|
+
}),
|
|
8730
|
+
hasOverflow && (React.createElement("button", { type: "button", className: "seekora-variant-swatch--overflow", onClick: (e) => toggleExpanded(option.name, e), style: {
|
|
8731
|
+
fontSize: '0.6875rem',
|
|
8732
|
+
color: 'var(--seekora-primary, #2563eb)',
|
|
8733
|
+
background: 'none',
|
|
8734
|
+
border: 'none',
|
|
8735
|
+
padding: 0,
|
|
8736
|
+
cursor: 'pointer',
|
|
8737
|
+
textDecoration: 'underline',
|
|
8738
|
+
fontWeight: 500,
|
|
8739
|
+
} }, isExpanded ? 'Show less' : `+${overflow} more`))));
|
|
8740
|
+
})));
|
|
8741
|
+
}
|
|
8742
|
+
|
|
8743
|
+
/**
|
|
8744
|
+
* ProductCardLayouts – internal layout sub-components for ProductCard
|
|
8745
|
+
*
|
|
8746
|
+
* Not exported from the package. Each layout renders the same product data
|
|
8747
|
+
* with different visual emphasis.
|
|
8748
|
+
*/
|
|
8749
|
+
const imgPlaceholderStyle = {
|
|
8750
|
+
width: '100%',
|
|
8751
|
+
aspectRatio: '1',
|
|
8752
|
+
objectFit: 'cover',
|
|
8753
|
+
borderRadius: 4,
|
|
8754
|
+
backgroundColor: 'var(--seekora-bg-secondary, #f3f4f6)',
|
|
8755
|
+
};
|
|
8756
|
+
function ImageBlock({ images, title, imageVariant, aspectRatio, enableZoom, zoomMode, zoomLevel }) {
|
|
8757
|
+
const ar = aspectRatio ? aspectRatio.replace(':', '/') : '1';
|
|
8758
|
+
if (images.length > 0) {
|
|
8759
|
+
return (React.createElement("div", { className: "seekora-product-card__image", style: { position: 'relative', overflow: 'hidden', borderRadius: 4 } },
|
|
8760
|
+
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 })));
|
|
8761
|
+
}
|
|
8762
|
+
return React.createElement("div", { className: "seekora-product-card__image seekora-suggestions-product-card-placeholder", style: { ...imgPlaceholderStyle, aspectRatio: ar }, "aria-hidden": true });
|
|
8763
|
+
}
|
|
8764
|
+
/** minimal: image, title, price (current default behavior) */
|
|
8765
|
+
function MinimalLayout({ images, title, price, product, imageVariant, displayConfig, enableImageZoom, imageZoomMode, imageZoomLevel }) {
|
|
8766
|
+
return (React.createElement(React.Fragment, null,
|
|
8767
|
+
React.createElement(ImageBlock, { images: images, title: title, imageVariant: imageVariant, aspectRatio: displayConfig.imageAspectRatio, enableZoom: enableImageZoom, zoomMode: imageZoomMode, zoomLevel: imageZoomLevel }),
|
|
8768
|
+
React.createElement("span", { className: "seekora-product-card__title", style: { fontSize: '0.875rem', fontWeight: 500 } }, title),
|
|
8769
|
+
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)' } },
|
|
8770
|
+
product.currency ?? '$',
|
|
8771
|
+
price.toFixed(2)))));
|
|
8772
|
+
}
|
|
8773
|
+
/** standard: image, badges, brand, title, price + compare price, color swatches */
|
|
8774
|
+
function StandardLayout({ images, title, price, comparePrice, brand, badges, options, variants, product, imageVariant, displayConfig, onVariantHover, onVariantClick, selectedVariants, actionButtons, actionButtonsPosition, showActionLabels, enableImageZoom, imageZoomMode, imageZoomLevel, }) {
|
|
8775
|
+
const cfg = displayConfig;
|
|
8776
|
+
return (React.createElement(React.Fragment, null,
|
|
8777
|
+
React.createElement("div", { style: { position: 'relative' } },
|
|
8778
|
+
React.createElement(ImageBlock, { images: images, title: title, imageVariant: imageVariant, aspectRatio: cfg.imageAspectRatio, enableZoom: enableImageZoom, zoomMode: imageZoomMode, zoomLevel: imageZoomLevel }),
|
|
8779
|
+
cfg.showBadges !== false && badges.length > 0 && (React.createElement(BadgeList, { badges: badges, position: "top-left", maxBadges: 2 })),
|
|
8780
|
+
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" }))),
|
|
8781
|
+
React.createElement("div", { className: "seekora-product-card__body", style: { display: 'flex', flexDirection: 'column', gap: 4 } },
|
|
8782
|
+
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)),
|
|
8783
|
+
React.createElement("span", { className: "seekora-product-card__title", style: { fontSize: '0.875rem', fontWeight: 500 } }, title),
|
|
8784
|
+
React.createElement("div", { className: "seekora-product-card__price" },
|
|
8785
|
+
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' } })),
|
|
8786
|
+
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 })),
|
|
8787
|
+
actionButtons && actionButtons.length > 0 && actionButtonsPosition === 'inline' && (React.createElement(ActionButtons, { buttons: actionButtons, position: "inline", showLabels: showActionLabels, size: "small", layout: "horizontal" })))));
|
|
8788
|
+
}
|
|
8789
|
+
/** detailed: image, badges, brand, title, price + compare + discount, rating, all swatches, stock */
|
|
8790
|
+
function DetailedLayout({ images, title, price, comparePrice, brand, badges, priceRange, options, variants, product, imageVariant, displayConfig, onVariantHover, onVariantClick, selectedVariants, actionButtons, actionButtonsPosition, showActionLabels, enableImageZoom, imageZoomMode, imageZoomLevel, }) {
|
|
8791
|
+
const cfg = displayConfig;
|
|
8792
|
+
const available = product.available;
|
|
8793
|
+
return (React.createElement(React.Fragment, null,
|
|
8794
|
+
React.createElement("div", { style: { position: 'relative' } },
|
|
8795
|
+
React.createElement(ImageBlock, { images: images, title: title, imageVariant: imageVariant, aspectRatio: cfg.imageAspectRatio, enableZoom: enableImageZoom, zoomMode: imageZoomMode, zoomLevel: imageZoomLevel }),
|
|
8796
|
+
cfg.showBadges !== false && badges.length > 0 && (React.createElement(BadgeList, { badges: badges, position: "top-left" })),
|
|
8797
|
+
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" }))),
|
|
8798
|
+
React.createElement("div", { className: "seekora-product-card__body", style: { display: 'flex', flexDirection: 'column', gap: 4 } },
|
|
8799
|
+
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)),
|
|
8800
|
+
React.createElement("span", { className: "seekora-product-card__title", style: { fontSize: '0.875rem', fontWeight: 500 } }, title),
|
|
8801
|
+
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" })),
|
|
8802
|
+
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' } }))),
|
|
8803
|
+
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 })),
|
|
8804
|
+
cfg.showStock !== false && available != null && (React.createElement("span", { className: "seekora-product-card__stock", style: {
|
|
8805
|
+
fontSize: '0.75rem',
|
|
8806
|
+
color: available ? 'var(--seekora-success, #22c55e)' : 'var(--seekora-error, #ef4444)',
|
|
8807
|
+
} }, available ? 'In Stock' : 'Out of Stock')),
|
|
8808
|
+
actionButtons && actionButtons.length > 0 && actionButtonsPosition === 'inline' && (React.createElement(ActionButtons, { buttons: actionButtons, position: "inline", showLabels: showActionLabels, size: "small", layout: "horizontal" })))));
|
|
8809
|
+
}
|
|
8810
|
+
/** compact: smaller image, 1-line title, price */
|
|
8811
|
+
function CompactLayout({ images, title, price, product, imageVariant, displayConfig, enableImageZoom, imageZoomMode, imageZoomLevel }) {
|
|
8812
|
+
return (React.createElement(React.Fragment, null,
|
|
8813
|
+
React.createElement(ImageBlock, { images: images, title: title, imageVariant: imageVariant, aspectRatio: displayConfig.imageAspectRatio ?? '1:1', enableZoom: enableImageZoom, zoomMode: imageZoomMode, zoomLevel: imageZoomLevel }),
|
|
8814
|
+
React.createElement("span", { className: "seekora-product-card__title", style: {
|
|
8815
|
+
fontSize: '0.8125rem',
|
|
8816
|
+
fontWeight: 500,
|
|
8817
|
+
overflow: 'hidden',
|
|
8818
|
+
textOverflow: 'ellipsis',
|
|
8819
|
+
whiteSpace: 'nowrap',
|
|
8820
|
+
} }, title),
|
|
8821
|
+
price != null && !Number.isNaN(price) && (React.createElement("span", { className: "seekora-product-card__price", style: { fontSize: '0.8125rem', color: 'var(--seekora-text-secondary, #6b7280)' } },
|
|
8822
|
+
product.currency ?? '$',
|
|
8823
|
+
price.toFixed(2)))));
|
|
8824
|
+
}
|
|
8825
|
+
/** horizontal: image left + content right (title, brand, price, swatches) */
|
|
8826
|
+
function HorizontalLayout({ images, title, price, comparePrice, brand, badges, options, variants, product, imageVariant, displayConfig, onVariantHover, onVariantClick, selectedVariants, actionButtons, actionButtonsPosition, showActionLabels, enableImageZoom, imageZoomMode, imageZoomLevel, }) {
|
|
8827
|
+
const cfg = displayConfig;
|
|
8828
|
+
return (React.createElement("div", { style: { display: 'flex', gap: 12, alignItems: 'flex-start' } },
|
|
8829
|
+
React.createElement("div", { style: { position: 'relative', width: 80, flexShrink: 0 } },
|
|
8830
|
+
React.createElement(ImageBlock, { images: images, title: title, imageVariant: imageVariant, aspectRatio: "1:1", enableZoom: enableImageZoom, zoomMode: imageZoomMode, zoomLevel: imageZoomLevel }),
|
|
8831
|
+
cfg.showBadges !== false && badges.length > 0 && (React.createElement(BadgeList, { badges: badges, position: "top-left", maxBadges: 1 })),
|
|
8832
|
+
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" }))),
|
|
8833
|
+
React.createElement("div", { className: "seekora-product-card__body", style: { display: 'flex', flexDirection: 'column', gap: 4, flex: 1, minWidth: 0 } },
|
|
8834
|
+
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)),
|
|
8835
|
+
React.createElement("span", { className: "seekora-product-card__title", style: { fontSize: '0.875rem', fontWeight: 500 } }, title),
|
|
8836
|
+
React.createElement("div", { className: "seekora-product-card__price" },
|
|
8837
|
+
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' } })),
|
|
8838
|
+
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 })),
|
|
8839
|
+
actionButtons && actionButtons.length > 0 && actionButtonsPosition === 'inline' && (React.createElement(ActionButtons, { buttons: actionButtons, position: "inline", showLabels: showActionLabels, size: "small", layout: "horizontal" })))));
|
|
8840
|
+
}
|
|
8841
|
+
|
|
8842
|
+
/**
|
|
8843
|
+
* ProductCard – one product tile (primitive)
|
|
8844
|
+
*
|
|
8845
|
+
* Without displayConfig: renders the original minimal layout (image, title, price).
|
|
8846
|
+
* With displayConfig: renders layout variants (minimal, standard, detailed, compact, horizontal).
|
|
8847
|
+
*/
|
|
8848
|
+
const cardStyle = {
|
|
8849
|
+
display: 'flex',
|
|
8850
|
+
flexDirection: 'column',
|
|
8851
|
+
gap: 8,
|
|
8852
|
+
padding: 8,
|
|
8853
|
+
cursor: 'pointer',
|
|
8854
|
+
border: 'none',
|
|
8855
|
+
borderRadius: 'var(--seekora-border-radius, 6px)',
|
|
8856
|
+
backgroundColor: 'transparent',
|
|
8857
|
+
textAlign: 'left',
|
|
8858
|
+
transition: 'background-color 120ms ease',
|
|
8859
|
+
};
|
|
8860
|
+
const imgStyle = {
|
|
8861
|
+
width: '100%',
|
|
8862
|
+
aspectRatio: '1',
|
|
8863
|
+
objectFit: 'cover',
|
|
8864
|
+
borderRadius: 4,
|
|
8865
|
+
backgroundColor: 'var(--seekora-bg-secondary, #f3f4f6)',
|
|
8866
|
+
};
|
|
8867
|
+
function ProductCard({ product, position, section, tabId, onSelect, className, style, imageVariant = 'single', displayConfig, onVariantHover, onVariantClick, selectedVariants, asLink, actionButtons, actionButtonsPosition = 'overlay-top-right', showActionLabels = false, enableImageZoom = false, imageZoomMode = 'both', imageZoomLevel = 2.5, }) {
|
|
8868
|
+
// Find selected variant if selections are provided
|
|
8869
|
+
const selectedVariant = React.useMemo(() => {
|
|
8870
|
+
if (!selectedVariants || !product.options || !product.variants)
|
|
8871
|
+
return null;
|
|
8872
|
+
return findVariantBySelections(product.options, product.variants, selectedVariants);
|
|
8873
|
+
}, [selectedVariants, product.options, product.variants]);
|
|
8874
|
+
// Compute effective display data (use selected variant if available, otherwise product defaults)
|
|
8875
|
+
const effectiveImages = React.useMemo(() => {
|
|
8876
|
+
// Priority: selected variant image > product images > product image
|
|
8877
|
+
if (selectedVariant?.image)
|
|
8878
|
+
return [selectedVariant.image];
|
|
8879
|
+
if (product.images?.length)
|
|
8880
|
+
return product.images;
|
|
8881
|
+
if (product.image ?? product.imageUrl)
|
|
8882
|
+
return [String(product.image ?? product.imageUrl)];
|
|
8883
|
+
return [];
|
|
8884
|
+
}, [selectedVariant, product]);
|
|
8885
|
+
const effectiveTitle = React.useMemo(() => {
|
|
8886
|
+
// Show variant title if available (e.g., "T-Shirt - Black / M")
|
|
8887
|
+
if (selectedVariant?.title && selectedVariant.title !== product.title) {
|
|
8888
|
+
return `${product.title ?? product.name ?? ''} - ${selectedVariant.title}`;
|
|
8889
|
+
}
|
|
8890
|
+
return product.title ?? product.name ?? '';
|
|
8891
|
+
}, [selectedVariant, product]);
|
|
8892
|
+
const effectivePrice = React.useMemo(() => {
|
|
8893
|
+
const variantPrice = selectedVariant?.price;
|
|
8894
|
+
if (variantPrice != null)
|
|
8895
|
+
return typeof variantPrice === 'number' ? variantPrice : Number(variantPrice);
|
|
8896
|
+
const productPrice = product.price;
|
|
8897
|
+
return productPrice != null ? (typeof productPrice === 'number' ? productPrice : Number(productPrice)) : null;
|
|
8898
|
+
}, [selectedVariant, product.price]);
|
|
8899
|
+
const effectiveComparePrice = React.useMemo(() => {
|
|
8900
|
+
const variantCompare = selectedVariant?.comparePrice;
|
|
8901
|
+
if (variantCompare != null)
|
|
8902
|
+
return typeof variantCompare === 'number' ? variantCompare : Number(variantCompare);
|
|
8903
|
+
const productCompare = product.original_price ?? product.compare_at_price;
|
|
8904
|
+
return productCompare != null ? (typeof productCompare === 'number' ? productCompare : Number(productCompare)) : null;
|
|
8905
|
+
}, [selectedVariant, product]);
|
|
8906
|
+
// Legacy vars for backwards compat
|
|
8907
|
+
const images = effectiveImages;
|
|
8908
|
+
const title = effectiveTitle;
|
|
8909
|
+
const price = effectivePrice;
|
|
8910
|
+
// If no displayConfig, render original minimal layout (backwards compat)
|
|
8911
|
+
if (!displayConfig) {
|
|
8912
|
+
return (React.createElement("button", { type: "button", className: clsx('seekora-suggestions-product-card', className), style: { ...cardStyle, ...style }, onMouseDown: (e) => {
|
|
8913
|
+
e.preventDefault();
|
|
8914
|
+
onSelect();
|
|
8915
|
+
}, "data-position": position, "data-section": section, "data-tab-id": tabId },
|
|
8916
|
+
images.length > 0 ? (React.createElement(ImageDisplay, { images: images, variant: imageVariant, alt: title, className: "seekora-suggestions-product-card-image", enableZoom: enableImageZoom, zoomMode: imageZoomMode, zoomLevel: imageZoomLevel })) : (React.createElement("div", { className: "seekora-suggestions-product-card-placeholder", style: imgStyle, "aria-hidden": true })),
|
|
8917
|
+
React.createElement("span", { className: "seekora-suggestions-product-card-title", style: { fontSize: '0.875rem', fontWeight: 500 } }, title),
|
|
8918
|
+
price != null && !Number.isNaN(price) ? (React.createElement("span", { className: "seekora-suggestions-product-card-price", style: { fontSize: '0.875rem', color: 'var(--seekora-text-secondary, #6b7280)' } },
|
|
8919
|
+
product.currency ?? '$',
|
|
8920
|
+
price.toFixed(2))) : null));
|
|
8921
|
+
}
|
|
8922
|
+
// Enhanced layout with displayConfig
|
|
8923
|
+
const layoutStyle = displayConfig.style ?? 'minimal';
|
|
8924
|
+
const comparePrice = effectiveComparePrice;
|
|
8925
|
+
const brand = product.brand ?? null;
|
|
8926
|
+
const options = product.options ?? [];
|
|
8927
|
+
const variants = product.variants ?? [];
|
|
8928
|
+
const badges = React.useMemo(() => {
|
|
8929
|
+
if (displayConfig.showBadges === false)
|
|
8930
|
+
return [];
|
|
8931
|
+
if (displayConfig.badgeExtractor)
|
|
8932
|
+
return displayConfig.badgeExtractor(product.tags, product);
|
|
8933
|
+
return extractBadges(product.tags, product);
|
|
8934
|
+
}, [displayConfig, product]);
|
|
8935
|
+
const priceRange = React.useMemo(() => {
|
|
8936
|
+
if (!displayConfig.showPriceRange)
|
|
8937
|
+
return null;
|
|
8938
|
+
return getPriceRange(product.variants);
|
|
8939
|
+
}, [displayConfig.showPriceRange, product.variants]);
|
|
8940
|
+
const layoutProps = {
|
|
8941
|
+
product,
|
|
8942
|
+
images,
|
|
8943
|
+
title,
|
|
8944
|
+
price,
|
|
8945
|
+
comparePrice,
|
|
8946
|
+
brand,
|
|
8947
|
+
badges,
|
|
8948
|
+
priceRange,
|
|
8949
|
+
options,
|
|
8950
|
+
variants,
|
|
8951
|
+
displayConfig,
|
|
8952
|
+
imageVariant,
|
|
8953
|
+
onVariantHover,
|
|
8954
|
+
onVariantClick,
|
|
8955
|
+
selectedVariants,
|
|
8956
|
+
actionButtons,
|
|
8957
|
+
actionButtonsPosition,
|
|
8958
|
+
showActionLabels,
|
|
8959
|
+
enableImageZoom,
|
|
8960
|
+
imageZoomMode,
|
|
8961
|
+
imageZoomLevel,
|
|
8962
|
+
};
|
|
8963
|
+
const layoutMap = {
|
|
8964
|
+
minimal: MinimalLayout,
|
|
8965
|
+
standard: StandardLayout,
|
|
8966
|
+
detailed: DetailedLayout,
|
|
8967
|
+
compact: CompactLayout,
|
|
8968
|
+
horizontal: HorizontalLayout,
|
|
8969
|
+
};
|
|
8970
|
+
const LayoutComponent = layoutMap[layoutStyle] ?? MinimalLayout;
|
|
8971
|
+
const rootClassName = clsx('seekora-product-card', `seekora-product-card--${layoutStyle}`, 'seekora-suggestions-product-card', className);
|
|
8972
|
+
const rootStyle = {
|
|
8973
|
+
...cardStyle,
|
|
8974
|
+
...(layoutStyle === 'horizontal' ? { flexDirection: 'row' } : {}),
|
|
8975
|
+
...style,
|
|
8976
|
+
};
|
|
8977
|
+
if (asLink && product.url) {
|
|
8978
|
+
return (React.createElement("a", { href: product.url, className: rootClassName, style: { ...rootStyle, textDecoration: 'none', color: 'inherit' }, onClick: (e) => {
|
|
8979
|
+
e.preventDefault();
|
|
8980
|
+
onSelect();
|
|
8981
|
+
}, "data-position": position, "data-section": section, "data-tab-id": tabId },
|
|
8982
|
+
React.createElement(LayoutComponent, { ...layoutProps })));
|
|
8983
|
+
}
|
|
8984
|
+
return (React.createElement("button", { type: "button", className: rootClassName, style: rootStyle, onMouseDown: (e) => {
|
|
8985
|
+
e.preventDefault();
|
|
8986
|
+
onSelect();
|
|
8987
|
+
}, "data-position": position, "data-section": section, "data-tab-id": tabId },
|
|
8988
|
+
React.createElement(LayoutComponent, { ...layoutProps })));
|
|
8989
|
+
}
|
|
8990
|
+
|
|
8991
|
+
/**
|
|
8992
|
+
* ProductGrid – grid of product cards from context (primitive)
|
|
8993
|
+
*
|
|
8994
|
+
* Uses trendingProducts or active tab products; each click calls context selectProduct.
|
|
8995
|
+
*/
|
|
8996
|
+
function ProductGrid({ maxItems = 8, source = 'trending', columns = 4, className, style, gridClassName, displayConfig, onVariantHover, }) {
|
|
8997
|
+
const { trendingProducts, filteredTabs, activeTabId, selectProduct, getAllNavigableItems, } = useSuggestionsContext();
|
|
8998
|
+
const products = React.useMemo(() => {
|
|
8999
|
+
if (source === 'trending')
|
|
9000
|
+
return trendingProducts;
|
|
9001
|
+
const tab = filteredTabs.find((t) => t.id === (source === 'tab' ? activeTabId : source));
|
|
9002
|
+
return tab?.products ?? [];
|
|
9003
|
+
}, [source, activeTabId, trendingProducts, filteredTabs]);
|
|
9004
|
+
const items = products.slice(0, maxItems);
|
|
9005
|
+
const navigableItems = getAllNavigableItems();
|
|
9006
|
+
const productStartIndex = navigableItems.findIndex((n) => n.type === 'product');
|
|
9007
|
+
if (items.length === 0)
|
|
9008
|
+
return null;
|
|
9009
|
+
const gridStyle = {
|
|
9010
|
+
display: 'grid',
|
|
9011
|
+
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
|
9012
|
+
gap: 12,
|
|
9013
|
+
padding: 12,
|
|
9014
|
+
};
|
|
9015
|
+
return (React.createElement("div", { className: clsx('seekora-suggestions-product-grid', className), style: style },
|
|
9016
|
+
React.createElement("div", { className: clsx('seekora-suggestions-product-grid-inner', gridClassName), style: gridStyle }, items.map((product, i) => {
|
|
9017
|
+
const globalIndex = productStartIndex >= 0 ? productStartIndex + i : i;
|
|
9018
|
+
const section = source === 'trending' ? 'products' : 'filtered_tab';
|
|
9019
|
+
const tabId = source !== 'trending' ? (source === 'tab' ? activeTabId : source) : undefined;
|
|
9020
|
+
return (React.createElement(ProductCard, { key: product.id ?? product.objectID ?? i, product: product, position: globalIndex, section: section, tabId: tabId, onSelect: () => selectProduct(product, globalIndex, section, tabId), displayConfig: displayConfig, onVariantHover: onVariantHover }));
|
|
9021
|
+
}))));
|
|
9022
|
+
}
|
|
9023
|
+
|
|
9024
|
+
/**
|
|
9025
|
+
* CategoriesTabs – horizontal tabs (e.g. filtered tabs) (primitive)
|
|
9026
|
+
*
|
|
9027
|
+
* Active tab from context; on select updates context and tracks analytics.
|
|
9028
|
+
*/
|
|
9029
|
+
function CategoriesTabs({ className, style, tabClassName }) {
|
|
9030
|
+
const { filteredTabs, activeTabId, setActiveTab } = useSuggestionsContext();
|
|
9031
|
+
if (filteredTabs.length === 0)
|
|
9032
|
+
return null;
|
|
9033
|
+
return (React.createElement("div", { className: clsx('seekora-suggestions-categories-tabs', className), style: {
|
|
9034
|
+
display: 'flex',
|
|
9035
|
+
gap: 4,
|
|
9036
|
+
padding: '8px 12px',
|
|
9037
|
+
borderBottom: '1px solid var(--seekora-border-color, #e5e7eb)',
|
|
9038
|
+
overflowX: 'auto',
|
|
9039
|
+
...style,
|
|
9040
|
+
}, role: "tablist" }, filteredTabs.map((tab) => {
|
|
9041
|
+
const isActive = activeTabId === tab.id;
|
|
9042
|
+
return (React.createElement("button", { key: tab.id, type: "button", role: "tab", "aria-selected": isActive, className: clsx('seekora-suggestions-tab', isActive && 'seekora-suggestions-tab--active', tabClassName), style: {
|
|
9043
|
+
padding: '8px 12px',
|
|
9044
|
+
border: 'none',
|
|
9045
|
+
borderRadius: 'var(--seekora-border-radius, 6px)',
|
|
9046
|
+
backgroundColor: isActive ? 'var(--seekora-primary-light, rgba(59, 130, 246, 0.1))' : 'transparent',
|
|
9047
|
+
color: isActive ? 'var(--seekora-primary, #3b82f6)' : 'var(--seekora-text-primary, #111827)',
|
|
9048
|
+
cursor: 'pointer',
|
|
9049
|
+
fontSize: '0.875rem',
|
|
9050
|
+
fontWeight: isActive ? 600 : 400,
|
|
9051
|
+
whiteSpace: 'nowrap',
|
|
9052
|
+
transition: 'background-color 120ms ease',
|
|
9053
|
+
}, onClick: () => setActiveTab(tab) }, tab.label));
|
|
9054
|
+
})));
|
|
9055
|
+
}
|
|
9056
|
+
|
|
9057
|
+
/**
|
|
9058
|
+
* RecentSearchesList – list of recent queries (primitive)
|
|
9059
|
+
*
|
|
9060
|
+
* Reads recentSearches from context; each click calls selectRecentSearch. Optional title/render.
|
|
9061
|
+
*/
|
|
9062
|
+
const itemStyle$1 = {
|
|
9063
|
+
padding: '10px 12px',
|
|
9064
|
+
cursor: 'pointer',
|
|
9065
|
+
border: 'none',
|
|
9066
|
+
width: '100%',
|
|
9067
|
+
textAlign: 'left',
|
|
9068
|
+
fontSize: 'inherit',
|
|
9069
|
+
fontFamily: 'inherit',
|
|
9070
|
+
backgroundColor: 'transparent',
|
|
9071
|
+
color: 'var(--seekora-text-primary, #111827)',
|
|
9072
|
+
transition: 'background-color 120ms ease',
|
|
9073
|
+
};
|
|
9074
|
+
function RecentSearchesList({ title = 'Recent', maxItems = 8, className, style, listClassName, renderItem, }) {
|
|
9075
|
+
const { recentSearches, query, selectRecentSearch } = useSuggestionsContext();
|
|
9076
|
+
const items = recentSearches.slice(0, maxItems);
|
|
9077
|
+
if (items.length === 0)
|
|
9078
|
+
return null;
|
|
9079
|
+
return (React.createElement("div", { className: clsx('seekora-suggestions-recent-list', className), style: style },
|
|
9080
|
+
title ? (React.createElement("div", { className: "seekora-suggestions-recent-title", style: { padding: '8px 12px', fontSize: '0.75rem', fontWeight: 600, color: 'var(--seekora-text-secondary, #6b7280)', textTransform: 'uppercase' } }, title)) : null,
|
|
9081
|
+
React.createElement("ul", { className: clsx('seekora-suggestions-recent-ul', listClassName), style: { margin: 0, padding: 0, listStyle: 'none' } }, items.map((search, i) => {
|
|
9082
|
+
const onSelect = () => selectRecentSearch(search);
|
|
9083
|
+
if (renderItem) {
|
|
9084
|
+
return React.createElement("li", { key: `${search.query}-${search.timestamp}` }, renderItem(search, i, onSelect));
|
|
9085
|
+
}
|
|
9086
|
+
return (React.createElement("li", { key: `${search.query}-${search.timestamp}` },
|
|
9087
|
+
React.createElement("button", { type: "button", className: "seekora-suggestions-recent-item", style: itemStyle$1, onMouseDown: (e) => {
|
|
9088
|
+
e.preventDefault();
|
|
9089
|
+
onSelect();
|
|
9090
|
+
} }, search.query)));
|
|
9091
|
+
}))));
|
|
9092
|
+
}
|
|
9093
|
+
|
|
9094
|
+
/**
|
|
9095
|
+
* TrendingList – list of trending searches (primitive)
|
|
9096
|
+
*
|
|
9097
|
+
* Reads trendingSearches from context; each click calls selectTrendingSearch. Optional title/render.
|
|
9098
|
+
*/
|
|
9099
|
+
const itemStyle = {
|
|
9100
|
+
padding: '10px 12px',
|
|
9101
|
+
cursor: 'pointer',
|
|
9102
|
+
border: 'none',
|
|
9103
|
+
width: '100%',
|
|
9104
|
+
textAlign: 'left',
|
|
9105
|
+
fontSize: 'inherit',
|
|
9106
|
+
fontFamily: 'inherit',
|
|
9107
|
+
backgroundColor: 'transparent',
|
|
9108
|
+
color: 'var(--seekora-text-primary, #111827)',
|
|
9109
|
+
transition: 'background-color 120ms ease',
|
|
9110
|
+
};
|
|
9111
|
+
function TrendingList({ title = 'Trending', maxItems = 8, className, style, listClassName, renderItem, }) {
|
|
9112
|
+
const { trendingSearches, selectTrendingSearch } = useSuggestionsContext();
|
|
9113
|
+
const items = trendingSearches.slice(0, maxItems);
|
|
9114
|
+
if (items.length === 0)
|
|
9115
|
+
return null;
|
|
9116
|
+
return (React.createElement("div", { className: clsx('seekora-suggestions-trending-list', className), style: style },
|
|
9117
|
+
title ? (React.createElement("div", { className: "seekora-suggestions-trending-title", style: { padding: '8px 12px', fontSize: '0.75rem', fontWeight: 600, color: 'var(--seekora-text-secondary, #6b7280)', textTransform: 'uppercase' } }, title)) : null,
|
|
9118
|
+
React.createElement("ul", { className: clsx('seekora-suggestions-trending-ul', listClassName), style: { margin: 0, padding: 0, listStyle: 'none' } }, items.map((trending, i) => {
|
|
9119
|
+
const onSelect = () => selectTrendingSearch(trending, i);
|
|
9120
|
+
if (renderItem) {
|
|
9121
|
+
return React.createElement("li", { key: `${trending.query}-${i}` }, renderItem(trending, i, onSelect));
|
|
9122
|
+
}
|
|
9123
|
+
return (React.createElement("li", { key: `${trending.query}-${i}` },
|
|
9124
|
+
React.createElement("button", { type: "button", className: "seekora-suggestions-trending-item", style: itemStyle, onMouseDown: (e) => {
|
|
9125
|
+
e.preventDefault();
|
|
9126
|
+
onSelect();
|
|
9127
|
+
} },
|
|
9128
|
+
trending.query,
|
|
9129
|
+
trending.count != null ? (React.createElement("span", { style: { marginLeft: 8, color: 'var(--seekora-text-secondary, #6b7280)', fontSize: '0.875em' } }, trending.count)) : null)));
|
|
9130
|
+
}))));
|
|
9131
|
+
}
|
|
9132
|
+
|
|
9133
|
+
/**
|
|
9134
|
+
* SuggestionsError – error message (primitive)
|
|
9135
|
+
*/
|
|
9136
|
+
function SuggestionsError({ className, style, render }) {
|
|
9137
|
+
const { error } = useSuggestionsContext();
|
|
9138
|
+
if (!error)
|
|
9139
|
+
return null;
|
|
9140
|
+
if (render)
|
|
9141
|
+
return React.createElement(React.Fragment, null, render(error));
|
|
9142
|
+
return (React.createElement("div", { className: clsx('seekora-suggestions-error', className), style: {
|
|
9143
|
+
padding: 16,
|
|
9144
|
+
color: 'var(--seekora-error, #dc2626)',
|
|
9145
|
+
fontSize: '0.875rem',
|
|
9146
|
+
...style,
|
|
9147
|
+
} }, error.message));
|
|
9148
|
+
}
|
|
9149
|
+
|
|
9150
|
+
/**
|
|
9151
|
+
* SuggestionsDropdownComposition – reference composition
|
|
9152
|
+
*
|
|
9153
|
+
* Example layout built from primitives: SearchInput + DropdownPanel containing
|
|
9154
|
+
* RecentSearchesList (when query empty), SuggestionList, CategoriesTabs, ProductGrid, TrendingList.
|
|
9155
|
+
* Wrap with SearchProvider and SuggestionsProvider. Use as reference or replace
|
|
9156
|
+
* with your own arrangement of the same primitives.
|
|
9157
|
+
*/
|
|
9158
|
+
function SuggestionsDropdownComposition({ showRecentSearches = true, showTrending = true, showTabs = true, showProducts = true, placeholder, ...providerProps }) {
|
|
9159
|
+
return (React.createElement(SuggestionsProvider, { ...providerProps },
|
|
9160
|
+
React.createElement("div", { className: "seekora-suggestions-dropdown-composition", style: { position: 'relative', width: '100%' } },
|
|
9161
|
+
React.createElement(SearchInput, { placeholder: placeholder }),
|
|
9162
|
+
React.createElement(DropdownPanel, null,
|
|
9163
|
+
React.createElement(SuggestionsError, null),
|
|
9164
|
+
React.createElement(SuggestionsLoading, null),
|
|
9165
|
+
showRecentSearches ? React.createElement(RecentSearchesList, null) : null,
|
|
9166
|
+
React.createElement(SuggestionList, null),
|
|
9167
|
+
showTabs ? React.createElement(CategoriesTabs, null) : null,
|
|
9168
|
+
showProducts ? React.createElement(ProductGrid, null) : null,
|
|
9169
|
+
showTrending ? React.createElement(TrendingList, null) : null))));
|
|
9170
|
+
}
|
|
9171
|
+
|
|
9172
|
+
/**
|
|
9173
|
+
* VariantSelector – full variant selector for product detail pages
|
|
9174
|
+
*
|
|
9175
|
+
* Three render modes per option:
|
|
9176
|
+
* - swatch: color circles (auto-detected for "Color" option or when colorMap provided)
|
|
9177
|
+
* - button: rectangular buttons (default for most options)
|
|
9178
|
+
* - dropdown: <select> for options with many values
|
|
9179
|
+
*/
|
|
9180
|
+
const COLOR_NAMES = {
|
|
9181
|
+
black: '#000', white: '#fff', red: '#ef4444', blue: '#3b82f6',
|
|
9182
|
+
green: '#22c55e', yellow: '#eab308', orange: '#f97316', purple: '#a855f7',
|
|
9183
|
+
pink: '#ec4899', brown: '#92400e', grey: '#6b7280', gray: '#6b7280',
|
|
9184
|
+
navy: '#1e3a5f', beige: '#d4c5a9', cream: '#fffdd0', ivory: '#fffff0',
|
|
9185
|
+
teal: '#0d9488', coral: '#ff7f50', maroon: '#800000', charcoal: '#36454f',
|
|
9186
|
+
sage: '#9caf88', lavender: '#e6e6fa', mint: '#98fb98', rust: '#b7410e',
|
|
9187
|
+
plum: '#8e4585', slate: '#708090', indigo: '#4b0082', gold: '#ffd700',
|
|
9188
|
+
silver: '#c0c0c0', rose: '#ff007f', raven: '#0a0a0a', natural: '#f5f0e1',
|
|
9189
|
+
bone: '#e3dac9', sand: '#c2b280', olive: '#808000', khaki: '#c3b091',
|
|
9190
|
+
burgundy: '#800020', wine: '#722f37', mauve: '#e0b0ff', tan: '#d2b48c',
|
|
9191
|
+
};
|
|
9192
|
+
const isColorOption = (name) => {
|
|
9193
|
+
const lower = name.toLowerCase();
|
|
9194
|
+
return lower === 'color' || lower === 'colour' || lower === 'colors' || lower === 'colours';
|
|
9195
|
+
};
|
|
9196
|
+
const resolveColor = (value, colorMap) => {
|
|
9197
|
+
if (colorMap?.[value])
|
|
9198
|
+
return colorMap[value];
|
|
9199
|
+
const lower = value.toLowerCase();
|
|
9200
|
+
if (colorMap?.[lower])
|
|
9201
|
+
return colorMap[lower];
|
|
9202
|
+
return COLOR_NAMES[lower] ?? null;
|
|
9203
|
+
};
|
|
9204
|
+
const getAvailability = (optionName, value, options, variants, selections) => {
|
|
9205
|
+
const optionIndex = options.findIndex((o) => o.name === optionName);
|
|
9206
|
+
if (optionIndex === -1)
|
|
9207
|
+
return true;
|
|
9208
|
+
const optionKey = `option${optionIndex + 1}`;
|
|
9209
|
+
return variants.some((variant) => {
|
|
9210
|
+
if (variant[optionKey] !== value)
|
|
9211
|
+
return false;
|
|
9212
|
+
if (variant.available === false)
|
|
9213
|
+
return false;
|
|
9214
|
+
for (const [selName, selValue] of Object.entries(selections)) {
|
|
9215
|
+
if (selName === optionName)
|
|
9216
|
+
continue;
|
|
9217
|
+
const selIdx = options.findIndex((o) => o.name === selName);
|
|
9218
|
+
if (selIdx === -1)
|
|
9219
|
+
continue;
|
|
9220
|
+
const selKey = `option${selIdx + 1}`;
|
|
9221
|
+
if (variant[selKey] !== selValue)
|
|
9222
|
+
return false;
|
|
9223
|
+
}
|
|
9224
|
+
return true;
|
|
9225
|
+
});
|
|
9226
|
+
};
|
|
9227
|
+
function VariantSelector({ options, variants, selections, onSelectionChange, optionRenderModes, dropdownThreshold = 8, colorMap, showAvailability = true, selectedVariant: _selectedVariant, className, style, }) {
|
|
9228
|
+
if (!options || options.length === 0)
|
|
9229
|
+
return null;
|
|
9230
|
+
const getRenderMode = (option) => {
|
|
9231
|
+
if (optionRenderModes?.[option.name])
|
|
9232
|
+
return optionRenderModes[option.name];
|
|
9233
|
+
if (isColorOption(option.name))
|
|
9234
|
+
return 'swatch';
|
|
9235
|
+
if (option.values.length > dropdownThreshold)
|
|
9236
|
+
return 'dropdown';
|
|
9237
|
+
return 'button';
|
|
9238
|
+
};
|
|
9239
|
+
return (React.createElement("div", { className: clsx('seekora-variant-selector', className), style: { display: 'flex', flexDirection: 'column', gap: 16, ...style } }, options.map((option) => {
|
|
9240
|
+
const mode = getRenderMode(option);
|
|
9241
|
+
const selected = selections[option.name];
|
|
9242
|
+
return (React.createElement("div", { key: option.name, className: "seekora-variant-option-group" },
|
|
9243
|
+
React.createElement("label", { className: "seekora-variant-option-label", style: {
|
|
9244
|
+
display: 'block',
|
|
9245
|
+
fontSize: '0.875rem',
|
|
9246
|
+
fontWeight: 600,
|
|
9247
|
+
marginBottom: 8,
|
|
9248
|
+
color: 'var(--seekora-text-primary, #111827)',
|
|
9249
|
+
} },
|
|
9250
|
+
option.name,
|
|
9251
|
+
selected && (React.createElement("span", { style: { fontWeight: 400, marginLeft: 6, color: 'var(--seekora-text-secondary, #6b7280)' } }, selected))),
|
|
9252
|
+
mode === 'dropdown' ? (React.createElement("select", { className: "seekora-variant-dropdown", value: selected ?? '', onChange: (e) => {
|
|
9253
|
+
e.stopPropagation();
|
|
9254
|
+
onSelectionChange(option.name, e.target.value);
|
|
9255
|
+
}, onMouseDown: (e) => e.stopPropagation(), onClick: (e) => e.stopPropagation(), style: {
|
|
9256
|
+
padding: '8px 12px',
|
|
9257
|
+
fontSize: '0.875rem',
|
|
9258
|
+
borderRadius: 6,
|
|
9259
|
+
border: '1px solid var(--seekora-border-color, #e5e7eb)',
|
|
9260
|
+
backgroundColor: 'var(--seekora-bg-surface, #fff)',
|
|
9261
|
+
color: 'var(--seekora-text-primary, #111827)',
|
|
9262
|
+
cursor: 'pointer',
|
|
9263
|
+
minWidth: 120,
|
|
9264
|
+
} },
|
|
9265
|
+
React.createElement("option", { value: "" },
|
|
9266
|
+
"Select ",
|
|
9267
|
+
option.name),
|
|
9268
|
+
option.values.map((value) => {
|
|
9269
|
+
const available = showAvailability
|
|
9270
|
+
? getAvailability(option.name, value, options, variants, selections)
|
|
9271
|
+
: true;
|
|
9272
|
+
return (React.createElement("option", { key: value, value: value, disabled: !available },
|
|
9273
|
+
value,
|
|
9274
|
+
!available ? ' (Unavailable)' : ''));
|
|
9275
|
+
}))) : (React.createElement("div", { className: "seekora-variant-buttons", style: { display: 'flex', flexWrap: 'wrap', gap: 8 } }, option.values.map((value) => {
|
|
9276
|
+
const isActive = selected === value;
|
|
9277
|
+
const available = showAvailability
|
|
9278
|
+
? getAvailability(option.name, value, options, variants, selections)
|
|
9279
|
+
: true;
|
|
9280
|
+
const color = mode === 'swatch' ? resolveColor(value, colorMap) : null;
|
|
9281
|
+
if (mode === 'swatch' && color) {
|
|
9282
|
+
return (React.createElement("button", { key: value, type: "button", className: clsx('seekora-variant-color-swatch', isActive && 'seekora-variant-button--active', !available && 'seekora-variant-button--unavailable'), title: value, onMouseDown: (e) => {
|
|
9283
|
+
e.stopPropagation();
|
|
9284
|
+
e.preventDefault();
|
|
9285
|
+
}, onClick: (e) => {
|
|
9286
|
+
e.stopPropagation();
|
|
9287
|
+
onSelectionChange(option.name, value);
|
|
9288
|
+
}, disabled: !available, style: {
|
|
9289
|
+
width: 32,
|
|
9290
|
+
height: 32,
|
|
9291
|
+
borderRadius: '50%',
|
|
9292
|
+
backgroundColor: color,
|
|
9293
|
+
border: isActive
|
|
9294
|
+
? '2px solid var(--seekora-primary, #111827)'
|
|
9295
|
+
: '1px solid var(--seekora-border-color, #e5e7eb)',
|
|
9296
|
+
outline: isActive ? '2px solid var(--seekora-primary, #111827)' : 'none',
|
|
9297
|
+
outlineOffset: 2,
|
|
9298
|
+
cursor: available ? 'pointer' : 'not-allowed',
|
|
9299
|
+
opacity: available ? 1 : 0.4,
|
|
9300
|
+
position: 'relative',
|
|
9301
|
+
padding: 0,
|
|
9302
|
+
}, "aria-label": `${option.name}: ${value}`, "aria-pressed": isActive }));
|
|
9303
|
+
}
|
|
9304
|
+
return (React.createElement("button", { key: value, type: "button", className: clsx('seekora-variant-button', isActive && 'seekora-variant-button--active', !available && 'seekora-variant-button--unavailable'), onMouseDown: (e) => {
|
|
9305
|
+
e.stopPropagation();
|
|
9306
|
+
e.preventDefault();
|
|
9307
|
+
}, onClick: (e) => {
|
|
9308
|
+
e.stopPropagation();
|
|
9309
|
+
onSelectionChange(option.name, value);
|
|
9310
|
+
}, disabled: !available, style: {
|
|
9311
|
+
padding: '6px 16px',
|
|
9312
|
+
fontSize: '0.875rem',
|
|
9313
|
+
borderRadius: 6,
|
|
9314
|
+
border: isActive
|
|
9315
|
+
? '2px solid var(--seekora-primary, #111827)'
|
|
9316
|
+
: '1px solid var(--seekora-border-color, #e5e7eb)',
|
|
9317
|
+
backgroundColor: isActive
|
|
9318
|
+
? 'var(--seekora-primary, #111827)'
|
|
9319
|
+
: 'var(--seekora-bg-surface, #fff)',
|
|
9320
|
+
color: isActive
|
|
9321
|
+
? '#fff'
|
|
9322
|
+
: 'var(--seekora-text-primary, #111827)',
|
|
9323
|
+
cursor: available ? 'pointer' : 'not-allowed',
|
|
9324
|
+
opacity: available ? 1 : 0.5,
|
|
9325
|
+
textDecoration: !available ? 'line-through' : 'none',
|
|
9326
|
+
fontWeight: isActive ? 600 : 400,
|
|
9327
|
+
transition: 'all 120ms ease',
|
|
9328
|
+
}, "aria-label": `${option.name}: ${value}`, "aria-pressed": isActive }, value));
|
|
9329
|
+
})))));
|
|
9330
|
+
})));
|
|
9331
|
+
}
|
|
9332
|
+
|
|
9333
|
+
/**
|
|
9334
|
+
* withAnalytics – HOC that wraps any React component to inject analytics tracking
|
|
9335
|
+
*/
|
|
9336
|
+
function withAnalytics(Component, config = {}) {
|
|
9337
|
+
const { trackClick = true, trackImpression = false, clickEventName = 'product.click', getProductFromProps = () => null, getPositionFromProps = () => 0, } = config;
|
|
9338
|
+
const displayName = Component.displayName || Component.name || 'Component';
|
|
9339
|
+
const Wrapped = React.forwardRef((props, ref) => {
|
|
9340
|
+
const { seekoraClient, seekoraContext, ...rest } = props;
|
|
9341
|
+
const containerRef = React.useRef(null);
|
|
9342
|
+
const impressionTrackedRef = React.useRef(false);
|
|
9343
|
+
const product = getProductFromProps(rest);
|
|
9344
|
+
const position = getPositionFromProps(rest);
|
|
9345
|
+
const sendEvent = React.useCallback(async (eventName, metadata) => {
|
|
9346
|
+
if (!seekoraClient)
|
|
9347
|
+
return;
|
|
9348
|
+
try {
|
|
9349
|
+
await seekoraClient.trackEvent?.({
|
|
9350
|
+
event_name: eventName,
|
|
9351
|
+
metadata: {
|
|
9352
|
+
product_id: product?.id || product?.objectID,
|
|
9353
|
+
product_title: product?.title || product?.name,
|
|
9354
|
+
product_price: product?.price,
|
|
9355
|
+
position,
|
|
9356
|
+
timestamp: Date.now(),
|
|
9357
|
+
source: 'with_analytics_hoc',
|
|
9358
|
+
...metadata,
|
|
9359
|
+
},
|
|
9360
|
+
}, seekoraContext);
|
|
9361
|
+
}
|
|
9362
|
+
catch (error) {
|
|
9363
|
+
log.warn(`withAnalytics: Failed to track ${eventName}`, { error });
|
|
9364
|
+
}
|
|
9365
|
+
}, [seekoraClient, seekoraContext, product, position]);
|
|
9366
|
+
const handleClick = React.useCallback(() => {
|
|
9367
|
+
if (!trackClick || !product)
|
|
9368
|
+
return;
|
|
9369
|
+
sendEvent(clickEventName, {});
|
|
9370
|
+
if (seekoraClient?.trackClick && product) {
|
|
9371
|
+
Promise.resolve(seekoraClient.trackClick(product.id || product.objectID || '', position + 1, seekoraContext)).catch(() => { });
|
|
9372
|
+
}
|
|
9373
|
+
}, [trackClick, product, sendEvent, seekoraClient, seekoraContext, position]);
|
|
9374
|
+
// Impression tracking
|
|
9375
|
+
React.useEffect(() => {
|
|
9376
|
+
if (!trackImpression || !seekoraClient || !containerRef.current || impressionTrackedRef.current)
|
|
9377
|
+
return;
|
|
9378
|
+
if (typeof IntersectionObserver === 'undefined')
|
|
9379
|
+
return;
|
|
9380
|
+
const observer = new IntersectionObserver((entries) => {
|
|
9381
|
+
for (const entry of entries) {
|
|
9382
|
+
if (entry.isIntersecting && !impressionTrackedRef.current) {
|
|
9383
|
+
impressionTrackedRef.current = true;
|
|
9384
|
+
sendEvent('product.impression', {});
|
|
9385
|
+
observer.disconnect();
|
|
9386
|
+
}
|
|
9387
|
+
}
|
|
9388
|
+
}, { threshold: 0.5 });
|
|
9389
|
+
observer.observe(containerRef.current);
|
|
9390
|
+
return () => observer.disconnect();
|
|
9391
|
+
}, [trackImpression, seekoraClient, sendEvent]);
|
|
9392
|
+
return (React.createElement("div", { ref: (node) => {
|
|
9393
|
+
containerRef.current = node;
|
|
9394
|
+
if (typeof ref === 'function')
|
|
9395
|
+
ref(node);
|
|
9396
|
+
else if (ref)
|
|
9397
|
+
ref.current = node;
|
|
9398
|
+
}, onClick: handleClick, style: { display: 'contents' } },
|
|
9399
|
+
React.createElement(Component, { ...rest })));
|
|
9400
|
+
});
|
|
9401
|
+
Wrapped.displayName = `withAnalytics(${displayName})`;
|
|
9402
|
+
return Wrapped;
|
|
9403
|
+
}
|
|
9404
|
+
|
|
9405
|
+
/**
|
|
9406
|
+
* AnalyticsProvider – context + delegated event listener for data-seekora-* attribute-based analytics
|
|
9407
|
+
*
|
|
9408
|
+
* Installs a delegated click listener on a container. Any descendant with
|
|
9409
|
+
* data-seekora-track="click"|"add-to-cart" and data-seekora-product-id="..."
|
|
9410
|
+
* automatically fires analytics events without React wiring.
|
|
9411
|
+
*/
|
|
9412
|
+
const AnalyticsContext = React.createContext(null);
|
|
9413
|
+
const useAnalyticsProvider = () => React.useContext(AnalyticsContext);
|
|
9414
|
+
function AnalyticsProvider({ client, context, children }) {
|
|
9415
|
+
const containerRef = React.useRef(null);
|
|
9416
|
+
const sendEvent = React.useCallback(async (eventName, metadata) => {
|
|
9417
|
+
try {
|
|
9418
|
+
await client.trackEvent?.({
|
|
9419
|
+
event_name: eventName,
|
|
9420
|
+
metadata: {
|
|
9421
|
+
timestamp: Date.now(),
|
|
9422
|
+
source: 'analytics_provider_delegation',
|
|
9423
|
+
...metadata,
|
|
9424
|
+
},
|
|
9425
|
+
}, context);
|
|
9426
|
+
log.verbose(`AnalyticsProvider: ${eventName}`, metadata);
|
|
9427
|
+
}
|
|
9428
|
+
catch (error) {
|
|
9429
|
+
log.warn(`AnalyticsProvider: Failed to track ${eventName}`, { error });
|
|
9430
|
+
}
|
|
9431
|
+
}, [client, context]);
|
|
9432
|
+
React.useEffect(() => {
|
|
9433
|
+
const container = containerRef.current;
|
|
9434
|
+
if (!container)
|
|
9435
|
+
return;
|
|
9436
|
+
const handleClick = (e) => {
|
|
9437
|
+
const target = e.target;
|
|
9438
|
+
// Walk up from target to find element with data-seekora-track
|
|
9439
|
+
const tracked = target.closest('[data-seekora-track]');
|
|
9440
|
+
if (!tracked)
|
|
9441
|
+
return;
|
|
9442
|
+
const trackType = tracked.getAttribute('data-seekora-track');
|
|
9443
|
+
const productId = tracked.getAttribute('data-seekora-product-id');
|
|
9444
|
+
const position = tracked.getAttribute('data-seekora-position');
|
|
9445
|
+
const section = tracked.getAttribute('data-seekora-section');
|
|
9446
|
+
const variantSku = tracked.getAttribute('data-seekora-variant-sku');
|
|
9447
|
+
const variantOption = tracked.getAttribute('data-seekora-variant-option');
|
|
9448
|
+
const variantValue = tracked.getAttribute('data-seekora-variant-value');
|
|
9449
|
+
const metadata = {};
|
|
9450
|
+
if (productId)
|
|
9451
|
+
metadata.product_id = productId;
|
|
9452
|
+
if (position)
|
|
9453
|
+
metadata.position = parseInt(position, 10);
|
|
9454
|
+
if (section)
|
|
9455
|
+
metadata.section = section;
|
|
9456
|
+
switch (trackType) {
|
|
9457
|
+
case 'click':
|
|
9458
|
+
sendEvent('product.click', metadata);
|
|
9459
|
+
if (client?.trackClick && productId) {
|
|
9460
|
+
const pos = position ? parseInt(position, 10) + 1 : 1;
|
|
9461
|
+
Promise.resolve(client.trackClick(productId, pos, context)).catch(() => { });
|
|
9462
|
+
}
|
|
9463
|
+
break;
|
|
9464
|
+
case 'add-to-cart':
|
|
9465
|
+
if (variantSku)
|
|
9466
|
+
metadata.variant_sku = variantSku;
|
|
9467
|
+
sendEvent('product.add_to_cart', metadata);
|
|
9468
|
+
break;
|
|
9469
|
+
case 'variant-select':
|
|
9470
|
+
if (variantOption)
|
|
9471
|
+
metadata.option_name = variantOption;
|
|
9472
|
+
if (variantValue)
|
|
9473
|
+
metadata.option_value = variantValue;
|
|
9474
|
+
sendEvent('product.variant_select', metadata);
|
|
9475
|
+
break;
|
|
9476
|
+
default:
|
|
9477
|
+
// Custom track type — use it as event name
|
|
9478
|
+
if (trackType) {
|
|
9479
|
+
sendEvent(trackType, metadata);
|
|
9480
|
+
}
|
|
9481
|
+
break;
|
|
9482
|
+
}
|
|
9483
|
+
};
|
|
9484
|
+
container.addEventListener('click', handleClick);
|
|
9485
|
+
return () => container.removeEventListener('click', handleClick);
|
|
9486
|
+
}, [client, context, sendEvent]);
|
|
9487
|
+
return (React.createElement(AnalyticsContext.Provider, { value: { client, context } },
|
|
9488
|
+
React.createElement("div", { ref: containerRef, style: { display: 'contents' } }, children)));
|
|
9489
|
+
}
|
|
9490
|
+
|
|
9491
|
+
/**
|
|
9492
|
+
* SectionSearchContext – preset query/filter section state
|
|
9493
|
+
*
|
|
9494
|
+
* For menus, sidebar, front-page blocks. Independent of main search state.
|
|
9495
|
+
*/
|
|
9496
|
+
const SectionSearchContext = React.createContext(null);
|
|
9497
|
+
function useSectionSearchContext() {
|
|
9498
|
+
const context = React.useContext(SectionSearchContext);
|
|
9499
|
+
if (!context) {
|
|
9500
|
+
const error = new Error('useSectionSearchContext must be used within a SectionSearchProvider');
|
|
9501
|
+
log.error('SectionSearchContext: not available', { error: error.message });
|
|
9502
|
+
throw error;
|
|
9503
|
+
}
|
|
9504
|
+
return context;
|
|
9505
|
+
}
|
|
9506
|
+
|
|
9507
|
+
/**
|
|
9508
|
+
* SectionSearchProvider – preset query + filter section
|
|
9509
|
+
*
|
|
9510
|
+
* Runs client.search(query, { refinements, hitsPerPage, sortBy }) on mount and when
|
|
9511
|
+
* query/filters change. Does not use global SearchStateManager. Use for menus,
|
|
9512
|
+
* sidebar, front-page blocks (e.g. "New arrivals", "On sale").
|
|
9513
|
+
*/
|
|
9514
|
+
function extractItems(response) {
|
|
9515
|
+
if (!response)
|
|
9516
|
+
return [];
|
|
9517
|
+
if (Array.isArray(response.results))
|
|
9518
|
+
return response.results;
|
|
9519
|
+
if (Array.isArray(response.hits))
|
|
9520
|
+
return response.hits;
|
|
9521
|
+
const data = response.data;
|
|
9522
|
+
if (data && Array.isArray(data.results))
|
|
9523
|
+
return data.results;
|
|
9524
|
+
if (data && Array.isArray(data.data))
|
|
9525
|
+
return data.data;
|
|
9526
|
+
return [];
|
|
9527
|
+
}
|
|
9528
|
+
function extractTotal(response) {
|
|
9529
|
+
if (!response)
|
|
9530
|
+
return 0;
|
|
9531
|
+
const n = response.totalResults ?? response.total ?? response.total_results;
|
|
9532
|
+
if (typeof n === 'number')
|
|
9533
|
+
return n;
|
|
9534
|
+
const data = response.data;
|
|
9535
|
+
if (data?.total_results != null)
|
|
9536
|
+
return Number(data.total_results);
|
|
9537
|
+
if (data?.data?.total_results != null)
|
|
9538
|
+
return Number(data.data.total_results);
|
|
9539
|
+
return 0;
|
|
9540
|
+
}
|
|
9541
|
+
function SectionSearchProvider({ children, query, refinements = [], maxItems = 12, sortBy, enabled = true, sectionId, }) {
|
|
9542
|
+
const { client } = useSearchContext();
|
|
9543
|
+
const [items, setItems] = React.useState([]);
|
|
9544
|
+
const [loading, setLoading] = React.useState(true);
|
|
9545
|
+
const [error, setError] = React.useState(null);
|
|
9546
|
+
const [totalCount, setTotalCount] = React.useState(0);
|
|
9547
|
+
React.useEffect(() => {
|
|
9548
|
+
if (!enabled || !client?.search) {
|
|
9549
|
+
setItems([]);
|
|
9550
|
+
setLoading(false);
|
|
9551
|
+
setError(null);
|
|
9552
|
+
setTotalCount(0);
|
|
9553
|
+
return;
|
|
9554
|
+
}
|
|
9555
|
+
let cancelled = false;
|
|
9556
|
+
setLoading(true);
|
|
9557
|
+
setError(null);
|
|
9558
|
+
const options = {
|
|
9559
|
+
per_page: maxItems,
|
|
9560
|
+
page: 1,
|
|
9561
|
+
};
|
|
9562
|
+
if (sortBy)
|
|
9563
|
+
options.sort_by = sortBy;
|
|
9564
|
+
if (refinements.length > 0) {
|
|
9565
|
+
options.filter_by = refinements.map((r) => `${r.field}:${r.value}`).join(',');
|
|
8127
9566
|
}
|
|
8128
|
-
|
|
8129
|
-
|
|
8130
|
-
|
|
8131
|
-
|
|
9567
|
+
client
|
|
9568
|
+
.search(query, options)
|
|
9569
|
+
.then((response) => {
|
|
9570
|
+
if (cancelled)
|
|
9571
|
+
return;
|
|
9572
|
+
setItems(extractItems(response));
|
|
9573
|
+
setTotalCount(extractTotal(response));
|
|
9574
|
+
setLoading(false);
|
|
9575
|
+
})
|
|
9576
|
+
.catch((err) => {
|
|
9577
|
+
if (cancelled)
|
|
9578
|
+
return;
|
|
9579
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
9580
|
+
setItems([]);
|
|
9581
|
+
setLoading(false);
|
|
8132
9582
|
});
|
|
8133
|
-
|
|
8134
|
-
|
|
8135
|
-
* Check if key exists and is valid
|
|
8136
|
-
*/
|
|
8137
|
-
has(key) {
|
|
8138
|
-
return this.get(key) !== null;
|
|
8139
|
-
}
|
|
8140
|
-
/**
|
|
8141
|
-
* Clear all cached entries
|
|
8142
|
-
*/
|
|
8143
|
-
clear() {
|
|
8144
|
-
this.cache.clear();
|
|
8145
|
-
}
|
|
8146
|
-
/**
|
|
8147
|
-
* Clear expired entries
|
|
8148
|
-
*/
|
|
8149
|
-
cleanup() {
|
|
8150
|
-
const now = Date.now();
|
|
8151
|
-
for (const [key, entry] of this.cache.entries()) {
|
|
8152
|
-
if (now - entry.timestamp > entry.ttl) {
|
|
8153
|
-
this.cache.delete(key);
|
|
8154
|
-
}
|
|
8155
|
-
}
|
|
8156
|
-
}
|
|
8157
|
-
/**
|
|
8158
|
-
* Get cache statistics
|
|
8159
|
-
*/
|
|
8160
|
-
getStats() {
|
|
8161
|
-
return {
|
|
8162
|
-
size: this.cache.size,
|
|
8163
|
-
maxSize: this.maxSize,
|
|
9583
|
+
return () => {
|
|
9584
|
+
cancelled = true;
|
|
8164
9585
|
};
|
|
8165
|
-
}
|
|
9586
|
+
}, [client, enabled, query, maxItems, sortBy, refinements]);
|
|
9587
|
+
const trackClick = React.useCallback((item, position) => {
|
|
9588
|
+
if (!client?.trackEvent)
|
|
9589
|
+
return;
|
|
9590
|
+
const id = item?.id ?? item?.objectID;
|
|
9591
|
+
client.trackEvent({
|
|
9592
|
+
event_name: 'section_result_click',
|
|
9593
|
+
clicked_item_id: id,
|
|
9594
|
+
position,
|
|
9595
|
+
section: sectionId,
|
|
9596
|
+
metadata: { section_id: sectionId },
|
|
9597
|
+
}, undefined);
|
|
9598
|
+
}, [client, sectionId]);
|
|
9599
|
+
const value = React.useMemo(() => ({
|
|
9600
|
+
items,
|
|
9601
|
+
loading,
|
|
9602
|
+
error,
|
|
9603
|
+
totalCount,
|
|
9604
|
+
sectionId,
|
|
9605
|
+
trackClick,
|
|
9606
|
+
}), [items, loading, error, totalCount, sectionId, trackClick]);
|
|
9607
|
+
return React.createElement(SectionSearchContext.Provider, { value: value }, children);
|
|
8166
9608
|
}
|
|
8167
|
-
|
|
8168
|
-
let globalSuggestionsCache = null;
|
|
9609
|
+
|
|
8169
9610
|
/**
|
|
8170
|
-
*
|
|
9611
|
+
* SectionLoading – loading state for section (primitive)
|
|
8171
9612
|
*/
|
|
8172
|
-
|
|
8173
|
-
|
|
8174
|
-
|
|
8175
|
-
|
|
8176
|
-
return
|
|
8177
|
-
}
|
|
9613
|
+
function SectionLoading({ className, style, text = 'Loading...' }) {
|
|
9614
|
+
const { loading } = useSectionSearchContext();
|
|
9615
|
+
if (!loading)
|
|
9616
|
+
return null;
|
|
9617
|
+
return (React.createElement("div", { className: className, style: { padding: 16, color: 'var(--seekora-text-secondary)', fontSize: '0.875rem', ...style } }, text));
|
|
9618
|
+
}
|
|
9619
|
+
|
|
8178
9620
|
/**
|
|
8179
|
-
*
|
|
9621
|
+
* SectionError – error state for section (primitive)
|
|
8180
9622
|
*/
|
|
8181
|
-
|
|
8182
|
-
|
|
8183
|
-
|
|
9623
|
+
function SectionError({ className, style, render }) {
|
|
9624
|
+
const { error } = useSectionSearchContext();
|
|
9625
|
+
if (!error)
|
|
9626
|
+
return null;
|
|
9627
|
+
if (render)
|
|
9628
|
+
return React.createElement(React.Fragment, null, render(error));
|
|
9629
|
+
return (React.createElement("div", { className: className, style: { padding: 16, color: 'var(--seekora-error,#dc2626)', fontSize: '0.875rem', ...style } }, error.message));
|
|
9630
|
+
}
|
|
9631
|
+
|
|
8184
9632
|
/**
|
|
8185
|
-
*
|
|
9633
|
+
* SectionItemGrid – generic grid of items from SectionSearchProvider (primitive)
|
|
8186
9634
|
*/
|
|
8187
|
-
|
|
8188
|
-
|
|
8189
|
-
|
|
9635
|
+
function SectionItemGrid({ columns = 4, maxItems = 12, className, style, getItemId = (i) => i.id ?? String(i?.objectID ?? ''), getItemTitle = (i) => i.title ?? i?.title ?? '', getItemImage = (i) => i.image ?? i?.image, getItemDescription = (i) => i.description ?? i?.description, getItemUrl = (i) => i.url ?? i?.url, renderItem, }) {
|
|
9636
|
+
const { items, loading, error, trackClick } = useSectionSearchContext();
|
|
9637
|
+
if (loading)
|
|
9638
|
+
return React.createElement(SectionLoading, { className: className, style: style });
|
|
9639
|
+
if (error)
|
|
9640
|
+
return React.createElement(SectionError, { className: className, style: style });
|
|
9641
|
+
return (React.createElement(ItemGrid, { items: items, maxItems: maxItems, columns: columns, className: className, style: style, getItemId: getItemId, getItemTitle: getItemTitle, getItemImage: getItemImage, getItemDescription: getItemDescription, getItemUrl: getItemUrl, renderItem: renderItem, onItemClick: (item, index) => trackClick(item, index) }));
|
|
9642
|
+
}
|
|
9643
|
+
|
|
9644
|
+
/**
|
|
9645
|
+
* ProductGallery – product detail image gallery (primitive)
|
|
9646
|
+
*
|
|
9647
|
+
* Uses ImageDisplay with configurable variant (carousel, thumbStrip, etc.).
|
|
9648
|
+
* For use on individual product page.
|
|
9649
|
+
*/
|
|
9650
|
+
function ProductGallery({ images, variant = 'thumbStrip', alt = 'Product', className, style, carouselAutoplay, carouselIntervalMs, }) {
|
|
9651
|
+
return (React.createElement("div", { className: clsx('seekora-product-gallery', className), style: style },
|
|
9652
|
+
React.createElement(ImageDisplay, { images: images, variant: variant, alt: alt, carouselAutoplay: carouselAutoplay, carouselIntervalMs: carouselIntervalMs })));
|
|
9653
|
+
}
|
|
9654
|
+
|
|
9655
|
+
/**
|
|
9656
|
+
* ProductInfo – product detail block (primitive)
|
|
9657
|
+
*
|
|
9658
|
+
* Title, description, price, optional variant selector and CTA. Minimal layout;
|
|
9659
|
+
* override with className/style. For use on individual product page.
|
|
9660
|
+
*
|
|
9661
|
+
* When options/variants/selections/onSelectionChange are provided and
|
|
9662
|
+
* renderVariantSelector is NOT provided, renders the built-in VariantSelector.
|
|
9663
|
+
* renderVariantSelector still takes priority as an override.
|
|
9664
|
+
*/
|
|
9665
|
+
function ProductInfo({ title, description, price, currency = '$', comparePrice, brand, available, badges, options, variants, selectedVariant: _selectedVariant, selections, onSelectionChange, renderVariantSelector, renderCTA, className, style, }) {
|
|
9666
|
+
const priceNum = price != null ? (typeof price === 'number' ? price : parseFloat(String(price))) : null;
|
|
9667
|
+
const comparePriceNum = comparePrice != null ? (typeof comparePrice === 'number' ? comparePrice : parseFloat(String(comparePrice))) : null;
|
|
9668
|
+
const showBuiltInVariantSelector = !renderVariantSelector && options && options.length > 0 && variants && selections && onSelectionChange;
|
|
9669
|
+
return (React.createElement("div", { className: clsx('seekora-product-info', className), style: { display: 'flex', flexDirection: 'column', gap: 12, ...style } },
|
|
9670
|
+
badges && badges.length > 0 && (React.createElement(BadgeList, { badges: badges, position: "inline" })),
|
|
9671
|
+
brand && (React.createElement("span", { className: "seekora-product-info-brand", style: { fontSize: '0.8125rem', color: 'var(--seekora-text-secondary)', textTransform: 'uppercase', letterSpacing: '0.02em' } }, brand)),
|
|
9672
|
+
React.createElement("h1", { className: "seekora-product-info-title", style: { fontSize: '1.25rem', fontWeight: 600, margin: 0 } }, title),
|
|
9673
|
+
(priceNum != null && !Number.isNaN(priceNum)) && (React.createElement("div", { className: "seekora-product-info-price" },
|
|
9674
|
+
React.createElement(PriceDisplay, { price: priceNum, comparePrice: comparePriceNum ?? undefined, currency: currency, style: { fontSize: '1.125rem' } }))),
|
|
9675
|
+
available != null && (React.createElement("span", { className: "seekora-product-info-availability", style: {
|
|
9676
|
+
fontSize: '0.875rem',
|
|
9677
|
+
color: available ? 'var(--seekora-success, #22c55e)' : 'var(--seekora-error, #ef4444)',
|
|
9678
|
+
} }, available ? 'In Stock' : 'Out of Stock')),
|
|
9679
|
+
description ? (React.createElement("p", { className: "seekora-product-info-description", style: { fontSize: '0.875rem', color: 'var(--seekora-text-secondary)', margin: 0, lineHeight: 1.5 } }, description)) : null,
|
|
9680
|
+
renderVariantSelector ? renderVariantSelector() : null,
|
|
9681
|
+
showBuiltInVariantSelector && (React.createElement(VariantSelector, { options: options, variants: variants, selections: selections, onSelectionChange: onSelectionChange, showAvailability: true })),
|
|
9682
|
+
renderCTA?.()));
|
|
9683
|
+
}
|
|
9684
|
+
|
|
9685
|
+
/**
|
|
9686
|
+
* ProductRecommendations – related / frequently bought (primitive)
|
|
9687
|
+
*
|
|
9688
|
+
* Renders a section of recommended items (generic ItemGrid or product list).
|
|
9689
|
+
* Pass items and onItemClick; or wrap SectionSearchProvider with preset query for "related".
|
|
9690
|
+
* For use on individual product page.
|
|
9691
|
+
*/
|
|
9692
|
+
function ProductRecommendations({ title = 'You may also like', items, onItemClick, maxItems = 6, columns = 3, className, style, renderItem, }) {
|
|
9693
|
+
if (!items?.length)
|
|
9694
|
+
return null;
|
|
9695
|
+
return (React.createElement("div", { className: clsx('seekora-product-recommendations', className), style: style },
|
|
9696
|
+
React.createElement("h2", { className: "seekora-product-recommendations-title", style: { fontSize: '1rem', fontWeight: 600, marginBottom: 12 } }, title),
|
|
9697
|
+
React.createElement(ItemGrid, { items: items, maxItems: maxItems, columns: columns, onItemClick: onItemClick, renderItem: renderItem })));
|
|
9698
|
+
}
|
|
8190
9699
|
|
|
8191
9700
|
/**
|
|
8192
9701
|
* Responsive Utilities and Breakpoints
|
|
@@ -13877,6 +15386,164 @@ function formatParsedFilters(filters) {
|
|
|
13877
15386
|
.join(', ');
|
|
13878
15387
|
}
|
|
13879
15388
|
|
|
15389
|
+
/**
|
|
15390
|
+
* useVariantSelection – manages variant selection state
|
|
15391
|
+
*
|
|
15392
|
+
* Single source of truth for variant interactions on product pages.
|
|
15393
|
+
* Cards display variants passively; product pages use this hook.
|
|
15394
|
+
*/
|
|
15395
|
+
function useVariantSelection({ options = [], variants = [], initialSelections = {}, onVariantChange, } = {}) {
|
|
15396
|
+
const [selections, setSelections] = React.useState(initialSelections);
|
|
15397
|
+
const selectedVariant = React.useMemo(() => findVariantBySelections(options, variants, selections), [options, variants, selections]);
|
|
15398
|
+
const isComplete = React.useMemo(() => options.length > 0 && options.every((opt) => !!selections[opt.name]), [options, selections]);
|
|
15399
|
+
const availableValues = React.useMemo(() => {
|
|
15400
|
+
const result = {};
|
|
15401
|
+
for (const option of options) {
|
|
15402
|
+
result[option.name] = getAvailableValuesForOption(option.name, options, variants, selections);
|
|
15403
|
+
}
|
|
15404
|
+
return result;
|
|
15405
|
+
}, [options, variants, selections]);
|
|
15406
|
+
const effectivePrice = selectedVariant?.price ?? null;
|
|
15407
|
+
const effectiveComparePrice = selectedVariant?.comparePrice ?? null;
|
|
15408
|
+
const setSelection = React.useCallback((optionName, value) => {
|
|
15409
|
+
setSelections((prev) => {
|
|
15410
|
+
const next = { ...prev, [optionName]: value };
|
|
15411
|
+
const variant = findVariantBySelections(options, variants, next);
|
|
15412
|
+
onVariantChange?.(variant, next);
|
|
15413
|
+
return next;
|
|
15414
|
+
});
|
|
15415
|
+
}, [options, variants, onVariantChange]);
|
|
15416
|
+
const resetSelections = React.useCallback(() => {
|
|
15417
|
+
setSelections({});
|
|
15418
|
+
onVariantChange?.(null, {});
|
|
15419
|
+
}, [onVariantChange]);
|
|
15420
|
+
return {
|
|
15421
|
+
selections,
|
|
15422
|
+
setSelection,
|
|
15423
|
+
resetSelections,
|
|
15424
|
+
selectedVariant,
|
|
15425
|
+
availableValues,
|
|
15426
|
+
isComplete,
|
|
15427
|
+
effectivePrice,
|
|
15428
|
+
effectiveComparePrice,
|
|
15429
|
+
};
|
|
15430
|
+
}
|
|
15431
|
+
|
|
15432
|
+
/**
|
|
15433
|
+
* useProductAnalytics – analytics event binding hook for custom components
|
|
15434
|
+
*
|
|
15435
|
+
* Returns event handler props ready to spread onto any element, plus imperative
|
|
15436
|
+
* tracking methods. Builds on existing useAnalytics infrastructure.
|
|
15437
|
+
*/
|
|
15438
|
+
function useProductAnalytics({ client, product, position = 0, section, tabId, query, context, enabled = true, }) {
|
|
15439
|
+
const impressionTrackedRef = React.useRef(false);
|
|
15440
|
+
const observerRef = React.useRef(null);
|
|
15441
|
+
const elementRef = React.useRef(null);
|
|
15442
|
+
const productId = product.id || product.objectID || '';
|
|
15443
|
+
const sendEvent = React.useCallback(async (eventName, metadata) => {
|
|
15444
|
+
if (!enabled || !client)
|
|
15445
|
+
return;
|
|
15446
|
+
try {
|
|
15447
|
+
await client.trackEvent?.({
|
|
15448
|
+
event_name: eventName,
|
|
15449
|
+
metadata: {
|
|
15450
|
+
product_id: productId,
|
|
15451
|
+
product_title: product.title || product.name,
|
|
15452
|
+
product_price: product.price,
|
|
15453
|
+
position,
|
|
15454
|
+
section,
|
|
15455
|
+
tab_id: tabId,
|
|
15456
|
+
original_query: query,
|
|
15457
|
+
timestamp: Date.now(),
|
|
15458
|
+
source: 'product_analytics',
|
|
15459
|
+
...metadata,
|
|
15460
|
+
},
|
|
15461
|
+
}, context);
|
|
15462
|
+
log.verbose(`ProductAnalytics: ${eventName}`, metadata);
|
|
15463
|
+
}
|
|
15464
|
+
catch (error) {
|
|
15465
|
+
log.warn(`Failed to track ${eventName}`, { error });
|
|
15466
|
+
}
|
|
15467
|
+
}, [client, enabled, productId, product, position, section, tabId, query, context]);
|
|
15468
|
+
const trackClick = React.useCallback(() => {
|
|
15469
|
+
sendEvent('product.click', {});
|
|
15470
|
+
// Also fire via client.trackClick for V3 compat
|
|
15471
|
+
if (client?.trackClick) {
|
|
15472
|
+
Promise.resolve(client.trackClick(productId, position + 1, context)).catch(() => { });
|
|
15473
|
+
}
|
|
15474
|
+
}, [sendEvent, client, productId, position, context]);
|
|
15475
|
+
const trackVariantSelect = React.useCallback((optionName, value) => {
|
|
15476
|
+
sendEvent('product.variant_select', {
|
|
15477
|
+
option_name: optionName,
|
|
15478
|
+
option_value: value,
|
|
15479
|
+
});
|
|
15480
|
+
}, [sendEvent]);
|
|
15481
|
+
const trackAddToCart = React.useCallback((variant) => {
|
|
15482
|
+
sendEvent('product.add_to_cart', {
|
|
15483
|
+
variant_id: variant?.id,
|
|
15484
|
+
variant_sku: variant?.sku,
|
|
15485
|
+
variant_title: variant?.title,
|
|
15486
|
+
variant_price: variant?.price,
|
|
15487
|
+
});
|
|
15488
|
+
}, [sendEvent]);
|
|
15489
|
+
const trackCustomEvent = React.useCallback((eventName, metadata) => {
|
|
15490
|
+
sendEvent(eventName, metadata ?? {});
|
|
15491
|
+
}, [sendEvent]);
|
|
15492
|
+
// Impression tracking via IntersectionObserver
|
|
15493
|
+
const impressionRef = React.useCallback((node) => {
|
|
15494
|
+
// Cleanup previous observer
|
|
15495
|
+
if (observerRef.current) {
|
|
15496
|
+
observerRef.current.disconnect();
|
|
15497
|
+
observerRef.current = null;
|
|
15498
|
+
}
|
|
15499
|
+
elementRef.current = node;
|
|
15500
|
+
if (!node || !enabled || impressionTrackedRef.current)
|
|
15501
|
+
return;
|
|
15502
|
+
if (typeof IntersectionObserver === 'undefined')
|
|
15503
|
+
return;
|
|
15504
|
+
observerRef.current = new IntersectionObserver((entries) => {
|
|
15505
|
+
for (const entry of entries) {
|
|
15506
|
+
if (entry.isIntersecting && !impressionTrackedRef.current) {
|
|
15507
|
+
impressionTrackedRef.current = true;
|
|
15508
|
+
sendEvent('product.impression', {});
|
|
15509
|
+
observerRef.current?.disconnect();
|
|
15510
|
+
}
|
|
15511
|
+
}
|
|
15512
|
+
}, { threshold: 0.5 });
|
|
15513
|
+
observerRef.current.observe(node);
|
|
15514
|
+
}, [enabled, sendEvent]);
|
|
15515
|
+
// Cleanup on unmount
|
|
15516
|
+
React.useEffect(() => {
|
|
15517
|
+
return () => {
|
|
15518
|
+
observerRef.current?.disconnect();
|
|
15519
|
+
};
|
|
15520
|
+
}, []);
|
|
15521
|
+
const clickProps = {
|
|
15522
|
+
onClick: () => trackClick(),
|
|
15523
|
+
'data-seekora-product-id': productId,
|
|
15524
|
+
'data-seekora-position': position,
|
|
15525
|
+
};
|
|
15526
|
+
const variantSelectProps = (optionName, value) => ({
|
|
15527
|
+
onClick: () => trackVariantSelect(optionName, value),
|
|
15528
|
+
'data-seekora-variant-option': optionName,
|
|
15529
|
+
'data-seekora-variant-value': value,
|
|
15530
|
+
});
|
|
15531
|
+
const addToCartProps = (variant) => ({
|
|
15532
|
+
onClick: () => trackAddToCart(variant),
|
|
15533
|
+
'data-seekora-action': 'add-to-cart',
|
|
15534
|
+
});
|
|
15535
|
+
return {
|
|
15536
|
+
clickProps,
|
|
15537
|
+
variantSelectProps,
|
|
15538
|
+
addToCartProps,
|
|
15539
|
+
impressionRef,
|
|
15540
|
+
trackClick,
|
|
15541
|
+
trackVariantSelect,
|
|
15542
|
+
trackAddToCart,
|
|
15543
|
+
trackCustomEvent,
|
|
15544
|
+
};
|
|
15545
|
+
}
|
|
15546
|
+
|
|
13880
15547
|
/**
|
|
13881
15548
|
* Dark Theme
|
|
13882
15549
|
*/
|
|
@@ -14416,7 +16083,10 @@ function updateSuggestionsStyles(theme) {
|
|
|
14416
16083
|
injectSuggestionsStyles(theme, true);
|
|
14417
16084
|
}
|
|
14418
16085
|
|
|
16086
|
+
exports.ActionButtons = ActionButtons;
|
|
14419
16087
|
exports.AmazonDropdown = AmazonDropdown;
|
|
16088
|
+
exports.AnalyticsProvider = AnalyticsProvider;
|
|
16089
|
+
exports.BadgeList = BadgeList;
|
|
14420
16090
|
exports.Breadcrumb = Breadcrumb;
|
|
14421
16091
|
exports.CategoriesTabs = CategoriesTabs;
|
|
14422
16092
|
exports.ClearRefinements = ClearRefinements;
|
|
@@ -14433,6 +16103,7 @@ exports.HierarchicalMenu = HierarchicalMenu;
|
|
|
14433
16103
|
exports.Highlight = Highlight$1;
|
|
14434
16104
|
exports.HitsPerPage = HitsPerPage;
|
|
14435
16105
|
exports.ImageDisplay = ImageDisplay;
|
|
16106
|
+
exports.ImageZoom = ImageZoom;
|
|
14436
16107
|
exports.InfiniteHits = InfiniteHits;
|
|
14437
16108
|
exports.ItemCard = ItemCard;
|
|
14438
16109
|
exports.ItemGrid = ItemGrid;
|
|
@@ -14442,6 +16113,7 @@ exports.MobileFiltersButton = MobileFiltersButton;
|
|
|
14442
16113
|
exports.MobileSheetDropdown = MobileSheetDropdown;
|
|
14443
16114
|
exports.Pagination = Pagination;
|
|
14444
16115
|
exports.PinterestDropdown = PinterestDropdown;
|
|
16116
|
+
exports.PriceDisplay = PriceDisplay;
|
|
14445
16117
|
exports.ProductCard = ProductCard;
|
|
14446
16118
|
exports.ProductGallery = ProductGallery;
|
|
14447
16119
|
exports.ProductGrid = ProductGrid;
|
|
@@ -14451,6 +16123,7 @@ exports.QuerySuggestions = QuerySuggestions;
|
|
|
14451
16123
|
exports.QuerySuggestionsDropdown = QuerySuggestionsDropdown;
|
|
14452
16124
|
exports.RangeInput = RangeInput;
|
|
14453
16125
|
exports.RangeSlider = RangeSlider;
|
|
16126
|
+
exports.RatingDisplay = RatingDisplay;
|
|
14454
16127
|
exports.RecentSearchesList = RecentSearchesList;
|
|
14455
16128
|
exports.RecentlyViewed = RecentlyViewed;
|
|
14456
16129
|
exports.RelatedProducts = RelatedProducts;
|
|
@@ -14480,6 +16153,8 @@ exports.SuggestionsLoading = SuggestionsLoading;
|
|
|
14480
16153
|
exports.SuggestionsProvider = SuggestionsProvider;
|
|
14481
16154
|
exports.TrendingItems = TrendingItems;
|
|
14482
16155
|
exports.TrendingList = TrendingList;
|
|
16156
|
+
exports.VariantSelector = VariantSelector;
|
|
16157
|
+
exports.VariantSwatches = VariantSwatches;
|
|
14483
16158
|
exports.addRecentSearch = addRecentSearch;
|
|
14484
16159
|
exports.addToRecentlyViewed = addToRecentlyViewed;
|
|
14485
16160
|
exports.brandPresets = brandPresets;
|
|
@@ -14492,14 +16167,19 @@ exports.createTheme = createTheme;
|
|
|
14492
16167
|
exports.darkTheme = darkTheme;
|
|
14493
16168
|
exports.darkThemeVariables = darkThemeVariables;
|
|
14494
16169
|
exports.defaultTheme = defaultTheme;
|
|
16170
|
+
exports.extractBadges = extractBadges;
|
|
14495
16171
|
exports.extractBrand = extractBrand;
|
|
14496
16172
|
exports.extractCategory = extractCategory;
|
|
14497
16173
|
exports.extractProduct = extractProduct;
|
|
14498
16174
|
exports.extractSuggestion = extractSuggestion;
|
|
16175
|
+
exports.findVariantBySelections = findVariantBySelections;
|
|
14499
16176
|
exports.formatParsedFilters = formatParsedFilters;
|
|
16177
|
+
exports.formatPriceRange = formatPriceRange;
|
|
14500
16178
|
exports.formatSuggestionPrice = formatPrice;
|
|
14501
16179
|
exports.generateSuggestionsStylesheet = generateSuggestionsStylesheet;
|
|
16180
|
+
exports.getAvailableValuesForOption = getAvailableValuesForOption;
|
|
14502
16181
|
exports.getFingerprint = getFingerprint;
|
|
16182
|
+
exports.getPriceRange = getPriceRange;
|
|
14503
16183
|
exports.getRecentSearches = getRecentSearches;
|
|
14504
16184
|
exports.getShortcutText = getShortcutText;
|
|
14505
16185
|
exports.getSuggestionsCache = getSuggestionsCache;
|
|
@@ -14516,11 +16196,13 @@ exports.removeRecentSearch = removeRecentSearch;
|
|
|
14516
16196
|
exports.touchTargets = touchTargets;
|
|
14517
16197
|
exports.updateSuggestionsStyles = updateSuggestionsStyles;
|
|
14518
16198
|
exports.useAnalytics = useAnalytics;
|
|
16199
|
+
exports.useAnalyticsProvider = useAnalyticsProvider;
|
|
14519
16200
|
exports.useDocSearch = useDocSearch;
|
|
14520
16201
|
exports.useDocSearchSeekoraSearch = useSeekoraSearch$1;
|
|
14521
16202
|
exports.useInjectResponsiveStyles = useInjectResponsiveStyles;
|
|
14522
16203
|
exports.useKeyboard = useKeyboard;
|
|
14523
16204
|
exports.useNaturalLanguageFilters = useNaturalLanguageFilters;
|
|
16205
|
+
exports.useProductAnalytics = useProductAnalytics;
|
|
14524
16206
|
exports.useQuerySuggestions = useQuerySuggestions;
|
|
14525
16207
|
exports.useQuerySuggestionsEnhanced = useQuerySuggestionsEnhanced;
|
|
14526
16208
|
exports.useResponsive = useResponsive;
|
|
@@ -14531,4 +16213,6 @@ exports.useSeekoraSearch = useSeekoraSearch;
|
|
|
14531
16213
|
exports.useSmartSuggestions = useSmartSuggestions;
|
|
14532
16214
|
exports.useSuggestionsAnalytics = useSuggestionsAnalytics;
|
|
14533
16215
|
exports.useSuggestionsContext = useSuggestionsContext;
|
|
16216
|
+
exports.useVariantSelection = useVariantSelection;
|
|
16217
|
+
exports.withAnalytics = withAnalytics;
|
|
14534
16218
|
//# sourceMappingURL=index.js.map
|