@solucx/react-native-solucx-widget 0.1.16 → 0.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.
- package/package.json +31 -4
- package/src/SoluCXWidget.tsx +106 -53
- package/src/__mocks__/expo-modules-core-web.js +16 -0
- package/src/__mocks__/expo-modules-core.js +33 -0
- package/src/__tests__/ClientVersionCollector.test.ts +55 -0
- package/src/__tests__/CloseButton.test.tsx +47 -0
- package/src/__tests__/Constants.test.ts +17 -0
- package/src/__tests__/InlineWidget.rendering.test.tsx +81 -0
- package/src/__tests__/ModalWidget.rendering.test.tsx +157 -0
- package/src/__tests__/OverlayWidget.rendering.test.tsx +123 -0
- package/src/__tests__/SoluCXWidget.rendering.test.tsx +315 -0
- package/src/__tests__/e2e/widget-lifecycle.test.tsx +353 -0
- package/src/__tests__/integration/webview-communication-simple.test.tsx +147 -0
- package/src/__tests__/integration/webview-communication.test.tsx +417 -0
- package/src/__tests__/useDeviceInfoCollector.test.ts +109 -0
- package/src/__tests__/useWidgetState.test.ts +76 -84
- package/src/__tests__/widgetBootstrapService.test.ts +182 -0
- package/src/components/ModalWidget.tsx +3 -5
- package/src/components/OverlayWidget.tsx +1 -1
- package/src/constants/Constants.ts +4 -0
- package/src/constants/webViewConstants.ts +1 -0
- package/src/hooks/useDeviceInfoCollector.ts +67 -0
- package/src/hooks/useWidgetState.ts +1 -1
- package/src/index.ts +4 -0
- package/src/interfaces/WidgetCallbacks.ts +15 -0
- package/src/interfaces/index.ts +3 -2
- package/src/services/ClientVersionCollector.ts +15 -0
- package/src/services/storage.ts +2 -2
- package/src/services/widgetBootstrapService.ts +67 -0
- package/src/services/widgetEventService.ts +14 -30
- package/src/services/widgetValidationService.ts +29 -13
- package/src/setupTests.js +43 -0
- package/src/styles/widgetStyles.ts +1 -1
- package/src/utils/urlUtils.ts +2 -2
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { SoluCXKey, WidgetData } from "../interfaces";
|
|
2
|
+
import { RATING_FORM_ENDPOINT } from "../constants/webViewConstants";
|
|
3
|
+
import { getClientVersion } from "../services/ClientVersionCollector";
|
|
4
|
+
import { getDeviceInfo } from "../hooks/useDeviceInfoCollector";
|
|
5
|
+
import { SDK_NAME, SDK_VERSION } from "../constants/Constants";
|
|
6
|
+
|
|
7
|
+
type Payload = {
|
|
8
|
+
url: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const buildRequestParams = (requestParams: WidgetData): URLSearchParams => {
|
|
12
|
+
const params = new URLSearchParams();
|
|
13
|
+
|
|
14
|
+
Object.entries(requestParams).forEach(([key, value]) => {
|
|
15
|
+
if (value === undefined || value === null) return;
|
|
16
|
+
params.append(key, typeof value === "number" ? value.toString() : String(value));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
params.set("transactionId", "");
|
|
20
|
+
params.set("attemptId", "");
|
|
21
|
+
|
|
22
|
+
return params;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const buildRequestHeaders = (instanceKey: SoluCXKey, userId: string): Record<string, string> => {
|
|
26
|
+
const deviceInfo = getDeviceInfo();
|
|
27
|
+
const appVersion = getClientVersion();
|
|
28
|
+
const isMobile = deviceInfo.deviceType === "phone" ? "?1" : "?0";
|
|
29
|
+
const userAgent = `${SDK_NAME}/${SDK_VERSION} (${deviceInfo.platform}; OS ${deviceInfo.osVersion}; ${deviceInfo.model}) App/${appVersion}`;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
accept: "application/json, text/plain, */*",
|
|
33
|
+
"x-solucx-api-key": instanceKey,
|
|
34
|
+
"x-solucx-device-id": userId,
|
|
35
|
+
"x-client-id": userId,
|
|
36
|
+
"x-sdk-name": SDK_NAME,
|
|
37
|
+
"x-sdk-version": SDK_VERSION,
|
|
38
|
+
"x-solucx-app-version": appVersion,
|
|
39
|
+
"x-solucx-client-platform": deviceInfo.platform,
|
|
40
|
+
"Sec-CH-UA": `"${SDK_NAME}";v="${SDK_VERSION}", "app";v="${appVersion}"`,
|
|
41
|
+
"Sec-CH-UA-Platform": `"${deviceInfo.platform}"`,
|
|
42
|
+
"Sec-CH-UA-Mobile": isMobile,
|
|
43
|
+
"Sec-CH-UA-Platform-Version": `"${deviceInfo.osVersion}"`,
|
|
44
|
+
"Sec-CH-UA-Model": `"${deviceInfo.model}"`,
|
|
45
|
+
"User-Agent": userAgent,
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export async function requestWidgetUrl(
|
|
50
|
+
instanceKey: SoluCXKey,
|
|
51
|
+
requestParams: WidgetData,
|
|
52
|
+
userId: string,
|
|
53
|
+
): Promise<string | undefined> {
|
|
54
|
+
if (typeof fetch !== "function") return;
|
|
55
|
+
|
|
56
|
+
const params = buildRequestParams(requestParams);
|
|
57
|
+
const url = `${RATING_FORM_ENDPOINT}?${params.toString()}`;
|
|
58
|
+
const headers = buildRequestHeaders(instanceKey, userId);
|
|
59
|
+
const response = await fetch(url, { method: "GET", headers });
|
|
60
|
+
|
|
61
|
+
if (!response.ok) return;
|
|
62
|
+
|
|
63
|
+
const payload: Payload = await response.json();
|
|
64
|
+
return payload?.url;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { buildRequestParams };
|
|
@@ -1,30 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type {
|
|
2
2
|
EventKey,
|
|
3
3
|
SurveyEventKey,
|
|
4
4
|
WidgetResponse,
|
|
5
|
-
|
|
5
|
+
WidgetCallbacks,
|
|
6
6
|
} from "../interfaces";
|
|
7
|
-
import { WidgetValidationService } from "./widgetValidationService";
|
|
8
7
|
|
|
9
8
|
export class WidgetEventService {
|
|
10
9
|
private setIsWidgetVisible: (visible: boolean) => void;
|
|
11
10
|
private resize: (value: string) => void;
|
|
12
|
-
private
|
|
13
|
-
private
|
|
14
|
-
private widgetOptions: WidgetOptions;
|
|
11
|
+
private userId: string;
|
|
12
|
+
private callbacks?: WidgetCallbacks;
|
|
15
13
|
|
|
16
14
|
constructor(
|
|
17
15
|
setIsWidgetVisible: (visible: boolean) => void,
|
|
18
16
|
resize: (value: string) => void,
|
|
19
|
-
open: () => void,
|
|
20
17
|
userId: string,
|
|
21
|
-
|
|
18
|
+
callbacks?: WidgetCallbacks,
|
|
22
19
|
) {
|
|
23
20
|
this.setIsWidgetVisible = setIsWidgetVisible;
|
|
24
21
|
this.resize = resize;
|
|
25
|
-
this.
|
|
26
|
-
this.
|
|
27
|
-
this.widgetOptions = widgetOptions;
|
|
22
|
+
this.userId = userId;
|
|
23
|
+
this.callbacks = callbacks;
|
|
28
24
|
}
|
|
29
25
|
|
|
30
26
|
async handleMessage(
|
|
@@ -44,7 +40,6 @@ export class WidgetEventService {
|
|
|
44
40
|
value: string,
|
|
45
41
|
): Promise<WidgetResponse> {
|
|
46
42
|
const eventHandlers = {
|
|
47
|
-
FORM_OPENED: () => this.handleFormOpened(),
|
|
48
43
|
FORM_CLOSE: () => this.handleFormClose(),
|
|
49
44
|
FORM_ERROR: (value: string) => this.handleFormError(value),
|
|
50
45
|
FORM_PAGECHANGED: (value: string) => this.handlePageChanged(value),
|
|
@@ -61,52 +56,41 @@ export class WidgetEventService {
|
|
|
61
56
|
});
|
|
62
57
|
}
|
|
63
58
|
|
|
64
|
-
private async handleFormOpened(): Promise<WidgetResponse> {
|
|
65
|
-
const canDisplay = await this.validationService.shouldDisplayWidget(
|
|
66
|
-
this.widgetOptions,
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
if (!canDisplay) {
|
|
70
|
-
return { status: "error", message: "Widget not allowed" };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
this.open();
|
|
74
|
-
this.setIsWidgetVisible(true);
|
|
75
|
-
return { status: "success" };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
59
|
private handleFormClose(): WidgetResponse {
|
|
79
60
|
this.setIsWidgetVisible(false);
|
|
61
|
+
this.callbacks?.onClosed?.();
|
|
80
62
|
return { status: "success" };
|
|
81
63
|
}
|
|
82
64
|
|
|
83
65
|
private handleFormError(value: string): WidgetResponse {
|
|
84
66
|
this.setIsWidgetVisible(false);
|
|
67
|
+
this.callbacks?.onError?.(value);
|
|
85
68
|
return { status: "error", message: value };
|
|
86
69
|
}
|
|
87
70
|
|
|
88
71
|
private handlePageChanged(value: string): WidgetResponse {
|
|
89
|
-
|
|
72
|
+
this.callbacks?.onPageChanged?.(value);
|
|
90
73
|
return { status: "success" };
|
|
91
74
|
}
|
|
92
75
|
|
|
93
76
|
private handleQuestionAnswered(): WidgetResponse {
|
|
94
|
-
|
|
77
|
+
this.callbacks?.onQuestionAnswered?.();
|
|
95
78
|
return { status: "success" };
|
|
96
79
|
}
|
|
97
80
|
|
|
98
81
|
private handleFormCompleted(): WidgetResponse {
|
|
99
|
-
|
|
82
|
+
this.callbacks?.onPartialCompleted?.(this.userId);
|
|
100
83
|
return { status: "success" };
|
|
101
84
|
}
|
|
102
85
|
|
|
103
86
|
private handlePartialCompleted(): WidgetResponse {
|
|
104
|
-
|
|
87
|
+
this.callbacks?.onPartialCompleted?.(this.userId);
|
|
105
88
|
return { status: "success" };
|
|
106
89
|
}
|
|
107
90
|
|
|
108
91
|
private handleResize(value: string): WidgetResponse {
|
|
109
92
|
this.resize(value);
|
|
93
|
+
this.callbacks?.onResize?.(value);
|
|
110
94
|
return { status: "success" };
|
|
111
95
|
}
|
|
112
96
|
|
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import { WidgetOptions, WidgetSamplerLog } from
|
|
2
|
-
import { StorageService } from
|
|
1
|
+
import type { WidgetOptions, WidgetSamplerLog } from "../interfaces";
|
|
2
|
+
import { StorageService } from "./storage";
|
|
3
|
+
|
|
4
|
+
export type BlockReason =
|
|
5
|
+
| "BLOCKED_BY_RATING_INTERVAL"
|
|
6
|
+
| "BLOCKED_BY_PARTIAL_INTERVAL"
|
|
7
|
+
| "BLOCKED_BY_MAX_RETRY_ATTEMPTS";
|
|
8
|
+
|
|
9
|
+
export interface WidgetDisplayResult {
|
|
10
|
+
canDisplay: boolean;
|
|
11
|
+
blockReason?: BlockReason;
|
|
12
|
+
}
|
|
3
13
|
|
|
4
14
|
export class WidgetValidationService {
|
|
5
15
|
private storageService: StorageService;
|
|
@@ -8,32 +18,38 @@ export class WidgetValidationService {
|
|
|
8
18
|
this.storageService = new StorageService(userId);
|
|
9
19
|
}
|
|
10
20
|
|
|
11
|
-
async shouldDisplayWidget(widgetOptions: WidgetOptions): Promise<
|
|
21
|
+
async shouldDisplayWidget(widgetOptions: WidgetOptions): Promise<WidgetDisplayResult> {
|
|
12
22
|
const { retry, waitDelayAfterRating = 60 } = widgetOptions;
|
|
13
23
|
const { attempts = 5, interval = 1 } = retry || {};
|
|
14
24
|
const userLog = await this.getLog();
|
|
15
25
|
const now = Date.now();
|
|
16
26
|
const dayInMilliseconds = 86400000;
|
|
17
27
|
|
|
18
|
-
if (this.isWithinCollectInterval(userLog, waitDelayAfterRating, now, dayInMilliseconds))
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
if (this.isWithinCollectInterval(userLog, waitDelayAfterRating, now, dayInMilliseconds)) {
|
|
29
|
+
return { canDisplay: false, blockReason: "BLOCKED_BY_RATING_INTERVAL" };
|
|
30
|
+
}
|
|
31
|
+
if (this.isWithinCollectPartialInterval(userLog, waitDelayAfterRating, now, dayInMilliseconds)) {
|
|
32
|
+
return { canDisplay: false, blockReason: "BLOCKED_BY_PARTIAL_INTERVAL" };
|
|
33
|
+
}
|
|
34
|
+
if (this.isWithinRetryInterval(userLog, interval, attempts, now, dayInMilliseconds)) {
|
|
35
|
+
return { canDisplay: false, blockReason: "BLOCKED_BY_MAX_RETRY_ATTEMPTS" };
|
|
36
|
+
}
|
|
21
37
|
|
|
22
38
|
await this.resetAttemptsIfNeeded(userLog, attempts);
|
|
23
39
|
await this.setLog(userLog);
|
|
24
|
-
return true;
|
|
40
|
+
return { canDisplay: true };
|
|
25
41
|
}
|
|
26
42
|
|
|
27
43
|
private async getLog(): Promise<WidgetSamplerLog> {
|
|
28
44
|
try {
|
|
29
45
|
return await this.storageService.read();
|
|
30
46
|
} catch (error) {
|
|
31
|
-
console.error(
|
|
47
|
+
console.error("Error reading widget log:", error);
|
|
32
48
|
return {
|
|
33
49
|
attempts: 0,
|
|
34
50
|
lastAttempt: 0,
|
|
35
51
|
lastRating: 0,
|
|
36
|
-
lastParcial: 0
|
|
52
|
+
lastParcial: 0,
|
|
37
53
|
};
|
|
38
54
|
}
|
|
39
55
|
}
|
|
@@ -42,7 +58,7 @@ export class WidgetValidationService {
|
|
|
42
58
|
try {
|
|
43
59
|
await this.storageService.write(userLog);
|
|
44
60
|
} catch (error) {
|
|
45
|
-
console.error(
|
|
61
|
+
console.error("Error writing widget log:", error);
|
|
46
62
|
}
|
|
47
63
|
}
|
|
48
64
|
|
|
@@ -50,7 +66,7 @@ export class WidgetValidationService {
|
|
|
50
66
|
userLog: WidgetSamplerLog,
|
|
51
67
|
waitDelayAfterRating: number,
|
|
52
68
|
now: number,
|
|
53
|
-
dayInMilliseconds: number
|
|
69
|
+
dayInMilliseconds: number,
|
|
54
70
|
): boolean {
|
|
55
71
|
const timeSinceLastRating = now - userLog.lastRating;
|
|
56
72
|
return userLog.lastRating > 0 && timeSinceLastRating < waitDelayAfterRating * dayInMilliseconds;
|
|
@@ -60,7 +76,7 @@ export class WidgetValidationService {
|
|
|
60
76
|
userLog: WidgetSamplerLog,
|
|
61
77
|
waitDelayAfterRating: number,
|
|
62
78
|
now: number,
|
|
63
|
-
dayInMilliseconds: number
|
|
79
|
+
dayInMilliseconds: number,
|
|
64
80
|
): boolean {
|
|
65
81
|
const timeSinceLastPartial = now - userLog.lastParcial;
|
|
66
82
|
return userLog.lastParcial > 0 && timeSinceLastPartial < waitDelayAfterRating * dayInMilliseconds;
|
|
@@ -71,7 +87,7 @@ export class WidgetValidationService {
|
|
|
71
87
|
interval: number,
|
|
72
88
|
attempts: number,
|
|
73
89
|
now: number,
|
|
74
|
-
dayInMilliseconds: number
|
|
90
|
+
dayInMilliseconds: number,
|
|
75
91
|
): boolean {
|
|
76
92
|
if (userLog.attempts < attempts) return false;
|
|
77
93
|
const timeSinceLastAttempt = now - userLog.lastAttempt;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Mock react-native-safe-area-context
|
|
2
|
+
jest.mock('react-native-safe-area-context', () => {
|
|
3
|
+
const React = require('react');
|
|
4
|
+
return {
|
|
5
|
+
SafeAreaView: ({ children, ...props}) =>
|
|
6
|
+
React.createElement('SafeAreaView', props, children),
|
|
7
|
+
SafeAreaProvider: ({ children }) => children,
|
|
8
|
+
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
|
9
|
+
};
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Suppress act() warnings from Animated components
|
|
13
|
+
// These warnings are expected for components with animations in tests
|
|
14
|
+
// since we're testing rendering, not animation behavior
|
|
15
|
+
const originalError = console.error;
|
|
16
|
+
|
|
17
|
+
beforeAll(() => {
|
|
18
|
+
console.error = (...args) => {
|
|
19
|
+
if (
|
|
20
|
+
typeof args[0] === 'string' &&
|
|
21
|
+
(args[0].includes('An update to Animated') ||
|
|
22
|
+
args[0].includes('was not wrapped in act'))
|
|
23
|
+
) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
originalError.call(console, ...args);
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(() => {
|
|
31
|
+
console.error = originalError;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Use fake timers to control React Native animations and prevent teardown errors
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
jest.useFakeTimers();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
jest.runOnlyPendingTimers();
|
|
41
|
+
jest.clearAllTimers();
|
|
42
|
+
jest.useRealTimers();
|
|
43
|
+
});
|
package/src/utils/urlUtils.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { BASE_URL } from
|
|
2
|
-
import { WidgetData, SoluCXKey } from
|
|
1
|
+
import { BASE_URL } from "../constants/webViewConstants";
|
|
2
|
+
import type { WidgetData, SoluCXKey } from "../interfaces";
|
|
3
3
|
|
|
4
4
|
export function buildWidgetURL(key: SoluCXKey, data: WidgetData): string {
|
|
5
5
|
const params = new URLSearchParams(data as Record<string, string>);
|