@solucx/react-native-solucx-widget 0.1.16 → 0.2.1

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 (34) hide show
  1. package/package.json +31 -4
  2. package/src/SoluCXWidget.tsx +108 -53
  3. package/src/__mocks__/expo-modules-core-web.js +16 -0
  4. package/src/__mocks__/expo-modules-core.js +33 -0
  5. package/src/__tests__/ClientVersionCollector.test.ts +55 -0
  6. package/src/__tests__/CloseButton.test.tsx +47 -0
  7. package/src/__tests__/Constants.test.ts +17 -0
  8. package/src/__tests__/InlineWidget.rendering.test.tsx +81 -0
  9. package/src/__tests__/ModalWidget.rendering.test.tsx +157 -0
  10. package/src/__tests__/OverlayWidget.rendering.test.tsx +123 -0
  11. package/src/__tests__/SoluCXWidget.rendering.test.tsx +504 -0
  12. package/src/__tests__/e2e/widget-lifecycle.test.tsx +352 -0
  13. package/src/__tests__/integration/webview-communication-simple.test.tsx +147 -0
  14. package/src/__tests__/integration/webview-communication.test.tsx +417 -0
  15. package/src/__tests__/useDeviceInfoCollector.test.ts +109 -0
  16. package/src/__tests__/useWidgetState.test.ts +76 -84
  17. package/src/__tests__/widgetBootstrapService.test.ts +182 -0
  18. package/src/components/ModalWidget.tsx +3 -5
  19. package/src/components/OverlayWidget.tsx +1 -1
  20. package/src/constants/Constants.ts +4 -0
  21. package/src/constants/webViewConstants.ts +1 -0
  22. package/src/hooks/useDeviceInfoCollector.ts +67 -0
  23. package/src/hooks/useWidgetState.ts +4 -4
  24. package/src/index.ts +4 -0
  25. package/src/interfaces/WidgetCallbacks.ts +14 -0
  26. package/src/interfaces/index.ts +3 -2
  27. package/src/services/ClientVersionCollector.ts +15 -0
  28. package/src/services/storage.ts +2 -2
  29. package/src/services/widgetBootstrapService.ts +67 -0
  30. package/src/services/widgetEventService.ts +14 -30
  31. package/src/services/widgetValidationService.ts +29 -13
  32. package/src/setupTests.js +43 -0
  33. package/src/styles/widgetStyles.ts +1 -1
  34. 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
- WidgetOptions,
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 open: () => void;
13
- private validationService: WidgetValidationService;
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
- widgetOptions: WidgetOptions,
18
+ callbacks?: WidgetCallbacks,
22
19
  ) {
23
20
  this.setIsWidgetVisible = setIsWidgetVisible;
24
21
  this.resize = resize;
25
- this.open = open;
26
- this.validationService = new WidgetValidationService(userId);
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
- console.log("Page changed:", value);
72
+ this.callbacks?.onPageChanged?.(value);
90
73
  return { status: "success" };
91
74
  }
92
75
 
93
76
  private handleQuestionAnswered(): WidgetResponse {
94
- console.log("Question answered");
77
+ this.callbacks?.onQuestionAnswered?.();
95
78
  return { status: "success" };
96
79
  }
97
80
 
98
81
  private handleFormCompleted(): WidgetResponse {
99
- // TODO: Implement completion logic
82
+ this.callbacks?.onCompleted?.(this.userId);
100
83
  return { status: "success" };
101
84
  }
102
85
 
103
86
  private handlePartialCompleted(): WidgetResponse {
104
- // TODO: Implement partial completion logic
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 '../interfaces';
2
- import { StorageService } from './storage';
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<boolean> {
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)) return false;
19
- if (this.isWithinCollectPartialInterval(userLog, waitDelayAfterRating, now, dayInMilliseconds)) return false;
20
- if (this.isWithinRetryInterval(userLog, interval, attempts, now, dayInMilliseconds)) return false;
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('Error reading widget log:', 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('Error writing widget log:', 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
+ });
@@ -1,5 +1,5 @@
1
1
  import { StyleSheet } from 'react-native';
2
- import { WidgetType } from '../interfaces';
2
+ import type { WidgetType } from '../interfaces';
3
3
 
4
4
  export const styles = StyleSheet.create({
5
5
  wrapper: {
@@ -1,5 +1,5 @@
1
- import { BASE_URL } from '../constants/webViewConstants';
2
- import { WidgetData, SoluCXKey } from '../interfaces';
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>);