@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.
- package/README.md +1 -1
- package/android/build.gradle +1 -1
- package/lib/commonjs/Digia.js +91 -3
- package/lib/commonjs/Digia.js.map +1 -1
- package/lib/commonjs/DigiaAnchorView.js +35 -3
- package/lib/commonjs/DigiaAnchorView.js.map +1 -1
- package/lib/commonjs/DigiaHealthReporter.js +1 -1
- package/lib/commonjs/DigiaHealthReporter.js.map +1 -1
- package/lib/commonjs/DigiaProvider.js +3 -4
- package/lib/commonjs/DigiaProvider.js.map +1 -1
- package/lib/commonjs/DigiaSlotView.js +3 -3
- package/lib/commonjs/DigiaSlotView.js.map +1 -1
- package/lib/commonjs/frequencyEvaluator.js +70 -0
- package/lib/commonjs/frequencyEvaluator.js.map +1 -0
- package/lib/commonjs/frequencyStore.js +70 -0
- package/lib/commonjs/frequencyStore.js.map +1 -0
- package/lib/module/Digia.js +90 -3
- package/lib/module/Digia.js.map +1 -1
- package/lib/module/DigiaAnchorView.js +33 -1
- package/lib/module/DigiaAnchorView.js.map +1 -1
- package/lib/module/DigiaHealthReporter.js +1 -1
- package/lib/module/DigiaHealthReporter.js.map +1 -1
- package/lib/module/DigiaProvider.js +3 -4
- package/lib/module/DigiaProvider.js.map +1 -1
- package/lib/module/DigiaSlotView.js +3 -3
- package/lib/module/DigiaSlotView.js.map +1 -1
- package/lib/module/frequencyEvaluator.js +61 -0
- package/lib/module/frequencyEvaluator.js.map +1 -0
- package/lib/module/frequencyStore.js +64 -0
- package/lib/module/frequencyStore.js.map +1 -0
- package/lib/typescript/Digia.d.ts +6 -1
- package/lib/typescript/Digia.d.ts.map +1 -1
- package/lib/typescript/DigiaAnchorView.d.ts +5 -1
- package/lib/typescript/DigiaAnchorView.d.ts.map +1 -1
- package/lib/typescript/DigiaProvider.d.ts.map +1 -1
- package/lib/typescript/frequencyEvaluator.d.ts +14 -0
- package/lib/typescript/frequencyEvaluator.d.ts.map +1 -0
- package/lib/typescript/frequencyStore.d.ts +7 -0
- package/lib/typescript/frequencyStore.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +23 -1
- package/lib/typescript/types.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/Digia.ts +100 -2
- package/src/DigiaAnchorView.tsx +30 -2
- package/src/DigiaHealthReporter.ts +1 -1
- package/src/DigiaProvider.tsx +3 -1
- package/src/DigiaSlotView.tsx +3 -3
- package/src/frequencyEvaluator.ts +57 -0
- package/src/frequencyStore.ts +79 -0
- 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
|
}
|
package/src/DigiaAnchorView.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
package/src/DigiaProvider.tsx
CHANGED
|
@@ -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
|
});
|
package/src/DigiaSlotView.tsx
CHANGED
|
@@ -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
|
/**
|