@shopify/shop-minis-react 0.11.1 → 0.12.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.
- package/dist/components/atoms/content-monitor.js +16 -12
- package/dist/components/atoms/content-monitor.js.map +1 -1
- package/dist/components/commerce/product-card.js +83 -78
- package/dist/components/commerce/product-card.js.map +1 -1
- package/dist/components/commerce/product-link.js +98 -93
- package/dist/components/commerce/product-link.js.map +1 -1
- package/dist/internal/useContentImpression.js +33 -0
- package/dist/internal/useContentImpression.js.map +1 -0
- package/dist/internal/useProductImpression.js +34 -0
- package/dist/internal/useProductImpression.js.map +1 -0
- package/package.json +1 -1
- package/src/components/atoms/content-monitor.tsx +15 -8
- package/src/components/commerce/product-card.tsx +27 -17
- package/src/components/commerce/product-link.tsx +60 -49
- package/src/internal/useContentImpression.ts +75 -0
- package/src/internal/useProductImpression.ts +88 -0
|
@@ -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
|
-
{
|
|
437
|
-
|
|
438
|
-
<
|
|
439
|
-
<
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
<
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
<
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
+
}
|