@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +157 -2
  4. package/lib/MySuperwall.d.ts +64 -0
  5. package/lib/MySuperwall.js +249 -0
  6. package/lib/engine/localRuleEngine.d.ts +44 -0
  7. package/lib/engine/localRuleEngine.js +318 -0
  8. package/lib/hooks/usePaywall.d.ts +7 -0
  9. package/lib/hooks/usePaywall.js +25 -0
  10. package/lib/index.d.ts +11 -0
  11. package/lib/index.js +27 -0
  12. package/lib/models/manifest.d.ts +76 -0
  13. package/lib/models/manifest.js +2 -0
  14. package/lib/models/paywall.d.ts +36 -0
  15. package/lib/models/paywall.js +2 -0
  16. package/lib/services/anonymousIdentity.d.ts +6 -0
  17. package/lib/services/anonymousIdentity.js +36 -0
  18. package/lib/services/followUpService.d.ts +9 -0
  19. package/lib/services/followUpService.js +26 -0
  20. package/lib/services/manifestService.d.ts +21 -0
  21. package/lib/services/manifestService.js +80 -0
  22. package/lib/services/offlineEventQueue.d.ts +18 -0
  23. package/lib/services/offlineEventQueue.js +101 -0
  24. package/lib/ui/PaywallModal.d.ts +8 -0
  25. package/lib/ui/PaywallModal.js +136 -0
  26. package/package.json +51 -4
  27. package/src/MySuperwall.ts +272 -0
  28. package/src/engine/localRuleEngine.ts +375 -0
  29. package/src/hooks/usePaywall.ts +27 -0
  30. package/src/index.ts +12 -0
  31. package/src/models/manifest.ts +81 -0
  32. package/src/models/paywall.ts +40 -0
  33. package/src/services/anonymousIdentity.ts +28 -0
  34. package/src/services/followUpService.ts +21 -0
  35. package/src/services/manifestService.ts +63 -0
  36. package/src/services/offlineEventQueue.ts +88 -0
  37. package/src/ui/PaywallModal.tsx +122 -0
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ManifestService = void 0;
4
+ class ManifestService {
5
+ constructor() {
6
+ this.scope = '';
7
+ this.generation = 0;
8
+ this.manifest = null;
9
+ this.asyncStorage = null;
10
+ try {
11
+ this.asyncStorage = require('@react-native-async-storage/async-storage').default;
12
+ }
13
+ catch (_) { /* Memory-only operation remains supported. */ }
14
+ }
15
+ static scopeKey(options) {
16
+ return JSON.stringify([options.baseUrl.replace(/\/+$/, '') + '/', options.appKey || '',
17
+ options.bundleId || '', options.platform || 'ios', options.lang || 'en']);
18
+ }
19
+ configure(options) {
20
+ const scope = ManifestService.scopeKey(options);
21
+ if (scope !== this.scope) {
22
+ this.scope = scope;
23
+ this.generation++;
24
+ this.manifest = null;
25
+ }
26
+ }
27
+ key(scope) { return '@mysuperwall_manifest_v2:' + encodeURIComponent(scope); }
28
+ async loadFromDisk() {
29
+ const scope = this.scope, generation = this.generation;
30
+ if (!scope || !this.asyncStorage)
31
+ return this.manifest;
32
+ try {
33
+ const raw = await this.asyncStorage.getItem(this.key(scope));
34
+ if (scope !== this.scope || generation !== this.generation)
35
+ return null;
36
+ const parsed = raw ? JSON.parse(raw) : null;
37
+ this.manifest = parsed?.ok && parsed.paywalls && Array.isArray(parsed.triggers) ? parsed : null;
38
+ }
39
+ catch (_) { /* Never adopt a legacy or malformed cache. */ }
40
+ return this.manifest;
41
+ }
42
+ async saveToDisk(manifest) {
43
+ const scope = this.scope;
44
+ if (!scope)
45
+ return;
46
+ this.manifest = manifest;
47
+ try {
48
+ await this.asyncStorage?.setItem(this.key(scope), JSON.stringify(manifest));
49
+ }
50
+ catch (_) { }
51
+ }
52
+ async syncInBackground(options) {
53
+ this.configure(options);
54
+ const scope = this.scope, generation = this.generation;
55
+ try {
56
+ const headers = { 'Content-Type': 'application/json' };
57
+ if (this.manifest?.hash)
58
+ headers['If-None-Match'] = `"${this.manifest.hash}"`;
59
+ const response = await fetch(options.baseUrl.replace(/\/+$/, '') + '/paywall/manifest', {
60
+ method: 'POST', headers, body: JSON.stringify({ platform: options.platform || 'ios',
61
+ appKey: options.appKey, bundleId: options.platform === 'android' ? undefined : options.bundleId,
62
+ packageName: options.platform === 'android' ? options.bundleId : undefined, lang: options.lang || 'en', hash: this.manifest?.hash })
63
+ });
64
+ if (scope !== this.scope || generation !== this.generation)
65
+ return null;
66
+ if (response.status === 304)
67
+ return this.manifest;
68
+ if (response.ok) {
69
+ const data = await response.json();
70
+ if (scope !== this.scope || generation !== this.generation)
71
+ return null;
72
+ if (!data.notModified && data.ok && data.paywalls && Array.isArray(data.triggers))
73
+ await this.saveToDisk(data);
74
+ }
75
+ }
76
+ catch (_) { /* Keep this scope's cache on transient failure. */ }
77
+ return scope === this.scope && generation === this.generation ? this.manifest : null;
78
+ }
79
+ }
80
+ exports.ManifestService = ManifestService;
@@ -0,0 +1,18 @@
1
+ export declare class OfflineEventQueue {
2
+ private queueKey;
3
+ private asyncStorage;
4
+ private isFlushing;
5
+ private memoryQueue;
6
+ private mutation;
7
+ constructor(scope?: string);
8
+ private serialized;
9
+ private read;
10
+ private write;
11
+ enqueue(event: Record<string, any>): Promise<void>;
12
+ flush(options: {
13
+ baseUrl: string;
14
+ appKey?: string;
15
+ bundleId?: string;
16
+ platform?: string;
17
+ }): Promise<void>;
18
+ }
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OfflineEventQueue = void 0;
4
+ function eventId() {
5
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, char => {
6
+ const value = Math.floor(Math.random() * 16);
7
+ return (char === 'x' ? value : (value & 3) | 8).toString(16);
8
+ });
9
+ }
10
+ class OfflineEventQueue {
11
+ constructor(scope) {
12
+ this.queueKey = '@mysuperwall_offline_event_queue_v1';
13
+ this.asyncStorage = null;
14
+ this.isFlushing = false;
15
+ this.memoryQueue = [];
16
+ this.mutation = Promise.resolve();
17
+ if (scope)
18
+ this.queueKey = '@mysuperwall_event_queue_v2:' + encodeURIComponent(scope);
19
+ try {
20
+ this.asyncStorage = require('@react-native-async-storage/async-storage').default;
21
+ }
22
+ catch (_) {
23
+ this.asyncStorage = null;
24
+ }
25
+ }
26
+ serialized(operation) {
27
+ const next = this.mutation.then(operation, operation);
28
+ this.mutation = next.catch(() => undefined);
29
+ return next;
30
+ }
31
+ async read() {
32
+ if (!this.asyncStorage)
33
+ return [...this.memoryQueue];
34
+ try {
35
+ const raw = await this.asyncStorage.getItem(this.queueKey);
36
+ const parsed = raw ? JSON.parse(raw) : [];
37
+ if (!Array.isArray(parsed))
38
+ return [];
39
+ const list = parsed.filter(item => item && typeof item === 'object').map(item => ({ ...item, eventId: item.eventId || eventId() }));
40
+ this.memoryQueue = list;
41
+ return list;
42
+ }
43
+ catch (_) {
44
+ this.asyncStorage = null;
45
+ return [...this.memoryQueue];
46
+ }
47
+ }
48
+ async write(list) {
49
+ this.memoryQueue = list;
50
+ if (this.asyncStorage) {
51
+ try {
52
+ await this.asyncStorage.setItem(this.queueKey, JSON.stringify(list));
53
+ }
54
+ catch (_) {
55
+ this.asyncStorage = null;
56
+ }
57
+ }
58
+ }
59
+ async enqueue(event) {
60
+ // IDs and context are captured before asynchronous persistence, once per event.
61
+ const captured = JSON.parse(JSON.stringify({ ...event, eventId: event.eventId || eventId(),
62
+ timestamp: event.timestamp || new Date().toISOString() }));
63
+ await this.serialized(async () => {
64
+ const list = await this.read();
65
+ await this.write([...list, captured].slice(-200));
66
+ });
67
+ }
68
+ async flush(options) {
69
+ if (this.isFlushing)
70
+ return;
71
+ this.isFlushing = true;
72
+ try {
73
+ const batch = await this.serialized(async () => {
74
+ const list = await this.read();
75
+ await this.write(list); // Persist upgraded legacy IDs before the request.
76
+ return list.slice(0, 100);
77
+ });
78
+ if (!batch.length)
79
+ return;
80
+ const base = options.baseUrl.endsWith('/') ? options.baseUrl : `${options.baseUrl}/`;
81
+ const response = await fetch(`${base}paywall/events/batch`, {
82
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
83
+ body: JSON.stringify({ platform: options.platform || 'react-native', appKey: options.appKey,
84
+ bundleId: options.platform === 'android' ? undefined : options.bundleId,
85
+ packageName: options.platform === 'android' ? options.bundleId : undefined, events: batch })
86
+ });
87
+ if (response.ok) {
88
+ const sent = new Set(batch.map(event => event.eventId));
89
+ await this.serialized(async () => {
90
+ const current = await this.read();
91
+ await this.write(current.filter(event => !sent.has(event.eventId)));
92
+ });
93
+ }
94
+ }
95
+ catch (_) { /* Retain the original IDs for retry. */ }
96
+ finally {
97
+ this.isFlushing = false;
98
+ }
99
+ }
100
+ }
101
+ exports.OfflineEventQueue = OfflineEventQueue;
@@ -0,0 +1,8 @@
1
+ import { FC } from 'react';
2
+ import { PaywallData, PaywallCallbackActions } from '../models/paywall';
3
+ export interface PaywallModalProps extends PaywallCallbackActions {
4
+ visible: boolean;
5
+ paywall: PaywallData | null;
6
+ onClose: () => void;
7
+ }
8
+ export declare const PaywallModal: FC<PaywallModalProps>;
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.PaywallModal = void 0;
37
+ const react_1 = __importStar(require("react"));
38
+ const MySuperwall_1 = require("../MySuperwall");
39
+ const react_native_1 = require("react-native");
40
+ const PaywallModal = props => {
41
+ if (!props.visible || !props.paywall)
42
+ return null;
43
+ // Remount the displayed session when HTML or attribution changes, even if the
44
+ // paywall ID stays the same. Freeze the payload to the HTML sent to WebView.
45
+ const captured = JSON.parse(JSON.stringify(props.paywall));
46
+ const sessionKey = JSON.stringify(captured);
47
+ return react_1.default.createElement(PaywallModalContent, { key: sessionKey, ...props, paywall: captured });
48
+ };
49
+ exports.PaywallModal = PaywallModal;
50
+ const PaywallModalContent = ({ visible, paywall, onClose, onPurchase, onRestore, onDismiss, onOpenUrl }) => {
51
+ const shown = (0, react_1.useRef)(false);
52
+ const dismissed = (0, react_1.useRef)(false);
53
+ if (!visible || !paywall)
54
+ return null;
55
+ const track = (type, productId) => {
56
+ void MySuperwall_1.MySuperwall.shared.trackPaywallEvent(type, paywall, productId);
57
+ };
58
+ const dismiss = () => {
59
+ if (dismissed.current)
60
+ return;
61
+ dismissed.current = true;
62
+ track('paywall_dismissed');
63
+ onClose();
64
+ void onDismiss?.();
65
+ };
66
+ let WebViewComponent = null;
67
+ try {
68
+ WebViewComponent = require('react-native-webview').WebView;
69
+ }
70
+ catch (_) {
71
+ console.warn('[MySuperwall] react-native-webview is required to render paywalls.');
72
+ return null;
73
+ }
74
+ const injectedJavaScript = `
75
+ window.MySuperWall = {
76
+ postMessage: function(msg) {
77
+ if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
78
+ window.ReactNativeWebView.postMessage(typeof msg === 'string' ? msg : JSON.stringify(msg));
79
+ }
80
+ }
81
+ };
82
+ true;
83
+ `;
84
+ const handleMessage = (event) => {
85
+ try {
86
+ const data = JSON.parse(event.nativeEvent.data);
87
+ const action = typeof data.action === 'string' ? data.action : data.type;
88
+ const type = typeof action === 'string' ? action.toLowerCase() : '';
89
+ switch (type) {
90
+ case 'purchase': {
91
+ const productId = data.productId || data.product_id;
92
+ if (typeof productId === 'string' && productId.trim()) {
93
+ track('purchase_started', productId);
94
+ if (onPurchase)
95
+ onPurchase(productId);
96
+ }
97
+ break;
98
+ }
99
+ case 'restore': {
100
+ track('restore_started');
101
+ if (onRestore)
102
+ onRestore();
103
+ break;
104
+ }
105
+ case 'close':
106
+ case 'dismiss': {
107
+ dismiss();
108
+ break;
109
+ }
110
+ case 'open_url':
111
+ case 'openurl': {
112
+ if (typeof data.url === 'string' && data.url.trim() && onOpenUrl)
113
+ onOpenUrl(data.url);
114
+ break;
115
+ }
116
+ }
117
+ }
118
+ catch (_) { }
119
+ };
120
+ return (react_1.default.createElement(react_native_1.Modal, { visible: visible, animationType: "slide", transparent: false, onRequestClose: dismiss },
121
+ react_1.default.createElement(react_native_1.SafeAreaView, { style: styles.container },
122
+ react_1.default.createElement(WebViewComponent, { originWhitelist: ['*'], source: { html: paywall.html, baseUrl: 'https://mysuperwall.com' }, injectedJavaScriptBeforeContentLoaded: injectedJavaScript, onMessage: handleMessage, onLoad: () => { if (!shown.current) {
123
+ shown.current = true;
124
+ track('paywall_shown');
125
+ } }, style: styles.webview, javaScriptEnabled: true, domStorageEnabled: true, bounces: false }))));
126
+ };
127
+ const styles = react_native_1.StyleSheet.create({
128
+ container: {
129
+ flex: 1,
130
+ backgroundColor: '#000000'
131
+ },
132
+ webview: {
133
+ flex: 1,
134
+ backgroundColor: 'transparent'
135
+ }
136
+ });
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@mysuperwall/react-native",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "React Native SDK for MySuperWall paywalls, targeting and offline rule evaluation",
5
+ "main": "lib/index.js",
6
+ "types": "lib/index.d.ts",
7
+ "scripts": {
8
+ "test": "node --test test/*.test.js",
9
+ "build": "tsc -p tsconfig.build.json",
10
+ "typecheck": "tsc -p tsconfig.build.json --noEmit",
11
+ "prepack": "npm run build"
12
+ },
13
+ "keywords": [
14
+ "react-native",
15
+ "mysuperwall",
16
+ "paywall",
17
+ "remote-config",
18
+ "in-app-purchases"
19
+ ],
20
+ "author": "MySuperwall",
21
+ "license": "MIT",
22
+ "peerDependencies": {
23
+ "react": ">=17.0.0",
24
+ "react-native": ">=0.64.0",
25
+ "react-native-webview": ">=11.0.0",
26
+ "@react-native-async-storage/async-storage": ">=1.15.0"
27
+ },
28
+ "react-native": "src/index.ts",
29
+ "files": [
30
+ "lib",
31
+ "src",
32
+ "README.md",
33
+ "LICENSE",
34
+ "CHANGELOG.md"
35
+ ],
36
+ "devDependencies": {
37
+ "typescript": "^5.9.3",
38
+ "@types/node": "^22.0.0",
39
+ "react": "18.2.0",
40
+ "@types/react": "^18.2.0",
41
+ "react-native": "0.73.4",
42
+ "react-native-webview": "13.8.1",
43
+ "@react-native-async-storage/async-storage": "1.21.0"
44
+ },
45
+ "homepage": "https://mysuperwall.com",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "https://github.com/Unevenn/mysuperwall_react_native.git"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }
@@ -0,0 +1,272 @@
1
+ import { AnonymousIdentity } from './services/anonymousIdentity';
2
+ import { FollowUpService } from './services/followUpService';
3
+ import { LocalRuleEngine } from './engine/localRuleEngine';
4
+ import { ManifestService } from './services/manifestService';
5
+ import { OfflineEventQueue } from './services/offlineEventQueue';
6
+ import { PaywallResult, PaywallData, PaywallCallbackActions } from './models/paywall';
7
+
8
+ export interface MySuperwallInitOptions {
9
+ appKey?: string;
10
+ userId?: string;
11
+ baseUrl?: string;
12
+ bundleId?: string;
13
+ platform?: 'ios' | 'android' | 'web';
14
+ locale?: string;
15
+ country?: string;
16
+ appVersion?: string;
17
+ }
18
+
19
+ export class MySuperwall {
20
+ private static instance: MySuperwall;
21
+
22
+ public appKey?: string;
23
+ public userId?: string;
24
+ public baseUrl: string = 'https://mysuperwall.com/';
25
+ public bundleId?: string;
26
+ public platform: string = 'ios';
27
+ public locale: string = 'en';
28
+ public country: string = '';
29
+ public appVersion?: string;
30
+ private premium = false;
31
+ private epoch = 0;
32
+ private presentationSequence = 0;
33
+ private followUps = new FollowUpService();
34
+ private activeFlow?: { paywall: PaywallData; callbacks: PaywallCallbackActions; metadata?: Record<string, any>; depth: number; seen: string[]; epoch: number; outcome?: boolean };
35
+ public userAttributes: Record<string, any> = {};
36
+
37
+ public manifestService: ManifestService;
38
+ public ruleEngine: LocalRuleEngine;
39
+ public eventQueue: OfflineEventQueue;
40
+
41
+ private onPresentPaywallListener?: (paywall: PaywallData, callbacks: PaywallCallbackActions) => void;
42
+
43
+ private constructor() {
44
+ this.manifestService = new ManifestService();
45
+ this.ruleEngine = new LocalRuleEngine();
46
+ this.eventQueue = new OfflineEventQueue();
47
+ }
48
+
49
+ public static get shared(): MySuperwall {
50
+ if (!MySuperwall.instance) {
51
+ MySuperwall.instance = new MySuperwall();
52
+ }
53
+ return MySuperwall.instance;
54
+ }
55
+
56
+ /**
57
+ * Initializes MySuperwall with cached local rule evaluation.
58
+ */
59
+ public static async initialize(options: MySuperwallInitOptions = {}): Promise<MySuperwall> {
60
+ const inst = MySuperwall.shared;
61
+ inst.resetFlow();
62
+ inst.userAttributes = {};
63
+ inst.premium = false;
64
+ inst.appKey = options.appKey;
65
+ inst.baseUrl = options.baseUrl
66
+ ? options.baseUrl.endsWith('/')
67
+ ? options.baseUrl
68
+ : `${options.baseUrl}/`
69
+ : 'https://mysuperwall.com/';
70
+ inst.bundleId = options.bundleId;
71
+ inst.platform = options.platform || 'ios';
72
+ let deviceLocale = 'en';
73
+ try { deviceLocale = Intl.DateTimeFormat().resolvedOptions().locale; } catch (_) {}
74
+ inst.locale = (options.locale || deviceLocale).replace(/_/g, '-').toLowerCase();
75
+ inst.country = (options.country || inst.locale.match(/-([a-z]{2})(?:-|$)/i)?.[1] || '').toUpperCase();
76
+ inst.appVersion = options.appVersion;
77
+ inst.manifestService.configure(inst.manifestOptions());
78
+ inst.eventQueue = new OfflineEventQueue(JSON.stringify([inst.baseUrl, inst.appKey, inst.bundleId, inst.platform]));
79
+
80
+ const epoch = inst.epoch;
81
+ const userId = options.userId?.trim() || await AnonymousIdentity.get(
82
+ JSON.stringify([inst.baseUrl, inst.appKey, inst.bundleId, inst.platform])
83
+ );
84
+ if (epoch !== inst.epoch) return inst;
85
+ inst.userId = userId;
86
+
87
+ // 1. Load this app’s cached manifest into memory
88
+ await inst.manifestService.loadFromDisk();
89
+ if (epoch !== inst.epoch) return inst;
90
+
91
+ // 2. Background non-blocking sync with ETag / 304 validation
92
+ void inst.manifestService.syncInBackground(inst.manifestOptions());
93
+
94
+ return inst;
95
+ }
96
+
97
+ public identify(userId: string): void {
98
+ if (!userId.trim()) throw new Error('User ID cannot be empty');
99
+ if (this.userId !== userId.trim()) { this.resetFlow(); this.userAttributes = {}; this.premium = false; }
100
+ this.userId = userId.trim();
101
+ }
102
+
103
+ private resetFlow(): void { this.epoch++; this.followUps.reset(); this.activeFlow = undefined; }
104
+ private manifestOptions() {
105
+ return { baseUrl: this.baseUrl, appKey: this.appKey, bundleId: this.bundleId, platform: this.platform, lang: this.locale };
106
+ }
107
+ public async setLocale(locale: string): Promise<void> {
108
+ const normalized = locale.trim().replace(/_/g, '-').toLowerCase();
109
+ if (!normalized || normalized === this.locale) return;
110
+ this.resetFlow(); this.locale = normalized;
111
+ const epoch = this.epoch;
112
+ this.manifestService.configure(this.manifestOptions());
113
+ await this.manifestService.loadFromDisk();
114
+ if (epoch === this.epoch) await this.manifestService.syncInBackground(this.manifestOptions());
115
+ }
116
+ public setCountry(country: string): void { this.country = country.trim().toUpperCase(); }
117
+ public setAppVersion(version: string): void { this.appVersion = version; }
118
+ public setSubscriptionStatus(premium: boolean): void { this.premium = premium; }
119
+
120
+ public setUserAttributes(attributes: Record<string, any>): void {
121
+ Object.assign(this.userAttributes, attributes);
122
+ }
123
+
124
+ public registerPresenter(listener: (paywall: PaywallData, callbacks: PaywallCallbackActions) => void): () => void {
125
+ this.onPresentPaywallListener = listener;
126
+ return () => {
127
+ if (this.onPresentPaywallListener === listener) {
128
+ this.onPresentPaywallListener = undefined;
129
+ }
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Evaluates rules locally in-memory in memory.
135
+ */
136
+ public resolveTrigger(triggerKey: string, metadata?: Record<string, any>): PaywallResult {
137
+ const manifest = this.manifestService.manifest;
138
+ if (!manifest) {
139
+ return { show: false, paywall: null, reason: 'manifest_not_cached' };
140
+ }
141
+
142
+ const trigger = (manifest.triggers || []).find(
143
+ t => t.key.trim().toLowerCase() === triggerKey.trim().toLowerCase()
144
+ );
145
+ if (!trigger) {
146
+ return { show: false, paywall: null, reason: 'trigger_not_found' };
147
+ }
148
+
149
+ const evalContext: Record<string, any> = {
150
+ platform: this.platform,
151
+ country: this.country,
152
+ appVersion: this.appVersion,
153
+ metadata: metadata || {},
154
+ userAttributes: this.userAttributes,
155
+ ...(metadata || {})
156
+ };
157
+
158
+ const resolution = this.ruleEngine.resolveTrigger(trigger, this.userId, evalContext);
159
+
160
+ if (resolution.isHoldout) {
161
+ return { show: false, paywall: null, reason: 'holdout' };
162
+ }
163
+
164
+ if (resolution.show && resolution.paywallId) {
165
+ const manifestPaywall = manifest.paywalls[resolution.paywallId];
166
+ if (manifestPaywall) {
167
+ const paywallData: PaywallData = {
168
+ followUpRules: manifestPaywall.contentJson?.followUpRules,
169
+ dismissFollowUpPaywallId: trigger.dismissAction?.type === 'follow_up_paywall' ? trigger.dismissAction.followUpPaywallId || undefined : undefined,
170
+ id: manifestPaywall.id,
171
+ name: manifestPaywall.name,
172
+ html: manifestPaywall.html,
173
+ productIds: manifestPaywall.productIds,
174
+ products: manifestPaywall.products,
175
+ triggerKey,
176
+ abVariant: resolution.abVariant || 'A',
177
+ attributionUserId: this.userId,
178
+ attributionScope: ManifestService.scopeKey(this.manifestOptions()),
179
+ experimentContext: (() => {
180
+ const rule = trigger.rules.find(rule => rule.id === resolution.matchedRuleId);
181
+ const audience = rule || trigger;
182
+ const matches = audience.variants.filter(variant =>
183
+ variant.paywallId === manifestPaywall.id && variant.variant === resolution.abVariant);
184
+ const variant = matches.length === 1 ? matches[0] : undefined;
185
+ if (!variant?.variantId || !audience.experimentId || !audience.experimentVersion ||
186
+ !audience.audienceId || manifestPaywall.publishedVersion == null) return undefined;
187
+ return { experimentId: audience.experimentId, experimentVersion: audience.experimentVersion,
188
+ audienceId: audience.audienceId, variantId: variant.variantId,
189
+ variantLabel: variant.variantLabel || variant.variant,
190
+ paywallId: manifestPaywall.id, paywallVersion: manifestPaywall.publishedVersion };
191
+ })()
192
+ };
193
+
194
+ return { show: true, paywall: paywallData, reason: resolution.reason };
195
+ }
196
+ }
197
+
198
+ return { show: false, paywall: null, reason: resolution.reason };
199
+ }
200
+
201
+ /** Attribute actions to the displayed paywall, never a new assignment. */
202
+ public async trackPaywallEvent(type: string, paywall: PaywallData, productId?: string): Promise<void> {
203
+ if (paywall.attributionScope !== undefined && paywall.attributionScope !== ManifestService.scopeKey(this.manifestOptions())) return;
204
+ if (paywall.attributionUserId !== undefined && paywall.attributionUserId !== this.userId) return;
205
+ const flow = this.activeFlow;
206
+ if (flow && flow.paywall.presentationId === paywall.presentationId && flow.epoch === this.epoch) {
207
+ const keys: Record<string, string> = { purchase: 'onPurchase', purchase_success: 'onPurchase', purchase_failed: 'onPurchaseFailed', restore: 'onRestore', restore_success: 'onRestore' };
208
+ if (keys[type]) { flow.outcome = true; this.scheduleFollowUp(flow, keys[type]); }
209
+ }
210
+ await this.eventQueue.enqueue({ type, paywallId: paywall.id,
211
+ triggerKey: paywall.triggerKey, abVariant: paywall.abVariant,
212
+ experimentContext: paywall.attributionUserId === this.userId ? paywall.experimentContext : undefined, userId: this.userId, productId });
213
+ await this.eventQueue.flush({ baseUrl: this.baseUrl, appKey: this.appKey,
214
+ bundleId: this.bundleId, platform: this.platform });
215
+ }
216
+
217
+ /**
218
+ * Fires a trigger, presents the paywall if resolved, and wires up callbacks.
219
+ */
220
+ public handleTrigger(
221
+ triggerKey: string,
222
+ callbacks?: PaywallCallbackActions & { metadata?: Record<string, any> }
223
+ ): PaywallResult {
224
+ this.epoch++; this.followUps.cancel(); this.activeFlow = undefined;
225
+ const result = this.resolveTrigger(triggerKey, callbacks?.metadata);
226
+ if (result.show && result.paywall) {
227
+ const paywall = this.present(result.paywall, callbacks || {}, callbacks?.metadata, 0, []);
228
+ return { ...result, paywall };
229
+ }
230
+ return result;
231
+ }
232
+
233
+ private present(paywall: PaywallData, callbacks: PaywallCallbackActions, metadata: Record<string, any> | undefined, depth: number, seen: string[]): PaywallData {
234
+ paywall = { ...paywall, presentationId: `${this.epoch}:${++this.presentationSequence}` };
235
+ const flow: NonNullable<MySuperwall['activeFlow']> = { paywall, callbacks, metadata, depth, seen: [...seen, paywall.id], epoch: this.epoch };
236
+ this.activeFlow = flow;
237
+ let dismissed = false;
238
+ this.onPresentPaywallListener?.(paywall, { ...callbacks, onDismiss: () => {
239
+ if (dismissed) return;
240
+ dismissed = true;
241
+ if (this.activeFlow !== flow || flow.epoch !== this.epoch) return;
242
+ if (!flow.outcome) this.scheduleFollowUp(flow, 'onDismiss');
243
+ void callbacks.onDismiss?.();
244
+ } });
245
+ return paywall;
246
+ }
247
+
248
+ private scheduleFollowUp(flow: NonNullable<MySuperwall['activeFlow']>, event: string): void {
249
+ const rule = flow.paywall.followUpRules?.[event];
250
+ const valid = () => this.activeFlow === flow && flow.epoch === this.epoch && flow.paywall.attributionUserId === this.userId;
251
+ if (!valid()) return;
252
+ if (rule?.enabled && (rule.actionType || 'trigger') === 'trigger' && rule.triggerKey?.trim()) {
253
+ this.followUps.schedule(`${flow.paywall.id}:${event}:${rule.triggerKey}`, rule, flow.depth, this.premium, () => {
254
+ if (!valid()) return;
255
+ const result = this.resolveTrigger(rule.triggerKey!, flow.metadata);
256
+ if (!result.show || !result.paywall || (rule.skipIfPremium && this.premium) ||
257
+ (rule.preventRepeatPaywall !== false && flow.seen.includes(result.paywall.id))) return;
258
+ this.present(result.paywall, flow.callbacks, flow.metadata, flow.depth + 1, flow.seen);
259
+ });
260
+ return;
261
+ }
262
+ // Legacy close recovery is one additional screen, never an unbounded chain.
263
+ const id = flow.paywall.dismissFollowUpPaywallId;
264
+ if (event !== 'onDismiss' || flow.depth !== 0 || !id || flow.seen.includes(id)) return;
265
+ const next = this.manifestService.manifest?.paywalls[id];
266
+ if (!next) return;
267
+ this.followUps.schedule(`${flow.paywall.id}:legacy`, { enabled: true, actionType: 'trigger', triggerKey: 'legacy', maxChainDepth: 1 }, 0, false, () => {
268
+ if (valid()) this.present({ ...next, attributionUserId: this.userId, attributionScope: ManifestService.scopeKey(this.manifestOptions()), triggerKey: flow.paywall.triggerKey,
269
+ followUpRules: next.contentJson?.followUpRules }, flow.callbacks, flow.metadata, 1, flow.seen);
270
+ });
271
+ }
272
+ }