diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76dbecd1..f7940cae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,13 @@ jobs: cd example/android ./gradlew :app:assembleDebug $GRADLE_FLAGS + - name: Run Purchasely Android native unit tests + env: + JAVA_OPTS: "-XX:MaxHeapSize=4g" + run: | + cd example/android + ./gradlew :react-native-purchasely:testDebugUnitTest + build-ios: runs-on: macos-latest steps: diff --git a/.github/workflows/e2e-android.yml b/.github/workflows/e2e-android.yml new file mode 100644 index 00000000..cbe206f2 --- /dev/null +++ b/.github/workflows/e2e-android.yml @@ -0,0 +1,118 @@ +name: E2E Android + +on: + workflow_dispatch: + schedule: + # 2h00 UTC chaque nuit + - cron: '0 2 * * *' + push: + branches: + - feat/sdk-v6-migration + paths: + # SDK source (shared JS bridge + native): changing the bridge must run E2E. + - 'packages/purchasely/src/**' + - 'packages/purchasely/ios/**' + - 'packages/purchasely/android/src/**' + - 'packages/purchasely/android/build.gradle' + - 'packages/*/android/build.gradle' + - '.github/workflows/e2e-android.yml' + - 'integration_test/**' + - 'example/src/E2ETestRunner.tsx' + - 'example/android/app/build.gradle' + pull_request: + paths: + # SDK source (shared JS bridge + native): changing the bridge must run E2E. + - 'packages/purchasely/src/**' + - 'packages/purchasely/ios/**' + - 'packages/purchasely/android/src/**' + - 'packages/purchasely/android/build.gradle' + - 'packages/*/android/build.gradle' + - '.github/workflows/e2e-android.yml' + - 'integration_test/**' + - 'example/src/E2ETestRunner.tsx' + - 'example/android/app/build.gradle' + +concurrency: + group: e2e-android + cancel-in-progress: true + +jobs: + e2e-android: + name: E2E Tests (Android T1-T20) + # ubuntu-latest: KVM disponible pour x86_64, requis par reactivecircuit/android-emulator-runner. + # macOS sera utilisé dans e2e-ios.yml (simulateur iOS). + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Setup Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'yarn' + + - name: Setup Java 17 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + gradle-version: wrapper + cache-read-only: false + + - name: Install JS dependencies + run: yarn install --immutable + + - name: Build JS SDK + run: yarn purchasely:prepare + + - name: Build release APK (JS always bundled in release) + # Release builds always embed the JS bundle — no Metro bundler needed in CI. + # ProGuard is disabled in build.gradle (enableProguardInReleaseBuilds = false). + # -x lintVitalRelease skips lint step that false-positives on ReactActivity. + env: + JAVA_OPTS: "-XX:MaxHeapSize=4g" + run: | + cd example/android + ./gradlew :app:assembleRelease -x lintVitalRelease + # Verify the JS bundle is embedded + APK="app/build/outputs/apk/release/app-release.apk" + if unzip -l "$APK" | grep -q "index.android.bundle"; then + echo "✅ JS bundle confirmed in APK" + else + echo "❌ JS bundle NOT found in APK" + exit 1 + fi + + - name: Run E2E suite on emulator (T1-T20) + # ReactiveCircus/android-emulator-runner handles: system-image install, + # AVD creation, emulator start with KVM, boot wait, animations disable. + uses: ReactiveCircus/android-emulator-runner@v2 + with: + api-level: 34 + arch: x86_64 + target: google_apis + avd-name: rn_e2e_api34 + disable-animations: true + emulator-options: -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect -no-snapshot + script: bash integration_test/run_e2e.sh emulator-5554 --skip-build + + - name: Upload logcat on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: logcat-${{ github.run_id }} + path: /tmp/e2e_rn_logcat_*.log + retention-days: 7 diff --git a/.github/workflows/e2e-ios.yml b/.github/workflows/e2e-ios.yml new file mode 100644 index 00000000..701fb16f --- /dev/null +++ b/.github/workflows/e2e-ios.yml @@ -0,0 +1,121 @@ +name: E2E iOS + +on: + workflow_dispatch: + schedule: + # 2h30 UTC chaque nuit (décalé de l'E2E Android à 2h00) + - cron: '30 2 * * *' + push: + branches: + - feat/sdk-v6-migration + paths: + # SDK source (shared JS bridge + native): changing the bridge must run E2E. + - 'packages/purchasely/src/**' + - 'packages/purchasely/ios/**' + - 'packages/purchasely/android/src/**' + - 'packages/purchasely/react-native-purchasely.podspec' + - '.github/workflows/e2e-ios.yml' + - 'integration_test/**' + - 'example/src/E2ETestRunner.tsx' + - 'example/ios/**' + pull_request: + paths: + # SDK source (shared JS bridge + native): changing the bridge must run E2E. + - 'packages/purchasely/src/**' + - 'packages/purchasely/ios/**' + - 'packages/purchasely/android/src/**' + - 'packages/purchasely/react-native-purchasely.podspec' + - '.github/workflows/e2e-ios.yml' + - 'integration_test/**' + - 'example/src/E2ETestRunner.tsx' + - 'example/ios/**' + +concurrency: + group: e2e-ios + cancel-in-progress: true + +jobs: + e2e-ios: + name: E2E Tests (iOS T1-T20) + # macOS requis : simulateur iOS + xcodebuild. Pas de HVF nécessaire (le + # simulateur iOS ne virtualise pas un OS invité, contrairement à l'émulateur + # Android ARM — c'est pourquoi Android tourne sur ubuntu + KVM). + # macos-15 : Xcode 16.x par défaut (RN 0.79 exige Xcode >= 16.1). + runs-on: macos-15 + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select latest Xcode 16 (RN 0.79 requires >= 16.1) + run: | + LATEST=$(ls -d /Applications/Xcode_16*.app 2>/dev/null | sort -V | tail -1) + if [ -n "$LATEST" ]; then sudo xcode-select -s "$LATEST/Contents/Developer"; fi + xcodebuild -version + + - name: Setup (Node + Yarn) + uses: ./.github/actions/setup + + - name: Build JS SDK + run: yarn prepare + + - name: Cache CocoaPods + uses: actions/cache@v4 + with: + path: | + example/ios/Pods + ~/.cocoapods + ~/Library/Caches/CocoaPods + key: ${{ runner.os }}-cocoapods-${{ hashFiles('example/ios/Podfile.lock', 'packages/purchasely/react-native-purchasely.podspec') }} + restore-keys: | + ${{ runner.os }}-cocoapods- + + - name: Install CocoaPods + run: | + cd example/ios + pod install --repo-update + env: + NO_FLIPPER: 1 + + - name: Install idb (companion + client) + run: | + # idb_companion : tap Facebook (nécessite brew trust sur les brew récents). + brew tap facebook/fb + brew trust facebook/fb || true + brew install idb-companion + # fb-idb (client Python) ne supporte pas Python 3.14 (asyncio.get_event_loop + # y lève) — on l'isole dans un venv 3.11/3.12/3.13 déterministe et on + # exporte le binaire via $IDB (lu par les drivers tap/swipe). + PY=$(command -v python3.12 || command -v python3.11 || command -v python3.13 || command -v python3) + "$PY" -m venv "$RUNNER_TEMP/idbvenv" + "$RUNNER_TEMP/idbvenv/bin/pip" install -q --upgrade pip fb-idb + echo "IDB=$RUNNER_TEMP/idbvenv/bin/idb" >> "$GITHUB_ENV" + "$RUNNER_TEMP/idbvenv/bin/idb" --help >/dev/null && echo "✅ idb client OK" + + - name: Boot iOS simulator + run: | + UDID=$(xcrun simctl list devices available -j | python3 -c " + import sys, json + d = json.load(sys.stdin)['devices'] + cands = [v for k, vs in d.items() if 'iOS' in k for v in vs if 'iPhone' in v['name']] + # iPhone 15/16 de préférence, sinon le dernier iPhone disponible + pref = [c for c in cands if any(n in c['name'] for n in ('iPhone 16', 'iPhone 15'))] + chosen = (pref or cands)[-1] + print(chosen['udid']) + ") + echo "Booting simulator $UDID" + xcrun simctl boot "$UDID" + xcrun simctl bootstatus "$UDID" -b + echo "IOS_SIMULATOR_UDID=$UDID" >> "$GITHUB_ENV" + + - name: Run E2E suite on simulator (build Release + T1-T20) + run: bash integration_test/run_e2e_ios.sh "$IOS_SIMULATOR_UDID" + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: ios-e2e-logs-${{ github.run_id }} + path: /tmp/e2e_rn_ios_*.log + retention-days: 7 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aeab902b..4f85920d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -69,20 +69,20 @@ jobs: - name: Publish react-native-purchasely working-directory: packages/purchasely - run: npm publish --access public --provenance + run: npm publish --access public --provenance --tag latest - name: Publish @purchasely/react-native-purchasely-google working-directory: packages/google - run: npm publish --access public --provenance + run: npm publish --access public --provenance --tag latest - name: Publish @purchasely/react-native-purchasely-amazon working-directory: packages/amazon - run: npm publish --access public --provenance + run: npm publish --access public --provenance --tag latest - name: Publish @purchasely/react-native-purchasely-huawei working-directory: packages/huawei - run: npm publish --access public --provenance + run: npm publish --access public --provenance --tag latest - name: Publish @purchasely/react-native-purchasely-android-player working-directory: packages/android-player - run: npm publish --access public --provenance + run: npm publish --access public --provenance --tag latest diff --git a/.gitignore b/.gitignore index b0e5923c..0d94b3d5 100644 --- a/.gitignore +++ b/.gitignore @@ -88,4 +88,7 @@ nitrogen/ .nx/cache -.nx/workspace-data \ No newline at end of file +.nx/workspace-data +jest_dx/ +node-compile-cache/ +**/coverage/ diff --git a/.nvmrc b/.nvmrc index 9a2a0e21..53d1c14d 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20 +v22 diff --git a/CLAUDE.md b/CLAUDE.md index 07f5ffa8..31b63403 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,13 +11,13 @@ | Property | Value | |----------|-------| -| Current Version | 5.7.3 | -| React Native | 0.79.2 | -| TypeScript | 5.2.2 (strict mode) | -| Node.js | v20 (see `.nvmrc`) | +| Current Version | 6.0.0-rc.3 | +| React Native | 0.86.0 | +| TypeScript | 5.8.3 (strict mode) | +| Node.js | v22 (see `.nvmrc`) | | Package Manager | Yarn 3.6.1 (workspaces) | -| Native iOS SDK | 5.7.4 | -| Native Android SDK | 5.7.4 | +| Native iOS SDK | 6.0.0-rc.3 | +| Native Android SDK | 6.0.0-rc.3 | ### Supported App Stores - Apple App Store (iOS) @@ -109,7 +109,6 @@ ReactNative_SDK/ | `tsconfig.build.json` | Build-specific TypeScript config | | `.eslintrc.js` | ESLint configuration | | `.prettierrc` | Prettier formatting rules | -| `turbo.json` | Turbo build orchestration | | `.yarnrc.yml` | Yarn 3 workspace settings | --- @@ -192,7 +191,7 @@ yarn player:clean | Type | Convention | Example | |------|------------|---------| | Components | PascalCase | `PLYPresentationView` | -| Enums | PascalCase with PLY prefix | `PLYPresentationAction` | +| Enums | PascalCase with PLY prefix | `PLYThemeMode` | | Private properties | Underscore prefix | `_view`, `_controller` | | Event constants | SCREAMING_SNAKE_CASE | `PURCHASELY_EVENTS` | | Functions | camelCase | `fetchPresentation` | @@ -231,39 +230,57 @@ The following sections provide quick API examples. For comprehensive documentati Refer to the [SDK Public Documentation](../sdk_public_doc.md). -### Initialization +> **v6 paywall API only.** The v5 paywall methods (`start({...})`, +> `presentPresentationForPlacement`, `presentPresentationWithIdentifier`, +> `presentProductWithIdentifier`, `presentPlanWithIdentifier`, +> `fetchPresentation`, `setPaywallActionInterceptorCallback`, `onProcessAction`, +> `setDefaultPresentationResultCallback`, `readyToOpenDeeplink`, …) are +> **removed**. Use the builders below. Full mapping: `MIGRATION-v6.md`. + +### Initialization (v6 builder) ```typescript -import Purchasely, { LogLevels, RunningMode } from 'react-native-purchasely' - -await Purchasely.start({ - apiKey: 'YOUR_API_KEY', - androidStores: ['Google'], // or ['Huawei', 'Amazon'] - storeKit1: false, // iOS: use StoreKit 2 - userId: 'user_id', // optional - logLevel: LogLevels.DEBUG, - runningMode: RunningMode.FULL -}) +import Purchasely from 'react-native-purchasely' + +await Purchasely.builder('YOUR_API_KEY') + .appUserId('user_id') // optional + .runningMode('full') // 'observer' (default) | 'full' + .logLevel('debug') // 'debug' | 'info' | 'warn' | 'error' + .allowDeeplink(true) // replaces readyToOpenDeeplink(true) + .stores(['google']) // Android only: 'google' | 'huawei' | 'amazon' + .storekitVersion('storeKit2') // iOS only: 'storeKit1' | 'storeKit2' + .start() ``` -### Presentation Methods +### Presentation Methods (v6 builders) + +`Purchasely.presentation` is the `PLYPresentationBuilder`. `build()` returns a +`PLYPresentationRequest`; `display()` resolves at dismiss with a 5-field +`PLYPresentationOutcome` (`{ presentation, purchaseResult, plan, closeReason, +error }`). `preload()` resolves a `PLYLoadedPresentation` that also exposes +`display()` / `close()` / `back()` delegating to its request. ```typescript -// Fetch presentation data -const presentation = await Purchasely.fetchPresentation({ - placementVendorId: 'ONBOARDING', - contentId: 'content_123' -}) +// Preload a placement (was fetchPresentation) +const request = Purchasely.presentation.placement('ONBOARDING').build() +const presentation = await request.preload() -// Present full-screen paywall -const result = await Purchasely.presentPresentationForPlacement({ - placementVendorId: 'ONBOARDING', - isFullscreen: true -}) +// Present a placement full-screen (was presentPresentationForPlacement) +const outcome = await Purchasely.presentation.placement('ONBOARDING').build().display() -// Present specific product or plan -await Purchasely.presentProductWithIdentifier('product_id') -await Purchasely.presentPlanWithIdentifier('plan_id') +// Present a specific screen (was presentPresentationWithIdentifier) +await Purchasely.presentation.screen('SCREEN_ID').build().display() + +// Present a specific product / plan (was presentProductWithIdentifier / presentPlanWithIdentifier) +await Purchasely.presentation.screen('SCREEN_ID').contentId('CONTENT_ID').build().display() + +// Lifecycle: request.display() (show) / request.close() (hide) / request.back() + +// Action interception (was setPaywallActionInterceptorCallback + onProcessAction) +Purchasely.interceptAction('purchase', async (info, payload) => { + // return 'success' | 'failed' | 'notHandled' + return 'notHandled' +}) ``` ### Event Listening @@ -351,11 +368,9 @@ enum LogLevels { ERROR } -// Running modes +// Running modes (v6: only two modes, builder takes 'observer' | 'full') enum RunningMode { - TRANSACTION_ONLY, // Handle transactions only - OBSERVER, // Observe transactions - PAYWALL_OBSERVER, // Observe paywalls + OBSERVER, // Observe transactions (default) FULL // Full functionality } @@ -378,23 +393,21 @@ Each package uses Builder Bob to generate: - `lib/module/` - ESM build - `lib/typescript/` - TypeScript declarations -### Turbo Pipeline +### Workspace Build Pipeline -Build orchestration with caching: -- `build` - Main build task -- `build:android` - Android native build -- `build:ios` - iOS native build +Yarn workspaces run package builds directly with `yarn all:prepare`; the CI +Android and iOS jobs invoke Gradle and `xcodebuild` directly. ### Native Dependencies **iOS (CocoaPods):** -- Purchasely SDK v5.7.4 -- Deployment target: iOS 13.4 +- Purchasely SDK v6.0.0-rc.3 +- Deployment target: iOS 15.1 **Android (Gradle):** -- io.purchasely:core:5.7.4 -- Min SDK: 21 -- Kotlin: 1.9+ +- io.purchasely:core:6.0.0-rc.3 +- Min SDK: 23 +- Kotlin: 2.3.21+ - Java: 11 --- @@ -696,5 +709,4 @@ example/src/App.tsx # Configuration package.json tsconfig.json -turbo.json ``` diff --git a/MIGRATION-v6.md b/MIGRATION-v6.md new file mode 100644 index 00000000..2fffc751 --- /dev/null +++ b/MIGRATION-v6.md @@ -0,0 +1,493 @@ +# Migrating to Purchasely React Native SDK v6 + +Purchasely React Native SDK **v6 is paywall-API-only**: the legacy v5 paywall +API has been **REMOVED** (not deprecated). Calling any of the removed methods +will fail to compile (TypeScript) and the methods no longer exist at runtime. + +This guide maps every removed v5 paywall method to its v6 replacement and lists +the methods that are **unchanged**. + +> **Tip — let the AI help you migrate.** The Purchasely AI plugin and the +> `purchasely-integrate`, `purchasely-review` and `purchasely-debug` skills can +> read your integration and rewrite the v5 paywall calls to the v6 builder API +> for you. Point them at the files that call `Purchasely.start`, +> `presentPresentationForPlacement`, `fetchPresentation`, +> `setPaywallActionInterceptorCallback`, etc. + +--- + +## TL;DR + +- The paywall surface is now built around three entry points exposed on the + `Purchasely` default export: + - `Purchasely.builder(apiKey)` — chainable SDK start. + - `Purchasely.presentation` — the `PLYPresentationBuilder` (`.placement(id)`, + `.screen(id)`, `.defaultSource()`). `.default()` remains a valid alias of + `.defaultSource()` (it matches the iOS entry-point name). + - `Purchasely.interceptAction(kind, handler)` — typed action interception. +- `PLYPresentationBuilder.build()` returns a **`PLYPresentationRequest`** with a + lifecycle (`preload()`, `display(transition?)`, `close()`, `back()`). +- `display()` resolves at **dismiss** with a 5-field `PLYPresentationOutcome` + (`{ presentation, purchaseResult, plan, closeReason, error }`). +- **All CORE methods are UNCHANGED** — see [Unchanged](#whats-unchanged). + +--- + +## Removed v5 paywall API → v6 replacement + +| Removed v5 method | v6 replacement | +|-------------------|----------------| +| `Purchasely.start({ apiKey, androidStores, storeKit1, userId, logLevel, runningMode })` | `Purchasely.builder(apiKey).appUserId(userId).runningMode('full').logLevel('error').stores(['google']).storekitVersion('storeKit2').start()` | +| `Purchasely.startWithAPIKey(apiKey, stores, userId, logLevel, runningMode)` | `Purchasely.builder(apiKey).appUserId(userId).runningMode('full').start()` | +| `Purchasely.fetchPresentation({ placementId })` | `Purchasely.presentation.placement(id).build().preload()` | +| `Purchasely.presentPresentationForPlacement({ placementVendorId })` | `Purchasely.presentation.placement(id).build().display()` | +| `Purchasely.presentPresentationWithIdentifier({ presentationVendorId })` | `Purchasely.presentation.screen(id).build().display()` | +| `Purchasely.presentPresentation({ presentation })` | preload then display the same request: `const req = Purchasely.presentation.placement(id).build(); await req.preload(); await req.display()` | +| `Purchasely.presentProductWithIdentifier(productId, …)` | `Purchasely.presentation.screen(id).contentId(contentId).build().display()` | +| `Purchasely.presentPlanWithIdentifier(planId, …)` | `Purchasely.presentation.screen(id).build().display()` | +| `Purchasely.showPresentation()` / `Purchasely.presentPresentation(...)` | request lifecycle: `request.display()` | +| `Purchasely.hidePresentation()` / `Purchasely.closePresentation()` | request lifecycle: `request.close()` | +| `Purchasely.setPaywallActionInterceptorCallback(cb)` + `Purchasely.onProcessAction(bool)` | `Purchasely.interceptAction(kind, handler)` — handler returns `'success' \| 'failed' \| 'notHandled'` (no more `onProcessAction`) | +| `Purchasely.setDefaultPresentationResultCallback(cb)` / `setDefaultPresentationResultHandler(cb)` | `Purchasely.setDefaultPresentationDismissHandler(outcome => …)` — global handler for presentations the SDK opens itself (campaigns, deeplinks, Promoted IAP). For paywalls **you** display, use `request.onDismissed(outcome => …)` instead. | +| `Purchasely.readyToOpenDeeplink(true)` | `Purchasely.builder(apiKey).allowDeeplink(true).start()` | +| `Purchasely.close()` (top-level) | `request.close()` on a `PLYPresentationRequest` | +| `Purchasely.displaySubscriptionCancellationInstruction()` | **Removed** — cancellation UX is owned by the OS/App Store; the SDK no longer opens it. | +| `Purchasely.clientPresentationDisplayed(...)` / `Purchasely.clientPresentationClosed(...)` | **Kept — NOT removed.** Same JS API as v5; pass the presentation obtained from `preload()`. Only the underlying iOS native call was renamed (`clientPresentationOpened` → `clientPresentationDisplayed`), which is invisible to JS. | +| `FetchPresentationParameters` / `PresentPresentation*Parameters` / `PresentProductParameters` / `PresentPlanParameters` / `PaywallActionInterceptorResult` | **Removed** — replaced by the `PLYPresentationBuilder` / `interceptAction(kind, handler)` types. | +| `setUserAttributeWithInt / setUserAttributeWithDouble` / `…WithIntArray / …WithDoubleArray` | **Added** — Flutter-compatible aliases of the `WithNumber / WithNumberArray` setters. | + +### New v6 helpers (Flutter parity) + +- `Purchasely.apiKey(key)` — alias of `Purchasely.builder(key)` (Flutter `Purchasely.apiKey(...)`). +- `Purchasely.allowCampaigns(allow)` — runtime toggle for automatic campaigns (callable after `start()`). +- `Purchasely.listenToEvents(cb)` / `Purchasely.stopListeningToEvents()` — Flutter-compatible aliases of `addEventListener` / `removeEventListener`. +- `Purchasely.listenToPurchases(cb)` / `Purchasely.stopListeningToPurchases()` — Flutter-compatible aliases of `addPurchasedListener` / `removePurchasedListener`. +- `Purchasely.setUserAttributeListener(listener)` / `Purchasely.clearUserAttributeListener()` — bundle the per-attribute set/remove listeners. +- `Purchasely.getConstants()` is kept for backward compatibility. + +--- + +## Initialization + +### Before (v5 — removed) + +```typescript +import Purchasely, { LogLevels, RunningMode } from 'react-native-purchasely' + +await Purchasely.start({ + apiKey: 'YOUR_API_KEY', + androidStores: ['Google'], + storeKit1: false, + userId: 'user_id', + logLevel: LogLevels.ERROR, + runningMode: RunningMode.FULL, +}) + +Purchasely.readyToOpenDeeplink(true) +``` + +### After (v6) + +```typescript +import Purchasely from 'react-native-purchasely' + +const configured = await Purchasely.builder('YOUR_API_KEY') + .appUserId('user_id') // optional, defaults to anonymous + .runningMode('full') // 'observer' (default) | 'full' + .logLevel('error') // 'debug' | 'info' | 'warn' | 'error' + .allowDeeplink(true) // replaces readyToOpenDeeplink(true) + .allowCampaigns(true) // automatic campaigns + .stores(['google']) // Android only: 'google' | 'huawei' | 'amazon' + .storekitVersion('storeKit2')// iOS only: 'storeKit1' | 'storeKit2' + .handleDeeplink(coldStartUrl)// optional cold-start deeplink, see note below + .start() +``` + +> **Cold-start deeplink — `.handleDeeplink(deeplink)`.** Hand the SDK the +> deeplink that launched the app from a cold start. At process launch the +> deeplink listener is not registered yet, so the builder **stores the deeplink +> and replays it once `start()` has completed** — a paywall opened by a deeplink +> is therefore not lost during startup. For deeplinks received while the app is +> already running, keep using the top-level `Purchasely.handleDeeplink(url)` +> method (see +> [Deeplinks](#deeplinks-campaigns--the-default-dismiss-handler)). + +> **⚠️ Major breaking change — the default `runningMode` is now `'observer'` +> (v5 effectively defaulted to `full`).** This is a **silent behavioural change**: +> it does **not** produce a compile error, so an app that previously let +> Purchasely own the purchase flow will **stop doing so** after upgrading unless +> it explicitly passes `.runningMode('full')`. Audit every `start()`/`builder()` +> call. The change is consistent across platforms (iOS, Android, Flutter, React +> Native), including the native fallback: any unknown/unset value now resolves to +> `observer`, never `full`. + +--- + +## Displaying a paywall + +### Before (v5 — removed) + +```typescript +const result = await Purchasely.presentPresentationForPlacement({ + placementVendorId: 'ONBOARDING', + contentId: 'my_content_id', + isFullscreen: true, +}) + +switch (result.result) { + case ProductResult.PRODUCT_RESULT_PURCHASED: + case ProductResult.PRODUCT_RESULT_RESTORED: + console.log('Purchased', result.plan?.name) + break + case ProductResult.PRODUCT_RESULT_CANCELLED: + break +} +``` + +### After (v6) + +`display()` resolves at **dismiss** with a `PLYPresentationOutcome`: + +```typescript +const outcome = await Purchasely.presentation + .placement('ONBOARDING') + .contentId('my_content_id') + .build() + .display() + +// outcome: { presentation, purchaseResult, plan, closeReason, error } +if (outcome.error) { + console.error(outcome.error.message) +} else if (outcome.purchaseResult === 'purchased' || outcome.purchaseResult === 'restored') { + console.log('Purchased', outcome.plan?.name) +} else { + console.log('Dismissed', outcome.closeReason) // 'button' | 'backSystem' | 'programmatic' +} +``` + +`purchaseResult` is now a string union (`'purchased' | 'cancelled' | 'restored'`) +instead of the `ProductResult` ordinal enum. + +### Targeting a specific screen / product / plan + +```typescript +// Specific presentation by screen id (was presentPresentationWithIdentifier) +await Purchasely.presentation.screen('SCREEN_ID').build().display() + +// Specific product (was presentProductWithIdentifier) +await Purchasely.presentation.screen('SCREEN_ID').contentId('CONTENT_ID').build().display() + +// Specific plan (was presentPlanWithIdentifier) +await Purchasely.presentation.screen('SCREEN_ID').build().display() +``` + +--- + +## Presentation builder options + +`Purchasely.presentation` (a `PLYPresentationBuilder`) chains modifiers before +`.build()`: + +| Modifier | Effect | +|----------|--------| +| `.contentId(id)` | Preselects a product/plan content id (was the `contentId` parameter on the v5 present\* calls). | +| `.backgroundColor(hex)` | Overrides the paywall background colour, e.g. `'#101828'`. | +| `.progressColor(hex)` | Overrides the loading-indicator colour. | +| `.displayCloseButton(bool)` | Toggles the close button. | +| `.displayBackButton(bool)` | Toggles the back button. | + +```typescript +await Purchasely.presentation + .placement('ONBOARDING') + .contentId('my_content_id') + .backgroundColor('#101828') + .progressColor('#FFFFFF') + .displayCloseButton(false) + .displayBackButton(false) + .build() + .display() +``` + +> **⚠️ Platform difference for the button/colour toggles.** On **Android** these +> are **full toggles** — `true` shows the element, `false` hides it. On **iOS** +> they are **removal-only**: only passing `false` has an effect (it hides the +> element); passing `true` does **not** force-show an element the screen did not +> already define. Design for the `false` case and treat the shown state as the +> screen default. + +--- + +## Display transitions — `display(transition?)` + +`request.display()` accepts an optional transition object describing **how** the +paywall is presented: + +```typescript +await request.display({ + type: 'drawer', // presentation style, see table + dismissible: true, // allow interactive dismissal + width: { type: 'percentage', value: 100 }, + height: { type: 'percentage', value: 60 }, + backgroundColors: { light: '#FFFFFF', dark: '#000000' }, +}) +``` + +| `type` | Description | +|--------|-------------| +| `'fullScreen'` | Full-screen (default). | +| `'push'` | Pushed onto the navigation stack. | +| `'modal'` | Modal presentation. | +| `'drawer'` | Bottom drawer sized by `width` / `height`. | +| `'popin'` | Centered pop-in sized by `width` / `height`. | +| `'inlinePaywall'` | Inline within your own layout. | + +- `width` / `height` are **dimension objects** — `{ type: 'pixel' | 'percentage', value }` + — and are used to size `drawer` / `popin` transitions. +- `dismissible` (bool) controls whether the user can dismiss the transition + interactively. +- `backgroundColors` sets the scrim/background per theme: `{ light, dark }`. + +> **v5 → v6 dimension change.** The v5 `heightPercentage` field is **replaced** +> by `height: { type: 'percentage', value }` (and, symmetrically, `width`). + +--- + +## Pre-fetching (preload) + +### Before (v5 — removed) + +```typescript +const presentation = await Purchasely.fetchPresentation({ placementId: 'ONBOARDING' }) +const result = await Purchasely.presentPresentation({ presentation }) +``` + +### After (v6) + +`preload()` resolves with a **`PLYLoadedPresentation`** once the screen is fully +loaded. It is a lightweight handle that **delegates back to the originating +request**, so you can drive either the request or the loaded handle: + +```typescript +const request = Purchasely.presentation.placement('ONBOARDING').build() + +const loaded = await request.preload() // PLYLoadedPresentation, screen is ready +// …later, when ready to show it (both forms are equivalent): +const outcome = await loaded.display() // or: await request.display() +``` + +`PLYLoadedPresentation` exposes the same lifecycle as the request — +`display(transition?)`, `close()` and `back()` — each delegating to the request +it was preloaded from. + +> **Platform difference for `close()`.** On **Android**, `close()` dismisses +> **all** currently displayed presentations (a limitation of the native SDK, +> which does not yet expose a per-presentation close). On **iOS**, `close()` +> dismisses only the targeted presentation. Account for this if you stack +> presentations. + +--- + +## Presentation lifecycle (show / hide / close) + +The imperative `showPresentation` / `hidePresentation` / `closePresentation` +methods are replaced by the request lifecycle: + +```typescript +const request = Purchasely.presentation.placement('ONBOARDING').build() + +request.display() // show +request.close() // hide / close +request.back() // navigate back inside a multi-step (Flow) presentation +``` + +> **Platform difference for `close()`.** On **Android**, `request.close()` +> currently dismisses **all** displayed presentations (the native SDK does not +> yet expose a per-request close), so if you stack presentations, closing one +> will dismiss the others. On **iOS**, only the targeted presentation is closed. + +--- + +## Action interceptor + +`setPaywallActionInterceptorCallback` + `onProcessAction` are replaced by +`Purchasely.interceptAction(kind, handler)`. Register **one handler per action +kind**; the handler returns `'success' | 'failed' | 'notHandled'` instead of +calling `onProcessAction(true/false)`. + +### Before (v5 — removed) + +```typescript +Purchasely.setPaywallActionInterceptorCallback((result) => { + if (result.action === PLYPaywallAction.PURCHASE) { + MyPurchaseSystem.purchase(result.parameters.plan.productId) + Purchasely.onProcessAction(false) + } else { + Purchasely.onProcessAction(true) + } +}) +``` + +### After (v6) + +```typescript +import { Linking } from 'react-native' + +Purchasely.interceptAction('purchase', async (info, payload) => { + if (payload?.kind === 'purchase') { + const ok = await MyPurchaseSystem.purchase(payload.plan.productId) + return ok ? 'success' : 'failed' + } + return 'notHandled' +}) + +Purchasely.interceptAction('navigate', async (info, payload) => { + if (payload?.kind === 'navigate') { + Linking.openURL(payload.url) + return 'success' + } + return 'notHandled' +}) + +// Cleanup +Purchasely.removeActionInterceptor('purchase') +Purchasely.removeAllActionInterceptors() +``` + +Known action kinds: `close`, `closeAll`, `login`, `navigate`, `purchase`, +`restore`, `openPresentation`, `openPlacement`, `promoCode`, `webCheckout`. + +> The `kind` argument and the `payload.kind` discriminant are typed by the +> `PLYPresentationActionKind` string union — this is the **only** action +> vocabulary in v6. The v5 `PLYPaywallAction` enum has been **removed**. + +--- + +## Deeplinks, campaigns & the default dismiss handler + +```typescript +// Allow deeplinks (replaces readyToOpenDeeplink(true)) — set at start: +await Purchasely.builder('YOUR_API_KEY').allowDeeplink(true).start() +``` + +There are **two distinct paywall flows** — don't conflate them: + +### 1. Paywalls **you** display + +When your app instantiates the presentation, read the result from that request +(`await display()` or `request.onDismissed(...)`): + +```typescript +const outcome = await Purchasely.presentation.placement('ONBOARDING').build().display() +``` + +### 2. Paywalls the **SDK** opens itself (campaigns, deeplinks, Promoted IAP) + +Your app never calls `display()` for these, so there is no request to attach a +callback to. Register the **global default dismiss handler** instead. It is the +v6 replacement for `setDefaultPresentationResultCallback` / +`setDefaultPresentationResultHandler`, and mirrors the native +`Purchasely.setDefaultPresentationDismissHandler`: + +```typescript +import Purchasely from 'react-native-purchasely' + +const subscription = Purchasely.setDefaultPresentationDismissHandler((outcome) => { + // outcome: { presentation, purchaseResult, plan, closeReason, error } + // `presentation` is always populated here — use it to tell which + // campaign/deeplink screen closed. + console.log( + 'SDK paywall dismissed:', + outcome.presentation?.screenId, + outcome.purchaseResult, // 'purchased' | 'restored' | 'cancelled' | null + outcome.closeReason // 'button' | 'backSystem' | 'programmatic' | null + ) +}) + +// Only one handler is active at a time — calling again replaces it. +// Remove it (e.g. on unmount) with either: +subscription.remove() +// …or: +Purchasely.removeDefaultPresentationDismissHandler() +``` + +> **Platform note.** `closeReason` mirrors the native `PLYCloseReason` +> (`button` / `backSystem` / `programmatic`) and is `null` when the SDK does not +> report a reason. The iOS interactive dismiss (swipe-down / nav pop) maps to +> `backSystem` for parity with Android's system back. + +```typescript +// `isDeeplinkHandled` was RENAMED to `handleDeeplink` (matches the native SDK): +const handled = await Purchasely.handleDeeplink('app://ply/presentations/') +``` + +--- + +## Synchronize (now awaitable) + +`Purchasely.synchronize()` previously returned `void` (fire-and-forget). The v6 +native SDKs expose completion callbacks (iOS `synchronize(success:failure:)`, +Android `synchronize(onSuccess:(PLYPlan?)->Unit, onError:(PLYError?)->Unit)`), +so the bridge now returns a **`Promise`** that resolves when the +receipt synchronization completes and rejects on failure. + +This is **source-compatible**: existing fire-and-forget callers keep working +(they just ignore the returned promise). New code can await it: + +```typescript +try { + await Purchasely.synchronize() // resolves when the sync finishes + console.log('Synchronized') +} catch (e) { + console.error('Synchronize failed', e) // e.g. PLYError.NoStoreConfigured +} +``` + +> In Observer mode after a host-side purchase, `await Purchasely.synchronize()` +> before chaining a follow-up placement so the receipt is uploaded first. + +--- + +## What's UNCHANGED + +All **core** SDK methods are unchanged in name, signature, and behaviour. Only +the v5 *paywall* surface was removed (plus `synchronize`, which gained an +awaitable result — see above). The following keep working exactly as in v5: + +- **User**: `userLogin`, `userLogout`, `getAnonymousUserId`, `isAnonymous`. +- **Products**: `allProducts`, `productWithIdentifier`, `planWithIdentifier`, + `purchaseWithPlanVendorId`, `signPromotionalOffer`, `isEligibleForIntroOffer`, + `setDynamicOffering`, `getDynamicOfferings`, `removeDynamicOffering`, + `clearDynamicOfferings`. +- **Subscriptions data**: `userSubscriptions`, `userSubscriptionsHistory`, + `restoreAllProducts`, `silentRestoreAllProducts`, + `userDidConsumeSubscriptionContent`. + +> **Removed:** `presentSubscriptions()` no longer exists (iOS **and** Android). +> The native v6 SDKs dropped the built-in subscription-list UI — build your own +> screen from `userSubscriptions()` / `userSubscriptionsHistory()`. +- **Attributes**: `setUserAttributeWith{String,Number,Boolean,Date,StringArray,NumberArray,BooleanArray}`, + `incrementUserAttribute`, `decrementUserAttribute`, `userAttributes`, + `userAttribute`, `clearUserAttribute`, `clearUserAttributes`, + `clearBuiltInAttributes`, `setAttribute`. +- **Listeners**: `addEventListener` / `removeEventListener`, + `addPurchasedListener` / `removePurchasedListener`, + `addUserAttributeSetListener` / `removeUserAttributeSetListener`, + `addUserAttributeRemovedListener` / `removeUserAttributeRemovedListener`. +- **Client (BYOS) presentations**: `clientPresentationDisplayed(presentation)` + / `clientPresentationClosed(presentation)` — unchanged. Preload via the + request lifecycle (`preload()` → inspect `PLYPresentationType.CLIENT` → + render your own UI), then notify Purchasely with these two methods, passing + the presentation resolved by `preload()`. (Internally, the iOS native call + was renamed `clientPresentationOpened` → `clientPresentationDisplayed`; no + JS change.) +- **Misc**: `setLogLevel`, `setLanguage`, `setThemeMode`, `setDebugMode`, + `revokeDataProcessingConsent`, `getConstants`. +- **Embedded component**: `PLYPresentationView` — unchanged. + +--- + +## Need a hand? + +Use the Purchasely AI plugin / skills (`purchasely-integrate`, +`purchasely-review`, `purchasely-debug`) to scan your project and apply this +migration automatically. diff --git a/README.md b/README.md index 5f926cf0..c88cb029 100644 --- a/README.md +++ b/README.md @@ -10,31 +10,32 @@ npm install react-native-purchasely ## 🔧 Setup +> **v6** — the SDK is initialized and paywalls are displayed with the chainable +> builder API. The legacy v5 paywall API (`start({...})`, `startWithAPIKey`, +> `presentPresentationForPlacement`, `fetchPresentation`, +> `setPaywallActionInterceptorCallback`, …) has been **removed**. See +> [`MIGRATION-v6.md`](./MIGRATION-v6.md) for the full old→new mapping. + Add the following code in the root of your project (typically `App.tsx` in a React Native project): ```ts -import Purchasely, { LogLevels, RunningMode } from 'react-native-purchasely' - -Purchasely.startWithAPIKey( - 'afa96c76-1d8e-4e3c-a48f-204a3cd93a15', - ['Google'], // List of stores for Android, accepted values: Google, Huawei, and Amazon - null, // Your user ID - LogLevels.DEBUG, // Log level, should be warning or error in production - RunningMode.FULL // Running mode -).then( - (configured) => { - if (!configured) { - console.log('Purchasely SDK not properly initialized') - return - } - - console.log('Purchasely SDK is initialized') - setupPurchasely() - }, - (error) => { - console.log('Purchasely SDK initialization error', error) - } -) +import Purchasely from 'react-native-purchasely' + +const configured = await Purchasely.builder('afa96c76-1d8e-4e3c-a48f-204a3cd93a15') + .stores(['google']) // Android stores: 'google' | 'huawei' | 'amazon' + .appUserId(null) // your user ID, or null for anonymous + .logLevel('debug') // 'warn' or 'error' in production + .runningMode('full') // 'observer' (default) | 'full' + .allowDeeplink(true) + .storekitVersion('storeKit2') // iOS only + .start() + +if (!configured) { + console.log('Purchasely SDK not properly initialized') +} else { + console.log('Purchasely SDK is initialized') + setupPurchasely() +} ``` ## 🎬 Usage @@ -42,28 +43,25 @@ Purchasely.startWithAPIKey( ### 1️⃣ Full Screen Paywall ```ts -import Purchasely, { - PLYPresentationType, - ProductResult, -} from 'react-native-purchasely' +import Purchasely from 'react-native-purchasely' try { - const result = await Purchasely.presentPresentationForPlacement({ - placementVendorId: 'composer', - loadingBackgroundColor: '#FFFFFFFF', - }) - - console.log('Result is ' + result.result) - - switch (result.result) { - case ProductResult.PRODUCT_RESULT_PURCHASED: - case ProductResult.PRODUCT_RESULT_RESTORED: - if (result.plan != null) { - console.log('User purchased ' + result.plan.name) - } - break - case ProductResult.PRODUCT_RESULT_CANCELLED: - break + // display() resolves at dismiss with a PresentationOutcome + const outcome = await Purchasely.presentation + .placement('composer') + .backgroundColor('#FFFFFFFF') + .build() + .display() + + if (outcome.error) { + console.error(outcome.error.message) + } else if ( + outcome.purchaseResult === 'purchased' || + outcome.purchaseResult === 'restored' + ) { + console.log('User purchased ' + outcome.plan?.name) + } else { + console.log('Dismissed: ' + outcome.closeReason) } } catch (e) { console.error(e) @@ -72,72 +70,30 @@ try { ### 2️⃣ Nested View Paywall +The embedded `PLYPresentationView` component is part of the **core** API and is +unchanged in v6. Pass a `placementId` directly — no manual pre-fetch step is +required. + ```ts import { Text, View } from 'react-native'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { Header } from 'react-native/Libraries/NewAppScreen'; import { Section } from './Section.tsx'; -import Purchasely, { - PLYPresentationView, - PresentPresentationResult, - ProductResult, - PurchaselyPresentation, -} from 'react-native-purchasely'; -import { useEffect, useState } from 'react'; +import { PLYPresentationView } from 'react-native-purchasely'; export const PaywallScreen: React.FC> = ({ navigation }) => { - const [purchaselyPresentation, setPurchaselyPresentation] = useState(); - - useEffect(() => { - fetchPresentation(); - }, []); - - const fetchPresentation = async () => { - try { - setPurchaselyPresentation( - await Purchasely.fetchPresentation({ - placementId: 'ONBOARDING', - contentId: null, - }) - ); - } catch (e) { - console.error(e); - } - }; - - const callback = (result: PresentPresentationResult) => { - console.log('### Paywall closed'); - console.log('### Result is ' + result.result); - switch (result.result) { - case ProductResult.PRODUCT_RESULT_PURCHASED: - case ProductResult.PRODUCT_RESULT_RESTORED: - if (result.plan != null) { - console.log('User purchased ' + result.plan.name); - } - break; - case ProductResult.PRODUCT_RESULT_CANCELLED: - console.log('User cancelled'); - break; - } + const callback = (result: any) => { + console.log('### Paywall closed, result is ' + result.result); navigation.goBack(); }; - if (purchaselyPresentation == null) { - return ( - - Loading ... - - ); - } - return (
callback(res)} + onPresentationClosed={(res) => callback(res)} />
@@ -149,10 +105,60 @@ export const PaywallScreen: React.FC> = ({ navigatio }; ``` +### 3️⃣ Custom Screens inside a flow + +Register one React component with `AppRegistry`, then register its name after +the SDK starts. Purchasely mounts that component when a flow reaches a screen +configured as client-authored in the Console. + +```tsx +// index.js +AppRegistry.registerComponent( + 'PurchaselyCustomScreen', + () => PurchaselyCustomScreen, +) + +// after await Purchasely.builder(...).start() +await Purchasely.setCustomScreenProvider({ + componentName: 'PurchaselyCustomScreen', +}) + +function PurchaselyCustomScreen(props: PLYCustomScreenProps) { + const { presentation, executeConnection, back, close } = + usePurchaselyCustomScreen(props) + + return ( + + {presentation.screenId} + {presentation.connections?.map((connection, index) => ( +