@mysuperwall/react-native 0.0.0-stage → 1.2.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 +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +173 -2
  4. package/lib/MySuperwall.d.ts +67 -0
  5. package/lib/MySuperwall.js +261 -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 +22 -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 +284 -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,81 @@
1
+ export interface ManifestCondition {
2
+ field: string;
3
+ operator: string;
4
+ value: any;
5
+ }
6
+
7
+ export interface ManifestVariant {
8
+ variantId?: string;
9
+ variantLabel?: string;
10
+ variant: string;
11
+ weight: number;
12
+ paywallId: string | null;
13
+ }
14
+
15
+ export interface ManifestRule {
16
+ experimentId?: string;
17
+ experimentVersion?: string;
18
+ audienceId?: string;
19
+ id: string;
20
+ name: string;
21
+ priority: number;
22
+ matchType: 'all' | 'any';
23
+ conditions: ManifestCondition[];
24
+ holdoutPercentage: number;
25
+ paywallId: string | null;
26
+ variants: ManifestVariant[];
27
+ }
28
+
29
+ export interface ManifestTrigger {
30
+ experimentId?: string;
31
+ experimentVersion?: string;
32
+ audienceId?: string;
33
+ id: string;
34
+ key: string;
35
+ prewarmPriority?: number;
36
+ holdoutPercentage?: number;
37
+ paywallId: string | null;
38
+ variants: ManifestVariant[];
39
+ rules: ManifestRule[];
40
+ dismissAction?: {
41
+ type: string;
42
+ followUpPaywallId?: string | null;
43
+ };
44
+ }
45
+
46
+ export interface ManifestPaywallProduct {
47
+ productId: string;
48
+ variableName?: string;
49
+ }
50
+
51
+ export interface FollowUpRule {
52
+ enabled?: boolean;
53
+ actionType?: string;
54
+ triggerKey?: string;
55
+ delayMs?: number;
56
+ oncePerSession?: boolean;
57
+ cooldownMs?: number;
58
+ maxChainDepth?: number;
59
+ skipIfPremium?: boolean;
60
+ preventRepeatPaywall?: boolean;
61
+ }
62
+
63
+ export interface ManifestPaywall {
64
+ publishedVersion?: number;
65
+ id: string;
66
+ name: string;
67
+ templateKey: string;
68
+ contentJson?: { followUpRules?: Record<string, FollowUpRule> };
69
+ html: string;
70
+ productIds: string[];
71
+ products: ManifestPaywallProduct[];
72
+ }
73
+
74
+ export interface PaywallManifest {
75
+ ok: boolean;
76
+ notModified?: boolean;
77
+ hash?: string;
78
+ appId?: string;
79
+ triggers: ManifestTrigger[];
80
+ paywalls: Record<string, ManifestPaywall>;
81
+ }
@@ -0,0 +1,40 @@
1
+ import { FollowUpRule, ManifestPaywallProduct } from './manifest';
2
+
3
+ export interface ExperimentContext {
4
+ experimentId: string;
5
+ experimentVersion: string;
6
+ audienceId: string;
7
+ variantId: string;
8
+ variantLabel: string;
9
+ paywallId: string;
10
+ paywallVersion: number;
11
+ }
12
+
13
+ export interface PaywallData {
14
+ attributionScope?: string;
15
+ presentationId?: string;
16
+ followUpRules?: Record<string, FollowUpRule>;
17
+ dismissFollowUpPaywallId?: string;
18
+ experimentContext?: ExperimentContext;
19
+ attributionUserId?: string;
20
+ id: string;
21
+ name: string;
22
+ html: string;
23
+ productIds: string[];
24
+ products: ManifestPaywallProduct[];
25
+ triggerKey?: string;
26
+ abVariant?: string;
27
+ }
28
+
29
+ export interface PaywallResult {
30
+ show: boolean;
31
+ paywall: PaywallData | null;
32
+ reason: string;
33
+ }
34
+
35
+ export interface PaywallCallbackActions {
36
+ onPurchase?: (productId: string) => void | Promise<void>;
37
+ onRestore?: () => void | Promise<void>;
38
+ onDismiss?: () => void | Promise<void>;
39
+ onOpenUrl?: (url: string) => void | Promise<void>;
40
+ }
@@ -0,0 +1,28 @@
1
+ interface IdentityStorage {
2
+ getItem(key: string): Promise<string | null>;
3
+ setItem(key: string, value: string): Promise<void>;
4
+ }
5
+
6
+ /** Stable installation identity, isolated by server and application. */
7
+ export class AnonymousIdentity {
8
+ private static identities = new Map<string, Promise<string>>();
9
+ static get(scope: string): Promise<string> {
10
+ const existing = this.identities.get(scope);
11
+ if (existing) return existing;
12
+ const identity = this.load(scope);
13
+ this.identities.set(scope, identity);
14
+ return identity;
15
+ }
16
+ private static async load(scope: string): Promise<string> {
17
+ const key = '@mysuperwall_anonymous_v1:' + encodeURIComponent(scope);
18
+ let storage: IdentityStorage | undefined;
19
+ try { storage = require('@react-native-async-storage/async-storage').default; } catch (_) {}
20
+ try {
21
+ const saved = await storage?.getItem(key);
22
+ if (saved?.startsWith('anon_')) return saved;
23
+ } catch (_) {}
24
+ const identity = 'anon_' + Date.now().toString(36) + Math.random().toString(36).slice(2);
25
+ try { await storage?.setItem(key, identity); } catch (_) {}
26
+ return identity;
27
+ }
28
+ }
@@ -0,0 +1,21 @@
1
+ import { FollowUpRule } from '../models/manifest';
2
+
3
+ /** Session-scoped policy; all timers and reservations are invalidated on identity/config changes. */
4
+ export class FollowUpService {
5
+ private used = new Map<string, number>();
6
+ private timers = new Set<ReturnType<typeof setTimeout>>();
7
+ cancel(): void { this.timers.forEach(clearTimeout); this.timers.clear(); }
8
+ reset(): void { this.cancel(); this.used.clear(); }
9
+ schedule(key: string, rule: FollowUpRule, depth: number, premium: boolean, run: () => void): boolean {
10
+ const number = (value: unknown, fallback: number, max: number) =>
11
+ typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.min(max, Math.floor(value))) : fallback;
12
+ if (!rule.enabled || (rule.actionType || 'trigger') !== 'trigger' || !rule.triggerKey?.trim() ||
13
+ (rule.skipIfPremium && premium) || depth >= Math.max(1, number(rule.maxChainDepth, 2, 5))) return false;
14
+ const previous = this.used.get(key);
15
+ if (previous !== undefined && (rule.oncePerSession !== false || Date.now() - previous < number(rule.cooldownMs, 0, 86400000))) return false;
16
+ this.used.set(key, Date.now());
17
+ const timer = setTimeout(() => { this.timers.delete(timer); run(); }, number(rule.delayMs, 0, 10000));
18
+ this.timers.add(timer);
19
+ return true;
20
+ }
21
+ }
@@ -0,0 +1,63 @@
1
+ import { PaywallManifest } from '../models/manifest';
2
+
3
+ export interface ManifestScope {
4
+ baseUrl: string; appKey?: string; bundleId?: string; platform?: string; lang?: string; substitutionData?: Record<string, unknown>;
5
+ }
6
+
7
+ export class ManifestService {
8
+ private scope = '';
9
+ private generation = 0;
10
+ public manifest: PaywallManifest | null = null;
11
+ private asyncStorage: any = null;
12
+ constructor() {
13
+ try { this.asyncStorage = require('@react-native-async-storage/async-storage').default; }
14
+ catch (_) { /* Memory-only operation remains supported. */ }
15
+ }
16
+ static scopeKey(options: ManifestScope): string {
17
+ return JSON.stringify([options.baseUrl.replace(/\/+$/, '') + '/', options.appKey || '',
18
+ options.bundleId || '', options.platform || 'ios', options.lang || 'en', JSON.stringify(options.substitutionData || {})]);
19
+ }
20
+ configure(options: ManifestScope): void {
21
+ const scope = ManifestService.scopeKey(options);
22
+ if (scope !== this.scope) { this.scope = scope; this.generation++; this.manifest = null; }
23
+ }
24
+ private key(scope: string): string { return '@mysuperwall_manifest_v2:' + encodeURIComponent(scope); }
25
+ async loadFromDisk(): Promise<PaywallManifest | null> {
26
+ const scope = this.scope, generation = this.generation;
27
+ if (!scope || !this.asyncStorage) return this.manifest;
28
+ try {
29
+ const raw = await this.asyncStorage.getItem(this.key(scope));
30
+ if (scope !== this.scope || generation !== this.generation) return null;
31
+ const parsed = raw ? JSON.parse(raw) : null;
32
+ this.manifest = parsed?.ok && parsed.paywalls && Array.isArray(parsed.triggers) ? parsed : null;
33
+ } catch (_) { /* Never adopt a legacy or malformed cache. */ }
34
+ return this.manifest;
35
+ }
36
+ async saveToDisk(manifest: PaywallManifest): Promise<void> {
37
+ const scope = this.scope;
38
+ if (!scope) return;
39
+ this.manifest = manifest;
40
+ try { await this.asyncStorage?.setItem(this.key(scope), JSON.stringify(manifest)); } catch (_) {}
41
+ }
42
+ async syncInBackground(options: ManifestScope): Promise<PaywallManifest | null> {
43
+ this.configure(options);
44
+ const scope = this.scope, generation = this.generation;
45
+ try {
46
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
47
+ if (this.manifest?.hash) headers['If-None-Match'] = `"${this.manifest.hash}"`;
48
+ const response = await fetch(options.baseUrl.replace(/\/+$/, '') + '/paywall/manifest', {
49
+ method: 'POST', headers, body: JSON.stringify({ platform: options.platform || 'ios',
50
+ appKey: options.appKey, bundleId: options.platform === 'android' ? undefined : options.bundleId,
51
+ packageName: options.platform === 'android' ? options.bundleId : undefined, lang: options.lang || 'en', hash: this.manifest?.hash, substitutionData: options.substitutionData || {} })
52
+ });
53
+ if (scope !== this.scope || generation !== this.generation) return null;
54
+ if (response.status === 304) return this.manifest;
55
+ if (response.ok) {
56
+ const data: PaywallManifest = await response.json();
57
+ if (scope !== this.scope || generation !== this.generation) return null;
58
+ if (!data.notModified && data.ok && data.paywalls && Array.isArray(data.triggers)) await this.saveToDisk(data);
59
+ }
60
+ } catch (_) { /* Keep this scope's cache on transient failure. */ }
61
+ return scope === this.scope && generation === this.generation ? this.manifest : null;
62
+ }
63
+ }
@@ -0,0 +1,88 @@
1
+ function eventId(): string {
2
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, char => {
3
+ const value = Math.floor(Math.random() * 16);
4
+ return (char === 'x' ? value : (value & 3) | 8).toString(16);
5
+ });
6
+ }
7
+
8
+ export class OfflineEventQueue {
9
+ private queueKey = '@mysuperwall_offline_event_queue_v1';
10
+ private asyncStorage: any = null;
11
+ private isFlushing = false;
12
+ private memoryQueue: Record<string, any>[] = [];
13
+ private mutation: Promise<unknown> = Promise.resolve();
14
+
15
+ constructor(scope?: string) {
16
+ if (scope) this.queueKey = '@mysuperwall_event_queue_v2:' + encodeURIComponent(scope);
17
+ try { this.asyncStorage = require('@react-native-async-storage/async-storage').default; }
18
+ catch (_) { this.asyncStorage = null; }
19
+ }
20
+
21
+ private serialized<T>(operation: () => Promise<T>): Promise<T> {
22
+ const next = this.mutation.then(operation, operation);
23
+ this.mutation = next.catch(() => undefined);
24
+ return next;
25
+ }
26
+
27
+ private async read(): Promise<Record<string, any>[]> {
28
+ if (!this.asyncStorage) return [...this.memoryQueue];
29
+ try {
30
+ const raw = await this.asyncStorage.getItem(this.queueKey);
31
+ const parsed = raw ? JSON.parse(raw) : [];
32
+ if (!Array.isArray(parsed)) return [];
33
+ const list = parsed.filter(item => item && typeof item === 'object').map(item =>
34
+ ({ ...item, eventId: item.eventId || eventId() }));
35
+ this.memoryQueue = list;
36
+ return list;
37
+ } catch (_) {
38
+ this.asyncStorage = null;
39
+ return [...this.memoryQueue];
40
+ }
41
+ }
42
+
43
+ private async write(list: Record<string, any>[]): Promise<void> {
44
+ this.memoryQueue = list;
45
+ if (this.asyncStorage) {
46
+ try { await this.asyncStorage.setItem(this.queueKey, JSON.stringify(list)); }
47
+ catch (_) { this.asyncStorage = null; }
48
+ }
49
+ }
50
+
51
+ async enqueue(event: Record<string, any>): Promise<void> {
52
+ // IDs and context are captured before asynchronous persistence, once per event.
53
+ const captured = JSON.parse(JSON.stringify({ ...event, eventId: event.eventId || eventId(),
54
+ timestamp: event.timestamp || new Date().toISOString() }));
55
+ await this.serialized(async () => {
56
+ const list = await this.read();
57
+ await this.write([...list, captured].slice(-200));
58
+ });
59
+ }
60
+
61
+ async flush(options: { baseUrl: string; appKey?: string; bundleId?: string; platform?: string }): Promise<void> {
62
+ if (this.isFlushing) return;
63
+ this.isFlushing = true;
64
+ try {
65
+ const batch = await this.serialized(async () => {
66
+ const list = await this.read();
67
+ await this.write(list); // Persist upgraded legacy IDs before the request.
68
+ return list.slice(0, 100);
69
+ });
70
+ if (!batch.length) return;
71
+ const base = options.baseUrl.endsWith('/') ? options.baseUrl : `${options.baseUrl}/`;
72
+ const response = await fetch(`${base}paywall/events/batch`, {
73
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify({ platform: options.platform || 'react-native', appKey: options.appKey,
75
+ bundleId: options.platform === 'android' ? undefined : options.bundleId,
76
+ packageName: options.platform === 'android' ? options.bundleId : undefined, events: batch })
77
+ });
78
+ if (response.ok) {
79
+ const sent = new Set(batch.map(event => event.eventId));
80
+ await this.serialized(async () => {
81
+ const current = await this.read();
82
+ await this.write(current.filter(event => !sent.has(event.eventId)));
83
+ });
84
+ }
85
+ } catch (_) { /* Retain the original IDs for retry. */ }
86
+ finally { this.isFlushing = false; }
87
+ }
88
+ }
@@ -0,0 +1,122 @@
1
+ import React, { FC, useRef } from 'react';
2
+ import { MySuperwall } from '../MySuperwall';
3
+ import { Modal, StyleSheet, SafeAreaView } from 'react-native';
4
+ import { PaywallData, PaywallCallbackActions } from '../models/paywall';
5
+
6
+ export interface PaywallModalProps extends PaywallCallbackActions {
7
+ visible: boolean;
8
+ paywall: PaywallData | null;
9
+ onClose: () => void;
10
+ }
11
+
12
+ export const PaywallModal: FC<PaywallModalProps> = props => {
13
+ if (!props.visible || !props.paywall) return null;
14
+ // Remount the displayed session when HTML or attribution changes, even if the
15
+ // paywall ID stays the same. Freeze the payload to the HTML sent to WebView.
16
+ const captured: PaywallData = JSON.parse(JSON.stringify(props.paywall));
17
+ const sessionKey = JSON.stringify(captured);
18
+ return <PaywallModalContent key={sessionKey} {...props} paywall={captured} />;
19
+ };
20
+
21
+ const PaywallModalContent: FC<PaywallModalProps> = ({
22
+ visible,
23
+ paywall,
24
+ onClose,
25
+ onPurchase,
26
+ onRestore,
27
+ onDismiss,
28
+ onOpenUrl
29
+ }) => {
30
+ const shown = useRef(false);
31
+ const dismissed = useRef(false);
32
+ if (!visible || !paywall) return null;
33
+ const track = (type: string, productId?: string) => {
34
+ void MySuperwall.shared.trackPaywallEvent(type, paywall, productId);
35
+ };
36
+ const dismiss = () => {
37
+ if (dismissed.current) return;
38
+ dismissed.current = true;
39
+ track('paywall_dismissed');
40
+ onClose();
41
+ void onDismiss?.();
42
+ };
43
+
44
+ let WebViewComponent: any = null;
45
+ try {
46
+ WebViewComponent = require('react-native-webview').WebView;
47
+ } catch (_) {
48
+ console.warn('[MySuperwall] react-native-webview is required to render paywalls.');
49
+ return null;
50
+ }
51
+
52
+ const injectedJavaScript = `
53
+ window.MySuperWall = {
54
+ postMessage: function(msg) {
55
+ if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
56
+ window.ReactNativeWebView.postMessage(typeof msg === 'string' ? msg : JSON.stringify(msg));
57
+ }
58
+ }
59
+ };
60
+ true;
61
+ `;
62
+
63
+ const handleMessage = (event: any) => {
64
+ try {
65
+ const data = JSON.parse(event.nativeEvent.data);
66
+ const action = typeof data.action === 'string' ? data.action : data.type;
67
+ const type = typeof action === 'string' ? action.toLowerCase() : '';
68
+
69
+ switch (type) {
70
+ case 'purchase': {
71
+ const productId = data.productId || data.product_id;
72
+ if (typeof productId === 'string' && productId.trim()) { track('purchase_started', productId); if (onPurchase) onPurchase(productId); }
73
+ break;
74
+ }
75
+ case 'restore': {
76
+ track('restore_started');
77
+ if (onRestore) onRestore();
78
+ break;
79
+ }
80
+ case 'close':
81
+ case 'dismiss': {
82
+ dismiss();
83
+ break;
84
+ }
85
+ case 'open_url':
86
+ case 'openurl': {
87
+ if (typeof data.url === 'string' && data.url.trim() && onOpenUrl) onOpenUrl(data.url);
88
+ break;
89
+ }
90
+ }
91
+ } catch (_) {}
92
+ };
93
+
94
+ return (
95
+ <Modal visible={visible} animationType="slide" transparent={false} onRequestClose={dismiss}>
96
+ <SafeAreaView style={styles.container}>
97
+ <WebViewComponent
98
+ originWhitelist={['*']}
99
+ source={{ html: paywall.html, baseUrl: 'https://mysuperwall.com' }}
100
+ injectedJavaScriptBeforeContentLoaded={injectedJavaScript}
101
+ onMessage={handleMessage}
102
+ onLoad={() => { if (!shown.current) { shown.current = true; track('paywall_shown'); } }}
103
+ style={styles.webview}
104
+ javaScriptEnabled={true}
105
+ domStorageEnabled={true}
106
+ bounces={false}
107
+ />
108
+ </SafeAreaView>
109
+ </Modal>
110
+ );
111
+ };
112
+
113
+ const styles = StyleSheet.create({
114
+ container: {
115
+ flex: 1,
116
+ backgroundColor: '#000000'
117
+ },
118
+ webview: {
119
+ flex: 1,
120
+ backgroundColor: 'transparent'
121
+ }
122
+ });