@mysuperwall/react-native 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +157 -2
- package/lib/MySuperwall.d.ts +64 -0
- package/lib/MySuperwall.js +249 -0
- package/lib/engine/localRuleEngine.d.ts +44 -0
- package/lib/engine/localRuleEngine.js +318 -0
- package/lib/hooks/usePaywall.d.ts +7 -0
- package/lib/hooks/usePaywall.js +25 -0
- package/lib/index.d.ts +11 -0
- package/lib/index.js +27 -0
- package/lib/models/manifest.d.ts +76 -0
- package/lib/models/manifest.js +2 -0
- package/lib/models/paywall.d.ts +36 -0
- package/lib/models/paywall.js +2 -0
- package/lib/services/anonymousIdentity.d.ts +6 -0
- package/lib/services/anonymousIdentity.js +36 -0
- package/lib/services/followUpService.d.ts +9 -0
- package/lib/services/followUpService.js +26 -0
- package/lib/services/manifestService.d.ts +21 -0
- package/lib/services/manifestService.js +80 -0
- package/lib/services/offlineEventQueue.d.ts +18 -0
- package/lib/services/offlineEventQueue.js +101 -0
- package/lib/ui/PaywallModal.d.ts +8 -0
- package/lib/ui/PaywallModal.js +136 -0
- package/package.json +51 -4
- package/src/MySuperwall.ts +272 -0
- package/src/engine/localRuleEngine.ts +375 -0
- package/src/hooks/usePaywall.ts +27 -0
- package/src/index.ts +12 -0
- package/src/models/manifest.ts +81 -0
- package/src/models/paywall.ts +40 -0
- package/src/services/anonymousIdentity.ts +28 -0
- package/src/services/followUpService.ts +21 -0
- package/src/services/manifestService.ts +63 -0
- package/src/services/offlineEventQueue.ts +88 -0
- package/src/ui/PaywallModal.tsx +122 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
- Add compiled JavaScript and TypeScript declarations to npm packages.
|
|
6
|
+
- Keep native integrations as peer dependencies, without pinning host apps to one major.
|
|
7
|
+
- Persist anonymous installation identity per application and server.
|
|
8
|
+
- Send Android package identifiers as packageName for manifests and analytics.
|
|
9
|
+
- Export initialization option types and include the MIT license.
|
|
10
|
+
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MySuperWall
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,158 @@
|
|
|
1
|
-
#
|
|
1
|
+
# MySuperwall React Native SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Official React Native SDK for [MySuperwall](https://mysuperwall.com). Remotely configure, evaluate, and display dynamic paywalls in your React Native apps using cached, on-device rule evaluation.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **⚡ Local rule evaluation:** Evaluates audience rules and deterministic A/B variants locally in-memory on the device.
|
|
8
|
+
- **💾 Offline Cache:** Automatically caches active paywall manifests with `@react-native-async-storage/async-storage` for instant cold starts without network wait.
|
|
9
|
+
- **🔄 ETag / 304 Synchronization:** Background sync fetches updates with zero bandwidth waste when rules haven't changed.
|
|
10
|
+
- **🎨 HTML/CSS Dynamic Paywalls:** High-performance `react-native-webview` rendering with two-way JS bridge.
|
|
11
|
+
- **📊 Offline Event Queue:** Fire-and-forget analytics event batching to `POST /paywall/events/batch`.
|
|
12
|
+
|
|
13
|
+
## Compatibility
|
|
14
|
+
|
|
15
|
+
The package includes compiled JavaScript and TypeScript declarations. Native WebView
|
|
16
|
+
and AsyncStorage remain peer dependencies so your app controls their versions.
|
|
17
|
+
|
|
18
|
+
Verified dependency combinations (installation and TypeScript checks):
|
|
19
|
+
|
|
20
|
+
| React | React Native | WebView | AsyncStorage |
|
|
21
|
+
| --- | --- | --- | --- |
|
|
22
|
+
| 18.2.0 | 0.73.4 | 13.8.1 | 1.21.0 |
|
|
23
|
+
| 19.2.3 | 0.87.1 | 14.0.1 | 3.1.1 |
|
|
24
|
+
|
|
25
|
+
For modern React Native use your app's `moduleResolution: "bundler"` TypeScript configuration.
|
|
26
|
+
These checks do not replace an iOS/Android device integration test. Versions below
|
|
27
|
+
these combinations are not covered by this audit, even if the peer range permits them.
|
|
28
|
+
Cached manifests are available after initialization; the first installation still
|
|
29
|
+
needs a successful background manifest download before a trigger can resolve.
|
|
30
|
+
For Android pass `platform: 'android'`; `bundleId` is sent as the Android package name.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npm install @mysuperwall/react-native react-native-webview @react-native-async-storage/async-storage
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Choose WebView and AsyncStorage versions compatible with your existing React Native
|
|
39
|
+
or Expo project. For Expo-managed dependencies use `npx expo install`.
|
|
40
|
+
Bare iOS projects also require `pod install` from the `ios` directory.
|
|
41
|
+
Expo requires a native development build.
|
|
42
|
+
|
|
43
|
+
Existing checksum-pinned source snapshots remain an alternative installation method.
|
|
44
|
+
Do not install both the snapshot and npm package in the same app.
|
|
45
|
+
|
|
46
|
+
## Quick Start
|
|
47
|
+
|
|
48
|
+
### 1. Initialize on App Launch
|
|
49
|
+
|
|
50
|
+
In your root `App.tsx` or `index.js`:
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
import React, { useEffect } from 'react';
|
|
54
|
+
import { Platform } from 'react-native';
|
|
55
|
+
import { MySuperwall, PaywallModal, usePaywall } from '@mysuperwall/react-native';
|
|
56
|
+
|
|
57
|
+
export default function App() {
|
|
58
|
+
const { activePaywall, isVisible, dismiss, callbacks } = usePaywall();
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
MySuperwall.initialize({
|
|
62
|
+
appKey: 'YOUR_APP_KEY',
|
|
63
|
+
platform: Platform.OS === 'android' ? 'android' : 'ios'
|
|
64
|
+
});
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<>
|
|
69
|
+
<MainAppScreen />
|
|
70
|
+
<PaywallModal
|
|
71
|
+
visible={isVisible}
|
|
72
|
+
paywall={activePaywall}
|
|
73
|
+
onClose={dismiss}
|
|
74
|
+
{...callbacks}
|
|
75
|
+
/>
|
|
76
|
+
</>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 2. Trigger a Paywall
|
|
82
|
+
|
|
83
|
+
Fires immediately with **no network request for cached rules**:
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
MySuperwall.shared.handleTrigger('onboarding_finish', {
|
|
87
|
+
onPurchase: async (productId) => {
|
|
88
|
+
console.log('User wants to purchase:', productId);
|
|
89
|
+
// Call your IAP flow (RevenueCat, react-native-iap)
|
|
90
|
+
},
|
|
91
|
+
onRestore: () => {
|
|
92
|
+
console.log('User requested restore');
|
|
93
|
+
},
|
|
94
|
+
onDismiss: () => {
|
|
95
|
+
console.log('Paywall closed');
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 3. User Attributes & Identification
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
// Identify authenticated users
|
|
104
|
+
MySuperwall.shared.identify('user_12345');
|
|
105
|
+
|
|
106
|
+
// Set custom attributes for audience targeting
|
|
107
|
+
MySuperwall.shared.setUserAttributes({
|
|
108
|
+
tier: 'gold',
|
|
109
|
+
level: 15,
|
|
110
|
+
isSubscriber: false
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
### Experiment attribution
|
|
116
|
+
|
|
117
|
+
Manifest resolution retains optional server-issued experiment, audience, variant and
|
|
118
|
+
published-paywall version identifiers. Older manifests remain usable without this
|
|
119
|
+
context. Fetching or resolving alone does not record an impression. Built-in UI
|
|
120
|
+
records `paywall_shown` after the page loads while displayed; custom presenters
|
|
121
|
+
must call `trackPaywallEvent` when they actually show the paywall.
|
|
122
|
+
|
|
123
|
+
Purchase and restore button requests record `purchase_started` and
|
|
124
|
+
`restore_started`, not successful transactions. After your billing provider confirms
|
|
125
|
+
a purchase, pass the **original displayed paywall** to the SDK:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
await MySuperwall.shared.trackPaywallEvent('purchase_success', displayedPaywall, productId)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Do not resolve the trigger again to attribute a completed purchase. An identity
|
|
132
|
+
change invalidates attribution from the previous user's paywall. Offline retries
|
|
133
|
+
retain the original event ID and context; events are removed by acknowledged ID.
|
|
134
|
+
Verified revenue remains a separate backend/provider concern.
|
|
135
|
+
|
|
136
|
+
## Locale, targeting and recovery (1.1.0)
|
|
137
|
+
|
|
138
|
+
Initialize with optional `locale`, `country` (ISO region, e.g. `TR`) and `appVersion`.
|
|
139
|
+
The default language and region come from the device's Intl locale when available.
|
|
140
|
+
React Native does not provide a universal built-in app-version API: pass your real
|
|
141
|
+
version from your existing app-info integration, or call `setAppVersion(version)`.
|
|
142
|
+
`await MySuperwall.shared.setLocale('tr')` switches to the language-specific cache
|
|
143
|
+
and refreshes its manifest; `setCountry('US')` does not change the paywall language.
|
|
144
|
+
Call `setSubscriptionStatus(true/false)` from your billing entitlement state for
|
|
145
|
+
`skipIfPremium` recovery rules.
|
|
146
|
+
|
|
147
|
+
`handleTrigger` automatically handles configured trigger-based follow-up rules and
|
|
148
|
+
the legacy dismiss second screen. Explicit rules take priority. Session/cooldown,
|
|
149
|
+
chain-depth and repeat checks bound the flow. A purchase tap does not run a success
|
|
150
|
+
rule: the host must call `trackPaywallEvent('purchase_success', displayedPaywall)`
|
|
151
|
+
after its billing provider confirms success. Use the captured presenter payload.
|
|
152
|
+
Identity/configuration/language changes cancel delayed recovery. The stock modal
|
|
153
|
+
calls the dismissal callback once for either a close action or Android Back.
|
|
154
|
+
Programmatic dismissal through the hook clears the modal without starting recovery.
|
|
155
|
+
|
|
156
|
+
Manifest and offline event storage are isolated by app/server scope. Unscoped legacy
|
|
157
|
+
caches are ignored; source updates need one successful manifest sync before offline use.
|
|
158
|
+
No store billing is replaced by this SDK.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { LocalRuleEngine } from './engine/localRuleEngine';
|
|
2
|
+
import { ManifestService } from './services/manifestService';
|
|
3
|
+
import { OfflineEventQueue } from './services/offlineEventQueue';
|
|
4
|
+
import { PaywallResult, PaywallData, PaywallCallbackActions } from './models/paywall';
|
|
5
|
+
export interface MySuperwallInitOptions {
|
|
6
|
+
appKey?: string;
|
|
7
|
+
userId?: string;
|
|
8
|
+
baseUrl?: string;
|
|
9
|
+
bundleId?: string;
|
|
10
|
+
platform?: 'ios' | 'android' | 'web';
|
|
11
|
+
locale?: string;
|
|
12
|
+
country?: string;
|
|
13
|
+
appVersion?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare class MySuperwall {
|
|
16
|
+
private static instance;
|
|
17
|
+
appKey?: string;
|
|
18
|
+
userId?: string;
|
|
19
|
+
baseUrl: string;
|
|
20
|
+
bundleId?: string;
|
|
21
|
+
platform: string;
|
|
22
|
+
locale: string;
|
|
23
|
+
country: string;
|
|
24
|
+
appVersion?: string;
|
|
25
|
+
private premium;
|
|
26
|
+
private epoch;
|
|
27
|
+
private presentationSequence;
|
|
28
|
+
private followUps;
|
|
29
|
+
private activeFlow?;
|
|
30
|
+
userAttributes: Record<string, any>;
|
|
31
|
+
manifestService: ManifestService;
|
|
32
|
+
ruleEngine: LocalRuleEngine;
|
|
33
|
+
eventQueue: OfflineEventQueue;
|
|
34
|
+
private onPresentPaywallListener?;
|
|
35
|
+
private constructor();
|
|
36
|
+
static get shared(): MySuperwall;
|
|
37
|
+
/**
|
|
38
|
+
* Initializes MySuperwall with cached local rule evaluation.
|
|
39
|
+
*/
|
|
40
|
+
static initialize(options?: MySuperwallInitOptions): Promise<MySuperwall>;
|
|
41
|
+
identify(userId: string): void;
|
|
42
|
+
private resetFlow;
|
|
43
|
+
private manifestOptions;
|
|
44
|
+
setLocale(locale: string): Promise<void>;
|
|
45
|
+
setCountry(country: string): void;
|
|
46
|
+
setAppVersion(version: string): void;
|
|
47
|
+
setSubscriptionStatus(premium: boolean): void;
|
|
48
|
+
setUserAttributes(attributes: Record<string, any>): void;
|
|
49
|
+
registerPresenter(listener: (paywall: PaywallData, callbacks: PaywallCallbackActions) => void): () => void;
|
|
50
|
+
/**
|
|
51
|
+
* Evaluates rules locally in-memory in memory.
|
|
52
|
+
*/
|
|
53
|
+
resolveTrigger(triggerKey: string, metadata?: Record<string, any>): PaywallResult;
|
|
54
|
+
/** Attribute actions to the displayed paywall, never a new assignment. */
|
|
55
|
+
trackPaywallEvent(type: string, paywall: PaywallData, productId?: string): Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Fires a trigger, presents the paywall if resolved, and wires up callbacks.
|
|
58
|
+
*/
|
|
59
|
+
handleTrigger(triggerKey: string, callbacks?: PaywallCallbackActions & {
|
|
60
|
+
metadata?: Record<string, any>;
|
|
61
|
+
}): PaywallResult;
|
|
62
|
+
private present;
|
|
63
|
+
private scheduleFollowUp;
|
|
64
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MySuperwall = void 0;
|
|
4
|
+
const anonymousIdentity_1 = require("./services/anonymousIdentity");
|
|
5
|
+
const followUpService_1 = require("./services/followUpService");
|
|
6
|
+
const localRuleEngine_1 = require("./engine/localRuleEngine");
|
|
7
|
+
const manifestService_1 = require("./services/manifestService");
|
|
8
|
+
const offlineEventQueue_1 = require("./services/offlineEventQueue");
|
|
9
|
+
class MySuperwall {
|
|
10
|
+
constructor() {
|
|
11
|
+
this.baseUrl = 'https://mysuperwall.com/';
|
|
12
|
+
this.platform = 'ios';
|
|
13
|
+
this.locale = 'en';
|
|
14
|
+
this.country = '';
|
|
15
|
+
this.premium = false;
|
|
16
|
+
this.epoch = 0;
|
|
17
|
+
this.presentationSequence = 0;
|
|
18
|
+
this.followUps = new followUpService_1.FollowUpService();
|
|
19
|
+
this.userAttributes = {};
|
|
20
|
+
this.manifestService = new manifestService_1.ManifestService();
|
|
21
|
+
this.ruleEngine = new localRuleEngine_1.LocalRuleEngine();
|
|
22
|
+
this.eventQueue = new offlineEventQueue_1.OfflineEventQueue();
|
|
23
|
+
}
|
|
24
|
+
static get shared() {
|
|
25
|
+
if (!MySuperwall.instance) {
|
|
26
|
+
MySuperwall.instance = new MySuperwall();
|
|
27
|
+
}
|
|
28
|
+
return MySuperwall.instance;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Initializes MySuperwall with cached local rule evaluation.
|
|
32
|
+
*/
|
|
33
|
+
static async initialize(options = {}) {
|
|
34
|
+
const inst = MySuperwall.shared;
|
|
35
|
+
inst.resetFlow();
|
|
36
|
+
inst.userAttributes = {};
|
|
37
|
+
inst.premium = false;
|
|
38
|
+
inst.appKey = options.appKey;
|
|
39
|
+
inst.baseUrl = options.baseUrl
|
|
40
|
+
? options.baseUrl.endsWith('/')
|
|
41
|
+
? options.baseUrl
|
|
42
|
+
: `${options.baseUrl}/`
|
|
43
|
+
: 'https://mysuperwall.com/';
|
|
44
|
+
inst.bundleId = options.bundleId;
|
|
45
|
+
inst.platform = options.platform || 'ios';
|
|
46
|
+
let deviceLocale = 'en';
|
|
47
|
+
try {
|
|
48
|
+
deviceLocale = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
49
|
+
}
|
|
50
|
+
catch (_) { }
|
|
51
|
+
inst.locale = (options.locale || deviceLocale).replace(/_/g, '-').toLowerCase();
|
|
52
|
+
inst.country = (options.country || inst.locale.match(/-([a-z]{2})(?:-|$)/i)?.[1] || '').toUpperCase();
|
|
53
|
+
inst.appVersion = options.appVersion;
|
|
54
|
+
inst.manifestService.configure(inst.manifestOptions());
|
|
55
|
+
inst.eventQueue = new offlineEventQueue_1.OfflineEventQueue(JSON.stringify([inst.baseUrl, inst.appKey, inst.bundleId, inst.platform]));
|
|
56
|
+
const epoch = inst.epoch;
|
|
57
|
+
const userId = options.userId?.trim() || await anonymousIdentity_1.AnonymousIdentity.get(JSON.stringify([inst.baseUrl, inst.appKey, inst.bundleId, inst.platform]));
|
|
58
|
+
if (epoch !== inst.epoch)
|
|
59
|
+
return inst;
|
|
60
|
+
inst.userId = userId;
|
|
61
|
+
// 1. Load this app’s cached manifest into memory
|
|
62
|
+
await inst.manifestService.loadFromDisk();
|
|
63
|
+
if (epoch !== inst.epoch)
|
|
64
|
+
return inst;
|
|
65
|
+
// 2. Background non-blocking sync with ETag / 304 validation
|
|
66
|
+
void inst.manifestService.syncInBackground(inst.manifestOptions());
|
|
67
|
+
return inst;
|
|
68
|
+
}
|
|
69
|
+
identify(userId) {
|
|
70
|
+
if (!userId.trim())
|
|
71
|
+
throw new Error('User ID cannot be empty');
|
|
72
|
+
if (this.userId !== userId.trim()) {
|
|
73
|
+
this.resetFlow();
|
|
74
|
+
this.userAttributes = {};
|
|
75
|
+
this.premium = false;
|
|
76
|
+
}
|
|
77
|
+
this.userId = userId.trim();
|
|
78
|
+
}
|
|
79
|
+
resetFlow() { this.epoch++; this.followUps.reset(); this.activeFlow = undefined; }
|
|
80
|
+
manifestOptions() {
|
|
81
|
+
return { baseUrl: this.baseUrl, appKey: this.appKey, bundleId: this.bundleId, platform: this.platform, lang: this.locale };
|
|
82
|
+
}
|
|
83
|
+
async setLocale(locale) {
|
|
84
|
+
const normalized = locale.trim().replace(/_/g, '-').toLowerCase();
|
|
85
|
+
if (!normalized || normalized === this.locale)
|
|
86
|
+
return;
|
|
87
|
+
this.resetFlow();
|
|
88
|
+
this.locale = normalized;
|
|
89
|
+
const epoch = this.epoch;
|
|
90
|
+
this.manifestService.configure(this.manifestOptions());
|
|
91
|
+
await this.manifestService.loadFromDisk();
|
|
92
|
+
if (epoch === this.epoch)
|
|
93
|
+
await this.manifestService.syncInBackground(this.manifestOptions());
|
|
94
|
+
}
|
|
95
|
+
setCountry(country) { this.country = country.trim().toUpperCase(); }
|
|
96
|
+
setAppVersion(version) { this.appVersion = version; }
|
|
97
|
+
setSubscriptionStatus(premium) { this.premium = premium; }
|
|
98
|
+
setUserAttributes(attributes) {
|
|
99
|
+
Object.assign(this.userAttributes, attributes);
|
|
100
|
+
}
|
|
101
|
+
registerPresenter(listener) {
|
|
102
|
+
this.onPresentPaywallListener = listener;
|
|
103
|
+
return () => {
|
|
104
|
+
if (this.onPresentPaywallListener === listener) {
|
|
105
|
+
this.onPresentPaywallListener = undefined;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Evaluates rules locally in-memory in memory.
|
|
111
|
+
*/
|
|
112
|
+
resolveTrigger(triggerKey, metadata) {
|
|
113
|
+
const manifest = this.manifestService.manifest;
|
|
114
|
+
if (!manifest) {
|
|
115
|
+
return { show: false, paywall: null, reason: 'manifest_not_cached' };
|
|
116
|
+
}
|
|
117
|
+
const trigger = (manifest.triggers || []).find(t => t.key.trim().toLowerCase() === triggerKey.trim().toLowerCase());
|
|
118
|
+
if (!trigger) {
|
|
119
|
+
return { show: false, paywall: null, reason: 'trigger_not_found' };
|
|
120
|
+
}
|
|
121
|
+
const evalContext = {
|
|
122
|
+
platform: this.platform,
|
|
123
|
+
country: this.country,
|
|
124
|
+
appVersion: this.appVersion,
|
|
125
|
+
metadata: metadata || {},
|
|
126
|
+
userAttributes: this.userAttributes,
|
|
127
|
+
...(metadata || {})
|
|
128
|
+
};
|
|
129
|
+
const resolution = this.ruleEngine.resolveTrigger(trigger, this.userId, evalContext);
|
|
130
|
+
if (resolution.isHoldout) {
|
|
131
|
+
return { show: false, paywall: null, reason: 'holdout' };
|
|
132
|
+
}
|
|
133
|
+
if (resolution.show && resolution.paywallId) {
|
|
134
|
+
const manifestPaywall = manifest.paywalls[resolution.paywallId];
|
|
135
|
+
if (manifestPaywall) {
|
|
136
|
+
const paywallData = {
|
|
137
|
+
followUpRules: manifestPaywall.contentJson?.followUpRules,
|
|
138
|
+
dismissFollowUpPaywallId: trigger.dismissAction?.type === 'follow_up_paywall' ? trigger.dismissAction.followUpPaywallId || undefined : undefined,
|
|
139
|
+
id: manifestPaywall.id,
|
|
140
|
+
name: manifestPaywall.name,
|
|
141
|
+
html: manifestPaywall.html,
|
|
142
|
+
productIds: manifestPaywall.productIds,
|
|
143
|
+
products: manifestPaywall.products,
|
|
144
|
+
triggerKey,
|
|
145
|
+
abVariant: resolution.abVariant || 'A',
|
|
146
|
+
attributionUserId: this.userId,
|
|
147
|
+
attributionScope: manifestService_1.ManifestService.scopeKey(this.manifestOptions()),
|
|
148
|
+
experimentContext: (() => {
|
|
149
|
+
const rule = trigger.rules.find(rule => rule.id === resolution.matchedRuleId);
|
|
150
|
+
const audience = rule || trigger;
|
|
151
|
+
const matches = audience.variants.filter(variant => variant.paywallId === manifestPaywall.id && variant.variant === resolution.abVariant);
|
|
152
|
+
const variant = matches.length === 1 ? matches[0] : undefined;
|
|
153
|
+
if (!variant?.variantId || !audience.experimentId || !audience.experimentVersion ||
|
|
154
|
+
!audience.audienceId || manifestPaywall.publishedVersion == null)
|
|
155
|
+
return undefined;
|
|
156
|
+
return { experimentId: audience.experimentId, experimentVersion: audience.experimentVersion,
|
|
157
|
+
audienceId: audience.audienceId, variantId: variant.variantId,
|
|
158
|
+
variantLabel: variant.variantLabel || variant.variant,
|
|
159
|
+
paywallId: manifestPaywall.id, paywallVersion: manifestPaywall.publishedVersion };
|
|
160
|
+
})()
|
|
161
|
+
};
|
|
162
|
+
return { show: true, paywall: paywallData, reason: resolution.reason };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { show: false, paywall: null, reason: resolution.reason };
|
|
166
|
+
}
|
|
167
|
+
/** Attribute actions to the displayed paywall, never a new assignment. */
|
|
168
|
+
async trackPaywallEvent(type, paywall, productId) {
|
|
169
|
+
if (paywall.attributionScope !== undefined && paywall.attributionScope !== manifestService_1.ManifestService.scopeKey(this.manifestOptions()))
|
|
170
|
+
return;
|
|
171
|
+
if (paywall.attributionUserId !== undefined && paywall.attributionUserId !== this.userId)
|
|
172
|
+
return;
|
|
173
|
+
const flow = this.activeFlow;
|
|
174
|
+
if (flow && flow.paywall.presentationId === paywall.presentationId && flow.epoch === this.epoch) {
|
|
175
|
+
const keys = { purchase: 'onPurchase', purchase_success: 'onPurchase', purchase_failed: 'onPurchaseFailed', restore: 'onRestore', restore_success: 'onRestore' };
|
|
176
|
+
if (keys[type]) {
|
|
177
|
+
flow.outcome = true;
|
|
178
|
+
this.scheduleFollowUp(flow, keys[type]);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
await this.eventQueue.enqueue({ type, paywallId: paywall.id,
|
|
182
|
+
triggerKey: paywall.triggerKey, abVariant: paywall.abVariant,
|
|
183
|
+
experimentContext: paywall.attributionUserId === this.userId ? paywall.experimentContext : undefined, userId: this.userId, productId });
|
|
184
|
+
await this.eventQueue.flush({ baseUrl: this.baseUrl, appKey: this.appKey,
|
|
185
|
+
bundleId: this.bundleId, platform: this.platform });
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Fires a trigger, presents the paywall if resolved, and wires up callbacks.
|
|
189
|
+
*/
|
|
190
|
+
handleTrigger(triggerKey, callbacks) {
|
|
191
|
+
this.epoch++;
|
|
192
|
+
this.followUps.cancel();
|
|
193
|
+
this.activeFlow = undefined;
|
|
194
|
+
const result = this.resolveTrigger(triggerKey, callbacks?.metadata);
|
|
195
|
+
if (result.show && result.paywall) {
|
|
196
|
+
const paywall = this.present(result.paywall, callbacks || {}, callbacks?.metadata, 0, []);
|
|
197
|
+
return { ...result, paywall };
|
|
198
|
+
}
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
present(paywall, callbacks, metadata, depth, seen) {
|
|
202
|
+
paywall = { ...paywall, presentationId: `${this.epoch}:${++this.presentationSequence}` };
|
|
203
|
+
const flow = { paywall, callbacks, metadata, depth, seen: [...seen, paywall.id], epoch: this.epoch };
|
|
204
|
+
this.activeFlow = flow;
|
|
205
|
+
let dismissed = false;
|
|
206
|
+
this.onPresentPaywallListener?.(paywall, { ...callbacks, onDismiss: () => {
|
|
207
|
+
if (dismissed)
|
|
208
|
+
return;
|
|
209
|
+
dismissed = true;
|
|
210
|
+
if (this.activeFlow !== flow || flow.epoch !== this.epoch)
|
|
211
|
+
return;
|
|
212
|
+
if (!flow.outcome)
|
|
213
|
+
this.scheduleFollowUp(flow, 'onDismiss');
|
|
214
|
+
void callbacks.onDismiss?.();
|
|
215
|
+
} });
|
|
216
|
+
return paywall;
|
|
217
|
+
}
|
|
218
|
+
scheduleFollowUp(flow, event) {
|
|
219
|
+
const rule = flow.paywall.followUpRules?.[event];
|
|
220
|
+
const valid = () => this.activeFlow === flow && flow.epoch === this.epoch && flow.paywall.attributionUserId === this.userId;
|
|
221
|
+
if (!valid())
|
|
222
|
+
return;
|
|
223
|
+
if (rule?.enabled && (rule.actionType || 'trigger') === 'trigger' && rule.triggerKey?.trim()) {
|
|
224
|
+
this.followUps.schedule(`${flow.paywall.id}:${event}:${rule.triggerKey}`, rule, flow.depth, this.premium, () => {
|
|
225
|
+
if (!valid())
|
|
226
|
+
return;
|
|
227
|
+
const result = this.resolveTrigger(rule.triggerKey, flow.metadata);
|
|
228
|
+
if (!result.show || !result.paywall || (rule.skipIfPremium && this.premium) ||
|
|
229
|
+
(rule.preventRepeatPaywall !== false && flow.seen.includes(result.paywall.id)))
|
|
230
|
+
return;
|
|
231
|
+
this.present(result.paywall, flow.callbacks, flow.metadata, flow.depth + 1, flow.seen);
|
|
232
|
+
});
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// Legacy close recovery is one additional screen, never an unbounded chain.
|
|
236
|
+
const id = flow.paywall.dismissFollowUpPaywallId;
|
|
237
|
+
if (event !== 'onDismiss' || flow.depth !== 0 || !id || flow.seen.includes(id))
|
|
238
|
+
return;
|
|
239
|
+
const next = this.manifestService.manifest?.paywalls[id];
|
|
240
|
+
if (!next)
|
|
241
|
+
return;
|
|
242
|
+
this.followUps.schedule(`${flow.paywall.id}:legacy`, { enabled: true, actionType: 'trigger', triggerKey: 'legacy', maxChainDepth: 1 }, 0, false, () => {
|
|
243
|
+
if (valid())
|
|
244
|
+
this.present({ ...next, attributionUserId: this.userId, attributionScope: manifestService_1.ManifestService.scopeKey(this.manifestOptions()), triggerKey: flow.paywall.triggerKey,
|
|
245
|
+
followUpRules: next.contentJson?.followUpRules }, flow.callbacks, flow.metadata, 1, flow.seen);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
exports.MySuperwall = MySuperwall;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ManifestCondition, ManifestRule, ManifestTrigger, ManifestVariant } from '../models/manifest';
|
|
2
|
+
export interface RuleEngineResolution {
|
|
3
|
+
matchedRuleId?: string;
|
|
4
|
+
show: boolean;
|
|
5
|
+
paywallId: string | null;
|
|
6
|
+
abVariant: string | null;
|
|
7
|
+
matchedRuleName: string | null;
|
|
8
|
+
isHoldout: boolean;
|
|
9
|
+
reason: string;
|
|
10
|
+
}
|
|
11
|
+
export declare class LocalRuleEngine {
|
|
12
|
+
/**
|
|
13
|
+
* Deterministic bucket from 0 to 99 matching backend SHA-256 logic.
|
|
14
|
+
*/
|
|
15
|
+
getDeterministicBucket(userId?: string | null, salt?: string): number;
|
|
16
|
+
/**
|
|
17
|
+
* Compares two semantic version strings.
|
|
18
|
+
*/
|
|
19
|
+
compareVersions(v1?: string | null, v2?: string | null): number;
|
|
20
|
+
/**
|
|
21
|
+
* Resolves nested field access like "metadata.tier".
|
|
22
|
+
*/
|
|
23
|
+
resolveContextValue(field: string, context: Record<string, any>): any;
|
|
24
|
+
/**
|
|
25
|
+
* Evaluates a single condition.
|
|
26
|
+
*/
|
|
27
|
+
evaluateCondition(condition: ManifestCondition, context: Record<string, any>): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Checks if a rule matches given context.
|
|
30
|
+
*/
|
|
31
|
+
matchesRule(rule: ManifestRule, context: Record<string, any>): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Deterministically selects a variant respecting holdout.
|
|
34
|
+
*/
|
|
35
|
+
selectDeterministicVariant(userId: string | null | undefined, salt: string, variants: ManifestVariant[], fallbackPaywallId: string | null, holdoutPercentage?: number): {
|
|
36
|
+
paywallId: string | null;
|
|
37
|
+
abVariant: string | null;
|
|
38
|
+
isHoldout: boolean;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Resolves a trigger in-memory with 0 ms latency.
|
|
42
|
+
*/
|
|
43
|
+
resolveTrigger(trigger: ManifestTrigger, userId: string | null | undefined, context: Record<string, any>): RuleEngineResolution;
|
|
44
|
+
}
|