diff --git a/.cursor/rules/development-workflow.mdc b/.cursor/rules/development-workflow.mdc
new file mode 100644
index 0000000..23396fe
--- /dev/null
+++ b/.cursor/rules/development-workflow.mdc
@@ -0,0 +1,92 @@
+---
+alwaysApply: true
+---
+
+# Development Workflow — Ketch React Native SDK
+
+## Prerequisites
+
+- **Node.js** >= 18
+- **Yarn** 4.x (per `example/package.json` `packageManager`) or npm
+- **Android**: Android SDK, `adb`, emulator or device
+- **iOS** (macOS only): Xcode, CocoaPods (`pod`), iOS Simulator
+
+## Setup
+
+```bash
+git clone git@github.com:ketch-com/ketch-react-native.git
+cd ketch-react-native
+```
+
+To run the **in-repo example** (npm release or local `file:../package` — see skill):
+
+```bash
+bash .cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh android # remote (default)
+bash .cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh android local # this checkout
+bash .cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh ios local
+```
+
+## Common Commands
+
+| Command | Purpose |
+| --- | --- |
+| `cd package && npm run all` | Lint, typecheck, and build the library |
+| `cd package && npm test` | Run package unit tests |
+| `cd example && npm run start` | Start Metro bundler |
+| `cd example && npm run android` / `npm run ios` | Run example (Metro must be running) |
+| `bash .cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh android local` | Full flow: configure, Metro, build, launch, logs |
+| `cd example/ios && pod install` | Refresh iOS pods after native dependency changes |
+
+## Validation Checklist
+
+Before merging SDK changes:
+
+1. `cd package && npm run lint && npm run typecheck` — **must pass**
+2. `cd package && npm test` — **must pass**
+3. `bash .cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh android local --build-only` — example must build
+4. If iOS native code changed: `bash .cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh ios local --build-only`
+
+## Testing
+
+- **Package tests**: Jest in `package/`
+- **Integration tests**: `KETCH_INTEGRATION_TESTS=1 npm run test:integration` in `package/`
+- **Example app**: manual verification via run-sample script or `npm run android` / `npm run ios`
+
+## Code Quality
+
+- Match existing **TypeScript** style under `package/src/`
+- Keep public API exports intentional in `package/src/index.ts`
+- Prefer **small, focused diffs**; do not reformat unrelated files
+
+## SDK Health Dashboard (manual QA)
+
+`example/` wires `KetchServiceProvider` callbacks in `App.tsx` into `DashboardContext`; `Main.tsx` renders structured panels above the existing controls.
+
+| Section | What to verify |
+| --- | --- |
+| **Connection** | Init, status, org/property/env (editable), data center |
+| **WebView / Experience** | Load, visibility, dismiss, WebView visibility; `ketch_att` on iOS |
+| **ATT (iOS)** | Native ATT; **Request ATT** then **Reload WebView** |
+| **Privacy / Consent** | Environment, jurisdiction, region, consent, US Privacy / TCF / GPP |
+| **Headless** | Fetch Location / Bootstrap / Cold Start (`useKetchService()` headless APIs) |
+| **Event log** | Timestamped callback trace |
+
+Launch: `bash .cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh ios local`
+
+Example org/property: `ethansch061226` / `website_smart_tag`. See `ketch-react-native-run-sample` skill for prerequisites, launch, and smoke flow.
+
+Provider uses `autoLoad={false}` so **Load** is an explicit manual step.
+
+## Local tag URL overrides (non-prod scripts)
+
+Pass `webResourceUrlOverrides` on `KetchServiceProvider` / `KetchMobile` — exact source URL → local replacement. Separate from `dataCenter` (boot.js base).
+
+```tsx
+
+```
+
+JS hook in `package/src/assets/index.ts` runs before boot.js. Sample: `DEV_URL_OVERRIDES_ENABLED = true` in `example/devUrlOverrides.ts`.
diff --git a/.cursor/skills/ketch-react-native-run-sample/SKILL.md b/.cursor/skills/ketch-react-native-run-sample/SKILL.md
new file mode 100644
index 0000000..43cff14
--- /dev/null
+++ b/.cursor/skills/ketch-react-native-run-sample/SKILL.md
@@ -0,0 +1,75 @@
+---
+name: ketch-react-native-run-sample
+description: Configures the in-repo React Native example for either the released @ketch-com/ketch-react-native npm package or the local file:../package link, starts Metro, builds, launches on android or ios, and streams filtered logs. Use when the user runs /ketch-react-native-run-sample.
+---
+
+# ketch-react-native-run-sample
+
+## Instructions
+
+When the user invokes **`/ketch-react-native-run-sample`** (or asks to run the RN example with production / local SDK):
+
+1. `cd` to the `ketch-react-native` repository root.
+
+2. Run the helper script with a **required** platform argument:
+
+**Released package (default)** — rewrites `example/package.json` to use npm. Fetches the **latest version** via `npm view` unless `KETCH_RN_VERSION` is set.
+
+```bash
+bash .cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh android
+bash .cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh ios
+```
+
+**Local package** — uses `file:../package`:
+
+```bash
+bash .cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh android local
+bash .cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh ios local
+```
+
+The script installs dependencies, starts Metro if needed, runs `pod install` on iOS when the dependency changes, launches via `react-native run-*`, and streams logs. Stop with `Ctrl-C`.
+
+## Manual testing basics
+
+**Needs:** Node + yarn (or npm), Metro (started by script), Android emulator + `adb` or iOS Simulator + Xcode/CocoaPods, network for CDN/headless steps.
+
+**Launch:** `bash .cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh ios local` or `... android local` from the `ketch-react-native` repo root.
+
+**In the app:** **SDK Health Dashboard** is the first section in the example scroll view. Provider defaults: org `ethansch061226`, property `website_smart_tag`. Provider uses `autoLoad={false}` — tap **Load** explicitly.
+
+**Smoke flow:** **Load** → dashboard rows update → **Show Consent** → WebView/Experience rows change → (iOS) **Request ATT** then **Reload WebView** → Headless **Fetch Bootstrap**.
+
+## Overrides
+
+```bash
+KETCH_RN_VERSION=0.6.9 bash .cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh android
+DEVICE_ID=emulator-5554 bash .cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh android local
+SIMULATOR_NAME="iPhone 15 Pro" bash .cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh ios local
+```
+
+## Other options
+
+```bash
+bash .cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh android local --build-only
+bash .cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh ios --full-system-logs
+bash .cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh android --no-logs
+```
+
+## Manual QA checklist (SDK Health Dashboard)
+
+After launch, verify on-screen panels in `example/Main.tsx` (SDK Health Dashboard section):
+
+1. **Connection** — Init/status; org/property/env from editable fields; data center from API Region.
+2. **Load** — Tap **Load** → Load row `loading`; status updates; provider callbacks fill privacy rows.
+3. **WebView / Experience** — **Show Consent** → visibility/dismiss/WebView rows update via `App.tsx` callbacks.
+4. **ATT (iOS)** — **Request ATT** then **Reload WebView**; `ketch_att` row updates.
+5. **Headless** — **Fetch Location** / **Fetch Bootstrap** / **Cold Start** show inline results.
+6. **Event log** — Timestamped callback trace at bottom of dashboard section.
+
+Provider uses `autoLoad={false}` — **Load** is explicit.
+
+## Notes
+
+- Default remote mode needs **network access** for `npm view` when not pinning with `KETCH_RN_VERSION`.
+- **iOS** requires CocoaPods (`pod`) and Xcode; **Android** requires `adb`.
+- Example uses `packageManager: yarn@4.2.2`; script prefers **yarn** when available.
diff --git a/.cursor/skills/ketch-react-native-run-sample/scripts/configure-sample-package.py b/.cursor/skills/ketch-react-native-run-sample/scripts/configure-sample-package.py
new file mode 100755
index 0000000..b7ef2e3
--- /dev/null
+++ b/.cursor/skills/ketch-react-native-run-sample/scripts/configure-sample-package.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+"""Switch example/package.json between local file: dependency and npm registry release."""
+
+from __future__ import annotations
+
+import os
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+PACKAGE_NAME = "@ketch-com/ketch-react-native"
+LOCAL_SPEC = "file:../package"
+LOCAL_RE = re.compile(
+ rf'"{re.escape(PACKAGE_NAME)}":\s*"file:\.\./package"'
+)
+REMOTE_RE = re.compile(
+ rf'"{re.escape(PACKAGE_NAME)}":\s*"[\^~]?\d+\.\d+\.\d+(?:-[\w.]+)?"'
+)
+
+
+def latest_npm_version() -> str:
+ proc = subprocess.run(
+ ["npm", "view", PACKAGE_NAME, "version"],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ if proc.returncode != 0:
+ raise SystemExit(
+ f"npm view failed ({proc.returncode}) for {PACKAGE_NAME!r}:\n{proc.stderr.strip()}"
+ )
+ version = proc.stdout.strip()
+ if not version:
+ raise SystemExit(f"No version returned for {PACKAGE_NAME!r}")
+ return version
+
+
+def remote_version() -> tuple[str, str]:
+ pinned = os.environ.get("KETCH_RN_VERSION", "").strip()
+ if pinned:
+ return pinned, f"exactVersion={pinned!r}"
+ version = latest_npm_version()
+ return version, f"{version!r} (latest on npm)"
+
+
+def target_spec(mode: str) -> tuple[str, str]:
+ if mode == "local":
+ return LOCAL_SPEC, "local file:../package"
+ version, summary = remote_version()
+ return version, f"npm ({summary})"
+
+
+def replace_dependency_text(content: str, new_spec: str) -> str:
+ replacement = f'"{PACKAGE_NAME}": "{new_spec}"'
+ if LOCAL_RE.search(content):
+ return LOCAL_RE.sub(replacement, content, count=1)
+ if REMOTE_RE.search(content):
+ return REMOTE_RE.sub(replacement, content, count=1)
+ raise SystemExit(
+ f"No {PACKAGE_NAME} dependency found in package.json "
+ '(expected "file:../package" or a semver version).'
+ )
+
+
+def main() -> None:
+ if len(sys.argv) != 3:
+ raise SystemExit(
+ "Usage: configure-sample-package.py "
+ )
+
+ mode = sys.argv[1]
+ package_path = Path(sys.argv[2])
+
+ if mode not in ("local", "remote"):
+ raise SystemExit("mode must be local or remote")
+ if not package_path.is_file():
+ raise SystemExit(f"Missing package.json: {package_path}")
+
+ new_spec, summary = target_spec(mode)
+ original = package_path.read_text(encoding="utf-8")
+ updated = replace_dependency_text(original, new_spec)
+
+ if updated != original:
+ package_path.write_text(updated, encoding="utf-8")
+ print(f"Updated {package_path} to use {summary}.")
+ else:
+ print(f"No changes needed for {package_path} ({mode}).")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh b/.cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh
new file mode 100755
index 0000000..844c07d
--- /dev/null
+++ b/.cursor/skills/ketch-react-native-run-sample/scripts/run-sample-app.sh
@@ -0,0 +1,259 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'USAGE'
+Usage: run-sample-app.sh [local] [--build-only] [--no-logs] [--full-system-logs]
+
+ android|ios Required. Target platform.
+ (no local) Use the released @ketch-com/ketch-react-native from npm (latest version).
+ local Link the example to file:../package in this repo.
+
+Options:
+ --build-only Build and install without streaming logs afterward.
+ --no-logs Skip post-launch log streaming.
+ --full-system-logs Stream unfiltered logs (Android logcat / iOS process logs).
+
+Environment:
+ DEVICE_ID Android device serial for react-native run-android.
+ SIMULATOR_NAME iOS simulator name for react-native run-ios.
+ METRO_PORT Metro port (default: 8081).
+ KETCH_RN_VERSION Pin remote npm version (no registry lookup).
+
+Starts Metro when not already reachable, installs deps, runs pod install on iOS when needed.
+USAGE
+}
+
+if [[ $# -lt 1 ]]; then
+ usage >&2
+ exit 2
+fi
+
+PLATFORM="$1"
+shift
+
+case "$PLATFORM" in
+ android|ios) ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "First argument must be android or ios, got: $PLATFORM" >&2
+ usage >&2
+ exit 2
+ ;;
+esac
+
+PACKAGE_MODE="remote"
+if [[ "${1:-}" == "local" ]]; then
+ PACKAGE_MODE="local"
+ shift
+fi
+
+build_only=0
+stream_logs=1
+full_system_logs=0
+METRO_PORT="${METRO_PORT:-8081}"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --build-only)
+ build_only=1
+ shift
+ ;;
+ --no-logs)
+ stream_logs=0
+ shift
+ ;;
+ --full-system-logs)
+ full_system_logs=1
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+require_command() {
+ if ! command -v "$1" >/dev/null 2>&1; then
+ echo "Required command not found: $1" >&2
+ exit 1
+ fi
+}
+
+require_command python3
+require_command node
+require_command npm
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
+EXAMPLE_DIR="$REPO_ROOT/example"
+PACKAGE_JSON="$EXAMPLE_DIR/package.json"
+IOS_BUNDLE_ID="org.reactjs.native.example.example"
+ANDROID_APP_ID="com.example"
+
+if [[ ! -f "$PACKAGE_JSON" ]]; then
+ echo "Example package.json not found: $PACKAGE_JSON" >&2
+ exit 1
+fi
+
+if [[ "$PLATFORM" == "ios" ]]; then
+ require_command xcrun
+fi
+if [[ "$PLATFORM" == "android" ]]; then
+ require_command adb
+fi
+
+CONFIGURE_OUTPUT="$(python3 "$SCRIPT_DIR/configure-sample-package.py" "$PACKAGE_MODE" "$PACKAGE_JSON")"
+echo "$CONFIGURE_OUTPUT"
+DEPENDENCY_CHANGED=0
+if echo "$CONFIGURE_OUTPUT" | grep -q "^Updated "; then
+ DEPENDENCY_CHANGED=1
+fi
+
+install_deps() {
+ cd "$EXAMPLE_DIR"
+ if grep -q '"packageManager".*yarn' package.json 2>/dev/null && command -v yarn >/dev/null 2>&1; then
+ echo "Installing dependencies with yarn..."
+ yarn install
+ else
+ echo "Installing dependencies with npm..."
+ npm install
+ fi
+}
+
+metro_running() {
+ curl -sf "http://localhost:${METRO_PORT}/status" >/dev/null 2>&1
+}
+
+start_metro() {
+ if metro_running; then
+ echo "Metro already running on port $METRO_PORT"
+ return
+ fi
+
+ echo "Starting Metro on port $METRO_PORT..."
+ cd "$EXAMPLE_DIR"
+ if grep -q '"packageManager".*yarn' package.json 2>/dev/null && command -v yarn >/dev/null 2>&1; then
+ yarn start --port "$METRO_PORT" --reset-cache >/dev/null 2>&1 &
+ else
+ npm run start -- --port "$METRO_PORT" >/dev/null 2>&1 &
+ fi
+ METRO_PID=$!
+
+ for _ in $(seq 1 30); do
+ if metro_running; then
+ echo "Metro ready."
+ return
+ fi
+ sleep 1
+ done
+
+ echo "Metro failed to start on port $METRO_PORT" >&2
+ kill "$METRO_PID" >/dev/null 2>&1 || true
+ exit 1
+}
+
+metro_pid=""
+log_pid=""
+cleanup() {
+ if [[ -n "$log_pid" ]] && kill -0 "$log_pid" >/dev/null 2>&1; then
+ kill "$log_pid" >/dev/null 2>&1 || true
+ fi
+}
+trap cleanup EXIT INT TERM
+
+install_deps
+
+if [[ "$PLATFORM" == "ios" ]]; then
+ if [[ "$DEPENDENCY_CHANGED" -eq 1 ]] || [[ ! -d "$EXAMPLE_DIR/ios/Pods" ]]; then
+ echo "Running pod install..."
+ cd "$EXAMPLE_DIR/ios"
+ pod install
+ fi
+fi
+
+start_metro
+if [[ -n "${METRO_PID:-}" ]]; then
+ metro_pid="$METRO_PID"
+fi
+
+cd "$EXAMPLE_DIR"
+
+RN_ARGS=()
+if [[ "$PLATFORM" == "android" && -n "${DEVICE_ID:-}" ]]; then
+ RN_ARGS+=(--deviceId "$DEVICE_ID")
+fi
+if [[ "$PLATFORM" == "ios" && -n "${SIMULATOR_NAME:-}" ]]; then
+ RN_ARGS+=(--simulator "$SIMULATOR_NAME")
+fi
+
+if [[ "$PACKAGE_MODE" == "local" ]]; then
+ echo "Running example using local @ketch-com/ketch-react-native at $REPO_ROOT/package"
+else
+ echo "Running example using remote @ketch-com/ketch-react-native from npm (KETCH_RN_VERSION or latest)"
+fi
+
+if [[ "$build_only" -eq 1 ]]; then
+ RN_ARGS+=(--no-packager)
+elif metro_running; then
+ RN_ARGS+=(--no-packager --port "$METRO_PORT")
+fi
+
+echo "Building and launching on $PLATFORM..."
+if [[ "$PLATFORM" == "android" ]]; then
+ npx react-native run-android ${RN_ARGS+"${RN_ARGS[@]}"}
+else
+ npx react-native run-ios ${RN_ARGS+"${RN_ARGS[@]}"}
+fi
+
+if [[ "$build_only" -eq 1 ]]; then
+ echo "Build/install/launch complete (--build-only)."
+ exit 0
+fi
+
+if [[ "$stream_logs" -eq 0 ]]; then
+ echo "Skipping log stream (--no-logs)."
+ exit 0
+fi
+
+if [[ "$PLATFORM" == "android" ]]; then
+ device="${DEVICE_ID:-$(adb devices | awk 'NR > 1 && $2 == "device" { print $1; exit }')}"
+ if [[ -z "$device" ]]; then
+ echo "No adb device for log streaming." >&2
+ exit 0
+ fi
+ adb -s "$device" logcat -c >/dev/null 2>&1 || true
+ if [[ "$full_system_logs" -eq 1 ]]; then
+ echo "Streaming logcat (full). Press Ctrl-C to stop."
+ adb -s "$device" logcat -v brief
+ else
+ echo "Streaming logcat (ReactNativeJS + Ketch). Press Ctrl-C to stop."
+ adb -s "$device" logcat -v brief ReactNativeJS:V ReactNative:V "$ANDROID_APP_ID":V "*:S"
+ fi
+else
+ booted_id="$(xcrun simctl list devices booted | awk -F '[()]' '/Booted/ { print $2; exit }')"
+ if [[ -z "$booted_id" ]]; then
+ echo "No booted iOS simulator for log streaming." >&2
+ exit 0
+ fi
+ if [[ "$full_system_logs" -eq 1 ]]; then
+ predicate='process == "example"'
+ echo "Streaming unified logs for process example (full). Press Ctrl-C to stop."
+ else
+ predicate='process == "example" AND (eventMessage CONTAINS "Ketch" OR eventMessage CONTAINS "ketch")'
+ echo "Streaming unified logs filtered to Ketch messages. Press Ctrl-C to stop."
+ fi
+ xcrun simctl spawn "$booted_id" log stream \
+ --level debug \
+ --style compact \
+ --predicate "$predicate"
+fi
diff --git a/example/App.tsx b/example/App.tsx
index eceaabb..cfcaa74 100644
--- a/example/App.tsx
+++ b/example/App.tsx
@@ -6,20 +6,109 @@
*/
import React from 'react';
+import {Platform} from 'react-native';
-import {KetchServiceProvider} from '@ketch-com/ketch-react-native';
+import {
+ KetchDataCenter,
+ KetchServiceProvider,
+ PrivacyProtocol,
+ type Consent,
+} from '@ketch-com/ketch-react-native';
import Main from './Main';
+import {DashboardProvider, useDashboard} from './src/dashboard/DashboardContext';
+import {formatConsent} from './src/dashboard/consentLogging';
+import {
+ DEV_URL_OVERRIDES_ENABLED,
+ forAndroidEmulator,
+ forSimulator,
+} from './devUrlOverrides';
+
+function AppWithCallbacks(): React.JSX.Element {
+ const {appendLog, updateDashboard} = useDashboard();
-function App(): React.JSX.Element {
return (
- {/* Main.tsx is the content of your app. */}
+ organizationCode="ethansch061226"
+ propertyCode="website_smart_tag"
+ dataCenter={KetchDataCenter.UAT}
+ identities={{email: 'test-123-1@gmail.com'}}
+ autoLoad={false}
+ webResourceUrlOverrides={
+ DEV_URL_OVERRIDES_ENABLED
+ ? Platform.OS === 'android'
+ ? forAndroidEmulator
+ : forSimulator
+ : undefined
+ }
+ onEnvironmentUpdated={environment => {
+ updateDashboard({environment});
+ appendLog(`onEnvironmentUpdated: ${environment}`);
+ }}
+ onRegionUpdated={region => {
+ updateDashboard({region});
+ appendLog(`onRegionUpdated: ${region}`);
+ }}
+ onJurisdictionUpdated={jurisdiction => {
+ updateDashboard({jurisdiction});
+ appendLog(`onJurisdictionUpdated: ${jurisdiction}`);
+ }}
+ onIdentitiesUpdated={identities => {
+ appendLog(`onIdentitiesUpdated: ${JSON.stringify(identities)}`);
+ }}
+ onConsentUpdated={(consent: Consent) => {
+ const summary = formatConsent(consent);
+ updateDashboard({consent: summary});
+ appendLog(`onConsentUpdated: ${summary}`);
+ console.log('[KetchSample] onConsentUpdated:', summary);
+ }}
+ onPrivacyProtocolUpdated={(protocol, values) => {
+ const text = JSON.stringify(values);
+ if (protocol === PrivacyProtocol.USPrivacy) {
+ updateDashboard({usPrivacy: text});
+ } else if (protocol === PrivacyProtocol.TCF) {
+ updateDashboard({tcf: text});
+ } else if (protocol === PrivacyProtocol.GPP) {
+ updateDashboard({gpp: text});
+ }
+ appendLog(`onPrivacyProtocolUpdated: ${protocol}`);
+ }}
+ onHasShownExperience={() => {
+ updateDashboard({
+ experienceVisibility: 'shown',
+ webViewVisible: 'visible',
+ });
+ appendLog('onHasShownExperience');
+ }}
+ onHideExperience={reason => {
+ updateDashboard({
+ experienceVisibility: 'dismissed',
+ dismissReason: String(reason),
+ webViewVisible: 'hidden',
+ });
+ appendLog(`onHideExperience: ${reason}`);
+ }}
+ onError={errorMessage => {
+ updateDashboard({
+ loadState: 'error',
+ initState: 'Error',
+ statusText: `Error: ${errorMessage}`,
+ });
+ appendLog(`error: ${errorMessage}`);
+ }}
+ onNativeStoragePut={(key, value) => {
+ appendLog(`onNativeStoragePut: ${key}=${value}`);
+ }}>
);
}
+function App(): React.JSX.Element {
+ return (
+
+
+
+ );
+}
+
export default App;
diff --git a/example/Gemfile b/example/Gemfile
index 6774209..d0da5a5 100644
--- a/example/Gemfile
+++ b/example/Gemfile
@@ -3,8 +3,7 @@ source 'https://rubygems.org'
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
ruby ">= 2.6.10"
-# Cocoapods 1.15 introduced a bug which break the build. We will remove the upper
-# bound in the template on Cocoapods with next React Native release.
-gem 'cocoapods', '>= 1.13', '< 1.15'
-gem 'activesupport', '>= 6.1.7.5', '< 7.1.0'
+gem 'cocoapods', '~> 1.16'
+gem 'activesupport', '~> 7.2'
gem 'addressable', '>= 2.9.0'
+gem 'nkf'
diff --git a/example/Gemfile.lock b/example/Gemfile.lock
index b9f1aa5..157cdd9 100644
--- a/example/Gemfile.lock
+++ b/example/Gemfile.lock
@@ -1,27 +1,33 @@
GEM
remote: https://rubygems.org/
specs:
- CFPropertyList (3.0.7)
+ CFPropertyList (3.0.8)
+ activesupport (7.2.3.1)
base64
- nkf
- rexml
- activesupport (7.0.8.1)
- concurrent-ruby (~> 1.0, >= 1.0.2)
+ benchmark (>= 0.3)
+ bigdecimal
+ concurrent-ruby (~> 1.0, >= 1.3.1)
+ connection_pool (>= 2.2.5)
+ drb
i18n (>= 1.6, < 2)
- minitest (>= 5.1)
- tzinfo (~> 2.0)
+ logger (>= 1.4.2)
+ minitest (>= 5.1, < 6)
+ securerandom (>= 0.3)
+ tzinfo (~> 2.0, >= 2.0.5)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
atomos (0.1.3)
- base64 (0.2.0)
+ base64 (0.3.0)
+ benchmark (0.5.0)
+ bigdecimal (4.1.2)
claide (1.1.0)
- cocoapods (1.14.3)
+ cocoapods (1.16.2)
addressable (~> 2.8)
claide (>= 1.0.2, < 2.0)
- cocoapods-core (= 1.14.3)
+ cocoapods-core (= 1.16.2)
cocoapods-deintegrate (>= 1.0.3, < 2.0)
cocoapods-downloader (>= 2.1, < 3.0)
cocoapods-plugins (>= 1.0.0, < 2.0)
@@ -35,8 +41,8 @@ GEM
molinillo (~> 0.8.0)
nap (~> 1.0)
ruby-macho (>= 2.3.0, < 3.0)
- xcodeproj (>= 1.23.0, < 2.0)
- cocoapods-core (1.14.3)
+ xcodeproj (>= 1.27.0, < 2.0)
+ cocoapods-core (1.16.2)
activesupport (>= 5.0, < 8)
addressable (~> 2.8)
algoliasearch (~> 1.0)
@@ -56,53 +62,107 @@ GEM
netrc (~> 0.11)
cocoapods-try (1.2.0)
colored2 (3.1.2)
- concurrent-ruby (1.2.3)
+ concurrent-ruby (1.3.6)
+ connection_pool (3.0.2)
drb (2.2.3)
escape (0.0.4)
- ethon (0.16.0)
+ ethon (0.18.0)
ffi (>= 1.15.0)
- ffi (1.16.3)
+ logger
+ ffi (1.17.4)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
- httpclient (2.8.3)
- i18n (1.14.4)
+ httpclient (2.9.0)
+ mutex_m
+ i18n (1.14.8)
concurrent-ruby (~> 1.0)
- json (2.7.1)
+ json (2.19.9)
+ logger (1.7.0)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
molinillo (0.8.0)
- nanaimo (0.3.0)
+ mutex_m (0.3.0)
+ nanaimo (0.4.0)
nap (1.1.0)
netrc (0.11.0)
- nkf (0.2.0)
+ nkf (0.3.0)
prism (1.9.0)
public_suffix (4.0.7)
- rexml (3.4.2)
+ rexml (3.4.4)
ruby-macho (2.5.1)
- typhoeus (1.4.1)
- ethon (>= 0.9.0)
+ securerandom (0.4.1)
+ typhoeus (1.6.0)
+ ethon (>= 0.18.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
- xcodeproj (1.25.0)
+ xcodeproj (1.27.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
- nanaimo (~> 0.3.0)
- rexml (>= 3.3.2, < 4.0)
+ nanaimo (~> 0.4.0)
+ rexml (>= 3.3.6, < 4.0)
PLATFORMS
ruby
DEPENDENCIES
- activesupport (>= 6.1.7.5, < 7.1.0)
+ activesupport (~> 7.2)
+ cocoapods (~> 1.16)
addressable (>= 2.9.0)
- cocoapods (>= 1.13, < 1.15)
+ nkf
+
+CHECKSUMS
+ CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
+ activesupport (7.2.3.1) sha256=11ebed516a43a0bb47346227a35ebae4d9427465a7c9eb197a03d5c8d283cb34
+ addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
+ algoliasearch (1.27.5) sha256=26c1cddf3c2ec4bd60c148389e42702c98fdac862881dc6b07a4c0b89ffec853
+ atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
+ base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
+ benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
+ bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
+ claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
+ cocoapods (1.16.2) sha256=0ff1c860f32df3db8b16df09b58da1a6bb2a12fe55f6d5e8be994a74fadd1e5e
+ cocoapods-core (1.16.2) sha256=4bb1b5c420691e60cf36fa227dec6bc48c096c34c97bb7aa512ea7f3246fc12b
+ cocoapods-deintegrate (1.0.5) sha256=517c2a448ef563afe99b6e7668704c27f5de9e02715a88ee9de6974dc1b3f6a2
+ cocoapods-downloader (2.1) sha256=bb6ebe1b3966dc4055de54f7a28b773485ac724fdf575d9bee2212d235e7b6d1
+ cocoapods-plugins (1.0.0) sha256=725d17ce90b52f862e73476623fd91441b4430b742d8a071000831efb440ca9a
+ cocoapods-search (1.0.1) sha256=1b133b0e6719ed439bd840e84a1828cca46425ab73a11eff5e096c3b2df05589
+ cocoapods-trunk (1.6.0) sha256=5f5bda8c172afead48fa2d43a718cf534b1313c367ba1194cebdeb9bfee9ed31
+ cocoapods-try (1.2.0) sha256=145b946c6e7747ed0301d975165157951153d27469e6b2763c83e25c84b9defe
+ colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
+ concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
+ connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
+ drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
+ escape (0.0.4) sha256=e49f44ae2b4f47c6a3abd544ae77fe4157802794e32f19b8e773cbc4dcec4169
+ ethon (0.18.0) sha256=b598afc9f30448cb068b850714b7d6948e941476095d04f90a4ac65b8d6efcb2
+ ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf
+ fourflusher (2.3.1) sha256=1b3de61c7c791b6a4e64f31e3719eb25203d151746bb519a0292bff1065ccaa9
+ fuzzy_match (2.0.4) sha256=b5de4f95816589c5b5c3ad13770c0af539b75131c158135b3f3bbba75d0cfca5
+ gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
+ httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8
+ i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
+ json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+ minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
+ molinillo (0.8.0) sha256=efbff2716324e2a30bccd3eba1ff3a735f4d5d53ffddbc6a2f32c0ca9433045d
+ mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
+ nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
+ nap (1.1.0) sha256=949691660f9d041d75be611bb2a8d2fd559c467537deac241f4097d9b5eea576
+ netrc (0.11.0) sha256=de1ce33da8c99ab1d97871726cba75151113f117146becbe45aa85cb3dabee3f
+ nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895
+ public_suffix (4.0.7) sha256=8be161e2421f8d45b0098c042c06486789731ea93dc3a896d30554ee38b573b8
+ rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
+ ruby-macho (2.5.1) sha256=9075e52e0f9270b552a90b24fcc6219ad149b0d15eae1bc364ecd0ac8984f5c9
+ securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
+ typhoeus (1.6.0) sha256=bacc41c23e379547e29801dc235cd1699b70b955a1ba3d32b2b877aa844c331d
+ tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
+ xcodeproj (1.27.0) sha256=8cc7a73b4505c227deab044dce118ede787041c702bc47636856a2e566f854d3
RUBY VERSION
- ruby 3.3.0p0
+ ruby 4.0.1
BUNDLED WITH
- 2.5.4
+ 4.0.3
diff --git a/example/Main.tsx b/example/Main.tsx
index b54385e..f38d62c 100644
--- a/example/Main.tsx
+++ b/example/Main.tsx
@@ -5,17 +5,18 @@
* @format
*/
-import React, {useState} from 'react';
+import React, {useEffect, useState} from 'react';
import {
Button,
Keyboard,
NativeSyntheticEvent,
+ Platform,
SafeAreaView,
ScrollView,
StatusBar,
StyleSheet,
+ Text,
TextInputEndEditingEventData,
- useColorScheme,
View,
} from 'react-native';
@@ -24,10 +25,21 @@ import {RadioList} from './UI';
import {LabeledTextInput} from './src/components/LabeledTextInput/LabeledTextInput';
import {Section} from './src/components/Section/Section';
import {dataCenterLabels, preferenceTabLabels} from './src/labels';
+import {
+ useDashboard,
+ truncate,
+} from './src/dashboard/DashboardContext';
+import {
+ formatAttState,
+ formatConsent,
+} from './src/dashboard/consentLogging';
import {
useKetchService,
KetchDataCenter,
PreferenceTab,
+ requestTrackingAuthorization,
+ trackingAuthorizationStatusString,
+ type ConsentConfig,
} from '@ketch-com/ketch-react-native';
import DefaultPreference from 'react-native-default-preference';
@@ -42,23 +54,88 @@ const PREFERENCE_TABS = Object.values(PreferenceTab).map(preferenceTab => ({
label: preferenceTabLabels[preferenceTab],
}));
+function consentConfigFromConfiguration(options: {
+ configuration: Record;
+ identities: Record;
+ organizationCode: string;
+ propertyCode: string;
+ environmentCode: string;
+}): ConsentConfig {
+ const {
+ configuration,
+ identities,
+ organizationCode,
+ propertyCode,
+ environmentCode,
+ } = options;
+ const jurisdictionMap = configuration.jurisdiction;
+ let jurisdiction = 'us';
+ if (jurisdictionMap && typeof jurisdictionMap === 'object') {
+ const map = jurisdictionMap as Record;
+ jurisdiction = String(
+ map.code ?? map.defaultJurisdictionCode ?? jurisdiction,
+ );
+ }
+ const purposesList = configuration.purposes;
+ const purposes: ConsentConfig['purposes'] = {};
+ if (Array.isArray(purposesList)) {
+ for (const entry of purposesList) {
+ if (entry && typeof entry === 'object') {
+ const purpose = entry as Record;
+ const code = purpose.code?.toString();
+ const legalBasis = purpose.legalBasisCode?.toString();
+ if (code && legalBasis) {
+ purposes[code] = {legalBasisCode: legalBasis};
+ }
+ }
+ }
+ }
+ if (Object.keys(purposes).length === 0) {
+ throw new Error('Configuration returned no purposes');
+ }
+ return {
+ organizationCode,
+ propertyCode,
+ environmentCode,
+ jurisdictionCode: jurisdiction,
+ identities,
+ purposes,
+ };
+}
+
+function DashboardRow({
+ label,
+ value,
+}: {
+ label: string;
+ value: string;
+}): React.JSX.Element {
+ return (
+
+ {label}:
+ {value}
+
+ );
+}
+
function Main(): React.JSX.Element {
const ketch = useKetchService();
- const [selectedRegion, setSelectedRegion] = useState(KetchDataCenter.US);
+ const {dashboard, appendLog, setStatus, updateDashboard} = useDashboard();
+ const [selectedRegion, setSelectedRegion] = useState(KetchDataCenter.UAT);
// Global options
const [organization, setOrganization] = useState(
- 'ketch_samples',
+ 'ethansch061226',
);
const [property, setProperty] = useState(
- 'react_native_sample_app',
+ 'website_smart_tag',
);
- const [language, setLanguage] = useState(undefined);
+ const [language, setLanguage] = useState('en');
const [jurisdiction, setJurisdiction] = useState(
undefined,
);
const [region, setRegion] = useState(undefined);
- const [environment, setEnvironment] = useState(undefined);
+ const [environment, setEnvironment] = useState('production');
const [identityName, setIdentityName] = useState('');
const [identityValue, setIdentityValue] = useState('');
const [identities, setIdentities] = useState({}); // TODO:JB - Default identities
@@ -74,6 +151,108 @@ function Main(): React.JSX.Element {
const [initialTab, setInitialTab] = useState(
PreferenceTab.OverviewTab,
);
+ const [attStatus, setAttStatus] = useState('N/A');
+
+ const ATT_LAST_STATUS_KEY = 'ketch_att_last';
+
+ const refreshAttState = async (logEvent = false) => {
+ if (Platform.OS !== 'ios') {
+ return;
+ }
+ const status = (await trackingAuthorizationStatusString()) ?? 'unknown';
+ const prev =
+ (await DefaultPreference.get(ATT_LAST_STATUS_KEY)) ?? 'notDetermined';
+ setAttStatus(status);
+ updateDashboard({attStatus: status, ketchAtt: status, ketchAttPrev: prev});
+ if (logEvent) {
+ const message = formatAttState(status, prev);
+ appendLog(`ATT: ${message}`);
+ console.log('[KetchSample] ATT:', message);
+ }
+ };
+
+ useEffect(() => {
+ refreshAttState().catch(() => {});
+ }, [updateDashboard]);
+
+ const handleRequestAtt = async () => {
+ const status = await requestTrackingAuthorization();
+ const value = status ?? 'unknown';
+ setAttStatus(value);
+ const prev =
+ (await DefaultPreference.get(ATT_LAST_STATUS_KEY)) ?? 'notDetermined';
+ updateDashboard({attStatus: value, ketchAtt: value, ketchAttPrev: prev});
+ const message = formatAttState(value, prev);
+ appendLog(`ATT requested: ${message}`);
+ console.log('[KetchSample] ATT requested:', message);
+ ketch.load();
+ };
+
+ const handleReloadWebView = async () => {
+ await refreshAttState(true);
+ ketch.load();
+ appendLog('WebView reload requested');
+ };
+
+ const runHeadlessLocation = async () => {
+ updateDashboard({headlessLocationResult: 'Loading...'});
+ try {
+ const response = await ketch.fetchLocation?.();
+ const code = response?.location?.countryCode ?? '?';
+ updateDashboard({headlessLocationResult: `OK: ${code}`});
+ appendLog(`headless location: ${code}`);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ updateDashboard({headlessLocationResult: `Error: ${message}`});
+ }
+ };
+
+ const runHeadlessBootstrap = async () => {
+ updateDashboard({headlessBootstrapResult: 'Loading...'});
+ try {
+ const boot = await ketch.fetchBootstrapConfiguration?.();
+ const count = Array.isArray(boot?.purposes) ? boot.purposes.length : 0;
+ updateDashboard({headlessBootstrapResult: `OK: ${count} purpose(s)`});
+ appendLog('headless bootstrap OK');
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ updateDashboard({headlessBootstrapResult: `Error: ${message}`});
+ }
+ };
+
+ const runHeadlessConsent = async () => {
+ updateDashboard({headlessConsentResult: 'Loading...'});
+ try {
+ const identities = {
+ email: `headless-${Date.now()}@integration.ketch.test`,
+ };
+ await ketch.fetchLocation?.();
+ await ketch.fetchBootstrapConfiguration?.();
+ const env = environment?.trim() || 'production';
+ const full = await ketch.fetchFullConfiguration?.({
+ organizationCode: organization ?? 'ethansch061226',
+ propertyCode: property ?? 'website_smart_tag',
+ environmentCode: env,
+ });
+ const config = consentConfigFromConfiguration({
+ configuration: full ?? {},
+ identities,
+ organizationCode: organization ?? 'ethansch061226',
+ propertyCode: property ?? 'website_smart_tag',
+ environmentCode: env,
+ });
+ const consent = await ketch.fetchConsent?.(config);
+ const count =
+ consent?.purposes && Object.keys(consent.purposes).length > 0
+ ? Object.keys(consent.purposes).length
+ : Object.keys(consent?.protocols ?? {}).length;
+ updateDashboard({headlessConsentResult: `OK: ${count} item(s)`});
+ appendLog('headless consent OK');
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ updateDashboard({headlessConsentResult: `Error: ${message}`});
+ }
+ };
// Reset identities
const handleResetIdentityPress = () => {
@@ -132,6 +311,65 @@ function Main(): React.JSX.Element {
Keyboard.dismiss()}>
+
+
+ Connection
+
+
+
+
+ WebView / Experience
+
+
+
+
+ {Platform.OS === 'ios' && (
+ <>
+
+
+ >
+ )}
+ {Platform.OS === 'ios' && (
+ <>
+ ATT (iOS)
+
+ >
+ )}
+ Privacy / Consent State
+
+
+
+
+
+
+
+ Headless (live CDN)
+
+
+
+
+
+
+
+
+ Event Log
+
+ {dashboard.eventLog.length === 0
+ ? 'Waiting for events...'
+ : dashboard.eventLog.join('\n')}
+
+
+
+
{/* Global options */}
+ {Platform.OS === 'ios' && (
+
+
+ ATT: {attStatus}
+
+
+
+
+
+
+ )}
+
{/* SDK Actions */}
<>
@@ -292,13 +542,27 @@ function Main(): React.JSX.Element {