@digia-engage/core 2.0.0-rc.1 → 2.0.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 (50) hide show
  1. package/README.md +1 -1
  2. package/android/build.gradle +1 -1
  3. package/lib/commonjs/Digia.js +91 -3
  4. package/lib/commonjs/Digia.js.map +1 -1
  5. package/lib/commonjs/DigiaAnchorView.js +35 -3
  6. package/lib/commonjs/DigiaAnchorView.js.map +1 -1
  7. package/lib/commonjs/DigiaHealthReporter.js +1 -1
  8. package/lib/commonjs/DigiaHealthReporter.js.map +1 -1
  9. package/lib/commonjs/DigiaProvider.js +3 -4
  10. package/lib/commonjs/DigiaProvider.js.map +1 -1
  11. package/lib/commonjs/DigiaSlotView.js +3 -3
  12. package/lib/commonjs/DigiaSlotView.js.map +1 -1
  13. package/lib/commonjs/frequencyEvaluator.js +70 -0
  14. package/lib/commonjs/frequencyEvaluator.js.map +1 -0
  15. package/lib/commonjs/frequencyStore.js +70 -0
  16. package/lib/commonjs/frequencyStore.js.map +1 -0
  17. package/lib/module/Digia.js +90 -3
  18. package/lib/module/Digia.js.map +1 -1
  19. package/lib/module/DigiaAnchorView.js +33 -1
  20. package/lib/module/DigiaAnchorView.js.map +1 -1
  21. package/lib/module/DigiaHealthReporter.js +1 -1
  22. package/lib/module/DigiaHealthReporter.js.map +1 -1
  23. package/lib/module/DigiaProvider.js +3 -4
  24. package/lib/module/DigiaProvider.js.map +1 -1
  25. package/lib/module/DigiaSlotView.js +3 -3
  26. package/lib/module/DigiaSlotView.js.map +1 -1
  27. package/lib/module/frequencyEvaluator.js +61 -0
  28. package/lib/module/frequencyEvaluator.js.map +1 -0
  29. package/lib/module/frequencyStore.js +64 -0
  30. package/lib/module/frequencyStore.js.map +1 -0
  31. package/lib/typescript/Digia.d.ts +6 -1
  32. package/lib/typescript/Digia.d.ts.map +1 -1
  33. package/lib/typescript/DigiaAnchorView.d.ts +5 -1
  34. package/lib/typescript/DigiaAnchorView.d.ts.map +1 -1
  35. package/lib/typescript/DigiaProvider.d.ts.map +1 -1
  36. package/lib/typescript/frequencyEvaluator.d.ts +14 -0
  37. package/lib/typescript/frequencyEvaluator.d.ts.map +1 -0
  38. package/lib/typescript/frequencyStore.d.ts +7 -0
  39. package/lib/typescript/frequencyStore.d.ts.map +1 -0
  40. package/lib/typescript/types.d.ts +23 -1
  41. package/lib/typescript/types.d.ts.map +1 -1
  42. package/package.json +5 -1
  43. package/src/Digia.ts +100 -2
  44. package/src/DigiaAnchorView.tsx +30 -2
  45. package/src/DigiaHealthReporter.ts +1 -1
  46. package/src/DigiaProvider.tsx +3 -1
  47. package/src/DigiaSlotView.tsx +3 -3
  48. package/src/frequencyEvaluator.ts +57 -0
  49. package/src/frequencyStore.ts +79 -0
  50. package/src/types.ts +30 -1
package/src/Digia.ts CHANGED
@@ -20,12 +20,17 @@ import { nativeDigiaModule } from './NativeDigiaEngage';
20
20
  import { digiaHealthReporter, HealthEventType } from './DigiaHealthReporter';
21
21
  import { digiaGuideController } from './DigiaGuideController';
22
22
  import { digiaActionHandler } from './actionHandler';
23
+ import uuid from 'react-native-uuid';
24
+ import { frequencyStore } from './frequencyStore';
25
+ import { evaluate, hasPolicy, isSessionPolicy } from './frequencyEvaluator';
23
26
  import type {
24
27
  CampaignType,
25
28
  DigiaConfig,
26
29
  DigiaDelegate,
27
30
  DigiaExperienceEvent,
28
31
  DigiaPlugin,
32
+ FrequencyPolicy,
33
+ FrequencyState,
29
34
  GuideLifecycleEvent,
30
35
  InAppPayload,
31
36
  } from './types';
@@ -41,6 +46,7 @@ interface SdkCampaign {
41
46
  campaign_key: string;
42
47
  campaign_type: CampaignType;
43
48
  templateConfig?: Record<string, unknown>;
49
+ frequency?: FrequencyPolicy | null;
44
50
  }
45
51
 
46
52
  class DigiaClass implements DigiaDelegate {
@@ -53,6 +59,7 @@ class DigiaClass implements DigiaDelegate {
53
59
  private readonly _activePayloads = new Map<string, InAppPayload>();
54
60
  private _engageSubscription: { remove(): void } | null = null;
55
61
  private _projectId = '';
62
+ private _deviceId = '';
56
63
  private _apiBaseUrl = '';
57
64
  private _logLevel: DigiaConfig['logLevel'] = 'error';
58
65
  private _fontFamily: string | undefined;
@@ -89,6 +96,8 @@ class DigiaClass implements DigiaDelegate {
89
96
  throw e;
90
97
  }
91
98
 
99
+ this._deviceId = await this._loadOrCreateDeviceId();
100
+ await frequencyStore.checkProjectId(config.projectId);
92
101
  await this._refreshCampaignStore();
93
102
  }
94
103
 
@@ -170,7 +179,7 @@ class DigiaClass implements DigiaDelegate {
170
179
  // Mirrors DigiaCEPDelegate on Android.
171
180
  // Forwards to the native DigiaCEPDelegate via the bridge.
172
181
 
173
- onCampaignTriggered(payload: InAppPayload): void {
182
+ async onCampaignTriggered(payload: InAppPayload): Promise<void> {
174
183
  if (!this._nativeBridgeWired) {
175
184
  digiaHealthReporter.report(HealthEventType.plugin_not_registered, { campaign_key: payload.id });
176
185
  }
@@ -180,6 +189,18 @@ class DigiaClass implements DigiaDelegate {
180
189
 
181
190
  if (campaignKey) {
182
191
  const campaign = this._campaignsByKey.get(campaignKey);
192
+
193
+ if (campaign && hasPolicy(campaign.frequency)) {
194
+ const policy = campaign.frequency!;
195
+ const isSession = isSessionPolicy(policy);
196
+ const state = await this._getFrequencyState(campaignKey, isSession);
197
+ const result = evaluate(policy, state, Date.now());
198
+ if (!result.allow) {
199
+ this._log(`frequency_capped campaign_key=${campaignKey} reason=${result.reason}`);
200
+ return;
201
+ }
202
+ }
203
+
183
204
  if (campaign?.campaign_type === 'inline' || campaign?.campaign_type === 'survey') {
184
205
  this._log(`${campaign.campaign_type} campaign triggered campaign_key=${campaignKey}, forwarding to native`);
185
206
  this._activePayloads.set(payload.id, payload);
@@ -270,16 +291,21 @@ class DigiaClass implements DigiaDelegate {
270
291
  const payload = this._activePayloads.get(data.campaignId);
271
292
  if (!payload) return;
272
293
 
294
+ const campaignKey = this._extractCampaignKey(payload);
295
+
273
296
  let event: DigiaExperienceEvent;
274
297
  switch (data.type) {
275
298
  case 'impressed':
276
299
  event = { type: 'impressed' };
300
+ if (campaignKey) void this._bumpFrequencyImpression(campaignKey);
277
301
  break;
278
302
  case 'clicked':
279
303
  event = { type: 'clicked', elementId: data.elementId };
304
+ if (campaignKey) void this._applyStopOn(campaignKey, 'click');
280
305
  break;
281
306
  case 'dismissed':
282
307
  event = { type: 'dismissed' };
308
+ if (campaignKey) void this._applyStopOn(campaignKey, 'dismiss');
283
309
  this._activePayloads.delete(data.campaignId);
284
310
  break;
285
311
  default:
@@ -299,6 +325,16 @@ class DigiaClass implements DigiaDelegate {
299
325
  const properties = this._buildGuideProperties(event, campaignId, campaignKey);
300
326
  this._plugins.forEach((p) => p.track?.(eventName, properties));
301
327
 
328
+ if (event.type === 'viewed') {
329
+ void this._bumpFrequencyImpression(campaignKey);
330
+ }
331
+ if (event.type === 'clicked' || event.type === 'completed') {
332
+ void this._applyStopOn(campaignKey, 'click');
333
+ }
334
+ if (event.type === 'dismissed') {
335
+ void this._applyStopOn(campaignKey, 'dismiss');
336
+ }
337
+
302
338
  // Notify plugins of CEP lifecycle termination (template cleanup) on exit events.
303
339
  if (event.type === 'dismissed' || event.type === 'completed') {
304
340
  const storedPayload = this._activePayloads.get(payloadId);
@@ -403,6 +439,7 @@ class DigiaClass implements DigiaDelegate {
403
439
  headers: {
404
440
  'Content-Type': 'application/json',
405
441
  'x-digia-project-id': this._projectId,
442
+ 'x-digia-device-id': this._deviceId,
406
443
  },
407
444
  body: JSON.stringify(body),
408
445
  });
@@ -489,10 +526,71 @@ class DigiaClass implements DigiaDelegate {
489
526
  return { ...raw, templateType: type, steps } as TemplateConfig;
490
527
  }
491
528
 
529
+ // ── Device ID ────────────────────────────────────────────────────────────
530
+
531
+ private async _loadOrCreateDeviceId(): Promise<string> {
532
+ const DEVICE_ID_KEY = 'digia:device_id';
533
+ try {
534
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
535
+ const AsyncStorage = require('@react-native-async-storage/async-storage').default;
536
+ const stored = await AsyncStorage.getItem(DEVICE_ID_KEY);
537
+ if (stored) return stored;
538
+ const id = uuid.v4() as string;
539
+ await AsyncStorage.setItem(DEVICE_ID_KEY, id);
540
+ return id;
541
+ } catch {
542
+ return uuid.v4() as string;
543
+ }
544
+ }
545
+
546
+ // ── Frequency capping ────────────────────────────────────────────────────
547
+
548
+ private async _getFrequencyState(campaignKey: string, isSession: boolean): Promise<FrequencyState | null> {
549
+ return frequencyStore.get(campaignKey, isSession);
550
+ }
551
+
552
+ async _bumpFrequencyImpression(campaignKey: string): Promise<void> {
553
+ const campaign = this._campaignsByKey.get(campaignKey);
554
+ if (!campaign || !hasPolicy(campaign.frequency)) return;
555
+ const isSession = isSessionPolicy(campaign.frequency!);
556
+ const now = Date.now();
557
+ const prev = await frequencyStore.get(campaignKey, isSession);
558
+ const next: FrequencyState = {
559
+ shown_count: (prev?.shown_count ?? 0) + 1,
560
+ first_shown_at: prev?.first_shown_at ?? now,
561
+ last_shown_at: now,
562
+ stopped_at: prev?.stopped_at ?? null,
563
+ stopped_reason: prev?.stopped_reason ?? null,
564
+ };
565
+ await frequencyStore.set(campaignKey, next, isSession);
566
+ }
567
+
568
+ async _applyStopOn(campaignKey: string, interactionType: 'click' | 'dismiss'): Promise<void> {
569
+ const campaign = this._campaignsByKey.get(campaignKey);
570
+ const stopOn = campaign?.frequency?.stop_on;
571
+ if (!stopOn) return;
572
+ const matches =
573
+ stopOn === 'any_action' ||
574
+ stopOn === interactionType;
575
+ if (!matches) return;
576
+ const isSession = isSessionPolicy(campaign!.frequency!);
577
+ const prev = await frequencyStore.get(campaignKey, isSession);
578
+ if (prev?.stopped_at) return;
579
+ const now = Date.now();
580
+ const next: FrequencyState = {
581
+ shown_count: prev?.shown_count ?? 0,
582
+ first_shown_at: prev?.first_shown_at ?? null,
583
+ last_shown_at: prev?.last_shown_at ?? null,
584
+ stopped_at: now,
585
+ stopped_reason: interactionType,
586
+ };
587
+ await frequencyStore.set(campaignKey, next, isSession);
588
+ }
589
+
492
590
  private _log(message: string): void {
493
591
  if (this._logLevel !== 'verbose') return;
494
592
  // eslint-disable-next-line no-console
495
- console.log(`[Digia] ${message}`);
593
+ // console.log(`[Digia] ${message}`);
496
594
  }
497
595
 
498
596
  }
@@ -5,13 +5,18 @@
5
5
  * When a SHOW_TOOLTIP or SHOW_SPOTLIGHT campaign fires, the native SDK looks up this view
6
6
  * via AnchorRegistry and uses getLocationOnScreen() for accurate pixel-perfect coordinates.
7
7
  *
8
+ * Also reports layout into the JS digiaAnchorRegistry so JS-rendered guides (tooltip/spotlight)
9
+ * can position themselves relative to this anchor.
10
+ *
8
11
  * Usage:
9
12
  * <DigiaAnchorView anchorKey="pdp_add_to_cart" style={{ alignSelf: 'flex-start' }}>
10
13
  * <TouchableOpacity ...>Add to Cart</TouchableOpacity>
11
14
  * </DigiaAnchorView>
12
15
  */
13
16
 
14
- import { requireNativeComponent, type ViewProps } from 'react-native';
17
+ import React, { useCallback, useRef } from 'react';
18
+ import { requireNativeComponent, View, type ViewProps, type LayoutChangeEvent } from 'react-native';
19
+ import { digiaAnchorRegistry } from './digiaAnchorRegistry';
15
20
 
16
21
  interface DigiaAnchorViewProps extends ViewProps {
17
22
  anchorKey: string;
@@ -19,4 +24,27 @@ interface DigiaAnchorViewProps extends ViewProps {
19
24
  cornerRadius?: number;
20
25
  }
21
26
 
22
- export const DigiaAnchorView = requireNativeComponent<DigiaAnchorViewProps>('DigiaAnchorView');
27
+ const NativeDigiaAnchorView = requireNativeComponent<DigiaAnchorViewProps>('DigiaAnchorView');
28
+
29
+ export const DigiaAnchorView = ({ anchorKey, onLayout, ...rest }: DigiaAnchorViewProps) => {
30
+ const viewRef = useRef<View>(null);
31
+
32
+ const handleLayout = useCallback((e: LayoutChangeEvent) => {
33
+ onLayout?.(e);
34
+ // Use measure() for absolute screen coordinates (onLayout gives relative coords)
35
+ viewRef.current?.measure((_x, _y, width, height, pageX, pageY) => {
36
+ if (width > 0 && height > 0) {
37
+ digiaAnchorRegistry.setLayout(anchorKey, { pageX, pageY, width, height });
38
+ }
39
+ });
40
+ }, [anchorKey, onLayout]);
41
+
42
+ return (
43
+ <NativeDigiaAnchorView
44
+ ref={viewRef as any}
45
+ anchorKey={anchorKey}
46
+ onLayout={handleLayout}
47
+ {...rest}
48
+ />
49
+ );
50
+ };
@@ -36,7 +36,7 @@ export class DigiaHealthReporter {
36
36
  // },
37
37
  // body: JSON.stringify({ event_type: eventType, detail }),
38
38
  // }).catch(() => { /* swallow */ });
39
- if (__DEV__) console.log('[DigiaHealth]', eventType, detail);
39
+ // if (__DEV__) console.log('[DigiaHealth]', eventType, detail);
40
40
  }
41
41
  }
42
42
 
@@ -221,7 +221,9 @@ function TooltipOverlay({
221
221
  useEffect(() => {
222
222
  setLayout(null);
223
223
  setFloatPos(null);
224
+ // if (__DEV__) console.log(`[Digia] guide waiting for anchor key="${step.anchorKey}"`);
224
225
  return digiaAnchorRegistry.subscribe(step.anchorKey, (l) => {
226
+ // if (__DEV__) console.log(`[Digia] anchor resolved key="${step.anchorKey}"`, l);
225
227
  setLayout(l);
226
228
  });
227
229
  }, [step.anchorKey]);
@@ -478,7 +480,7 @@ function SpotlightCallout({
478
480
  middleware: [offset(gap), flip(), shift({ padding: 16 })],
479
481
  },
480
482
  ).then(({ x, y, placement }) => {
481
- console.log('[Digia:spotlight] floatPos=', { x, y }, 'resolved=', placement);
483
+ // console.log('[Digia:spotlight] floatPos=', { x, y }, 'resolved=', placement);
482
484
  setFloatPos({ x, y });
483
485
  setResolvedPlacement(placement as string);
484
486
  });
@@ -36,11 +36,11 @@ export function DigiaSlotView({ placementKey, style }: DigiaSlotViewProps) {
36
36
  const [contentWidth, setContentWidth] = useState<number | null>(null);
37
37
 
38
38
  useEffect(() => {
39
- console.log('[DigiaSlotView:debug] mounted placementKey=' + placementKey);
39
+ // console.log('[DigiaSlotView:debug] mounted placementKey=' + placementKey);
40
40
  const sub = DeviceEventEmitter.addListener(
41
41
  'digiaSlotWidth',
42
42
  (data: { slotKey: string; width: number | null }) => {
43
- console.log('[DigiaSlotView:debug] digiaSlotWidth event received', JSON.stringify(data), 'myKey=' + placementKey, 'match=' + (data.slotKey === placementKey));
43
+ // console.log('[DigiaSlotView:debug] digiaSlotWidth event received', JSON.stringify(data), 'myKey=' + placementKey, 'match=' + (data.slotKey === placementKey));
44
44
  if (data.slotKey === placementKey) {
45
45
  setContentWidth(data.width && data.width > 0 ? data.width : null);
46
46
  }
@@ -53,7 +53,7 @@ export function DigiaSlotView({ placementKey, style }: DigiaSlotViewProps) {
53
53
  (event: { nativeEvent: { height: number; width: number } }) => {
54
54
  const h = event.nativeEvent.height ?? 0;
55
55
  const w = event.nativeEvent.width ?? 0;
56
- console.log('[DigiaSlotView:debug] onContentSizeChange placementKey=' + placementKey + ' h=' + h + ' w=' + w);
56
+ // console.log('[DigiaSlotView:debug] onContentSizeChange placementKey=' + placementKey + ' h=' + h + ' w=' + w);
57
57
  setContentHeight(Math.max(0, h));
58
58
  setContentWidth(w > 0 ? w : null);
59
59
  },
@@ -0,0 +1,57 @@
1
+ import type { FrequencyPolicy, FrequencyState, FrequencyEvalResult } from './types';
2
+
3
+ const WINDOW_MS: Record<string, number> = {
4
+ day: 86_400_000,
5
+ week: 7 * 86_400_000,
6
+ month: 30 * 86_400_000,
7
+ };
8
+
9
+ export const isSessionPolicy = (policy: FrequencyPolicy): boolean =>
10
+ policy.max_per_window?.window === 'session';
11
+
12
+ /**
13
+ * Pure eligibility function. No side effects.
14
+ *
15
+ * Semantics for max_per_window { count, window }:
16
+ * - "count" shows are allowed, measured from first_shown_at.
17
+ * - Once the window duration has elapsed since first_shown_at, permanently blocked (reason: 'window').
18
+ * - Once shown_count >= count, permanently blocked (reason: 'max_total').
19
+ * - 'session' window is checked by the caller via in-memory state — same logic applies.
20
+ */
21
+ export const evaluate = (
22
+ policy: FrequencyPolicy,
23
+ state: FrequencyState | null,
24
+ now: number,
25
+ ): FrequencyEvalResult => {
26
+ if (!state) return { allow: true, reason: null };
27
+
28
+ if (state.stopped_at !== null) {
29
+ return { allow: false, reason: 'stopped' };
30
+ }
31
+
32
+ if (policy.max_total !== null && state.shown_count >= policy.max_total) {
33
+ return { allow: false, reason: 'max_total' };
34
+ }
35
+
36
+ if (policy.max_per_window !== null) {
37
+ const { count, window: win } = policy.max_per_window;
38
+ const windowMs = WINDOW_MS[win];
39
+
40
+ if (windowMs !== undefined && state.first_shown_at !== null) {
41
+ if (now - state.first_shown_at > windowMs) {
42
+ return { allow: false, reason: 'window' };
43
+ }
44
+ }
45
+
46
+ if (state.shown_count >= count) {
47
+ return { allow: false, reason: 'max_total' };
48
+ }
49
+ }
50
+
51
+ return { allow: true, reason: null };
52
+ };
53
+
54
+ export const hasPolicy = (policy: FrequencyPolicy | null | undefined): policy is FrequencyPolicy =>
55
+ policy !== null &&
56
+ policy !== undefined &&
57
+ (policy.max_total !== null || policy.max_per_window !== null || policy.stop_on !== null);
@@ -0,0 +1,79 @@
1
+ import type { FrequencyState } from './types';
2
+
3
+ // eslint-disable-next-line @typescript-eslint/naming-convention
4
+ const STORE_META_KEY = 'digia:freq:__meta__';
5
+
6
+ type AsyncStorageAdapter = {
7
+ getItem(key: string): Promise<string | null>;
8
+ setItem(key: string, value: string): Promise<void>;
9
+ removeItem(key: string): Promise<void>;
10
+ getAllKeys(): Promise<readonly string[]>;
11
+ multiRemove(keys: string[]): Promise<void>;
12
+ };
13
+
14
+ let _storage: AsyncStorageAdapter | null = null;
15
+
16
+ const _loadStorage = (): AsyncStorageAdapter | null => {
17
+ try {
18
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
19
+ const mod = require('@react-native-async-storage/async-storage');
20
+ return mod.default ?? mod;
21
+ } catch {
22
+ console.warn('[Digia] AsyncStorage unavailable — frequency state is in-memory only (resets on app restart)');
23
+ return null;
24
+ }
25
+ };
26
+
27
+ const getStorage = (): AsyncStorageAdapter | null => {
28
+ if (_storage === undefined) {
29
+ _storage = _loadStorage();
30
+ }
31
+ return _storage;
32
+ };
33
+
34
+ const _sessionStore = new Map<string, FrequencyState>();
35
+
36
+ const storeKey = (campaignKey: string) => `digia:freq:${campaignKey}`;
37
+
38
+ export const frequencyStore = {
39
+ async checkProjectId(projectId: string): Promise<void> {
40
+ const storage = getStorage();
41
+ if (!storage) return;
42
+ try {
43
+ const stored = await storage.getItem(STORE_META_KEY);
44
+ const meta = stored ? (JSON.parse(stored) as { projectId: string }) : null;
45
+ if (meta && meta.projectId !== projectId) {
46
+ const keys = await storage.getAllKeys();
47
+ const digiaKeys = keys.filter((k) => k.startsWith('digia:freq:'));
48
+ if (digiaKeys.length > 0) await storage.multiRemove([...digiaKeys]);
49
+ }
50
+ await storage.setItem(STORE_META_KEY, JSON.stringify({ projectId }));
51
+ } catch {
52
+ // non-fatal
53
+ }
54
+ },
55
+
56
+ async get(campaignKey: string, isSession: boolean): Promise<FrequencyState | null> {
57
+ if (isSession) return _sessionStore.get(campaignKey) ?? null;
58
+ const storage = getStorage();
59
+ if (!storage) return _sessionStore.get(campaignKey) ?? null;
60
+ try {
61
+ const raw = await storage.getItem(storeKey(campaignKey));
62
+ return raw ? (JSON.parse(raw) as FrequencyState) : null;
63
+ } catch {
64
+ return null;
65
+ }
66
+ },
67
+
68
+ async set(campaignKey: string, state: FrequencyState, isSession: boolean): Promise<void> {
69
+ _sessionStore.set(campaignKey, state);
70
+ if (isSession) return;
71
+ const storage = getStorage();
72
+ if (!storage) return;
73
+ try {
74
+ await storage.setItem(storeKey(campaignKey), JSON.stringify(state));
75
+ } catch {
76
+ // non-fatal: state already updated in-memory above
77
+ }
78
+ },
79
+ };
package/src/types.ts CHANGED
@@ -70,7 +70,7 @@ export type GuideLifecycleEvent =
70
70
  */
71
71
  export interface DigiaDelegate {
72
72
  /** Deliver a campaign payload into the Digia rendering engine. */
73
- onCampaignTriggered(payload: InAppPayload): void;
73
+ onCampaignTriggered(payload: InAppPayload): void | Promise<void>;
74
74
  /** Invalidate / dismiss a campaign by its ID. */
75
75
  onCampaignInvalidated(campaignId: string): void;
76
76
  }
@@ -134,6 +134,35 @@ export type InAppBrowserAdapter = {
134
134
  open: (url: string) => Promise<void>;
135
135
  };
136
136
 
137
+ // ─── Frequency capping ────────────────────────────────────────────────────────
138
+
139
+ export interface FrequencyWindow {
140
+ count: number;
141
+ window: 'session' | 'day' | 'week' | 'month';
142
+ }
143
+
144
+ export interface FrequencyPolicy {
145
+ max_total: number | null;
146
+ max_per_window: FrequencyWindow | null;
147
+ stop_on: 'click' | 'dismiss' | 'any_action' | null;
148
+ min_gap_ms?: number | null; // reserved — not evaluated in v1
149
+ }
150
+
151
+ export interface FrequencyState {
152
+ shown_count: number;
153
+ first_shown_at: number | null; // ms timestamp — set on first impression
154
+ last_shown_at: number | null; // ms timestamp — reserved for min_gap_ms
155
+ stopped_at: number | null;
156
+ stopped_reason: string | null;
157
+ }
158
+
159
+ export type FrequencySkipReason = 'max_total' | 'window' | 'stopped';
160
+
161
+ export interface FrequencyEvalResult {
162
+ allow: boolean;
163
+ reason: FrequencySkipReason | null;
164
+ }
165
+
137
166
  // ─── SDK init config ──────────────────────────────────────────────────────────
138
167
 
139
168
  /**