@solucx/react-native-solucx-widget 0.1.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.intern.md +476 -0
- package/README.md +260 -0
- package/package.json +13 -0
- package/src/SoluCXWidget.tsx +117 -0
- package/src/__tests__/urlUtils.test.ts +56 -0
- package/src/__tests__/useWidgetState.test.ts +190 -0
- package/src/components/CloseButton.tsx +37 -0
- package/src/components/InlineWidget.tsx +25 -0
- package/src/components/ModalWidget.tsx +35 -0
- package/src/components/OverlayWidget.tsx +70 -0
- package/src/constants/webViewConstants.ts +14 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useWidgetState.ts +72 -0
- package/src/index.ts +8 -0
- package/src/interfaces/WidgetData.ts +20 -0
- package/src/interfaces/WidgetOptions.ts +9 -0
- package/src/interfaces/WidgetResponse.ts +15 -0
- package/src/interfaces/WidgetSamplerLog.ts +6 -0
- package/src/interfaces/index.ts +24 -0
- package/src/services/storage.ts +21 -0
- package/src/services/widgetEventService.ts +111 -0
- package/src/services/widgetValidationService.ts +86 -0
- package/src/styles/widgetStyles.ts +59 -0
- package/src/utils/urlUtils.ts +13 -0
package/README.md
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# @solucx/react-native-solucx-widget
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/js/@solucx%2Freact-native-solucx-widget)
|
|
4
|
+
[](https://reactnative.dev/)
|
|
5
|
+
[](https://expo.dev/)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
[](https://developer.apple.com/ios/)
|
|
8
|
+
[](https://developer.android.com/)
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
|
|
11
|
+
Um widget React Native modular para coleta de feedback e pesquisas de satisfação, desenvolvido pela SoluCX seguindo princípios de Clean Code e arquitetura escalável.
|
|
12
|
+
|
|
13
|
+
## 🛠️ Tecnologias
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+

|
|
17
|
+

|
|
18
|
+

|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
## 📋 Visão Geral
|
|
22
|
+
|
|
23
|
+
O SoluCX Widget permite integrar pesquisas de satisfação diretamente em aplicações React Native/Expo de forma simples e flexível. Desenvolvido para empresas que precisam coletar feedback em tempo real através de diferentes modalidades de apresentação.
|
|
24
|
+
|
|
25
|
+
### 🎯 Principais Características
|
|
26
|
+
|
|
27
|
+
- **4 Modos de Renderização**: Bottom, Top, Modal e Inline
|
|
28
|
+
- **Persistência Automática**: Controle inteligente de frequência
|
|
29
|
+
- **Comunicação WebView**: Integração transparente com plataforma SoluCX
|
|
30
|
+
- **TypeScript**: Totalmente tipado para melhor experiência de desenvolvimento
|
|
31
|
+
- **Performático**: Carregamento otimizado e cache local
|
|
32
|
+
|
|
33
|
+
## 🚀 Instalação
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install @solucx/react-native-solucx-widget
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 🎛️ Uso Básico
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
import React from 'react';
|
|
43
|
+
import { SoluCXWidget } from '@solucx/react-native-solucx-widget';
|
|
44
|
+
|
|
45
|
+
export default function MyScreen() {
|
|
46
|
+
return (
|
|
47
|
+
<SoluCXWidget
|
|
48
|
+
soluCXKey="sua-chave-solucx"
|
|
49
|
+
type="bottom"
|
|
50
|
+
data={{
|
|
51
|
+
journey: "checkout_flow",
|
|
52
|
+
name: "João Silva",
|
|
53
|
+
email: "joao@email.com",
|
|
54
|
+
store_id: "loja_01",
|
|
55
|
+
amount: 150.00
|
|
56
|
+
}}
|
|
57
|
+
options={{
|
|
58
|
+
width: 380,
|
|
59
|
+
height: 400
|
|
60
|
+
}}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## 📱 Modos de Renderização
|
|
67
|
+
|
|
68
|
+
### Bottom (Padrão)
|
|
69
|
+
Widget fixo na parte inferior da tela, ideal para feedback não intrusivo.
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
<SoluCXWidget type="bottom" {...props} />
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Top
|
|
76
|
+
Widget fixo no topo da tela, perfeito para notificações importantes.
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
<SoluCXWidget type="top" {...props} />
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Modal
|
|
83
|
+
Sobreposição centralizada que bloqueia interação com o fundo.
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
<SoluCXWidget type="modal" {...props} />
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Inline
|
|
90
|
+
Integrado ao fluxo normal do layout, respeitando a posição no código.
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
<SoluCXWidget type="inline" {...props} />
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## 🔧 API Completa
|
|
97
|
+
|
|
98
|
+
### Props
|
|
99
|
+
|
|
100
|
+
| Propriedade | Tipo | Obrigatório | Descrição |
|
|
101
|
+
|------------|------|-------------|-----------|
|
|
102
|
+
| `soluCXKey` | `string` | ✅ | Chave de autenticação SoluCX |
|
|
103
|
+
| `type` | `WidgetType` | ✅ | Modo de renderização |
|
|
104
|
+
| `data` | `WidgetData` | ✅ | Dados do cliente/transação |
|
|
105
|
+
| `options` | `WidgetOptions` | ✅ | Configurações do widget |
|
|
106
|
+
|
|
107
|
+
### WidgetData
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
interface WidgetData {
|
|
111
|
+
// Identificadores
|
|
112
|
+
transaction_id?: string;
|
|
113
|
+
customer_id?: string;
|
|
114
|
+
|
|
115
|
+
// Dados do cliente
|
|
116
|
+
name?: string;
|
|
117
|
+
email?: string;
|
|
118
|
+
phone?: string;
|
|
119
|
+
birth_date?: string; // Formato: YYYY-MM-DD
|
|
120
|
+
document?: string;
|
|
121
|
+
|
|
122
|
+
// Contexto da transação
|
|
123
|
+
store_id?: string;
|
|
124
|
+
store_name?: string;
|
|
125
|
+
employee_id?: string;
|
|
126
|
+
employee_name?: string;
|
|
127
|
+
amount?: number;
|
|
128
|
+
score?: number;
|
|
129
|
+
journey?: string; // Nome da jornada/fluxo
|
|
130
|
+
|
|
131
|
+
// Parâmetros customizados (prefixo param_)
|
|
132
|
+
param_REGIAO?: string;
|
|
133
|
+
[key: string]: string | number | undefined;
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### WidgetOptions
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
interface WidgetOptions {
|
|
141
|
+
width?: number; // Largura (padrão: 380)
|
|
142
|
+
height?: number; // Altura (padrão: 400)
|
|
143
|
+
retry?: {
|
|
144
|
+
attempts?: number; // Tentativas (padrão: 3)
|
|
145
|
+
interval?: number; // Intervalo em ms (padrão: 1000)
|
|
146
|
+
};
|
|
147
|
+
waitDelayAfterRating?: number; // Delay após avaliação
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### WidgetType
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
type WidgetType = "bottom" | "top" | "inline" | "modal";
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## 🔄 Sistema de Eventos
|
|
158
|
+
|
|
159
|
+
O widget processa automaticamente os seguintes eventos da pesquisa:
|
|
160
|
+
|
|
161
|
+
- `FORM_OPENED` - Widget foi aberto
|
|
162
|
+
- `FORM_CLOSE` - Usuário fechou o widget
|
|
163
|
+
- `FORM_COMPLETED` - Pesquisa concluída
|
|
164
|
+
- `FORM_PARTIALCOMPLETED` - Completada parcialmente
|
|
165
|
+
- `FORM_RESIZE` - Widget redimensionado
|
|
166
|
+
- `FORM_ERROR` - Erro no carregamento
|
|
167
|
+
|
|
168
|
+
## 💾 Persistência Inteligente
|
|
169
|
+
|
|
170
|
+
O widget controla automaticamente:
|
|
171
|
+
|
|
172
|
+
- **Histórico de tentativas**: Evita spam de widgets
|
|
173
|
+
- **Última avaliação**: Data da última interação
|
|
174
|
+
- **Controle de frequência**: Respeita configurações de exibição
|
|
175
|
+
- **Armazenamento local**: Dados persistem entre sessões
|
|
176
|
+
|
|
177
|
+
## ⚙️ Múltiplos Widgets
|
|
178
|
+
|
|
179
|
+
```tsx
|
|
180
|
+
const widgets = ['bottom', 'top'] as WidgetType[];
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<>
|
|
184
|
+
{widgets.map((type) => (
|
|
185
|
+
<SoluCXWidget
|
|
186
|
+
key={type}
|
|
187
|
+
soluCXKey={key}
|
|
188
|
+
type={type}
|
|
189
|
+
data={data}
|
|
190
|
+
options={options}
|
|
191
|
+
/>
|
|
192
|
+
))}
|
|
193
|
+
</>
|
|
194
|
+
);
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## 🚨 Considerações Importantes
|
|
198
|
+
|
|
199
|
+
### Posicionamento
|
|
200
|
+
|
|
201
|
+
⚠️ **Comportamento Crítico**: A posição no JSX **não determina** onde widgets `top`, `bottom` e `modal` aparecem:
|
|
202
|
+
|
|
203
|
+
```tsx
|
|
204
|
+
// ❌ Widget "bottom" sempre aparece embaixo, independente da posição
|
|
205
|
+
<Text>Conteúdo antes</Text>
|
|
206
|
+
<SoluCXWidget type="bottom" {...props} />
|
|
207
|
+
<Text>Conteúdo depois</Text>
|
|
208
|
+
|
|
209
|
+
// ✅ Apenas "inline" respeita a posição no código
|
|
210
|
+
<Text>Conteúdo antes</Text>
|
|
211
|
+
<SoluCXWidget type="inline" {...props} />
|
|
212
|
+
<Text>Conteúdo depois</Text>
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## 🔍 Troubleshooting
|
|
216
|
+
|
|
217
|
+
### Widget não aparece
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
// Verificações essenciais:
|
|
221
|
+
// ✅ Chave SoluCX válida?
|
|
222
|
+
// ✅ Conectividade com internet?
|
|
223
|
+
// ✅ Logs do WebView no console?
|
|
224
|
+
// ✅ Dados obrigatórios preenchidos?
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Eventos não funcionam
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
// Debug de comunicação:
|
|
231
|
+
const handleMessage = (message: string) => {
|
|
232
|
+
console.log('Widget event:', message);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// Verificar se JavaScript foi injetado corretamente
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Layout quebrado
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
// Ajustar dimensões para o dispositivo:
|
|
242
|
+
const { width, height } = Dimensions.get('window');
|
|
243
|
+
|
|
244
|
+
const options = {
|
|
245
|
+
width: width * 0.9,
|
|
246
|
+
height: Math.min(height * 0.6, 400)
|
|
247
|
+
};
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## 📚 Compatibilidade
|
|
251
|
+
|
|
252
|
+
| Versão | React Native | Expo | iOS | Android |
|
|
253
|
+
|--------|--------------|------|-----|---------|
|
|
254
|
+
| 1.0.x | 0.70+ | 50+ | 11+ | API 21+ |
|
|
255
|
+
|
|
256
|
+
## 📄 Licença
|
|
257
|
+
|
|
258
|
+
Este pacote é proprietário da SoluCX. O uso é restrito a clientes licenciados da plataforma SoluCX.
|
|
259
|
+
|
|
260
|
+
---
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@solucx/react-native-solucx-widget",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The React Native SDK for Solucx Widget",
|
|
5
|
+
"main": "src/index",
|
|
6
|
+
"author": " <> ()",
|
|
7
|
+
"homepage": "#readme",
|
|
8
|
+
"files": [
|
|
9
|
+
"lib/",
|
|
10
|
+
"src/",
|
|
11
|
+
"README.md"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useCallback } from 'react';
|
|
2
|
+
import { Dimensions } from 'react-native';
|
|
3
|
+
import { WebView } from 'react-native-webview';
|
|
4
|
+
|
|
5
|
+
import { SoluCXKey, WidgetData, WidgetOptions, WidgetType } from './interfaces';
|
|
6
|
+
import { useWidgetState } from './hooks/useWidgetState';
|
|
7
|
+
import { WidgetEventService } from './services/widgetEventService';
|
|
8
|
+
import { buildWidgetURL } from './utils/urlUtils';
|
|
9
|
+
import { WEB_VIEW_MESSAGE_LISTENER } from './constants/webViewConstants';
|
|
10
|
+
import { ModalWidget } from './components/ModalWidget';
|
|
11
|
+
import { InlineWidget } from './components/InlineWidget';
|
|
12
|
+
import { OverlayWidget } from './components/OverlayWidget';
|
|
13
|
+
|
|
14
|
+
interface SoluCXWidgetProps {
|
|
15
|
+
soluCXKey: SoluCXKey;
|
|
16
|
+
type: WidgetType;
|
|
17
|
+
data: WidgetData;
|
|
18
|
+
options: WidgetOptions;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const SoluCXWidget: React.FC<SoluCXWidgetProps> = ({
|
|
22
|
+
soluCXKey,
|
|
23
|
+
type,
|
|
24
|
+
data,
|
|
25
|
+
options
|
|
26
|
+
}) => {
|
|
27
|
+
const webviewRef = useRef<WebView>(null);
|
|
28
|
+
const { width } = Dimensions.get('window');
|
|
29
|
+
|
|
30
|
+
const {
|
|
31
|
+
widgetHeight,
|
|
32
|
+
isWidgetVisible,
|
|
33
|
+
setIsWidgetVisible,
|
|
34
|
+
loadSavedData,
|
|
35
|
+
resize,
|
|
36
|
+
open,
|
|
37
|
+
close,
|
|
38
|
+
userId,
|
|
39
|
+
} = useWidgetState(data, type);
|
|
40
|
+
|
|
41
|
+
const eventService = new WidgetEventService(setIsWidgetVisible, resize, open, userId, options);
|
|
42
|
+
|
|
43
|
+
const uri = buildWidgetURL(soluCXKey, data);
|
|
44
|
+
const isForm = Boolean(data.form_id);
|
|
45
|
+
const height = options.height ? Number(options.height) : undefined;
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
loadSavedData();
|
|
49
|
+
}, [loadSavedData]);
|
|
50
|
+
|
|
51
|
+
const handleWebViewMessage = useCallback(async (message: string) => {
|
|
52
|
+
if (message && message.length > 0) {
|
|
53
|
+
try {
|
|
54
|
+
await eventService.handleMessage(message, isForm);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error('Error handling widget message:', error);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}, [eventService, isForm]);
|
|
60
|
+
|
|
61
|
+
const handleWebViewLoad = useCallback(() => {
|
|
62
|
+
webviewRef.current?.injectJavaScript(WEB_VIEW_MESSAGE_LISTENER);
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
const handleClose = useCallback(() => {
|
|
66
|
+
if (type === 'inline' || type === 'modal') {
|
|
67
|
+
close();
|
|
68
|
+
}
|
|
69
|
+
setIsWidgetVisible(false);
|
|
70
|
+
}, [setIsWidgetVisible]);
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
const webViewStyle = [
|
|
74
|
+
{ height: widgetHeight },
|
|
75
|
+
{ width }
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
if (type === 'modal') {
|
|
79
|
+
return (
|
|
80
|
+
<ModalWidget visible={isWidgetVisible} onClose={handleClose}>
|
|
81
|
+
<WebView
|
|
82
|
+
ref={webviewRef}
|
|
83
|
+
style={webViewStyle}
|
|
84
|
+
source={{ uri }}
|
|
85
|
+
onLoadEnd={handleWebViewLoad}
|
|
86
|
+
onMessage={(event) => handleWebViewMessage(event.nativeEvent.data)}
|
|
87
|
+
/>
|
|
88
|
+
</ModalWidget>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (type === 'inline') {
|
|
93
|
+
return (
|
|
94
|
+
<InlineWidget visible={isWidgetVisible} onClose={handleClose}>
|
|
95
|
+
<WebView
|
|
96
|
+
ref={webviewRef}
|
|
97
|
+
style={webViewStyle}
|
|
98
|
+
source={{ uri }}
|
|
99
|
+
onLoadEnd={handleWebViewLoad}
|
|
100
|
+
onMessage={(event) => handleWebViewMessage(event.nativeEvent.data)}
|
|
101
|
+
/>
|
|
102
|
+
</InlineWidget>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<OverlayWidget visible={isWidgetVisible} width={width} height={widgetHeight} position={type} onClose={handleClose}>
|
|
108
|
+
<WebView
|
|
109
|
+
ref={webviewRef}
|
|
110
|
+
style={webViewStyle}
|
|
111
|
+
source={{ uri }}
|
|
112
|
+
onLoadEnd={handleWebViewLoad}
|
|
113
|
+
onMessage={(event) => handleWebViewMessage(event.nativeEvent.data)}
|
|
114
|
+
/>
|
|
115
|
+
</OverlayWidget>
|
|
116
|
+
);
|
|
117
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { buildWidgetURL } from '../utils/urlUtils';
|
|
2
|
+
|
|
3
|
+
describe('urlUtils', () => {
|
|
4
|
+
describe('buildWidgetURL', () => {
|
|
5
|
+
const mockKey = 'test-widget-key';
|
|
6
|
+
|
|
7
|
+
it('should build URL with transaction_id when provided', () => {
|
|
8
|
+
const data = {
|
|
9
|
+
customer_id: 'customer123',
|
|
10
|
+
form_id: 'form456',
|
|
11
|
+
transaction_id: 'trans789'
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const result = buildWidgetURL(mockKey, data);
|
|
15
|
+
|
|
16
|
+
expect(result).toContain('https://survey-link.solucx.com.br/link/test-widget-key/?mode=widget');
|
|
17
|
+
expect(result).toContain('customer_id=customer123');
|
|
18
|
+
expect(result).toContain('form_id=form456');
|
|
19
|
+
expect(result).toContain('transaction_id=trans789');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should build URL with empty transaction_id when not provided', () => {
|
|
23
|
+
const data = {
|
|
24
|
+
customer_id: 'customer123',
|
|
25
|
+
form_id: 'form456'
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const result = buildWidgetURL(mockKey, data);
|
|
29
|
+
|
|
30
|
+
expect(result).toContain('https://survey-link.solucx.com.br/link/test-widget-key/?mode=widget');
|
|
31
|
+
expect(result).toContain('transaction_id=&');
|
|
32
|
+
expect(result).toContain('customer_id=customer123');
|
|
33
|
+
expect(result).toContain('form_id=form456');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should handle empty data object', () => {
|
|
37
|
+
const data = {};
|
|
38
|
+
|
|
39
|
+
const result = buildWidgetURL(mockKey, data);
|
|
40
|
+
|
|
41
|
+
expect(result).toBe('https://survey-link.solucx.com.br/link/test-widget-key/?mode=widget&transaction_id=&');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should encode URL parameters correctly', () => {
|
|
45
|
+
const data = {
|
|
46
|
+
email: 'test@example.com',
|
|
47
|
+
name: 'John Doe'
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const result = buildWidgetURL(mockKey, data);
|
|
51
|
+
|
|
52
|
+
expect(result).toContain('email=test%40example.com');
|
|
53
|
+
expect(result).toContain('name=John+Doe');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { WidgetEventService } from '../services/widgetEventService';
|
|
2
|
+
import { WidgetOptions } from '../interfaces';
|
|
3
|
+
|
|
4
|
+
const mockWidgetValidationService = {
|
|
5
|
+
shouldDisplayWidget: jest.fn().mockResolvedValue(true)
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
jest.mock('../services/widgetValidationService', () => ({
|
|
9
|
+
WidgetValidationService: jest.fn().mockImplementation(() => mockWidgetValidationService)
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
describe('WidgetEventService', () => {
|
|
13
|
+
let mockSetIsWidgetVisible: jest.Mock;
|
|
14
|
+
let mockResize: jest.Mock;
|
|
15
|
+
let service: WidgetEventService;
|
|
16
|
+
let open: jest.Mock;
|
|
17
|
+
let mockUserId: string;
|
|
18
|
+
let mockWidgetOptions: WidgetOptions;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
jest.clearAllMocks();
|
|
22
|
+
|
|
23
|
+
mockSetIsWidgetVisible = jest.fn();
|
|
24
|
+
mockResize = jest.fn();
|
|
25
|
+
open = jest.fn();
|
|
26
|
+
mockUserId = 'test-user-123';
|
|
27
|
+
mockWidgetOptions = {
|
|
28
|
+
width: 380,
|
|
29
|
+
height: 400,
|
|
30
|
+
retry: {
|
|
31
|
+
attempts: 5,
|
|
32
|
+
interval: 1
|
|
33
|
+
},
|
|
34
|
+
waitDelayAfterRating: 60
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
mockWidgetValidationService.shouldDisplayWidget.mockResolvedValue(true);
|
|
38
|
+
|
|
39
|
+
service = new WidgetEventService(
|
|
40
|
+
mockSetIsWidgetVisible,
|
|
41
|
+
mockResize,
|
|
42
|
+
open,
|
|
43
|
+
mockUserId,
|
|
44
|
+
mockWidgetOptions
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
jest.clearAllMocks();
|
|
50
|
+
jest.restoreAllMocks();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should handle FORM_OPENED event correctly', async () => {
|
|
54
|
+
const result = await service.handleMessage('FORM_OPENED', true);
|
|
55
|
+
|
|
56
|
+
expect(mockWidgetValidationService.shouldDisplayWidget).toHaveBeenCalledWith(mockWidgetOptions);
|
|
57
|
+
expect(open).toHaveBeenCalled();
|
|
58
|
+
expect(mockSetIsWidgetVisible).toHaveBeenCalledWith(true);
|
|
59
|
+
expect(result).toEqual({ status: 'success' });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should prevent widget from opening when validation fails', async () => {
|
|
63
|
+
mockWidgetValidationService.shouldDisplayWidget.mockResolvedValueOnce(await Promise.resolve(false));
|
|
64
|
+
|
|
65
|
+
const result = await service.handleMessage('FORM_OPENED', true);
|
|
66
|
+
|
|
67
|
+
expect(mockWidgetValidationService.shouldDisplayWidget).toHaveBeenCalledWith(mockWidgetOptions);
|
|
68
|
+
expect(open).not.toHaveBeenCalled();
|
|
69
|
+
expect(mockSetIsWidgetVisible).not.toHaveBeenCalled();
|
|
70
|
+
expect(result).toEqual({ status: 'error', message: 'Widget not allowed' });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should handle FORM_CLOSE event correctly', async () => {
|
|
74
|
+
const result = await service.handleMessage('FORM_CLOSE', true);
|
|
75
|
+
|
|
76
|
+
expect(mockSetIsWidgetVisible).toHaveBeenCalledWith(false);
|
|
77
|
+
expect(result).toEqual({ status: 'success' });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should handle FORM_RESIZE event correctly', async () => {
|
|
81
|
+
const result = await service.handleMessage('FORM_RESIZE-350', true);
|
|
82
|
+
|
|
83
|
+
expect(mockSetIsWidgetVisible).toHaveBeenCalledWith(true);
|
|
84
|
+
expect(mockResize).toHaveBeenCalledWith('350');
|
|
85
|
+
expect(result).toEqual({ status: 'success' });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should handle FORM_ERROR event correctly', async () => {
|
|
89
|
+
const result = await service.handleMessage('FORM_ERROR-Something went wrong', true);
|
|
90
|
+
|
|
91
|
+
expect(mockSetIsWidgetVisible).toHaveBeenCalledWith(false);
|
|
92
|
+
expect(result).toEqual({ status: 'error', message: 'Something went wrong' });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should adapt survey keys to widget keys correctly for non-form widgets', async () => {
|
|
96
|
+
const result = await service.handleMessage('closeSoluCXWidget', false);
|
|
97
|
+
|
|
98
|
+
expect(mockSetIsWidgetVisible).toHaveBeenCalledWith(false);
|
|
99
|
+
expect(result).toEqual({ status: 'success' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should return error for unknown events', async () => {
|
|
103
|
+
const result = await service.handleMessage('UNKNOWN_EVENT', true);
|
|
104
|
+
|
|
105
|
+
expect(result).toEqual({ status: 'error', message: 'Unknown event' });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should handle messages with no value correctly', async () => {
|
|
109
|
+
const result = await service.handleMessage('FORM_OPENED', true);
|
|
110
|
+
|
|
111
|
+
expect(mockWidgetValidationService.shouldDisplayWidget).toHaveBeenCalledWith(mockWidgetOptions);
|
|
112
|
+
expect(open).toHaveBeenCalled();
|
|
113
|
+
expect(mockSetIsWidgetVisible).toHaveBeenCalledWith(true);
|
|
114
|
+
expect(result).toEqual({ status: 'success' });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should handle FORM_PAGECHANGED event correctly', async () => {
|
|
118
|
+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => { });
|
|
119
|
+
const result = await service.handleMessage('FORM_PAGECHANGED-page2', true);
|
|
120
|
+
|
|
121
|
+
expect(consoleSpy).toHaveBeenCalledWith("Page changed:", "page2");
|
|
122
|
+
expect(result).toEqual({ status: 'success' });
|
|
123
|
+
|
|
124
|
+
consoleSpy.mockRestore();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should handle QUESTION_ANSWERED event correctly', async () => {
|
|
128
|
+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => { });
|
|
129
|
+
const result = await service.handleMessage('QUESTION_ANSWERED', true);
|
|
130
|
+
expect(consoleSpy).toHaveBeenCalledWith("Question answered");
|
|
131
|
+
expect(result).toEqual({ status: 'success' });
|
|
132
|
+
|
|
133
|
+
consoleSpy.mockRestore();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should handle FORM_COMPLETED event correctly', async () => {
|
|
137
|
+
const result = await service.handleMessage('FORM_COMPLETED', true);
|
|
138
|
+
|
|
139
|
+
expect(result).toEqual({ status: 'success' });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should handle FORM_PARTIALCOMPLETED event correctly', async () => {
|
|
143
|
+
const result = await service.handleMessage('FORM_PARTIALCOMPLETED', true);
|
|
144
|
+
|
|
145
|
+
expect(result).toEqual({ status: 'success' });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should adapt completeSoluCXWidget to FORM_COMPLETED', async () => {
|
|
149
|
+
const result = await service.handleMessage('completeSoluCXWidget', false);
|
|
150
|
+
|
|
151
|
+
expect(result).toEqual({ status: 'success' });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should adapt partialSoluCXWidget to FORM_PARTIALCOMPLETED', async () => {
|
|
155
|
+
const result = await service.handleMessage('partialSoluCXWidget', false);
|
|
156
|
+
|
|
157
|
+
expect(result).toEqual({ status: 'success' });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should adapt dismissSoluCXWidget to FORM_CLOSE', async () => {
|
|
161
|
+
const result = await service.handleMessage('dismissSoluCXWidget', false);
|
|
162
|
+
|
|
163
|
+
expect(mockSetIsWidgetVisible).toHaveBeenCalledWith(false);
|
|
164
|
+
expect(result).toEqual({ status: 'success' });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should adapt openSoluCXWidget to FORM_OPENED', async () => {
|
|
168
|
+
const result = await service.handleMessage('openSoluCXWidget', false);
|
|
169
|
+
|
|
170
|
+
expect(mockWidgetValidationService.shouldDisplayWidget).toHaveBeenCalledWith(mockWidgetOptions);
|
|
171
|
+
expect(open).toHaveBeenCalled();
|
|
172
|
+
expect(mockSetIsWidgetVisible).toHaveBeenCalledWith(true);
|
|
173
|
+
expect(result).toEqual({ status: 'success' });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should adapt errorSoluCXWidget to FORM_ERROR', async () => {
|
|
177
|
+
const result = await service.handleMessage('errorSoluCXWidget-Network error', false);
|
|
178
|
+
|
|
179
|
+
expect(mockSetIsWidgetVisible).toHaveBeenCalledWith(false);
|
|
180
|
+
expect(result).toEqual({ status: 'error', message: 'Network error' });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should adapt resizeSoluCXWidget to FORM_RESIZE', async () => {
|
|
184
|
+
const result = await service.handleMessage('resizeSoluCXWidget-400', false);
|
|
185
|
+
|
|
186
|
+
expect(mockSetIsWidgetVisible).toHaveBeenCalledWith(true);
|
|
187
|
+
expect(mockResize).toHaveBeenCalledWith('400');
|
|
188
|
+
expect(result).toEqual({ status: 'success' });
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { TouchableOpacity, Text, StyleSheet } from 'react-native';
|
|
3
|
+
|
|
4
|
+
interface CloseButtonProps {
|
|
5
|
+
onPress: () => void;
|
|
6
|
+
visible?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const CloseButton: React.FC<CloseButtonProps> = ({ onPress, visible = true }) => {
|
|
10
|
+
if (!visible) return null;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<TouchableOpacity style={styles.closeButton} onPress={onPress}>
|
|
14
|
+
<Text style={styles.closeButtonText}>✕</Text>
|
|
15
|
+
</TouchableOpacity>
|
|
16
|
+
);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const styles = StyleSheet.create({
|
|
20
|
+
closeButton: {
|
|
21
|
+
position: 'absolute',
|
|
22
|
+
top: 10,
|
|
23
|
+
right: 10,
|
|
24
|
+
width: 30,
|
|
25
|
+
height: 30,
|
|
26
|
+
borderRadius: 15,
|
|
27
|
+
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
28
|
+
justifyContent: 'center',
|
|
29
|
+
alignItems: 'center',
|
|
30
|
+
zIndex: 10001,
|
|
31
|
+
},
|
|
32
|
+
closeButtonText: {
|
|
33
|
+
color: 'white',
|
|
34
|
+
fontSize: 16,
|
|
35
|
+
fontWeight: 'bold',
|
|
36
|
+
},
|
|
37
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import { styles, getWidgetVisibility } from '../styles/widgetStyles';
|
|
4
|
+
import { CloseButton } from './CloseButton';
|
|
5
|
+
|
|
6
|
+
interface InlineWidgetProps {
|
|
7
|
+
visible: boolean;
|
|
8
|
+
children?: React.ReactNode;
|
|
9
|
+
onClose?: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const InlineWidget: React.FC<InlineWidgetProps> = ({ visible, children, onClose }) => {
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<View style={[styles.wrapper, getWidgetVisibility(visible)]}>
|
|
16
|
+
<View style={[styles.inline, getWidgetVisibility(visible)]}>
|
|
17
|
+
{children}
|
|
18
|
+
<CloseButton
|
|
19
|
+
visible={visible}
|
|
20
|
+
onPress={onClose || (() => { })}
|
|
21
|
+
/>
|
|
22
|
+
</View>
|
|
23
|
+
</View>
|
|
24
|
+
);
|
|
25
|
+
};
|