Skip to content

Latest commit

 

History

198 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Salesforce Personalization SDK for React Native

A React Native plugin that wraps the native iOS / Android Salesforce Personalization SDKs. Renders personalized UI (SalesforceBanner, SalesforceRecommendations) in React Native apps and lets you ship your own custom components against the same backend.

Looking for a runnable demo? See the example/ app in the repository.

Table of Contents

Requirements

Requirement Version
React Native 0.76+
React 18.3+ or 19.x
Node.js Host React Native requirement
iOS 15.1+
Android API 26+

Install

npm install @salesforce-personalization/react-native-personalization
# or
yarn add @salesforce-personalization/react-native-personalization

Platform Setup

The host app owns native SDK initialization — there is no JS-side configure(...). The SDK requires CDP_APP_ID and CDP_ENDPOINT. The example defaults DATASPACE to default and applies CDN_URL only when you provide an override. Contact your Salesforce Marketing Cloud administrator for tenant values, and don't commit real credentials.

After installing, follow the platform setup guide:

  • iOS — see docs/iOS.md for Info.plist, CocoaPods, and AppDelegate.swift setup.
  • Android — see docs/Android.md for AndroidManifest.xml and MainApplication.kt setup.

Quick Start

Once the SDK is initialized natively and the user is opted in, render personalized content with ContentZone. Do not mount a live zone while consent is unset or opted out: changing consent does not automatically retry a failed content request. Gate the zone on your app's persisted opt-in state, as shown below. ContentZone fetches content for the named zone, matches the backend's component name against your allowedComponents, and renders the result.

import { Text } from 'react-native'
import {
  ContentZone,
  SalesforceBanner,
  SalesforceRecommendations,
} from '@salesforce-personalization/react-native-personalization'

function HomeScreen({ isOptedIn }: { isOptedIn: boolean }) {
  if (!isOptedIn) {
    return <Text>Personalized content requires opt-in consent.</Text>
  }

  return (
    <ContentZone
      name="HomeScreen"
      allowedComponents={[
        SalesforceBanner({
          onTap: (event) => console.log('Banner tapped:', event.header),
        }),
        SalesforceRecommendations({
          onTap: (event) => console.log('Item tapped:', event.item.name),
        }),
      ]}
      loading={<Text>Loading personalized content...</Text>}
      fallback={(error) => <Text>Could not load content</Text>}
    />
  )
}

Only name and allowedComponents are required. Fetch, timeout, component matching, and model-validation failures render the optional fallback. Errors thrown while composing or rendering a component propagate through React; use an error boundary for those failures. When fallback is omitted, handled failures render nothing.

See docs/GETTING_STARTED.md for the full guide.

Public API Exports

The package root exports the following public API from src/index.tsx:

  • Components and controller: ContentZone, MockContentZone, useContentZoneController
  • OOTB factories: SalesforceBanner, SalesforceRecommendations
  • Engagement: trackEngagement, trackEngagementPerItem, useTrackEngagementViewOnce, useTrackEngagementViewOncePerItem, EngagementAction
  • Native bridge facade: PersonalizationModule
  • Component types: ContentZoneProps, MockContentZoneProps, ContentZoneController, Component, ComponentModel, ComponentContext, ContentSource, ViewableItemsChangedHandler
  • Banner types: SalesforceBannerModel, SalesforceBannerStyle, SalesforceBannerConfig, SalesforceBannerTapEvent
  • Recommendation types: SalesforceRecommendationsModel, SalesforceRecommendationsStyle, SalesforceRecommendationsConfig, SalesforceRecommendationItem, SalesforceRecommendationTapEvent, SalesforceRecommendationCardStyle, SalesforceSectionHeaderStyle
  • Identity and decision types: PartyIdentification, ProfileAttributes, DecisionsRequestContext
  • Event types: SFMCEvent, CustomEvent, EngagementEvent, SystemEvent, CartEvent, OrderEvent, CatalogObjectEvent, LineItem, Order, CatalogObject

Core APIs

ContentZone

The main component for displaying personalized content.

Prop Type Required Description
name string Yes Unique zone identifier matching server config
allowedComponents Component[] Yes Component renderers the zone may use
controller ContentZoneController No Controller for programmatic refresh
timeoutSeconds number No Fetch timeout in seconds (default: 10)
decisionsRequestContext DecisionsRequestContext No Contextual data to bias personalization
loading ReactNode No Loading-state UI
fallback (error: Error) => ReactNode No Error-state UI

Pull-to-refresh. Pass a controller from useContentZoneController and route a RefreshControl to controller.refresh():

import { useState } from 'react'
import { RefreshControl, ScrollView } from 'react-native'
import {
  ContentZone,
  SalesforceBanner,
  useContentZoneController,
} from '@salesforce-personalization/react-native-personalization'

function HomeScreen() {
  const controller = useContentZoneController()
  const [refreshing, setRefreshing] = useState(false)

  const refresh = async () => {
    setRefreshing(true)
    try {
      await controller.refresh()
    } finally {
      setRefreshing(false)
    }
  }

  return (
    <ScrollView
      refreshControl={
        <RefreshControl refreshing={refreshing} onRefresh={refresh} />
      }
    >
      <ContentZone
        controller={controller}
        name="HomeScreen"
        allowedComponents={[SalesforceBanner()]}
      />
    </ScrollView>
  )
}

controller.refresh() keeps existing content visible during the fetch; call controller.refresh(true) to show the loading state instead.

Controller sharing is unsupported: create one controller per mounted zone. Although a second binding is rejected, cleanup is not ownership-aware, so mounting or unmounting multiple zones with one controller can leave it unbound. Calling refresh() while unbound logs a warning and resolves without doing anything. Changing name refetches and resubscribes. Changes to timeoutSeconds or decisionsRequestContext do not trigger a fetch, but their latest values are used by the next automatic or manual fetch. allowedComponents is captured on mount.

timeoutSeconds must be positive. Loading defaults to no UI, and failures such as native errors, timeouts, empty allowed components, unmatched component names, or component validation errors call fallback(error) when supplied; otherwise the zone renders nothing. Errors thrown by a component's compose function or rendered React subtree are not converted to fallback state.

Preview Mode

Forward preview deep links to the SDK so mounted zones affected by the changed per-zone preview receipt refetch:

import { useEffect } from 'react'
import { Linking } from 'react-native'
import { PersonalizationModule } from '@salesforce-personalization/react-native-personalization'

useEffect(() => {
  const handleUrl = async ({ url }: { url: string }) => {
    if (!url.includes('sfp-preview')) return
    try {
      await PersonalizationModule.handlePreviewUrl(url)
    } catch (error) {
      console.error('Failed to handle preview URL', error)
    }
  }
  Linking.getInitialURL()
    .then((url) => url && handleUrl({ url }))
    .catch((error) => console.error('Failed to read initial URL', error))
  const sub = Linking.addEventListener('url', handleUrl)
  return () => sub.remove()
}, [])

Deep-link setup is covered in docs/iOS.md and docs/Android.md.

Out-of-the-Box Components

Two ready-to-use components render production content with automatic View/Click engagement tracking. Both accept an optional onTap callback and a style config.

SalesforceBanner requires non-blank header and imageUrl values. SalesforceRecommendations requires a non-empty items array; each retained item needs non-blank id, name, and imageUrl. Invalid recommendation items are logged and skipped, but the component fails if none remain. A non-string model-level ctaText is logged and ignored.

Filtering is display resilience only. Engagement payload indices are not remapped after invalid items are removed, so per-item View/Click attribution can be incorrect when filtering occurs. Production responses should contain only valid items in the original serving order.

Cards are actionable only when an onTap callback or URL exists. On tap, the OOTB component attempts Click tracking first, then calls onTap; when onTap is present it takes precedence over opening ctaUrl/item.url. Banner defaults include image fraction 0.3, content padding 12, white background, and max image size 150. Recommendations default to one column, except landscape phones use two; tablets remain one column. Their defaults include card spacing 12, horizontal card content padding 20 with vertical padding 0, white background, and max image size 150.

SalesforceBanner

A single banner with image, title, subtitle, and CTA. Backed by the Salesforce_Banner transformer.

SalesforceBanner({
  onTap: (event) => console.log('Banner tapped:', event.header),
  style: {
    backgroundColor: '#f0f0f0',
    headerTextColor: '#1a1a1a',
    ctaTextColor: '#0066cc',
  },
})

The tap event is the full SalesforceBannerModel: { id?, header, subheader?, imageUrl, ctaText?, ctaUrl? }.

SalesforceRecommendations

A scrollable list of recommendation cards. Backed by the Salesforce_Recommendations transformer.

SalesforceRecommendations({
  onTap: (event) =>
    console.log('Item tapped:', event.item.name, 'at index:', event.index),
  style: {
    cardSpacing: 16,
    card: { nameTextColor: '#1a1a1a', descriptionTextColor: '#666' },
  },
})

The model is shared ctaText plus a list of items:

type SalesforceRecommendationItem = {
  id: string
  name: string
  description?: string
  imageUrl: string
  url?: string
}

interface SalesforceRecommendationsModel {
  sectionHeader?: string
  ctaText?: string // shared by all items
  items: SalesforceRecommendationItem[]
}

The tap event is { item: SalesforceRecommendationItem, index: number }. See docs/GETTING_STARTED.md for the full style reference.

Engagement Tracking

The OOTB SalesforceBanner and SalesforceRecommendations components automatically attempt View and Click tracking when the serving includes matching engagement payloads. Both PRODUCTION and PREVIEW content can carry these payloads; MOCK content (via MockContentZone) never does.

For Recommendations, automatic per-item attribution assumes the rendered item order still aligns with the server-provided engagement payload order.

Custom components opt in via the engagementPayloads the SDK delivers on ComponentContext. There are exactly four public runtime engagement APIs:

  • trackEngagement(context, action) — the un-deduped tracker for clicks and other repeatable signals on a single element. Call it on every tap.
  • trackEngagementPerItem(context, index, action) — the un-deduped tracker for clicks and other repeatable signals on a list item. Call it on every tap.
  • useTrackEngagementViewOnce(context) — a hook for view-appearance tracking (View) on a single-element component (e.g. a Banner or a custom card). Makes at most one tracking attempt per serving, from the component's own mount.
  • useTrackEngagementViewOncePerItem(context) — a hook for view-appearance tracking on a flat-index virtualized list (FlatList/VirtualizedList). Returns a handler for the list's onViewableItemsChanged; each item makes at most one View attempt per serving, from RN-reported viewability. (SectionList is not supported directly — its per-section index would collide across sections; see the hook's API docs.)

Both View hooks are convenience layers over the same once-per-serving dedup: they key the guarantee on the per-serving ComponentContext identity (not on personalizationId), and they own the correct wiring so you don't have to.

ComponentContext fields and engagement data are recursively readonly in TypeScript, providing compile-time guidance against accidental mutation while keeping the public type structural. Always pass the SDK-provided context unchanged; spreading or copying it creates a new object identity and resets the WeakMap-based View dedup scope.

React Native uses separate hooks for single elements and virtualized lists because FlatList can mount rows before they enter the viewport, so "mounted" is not "seen." Use the mount-based useTrackEngagementViewOnce for a single always-present element and the visibility-based useTrackEngagementViewOncePerItem for a list.

import {
  EngagementAction,
  trackEngagement,
  useTrackEngagementViewOnce,
} from '@salesforce-personalization/react-native-personalization'

// Single-element custom component. The hook wires the once-per-serving View
// (effect + [context] dependency) for you.
useTrackEngagementViewOnce(context)

const handlePress = () => {
  // repeat clicks are meaningful — never deduped
  trackEngagement(context, EngagementAction.click)
}

Single element vs. list — pick the matching hook. The difference is the trigger:

  • useTrackEngagementViewOnce fires from the component's mount, which is correct for one always-present element like a Banner.
  • useTrackEngagementViewOncePerItem fires from RN-reported viewability. This matters for lists: a FlatList mounts rows in a render buffer ahead of the viewport (windowSize, initialNumToRender), so a mount-based approach would count Views for off-screen rows that may never be seen. The list hook keys off RN's onViewableItemsChanged reports instead. Pair it with a viewabilityConfig that defines "viewed":
import { useTrackEngagementViewOncePerItem } from '@salesforce-personalization/react-native-personalization'

function MyRecs({ context, items }) {
  const onViewableItemsChanged = useTrackEngagementViewOncePerItem(context)
  return (
    <FlatList
      data={items}
      renderItem={renderItem}
      onViewableItemsChanged={onViewableItemsChanged}
      viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
    />
  )
}

Already have your own onViewableItemsChanged? The hook composes — call the returned handler from inside yours:

const trackViews = useTrackEngagementViewOncePerItem(context)
const onViewableItemsChanged = useCallback(
  (info) => {
    trackViews(info)
    // ...your own logic (analytics, autoplay, …)
  },
  [trackViews]
)

All four APIs are safe no-ops when tracking is not possible: a missing or wrong-shape engagementPayloads logs at debug (expected for MOCK content or contexts without payloads), while an action not found on a correctly-shaped payload logs at warn (an actionable misconfiguration). They never throw, and they do the payload lookup for you, so custom components never inspect engagementPayloads directly.

Action names are server-defined and matched case-insensitively. The exported EngagementAction constant provides the canonical names for the common cases — EngagementAction.view ('View'), EngagementAction.click ('Click'), and EngagementAction.dismiss ('Dismiss') — with string values byte-identical to the native iOS/Android SDKs. Using it is optional: every engagement API still accepts any custom action string, so both View hooks also take a custom override (e.g. useTrackEngagementViewOnce(context, 'Impression')).

Why identity-keyed dedup matters: View is attempted at most once per serving even when the same element reports becoming visible more than once within it — e.g. a SalesforceRecommendations card that scrolls out of view and back in reports one View attempt, and an in-place re-render does not re-attempt. A refresh yields a new ComponentContext, which is a new serving, so both hooks make a new tracking attempt: the mount-based useTrackEngagementViewOnce runs from its [context] effect, and useTrackEngagementViewOncePerItem replays the last set of items RN reported as viewable through the new serving's limiter — so previously reported rows are attempted even when an identical, unscrolled refresh emits no new viewability event. Because that set comes from before the refresh, if the viewport changes without RN re-reporting viewability, still-valid but now off-screen indices may each report one View attempt for the new serving. The hooks use context identity, not personalizationId, as the dedup key. See the Custom Components Guide for a full worked example.

Identity

import { PersonalizationModule } from '@salesforce-personalization/react-native-personalization'

// Profile ID (contact key)
await PersonalizationModule.setProfileId('user-123')

// Attributes — all values must be strings
await PersonalizationModule.setAttribute('tier', 'gold')
await PersonalizationModule.setAttributes({
  firstName: 'John',
  lastName: 'Doe',
})

// Read
const profileId = await PersonalizationModule.getProfileId()
const attributes = await PersonalizationModule.getAttributes()

// Clear attributes only; profile ID and party identification are unchanged
await PersonalizationModule.clearAttribute('tier')
await PersonalizationModule.clearAllAttributes()

// Party identification — per-field setters / getters
await PersonalizationModule.setPartyIdentificationName('John Doe')
await PersonalizationModule.setPartyIdentificationNumber('12345')
await PersonalizationModule.setPartyIdentificationType('individual')
const partyName = await PersonalizationModule.getPartyIdentificationName()
const partyNumber = await PersonalizationModule.getPartyIdentificationNumber()
const partyType = await PersonalizationModule.getPartyIdentificationType()

Events

All tracking flows through PersonalizationModule.track(event). Pick the objType that matches the customer journey. The later calls omit try/catch for brevity; handle their rejected Promises at the same application boundary as the first example.

// Custom — free-form name + attributes
try {
  await PersonalizationModule.track({
    objType: 'CustomEvent',
    name: 'product_viewed',
    attributes: { productId: 'ABC123', category: 'shoes' },
  })
} catch (error) {
  console.error('Event tracking failed', error)
}

// Cart — subtype: 'add' | 'remove' use a singular `lineItem`;
//        subtype: 'replace' uses a `lineItems` array
await PersonalizationModule.track({
  objType: 'CartEvent',
  subtype: 'add',
  lineItem: {
    catalogObjectType: 'product',
    catalogObjectId: 'ABC123',
    quantity: 2,
    price: 49.99,
    currency: 'USD',
  },
})

// Order — subtype: 'purchase' | 'preorder' | 'cancel' | 'ship' | 'deliver' | 'return' | 'exchange'
await PersonalizationModule.track({
  objType: 'OrderEvent',
  subtype: 'purchase',
  order: {
    id: 'ORDER-123',
    lineItems: [
      {
        catalogObjectType: 'product',
        catalogObjectId: 'ABC123',
        quantity: 2,
        price: 49.99,
        currency: 'USD',
      },
    ],
    totalValue: 99.98,
    currency: 'USD',
  },
})

// Catalog — subtype: 'view' | 'viewDetail' | 'quickView' | 'favorite' | 'share' | 'review' | 'comment'
await PersonalizationModule.track({
  objType: 'CatalogEvent',
  subtype: 'view',
  catalogObject: {
    type: 'product',
    id: 'ABC123',
    attributes: { name: 'Blue Sneakers' },
  },
})

See docs/GETTING_STARTED.md for the full event reference.

Consent

isConsentOptIn() returns true only when native consent is OPT_IN; explicit OPT_OUT and consent that has not yet been set both return false. Set consent according to the native CDP SDK's policy before tracking or requesting personalized content. This wrapper forwards consent and data operations without adding a separate JavaScript consent gate.

await PersonalizationModule.setConsent(true) // OPT_IN
await PersonalizationModule.setConsent(false) // OPT_OUT
const isOptedIn = await PersonalizationModule.isConsentOptIn()

Custom Components

Render personalized content however you want by implementing Component<T>. Two patterns:

  1. Reuse an OOTB transformer (e.g. Salesforce_Banner, Salesforce_Recommendations) with your own UI — your UI explicitly wires the View/Click helpers and uses engagement payloads when the serving supplies them. No backend configuration is needed.
  2. Define a fully custom transformer with your own fields — you implement engagement tracking manually.
import {
  EngagementAction,
  trackEngagement,
  useTrackEngagementViewOnce,
} from '@salesforce-personalization/react-native-personalization'
import type {
  Component,
  ComponentModel,
  ComponentContext,
} from '@salesforce-personalization/react-native-personalization'
import { Image, Text, TouchableOpacity } from 'react-native'

interface ProductCardModel extends ComponentModel {
  header: string
  imageUrl: string
  ctaText?: string
}

export function ProductCard(config?: {
  onTap?: (m: ProductCardModel) => void
}): Component<ProductCardModel> {
  return {
    name: 'Salesforce_Banner', // reuse the SalesforceBanner transformer's data + engagement payloads
    validateAndCreateComponentModel: (json, context) => {
      const data = JSON.parse(json)
      if (!data.header || !data.imageUrl)
        throw new Error('Missing required fields')
      return data as ProductCardModel
    },
    compose: (model, context) => (
      <ProductCardView model={model} context={context} onTap={config?.onTap} />
    ),
  }
}

function ProductCardView({
  model,
  context,
  onTap,
}: {
  model: ProductCardModel
  context: ComponentContext
  onTap?: (model: ProductCardModel) => void
}) {
  useTrackEngagementViewOnce(context)

  const handlePress = () => {
    trackEngagement(context, EngagementAction.click)
    onTap?.(model)
  }

  return (
    <TouchableOpacity onPress={handlePress}>
      <Image
        source={{ uri: model.imageUrl }}
        style={{ width: '100%', height: 200 }}
      />
      <Text>{model.header}</Text>
    </TouchableOpacity>
  )
}

ComponentContext carries:

  • contentSource: 'PRODUCTION' | 'PREVIEW' | 'MOCK'
  • personalizationId: identifier supplied for the served personalization; context identity is the JavaScript serving boundary
  • engagementPayloads: tracking data supplied by OOTB transformers
  • componentName (optional): resolved component name, used to tag engagement diagnostic logs

For View, use the hook that matches your component shape — useTrackEngagementViewOnce(context) for a single element, useTrackEngagementViewOncePerItem(context) for a flat-index virtualized list (FlatList/VirtualizedList). Both dedup on the context's own object identity, so a repeated view-appearance signal within a serving makes one View attempt. On a refresh's new ComponentContext (a new serving), both hooks make a new tracking attempt: the mount hook from its [context] effect, and the scroll hook by replaying the last-viewable item set through the new serving's limiter (so previously reported rows are attempted even when an identical, unscrolled refresh emits no new viewability event). Because the replay uses the pre-refresh set, a viewport change without a new RN visibility report may cause still-valid but now off-screen indices to each report one View for the new serving.

📖 See the Custom Components Guide for complete examples with engagement tracking, including SalesforceRecommendations-shaped list payloads.

Architecture Compatibility

This plugin uses TurboModules. React Native 0.76 through 0.81 supports both architectures, with the New Architecture enabled by default; the plugin also works when those versions explicitly opt out. React Native 0.82 and later are New-Architecture-only and ignore Legacy opt-out flags. Your JS/TS code needs no changes either way: autolinking runs Codegen from the plugin's codegenConfig.

For React Native 0.76 through 0.81 only, use the version's documented opt-out settings if you must run the Legacy Architecture. Otherwise keep the generated template defaults. Clean native build artifacts after changing architecture settings.

Either way, PersonalizationModule, ContentZone, and every other exported API behave identically. See TROUBLESHOOTING.md if you hit a Codegen or CMake linking error after switching architectures.

More

License

BSD-3-Clause License — see LICENSE for details.

Support

About

No description, website, or topics provided.

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages