Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions implementations/react-native-sdk/e2e/tap-tracking.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const {
clearProfileState,
ELEMENT_VISIBILITY_TIMEOUT,
waitForEventsCountAtLeast,
} = require('./helpers')

describe('Tap Tracking', () => {
beforeAll(async () => {
await device.launchApp()
})

beforeEach(async () => {
await clearProfileState()
})

it('should emit component_click when tapping a content entry', async () => {
const analyticsTitle = element(by.text('Analytics Events'))
await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT)

await element(by.id('content-entry-1MwiFl4z7gkwqGYdvCmr8c')).tap()

await waitForEventsCountAtLeast(1)

await waitFor(element(by.id('event-component_click-1MwiFl4z7gkwqGYdvCmr8c')))
.toBeVisible()
.whileElement(by.id('main-scroll-view'))
.scroll(500, 'down')
})

it('should emit component_click for a different entry', async () => {
const analyticsTitle = element(by.text('Analytics Events'))
await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT)

await element(by.id('content-entry-2Z2WLOx07InSewC3LUB3eX')).tap()

await waitForEventsCountAtLeast(1)

await waitFor(element(by.id('event-component_click-2Z2WLOx07InSewC3LUB3eX')))
.toBeVisible()
.whileElement(by.id('main-scroll-view'))
.scroll(500, 'down')
})
})
4 changes: 2 additions & 2 deletions implementations/react-native-sdk/sections/ContentEntry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,15 @@ export function ContentEntry({ entry, sdk }: ContentEntryProps): React.JSX.Eleme
return (
<View testID={`content-entry-${entry.sys.id}`}>
{isPersonalizedEntry(entry) ? (
<Personalization baselineEntry={entry}>
<Personalization baselineEntry={entry} trackTaps>
{(resolvedEntry) => (
<View testID={`content-${entry.sys.id}`}>
{renderContent(resolvedEntry, entry.sys.id)}
</View>
)}
</Personalization>
) : (
<Analytics entry={entry}>
<Analytics entry={entry} trackTaps>
<View testID={`content-${entry.sys.id}`}>{renderContent(entry, entry.sys.id)}</View>
</Analytics>
)}
Expand Down
78 changes: 63 additions & 15 deletions packages/react-native-sdk/src/components/Analytics.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Entry } from 'contentful'
import React, { type ReactNode } from 'react'
import { View, type StyleProp, type ViewStyle } from 'react-native'
import { useInteractionTracking } from '../context/InteractionTrackingContext'
import { useTapTracking } from '../hooks/useTapTracking'
import { useViewportTracking } from '../hooks/useViewportTracking'

/**
Expand Down Expand Up @@ -29,15 +31,15 @@ export interface AnalyticsProps {
* Minimum time (in milliseconds) the component must be visible
* before tracking fires.
*
* @defaultValue 2000
* @defaultValue `2000`
*/
viewTimeMs?: number

/**
* Minimum visibility ratio (0.0 - 1.0) required to consider
* the component "visible".
*
* @defaultValue 0.8
* @defaultValue `0.8`
*/
threshold?: number

Expand All @@ -50,16 +52,44 @@ export interface AnalyticsProps {
* Optional testID for testing purposes.
*/
testID?: string

/**
* Per-component override for view tracking.
* - `undefined`: inherits from `trackEntryInteraction.views` on {@link OptimizationRoot}
* - `true`: enable view tracking for this entry
* - `false`: disable view tracking for this entry
*
* @defaultValue `undefined`
*/
trackViews?: boolean

/**
* Per-component override for tap tracking.
* - `undefined`: inherits from `trackEntryInteraction.taps` on {@link OptimizationRoot}
* - `true`: enable tap tracking for this entry
* - `false`: disable tap tracking (overrides the global setting)
*
* @defaultValue `undefined`
*/
trackTaps?: boolean

/**
* Optional callback invoked with the entry after a tap tracking event is emitted.
* When provided, implicitly enables tap tracking unless `trackTaps` is explicitly `false`.
*
* @defaultValue `undefined`
*/
onTap?: (entry: Entry) => void
}

/**
* Tracks views of non-personalized Contentful entry components (content entries).
* Tracks views and taps of non-personalized Contentful entry components (content entries).
*
* Use this component for standard Contentful entries you want analytics on
* (products, articles, etc.) that are not personalized.
*
* @param props - Component props
* @returns A wrapper View with viewport tracking attached
* @param props - {@link AnalyticsProps}
* @returns A wrapper View with interaction tracking attached
*
* @remarks
* Must be used within an {@link OptimizationProvider}. Works with or without a
Expand All @@ -78,15 +108,12 @@ export interface AnalyticsProps {
* </Analytics>
* </OptimizationScrollProvider>
* ```
*
* @example Custom Thresholds
* @example With Tap Tracking
* ```tsx
* <Analytics
* entry={articleEntry}
* viewTimeMs={1500}
* threshold={0.9}
* >
* <ArticleCard data={articleEntry.fields} />
* <Analytics entry={productEntry} trackTaps>
* <Pressable onPress={() => navigate(productEntry)}>
* <ProductCard name={productEntry.fields.name} />
* </Pressable>
* </Analytics>
* ```
*
Expand All @@ -101,16 +128,37 @@ export function Analytics({
threshold,
style,
testID,
trackViews,
trackTaps,
onTap,
}: AnalyticsProps): React.JSX.Element {
// Set up viewport tracking - the hook extracts tracking metadata from the entry
const interactionTracking = useInteractionTracking()

const viewsEnabled = trackViews ?? interactionTracking.views
const tapsEnabled =
trackTaps === false ? false : (trackTaps ?? onTap) ? true : interactionTracking.taps

const { onLayout } = useViewportTracking({
entry,
threshold,
viewTimeMs,
enabled: viewsEnabled,
})

const { onTouchStart, onTouchEnd } = useTapTracking({
entry,
enabled: tapsEnabled,
onTap,
})

return (
<View style={style} onLayout={onLayout} testID={testID}>
<View
style={style}
onLayout={onLayout}
testID={testID}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
{children}
</View>
)
Expand Down
63 changes: 48 additions & 15 deletions packages/react-native-sdk/src/components/OptimizationRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import React, { type ReactNode } from 'react'
import type Optimization from '../'
import {
InteractionTrackingProvider,
type TrackEntryInteractionOptions,
} from '../context/InteractionTrackingContext'
import { LiveUpdatesProvider } from '../context/LiveUpdatesContext'
import { PreviewPanelOverlay } from '../preview/components/PreviewPanelOverlay'
import type { ContentfulClient } from '../preview/types'
Expand Down Expand Up @@ -58,14 +62,38 @@ export interface OptimizationRootProps {
/**
* Whether {@link Personalization} components should react to state changes in real-time.
*
* @defaultValue `false`
*
* @remarks
* Live updates are always enabled when the preview panel is open,
* regardless of this setting.
*
* @defaultValue false
*/
liveUpdates?: boolean

/**
* Controls which entry interactions are tracked automatically for all
* {@link Personalization} and {@link Analytics} components. Individual
* components can override each interaction type with their `trackViews`
* and `trackTaps` props.
*
* @defaultValue `{ views: true, taps: false }`
*
* @remarks
* Mirrors the web SDK's `autoTrackEntryInteraction` pattern. Uses `taps`
* instead of `clicks` to match React Native terminology.
*
* @example
* ```tsx
* <OptimizationRoot
* instance={optimization}
* trackEntryInteraction={{ views: true, taps: true }}
* >
* <App />
* </OptimizationRoot>
* ```
*/
trackEntryInteraction?: TrackEntryInteractionOptions

/**
* Children components that will have access to the Optimization instance.
*/
Expand All @@ -74,9 +102,9 @@ export interface OptimizationRootProps {

/**
* Recommended top-level wrapper that combines {@link OptimizationProvider} with optional
* preview panel and live updates support.
* preview panel, live updates, and interaction tracking support.
*
* @param props - Component props
* @param props - {@link OptimizationRootProps}
* @returns The provider tree wrapping children
*
* @example Basic usage
Expand All @@ -85,12 +113,19 @@ export interface OptimizationRootProps {
* clientId: 'your-client-id',
* environment: 'main',
* })
*
* <OptimizationRoot instance={optimization}>
* <App />
* </OptimizationRoot>
* ```
*
* @example With interaction tracking
* ```tsx
* <OptimizationRoot
* instance={optimization}
* trackEntryInteraction={{ views: true, taps: true }}
* >
* <App />
* </OptimizationRoot>
* ```
* @example With preview panel
* ```tsx
* <OptimizationRoot
Expand All @@ -105,22 +140,16 @@ export interface OptimizationRootProps {
* </OptimizationRoot>
* ```
*
* @example With global live updates
* ```tsx
* <OptimizationRoot instance={optimization} liveUpdates={true}>
* <App />
* </OptimizationRoot>
* ```
*
* @see {@link OptimizationProvider}
* @see {@link Personalization} for per-component live updates override
* @see {@link Personalization} for per-component interaction overrides
*
* @public
*/
export function OptimizationRoot({
instance,
previewPanel,
liveUpdates = false,
trackEntryInteraction,
children,
}: OptimizationRootProps): React.JSX.Element {
const content = previewPanel?.enabled ? (
Expand All @@ -138,7 +167,11 @@ export function OptimizationRoot({

return (
<OptimizationProvider instance={instance}>
<LiveUpdatesProvider globalLiveUpdates={liveUpdates}>{content}</LiveUpdatesProvider>
<LiveUpdatesProvider globalLiveUpdates={liveUpdates}>
<InteractionTrackingProvider trackEntryInteraction={trackEntryInteraction}>
{content}
</InteractionTrackingProvider>
</LiveUpdatesProvider>
</OptimizationProvider>
)
}
Expand Down
Loading