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) + + + + +
+ {/* Global options */}
+ {Platform.OS === 'ios' && ( +
+ + ATT: {attStatus} + +
+ )} + {/* SDK Actions */}
<> @@ -292,13 +542,27 @@ function Main(): React.JSX.Element {