@shopify/shop-minis-react 0.11.1 → 0.13.0

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.
@@ -6,6 +6,7 @@ import {type Product, type ProductVariant} from '@shopify/shop-minis-platform'
6
6
  import {useShopNavigation} from '../../hooks/navigation/useShopNavigation'
7
7
  import {useSavedProductsActions} from '../../hooks/user/useSavedProductsActions'
8
8
  import {ProductReviewStars} from '../../internal/components/product-review-stars'
9
+ import {useProductImpression} from '../../internal/useProductImpression'
9
10
  import {cn} from '../../lib/utils'
10
11
  import {formatMoney} from '../../utils/formatMoney'
11
12
  import {Image} from '../atoms/image'
@@ -326,6 +327,8 @@ export interface ProductCardProps {
326
327
  favoriteButtonDisabled?: boolean
327
328
  /** Whether review stars are disabled */
328
329
  reviewsDisabled?: boolean
330
+ /** Whether to disable impression tracking */
331
+ impressionTrackingDisabled?: boolean
329
332
  }
330
333
 
331
334
  function ProductCard({
@@ -340,9 +343,14 @@ function ProductCard({
340
343
  children,
341
344
  favoriteButtonDisabled = false,
342
345
  reviewsDisabled = false,
346
+ impressionTrackingDisabled = false,
343
347
  }: ProductCardProps) {
344
348
  const {navigateToProduct} = useShopNavigation()
345
349
  const {saveProduct, unsaveProduct} = useSavedProductsActions()
350
+ const {ref: impressionRef} = useProductImpression({
351
+ productId: product.id,
352
+ skip: impressionTrackingDisabled,
353
+ })
346
354
 
347
355
  // Local state for optimistic UI updates
348
356
  const [isFavoritedLocal, setIsFavoritedLocal] = useState(product.isFavorited)
@@ -433,23 +441,25 @@ function ProductCard({
433
441
 
434
442
  return (
435
443
  <ProductCardContext.Provider value={contextValue}>
436
- {children ?? (
437
- <ProductCardContainer>
438
- <ProductCardImageContainer>
439
- <ProductCardImage />
440
- {variant === 'priceOverlay' && <ProductCardPriceOverlayBadge />}
441
- <ProductCardBadge />
442
- <ProductCardFavoriteButton />
443
- </ProductCardImageContainer>
444
- {variant === 'default' && (
445
- <ProductCardInfo>
446
- <ProductCardTitle />
447
- <ProductCardReviewStars />
448
- <ProductCardPrice />
449
- </ProductCardInfo>
450
- )}
451
- </ProductCardContainer>
452
- )}
444
+ <div ref={impressionRef}>
445
+ {children ?? (
446
+ <ProductCardContainer>
447
+ <ProductCardImageContainer>
448
+ <ProductCardImage />
449
+ {variant === 'priceOverlay' && <ProductCardPriceOverlayBadge />}
450
+ <ProductCardBadge />
451
+ <ProductCardFavoriteButton />
452
+ </ProductCardImageContainer>
453
+ {variant === 'default' && (
454
+ <ProductCardInfo>
455
+ <ProductCardTitle />
456
+ <ProductCardReviewStars />
457
+ <ProductCardPrice />
458
+ </ProductCardInfo>
459
+ )}
460
+ </ProductCardContainer>
461
+ )}
462
+ </div>
453
463
  </ProductCardContext.Provider>
454
464
  )
455
465
  }
@@ -7,6 +7,7 @@ import {Slot as SlotPrimitive} from 'radix-ui'
7
7
  import {useShopNavigation} from '../../hooks/navigation/useShopNavigation'
8
8
  import {useSavedProductsActions} from '../../hooks/user/useSavedProductsActions'
9
9
  import {ProductReviewStars} from '../../internal/components/product-review-stars'
10
+ import {useProductImpression} from '../../internal/useProductImpression'
10
11
  import {cn} from '../../lib/utils'
11
12
  import {formatMoney} from '../../utils/formatMoney'
12
13
  import {Touchable} from '../atoms/touchable'
@@ -239,6 +240,8 @@ export interface ProductLinkDocProps {
239
240
  customAction?: React.ReactNode
240
241
  /** Callback when the custom action is clicked. Must be provided with `customAction`. */
241
242
  onCustomActionClick?: () => void
243
+ /** Whether to disable impression tracking */
244
+ impressionTrackingDisabled?: boolean
242
245
  }
243
246
 
244
247
  export type ProductLinkProps = {
@@ -246,6 +249,7 @@ export type ProductLinkProps = {
246
249
  hideFavoriteAction?: boolean
247
250
  onClick?: (product: Product) => void
248
251
  reviewsDisabled?: boolean
252
+ impressionTrackingDisabled?: boolean
249
253
  } & (
250
254
  | {
251
255
  customAction?: never
@@ -265,9 +269,14 @@ function ProductLink({
265
269
  customAction,
266
270
  onCustomActionClick,
267
271
  reviewsDisabled = false,
272
+ impressionTrackingDisabled = false,
268
273
  }: ProductLinkProps) {
269
274
  const {navigateToProduct} = useShopNavigation()
270
275
  const {saveProduct, unsaveProduct} = useSavedProductsActions()
276
+ const {ref: impressionRef} = useProductImpression({
277
+ productId: product.id,
278
+ skip: impressionTrackingDisabled,
279
+ })
271
280
 
272
281
  const {
273
282
  id,
@@ -346,58 +355,60 @@ function ProductLink({
346
355
  ])
347
356
 
348
357
  return (
349
- <ProductLinkRoot
350
- layout="horizontal"
351
- discount={hasDiscount ? 'small' : 'none'}
352
- onPress={handlePress}
353
- >
354
- <ProductLinkImage layout="horizontal">
355
- {imageUrl ? (
356
- <img
357
- src={imageUrl}
358
- alt={imageAltText}
359
- className="h-full w-full object-cover"
360
- />
361
- ) : (
362
- <div className="h-full w-full bg-muted flex items-center justify-center text-muted-foreground text-xs">
363
- No Image
364
- </div>
365
- )}
366
- </ProductLinkImage>
367
-
368
- <ProductLinkInfo layout="horizontal">
369
- <ProductLinkTitle>{title}</ProductLinkTitle>
370
-
371
- {averageRating && !reviewsDisabled ? (
372
- <ProductLinkRating>
373
- <ProductReviewStars
374
- averageRating={averageRating}
375
- reviewCount={reviewCount}
358
+ <div ref={impressionRef}>
359
+ <ProductLinkRoot
360
+ layout="horizontal"
361
+ discount={hasDiscount ? 'small' : 'none'}
362
+ onPress={handlePress}
363
+ >
364
+ <ProductLinkImage layout="horizontal">
365
+ {imageUrl ? (
366
+ <img
367
+ src={imageUrl}
368
+ alt={imageAltText}
369
+ className="h-full w-full object-cover"
376
370
  />
377
- </ProductLinkRating>
378
- ) : null}
379
-
380
- <ProductLinkPrice>
381
- {hasDiscount ? (
382
- <>
383
- <ProductLinkDiscountPrice>{amount}</ProductLinkDiscountPrice>
384
- <ProductLinkOriginalPrice>
385
- {compareAtPriceAmount}
386
- </ProductLinkOriginalPrice>
387
- </>
388
371
  ) : (
389
- <ProductLinkCurrentPrice>{amount}</ProductLinkCurrentPrice>
372
+ <div className="h-full w-full bg-muted flex items-center justify-center text-muted-foreground text-xs">
373
+ No Image
374
+ </div>
390
375
  )}
391
- </ProductLinkPrice>
392
- </ProductLinkInfo>
393
-
394
- <ProductLinkActions
395
- filled={isFavoritedLocal}
396
- onPress={handleActionPress}
397
- customAction={customAction}
398
- hideFavoriteAction={hideFavoriteAction}
399
- />
400
- </ProductLinkRoot>
376
+ </ProductLinkImage>
377
+
378
+ <ProductLinkInfo layout="horizontal">
379
+ <ProductLinkTitle>{title}</ProductLinkTitle>
380
+
381
+ {averageRating && !reviewsDisabled ? (
382
+ <ProductLinkRating>
383
+ <ProductReviewStars
384
+ averageRating={averageRating}
385
+ reviewCount={reviewCount}
386
+ />
387
+ </ProductLinkRating>
388
+ ) : null}
389
+
390
+ <ProductLinkPrice>
391
+ {hasDiscount ? (
392
+ <>
393
+ <ProductLinkDiscountPrice>{amount}</ProductLinkDiscountPrice>
394
+ <ProductLinkOriginalPrice>
395
+ {compareAtPriceAmount}
396
+ </ProductLinkOriginalPrice>
397
+ </>
398
+ ) : (
399
+ <ProductLinkCurrentPrice>{amount}</ProductLinkCurrentPrice>
400
+ )}
401
+ </ProductLinkPrice>
402
+ </ProductLinkInfo>
403
+
404
+ <ProductLinkActions
405
+ filled={isFavoritedLocal}
406
+ onPress={handleActionPress}
407
+ customAction={customAction}
408
+ hideFavoriteAction={hideFavoriteAction}
409
+ />
410
+ </ProductLinkRoot>
411
+ </div>
401
412
  )
402
413
  }
403
414
 
@@ -0,0 +1,75 @@
1
+ import {useCallback, useEffect, useRef} from 'react'
2
+
3
+ import {useHandleAction} from './useHandleAction'
4
+ import {useShopActions} from './useShopActions'
5
+
6
+ export interface UseContentImpressionParams {
7
+ /**
8
+ * The public ID of the content to report impressions for
9
+ */
10
+ publicId: string
11
+ /**
12
+ * Whether to skip reporting impressions
13
+ */
14
+ skip?: boolean
15
+ }
16
+
17
+ export interface UseContentImpressionReturns {
18
+ /**
19
+ * Ref to attach to the content element for visibility tracking.
20
+ * When the element becomes visible, an impression will be reported.
21
+ */
22
+ ref: {current: HTMLDivElement | null}
23
+ }
24
+
25
+ export function useContentImpression({
26
+ publicId,
27
+ skip = false,
28
+ }: UseContentImpressionParams): UseContentImpressionReturns {
29
+ const {reportContentImpression} = useShopActions()
30
+ const handleAction = useHandleAction(reportContentImpression)
31
+ const ref = useRef<HTMLDivElement>(null)
32
+ const hasReportedRef = useRef(false)
33
+
34
+ const reportImpression = useCallback(() => {
35
+ if (hasReportedRef.current || skip || !publicId) {
36
+ return
37
+ }
38
+
39
+ hasReportedRef.current = true
40
+ handleAction({publicId, pageValue: window.location.pathname})
41
+ }, [handleAction, publicId, skip])
42
+
43
+ useEffect(() => {
44
+ hasReportedRef.current = false
45
+ }, [publicId])
46
+
47
+ useEffect(() => {
48
+ if (skip || !ref.current) {
49
+ return
50
+ }
51
+
52
+ const element = ref.current
53
+
54
+ const observer = new IntersectionObserver(
55
+ entries => {
56
+ const [entry] = entries
57
+ if (entry?.isIntersecting) {
58
+ reportImpression()
59
+ observer.disconnect()
60
+ }
61
+ },
62
+ {
63
+ threshold: 0.5,
64
+ }
65
+ )
66
+
67
+ observer.observe(element)
68
+
69
+ return () => {
70
+ observer.disconnect()
71
+ }
72
+ }, [reportImpression, skip])
73
+
74
+ return {ref}
75
+ }
@@ -0,0 +1,88 @@
1
+ import {useCallback, useEffect, useRef} from 'react'
2
+
3
+ import {useHandleAction} from './useHandleAction'
4
+ import {useShopActions} from './useShopActions'
5
+
6
+ export interface UseProductImpressionParams {
7
+ /**
8
+ * The product GID string to report impressions for
9
+ */
10
+ productId: string
11
+ /**
12
+ * Whether to skip reporting impressions (e.g., when product data is loading)
13
+ */
14
+ skip?: boolean
15
+ }
16
+
17
+ export interface UseProductImpressionReturns {
18
+ /**
19
+ * Ref to attach to the product element for visibility tracking.
20
+ * When the element becomes visible, an impression will be reported.
21
+ */
22
+ ref: {current: HTMLDivElement | null}
23
+ }
24
+
25
+ /**
26
+ * Hook to report product impressions when a product becomes visible.
27
+ * Attach the returned ref to the product container element.
28
+ *
29
+ * @example
30
+ * ```tsx
31
+ * function ProductCard({ product }) {
32
+ * const { ref } = useProductImpression({ productId: product.id });
33
+ * return <div ref={ref}>...</div>;
34
+ * }
35
+ * ```
36
+ */
37
+ export function useProductImpression({
38
+ productId,
39
+ skip = false,
40
+ }: UseProductImpressionParams): UseProductImpressionReturns {
41
+ const {productRecommendationImpression} = useShopActions()
42
+ const handleAction = useHandleAction(productRecommendationImpression)
43
+ const ref = useRef<HTMLDivElement>(null)
44
+ const hasReportedRef = useRef(false)
45
+
46
+ const reportImpression = useCallback(() => {
47
+ if (hasReportedRef.current || skip || !productId) {
48
+ return
49
+ }
50
+
51
+ hasReportedRef.current = true
52
+ handleAction({productId})
53
+ }, [handleAction, productId, skip])
54
+
55
+ useEffect(() => {
56
+ // Reset reported state when productId changes
57
+ hasReportedRef.current = false
58
+ }, [productId])
59
+
60
+ useEffect(() => {
61
+ if (skip || !ref.current) {
62
+ return
63
+ }
64
+
65
+ const element = ref.current
66
+
67
+ const observer = new IntersectionObserver(
68
+ entries => {
69
+ const [entry] = entries
70
+ if (entry?.isIntersecting) {
71
+ reportImpression()
72
+ observer.disconnect()
73
+ }
74
+ },
75
+ {
76
+ threshold: 0.5, // Report when 50% of the element is visible
77
+ }
78
+ )
79
+
80
+ observer.observe(element)
81
+
82
+ return () => {
83
+ observer.disconnect()
84
+ }
85
+ }, [reportImpression, skip])
86
+
87
+ return {ref}
88
+ }