npm.io
0.9.0 • Published yesterday

@pnlight/sdk-react-native

Licence
Commercial
Version
0.9.0
Deps
0
Size
2.6 MB
Vulns
0
Weekly
0

PNLight SDK - React Native Package

npm package

A React Native wrapper for the PNLight iOS SDK.

Installation

Install the package in your React Native or Expo app:

npm install @pnlight/sdk-react-native

or:

yarn add @pnlight/sdk-react-native

Then install iOS pods:

cd ios
pod install

Requirements

  • iOS 15.0+
  • React Native app with CocoaPods
  • Swift 5.7+

This package is iOS-only. Android is not supported.


iOS Setup

Native dependencies

The package's podspec installs DivKit, its extension module, and Lottie, and links the Apple frameworks PNLight needs. No DivKit source, Lottie registration, navigation controller, haptic handler, or other Remote UI setup is required in the host app. After installing the package, run:

cd ios
pod install

For apps that request IDFA tracking, add NSUserTrackingUsageDescription to Info.plist:

<key>NSUserTrackingUsageDescription</key>
<string>This app uses device tracking to provide analytics and improve user experience.</string>

Usage

Initialization

Initialize PNLight before using analytics, attribution, or Remote UI:

import { initialize } from "@pnlight/sdk-react-native";

await initialize("your-api-key");
Event Logging
import { logEvent } from "@pnlight/sdk-react-native";

await logEvent("purchase_completed", {
  product_id: "premium_subscription",
  amount: 9.99,
  currency: "USD",
});
Attribution

Send attribution data from external providers before requesting UI config.

import { addAttribution } from "@pnlight/sdk-react-native";

const success = await addAttribution(
  "appsFlyer",
  { af_status: "Non-organic" },
  "your-appsflyer-id",
);
AppsFlyer Integration Example

PNLight does not depend on the AppsFlyer initialization order — it only needs the conversion data, delivered via addAttribution. Make sure the conversion ("attribution success") callback is not processed before PNLight is initialized: if it can fire earlier, store the conversion data in memory and call addAttribution once initialize completes.

AppsFlyer requires the ATT prompt to complete before it starts. If PNLight is initialized before ATT authorization, call updateIdfa() after the prompt completes so PNLight receives the granted IDFA.

import appsFlyer from "react-native-appsflyer";
import { requestTrackingPermission } from "react-native-tracking-transparency";
import {
  addAttribution,
  initialize,
  updateIdfa,
} from "@pnlight/sdk-react-native";

const getAppsFlyerUID = () =>
  new Promise<string | null>((resolve) => {
    appsFlyer.getAppsFlyerUID((error, uid) => {
      if (error) {
        console.error("AppsFlyer UID error", error);
        resolve(null);
        return;
      }

      resolve(uid);
    });
  });

export async function startSdks() {
  await initialize("your-api-key");

  // Request ATT, then pass the granted IDFA to PNLight.
  await requestTrackingPermission();
  await updateIdfa();

  // Start AppsFlyer after ATT completes (an AppsFlyer requirement).
  appsFlyer.onInstallConversionData(async (conversionData) => {
    const appsFlyerId = await getAppsFlyerUID();

    await addAttribution(
      "appsFlyer",
      conversionData?.data ?? conversionData,
      appsFlyerId,
    );
  });

  appsFlyer.initSdk(
    {
      devKey: "your-appsflyer-dev-key",
      appId: "your-ios-app-id",
      isDebug: false,
      onInstallConversionDataListener: true,
    },
    (result) => {
      console.log("AppsFlyer initialized", result);
    },
    (error) => {
      console.error("AppsFlyer init error", error);
    },
  );
}

Supported providers:

  • appsFlyer
  • firebase
  • facebook
User Identity
import { getUserId } from "@pnlight/sdk-react-native";

const userId = await getUserId();
In-App Purchases

PNLight wraps StoreKit 2 for fetching products (price, offers, trial info), purchasing, restoring, and checking entitlements. The product ids are configured on the backend — fetchProducts resolves them against the App Store.

import {
  fetchProducts,
  purchase,
  restorePurchases,
  isPremium,
  isEligibleForTrial,
} from "@pnlight/sdk-react-native";

// Load the configured products with their App Store price/offer info.
const products = await fetchProducts();
for (const product of products) {
  console.log(`${product.displayName}: ${product.displayPrice}`);

  const offer = product.subscription?.introductoryOffer;
  if (offer && product.subscription?.isEligibleForIntroOffer) {
    // e.g. pay-as-you-go: "$0.99/month for 6 months"
    console.log(
      `Offer: ${offer.displayPrice} (${offer.paymentMode}, ` +
        `${offer.periodCount} × ${offer.period.value} ${offer.period.unit})`,
    );
  }
}

// Purchase.
const result = await purchase("your.product.id");
if (result === "success") {
  // Unlock content.
}

// Entitlement checks (local StoreKit entitlements, work offline).
const premium = await isPremium();
const eligible = await isEligibleForTrial("your.product.id");

// Restore previous purchases.
await restorePurchases();

RemoteUiView - Server-driven UI

RemoteUiView fetches and renders a server-driven layout from PNLight for a given placement. It calls getUIConfig(placement) internally, renders the native view, and emits action events to JavaScript.

When using external attribution providers such as AppsFlyer, send attribution as early as possible (see the AppsFlyer example above). getUIConfig waits for attribution data internally when attributionRequired is true (the default), so no manual delay is needed.

import React from "react";
import { StyleSheet, View } from "react-native";
import { RemoteUiView, type ActionEvent } from "@pnlight/sdk-react-native";

export function PaywallScreen() {
  const handleAction = (event: ActionEvent) => {
    if (event.logId === "purchase_button") {
      const productId = event.params?.id;
      // Start purchase flow for productId
    } else if (event.logId === "close_button") {
      // Close the screen
    }
  };

  return (
    <View style={styles.root}>
      <RemoteUiView
        placement="paywall"
        onAction={handleAction}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  root: {
    flex: 1,
  },
});
Remote UI schema version

PNLight versions the Remote UI envelope independently from the npm package. Declare "schemaVersion": 2 at the document root to enable PNLight safe-area handling, linear-scaling variables, server-driven flows, native haptics, and native dialogs:

{
  "schemaVersion": 2,
  "card": {}
}

A missing schemaVersion (or an explicit value of 1) is legacy v1. Existing backend configs therefore keep their previous edge-to-edge DivKit behavior when an app upgrades the package. They do not receive v2 scaling variables or opt into PNLight-owned navigation, haptic, and dialog action handling. New configs that use any feature below should declare "schemaVersion": 2.

Safe areas

PNLight keeps Remote UI content clear of the Dynamic Island, status bar, home indicator, and landscape sensor housing without host configuration in schema v2. V2 documents default to inset mode on all four edges.

Mode Behavior
inset Default. Places the complete DivKit document inside the selected safe-area edges.
content Keeps the document edge-to-edge and exposes the selected insets through DivKit variables.
edge_to_edge Keeps the document edge-to-edge and resolves the safe-area variables to zero.

Use content for a full-bleed background whose inner content respects system UI:

{
  "schemaVersion": 2,
  "safe_area": {
    "mode": "content",
    "edges": ["top", "bottom"]
  },
  "card": {
    "log_id": "full_bleed_offer",
    "states": [
      {
        "state_id": 0,
        "div": {
          "type": "container",
          "width": { "type": "match_parent" },
          "height": { "type": "match_parent" },
          "paddings": {
            "top": "@{safe_area_top + 24}",
            "bottom": "@{safe_area_bottom + 24}",
            "left": 24,
            "right": 24
          },
          "items": []
        }
      }
    ]
  }
}

The variables are safe_area_top, safe_area_bottom, safe_area_left, and safe_area_right. They update automatically after rotation, resizing, and sheet presentation. A flow may declare a default safe_area beside routes; an individual route may override it beside that route's divkit document.

Linear scaling variables

Remote UI JSON may declare a logical design viewport:

{
  "schemaVersion": 2,
  "referenceSize": { "width": 390, "height": 844 },
  "card": {}
}

PNLight exposes scaleX = availableWidth / referenceSize.width and scaleY = availableHeight / referenceSize.height as live numeric DivKit variables. Use them directly in expressions, for example "left": "@{24 * scaleX}". They update for rotation, host-view resize, and native sheet-size changes. In inset safe-area mode, the available viewport is measured after the selected system insets are removed.

The ratios are raw, not automatically clamped, and PNLight does not scale the layout unless markup references them. For a uniform mobile design scale, prefer scaleX and apply your chosen bounds in markup. Never multiply system safe-area or keyboard insets by these values. Without referenceSize, both variables equal 1 in schema v2.

Flows may define referenceSize beside routes; individual routes may override it beside divkit or inside their complete divkit document. No React Native configuration is required.

Server-driven native flows

RemoteUiView accepts a PNLight flow envelope without changing the React Native API. The initial route is embedded in the React Native view; the shared iOS renderer owns subsequent native navigation and modal presentation.

{
  "schemaVersion": 2,
  "type": "flow",
  "initial_route": "welcome",
  "routes": {
    "welcome": {
      "divkit": {
        "templates": {},
        "card": {
          "log_id": "welcome",
          "states": [
            {
              "state_id": 0,
              "div": {
                "type": "custom",
                "custom_type": "pnlight.cta_button",
                "width": { "type": "match_parent" },
                "height": { "type": "fixed", "value": 56 },
                "custom_props": {
                  "title": "Open offer",
                  "url": "pnlight://navigation/present?route=offer"
                }
              }
            }
          ]
        }
      }
    },
    "offer": {
      "presentation": {
        "style": "sheet",
        "detent": "large",
        "grabber": true,
        "dismissible": true,
        "corner_radius": 28
      },
      "divkit": {
        "templates": {},
        "card": {
          "log_id": "offer",
          "states": [
            {
              "state_id": 0,
              "div": {
                "type": "custom",
                "custom_type": "pnlight.cta_button",
                "width": { "type": "match_parent" },
                "height": { "type": "fixed", "value": 56 },
                "custom_props": {
                  "title": "Dismiss",
                  "url": "pnlight://navigation/dismiss"
                }
              }
            }
          ]
        }
      }
    }
  }
}

Each route contains a complete DivKit document under divkit. The renderer prepares declared routes and consumes these URLs before they reach onAction:

URL Native behavior
pnlight://navigation/push?route=details Pushes onto the active private navigation stack.
pnlight://navigation/pop Pops the active stack.
pnlight://navigation/replace?route=details Replaces the active route.
pnlight://navigation/pop_to_root Pops to the first route.
pnlight://navigation/present?route=offer Presents a new native modal navigation context over the app.
pnlight://navigation/dismiss Dismisses the current PNLight modal context.

Presented routes can push their own routes without losing the embedded stack's DivKit variables or scroll state. presentation.style accepts "sheet" (the default) or "full_screen". Native sheets support detent ("large" or "medium" with expansion to large), grabber, dismissible, and corner_radius. A present URL may override these fields, for example:

pnlight://navigation/present?route=offer&detent=medium&grabber=false

Navigation URLs in a legacy single-card document continue to reach onAction; PNLight consumes them only inside a valid flow.

Native alerts and action sheets

Put dialogs at the Remote UI document root (beside card, or beside routes in a flow) and trigger one with pnlight://dialog/show?id=<dialog-id>. The package presents a native iOS UIAlertController; the React Native app needs no modal state or extra setup.

{
  "schemaVersion": 2,
  "dialogs": {
    "delete_confirmation": {
      "style": "action_sheet",
      "title": "Delete item?",
      "message": "This cannot be undone.",
      "buttons": [
        { "title": "Cancel", "style": "cancel", "actions": [] },
        {
          "title": "Delete",
          "style": "destructive",
          "actions": [
            {
              "log_id": "delete_confirmed",
              "url": "my-app://delete?id=42"
            }
          ]
        }
      ]
    }
  },
  "card": {}
}

Dialog style is alert or action_sheet; button style is default, cancel, or destructive. Every button owns an ordered array of normal DivKit actions. They run through the current card's DivKit handler, including typed variable/state actions, PNLight navigation and haptics, analytics, and custom URLs delivered through onAction. Empty arrays are dismiss-only.

Native haptics

PNLight consumes pnlight://haptic/... actions before they reach onAction. In schema v2, simple feedback works in both single-card documents and flow routes:

URL Native behavior
pnlight://haptic/impact?style=light UIKit impact feedback. Styles: light, medium, heavy, soft, or rigid; optional intensity is clamped to 0...1.
pnlight://haptic/selection UIKit selection feedback.
pnlight://haptic/notification?type=success UIKit notification feedback. Types: success, warning, or error.

Flows can also declare named Core Haptics patterns:

{
  "schemaVersion": 2,
  "type": "flow",
  "initial_route": "scan",
  "haptics": {
    "scanning": {
      "events": [
        {
          "type": "continuous",
          "time": 0,
          "duration": 0.8,
          "intensity": 0.25,
          "sharpness": 0.35
        },
        {
          "type": "transient",
          "time": 0.4,
          "intensity": 0.65,
          "sharpness": 0.55
        }
      ],
      "loop": true,
      "max_duration": 120
    }
  },
  "routes": {
    "scan": {
      "divkit": {
        "templates": {},
        "card": {
          "log_id": "scan",
          "states": [
            {
              "state_id": 0,
              "div": {
                "type": "text",
                "text": "Scan",
                "width": { "type": "match_parent" },
                "height": { "type": "wrap_content" }
              }
            }
          ]
        }
      }
    }
  }
}

Start a named pattern with pnlight://haptic/start?pattern=scanning and stop it with pnlight://haptic/stop?pattern=scanning. Calling stop without a pattern stops every active player. Patterns accept 1–128 transient or continuous events. Time values are seconds; time_ms, duration_ms, and max_duration_ms are also accepted. Core Haptics safely does nothing on unsupported hardware, and PNLight stops active players when their route leaves the window or the app enters the background.

Lottie animations

The package installs Lottie and configures DivKit's standard lottie extension. The host app does not install Lottie separately or register a renderer. Add the extension to a fixed-size DivKit element:

{
  "type": "container",
  "width": { "type": "fixed", "value": 200 },
  "height": { "type": "fixed", "value": 200 },
  "items": [],
  "extensions": [
    {
      "id": "lottie",
      "params": {
        "lottie_url": "https://cdn.example.com/animation.json",
        "repeat_count": 0,
        "repeat_mode": "restart",
        "is_playing": true
      }
    }
  ]
}

lottie_json may replace lottie_url with an inline decoded Lottie document. repeat_mode accepts "restart" (the default) or "reverse"; repeat_count: 0 repeats indefinitely; is_playing defaults to true. Remote URLs should use HTTPS. Uncompressed Lottie JSON is supported inline or remotely; .lottie ZIP archives are not.

Native iOS components

PNLight's custom Remote UI components are native UIKit views controlled by DivKit markup. React Native does not recreate them in JavaScript: on iOS, RemoteUiView recognizes their custom_type, renders the native component, and forwards interactive actions through the same onAction callback shown above.

The currently supported custom types are:

  • pnlight.circular_loader
  • pnlight.cta_button
  • pnlight.icon_button

These components are iOS-only.

Circular loader

Use DivKit's custom element to render a native UIActivityIndicatorView inside Remote UI markup:

{
  "type": "custom",
  "custom_type": "pnlight.circular_loader",
  "width": { "type": "fixed", "value": 48 },
  "height": { "type": "fixed", "value": 48 },
  "custom_props": {
    "style": "large",
    "color": "#FF007AFF",
    "accessibility_label": "Loading"
  }
}

style accepts "medium" (the default) or "large". color accepts #RRGGBB or DivKit-style #AARRGGBB and defaults to the adaptive iOS label color. accessibility_label defaults to "Loading". Standard DivKit width and height fields control the element's layout; set a dimension to { "type": "wrap_content" } to use the native indicator's intrinsic size for that dimension.

CTA button

Render a native, animated call-to-action button with a repeating shimmer streak, an idle attention pulse, and a spring press bounce. Taps are routed through the same action pipeline as DivKit buttons, so onAction receives the payload decoded from url.

{
  "type": "custom",
  "custom_type": "pnlight.cta_button",
  "width": { "type": "match_parent" },
  "height": { "type": "fixed", "value": 58 },
  "custom_props": {
    "title": "Remove threats now",
    "background_color": "#FF007AFF",
    "title_color": "#FFFFFFFF",
    "corner_radius": 16,
    "font_size": 19,
    "font_weight": "bold",
    "shimmer": true,
    "bounce": true,
    "url": "pnlight://cta?id=remove_threats"
  }
}

All props are optional; only title and url are usually needed. Colors accept #RRGGBB or DivKit-style #AARRGGBB.

Prop Default Description
title "" Button label.
background_color #FF007AFF Fill color (also the gradient start).
background_color_end When set, the fill is a horizontal gradient to this color.
glass off (on for icon buttons) Native Liquid Glass fill on iOS 26+. See below.
title_color #FFFFFFFF Label color.
corner_radius 14 Corner radius in points (continuous curve).
font_size 18 Label point size.
font_weight semibold regular/medium/semibold/bold/heavy/black.
horizontal_padding / vertical_padding 24 / 16 Used to size the button when width/height is wrap_content.
icon SF Symbol name, e.g. "shield.lefthalf.filled". Renders before the title.
icon_size 20 Symbol point size.
icon_weight semibold ultralightblack.
icon_color title_color Symbol tint.
icon_spacing 8 Gap between icon and title.
loading false Swaps the title for a native spinner and ignores taps.
disabled false Dims the button and ignores taps.
disabled_alpha 0.45 Opacity used while disabled.
disabled_background_color Replaces the fill (and any gradient) while disabled.
disabled_title_color Replaces the label color while disabled.
loading_indicator_color title_color Spinner color.
loading_indicator_style medium medium or large.
url Action fired on tap (custom scheme → onAction; http(s) → opened).
log_id Emitted as the action's logId when there is no url.
accessibility_label title VoiceOver label (defaults to "Loading" while loading).

Both loading and disabled make the button inert: taps are ignored and the shimmer and bounce animations stop, so an in-flight CTA sits still.

shimmer and bounce also accept configuration objects. The shimmer object supports enabled, color, duration, pause, band_width, and angle; the bounce object supports idle (enabled, scale, period) and press (enabled, scale). Props may contain DivKit expressions, so values such as loading can be bound to card variables.

On iOS 26+, glass enables native Liquid Glass. It accepts true or an object with enabled, style ("regular" or "clear"), tint, and prominent. prominent selects the filled primary-action treatment. It falls back to the solid fill on earlier iOS versions or when Reduce Transparency is enabled.

For a custom-scheme URL such as pnlight://cta?id=remove_threats, event.params?.id is "remove_threats" in React Native's onAction callback.

Icon button

pnlight.icon_button is the same native button tuned for a small circular control with an SF Symbol—ideal for close, settings, or favorite affordances.

{
  "type": "custom",
  "custom_type": "pnlight.icon_button",
  "width": { "type": "fixed", "value": 44 },
  "height": { "type": "fixed", "value": 44 },
  "custom_props": {
    "icon": "xmark",
    "accessibility_label": "Close",
    "url": "pnlight://cta?id=close"
  }
}

It accepts every pnlight.cta_button prop above, including loading, disabled, shimmer, bounce, and expression binding. Only the defaults differ:

Prop CTA default Icon default
shape corner_radius: 14 fully circular
background_color #FF007AFF secondarySystemFill (adaptive)
glass off on when no background_color is set
icon/title color white label (adaptive)
horizontal_padding / vertical_padding 24 / 16 12 / 12
shimmer on off
bounce.idle on off
bounce.press on on

Setting corner_radius opts out of the circular shape, giving a rounded square. With wrap_content on both axes, the button sizes itself from the symbol plus padding and stays square.

For accessibility, set accessibility_label on icon-only buttons. It falls back to the SF Symbol name, which is rarely what you want VoiceOver to read. An unknown symbol name is reported in the card's DivKit errors rather than silently rendering an empty button.

Manual Config Fetching

Use getUIConfig if you need to fetch the placement configuration yourself. When attributionRequired is true (the default), the SDK waits for attribution data internally before returning:

import { getUIConfig } from "@pnlight/sdk-react-native";

const config = await getUIConfig("paywall");
const configWithoutAttributionWait = await getUIConfig("paywall", false);

API Reference

SDK Methods
Method Description
initialize(apiKey, baseDomain?) Initialize the SDK with your API key and optional base domain
logEvent(eventName, eventArgs?) Log a custom event with optional arguments
addAttribution(provider, data?, identifier?) Send attribution data from AppsFlyer, Firebase, or Facebook
getUserId() Get or create a stable user identifier
updateIdfa() Send the current IDFA to PNLight after the ATT prompt completes
prefetchUIConfig(placement) Prefetch a UI config into the in-memory cache
getUIConfig(placement, attributionRequired?) Fetch a UI config; waits for attribution by default
fetchProducts() Load configured products with App Store price/offer/trial info
purchase(productId) Purchase a product; resolves a PNLightPurchaseResult
restorePurchases() Restore previous purchases by syncing with the App Store
isPremium() Whether the user has an active entitlement to any configured product
isPurchased(productId) Whether the user has an active entitlement to a specific product
isEligibleForTrial(productId) Whether the user is eligible for a product's introductory offer
getAppleReceipt() Base64 App Store receipt for server-side validation, or null if absent
RemoteUiView
Prop Type Description
placement string PNLight placement identifier
style StyleProp<ViewStyle> Optional React Native style
secure boolean Deprecated. Secure rendering is controlled by the backend response.
preventRecording boolean Deprecated. Capture blocking is controlled by the backend.
attributionRequired boolean Wait for attribution before returning config; defaults to true
onAction (event: ActionEvent) => void Called when a custom action is triggered
ActionEvent
Property Type Description
url string Full URL string of the triggered action
scheme string URL scheme
path string URL path component
params Record<string, string> Query parameters extracted from the URL
logId string | undefined Log ID for the triggered action
action string | undefined Raw action value when provided by the native view
UIConfig
Property Type Description
config string | null Remote UI JSON config
parameters Record<string, any> | null Placement parameters returned by PNLight
debug boolean | null | undefined Debug flag returned by PNLight

Support

For support and questions, visit docs.pnlight.app.

Keywords