@erlancarreira/evolution-chat 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.
@@ -0,0 +1,1009 @@
1
+ // src/widget/chat-widget.tsx
2
+ import {
3
+ useCallback as useCallback2,
4
+ useEffect as useEffect2,
5
+ useId,
6
+ useRef as useRef2,
7
+ useState
8
+ } from "react";
9
+
10
+ // src/widget/i18n.ts
11
+ var WIDGET_KEYS = [
12
+ "openChat",
13
+ "close",
14
+ "name",
15
+ "phone",
16
+ "message",
17
+ "send",
18
+ "sending",
19
+ "welcomeNotice",
20
+ "privacyNote",
21
+ "invalidPhone",
22
+ "sendError",
23
+ "retry",
24
+ "sessionClosed",
25
+ "newConversation",
26
+ "poweredBy"
27
+ ];
28
+ var pt = {
29
+ openChat: "Abrir chat",
30
+ close: "Fechar",
31
+ name: "Nome",
32
+ phone: "WhatsApp",
33
+ message: "Mensagem",
34
+ send: "Enviar",
35
+ sending: "Enviando\u2026",
36
+ welcomeNotice: "Ao continuar, voc\xEA entra no grupo de WhatsApp do site com o nosso time.",
37
+ privacyNote: "Seus dados s\xE3o usados apenas para o atendimento.",
38
+ invalidPhone: "Informe um n\xFAmero v\xE1lido com DDD (m\xEDnimo 10 d\xEDgitos).",
39
+ sendError: "N\xE3o foi poss\xEDvel enviar. Tente novamente.",
40
+ retry: "Tentar novamente",
41
+ sessionClosed: "Esta conversa foi encerrada. Abra uma nova para continuar.",
42
+ newConversation: "Iniciar nova conversa",
43
+ poweredBy: "Powered by"
44
+ };
45
+ var en = {
46
+ openChat: "Open chat",
47
+ close: "Close",
48
+ name: "Name",
49
+ phone: "WhatsApp",
50
+ message: "Message",
51
+ send: "Send",
52
+ sending: "Sending\u2026",
53
+ welcomeNotice: "When you continue, you join the site's WhatsApp group with our team.",
54
+ privacyNote: "Your data is used only for support.",
55
+ invalidPhone: "Enter a valid number with area code (at least 10 digits).",
56
+ sendError: "Couldn't send. Please try again.",
57
+ retry: "Try again",
58
+ sessionClosed: "This conversation was closed. Start a new one to continue.",
59
+ newConversation: "Start a new conversation",
60
+ poweredBy: "Powered by"
61
+ };
62
+ var es = {
63
+ openChat: "Abrir chat",
64
+ close: "Cerrar",
65
+ name: "Nombre",
66
+ phone: "WhatsApp",
67
+ message: "Mensaje",
68
+ send: "Enviar",
69
+ sending: "Enviando\u2026",
70
+ welcomeNotice: "Al continuar, entras en el grupo de WhatsApp del sitio con nuestro equipo.",
71
+ privacyNote: "Tus datos se usan solo para la atenci\xF3n.",
72
+ invalidPhone: "Introduce un n\xFAmero v\xE1lido con \xE1rea (m\xEDnimo 10 d\xEDgitos).",
73
+ sendError: "No se pudo enviar. Int\xE9ntalo de nuevo.",
74
+ retry: "Reintentar",
75
+ sessionClosed: "Esta conversaci\xF3n fue cerrada. Abre una nueva para continuar.",
76
+ newConversation: "Iniciar nueva conversaci\xF3n",
77
+ poweredBy: "Powered by"
78
+ };
79
+ var dictionaries = { pt, en, es };
80
+ function t(locale, key, overrides) {
81
+ const override = overrides?.[key];
82
+ if (override !== void 0) return override;
83
+ const dict = dictionaries[locale];
84
+ return dict[key] ?? dictionaries.pt[key] ?? key;
85
+ }
86
+
87
+ // src/widget/styles.ts
88
+ var WIDGET_CSS = `/*
89
+ * src/widget/styles.css \u2014 folha de estilos do ChatWidget (classes \`.ecw-*\`).
90
+ *
91
+ * Fonte can\xF4nica do CSS INJETADO em runtime: src/widget/styles.ts espelha este arquivo
92
+ * e injeta \`<style id="ecw-styles">\` via \`injectWidgetStyles()\` (o widget \xE9
93
+ * auto-contido \u2014 o consumidor n\xE3o precisa importar CSS). O teste de paridade em
94
+ * test/widget/widget.test.tsx garante que os dois textos n\xE3o divergem.
95
+ * Consumidores que preferem \`<link>\` podem importar "@erlancarreira/evolution-chat/widget/styles.css".
96
+ *
97
+ * Acessibilidade: \`color-scheme\` (dark nativo), \`:focus-visible\` sempre vis\xEDvel,
98
+ * contraste AA sobre o accent (texto escuro no verde), e transi\xE7\xF5es desligadas com
99
+ * \`prefers-reduced-motion\`.
100
+ */
101
+
102
+ .ecw-root {
103
+ --ecw-accent: #25d366;
104
+ --ecw-accent-ink: #06251a;
105
+ --ecw-surface: #ffffff;
106
+ --ecw-surface-2: #f1f4f7;
107
+ --ecw-text: #14181d;
108
+ --ecw-muted: #51606e;
109
+ --ecw-border: #d7dde3;
110
+ --ecw-danger: #b3261e;
111
+ --ecw-shadow: 0 8px 28px rgb(9 20 28 / 22%);
112
+ color-scheme: light dark;
113
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
114
+ font-size: 14px;
115
+ line-height: 1.45;
116
+ color: var(--ecw-text);
117
+ }
118
+
119
+ @media (prefers-color-scheme: dark) {
120
+ .ecw-root {
121
+ --ecw-accent-ink: #04170f;
122
+ --ecw-surface: #151a20;
123
+ --ecw-surface-2: #232a33;
124
+ --ecw-text: #e8edf2;
125
+ --ecw-muted: #9aa7b4;
126
+ --ecw-border: #2c343d;
127
+ --ecw-danger: #ff9a8f;
128
+ --ecw-shadow: 0 8px 28px rgb(0 0 0 / 45%);
129
+ }
130
+ }
131
+
132
+ /* \u2500\u2500 bal\xE3o flutuante \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
133
+ .ecw-button {
134
+ position: fixed;
135
+ right: 20px;
136
+ bottom: 20px;
137
+ z-index: 2147483000;
138
+ display: flex;
139
+ align-items: center;
140
+ justify-content: center;
141
+ width: 56px;
142
+ height: 56px;
143
+ padding: 0;
144
+ border: none;
145
+ border-radius: 50%;
146
+ background: var(--ecw-accent);
147
+ color: var(--ecw-accent-ink);
148
+ box-shadow: var(--ecw-shadow);
149
+ cursor: pointer;
150
+ transition: transform 160ms ease, box-shadow 160ms ease;
151
+ }
152
+ .ecw-button:hover { transform: translateY(-2px) scale(1.03); }
153
+ .ecw-button:active { transform: translateY(0) scale(0.98); }
154
+ .ecw-button svg { width: 26px; height: 26px; }
155
+
156
+ .ecw-badge {
157
+ position: absolute;
158
+ top: -4px;
159
+ right: -4px;
160
+ min-width: 22px;
161
+ height: 22px;
162
+ padding: 0 6px;
163
+ border-radius: 11px;
164
+ border: 2px solid var(--ecw-surface);
165
+ background: var(--ecw-danger);
166
+ color: #fff;
167
+ font-size: 12px;
168
+ font-weight: 700;
169
+ line-height: 18px;
170
+ text-align: center;
171
+ }
172
+
173
+ /* \u2500\u2500 painel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
174
+ .ecw-panel {
175
+ position: fixed;
176
+ right: 20px;
177
+ bottom: 88px;
178
+ z-index: 2147483000;
179
+ display: flex;
180
+ flex-direction: column;
181
+ width: min(380px, calc(100vw - 24px));
182
+ min-width: 320px;
183
+ height: 480px;
184
+ max-height: 70vh;
185
+ overflow: hidden;
186
+ border: 1px solid var(--ecw-border);
187
+ border-radius: 14px;
188
+ background: var(--ecw-surface);
189
+ box-shadow: var(--ecw-shadow);
190
+ animation: ecw-pop 160ms ease-out;
191
+ }
192
+ @keyframes ecw-pop { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
193
+
194
+ .ecw-header {
195
+ display: flex;
196
+ align-items: center;
197
+ gap: 8px;
198
+ padding: 12px 14px;
199
+ background: var(--ecw-accent);
200
+ color: var(--ecw-accent-ink);
201
+ }
202
+ .ecw-title { flex: 1; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
203
+ .ecw-code { font-size: 12px; font-weight: 600; opacity: 0.85; letter-spacing: 0.02em; }
204
+ .ecw-close {
205
+ display: flex;
206
+ align-items: center;
207
+ justify-content: center;
208
+ width: 28px;
209
+ height: 28px;
210
+ padding: 0;
211
+ border: none;
212
+ border-radius: 8px;
213
+ background: transparent;
214
+ color: inherit;
215
+ font-size: 18px;
216
+ line-height: 1;
217
+ cursor: pointer;
218
+ }
219
+ .ecw-close:hover { background: rgb(0 0 0 / 12%); }
220
+
221
+ /* \u2500\u2500 lista de mensagens \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
222
+ .ecw-list {
223
+ flex: 1;
224
+ margin: 0;
225
+ padding: 12px;
226
+ overflow-y: auto;
227
+ display: flex;
228
+ flex-direction: column;
229
+ gap: 10px;
230
+ list-style: none;
231
+ background: var(--ecw-surface-2);
232
+ }
233
+ .ecw-item { display: flex; flex-direction: column; max-width: 82%; }
234
+ .ecw-item--visitor { align-self: flex-end; align-items: flex-end; }
235
+ .ecw-item--owner { align-self: flex-start; align-items: flex-start; }
236
+ .ecw-bubble {
237
+ padding: 8px 12px;
238
+ border-radius: 12px;
239
+ overflow-wrap: anywhere;
240
+ white-space: pre-wrap;
241
+ }
242
+ .ecw-bubble--visitor {
243
+ background: var(--ecw-accent);
244
+ color: var(--ecw-accent-ink);
245
+ border-bottom-right-radius: 4px;
246
+ }
247
+ .ecw-bubble--owner {
248
+ background: var(--ecw-surface);
249
+ color: var(--ecw-text);
250
+ border: 1px solid var(--ecw-border);
251
+ border-bottom-left-radius: 4px;
252
+ }
253
+ .ecw-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; font-size: 11px; color: var(--ecw-muted); }
254
+ .ecw-status--failed { color: var(--ecw-danger); }
255
+ .ecw-retry {
256
+ padding: 0;
257
+ border: none;
258
+ background: none;
259
+ color: var(--ecw-danger);
260
+ font: inherit;
261
+ font-size: 11px;
262
+ text-decoration: underline;
263
+ cursor: pointer;
264
+ }
265
+
266
+ /* \u2500\u2500 pr\xE9-chat form \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
267
+ .ecw-form { display: flex; flex-direction: column; gap: 12px; padding: 16px 14px; overflow-y: auto; }
268
+ .ecw-welcome { margin: 0; font-weight: 600; }
269
+ .ecw-notice { margin: 0; font-size: 12px; color: var(--ecw-muted); }
270
+ .ecw-field { display: flex; flex-direction: column; gap: 4px; }
271
+ .ecw-label { font-size: 12px; font-weight: 600; color: var(--ecw-text); }
272
+ .ecw-input {
273
+ width: 100%;
274
+ padding: 9px 10px;
275
+ border: 1px solid var(--ecw-border);
276
+ border-radius: 8px;
277
+ background: var(--ecw-surface);
278
+ color: var(--ecw-text);
279
+ font: inherit;
280
+ }
281
+ .ecw-input:focus { outline: 2px solid var(--ecw-accent); outline-offset: 1px; }
282
+ .ecw-input[aria-invalid="true"] { border-color: var(--ecw-danger); }
283
+ .ecw-error { margin: 0; font-size: 12px; color: var(--ecw-danger); }
284
+ .ecw-submit {
285
+ padding: 10px 14px;
286
+ border: none;
287
+ border-radius: 8px;
288
+ background: var(--ecw-accent);
289
+ color: var(--ecw-accent-ink);
290
+ font: inherit;
291
+ font-weight: 700;
292
+ cursor: pointer;
293
+ }
294
+ .ecw-submit:disabled { opacity: 0.55; cursor: not-allowed; }
295
+
296
+ /* \u2500\u2500 composer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
297
+ .ecw-composer { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--ecw-border); background: var(--ecw-surface); }
298
+ .ecw-composer .ecw-input { flex: 1; }
299
+ .ecw-send {
300
+ display: flex;
301
+ align-items: center;
302
+ justify-content: center;
303
+ width: 40px;
304
+ height: 40px;
305
+ padding: 0;
306
+ border: none;
307
+ border-radius: 8px;
308
+ background: var(--ecw-accent);
309
+ color: var(--ecw-accent-ink);
310
+ cursor: pointer;
311
+ }
312
+ .ecw-send:disabled { opacity: 0.55; cursor: not-allowed; }
313
+ .ecw-send svg { width: 18px; height: 18px; }
314
+
315
+ /* \u2500\u2500 sess\xE3o encerrada / falha \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
316
+ .ecw-closed { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--ecw-border); background: var(--ecw-surface); }
317
+
318
+ .ecw-footer { padding: 6px 12px 10px; font-size: 11px; color: var(--ecw-muted); text-align: center; background: var(--ecw-surface); }
319
+
320
+ /* \u2500\u2500 utilit\xE1rios \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
321
+ .ecw-hp { display: none !important; }
322
+ .ecw-sr-only {
323
+ position: absolute;
324
+ width: 1px; height: 1px;
325
+ margin: -1px; padding: 0;
326
+ overflow: hidden;
327
+ clip: rect(0 0 0 0);
328
+ white-space: nowrap;
329
+ border: 0;
330
+ }
331
+ .ecw-button:focus-visible, .ecw-close:focus-visible, .ecw-submit:focus-visible,
332
+ .ecw-send:focus-visible, .ecw-retry:focus-visible {
333
+ outline: 3px solid #1b6fec;
334
+ outline-offset: 2px;
335
+ }
336
+
337
+ @media (prefers-reduced-motion: reduce) {
338
+ .ecw-root *, .ecw-root *::before, .ecw-root *::after {
339
+ animation: none !important;
340
+ transition: none !important;
341
+ }
342
+ }
343
+
344
+ @media (max-width: 420px) {
345
+ .ecw-panel { right: 12px; left: 12px; width: auto; min-width: 0; bottom: 80px; }
346
+ }
347
+ `;
348
+ var injected = false;
349
+ function injectWidgetStyles() {
350
+ if (typeof document === "undefined") return;
351
+ if (injected && document.getElementById("ecw-styles") !== null) return;
352
+ const existing = document.getElementById("ecw-styles");
353
+ if (existing !== null) {
354
+ injected = true;
355
+ return;
356
+ }
357
+ const style = document.createElement("style");
358
+ style.id = "ecw-styles";
359
+ style.textContent = WIDGET_CSS;
360
+ document.head.appendChild(style);
361
+ injected = true;
362
+ }
363
+
364
+ // src/widget/use-chat.ts
365
+ import { useCallback, useEffect, useReducer, useRef } from "react";
366
+ var SESSION_STORAGE_KEY = "ecw:session";
367
+ var POLL_INTERVAL_MS = 5e3;
368
+ var MIN_PHONE_DIGITS = 10;
369
+ var initialState = {
370
+ phase: "idle",
371
+ open: false,
372
+ session: null,
373
+ token: null,
374
+ messages: [],
375
+ unread: 0,
376
+ sending: false,
377
+ error: null
378
+ };
379
+ function withMessage(state, message) {
380
+ const index = state.messages.findIndex((m) => m.id === message.id);
381
+ if (index >= 0) {
382
+ const messages = [...state.messages];
383
+ messages[index] = message;
384
+ return { ...state, messages };
385
+ }
386
+ const bump = state.open || message.direction !== "owner" ? 0 : 1;
387
+ return { ...state, messages: [...state.messages, message], unread: state.unread + bump };
388
+ }
389
+ function chatReducer(state, action) {
390
+ switch (action.type) {
391
+ case "open":
392
+ return {
393
+ ...state,
394
+ open: true,
395
+ unread: 0,
396
+ phase: state.phase === "idle" ? "form" : state.phase
397
+ };
398
+ case "close":
399
+ return { ...state, open: false };
400
+ case "restore":
401
+ return {
402
+ ...state,
403
+ phase: "chat",
404
+ session: action.session,
405
+ token: action.token,
406
+ messages: action.messages
407
+ };
408
+ case "start": {
409
+ let next = {
410
+ ...state,
411
+ phase: "chat",
412
+ session: action.session,
413
+ token: action.token,
414
+ messages: action.messages
415
+ };
416
+ for (const pending of state.messages) {
417
+ const confirmed = action.messages.some(
418
+ (m) => m.direction === "visitor" && m.body === pending.body
419
+ );
420
+ if (pending.status === "pending" && !confirmed) next = withMessage(next, pending);
421
+ }
422
+ return next;
423
+ }
424
+ case "reset-form":
425
+ return { ...state, phase: "form", session: null, token: null, messages: [], unread: 0, error: null };
426
+ case "append":
427
+ return withMessage(state, action.message);
428
+ case "merge": {
429
+ let next = state;
430
+ for (const message of action.messages) next = withMessage(next, message);
431
+ return next;
432
+ }
433
+ case "replace": {
434
+ const messages = state.messages.map((m) => m.id === action.tmpId ? action.message : m);
435
+ const kept = messages.some((m) => m.id === action.message.id);
436
+ return { ...state, messages: kept ? messages : [...messages, action.message] };
437
+ }
438
+ case "mark":
439
+ return {
440
+ ...state,
441
+ messages: state.messages.map((m) => m.id === action.id ? { ...m, status: action.status } : m)
442
+ };
443
+ case "session-status": {
444
+ if (state.session === null) return state;
445
+ return { ...state, session: { ...state.session, status: action.status } };
446
+ }
447
+ case "sending":
448
+ return { ...state, sending: action.value };
449
+ case "error":
450
+ return { ...state, error: action.value };
451
+ }
452
+ }
453
+ function normalizePhone(value) {
454
+ return value.replace(/\D/g, "");
455
+ }
456
+ function maskPhone(value) {
457
+ const digits = normalizePhone(value).slice(0, 11);
458
+ if (digits.length === 0) return "";
459
+ const ddd = digits.slice(0, 2);
460
+ if (digits.length <= 2) return `(${ddd}`;
461
+ const rest = digits.slice(2);
462
+ if (rest.length <= 5) return `(${ddd}) ${rest}`;
463
+ return `(${ddd}) ${rest.slice(0, 5)}-${rest.slice(5)}`;
464
+ }
465
+ function isValidPhone(value) {
466
+ return normalizePhone(value).length >= MIN_PHONE_DIGITS;
467
+ }
468
+ function readStoredSession() {
469
+ if (typeof window === "undefined") return null;
470
+ try {
471
+ const raw = window.localStorage.getItem(SESSION_STORAGE_KEY);
472
+ if (raw === null) return null;
473
+ const parsed = JSON.parse(raw);
474
+ if (parsed !== null && typeof parsed === "object" && typeof parsed.token === "string" && parsed.token !== "") {
475
+ const { token, code } = parsed;
476
+ return { token, code: typeof code === "string" ? code : "" };
477
+ }
478
+ return null;
479
+ } catch {
480
+ return null;
481
+ }
482
+ }
483
+ function writeStoredSession(session) {
484
+ if (typeof window === "undefined") return;
485
+ try {
486
+ window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
487
+ } catch {
488
+ }
489
+ }
490
+ function clearStoredSession() {
491
+ if (typeof window === "undefined") return;
492
+ try {
493
+ window.localStorage.removeItem(SESSION_STORAGE_KEY);
494
+ } catch {
495
+ }
496
+ }
497
+ function historyUrl(endpoint, token, after) {
498
+ const params = new URLSearchParams({ token });
499
+ if (after !== null) params.set("after", after);
500
+ const sep = endpoint.includes("?") ? "&" : "?";
501
+ return `${endpoint}${sep}${params.toString()}`;
502
+ }
503
+ function lastCursor(messages) {
504
+ let latest = null;
505
+ for (const m of messages) if (latest === null || m.createdAt > latest) latest = m.createdAt;
506
+ return latest;
507
+ }
508
+ var tmpSeq = 0;
509
+ function tmpMessage(body) {
510
+ tmpSeq += 1;
511
+ return {
512
+ id: `tmp-${tmpSeq}`,
513
+ sessionId: "",
514
+ direction: "visitor",
515
+ body,
516
+ status: "pending",
517
+ waMessageId: null,
518
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
519
+ };
520
+ }
521
+ function useChat({ endpoint, realtime }) {
522
+ const [state, dispatch] = useReducer(chatReducer, initialState);
523
+ const stateRef = useRef(state);
524
+ useEffect(() => {
525
+ stateRef.current = state;
526
+ }, [state]);
527
+ const realtimeRef = useRef(realtime);
528
+ useEffect(() => {
529
+ realtimeRef.current = realtime;
530
+ }, [realtime]);
531
+ const pollRef = useRef(null);
532
+ const stopPoll = useCallback(() => {
533
+ if (pollRef.current !== null) {
534
+ clearInterval(pollRef.current);
535
+ pollRef.current = null;
536
+ }
537
+ }, []);
538
+ const pollOnce = useCallback(async () => {
539
+ const { token, messages } = stateRef.current;
540
+ if (token === null) return;
541
+ try {
542
+ const res = await fetch(historyUrl(endpoint, token, lastCursor(messages)), {
543
+ method: "GET",
544
+ headers: { accept: "application/json" }
545
+ });
546
+ if (res.status === 404) {
547
+ clearStoredSession();
548
+ stopPoll();
549
+ dispatch({ type: "reset-form" });
550
+ return;
551
+ }
552
+ if (!res.ok) return;
553
+ const data = await res.json();
554
+ if (Array.isArray(data.messages)) dispatch({ type: "merge", messages: data.messages });
555
+ if (data.session !== void 0) dispatch({ type: "session-status", status: data.session.status });
556
+ } catch {
557
+ }
558
+ }, [endpoint, stopPoll]);
559
+ const startPoll = useCallback(() => {
560
+ if (pollRef.current !== null) return;
561
+ pollRef.current = setInterval(() => {
562
+ void pollOnce();
563
+ }, POLL_INTERVAL_MS);
564
+ }, [pollOnce]);
565
+ const handleStatus = useCallback(
566
+ (s) => {
567
+ if (s === "closed") startPoll();
568
+ else stopPoll();
569
+ },
570
+ [startPoll, stopPoll]
571
+ );
572
+ const handleEvent = useCallback((e) => {
573
+ if (e.type === "message" && e.message !== void 0) {
574
+ dispatch({ type: "append", message: e.message });
575
+ } else if (e.type === "session" && e.status !== void 0) {
576
+ dispatch({ type: "session-status", status: e.status });
577
+ }
578
+ }, []);
579
+ useEffect(() => {
580
+ const stored = readStoredSession();
581
+ if (stored === null) return;
582
+ let cancelled = false;
583
+ void (async () => {
584
+ try {
585
+ const res = await fetch(historyUrl(endpoint, stored.token, null), {
586
+ method: "GET",
587
+ headers: { accept: "application/json" }
588
+ });
589
+ if (cancelled) return;
590
+ if (res.status === 404) {
591
+ clearStoredSession();
592
+ dispatch({ type: "reset-form" });
593
+ return;
594
+ }
595
+ if (!res.ok) return;
596
+ const data = await res.json();
597
+ if (cancelled || data.session === void 0) return;
598
+ dispatch({
599
+ type: "restore",
600
+ session: data.session,
601
+ token: stored.token,
602
+ messages: Array.isArray(data.messages) ? data.messages : []
603
+ });
604
+ } catch {
605
+ }
606
+ })();
607
+ return () => {
608
+ cancelled = true;
609
+ };
610
+ }, [endpoint]);
611
+ useEffect(() => {
612
+ const token = state.token;
613
+ if (token === null) return;
614
+ const unsubscribe = realtimeRef.current.subscribe(token, handleEvent, handleStatus);
615
+ return () => {
616
+ unsubscribe();
617
+ stopPoll();
618
+ };
619
+ }, [state.token, handleEvent, handleStatus, stopPoll]);
620
+ useEffect(() => stopPoll, [stopPoll]);
621
+ const openPanel = useCallback(() => {
622
+ dispatch({ type: "open" });
623
+ }, []);
624
+ const closePanel = useCallback(() => {
625
+ dispatch({ type: "close" });
626
+ }, []);
627
+ const togglePanel = useCallback(() => {
628
+ if (stateRef.current.open) dispatch({ type: "close" });
629
+ else dispatch({ type: "open" });
630
+ }, []);
631
+ const postMessage = useCallback(
632
+ async (token, body, id) => {
633
+ dispatch({ type: "sending", value: true });
634
+ dispatch({ type: "error", value: null });
635
+ try {
636
+ const res = await fetch(endpoint, {
637
+ method: "POST",
638
+ headers: { "content-type": "application/json" },
639
+ body: JSON.stringify({ token, message: body })
640
+ });
641
+ if (!res.ok) {
642
+ dispatch({ type: "mark", id, status: "failed" });
643
+ dispatch({ type: "error", value: "sendError" });
644
+ return;
645
+ }
646
+ const data = await res.json();
647
+ if (data.message !== void 0) dispatch({ type: "replace", tmpId: id, message: data.message });
648
+ else dispatch({ type: "mark", id, status: "failed" });
649
+ } catch {
650
+ dispatch({ type: "mark", id, status: "failed" });
651
+ dispatch({ type: "error", value: "sendError" });
652
+ } finally {
653
+ dispatch({ type: "sending", value: false });
654
+ }
655
+ },
656
+ [endpoint]
657
+ );
658
+ const sendMessage = useCallback(
659
+ async (text) => {
660
+ const body = text.trim();
661
+ const token = stateRef.current.token;
662
+ if (body === "" || token === null) return;
663
+ const optimistic = tmpMessage(body);
664
+ dispatch({ type: "append", message: optimistic });
665
+ await postMessage(token, body, optimistic.id);
666
+ },
667
+ [postMessage]
668
+ );
669
+ const retryMessage = useCallback(
670
+ async (id) => {
671
+ const token = stateRef.current.token;
672
+ const target = stateRef.current.messages.find((m) => m.id === id);
673
+ if (token === null || target === void 0 || target.direction !== "visitor") return;
674
+ dispatch({ type: "mark", id, status: "pending" });
675
+ await postMessage(token, target.body, id);
676
+ },
677
+ [postMessage]
678
+ );
679
+ const startNewConversation = useCallback(() => {
680
+ clearStoredSession();
681
+ dispatch({ type: "reset-form" });
682
+ }, []);
683
+ const submitForm = useCallback(
684
+ async ({ name, phone, message, honeypot }) => {
685
+ if (honeypot.trim() !== "") return;
686
+ const digits = normalizePhone(phone);
687
+ if (name.trim() === "" || message.trim() === "" || digits.length < MIN_PHONE_DIGITS) {
688
+ dispatch({ type: "error", value: digits.length < MIN_PHONE_DIGITS ? "invalidPhone" : "sendError" });
689
+ return;
690
+ }
691
+ const optimistic = tmpMessage(message.trim());
692
+ dispatch({ type: "append", message: optimistic });
693
+ dispatch({ type: "sending", value: true });
694
+ dispatch({ type: "error", value: null });
695
+ try {
696
+ const res = await fetch(endpoint, {
697
+ method: "POST",
698
+ headers: { "content-type": "application/json" },
699
+ body: JSON.stringify({ name: name.trim(), phone: digits, message: message.trim(), honeypot: "" })
700
+ });
701
+ if (!res.ok) {
702
+ dispatch({ type: "mark", id: optimistic.id, status: "failed" });
703
+ dispatch({ type: "error", value: "sendError" });
704
+ return;
705
+ }
706
+ const data = await res.json();
707
+ const session = data.session;
708
+ if (session === void 0 || typeof session.realtimeToken !== "string" || session.realtimeToken === "") {
709
+ dispatch({ type: "mark", id: optimistic.id, status: "failed" });
710
+ dispatch({ type: "error", value: "sendError" });
711
+ return;
712
+ }
713
+ const token = session.realtimeToken;
714
+ writeStoredSession({ token, code: session.code });
715
+ dispatch({
716
+ type: "start",
717
+ session: { code: session.code, status: session.status, visitorName: session.visitorName },
718
+ token,
719
+ messages: Array.isArray(data.messages) ? data.messages : []
720
+ });
721
+ } catch {
722
+ dispatch({ type: "mark", id: optimistic.id, status: "failed" });
723
+ dispatch({ type: "error", value: "sendError" });
724
+ } finally {
725
+ dispatch({ type: "sending", value: false });
726
+ }
727
+ },
728
+ [endpoint]
729
+ );
730
+ return { state, openPanel, closePanel, togglePanel, submitForm, sendMessage, retryMessage, startNewConversation };
731
+ }
732
+
733
+ // src/widget/chat-widget.tsx
734
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
735
+ var DEFAULT_ACCENT = "#25D366";
736
+ function ChatIcon() {
737
+ return /* @__PURE__ */ jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: /* @__PURE__ */ jsx("path", { d: "M21 11.5a8.38 8.38 0 0 1-8.5 8.5c-1.5 0-2.9-.36-4.13-1L3 20l1.1-3.85A8.36 8.36 0 0 1 12.5 3 8.38 8.38 0 0 1 21 11.5z" }) });
738
+ }
739
+ function SendIcon() {
740
+ return /* @__PURE__ */ jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: [
741
+ /* @__PURE__ */ jsx("path", { d: "M22 2 11 13" }),
742
+ /* @__PURE__ */ jsx("path", { d: "M22 2 15 22l-4-9-9-4 20-7z" })
743
+ ] });
744
+ }
745
+ function formatTime(iso, locale) {
746
+ const date = new Date(iso);
747
+ if (Number.isNaN(date.getTime())) return "";
748
+ return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" }).format(date);
749
+ }
750
+ function StatusMark({ status }) {
751
+ const glyph = status === "pending" ? "\u2713" : status === "sent" ? "\u2713\u2713" : "\u26A0";
752
+ return /* @__PURE__ */ jsx("span", { className: status === "failed" ? "ecw-status ecw-status--failed" : "ecw-status", "aria-hidden": "true", children: glyph });
753
+ }
754
+ function PreChatForm({ welcome, tr, error, sending, firstFieldRef, onSubmit }) {
755
+ const uid = useId();
756
+ const [name, setName] = useState("");
757
+ const [phone, setPhone] = useState("");
758
+ const [message, setMessage] = useState("");
759
+ const [honeypot, setHoneypot] = useState("");
760
+ const [phoneTouched, setPhoneTouched] = useState(false);
761
+ const phoneValid = isValidPhone(phone);
762
+ const complete = name.trim() !== "" && phoneValid && message.trim() !== "";
763
+ const handleSubmit = (e) => {
764
+ e.preventDefault();
765
+ if (!complete || sending) return;
766
+ void onSubmit({ name, phone, message, honeypot });
767
+ };
768
+ return /* @__PURE__ */ jsxs("form", { className: "ecw-form", onSubmit: handleSubmit, noValidate: true, children: [
769
+ /* @__PURE__ */ jsx("p", { className: "ecw-welcome", children: welcome }),
770
+ /* @__PURE__ */ jsx("p", { className: "ecw-notice", children: tr("welcomeNotice") }),
771
+ /* @__PURE__ */ jsxs("div", { className: "ecw-field", children: [
772
+ /* @__PURE__ */ jsx("label", { className: "ecw-label", htmlFor: `${uid}-name`, children: tr("name") }),
773
+ /* @__PURE__ */ jsx(
774
+ "input",
775
+ {
776
+ id: `${uid}-name`,
777
+ ref: firstFieldRef,
778
+ className: "ecw-input",
779
+ type: "text",
780
+ autoComplete: "name",
781
+ value: name,
782
+ onChange: (e) => {
783
+ setName(e.target.value);
784
+ },
785
+ required: true
786
+ }
787
+ )
788
+ ] }),
789
+ /* @__PURE__ */ jsxs("div", { className: "ecw-field", children: [
790
+ /* @__PURE__ */ jsx("label", { className: "ecw-label", htmlFor: `${uid}-phone`, children: tr("phone") }),
791
+ /* @__PURE__ */ jsx(
792
+ "input",
793
+ {
794
+ id: `${uid}-phone`,
795
+ className: "ecw-input",
796
+ type: "tel",
797
+ inputMode: "tel",
798
+ autoComplete: "tel",
799
+ placeholder: "(11) 99999-8888",
800
+ value: phone,
801
+ onChange: (e) => {
802
+ setPhone(maskPhone(e.target.value));
803
+ },
804
+ onBlur: () => {
805
+ setPhoneTouched(true);
806
+ },
807
+ "aria-invalid": phoneTouched && !phoneValid ? true : void 0,
808
+ "aria-describedby": phoneTouched && !phoneValid ? `${uid}-phone-err` : void 0,
809
+ required: true
810
+ }
811
+ ),
812
+ phoneTouched && !phoneValid && /* @__PURE__ */ jsx("p", { className: "ecw-error", id: `${uid}-phone-err`, role: "alert", children: tr("invalidPhone") })
813
+ ] }),
814
+ /* @__PURE__ */ jsxs("div", { className: "ecw-field", children: [
815
+ /* @__PURE__ */ jsx("label", { className: "ecw-label", htmlFor: `${uid}-message`, children: tr("message") }),
816
+ /* @__PURE__ */ jsx(
817
+ "textarea",
818
+ {
819
+ id: `${uid}-message`,
820
+ className: "ecw-input",
821
+ rows: 3,
822
+ autoComplete: "off",
823
+ value: message,
824
+ onChange: (e) => {
825
+ setMessage(e.target.value);
826
+ },
827
+ required: true
828
+ }
829
+ )
830
+ ] }),
831
+ /* @__PURE__ */ jsxs("div", { className: "ecw-hp", "aria-hidden": "true", children: [
832
+ /* @__PURE__ */ jsx("label", { htmlFor: `${uid}-website`, children: "Website" }),
833
+ /* @__PURE__ */ jsx(
834
+ "input",
835
+ {
836
+ id: `${uid}-website`,
837
+ name: "website",
838
+ type: "text",
839
+ tabIndex: -1,
840
+ autoComplete: "off",
841
+ value: honeypot,
842
+ onChange: (e) => {
843
+ setHoneypot(e.target.value);
844
+ }
845
+ }
846
+ )
847
+ ] }),
848
+ error === "sendError" && /* @__PURE__ */ jsx("p", { className: "ecw-error", role: "alert", children: tr("sendError") }),
849
+ /* @__PURE__ */ jsx("button", { className: "ecw-submit", type: "submit", disabled: !complete || sending, children: sending ? tr("sending") : tr("send") }),
850
+ /* @__PURE__ */ jsx("p", { className: "ecw-notice", children: tr("privacyNote") })
851
+ ] });
852
+ }
853
+ function ChatPanel({ messages, canCompose, sending, locale, tr, listRef, firstFieldRef, onSend, onRetry, onNewConversation }) {
854
+ const uid = useId();
855
+ const [draft, setDraft] = useState("");
856
+ const submit = (e) => {
857
+ e.preventDefault();
858
+ const text = draft.trim();
859
+ if (text === "" || !canCompose) return;
860
+ setDraft("");
861
+ void onSend(text);
862
+ };
863
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
864
+ /* @__PURE__ */ jsx("ul", { ref: listRef, className: "ecw-list", children: messages.map((m) => /* @__PURE__ */ jsxs("li", { className: `ecw-item ecw-item--${m.direction}`, children: [
865
+ /* @__PURE__ */ jsx("div", { className: `ecw-bubble ecw-bubble--${m.direction}`, children: m.body }),
866
+ /* @__PURE__ */ jsxs("div", { className: "ecw-meta", children: [
867
+ /* @__PURE__ */ jsx("time", { dateTime: m.createdAt, children: formatTime(m.createdAt, locale) }),
868
+ m.direction === "visitor" && /* @__PURE__ */ jsx(StatusMark, { status: m.status }),
869
+ m.status === "failed" && /* @__PURE__ */ jsx("button", { type: "button", className: "ecw-retry", onClick: () => {
870
+ void onRetry(m.id);
871
+ }, children: tr("retry") })
872
+ ] })
873
+ ] }, m.id)) }),
874
+ !canCompose && /* @__PURE__ */ jsxs("div", { className: "ecw-closed", children: [
875
+ /* @__PURE__ */ jsx("p", { className: "ecw-notice", role: "status", children: tr("sessionClosed") }),
876
+ /* @__PURE__ */ jsx("button", { type: "button", className: "ecw-submit", onClick: onNewConversation, children: tr("newConversation") })
877
+ ] }),
878
+ /* @__PURE__ */ jsxs("form", { className: "ecw-composer", onSubmit: submit, children: [
879
+ /* @__PURE__ */ jsx("label", { className: "ecw-sr-only", htmlFor: `${uid}-input`, children: tr("message") }),
880
+ /* @__PURE__ */ jsx(
881
+ "input",
882
+ {
883
+ id: `${uid}-input`,
884
+ ref: firstFieldRef,
885
+ className: "ecw-input",
886
+ type: "text",
887
+ autoComplete: "off",
888
+ placeholder: tr("message"),
889
+ value: draft,
890
+ onChange: (e) => {
891
+ setDraft(e.target.value);
892
+ },
893
+ disabled: !canCompose
894
+ }
895
+ ),
896
+ /* @__PURE__ */ jsx("button", { className: "ecw-send", type: "submit", disabled: !canCompose || draft.trim() === "" || sending, "aria-label": sending ? tr("sending") : tr("send"), children: sending ? /* @__PURE__ */ jsx("span", { className: "ecw-sr-only", children: tr("sending") }) : /* @__PURE__ */ jsx(SendIcon, {}) })
897
+ ] })
898
+ ] });
899
+ }
900
+ function ChatWidget(props) {
901
+ const { endpoint, locale, welcome, projectName, realtime, labels } = props;
902
+ const accentColor = props.accentColor ?? DEFAULT_ACCENT;
903
+ const tr = useCallback2((key) => t(locale, key, labels), [locale, labels]);
904
+ const { state, closePanel, togglePanel, submitForm, sendMessage, retryMessage, startNewConversation } = useChat({ endpoint, realtime });
905
+ const bubbleRef = useRef2(null);
906
+ const listRef = useRef2(null);
907
+ const firstFieldRef = useRef2(null);
908
+ useEffect2(() => {
909
+ injectWidgetStyles();
910
+ }, []);
911
+ useEffect2(() => {
912
+ if (state.open) firstFieldRef.current?.focus();
913
+ }, [state.open, state.phase]);
914
+ useEffect2(() => {
915
+ if (!state.open) return;
916
+ const onKeyDown = (e) => {
917
+ if (e.key !== "Escape") return;
918
+ closePanel();
919
+ bubbleRef.current?.focus();
920
+ };
921
+ document.addEventListener("keydown", onKeyDown);
922
+ return () => {
923
+ document.removeEventListener("keydown", onKeyDown);
924
+ };
925
+ }, [state.open, closePanel]);
926
+ useEffect2(() => {
927
+ const list = listRef.current;
928
+ if (list !== null) list.scrollTop = list.scrollHeight;
929
+ }, [state.messages, state.open]);
930
+ const handleClose = useCallback2(() => {
931
+ closePanel();
932
+ bubbleRef.current?.focus();
933
+ }, [closePanel]);
934
+ const canCompose = state.session?.status === "active";
935
+ const bubbleLabel = state.unread > 0 ? `${tr("openChat")} (${state.unread})` : tr("openChat");
936
+ return /* @__PURE__ */ jsxs("div", { className: "ecw-root", style: { "--ecw-accent": accentColor }, children: [
937
+ /* @__PURE__ */ jsxs(
938
+ "button",
939
+ {
940
+ ref: bubbleRef,
941
+ type: "button",
942
+ className: "ecw-button",
943
+ "aria-label": bubbleLabel,
944
+ "aria-expanded": state.open,
945
+ "aria-haspopup": "dialog",
946
+ onClick: togglePanel,
947
+ children: [
948
+ /* @__PURE__ */ jsx(ChatIcon, {}),
949
+ state.unread > 0 && /* @__PURE__ */ jsx("span", { className: "ecw-badge", children: state.unread })
950
+ ]
951
+ }
952
+ ),
953
+ state.open && /* @__PURE__ */ jsxs("section", { className: "ecw-panel", role: "dialog", "aria-label": "Chat", children: [
954
+ /* @__PURE__ */ jsxs("header", { className: "ecw-header", children: [
955
+ /* @__PURE__ */ jsx("span", { className: "ecw-title", children: projectName }),
956
+ state.session !== null && /* @__PURE__ */ jsxs("span", { className: "ecw-code", children: [
957
+ "#",
958
+ state.session.code
959
+ ] }),
960
+ /* @__PURE__ */ jsx("button", { type: "button", className: "ecw-close", "aria-label": tr("close"), onClick: handleClose, children: /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "\xD7" }) })
961
+ ] }),
962
+ state.phase === "chat" ? /* @__PURE__ */ jsx(
963
+ ChatPanel,
964
+ {
965
+ messages: state.messages,
966
+ canCompose,
967
+ sending: state.sending,
968
+ locale,
969
+ tr,
970
+ listRef,
971
+ firstFieldRef,
972
+ onSend: sendMessage,
973
+ onRetry: retryMessage,
974
+ onNewConversation: startNewConversation
975
+ }
976
+ ) : /* @__PURE__ */ jsx(
977
+ PreChatForm,
978
+ {
979
+ welcome,
980
+ tr,
981
+ error: state.error,
982
+ sending: state.sending,
983
+ firstFieldRef,
984
+ onSubmit: submitForm
985
+ }
986
+ ),
987
+ /* @__PURE__ */ jsx("footer", { className: "ecw-footer", children: `${tr("poweredBy")} Evolution Chat` })
988
+ ] })
989
+ ] });
990
+ }
991
+ export {
992
+ ChatWidget,
993
+ MIN_PHONE_DIGITS,
994
+ POLL_INTERVAL_MS,
995
+ SESSION_STORAGE_KEY,
996
+ WIDGET_CSS,
997
+ WIDGET_KEYS,
998
+ dictionaries,
999
+ en,
1000
+ es,
1001
+ injectWidgetStyles,
1002
+ isValidPhone,
1003
+ maskPhone,
1004
+ normalizePhone,
1005
+ pt,
1006
+ t,
1007
+ useChat
1008
+ };
1009
+ //# sourceMappingURL=index.js.map