@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,284 @@
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 productData: Record<string, unknown> = {};
32
+ private epoch = 0;
33
+ private presentationSequence = 0;
34
+ private followUps = new FollowUpService();
35
+ private activeFlow?: { paywall: PaywallData; callbacks: PaywallCallbackActions; metadata?: Record<string, any>; depth: number; seen: string[]; epoch: number; outcome?: boolean };
36
+ public userAttributes: Record<string, any> = {};
37
+
38
+ public manifestService: ManifestService;
39
+ public ruleEngine: LocalRuleEngine;
40
+ public eventQueue: OfflineEventQueue;
41
+
42
+ private onPresentPaywallListener?: (paywall: PaywallData, callbacks: PaywallCallbackActions) => void;
43
+
44
+ private constructor() {
45
+ this.manifestService = new ManifestService();
46
+ this.ruleEngine = new LocalRuleEngine();
47
+ this.eventQueue = new OfflineEventQueue();
48
+ }
49
+
50
+ public static get shared(): MySuperwall {
51
+ if (!MySuperwall.instance) {
52
+ MySuperwall.instance = new MySuperwall();
53
+ }
54
+ return MySuperwall.instance;
55
+ }
56
+
57
+ /**
58
+ * Initializes MySuperwall with cached local rule evaluation.
59
+ */
60
+ public static async initialize(options: MySuperwallInitOptions = {}): Promise<MySuperwall> {
61
+ const inst = MySuperwall.shared;
62
+ inst.resetFlow();
63
+ inst.userAttributes = {};
64
+ inst.premium = false;
65
+ inst.productData = {};
66
+ inst.appKey = options.appKey;
67
+ inst.baseUrl = options.baseUrl
68
+ ? options.baseUrl.endsWith('/')
69
+ ? options.baseUrl
70
+ : `${options.baseUrl}/`
71
+ : 'https://mysuperwall.com/';
72
+ inst.bundleId = options.bundleId;
73
+ inst.platform = options.platform || 'ios';
74
+ let deviceLocale = 'en';
75
+ try { deviceLocale = Intl.DateTimeFormat().resolvedOptions().locale; } catch (_) {}
76
+ inst.locale = (options.locale || deviceLocale).replace(/_/g, '-').toLowerCase();
77
+ inst.country = (options.country || inst.locale.match(/-([a-z]{2})(?:-|$)/i)?.[1] || '').toUpperCase();
78
+ inst.appVersion = options.appVersion;
79
+ inst.manifestService.configure(inst.manifestOptions());
80
+ inst.eventQueue = new OfflineEventQueue(JSON.stringify([inst.baseUrl, inst.appKey, inst.bundleId, inst.platform]));
81
+
82
+ const epoch = inst.epoch;
83
+ const userId = options.userId?.trim() || await AnonymousIdentity.get(
84
+ JSON.stringify([inst.baseUrl, inst.appKey, inst.bundleId, inst.platform])
85
+ );
86
+ if (epoch !== inst.epoch) return inst;
87
+ inst.userId = userId;
88
+
89
+ // 1. Load this app’s cached manifest into memory
90
+ await inst.manifestService.loadFromDisk();
91
+ if (epoch !== inst.epoch) return inst;
92
+
93
+ // 2. Background non-blocking sync with ETag / 304 validation
94
+ void inst.manifestService.syncInBackground(inst.manifestOptions());
95
+
96
+ return inst;
97
+ }
98
+
99
+ public identify(userId: string): void {
100
+ if (!userId.trim()) throw new Error('User ID cannot be empty');
101
+ if (this.userId !== userId.trim()) { this.resetFlow(); this.userAttributes = {}; this.premium = false; }
102
+ this.userId = userId.trim();
103
+ }
104
+
105
+ private resetFlow(): void { this.epoch++; this.followUps.reset(); this.activeFlow = undefined; }
106
+ private manifestOptions() {
107
+ return { baseUrl: this.baseUrl, appKey: this.appKey, bundleId: this.bundleId, platform: this.platform, lang: this.locale, substitutionData: this.productData };
108
+ }
109
+ public async setLocale(locale: string): Promise<void> {
110
+ const normalized = locale.trim().replace(/_/g, '-').toLowerCase();
111
+ if (!normalized || normalized === this.locale) return;
112
+ this.resetFlow(); this.locale = normalized;
113
+ const epoch = this.epoch;
114
+ this.manifestService.configure(this.manifestOptions());
115
+ await this.manifestService.loadFromDisk();
116
+ if (epoch === this.epoch) await this.manifestService.syncInBackground(this.manifestOptions());
117
+ }
118
+ /** Supply localized prices from StoreKit, Play Billing or RevenueCat before presenting. */
119
+ public async setProductData(products: Record<string, unknown>): Promise<void> {
120
+ const captured = JSON.parse(JSON.stringify(products)) as Record<string, unknown>;
121
+ if (JSON.stringify(captured) === JSON.stringify(this.productData)) return;
122
+ this.productData = captured;
123
+ const options = this.manifestOptions();
124
+ this.manifestService.configure(options); // Drops old price HTML and rejects stale responses.
125
+ await this.manifestService.syncInBackground(options);
126
+ }
127
+
128
+ public setCountry(country: string): void { this.country = country.trim().toUpperCase(); }
129
+ public setAppVersion(version: string): void { this.appVersion = version; }
130
+ public setSubscriptionStatus(premium: boolean): void { this.premium = premium; }
131
+
132
+ public setUserAttributes(attributes: Record<string, any>): void {
133
+ Object.assign(this.userAttributes, attributes);
134
+ }
135
+
136
+ public registerPresenter(listener: (paywall: PaywallData, callbacks: PaywallCallbackActions) => void): () => void {
137
+ this.onPresentPaywallListener = listener;
138
+ return () => {
139
+ if (this.onPresentPaywallListener === listener) {
140
+ this.onPresentPaywallListener = undefined;
141
+ }
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Evaluates rules locally in-memory in memory.
147
+ */
148
+ public resolveTrigger(triggerKey: string, metadata?: Record<string, any>): PaywallResult {
149
+ const manifest = this.manifestService.manifest;
150
+ if (!manifest) {
151
+ return { show: false, paywall: null, reason: 'manifest_not_cached' };
152
+ }
153
+
154
+ const trigger = (manifest.triggers || []).find(
155
+ t => t.key.trim().toLowerCase() === triggerKey.trim().toLowerCase()
156
+ );
157
+ if (!trigger) {
158
+ return { show: false, paywall: null, reason: 'trigger_not_found' };
159
+ }
160
+
161
+ const evalContext: Record<string, any> = {
162
+ platform: this.platform,
163
+ country: this.country,
164
+ appVersion: this.appVersion,
165
+ metadata: metadata || {},
166
+ userAttributes: this.userAttributes,
167
+ ...(metadata || {})
168
+ };
169
+
170
+ const resolution = this.ruleEngine.resolveTrigger(trigger, this.userId, evalContext);
171
+
172
+ if (resolution.isHoldout) {
173
+ return { show: false, paywall: null, reason: 'holdout' };
174
+ }
175
+
176
+ if (resolution.show && resolution.paywallId) {
177
+ const manifestPaywall = manifest.paywalls[resolution.paywallId];
178
+ if (manifestPaywall) {
179
+ const paywallData: PaywallData = {
180
+ followUpRules: manifestPaywall.contentJson?.followUpRules,
181
+ dismissFollowUpPaywallId: trigger.dismissAction?.type === 'follow_up_paywall' ? trigger.dismissAction.followUpPaywallId || undefined : undefined,
182
+ id: manifestPaywall.id,
183
+ name: manifestPaywall.name,
184
+ html: manifestPaywall.html,
185
+ productIds: manifestPaywall.productIds,
186
+ products: manifestPaywall.products,
187
+ triggerKey,
188
+ abVariant: resolution.abVariant || 'A',
189
+ attributionUserId: this.userId,
190
+ attributionScope: ManifestService.scopeKey(this.manifestOptions()),
191
+ experimentContext: (() => {
192
+ const rule = trigger.rules.find(rule => rule.id === resolution.matchedRuleId);
193
+ const audience = rule || trigger;
194
+ const matches = audience.variants.filter(variant =>
195
+ variant.paywallId === manifestPaywall.id && variant.variant === resolution.abVariant);
196
+ const variant = matches.length === 1 ? matches[0] : undefined;
197
+ if (!variant?.variantId || !audience.experimentId || !audience.experimentVersion ||
198
+ !audience.audienceId || manifestPaywall.publishedVersion == null) return undefined;
199
+ return { experimentId: audience.experimentId, experimentVersion: audience.experimentVersion,
200
+ audienceId: audience.audienceId, variantId: variant.variantId,
201
+ variantLabel: variant.variantLabel || variant.variant,
202
+ paywallId: manifestPaywall.id, paywallVersion: manifestPaywall.publishedVersion };
203
+ })()
204
+ };
205
+
206
+ return { show: true, paywall: paywallData, reason: resolution.reason };
207
+ }
208
+ }
209
+
210
+ return { show: false, paywall: null, reason: resolution.reason };
211
+ }
212
+
213
+ /** Attribute actions to the displayed paywall, never a new assignment. */
214
+ public async trackPaywallEvent(type: string, paywall: PaywallData, productId?: string): Promise<void> {
215
+ if (paywall.attributionScope !== undefined && paywall.attributionScope !== ManifestService.scopeKey(this.manifestOptions())) return;
216
+ if (paywall.attributionUserId !== undefined && paywall.attributionUserId !== this.userId) return;
217
+ const flow = this.activeFlow;
218
+ if (flow && flow.paywall.presentationId === paywall.presentationId && flow.epoch === this.epoch) {
219
+ const keys: Record<string, string> = { purchase: 'onPurchase', purchase_success: 'onPurchase', purchase_failed: 'onPurchaseFailed', restore: 'onRestore', restore_success: 'onRestore' };
220
+ if (keys[type]) { flow.outcome = true; this.scheduleFollowUp(flow, keys[type]); }
221
+ }
222
+ await this.eventQueue.enqueue({ type, paywallId: paywall.id,
223
+ triggerKey: paywall.triggerKey, abVariant: paywall.abVariant,
224
+ experimentContext: paywall.attributionUserId === this.userId ? paywall.experimentContext : undefined, userId: this.userId, productId });
225
+ await this.eventQueue.flush({ baseUrl: this.baseUrl, appKey: this.appKey,
226
+ bundleId: this.bundleId, platform: this.platform });
227
+ }
228
+
229
+ /**
230
+ * Fires a trigger, presents the paywall if resolved, and wires up callbacks.
231
+ */
232
+ public handleTrigger(
233
+ triggerKey: string,
234
+ callbacks?: PaywallCallbackActions & { metadata?: Record<string, any> }
235
+ ): PaywallResult {
236
+ this.epoch++; this.followUps.cancel(); this.activeFlow = undefined;
237
+ const result = this.resolveTrigger(triggerKey, callbacks?.metadata);
238
+ if (result.show && result.paywall) {
239
+ const paywall = this.present(result.paywall, callbacks || {}, callbacks?.metadata, 0, []);
240
+ return { ...result, paywall };
241
+ }
242
+ return result;
243
+ }
244
+
245
+ private present(paywall: PaywallData, callbacks: PaywallCallbackActions, metadata: Record<string, any> | undefined, depth: number, seen: string[]): PaywallData {
246
+ paywall = { ...paywall, presentationId: `${this.epoch}:${++this.presentationSequence}` };
247
+ const flow: NonNullable<MySuperwall['activeFlow']> = { paywall, callbacks, metadata, depth, seen: [...seen, paywall.id], epoch: this.epoch };
248
+ this.activeFlow = flow;
249
+ let dismissed = false;
250
+ this.onPresentPaywallListener?.(paywall, { ...callbacks, onDismiss: () => {
251
+ if (dismissed) return;
252
+ dismissed = true;
253
+ if (this.activeFlow !== flow || flow.epoch !== this.epoch) return;
254
+ if (!flow.outcome) this.scheduleFollowUp(flow, 'onDismiss');
255
+ void callbacks.onDismiss?.();
256
+ } });
257
+ return paywall;
258
+ }
259
+
260
+ private scheduleFollowUp(flow: NonNullable<MySuperwall['activeFlow']>, event: string): void {
261
+ const rule = flow.paywall.followUpRules?.[event];
262
+ const valid = () => this.activeFlow === flow && flow.epoch === this.epoch && flow.paywall.attributionUserId === this.userId;
263
+ if (!valid()) return;
264
+ if (rule?.enabled && (rule.actionType || 'trigger') === 'trigger' && rule.triggerKey?.trim()) {
265
+ this.followUps.schedule(`${flow.paywall.id}:${event}:${rule.triggerKey}`, rule, flow.depth, this.premium, () => {
266
+ if (!valid()) return;
267
+ const result = this.resolveTrigger(rule.triggerKey!, flow.metadata);
268
+ if (!result.show || !result.paywall || (rule.skipIfPremium && this.premium) ||
269
+ (rule.preventRepeatPaywall !== false && flow.seen.includes(result.paywall.id))) return;
270
+ this.present(result.paywall, flow.callbacks, flow.metadata, flow.depth + 1, flow.seen);
271
+ });
272
+ return;
273
+ }
274
+ // Legacy close recovery is one additional screen, never an unbounded chain.
275
+ const id = flow.paywall.dismissFollowUpPaywallId;
276
+ if (event !== 'onDismiss' || flow.depth !== 0 || !id || flow.seen.includes(id)) return;
277
+ const next = this.manifestService.manifest?.paywalls[id];
278
+ if (!next) return;
279
+ this.followUps.schedule(`${flow.paywall.id}:legacy`, { enabled: true, actionType: 'trigger', triggerKey: 'legacy', maxChainDepth: 1 }, 0, false, () => {
280
+ if (valid()) this.present({ ...next, attributionUserId: this.userId, attributionScope: ManifestService.scopeKey(this.manifestOptions()), triggerKey: flow.paywall.triggerKey,
281
+ followUpRules: next.contentJson?.followUpRules }, flow.callbacks, flow.metadata, 1, flow.seen);
282
+ });
283
+ }
284
+ }
@@ -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';