@amplytools/react-native-amply-sdk 0.4.1 → 0.5.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.
@@ -1,7 +1,12 @@
1
1
  import * as Amply from '../index';
2
2
  import {__setNativeModule} from '../nativeModule';
3
3
 
4
- type CampaignPresentEvent = {url: string; info: Record<string, unknown>; mediationId: string};
4
+ type CampaignPresentEvent = {
5
+ url: string;
6
+ params: Record<string, unknown>;
7
+ info: Record<string, unknown>;
8
+ mediationId: string;
9
+ };
5
10
 
6
11
  // Drains the microtask queue a few times so promise-chained presenter resolutions
7
12
  // (sync or async) settle before assertions run.
@@ -15,12 +20,12 @@ const mockNativeModule = {
15
20
  initialize: jest.fn(),
16
21
  isInitialized: jest.fn(() => true),
17
22
  track: jest.fn(),
18
- trackEventGated: jest.fn(),
23
+ trackGated: jest.fn(),
19
24
  resolveCampaign: jest.fn(),
20
25
  getRecentEvents: jest.fn(),
21
26
  getDataSetSnapshot: jest.fn(),
22
27
  registerDeepLinkListener: jest.fn(),
23
- registerCampaignPresenter: jest.fn(),
28
+ registerGate: jest.fn(),
24
29
  addListener: jest.fn(),
25
30
  removeListeners: jest.fn(),
26
31
  onSystemEvent: jest.fn(),
@@ -32,9 +37,10 @@ const mockNativeModule = {
32
37
  clearCustomProperties: jest.fn(),
33
38
  };
34
39
 
35
- describe('Amply CampaignResolved present/resolve/result API', () => {
40
+ describe('Amply gate API (trackGated + registerGate)', () => {
36
41
  afterEach(() => {
37
42
  jest.clearAllMocks();
43
+ Amply.removeAllListeners();
38
44
  __setNativeModule(null);
39
45
  });
40
46
 
@@ -42,139 +48,229 @@ describe('Amply CampaignResolved present/resolve/result API', () => {
42
48
  __setNativeModule(mockNativeModule as never);
43
49
  });
44
50
 
45
- describe('trackEvent continuation overload', () => {
46
- it('calls fire-and-forget track when no callbacks are supplied', async () => {
51
+ describe('trackEvent (fire-and-forget)', () => {
52
+ it('forwards to native track and never gates', async () => {
47
53
  await Amply.trackEvent('SaveTapped', {foo: 'bar'});
48
54
 
49
55
  expect(mockNativeModule.track).toHaveBeenCalledWith({
50
56
  name: 'SaveTapped',
51
57
  properties: {foo: 'bar'},
52
58
  });
53
- expect(mockNativeModule.trackEventGated).not.toHaveBeenCalled();
59
+ expect(mockNativeModule.trackGated).not.toHaveBeenCalled();
54
60
  });
55
61
 
56
- it('routes a "complete" resolution to onProceed', async () => {
57
- mockNativeModule.trackEventGated.mockResolvedValueOnce('complete');
62
+ it('defaults missing properties to an empty object', async () => {
63
+ await Amply.trackEvent('Ping');
64
+
65
+ expect(mockNativeModule.track).toHaveBeenCalledWith({
66
+ name: 'Ping',
67
+ properties: {},
68
+ });
69
+ });
70
+ });
58
71
 
59
- const onProceed = jest.fn();
60
- const onCancel = jest.fn();
72
+ describe('trackGated', () => {
73
+ it('resolves proceed/completed when the gate is satisfied', async () => {
74
+ mockNativeModule.trackGated.mockResolvedValueOnce({
75
+ outcome: 'proceed',
76
+ reason: 'completed',
77
+ });
61
78
 
62
- await Amply.trackEvent('SaveTapped', {foo: 'bar'}, {onProceed, onCancel});
79
+ const decision = await Amply.trackGated('SaveTapped', {foo: 'bar'});
63
80
 
64
- expect(mockNativeModule.trackEventGated).toHaveBeenCalledWith('SaveTapped', {
81
+ expect(mockNativeModule.trackGated).toHaveBeenCalledWith('SaveTapped', {
65
82
  foo: 'bar',
66
83
  });
67
- expect(onProceed).toHaveBeenCalledTimes(1);
68
- expect(onCancel).not.toHaveBeenCalled();
84
+ expect(decision).toEqual({outcome: 'proceed', reason: 'completed'});
69
85
  });
70
86
 
71
- it('routes a "cancel" resolution to onCancel', async () => {
72
- mockNativeModule.trackEventGated.mockResolvedValueOnce('cancel');
73
-
74
- const onProceed = jest.fn();
75
- const onCancel = jest.fn();
87
+ it('resolves proceed/failOpen when the native layer reports fail-open', async () => {
88
+ mockNativeModule.trackGated.mockResolvedValueOnce({
89
+ outcome: 'proceed',
90
+ reason: 'failOpen',
91
+ });
76
92
 
77
- await Amply.trackEvent('SaveTapped', undefined, {onProceed, onCancel});
93
+ const decision = await Amply.trackGated('SaveTapped', {});
78
94
 
79
- expect(mockNativeModule.trackEventGated).toHaveBeenCalledWith('SaveTapped', {});
80
- expect(onCancel).toHaveBeenCalledTimes(1);
81
- expect(onProceed).not.toHaveBeenCalled();
95
+ expect(decision).toEqual({outcome: 'proceed', reason: 'failOpen'});
82
96
  });
83
97
 
84
- it('fails open to onProceed when the native call rejects', async () => {
85
- mockNativeModule.trackEventGated.mockRejectedValueOnce(new Error('boom'));
98
+ it('resolves cancelled on a user dismiss under onAbort=cancel', async () => {
99
+ mockNativeModule.trackGated.mockResolvedValueOnce({outcome: 'cancelled'});
100
+
101
+ const decision = await Amply.trackGated('SaveTapped', undefined);
86
102
 
87
- const onProceed = jest.fn();
88
- const onCancel = jest.fn();
103
+ expect(mockNativeModule.trackGated).toHaveBeenCalledWith('SaveTapped', {});
104
+ expect(decision).toEqual({outcome: 'cancelled'});
105
+ });
106
+
107
+ it('never rejects — fails open to proceed/failOpen when native rejects', async () => {
108
+ mockNativeModule.trackGated.mockRejectedValueOnce(new Error('boom'));
89
109
 
90
- await Amply.trackEvent('SaveTapped', {}, {onProceed, onCancel});
110
+ const decision = await Amply.trackGated('SaveTapped', {});
91
111
 
92
- expect(onProceed).toHaveBeenCalledTimes(1);
93
- expect(onCancel).not.toHaveBeenCalled();
112
+ expect(decision).toEqual({outcome: 'proceed', reason: 'failOpen'});
94
113
  });
95
114
 
96
- it('runs onProceed (not onCancel) for "cancel" when no onCancel is supplied', async () => {
97
- mockNativeModule.trackEventGated.mockResolvedValueOnce('cancel');
115
+ it('fails open on an unexpected/malformed native decision', async () => {
116
+ mockNativeModule.trackGated.mockResolvedValueOnce({outcome: 'wat'} as never);
117
+
118
+ const decision = await Amply.trackGated('SaveTapped', {});
119
+
120
+ expect(decision).toEqual({outcome: 'proceed', reason: 'failOpen'});
121
+ });
98
122
 
99
- const onProceed = jest.fn();
123
+ it('normalizes an unknown proceed reason to failOpen', async () => {
124
+ mockNativeModule.trackGated.mockResolvedValueOnce({
125
+ outcome: 'proceed',
126
+ reason: 'something-else',
127
+ } as never);
100
128
 
101
- await Amply.trackEvent('SaveTapped', {}, {onProceed});
129
+ const decision = await Amply.trackGated('SaveTapped', {});
102
130
 
103
- // The raw result is never exposed; a "cancel" with no onCancel hook is a no-op
104
- // for the continuation. onProceed must not fire on a cancel.
105
- expect(onProceed).not.toHaveBeenCalled();
131
+ expect(decision).toEqual({outcome: 'proceed', reason: 'failOpen'});
106
132
  });
107
133
  });
108
134
 
109
- describe('registerCampaignPresenter', () => {
110
- it('registers once on the native module and subscribes to onCampaignPresent', async () => {
135
+ describe('registerGate', () => {
136
+ it('registers once on the native module per baseUrl and subscribes to onCampaignPresent', async () => {
111
137
  const remove = jest.fn();
112
138
  mockNativeModule.onCampaignPresent.mockReturnValue({remove});
113
139
 
114
- const presenter = jest.fn(() => 'Completed' as const);
115
- const unsubscribe = await Amply.registerCampaignPresenter(presenter);
140
+ const presenter = jest.fn();
141
+ const unsubscribe = await Amply.registerGate('stillframe://ad', presenter);
116
142
 
117
- expect(mockNativeModule.registerCampaignPresenter).toHaveBeenCalledTimes(1);
143
+ expect(mockNativeModule.registerGate).toHaveBeenCalledTimes(1);
144
+ expect(mockNativeModule.registerGate).toHaveBeenCalledWith('stillframe://ad', 'cancel', 0);
118
145
  expect(mockNativeModule.onCampaignPresent).toHaveBeenCalledTimes(1);
119
146
 
147
+ // Second registration of the same baseUrl re-subscribes but does NOT re-register.
148
+ await Amply.registerGate('stillframe://ad', jest.fn());
149
+ expect(mockNativeModule.registerGate).toHaveBeenCalledTimes(1);
150
+
120
151
  unsubscribe();
121
152
  expect(remove).toHaveBeenCalledTimes(1);
122
153
  });
123
154
 
124
- it('forwards a synchronous result to resolveCampaign', async () => {
125
- let emit: ((e: CampaignPresentEvent) => void) | undefined;
126
- mockNativeModule.onCampaignPresent.mockImplementation((listener: (e: CampaignPresentEvent) => void) => {
127
- emit = listener;
128
- return {remove: jest.fn()};
155
+ it('passes through onAbort and timeoutMs options', async () => {
156
+ mockNativeModule.onCampaignPresent.mockReturnValue({remove: jest.fn()});
157
+
158
+ await Amply.registerGate('stillframe://ad', jest.fn(), {
159
+ onAbort: 'proceed',
160
+ timeoutMs: 8000,
129
161
  });
130
162
 
131
- const presenter = jest.fn(() => 'Dismissed' as const);
132
- await Amply.registerCampaignPresenter(presenter);
163
+ expect(mockNativeModule.registerGate).toHaveBeenCalledWith('stillframe://ad', 'proceed', 8000);
164
+ });
165
+
166
+ it('delivers params + info to the presenter and forwards completed', async () => {
167
+ let emit: ((e: CampaignPresentEvent) => void) | undefined;
168
+ mockNativeModule.onCampaignPresent.mockImplementation(
169
+ (listener: (e: CampaignPresentEvent) => void) => {
170
+ emit = listener;
171
+ return {remove: jest.fn()};
172
+ },
173
+ );
174
+
175
+ const presenter = jest.fn(
176
+ (_params, _info, resolution: Amply.GateResolution) => resolution.completed(),
177
+ );
178
+ await Amply.registerGate('stillframe://ad', presenter);
133
179
 
134
180
  expect(emit).toBeDefined();
135
- emit!({url: 'stillframe://ad', info: {campaignId: 'c1'}, mediationId: 'm1'});
181
+ emit!({
182
+ url: 'stillframe://ad?adUnit=rewarded_main',
183
+ params: {adUnit: 'rewarded_main'},
184
+ info: {campaignId: 'c1'},
185
+ mediationId: 'm1',
186
+ });
136
187
 
137
- // allow the (possibly async) presenter resolution to flush
138
188
  await flushMicrotasks();
139
189
 
140
- expect(presenter).toHaveBeenCalledWith('stillframe://ad', {campaignId: 'c1'});
141
- expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m1', 'Dismissed');
190
+ expect(presenter).toHaveBeenCalledWith(
191
+ {adUnit: 'rewarded_main'},
192
+ {campaignId: 'c1'},
193
+ expect.any(Object),
194
+ );
195
+ expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m1', 'Completed');
142
196
  });
143
197
 
144
- it('forwards a promise-resolved result to resolveCampaign', async () => {
198
+ it('forwards a dismissed resolution', async () => {
145
199
  let emit: ((e: CampaignPresentEvent) => void) | undefined;
146
- mockNativeModule.onCampaignPresent.mockImplementation((listener: (e: CampaignPresentEvent) => void) => {
147
- emit = listener;
148
- return {remove: jest.fn()};
149
- });
200
+ mockNativeModule.onCampaignPresent.mockImplementation(
201
+ (listener: (e: CampaignPresentEvent) => void) => {
202
+ emit = listener;
203
+ return {remove: jest.fn()};
204
+ },
205
+ );
206
+
207
+ await Amply.registerGate('stillframe://ad', (_p, _i, resolution) =>
208
+ resolution.dismissed(),
209
+ );
210
+
211
+ emit!({url: 'stillframe://ad', params: {}, info: {}, mediationId: 'm2'});
212
+ await flushMicrotasks();
150
213
 
151
- const presenter = jest.fn(async () => 'Unavailable' as const);
152
- await Amply.registerCampaignPresenter(presenter);
214
+ expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m2', 'Dismissed');
215
+ });
153
216
 
154
- emit!({url: 'stillframe://ad', info: {}, mediationId: 'm2'});
217
+ it('reports Unavailable (fail-open) when the presenter throws', async () => {
218
+ let emit: ((e: CampaignPresentEvent) => void) | undefined;
219
+ mockNativeModule.onCampaignPresent.mockImplementation(
220
+ (listener: (e: CampaignPresentEvent) => void) => {
221
+ emit = listener;
222
+ return {remove: jest.fn()};
223
+ },
224
+ );
225
+
226
+ await Amply.registerGate('stillframe://ad', () => {
227
+ throw new Error('presenter blew up');
228
+ });
155
229
 
230
+ emit!({url: 'stillframe://ad', params: {}, info: {}, mediationId: 'm3'});
156
231
  await flushMicrotasks();
157
232
 
158
- expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m2', 'Unavailable');
233
+ expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m3', 'Unavailable');
159
234
  });
160
235
 
161
- it('reports Unavailable (fail-open) when the presenter throws', async () => {
236
+ it('resolves at most once even if the presenter reports twice', async () => {
162
237
  let emit: ((e: CampaignPresentEvent) => void) | undefined;
163
- mockNativeModule.onCampaignPresent.mockImplementation((listener: (e: CampaignPresentEvent) => void) => {
164
- emit = listener;
165
- return {remove: jest.fn()};
238
+ mockNativeModule.onCampaignPresent.mockImplementation(
239
+ (listener: (e: CampaignPresentEvent) => void) => {
240
+ emit = listener;
241
+ return {remove: jest.fn()};
242
+ },
243
+ );
244
+
245
+ await Amply.registerGate('stillframe://ad', (_p, _i, resolution) => {
246
+ resolution.completed();
247
+ resolution.dismissed();
166
248
  });
167
249
 
168
- const presenter = jest.fn(() => {
169
- throw new Error('presenter blew up');
170
- });
171
- await Amply.registerCampaignPresenter(presenter);
250
+ emit!({url: 'stillframe://ad', params: {}, info: {}, mediationId: 'm4'});
251
+ await flushMicrotasks();
172
252
 
173
- emit!({url: 'stillframe://ad', info: {}, mediationId: 'm3'});
253
+ expect(mockNativeModule.resolveCampaign).toHaveBeenCalledTimes(1);
254
+ expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m4', 'Completed');
255
+ });
174
256
 
257
+ it('ignores dispatches whose url does not match the registered baseUrl', async () => {
258
+ let emit: ((e: CampaignPresentEvent) => void) | undefined;
259
+ mockNativeModule.onCampaignPresent.mockImplementation(
260
+ (listener: (e: CampaignPresentEvent) => void) => {
261
+ emit = listener;
262
+ return {remove: jest.fn()};
263
+ },
264
+ );
265
+
266
+ const presenter = jest.fn();
267
+ await Amply.registerGate('stillframe://ad', presenter);
268
+
269
+ emit!({url: 'other://thing', params: {}, info: {}, mediationId: 'm5'});
175
270
  await flushMicrotasks();
176
271
 
177
- expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m3', 'Unavailable');
272
+ expect(presenter).not.toHaveBeenCalled();
273
+ expect(mockNativeModule.resolveCampaign).not.toHaveBeenCalled();
178
274
  });
179
275
  });
180
276
  });
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export {formatSystemEventLabel} from './systemEventUtils';
5
5
  export type {FormatOptions} from './systemEventUtils';
6
6
  import type {
7
7
  CampaignResult,
8
+ GateDecision,
8
9
  AmplyInitializationConfig,
9
10
  DataSetSnapshot,
10
11
  DataSetType,
@@ -17,7 +18,7 @@ import type {
17
18
 
18
19
  let deepLinkRegistered = false;
19
20
  let debugLogListenerRegistered = false;
20
- let campaignPresenterRegistered = false;
21
+ const registeredGates = new Set<string>();
21
22
  const deepLinkSubscriptions = new Set<() => void>();
22
23
  const campaignPresenterSubscriptions = new Set<() => void>();
23
24
 
@@ -137,96 +138,105 @@ export async function track(payload: TrackEventPayload): Promise<void> {
137
138
  }
138
139
 
139
140
  /**
140
- * Continuation hooks for the gating form of {@link trackEvent}.
141
- *
142
- * `onProceed` is "what to do next" (run exactly once when the gate resolves in
143
- * favor of proceeding). `onCancel` is an OPTIONAL local-UI cleanup hook, invoked
144
- * only when a campaign's blocking action is user-dismissed under an `onAbort=cancel`
145
- * policy. Never put business logic in `onCancel`.
146
- *
147
- * The raw campaign result is deliberately not exposed — app code must never branch
148
- * on which action ran or how it resolved.
141
+ * Possible results a gate presenter can report. Re-exported from the
142
+ * native spec for app presenters.
149
143
  */
150
- export type TrackEventContinuation = {
151
- onProceed: () => void;
152
- onCancel?: () => void;
153
- };
144
+ export type {CampaignResult, GateDecision};
154
145
 
155
146
  /**
156
- * Possible results a campaign presenter can report. Re-exported from the
157
- * native spec for app presenters.
147
+ * Outcome of a gate presenter's presentation, reported back via {@link GateResolution}.
148
+ */
149
+ export type GatePresenterResult = CampaignResult;
150
+
151
+ /**
152
+ * Resolution token handed to a {@link GatePresenter}. The presenter MUST report
153
+ * exactly one terminal result. Convenience helpers map to the underlying
154
+ * {@link CampaignResult}.
158
155
  */
159
- export type {CampaignResult};
156
+ export type GateResolution = {
157
+ /** The gate condition was satisfied (e.g. rewarded ad watched to completion). */
158
+ completed: () => void;
159
+ /** The user deliberately dismissed without satisfying the condition. */
160
+ dismissed: () => void;
161
+ /** Any non-user failure (no-fill, not-ready, failed-to-show). Fails open. */
162
+ unavailable: () => void;
163
+ };
160
164
 
161
165
  /**
162
- * A capability presenter for a campaign present (blocking) action. Keyed by deeplink
163
- * URL semantics, NOT by event — the app is a registry of capabilities. Returns (or
164
- * resolves) the terminal {@link CampaignResult}. Throwing / rejecting is treated as
165
- * `'Unavailable'` (fail-open).
166
+ * A capability presenter for a gate (blocking) action, registered via
167
+ * {@link registerGate} and keyed by the gate's `baseUrl`. Receives the parsed query
168
+ * `params`, the enriched `info`, and a {@link GateResolution} token it must resolve
169
+ * exactly once. Throwing synchronously is treated as `unavailable` (fail-open).
166
170
  */
167
- export type CampaignPresenter = (
168
- url: string,
171
+ export type GatePresenter = (
172
+ params: Record<string, unknown>,
169
173
  info: Record<string, unknown>,
170
- ) => CampaignResult | Promise<CampaignResult>;
174
+ resolution: GateResolution,
175
+ ) => void;
171
176
 
172
177
  /**
173
- * Fire-and-forget analytics track. Records the event and lets any matching NORMAL
174
- * campaign action dispatch as usual. A blocking action matched here will NOT fire —
175
- * there is no continuation to gate.
178
+ * Options for {@link registerGate}.
176
179
  */
177
- export function trackEvent(event: string, properties?: Record<string, unknown> | null): Promise<void>;
180
+ export type RegisterGateOptions = {
181
+ /**
182
+ * Abort policy when the user dismisses the gate without satisfying it.
183
+ * - `'cancel'` → {@link trackGated} resolves `{ outcome: 'cancelled' }`.
184
+ * - `'proceed'` → {@link trackGated} resolves `{ outcome: 'proceed', reason: 'failOpen' }`.
185
+ * Defaults to `'cancel'`.
186
+ */
187
+ onAbort?: 'cancel' | 'proceed';
188
+ /**
189
+ * Max time (ms) the gate waits for a presenter result before failing open.
190
+ * Defaults to the SDK's gate default.
191
+ */
192
+ timeoutMs?: number;
193
+ };
194
+
178
195
  /**
179
- * Continuation (gating) form. Records the event; if a blocking campaign action
180
- * matches AND a presenter is registered, the SDK dispatches it and awaits its result
181
- * before resolving the continuation:
182
- * - proceed (completed / unavailable / timeout / dismissed+proceed) → `onProceed`
183
- * - user dismiss against an `onAbort=cancel` action → `onCancel`
184
- *
185
- * Fails open: any error path runs `onProceed`. Passing a continuation is the gating
186
- * switch — the same event tracked elsewhere without one is a valid, non-gating call.
196
+ * Fire-and-forget analytics track. Records the event and lets any matching NORMAL
197
+ * campaign action dispatch as usual. A gate action matched here will NOT block —
198
+ * there is no caller to gate. Use {@link trackGated} to await a gate.
187
199
  */
188
- export function trackEvent(
189
- event: string,
190
- properties: Record<string, unknown> | null | undefined,
191
- continuation: TrackEventContinuation,
192
- ): Promise<void>;
193
200
  export async function trackEvent(
194
201
  event: string,
195
202
  properties?: Record<string, unknown> | null,
196
- continuation?: TrackEventContinuation,
197
203
  ): Promise<void> {
198
- const props = properties ?? {};
199
-
200
- if (!continuation) {
201
- await getNativeModule().track({name: event, properties: props});
202
- return;
203
- }
204
+ await getNativeModule().track({name: event, properties: properties ?? {}});
205
+ }
204
206
 
205
- let decision: string;
207
+ /**
208
+ * Gated track. Records the event; if a gate action matches AND a presenter is
209
+ * registered for its `baseUrl`, the SDK dispatches the presentation and awaits its
210
+ * outcome before resolving:
211
+ * - `{ outcome: 'proceed', reason: 'completed' }` — the presenter satisfied the gate.
212
+ * - `{ outcome: 'proceed', reason: 'failOpen' }` — no gate matched, not initialized,
213
+ * timeout, unavailable, infra failure, or dismiss under `onAbort=proceed`.
214
+ * - `{ outcome: 'cancelled' }` — user dismiss under an `onAbort=cancel` gate.
215
+ *
216
+ * This NEVER rejects: any infra failure resolves to `{ outcome: 'proceed', reason:
217
+ * 'failOpen' }` so the caller is never stranded.
218
+ */
219
+ export async function trackGated(
220
+ event: string,
221
+ properties?: Record<string, unknown> | null,
222
+ ): Promise<GateDecision> {
223
+ const props = properties ?? {};
206
224
  try {
207
- decision = await getNativeModule().trackEventGated(event, props);
225
+ const decision = (await getNativeModule().trackGated(event, props)) as Partial<GateDecision>;
226
+ if (decision && decision.outcome === 'cancelled') {
227
+ return {outcome: 'cancelled'};
228
+ }
229
+ if (decision && decision.outcome === 'proceed') {
230
+ const reason = decision.reason === 'completed' ? 'completed' : 'failOpen';
231
+ return {outcome: 'proceed', reason};
232
+ }
233
+ // Unexpected/malformed payload: fail open.
234
+ console.warn('[Amply] trackGated returned an unexpected decision; failing open', decision);
235
+ return {outcome: 'proceed', reason: 'failOpen'};
208
236
  } catch (error) {
209
- // Fail open: any bridge/SDK failure must proceed, never strand the app.
210
- console.warn('[Amply] trackEvent continuation failed; proceeding (fail-open)', error);
211
- continuation.onProceed();
212
- return;
213
- }
214
-
215
- if (decision === 'cancel') {
216
- // A deliberate user-dismiss under an onAbort=cancel action. Only the local-UI
217
- // cleanup hook runs; the continuation is suppressed.
218
- continuation.onCancel?.();
219
- return;
220
- }
221
-
222
- // 'complete' and any unexpected value fail open to onProceed.
223
- continuation.onProceed();
224
- }
225
-
226
- async function ensureCampaignPresenterRegistration(): Promise<void> {
227
- if (!campaignPresenterRegistered) {
228
- getNativeModule().registerCampaignPresenter();
229
- campaignPresenterRegistered = true;
237
+ // Fail open: any bridge/SDK failure must proceed, never strand the caller.
238
+ console.warn('[Amply] trackGated failed; proceeding (fail-open)', error);
239
+ return {outcome: 'proceed', reason: 'failOpen'};
230
240
  }
231
241
  }
232
242
 
@@ -245,33 +255,62 @@ function trackCampaignPresenterSubscription(subscription?: {remove?: () => void}
245
255
  }
246
256
 
247
257
  /**
248
- * Register a capability presenter for campaign present (blocking) actions. The presenter
249
- * is invoked when a blocking action is dispatched; it must report a terminal
250
- * {@link CampaignResult} (returned or resolved). The SDK collapses multi-step ad
251
- * lifecycles into the single result you report. Throwing/rejecting → `'Unavailable'`.
258
+ * Register a gate presenter for a gate (blocking) action, keyed by its `baseUrl`.
259
+ * The presenter is invoked when a matching gate is dispatched; it receives the parsed
260
+ * query `params`, the enriched `info`, and a {@link GateResolution} it must resolve
261
+ * exactly once (`completed`/`dismissed`/`unavailable`). The SDK collapses multi-step
262
+ * ad lifecycles into the single result you report. A synchronous throw → `unavailable`
263
+ * (fail-open).
252
264
  *
253
265
  * @returns an unsubscribe function.
254
266
  */
255
- export async function registerCampaignPresenter(
256
- presenter: CampaignPresenter,
267
+ export async function registerGate(
268
+ baseUrl: string,
269
+ presenter: GatePresenter,
270
+ opts?: RegisterGateOptions,
257
271
  ): Promise<() => void> {
258
- await ensureCampaignPresenterRegistration();
272
+ const onAbort = opts?.onAbort ?? 'cancel';
273
+ const timeoutMs = opts?.timeoutMs ?? 0;
274
+
275
+ if (!registeredGates.has(baseUrl)) {
276
+ getNativeModule().registerGate(baseUrl, onAbort, timeoutMs);
277
+ registeredGates.add(baseUrl);
278
+ }
259
279
 
260
280
  const nativeModule = getNativeModule();
261
281
  const subscription = nativeModule.onCampaignPresent((event: CampaignPresentEvent) => {
262
- const {url, info, mediationId} = event;
263
- void Promise.resolve()
264
- .then(() => presenter(url, info as Record<string, unknown>))
265
- .then(
266
- result => {
267
- nativeModule.resolveCampaign(mediationId, result);
268
- },
269
- error => {
270
- // Presenter failure is an infra failure, never a user dismiss → Unavailable.
271
- console.warn('[Amply] campaign presenter failed; reporting Unavailable', error);
272
- nativeModule.resolveCampaign(mediationId, 'Unavailable');
273
- },
282
+ const {url, params, info, mediationId} = event;
283
+ // The native side routes by baseUrl, but guard here too in case multiple gates
284
+ // share the single onCampaignPresent channel.
285
+ if (baseUrl && url && !url.startsWith(baseUrl)) {
286
+ return;
287
+ }
288
+
289
+ let settled = false;
290
+ const report = (result: CampaignResult) => {
291
+ if (settled) {
292
+ return;
293
+ }
294
+ settled = true;
295
+ nativeModule.resolveCampaign(mediationId, result);
296
+ };
297
+ const resolution: GateResolution = {
298
+ completed: () => report('Completed'),
299
+ dismissed: () => report('Dismissed'),
300
+ unavailable: () => report('Unavailable'),
301
+ };
302
+
303
+ try {
304
+ presenter(
305
+ (params ?? {}) as Record<string, unknown>,
306
+ info as Record<string, unknown>,
307
+ resolution,
274
308
  );
309
+ } catch (error) {
310
+ // Presenter failure is an infra failure, never a user dismiss → Unavailable.
311
+ console.warn('[Amply] gate presenter threw; reporting Unavailable (fail-open)', error);
312
+ report('Unavailable');
313
+ }
275
314
  });
276
315
 
277
316
  return trackCampaignPresenterSubscription(subscription);
@@ -361,10 +400,11 @@ export function removeAllListeners(): void {
361
400
  try {
362
401
  unsubscribe();
363
402
  } catch (error) {
364
- console.warn('[Amply] Failed to remove campaign presenter', error);
403
+ console.warn('[Amply] Failed to remove gate presenter', error);
365
404
  }
366
405
  });
367
406
  campaignPresenterSubscriptions.clear();
407
+ registeredGates.clear();
368
408
  }
369
409
 
370
410
  export type {
@@ -393,7 +433,8 @@ export default {
393
433
  isInitialized,
394
434
  track,
395
435
  trackEvent,
396
- registerCampaignPresenter,
436
+ trackGated,
437
+ registerGate,
397
438
  getRecentEvents,
398
439
  getDataSetSnapshot,
399
440
  addDeepLinkListener,