@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,375 @@
1
+ import { ManifestCondition, ManifestRule, ManifestTrigger, ManifestVariant } from '../models/manifest';
2
+
3
+ /**
4
+ * Pure TypeScript SHA-256 hash to ensure 0-dependency, 0-native-bridge execution on Hermes & JSC.
5
+ */
6
+ function sha256Bytes(ascii: string): Uint8Array {
7
+ function rightRotate(value: number, amount: number) {
8
+ return (value >>> amount) | (value << (32 - amount));
9
+ }
10
+
11
+ const mathPow = Math.pow;
12
+ const maxWord = mathPow(2, 32);
13
+ const lengthProperty = 'length';
14
+ let i = 0, j = 0;
15
+ let result = '';
16
+
17
+ const words: number[] = [];
18
+ const asciiBitLength = ascii[lengthProperty] * 8;
19
+
20
+ const hash: number[] = [];
21
+ const k: number[] = [];
22
+ let primeCounter = 0;
23
+
24
+ const isComposite: Record<number, boolean> = {};
25
+ for (let candidate = 2; primeCounter < 64; candidate++) {
26
+ if (!isComposite[candidate]) {
27
+ for (i = 0; i < 313; i += candidate) {
28
+ isComposite[i] = true;
29
+ }
30
+ hash[primeCounter] = (mathPow(candidate, 0.5) * maxWord) | 0;
31
+ k[primeCounter++] = (mathPow(candidate, 1 / 3) * maxWord) | 0;
32
+ }
33
+ }
34
+
35
+ ascii += '\x80';
36
+ while ((ascii[lengthProperty] % 64) - 56) ascii += '\x00';
37
+ for (i = 0; i < ascii[lengthProperty]; i++) {
38
+ j = ascii.charCodeAt(i);
39
+ words[i >> 2] |= j << (((3 - i) % 4) * 8);
40
+ }
41
+ words[words[lengthProperty]] = (asciiBitLength / maxWord) | 0;
42
+ words[words[lengthProperty]] = asciiBitLength;
43
+
44
+ for (j = 0; j < words[lengthProperty]; ) {
45
+ const w = words.slice(j, (j += 16));
46
+ const oldHash = hash.slice(0);
47
+
48
+ for (i = 0; i < 64; i++) {
49
+ const w15 = w[i - 15], w2 = w[i - 2];
50
+ const s0 = rightRotate(w15, 7) ^ rightRotate(w15, 18) ^ (w15 >>> 3);
51
+ const s1 = rightRotate(w2, 17) ^ rightRotate(w2, 19) ^ (w2 >>> 10);
52
+ w[i] = i < 16 ? w[i] : (w[i - 16] + s0 + w[i - 7] + s1) | 0;
53
+
54
+ const s1_maj = rightRotate(hash[0], 2) ^ rightRotate(hash[0], 13) ^ rightRotate(hash[0], 22);
55
+ const maj = (hash[0] & hash[1]) ^ (hash[0] & hash[2]) ^ (hash[1] & hash[2]);
56
+ const t2 = (s1_maj + maj) | 0;
57
+
58
+ const s1_ch = rightRotate(hash[4], 6) ^ rightRotate(hash[4], 11) ^ rightRotate(hash[4], 25);
59
+ const ch = (hash[4] & hash[5]) ^ (~hash[4] & hash[6]);
60
+ const t1 = (hash[7] + s1_ch + ch + k[i] + w[i]) | 0;
61
+
62
+ hash[7] = hash[6];
63
+ hash[6] = hash[5];
64
+ hash[5] = hash[4];
65
+ hash[4] = (hash[3] + t1) | 0;
66
+ hash[3] = hash[2];
67
+ hash[2] = hash[1];
68
+ hash[1] = hash[0];
69
+ hash[0] = (t1 + t2) | 0;
70
+ }
71
+
72
+ for (i = 0; i < 8; i++) {
73
+ hash[i] = (hash[i] + oldHash[i]) | 0;
74
+ }
75
+ }
76
+
77
+ const out = new Uint8Array(32);
78
+ for (i = 0; i < 8; i++) {
79
+ out[i * 4] = (hash[i] >>> 24) & 255;
80
+ out[i * 4 + 1] = (hash[i] >>> 16) & 255;
81
+ out[i * 4 + 2] = (hash[i] >>> 8) & 255;
82
+ out[i * 4 + 3] = hash[i] & 255;
83
+ }
84
+ return out;
85
+ }
86
+
87
+ export interface RuleEngineResolution {
88
+ matchedRuleId?: string;
89
+ show: boolean;
90
+ paywallId: string | null;
91
+ abVariant: string | null;
92
+ matchedRuleName: string | null;
93
+ isHoldout: boolean;
94
+ reason: string;
95
+ }
96
+
97
+ export class LocalRuleEngine {
98
+ /**
99
+ * Deterministic bucket from 0 to 99 matching backend SHA-256 logic.
100
+ */
101
+ getDeterministicBucket(userId?: string | null, salt: string = ''): number {
102
+ if (!userId) {
103
+ return Math.floor(Math.random() * 100);
104
+ }
105
+ const input = `${userId}:${salt}`;
106
+ // Match backend/native SHA-256 over UTF-8, including non-ASCII user IDs.
107
+ const utf8 = encodeURIComponent(input).replace(/%([0-9A-F]{2})/g,
108
+ (_, hex: string) => String.fromCharCode(parseInt(hex, 16)));
109
+ const bytes = sha256Bytes(utf8);
110
+ const u32 =
111
+ ((bytes[0] << 24) >>> 0) +
112
+ ((bytes[1] << 16) >>> 0) +
113
+ ((bytes[2] << 8) >>> 0) +
114
+ (bytes[3] >>> 0);
115
+ return (u32 % 100) >>> 0;
116
+ }
117
+
118
+ /**
119
+ * Compares two semantic version strings.
120
+ */
121
+ compareVersions(v1?: string | null, v2?: string | null): number {
122
+ const clean1 = (v1 || '').replace(/[^0-9.]/g, '');
123
+ const clean2 = (v2 || '').replace(/[^0-9.]/g, '');
124
+ const p1 = clean1.split('.').map(n => parseInt(n, 10) || 0);
125
+ const p2 = clean2.split('.').map(n => parseInt(n, 10) || 0);
126
+ const maxLen = Math.max(p1.length, p2.length);
127
+
128
+ for (let i = 0; i < maxLen; i++) {
129
+ const n1 = p1[i] ?? 0;
130
+ const n2 = p2[i] ?? 0;
131
+ if (n1 > n2) return 1;
132
+ if (n1 < n2) return -1;
133
+ }
134
+ return 0;
135
+ }
136
+
137
+ /**
138
+ * Resolves nested field access like "metadata.tier".
139
+ */
140
+ resolveContextValue(field: string, context: Record<string, any>): any {
141
+ if (field in context) return context[field];
142
+ const parts = field.split('.');
143
+ let current: any = context;
144
+ for (const part of parts) {
145
+ if (current && typeof current === 'object' && part in current) {
146
+ current = current[part];
147
+ } else {
148
+ return undefined;
149
+ }
150
+ }
151
+ return current;
152
+ }
153
+
154
+ /**
155
+ * Evaluates a single condition.
156
+ */
157
+ evaluateCondition(condition: ManifestCondition, context: Record<string, any>): boolean {
158
+ const actual = this.resolveContextValue(condition.field, context);
159
+ const op = (condition.operator || 'equals').toLowerCase();
160
+ const target = condition.value;
161
+
162
+ switch (op) {
163
+ case 'equals':
164
+ case 'eq':
165
+ if (actual !== undefined && actual !== null && target !== undefined && target !== null) {
166
+ return String(actual).trim().toLowerCase() === String(target).trim().toLowerCase();
167
+ }
168
+ return actual == target;
169
+
170
+ case 'not_equals':
171
+ case 'neq':
172
+ if (actual !== undefined && actual !== null && target !== undefined && target !== null) {
173
+ return String(actual).trim().toLowerCase() !== String(target).trim().toLowerCase();
174
+ }
175
+ return actual != target;
176
+
177
+ case 'contains':
178
+ if (actual === undefined || actual === null || target === undefined || target === null) return false;
179
+ return String(actual).toLowerCase().includes(String(target).toLowerCase());
180
+
181
+ case 'not_contains':
182
+ if (actual === undefined || actual === null || target === undefined || target === null) return true;
183
+ return !String(actual).toLowerCase().includes(String(target).toLowerCase());
184
+
185
+ case 'gt':
186
+ case 'gte':
187
+ case 'lt':
188
+ case 'lte': {
189
+ const aNum = Number(actual);
190
+ const tNum = Number(target);
191
+ if (isNaN(aNum) || isNaN(tNum)) return false;
192
+ if (op === 'gt') return aNum > tNum;
193
+ if (op === 'gte') return aNum >= tNum;
194
+ if (op === 'lt') return aNum < tNum;
195
+ if (op === 'lte') return aNum <= tNum;
196
+ return false;
197
+ }
198
+
199
+ case 'in_list':
200
+ case 'in': {
201
+ if (actual === undefined || actual === null) return false;
202
+ const aStr = String(actual).trim().toLowerCase();
203
+ if (Array.isArray(target)) {
204
+ return target.some(elem => String(elem).trim().toLowerCase() === aStr);
205
+ }
206
+ const parts = String(target).split(',').map(s => s.trim().toLowerCase());
207
+ return parts.includes(aStr);
208
+ }
209
+
210
+ case 'not_in_list':
211
+ case 'not_in': {
212
+ if (actual === undefined || actual === null) return true;
213
+ const aStr = String(actual).trim().toLowerCase();
214
+ if (Array.isArray(target)) {
215
+ return !target.some(elem => String(elem).trim().toLowerCase() === aStr);
216
+ }
217
+ const parts = String(target).split(',').map(s => s.trim().toLowerCase());
218
+ return !parts.includes(aStr);
219
+ }
220
+
221
+ case 'version_gt':
222
+ return this.compareVersions(actual, target) > 0;
223
+ case 'version_gte':
224
+ return this.compareVersions(actual, target) >= 0;
225
+ case 'version_lt':
226
+ return this.compareVersions(actual, target) < 0;
227
+ case 'version_lte':
228
+ return this.compareVersions(actual, target) <= 0;
229
+
230
+ default:
231
+ return false;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Checks if a rule matches given context.
237
+ */
238
+ matchesRule(rule: ManifestRule, context: Record<string, any>): boolean {
239
+ if (!rule.conditions || rule.conditions.length === 0) return true;
240
+ const isAny = rule.matchType === 'any';
241
+ return isAny
242
+ ? rule.conditions.some(c => this.evaluateCondition(c, context))
243
+ : rule.conditions.every(c => this.evaluateCondition(c, context));
244
+ }
245
+
246
+ /**
247
+ * Deterministically selects a variant respecting holdout.
248
+ */
249
+ selectDeterministicVariant(
250
+ userId: string | null | undefined,
251
+ salt: string,
252
+ variants: ManifestVariant[],
253
+ fallbackPaywallId: string | null,
254
+ holdoutPercentage: number = 0
255
+ ): { paywallId: string | null; abVariant: string | null; isHoldout: boolean } {
256
+ if (holdoutPercentage > 0) {
257
+ const holdoutBucket = this.getDeterministicBucket(userId, `${salt}:holdout`);
258
+ if (holdoutBucket < holdoutPercentage) {
259
+ return { paywallId: null, abVariant: null, isHoldout: true };
260
+ }
261
+ }
262
+
263
+ const validVariants = (variants || []).filter(v => Boolean(v.paywallId));
264
+ if (validVariants.length === 0) {
265
+ return { paywallId: fallbackPaywallId, abVariant: 'A', isHoldout: false };
266
+ }
267
+
268
+ const totalWeight = validVariants.reduce((sum, v) => sum + Math.max(1, v.weight ?? 50), 0);
269
+ if (totalWeight <= 0) {
270
+ const first = validVariants[0];
271
+ return { paywallId: first.paywallId, abVariant: first.variant, isHoldout: false };
272
+ }
273
+
274
+ const bucket = this.getDeterministicBucket(userId, `${salt}:variant`);
275
+ const target = Math.floor((bucket * totalWeight) / 100);
276
+ let cumulative = 0;
277
+
278
+ for (const v of validVariants) {
279
+ cumulative += Math.max(1, v.weight ?? 50);
280
+ if (target < cumulative) {
281
+ return { paywallId: v.paywallId, abVariant: v.variant, isHoldout: false };
282
+ }
283
+ }
284
+
285
+ const last = validVariants[validVariants.length - 1];
286
+ return { paywallId: last.paywallId, abVariant: last.variant, isHoldout: false };
287
+ }
288
+
289
+ /**
290
+ * Resolves a trigger in-memory with 0 ms latency.
291
+ */
292
+ resolveTrigger(
293
+ trigger: ManifestTrigger,
294
+ userId: string | null | undefined,
295
+ context: Record<string, any>
296
+ ): RuleEngineResolution {
297
+ // 1. Check priority-ordered audience rules
298
+ const activeRules = [...(trigger.rules || [])].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
299
+ for (const rule of activeRules) {
300
+ if (this.matchesRule(rule, context)) {
301
+ const { paywallId, abVariant, isHoldout } = this.selectDeterministicVariant(
302
+ userId,
303
+ rule.id,
304
+ rule.variants,
305
+ rule.paywallId || trigger.paywallId,
306
+ rule.holdoutPercentage || 0
307
+ );
308
+
309
+ if (isHoldout) {
310
+ return {
311
+ show: false,
312
+ paywallId: null,
313
+ abVariant: null,
314
+ matchedRuleName: rule.name,
315
+ matchedRuleId: rule.id,
316
+ isHoldout: true,
317
+ reason: 'rule_holdout'
318
+ };
319
+ }
320
+
321
+ if (paywallId) {
322
+ return {
323
+ show: true,
324
+ paywallId,
325
+ abVariant: abVariant || 'A',
326
+ matchedRuleName: rule.name,
327
+ matchedRuleId: rule.id,
328
+ isHoldout: false,
329
+ reason: 'rule_matched'
330
+ };
331
+ }
332
+ }
333
+ }
334
+
335
+ // 2. Fall back to root trigger variants & holdout
336
+ const { paywallId, abVariant, isHoldout } = this.selectDeterministicVariant(
337
+ userId,
338
+ trigger.key,
339
+ trigger.variants,
340
+ trigger.paywallId,
341
+ trigger.holdoutPercentage || 0
342
+ );
343
+
344
+ if (isHoldout) {
345
+ return {
346
+ show: false,
347
+ paywallId: null,
348
+ abVariant: null,
349
+ matchedRuleName: null,
350
+ isHoldout: true,
351
+ reason: 'root_holdout'
352
+ };
353
+ }
354
+
355
+ if (paywallId) {
356
+ return {
357
+ show: true,
358
+ paywallId,
359
+ abVariant: abVariant || 'A',
360
+ matchedRuleName: null,
361
+ isHoldout: false,
362
+ reason: 'root_variant'
363
+ };
364
+ }
365
+
366
+ return {
367
+ show: false,
368
+ paywallId: null,
369
+ abVariant: null,
370
+ matchedRuleName: null,
371
+ isHoldout: false,
372
+ reason: 'no_paywall_mapped'
373
+ };
374
+ }
375
+ }
@@ -0,0 +1,27 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { MySuperwall } from '../MySuperwall';
3
+ import { PaywallData, PaywallCallbackActions } from '../models/paywall';
4
+
5
+ export function usePaywall() {
6
+ const [activePaywall, setActivePaywall] = useState<PaywallData | null>(null);
7
+ const [callbacks, setCallbacks] = useState<PaywallCallbackActions>({});
8
+
9
+ useEffect(() => {
10
+ const unregister = MySuperwall.shared.registerPresenter((paywall, actions) => {
11
+ setActivePaywall(paywall);
12
+ setCallbacks(actions);
13
+ });
14
+ return unregister;
15
+ }, []);
16
+
17
+ const dismiss = useCallback(() => {
18
+ setActivePaywall(null);
19
+ }, []);
20
+
21
+ return {
22
+ activePaywall,
23
+ isVisible: Boolean(activePaywall),
24
+ callbacks,
25
+ dismiss
26
+ };
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export * from './models/manifest';
2
+ export * from './models/paywall';
3
+ export * from './engine/localRuleEngine';
4
+ export * from './services/manifestService';
5
+ export * from './services/offlineEventQueue';
6
+ export * from './ui/PaywallModal';
7
+ export * from './hooks/usePaywall';
8
+ import { MySuperwall } from './MySuperwall';
9
+ export { MySuperwall };
10
+ export default MySuperwall;
11
+
12
+ export type { MySuperwallInitOptions } from './MySuperwall';
@@ -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;
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']);
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 })
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
+ }