vue-wiguet-chatweb 0.1.34 → 0.1.36

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. package/README.md +68 -68
  2. package/dist/App.vue.d.ts +2 -0
  3. package/dist/components/Chat.vue.d.ts +5 -2
  4. package/dist/components/ChatMessage.vue.d.ts +12 -0
  5. package/dist/components/DangerIcon.vue.d.ts +1 -1
  6. package/dist/components/IconAttach.vue.d.ts +1 -1
  7. package/dist/components/IconChat.vue.d.ts +1 -1
  8. package/dist/components/IconClose.vue.d.ts +1 -1
  9. package/dist/components/IconSend.vue.d.ts +1 -1
  10. package/dist/components/IconTelegram.vue.d.ts +1 -1
  11. package/dist/components/IconWhatsApp.vue.d.ts +1 -1
  12. package/dist/components/Loader.vue.d.ts +1 -1
  13. package/dist/components/MessageList.vue.d.ts +3 -2
  14. package/dist/components/ODialog/IPropsDialog.d.ts +0 -1
  15. package/dist/components/ODialog/ODialog.vue.d.ts +13 -15
  16. package/dist/components/Widget.vue.d.ts +1 -2
  17. package/dist/dto/app.dto.d.ts +0 -1
  18. package/dist/index.d.ts +0 -1
  19. package/dist/main.d.ts +0 -1
  20. package/dist/store/config.d.ts +0 -1
  21. package/dist/store/index.d.ts +0 -1
  22. package/dist/style.css +1 -1
  23. package/dist/vue-wiguet-chatweb.js +792 -755
  24. package/dist/vue-wiguet-chatweb.umd.cjs +26 -26
  25. package/package.json +66 -66
  26. package/src/assets/emojis/AngryIcon.svg +4 -4
  27. package/src/assets/emojis/HappiestIcon.svg +4 -4
  28. package/src/assets/emojis/HappyIcon.svg +4 -4
  29. package/src/assets/emojis/NeutralIcon.svg +4 -4
  30. package/src/assets/emojis/SadIcon.svg +4 -4
  31. package/src/components/Chat.vue +716 -696
  32. package/src/components/ChatMessage.vue +102 -102
  33. package/src/components/DangerIcon.vue +12 -12
  34. package/src/components/IconAttach.vue +24 -24
  35. package/src/components/IconChat.vue +23 -23
  36. package/src/components/IconClose.vue +5 -5
  37. package/src/components/IconSend.vue +8 -8
  38. package/src/components/IconTelegram.vue +28 -28
  39. package/src/components/IconWhatsApp.vue +39 -39
  40. package/src/components/IconWidget.vue +45 -45
  41. package/src/components/Loader.vue +31 -31
  42. package/src/components/LoadingComponent.vue +111 -111
  43. package/src/components/MessageList.vue +357 -357
  44. package/src/components/ODialog/IPropsDialog.ts +4 -4
  45. package/src/components/ODialog/IPropsSidebar.ts +13 -13
  46. package/src/components/ODialog/ODialog.vue +107 -107
  47. package/src/components/Widget.vue +196 -196
  48. package/src/components/__tests__/Chat.spec.ts +5 -5
  49. package/src/components/__tests__/ChatMessage.spec.ts +5 -5
  50. package/src/components/__tests__/MessageList.spec.ts +47 -47
@@ -1,696 +1,716 @@
1
- <template>
2
- <div class="widget">
3
- <div class="header-widget">
4
- <h4 class="title-chat">{{ titlePrincipal }}</h4>
5
- <button @click="() => toggleChat()" class="btn-close">
6
- <IconClose class="pointer" />
7
- </button>
8
- </div>
9
- <div class="messages-container" ref="messageContainerRef">
10
- <div class="loader" v-if="isLoading">
11
- <Loader />
12
- </div>
13
- <MessageList
14
- v-if="messagesData.data.length > 0"
15
- :messages="messagesData.data"
16
- :canLoadMoreMessages="messagesData.canLoadMoreMessages"
17
- @loadMore="getMessages"
18
- @retry="retryMessage"
19
- @on-qualifying="(args) => onQualifying(args)"
20
- @see="(message: Message) => {
21
- currentDialogView = DIALOG_VIEWS.SEE
22
- urlFileMessage = message.urlFile
23
- dialog.title = 'Imagen'
24
- dialog.modelValue = true
25
- }"
26
- />
27
- <div class="fit" v-else>
28
- <span class="center">No tienes mensajes</span>
29
- </div>
30
- </div>
31
-
32
- <div class="w-full">
33
- <form v-if="!isDisabledBoxMessage" class="message-send" @submit.prevent="(event) => submitMessage()">
34
- <div class="form-message">
35
- <div class="jl-inputgroup-chat">
36
- <textarea
37
- v-model="message"
38
- class="jl2-input-chat"
39
- placeholder="Escribe tu mensaje"
40
- required
41
- ref="textAreaRef"
42
- @input="() => autoAdjustHeight()"
43
- @keydown.enter="
44
- (event) => {
45
- !isMobile && event.preventDefault();
46
- }
47
- "
48
- @keyup.enter="saltoDeLineaOEnviar"
49
- />
50
-
51
- <input
52
- type="file"
53
- ref="fileInputRef"
54
- @change="onFileSelect"
55
- accept="image/*"
56
- style="display: none;"
57
- key="fileInputKey"
58
-
59
- />
60
- <button
61
- type="button"
62
- class="pointer btn-primary"
63
- title="Adjuntar archivo"
64
- @click="
65
- () => {
66
- fileInputRef.value = '';
67
- fileInputKey++;
68
- fileInputRef?.click();
69
- }
70
- "
71
- >
72
- <IconAttach style="width: 20px; height: 20px" />
73
- </button>
74
-
75
- <button type="submit" class="pointer btn-primary">
76
- <IconSend style="width: 20px; height: 20px" />
77
- </button>
78
- </div>
79
- </div>
80
- </form>
81
- <span class="message-send-block" v-else>
82
- Necesitamos que nos califique la atención para continuar
83
- </span>
84
- </div>
85
- </div>
86
-
87
- <ODialog v-bind="dialog">
88
- <div v-if="currentDialogView === DIALOG_VIEWS.UPLOAD" class="flex flex-col gap-3 justify-center items-center">
89
- <img v-for="(urlFile, i) in urlFiles" :key="i" :src="urlFile.toString()" alt="Image" width="400" />
90
-
91
- <form
92
- class="message-send"
93
- @submit.prevent="
94
- (event) => {
95
- submitMessage({ codigoTipoMensaje: MESSAGE_TYPE_CODES.IMAGEN});
96
- dialog.modelValue = false;
97
- }
98
- "
99
- >
100
- <div class="form-message">
101
- <div class="jl-inputgroup-chat">
102
- <textarea
103
- v-model="message"
104
- autofocus
105
- class="jl2-input-chat"
106
- placeholder="Escribe tu mensaje"
107
- ref="textAreaRef"
108
- @input="() => autoAdjustHeight()"
109
- @keydown.enter="
110
- (event) => {
111
- !isMobile && event.preventDefault();
112
- }
113
- "
114
- @keyup.enter="(event)=>{
115
- saltoDeLineaOEnviar(event, MESSAGE_TYPE_CODES.IMAGEN)
116
- }"
117
- />
118
-
119
- <button type="submit" class="pointer btn-primary">
120
- <IconSend style="width: 20px; height: 20px" />
121
- </button>
122
- </div>
123
- </div>
124
- </form>
125
- </div>
126
- <div v-else>
127
- <img v-if="urlFileMessage" :src="urlFileMessage" alt="Image" style="width: 55vw;" />
128
- </div>
129
- </ODialog>
130
- </template>
131
-
132
- <script setup lang="ts">
133
- import {
134
- ref,
135
- onMounted,
136
- nextTick,
137
- PropType,
138
- watch,
139
- onUnmounted,
140
- reactive,
141
- } from "vue";
142
- import { v4 as uuidv4 } from "uuid";
143
-
144
- import {
145
- type SendMessageBody,
146
- ChatInformation,
147
- ListMessageBody,
148
- Message,
149
- } from "../dto/app.dto";
150
- import IconClose from "./IconClose.vue";
151
- import IconSend from "./IconSend.vue";
152
- import {
153
- getMessagesApi,
154
- sendMessageApi,
155
- setVistoToTrueApi,
156
- updateMessageApi,
157
- } from "../store/index";
158
- import { getInformationApi } from "../store";
159
- import MessageList from "./MessageList.vue";
160
- import Loader from "./Loader.vue";
161
- import { searchFromLast } from "../resources/functions.helpers";
162
- import { io, Socket } from "socket.io-client";
163
- import { APP_TYPE } from "../dto/chat.dto";
164
- import { MESSAGE_TYPE_CODES, TypeMessageTypeCodes } from "../resources/constants/message-type.constant";
165
- import { useMobile } from "../hooks/useMobile";
166
- import IconAttach from "./IconAttach.vue";
167
- import ODialog from "./ODialog/ODialog.vue";
168
- import { IPropsDialog } from "./ODialog/IPropsDialog";
169
-
170
- const enum DIALOG_VIEWS {
171
- UPLOAD,
172
- SEE
173
- }
174
-
175
- //DATA
176
- const message = ref("");
177
- const notViewed = ref(0);
178
- const fileInputRef = ref();
179
- const fileInputKey = ref(0);
180
- const currentDialogView = ref(DIALOG_VIEWS.SEE)
181
- const urlFileMessage = ref<string>()
182
-
183
- const messagesData = ref<{ data: Message[]; canLoadMoreMessages: boolean }>({
184
- data: [],
185
- canLoadMoreMessages: false,
186
- });
187
-
188
- const appChatId = ref("");
189
- const isLoading = ref(false);
190
-
191
- const emit = defineEmits([
192
- "show-toast",
193
- "show-confirm",
194
- "new-message",
195
- "clear-new-messages",
196
- "not-viewed-total",
197
- "onQualifying",
198
- ]);
199
-
200
- const props = defineProps({
201
- titlePrincipal: {
202
- type: String,
203
- default: "Comunicación en linea para consultas",
204
- },
205
- toggleChat: { type: Function, required: true },
206
- tokenAuth: { type: String, required: true },
207
- user: {
208
- type: Object as PropType<{
209
- nombreCompleto: string;
210
- ci: string;
211
- msPersonaId: number;
212
- }>,
213
- required: true,
214
- },
215
- visible: { type: Boolean, required: true },
216
- });
217
-
218
- const messageContainerRef = ref<HTMLElement | null>(null);
219
-
220
- watch(
221
- () => props.visible,
222
- async (current) => {
223
- if (!current) return;
224
-
225
- emit("clear-new-messages");
226
- scrollToBottom();
227
-
228
- if (notViewed.value > 0) {
229
- setVistoToTrueApi(appChatId.value, props.tokenAuth);
230
- }
231
-
232
- if (appChatId.value) return;
233
-
234
- const resp = await getInformationApi(props.tokenAuth);
235
-
236
- if (resp) {
237
- appChatId.value = resp.appChat.id;
238
- }
239
- }
240
- );
241
-
242
- function saltoDeLineaOEnviar(event: KeyboardEvent, messageTypeCode?: TypeMessageTypeCodes) {
243
- if(isMobile.value) {
244
- autoAdjustHeight();
245
- return;
246
- }
247
-
248
- if (event.key === "Enter" && event.shiftKey) {
249
- const val = (event.target as any)?.value || "";
250
- (event.target as any).value = val + "\n";
251
- autoAdjustHeight();
252
- return;
253
- }
254
-
255
- submitMessage({ codigoTipoMensaje: messageTypeCode });
256
- dialog.modelValue = false;
257
- }
258
-
259
- function createMessage(message: string, codigoTipoMensaje?: TypeMessageTypeCodes) {
260
- if (message?.length > 300) {
261
- emit("show-toast", {
262
- severity: "warn",
263
- summary: "Error",
264
- detail: "El mensaje no puede superar los 300 caracteres",
265
- life: 5000,
266
- });
267
- return;
268
- }
269
-
270
- if (!message.trim() && codigoTipoMensaje !== MESSAGE_TYPE_CODES.IMAGEN) {
271
- emit("show-toast", {
272
- severity: "warn",
273
- summary: "Error",
274
- detail: "Por favor ingrese un mensaje",
275
- life: 5000,
276
- });
277
- return;
278
- }
279
-
280
- const messageType = codigoTipoMensaje
281
- ? {
282
- code: codigoTipoMensaje,
283
- }
284
- : undefined;
285
-
286
- const newMessage: Message = {
287
- id: uuidv4(),
288
- chatId: information.value?.chat?.id,
289
- message,
290
- visto: true,
291
- multimedia: false,
292
- esCliente: true,
293
- appChatId: appChatId.value,
294
- createdAt: new Date().toISOString(),
295
- updatedAt: new Date().toISOString(),
296
- file: inputFiles.value?.[0],
297
- urlFile: urlFiles.value?.[0],
298
- messageType,
299
- sender: {
300
- nombreCompleto: props.user.nombreCompleto,
301
- ci: props.user.ci,
302
- msPersonaId: props.user.msPersonaId,
303
- },
304
- response: undefined
305
- };
306
-
307
- return newMessage
308
- }
309
-
310
- function updateMessageStatus(
311
- newMessage: Message,
312
- idxMessageToCommunicate?: number,
313
- messageSaved?: Message,
314
- tipoCalificacionId?: number
315
- ) {
316
- if (idxMessageToCommunicate == null) throw new Error('idx is required')
317
-
318
- if (!messageSaved) {
319
- if (tipoCalificacionId) {
320
- const messageAResponder = messagesData.value.data[idxMessageToCommunicate]
321
- messageAResponder.response = undefined
322
- } else {
323
- messagesData.value.data[idxMessageToCommunicate].error = {
324
- error: true,
325
- id: newMessage.id,
326
- };
327
- }
328
-
329
- emit("show-toast", {
330
- severity: "error",
331
- summary: "Error",
332
- detail: "Ocurrio un error al enviar el mensaje, intente nuevamente",
333
- life: 5000,
334
- });
335
-
336
- return
337
- }
338
-
339
- let messageUpdated = { ...messageSaved, chatId: newMessage?.chatId }
340
-
341
- if (tipoCalificacionId) {
342
- const messageAResponder = messagesData.value.data[idxMessageToCommunicate]
343
- messageAResponder.response = messageSaved
344
-
345
- messageUpdated = { ...messageAResponder, chatId: newMessage?.chatId }
346
- } else {
347
- messagesData.value.data[idxMessageToCommunicate] = messageUpdated;
348
- }
349
-
350
- return messageUpdated;
351
- }
352
-
353
- const submitMessage = async (
354
- messageExtraData?: { mensajeARespondeId?: string, tipoCalificacionId?: number, codigoTipoMensaje?: TypeMessageTypeCodes}
355
- ) => {
356
-
357
- const newMessage = createMessage(message.value, messageExtraData?.codigoTipoMensaje);
358
- message.value = '';
359
- if (!newMessage) return;
360
-
361
- let idxMessageToCommunicate = -1;
362
- if (messageExtraData?.tipoCalificacionId) {
363
- idxMessageToCommunicate = messagesData.value.data.findIndex((val) => val.id === messageExtraData?.mensajeARespondeId )
364
- const messageAResponder = messagesData.value.data[idxMessageToCommunicate]
365
-
366
- if ( messageAResponder ) {
367
- messageAResponder.response = newMessage
368
- }
369
- } else {
370
- idxMessageToCommunicate = messagesData.value.data.push(newMessage) - 1;
371
- }
372
-
373
- const body = {
374
- message: message.value,
375
- appChatId: appChatId.value,
376
- ...messageExtraData,
377
- codigoTipoMensaje: messageExtraData?.codigoTipoMensaje,
378
- files: inputFiles.value,
379
- }
380
- sendApi(body).then((messageSaved) => {
381
- let messageUpdated = updateMessageStatus(
382
- newMessage,
383
- idxMessageToCommunicate,
384
- messageSaved,
385
- messageExtraData?.tipoCalificacionId
386
- )
387
-
388
- if(!messageUpdated) return
389
-
390
- isDisabledBoxMessage.value = !!messagesData.value.data.find((msg) => msg.messageType?.code === MESSAGE_TYPE_CODES.PREGUNTA && msg.response == null);
391
-
392
- socketService.value?.emit(
393
- "sendMessage",
394
- { roomId: information?.value?.appChat.id, message: messageUpdated },
395
- (response: any) => {
396
- console.log("🚀 ~ socketService.value.emit ~ response:", response);
397
- }
398
- );
399
- });
400
-
401
- message.value = "";
402
- scrollToBottom();
403
- textAreaRef.value && (textAreaRef.value.style.height = "20px");
404
- };
405
-
406
- const sendApi = async (bodyParam: Omit<SendMessageBody, 'esCliente'>) => {
407
- const body: SendMessageBody = {
408
- ...bodyParam,
409
- esCliente: true,
410
- };
411
- return sendMessageApi(body, props.tokenAuth);
412
- };
413
-
414
- const getMessages = async () => {
415
- const lastMessagesId = messagesData.value.data[0]?.id;
416
- const body: ListMessageBody = {
417
- lastMessagesId,
418
- appChatId: appChatId.value,
419
- limit: 10,
420
- };
421
-
422
- isLoading.value = true;
423
- const resp = await getMessagesApi({ body, token: props.tokenAuth });
424
- isLoading.value = false;
425
-
426
- isDisabledBoxMessage.value = !!resp.data.find((msg) => msg.messageType?.code === MESSAGE_TYPE_CODES.PREGUNTA && msg.response == null);
427
-
428
- messagesData.value.data.unshift(
429
- ...resp.data.sort((a, b) => -b.createdAt.localeCompare(a.createdAt))
430
- );
431
-
432
- messagesData.value.canLoadMoreMessages =
433
- resp.pagination.total > resp.pagination.size;
434
-
435
- if (lastMessagesId && messageContainerRef.value?.scrollHeight) {
436
- mantainElementsOnViewport(messageContainerRef.value?.scrollHeight);
437
- }
438
-
439
- if (!lastMessagesId) scrollToBottom();
440
- };
441
-
442
- const retryMessage = async (message: Message) => {
443
- emit("show-confirm", async () => {
444
- if (!message.error?.id) return;
445
-
446
- const msg = await sendApi({
447
- message: message.message,
448
- appChatId: appChatId.value
449
- });
450
-
451
- if (!msg) {
452
- emit("show-toast", {
453
- severity: "error",
454
- summary: "Error",
455
- detail: "Ocurrio un error al enviar el mensaje, intente nuevamente",
456
- life: 5000,
457
- });
458
- } else {
459
- const idx = searchFromLast<Message>(
460
- messagesData.value.data,
461
- "id",
462
- message.error.id
463
- );
464
-
465
- messagesData.value.data[idx] = { ...msg, error: undefined };
466
-
467
- socketService.value?.emit("sendMessage", {
468
- roomId: information?.value?.appChat.id,
469
- message,
470
- });
471
- }
472
-
473
- scrollToBottom();
474
- });
475
- };
476
-
477
- const scrollToBottom = () => {
478
- nextTick(() => {
479
- if (messageContainerRef.value) {
480
- messageContainerRef.value.scrollTop =
481
- messageContainerRef.value.scrollHeight;
482
- }
483
- });
484
- };
485
-
486
- const mantainElementsOnViewport = (scrollHeightBeforeAdd: number) => {
487
- nextTick(() => {
488
- const objDiv = messageContainerRef.value;
489
- if (objDiv) {
490
- objDiv.scrollTop = objDiv.scrollHeight - scrollHeightBeforeAdd;
491
- }
492
- });
493
- };
494
-
495
- const textAreaRef = ref<HTMLTextAreaElement>();
496
-
497
- const fontSpace = 14;
498
-
499
- function autoAdjustHeight() {
500
- if (!textAreaRef.value) return;
501
-
502
- textAreaRef.value.style.height =
503
- textAreaRef.value.scrollHeight - fontSpace + "px";
504
-
505
- if (textAreaRef.value.scrollHeight === textAreaRef.value.clientHeight)
506
- return;
507
-
508
- textAreaRef.value.style.height = textAreaRef.value.scrollHeight + "px";
509
- }
510
-
511
- const socketService = ref<Socket>();
512
- const information = ref<ChatInformation>();
513
-
514
- function connectMsWebSocket(
515
- userChat: ChatInformation,
516
- app: APP_TYPE = APP_TYPE.WEBCHAT
517
- ) {
518
- if (!userChat) throw new Error("user chat is required");
519
-
520
- socketService.value = io(window.VITE_SOCKET_URI, {
521
- query: {
522
- // TODO: confirmar si se quita o no
523
- usuarioId: `${userChat?.chat?.persona?.funcionarioId}`,
524
- aplicacion: app,
525
- },
526
- extraHeaders: { Authorization: props.tokenAuth },
527
- });
528
-
529
- socketService.value.removeAllListeners();
530
-
531
- socketService.value.on("connect", () => {
532
- console.log("Conectado al servidor de sockets");
533
-
534
- socketService.value?.emit("joinRoom", `${userChat?.appChat?.id}`);
535
- });
536
-
537
- socketService.value.on("disconnect", () => {
538
- console.log("Desconectado del servidor de sockets");
539
- });
540
-
541
- socketService.value.on("receiveMessage", (data: any) => {
542
- console.log("Mensaje recibido:", data.message, userChat);
543
- const indexMessage = messagesData.value.data.findIndex((msg) => msg.id === data.message.id);
544
-
545
- if (
546
- data.message.sender.msPersonaId === userChat?.chat?.persona?.msPersonaId && indexMessage === -1
547
- )
548
- return;
549
-
550
- if (indexMessage !== -1) {
551
- messagesData.value.data[indexMessage].response = data.message.response;
552
- } else {
553
- messagesData.value.data.push(data.message);
554
- }
555
-
556
- isDisabledBoxMessage.value = !!messagesData.value.data.find((msg) => msg.messageType?.code === MESSAGE_TYPE_CODES.PREGUNTA && msg.response == null);
557
-
558
- setVistoToTrueApi(data.message.appChatId, props.tokenAuth);
559
- scrollToBottom();
560
- !props.visible && emit("new-message");
561
- });
562
- }
563
-
564
- const { isMobile } = useMobile()
565
-
566
- function onQualifying({ message: messageParam, emoji }: { message: Message, emoji: { iconUnicode: string, value: number } }) {
567
- const callback = async () => {
568
- if (!messageParam.id || !emoji) return;
569
-
570
- message.value = emoji.iconUnicode
571
-
572
- submitMessage({
573
- tipoCalificacionId: emoji.value,
574
- mensajeARespondeId: messageParam.id
575
- }).then();
576
- }
577
-
578
- emit('onQualifying', { message: emoji.iconUnicode , callback})
579
- };
580
-
581
- const isDisabledBoxMessage = ref(false);
582
-
583
- // Adjuntar
584
- function onFileSelect() {
585
- inputFiles.value = [];
586
- urlFiles.value = [];
587
-
588
- const filesParam = fileInputRef.value?.files ?? []
589
-
590
- const file = filesParam?.[0];
591
-
592
- inputFiles.value = filesParam;
593
-
594
- if (!file) return;
595
-
596
- urlFiles.value.push(URL.createObjectURL(file))
597
-
598
- currentDialogView.value = DIALOG_VIEWS.UPLOAD;
599
- dialog.title = 'Preparar imagen';
600
- dialog.modelValue = true;
601
-
602
- nextTick(()=>{
603
- textAreaRef.value?.focus()
604
- })
605
- }
606
-
607
- // Dialog
608
-
609
- const inputFiles = ref<(File & { objectURL: string })[]>([]);
610
- const urlFiles = ref<Array<string>>([]);
611
-
612
- const dialog = reactive<IPropsDialog>({
613
- modelValue: false,
614
- 'onUpdate:modelValue': (args) => {
615
- dialog.modelValue = args;
616
-
617
- if (args) return;
618
-
619
- urlFiles.value = [];
620
- inputFiles.value = [];
621
- },
622
- title: 'Preparar imagen'
623
- });
624
-
625
- //
626
-
627
- onMounted(async () => {
628
- if (appChatId.value) return;
629
-
630
- const resp = await getInformationApi(props.tokenAuth);
631
-
632
- if (!resp) return;
633
-
634
- information.value = resp;
635
- appChatId.value = resp.appChat.id;
636
- notViewed.value = resp.appChat.totalNoVistosCliente;
637
- connectMsWebSocket(resp);
638
- getMessages();
639
- emit("not-viewed-total", resp.appChat.totalNoVistosCliente);
640
- });
641
-
642
- onUnmounted(() => {
643
- socketService.value?.off();
644
- });
645
- </script>
646
-
647
- <style scoped>
648
- .btn-primary {
649
- padding: 10px 12px;
650
- color: var(--primary-color);
651
- &:hover {
652
- background-color: rgb(0,0,0, 0.1);
653
- }
654
- }
655
-
656
- .btn-close {
657
- padding: 0;
658
- background-color: transparent;
659
- border: none;
660
- display: flex;
661
- align-items: center;
662
- border-radius: 50%;
663
- &:hover {
664
- background-color: rgba(202, 202, 202, 0.534);
665
- }
666
- }
667
-
668
- .messages-container {
669
- position: relative;
670
- }
671
- .loader {
672
- position: absolute;
673
- top: 18px;
674
- z-index: 5;
675
- left: 50%;
676
- transform: translate(-50%, -50%);
677
- }
678
-
679
- .fit {
680
- width: 100%;
681
- height: 100%;
682
- position: relative;
683
- }
684
- .center {
685
- position: absolute;
686
- top: 50%;
687
- left: 50%;
688
- transform: translate(-50%, -50%);
689
- }
690
-
691
- .message-send-block {
692
- display: block;
693
- margin: 1.5rem;
694
- text-align: center;
695
- }
696
- </style>
1
+ <template>
2
+ <div class="widget">
3
+ <div class="header-widget">
4
+ <h4 class="title-chat">{{ titlePrincipal }}</h4>
5
+ <button @click="() => toggleChat()" class="btn-close">
6
+ <IconClose class="pointer" />
7
+ </button>
8
+ </div>
9
+ <div class="messages-container" ref="messageContainerRef">
10
+ <div class="loader" v-if="isLoading">
11
+ <Loader />
12
+ </div>
13
+ <MessageList
14
+ v-if="messagesData.data.length > 0"
15
+ :messages="messagesData.data"
16
+ :canLoadMoreMessages="messagesData.canLoadMoreMessages"
17
+ @loadMore="getMessages"
18
+ @retry="retryMessage"
19
+ @on-qualifying="(args) => onQualifying(args)"
20
+ @see="(message: Message) => {
21
+ currentDialogView = DIALOG_VIEWS.SEE
22
+ urlFileMessage = message.urlFile
23
+ dialog.title = 'Imagen'
24
+ dialog.modelValue = true
25
+ }"
26
+ />
27
+ <div class="fit" v-else>
28
+ <span class="center">No tienes mensajes</span>
29
+ </div>
30
+ </div>
31
+
32
+ <div class="w-full">
33
+ <form v-if="!isDisabledBoxMessage" class="message-send" @submit.prevent="(event) => submitMessage()">
34
+ <div class="form-message">
35
+ <div class="jl-inputgroup-chat">
36
+ <textarea
37
+ v-model="message"
38
+ class="jl2-input-chat"
39
+ placeholder="Escribe tu mensaje"
40
+ required
41
+ ref="textAreaRef"
42
+ @input="() => autoAdjustHeight()"
43
+ @keydown.enter="
44
+ (event) => {
45
+ !isMobile && event.preventDefault();
46
+ }
47
+ "
48
+ @keyup.enter="saltoDeLineaOEnviar"
49
+ />
50
+
51
+ <input
52
+ type="file"
53
+ ref="fileInputRef"
54
+ @change="onFileSelect"
55
+ accept="image/*"
56
+ style="display: none;"
57
+ key="fileInputKey"
58
+
59
+ />
60
+ <button
61
+ type="button"
62
+ class="pointer btn-primary"
63
+ title="Adjuntar archivo"
64
+ @click="
65
+ () => {
66
+ fileInputRef.value = '';
67
+ fileInputKey++;
68
+ fileInputRef?.click();
69
+ }
70
+ "
71
+ >
72
+ <IconAttach style="width: 20px; height: 20px" />
73
+ </button>
74
+
75
+ <button type="submit" class="pointer btn-primary">
76
+ <IconSend style="width: 20px; height: 20px" />
77
+ </button>
78
+ </div>
79
+ </div>
80
+ </form>
81
+ <span class="message-send-block" v-else>
82
+ Necesitamos que nos califique la atención para continuar
83
+ </span>
84
+ </div>
85
+ </div>
86
+
87
+ <ODialog v-bind="dialog">
88
+ <div v-if="currentDialogView === DIALOG_VIEWS.UPLOAD" class="flex flex-col justify-center items-center">
89
+ <img v-for="(urlFile, i) in urlFiles" :key="i" :src="urlFile.toString()" alt="Image" width="400" />
90
+
91
+ <form
92
+ class="message-send"
93
+ @submit.prevent="
94
+ (event) => {
95
+ submitMessage({ codigoTipoMensaje: MESSAGE_TYPE_CODES.IMAGEN});
96
+ dialog.modelValue = false;
97
+ }
98
+ "
99
+ >
100
+ <div class="form-message">
101
+ <div class="jl-inputgroup-chat">
102
+ <textarea
103
+ v-model="message"
104
+ autofocus
105
+ class="jl2-input-chat"
106
+ placeholder="Escribe tu mensaje"
107
+ ref="textAreaRef"
108
+ @input="() => autoAdjustHeight()"
109
+ @keydown.enter="
110
+ (event) => {
111
+ !isMobile && event.preventDefault();
112
+ }
113
+ "
114
+ @keyup.enter="(event)=>{
115
+ saltoDeLineaOEnviar(event, MESSAGE_TYPE_CODES.IMAGEN)
116
+ }"
117
+ />
118
+
119
+ <button type="submit" class="pointer btn-primary">
120
+ <IconSend style="width: 20px; height: 20px" />
121
+ </button>
122
+ </div>
123
+ </div>
124
+ </form>
125
+ </div>
126
+ <div v-else>
127
+ <img v-if="urlFileMessage" :src="urlFileMessage" alt="Image" style="width: 55vw;" />
128
+ </div>
129
+ </ODialog>
130
+ </template>
131
+
132
+ <script setup lang="ts">
133
+ import {
134
+ ref,
135
+ onMounted,
136
+ nextTick,
137
+ PropType,
138
+ watch,
139
+ onUnmounted,
140
+ reactive,
141
+ } from "vue";
142
+ import { v4 as uuidv4 } from "uuid";
143
+
144
+ import {
145
+ type SendMessageBody,
146
+ ChatInformation,
147
+ ListMessageBody,
148
+ Message,
149
+ } from "../dto/app.dto";
150
+ import IconClose from "./IconClose.vue";
151
+ import IconSend from "./IconSend.vue";
152
+ import {
153
+ getMessagesApi,
154
+ sendMessageApi,
155
+ setVistoToTrueApi,
156
+ updateMessageApi,
157
+ } from "../store/index";
158
+ import { getInformationApi } from "../store";
159
+ import MessageList from "./MessageList.vue";
160
+ import Loader from "./Loader.vue";
161
+ import { searchFromLast } from "../resources/functions.helpers";
162
+ import { io, Socket } from "socket.io-client";
163
+ import { APP_TYPE } from "../dto/chat.dto";
164
+ import { MESSAGE_TYPE_CODES, TypeMessageTypeCodes } from "../resources/constants/message-type.constant";
165
+ import { useMobile } from "../hooks/useMobile";
166
+ import IconAttach from "./IconAttach.vue";
167
+ import ODialog from "./ODialog/ODialog.vue";
168
+ import { IPropsDialog } from "./ODialog/IPropsDialog";
169
+
170
+ const enum DIALOG_VIEWS {
171
+ UPLOAD,
172
+ SEE
173
+ }
174
+
175
+ //DATA
176
+ const message = ref("");
177
+ const notViewed = ref(0);
178
+ const fileInputRef = ref();
179
+ const fileInputKey = ref(0);
180
+ const currentDialogView = ref(DIALOG_VIEWS.SEE)
181
+ const urlFileMessage = ref<string>()
182
+
183
+ const messagesData = ref<{ data: Message[]; canLoadMoreMessages: boolean }>({
184
+ data: [],
185
+ canLoadMoreMessages: false,
186
+ });
187
+
188
+ const appChatId = ref("");
189
+ const isLoading = ref(false);
190
+
191
+ const emit = defineEmits([
192
+ "show-toast",
193
+ "show-confirm",
194
+ "new-message",
195
+ "clear-new-messages",
196
+ "not-viewed-total",
197
+ "onQualifying",
198
+ ]);
199
+
200
+ const props = defineProps({
201
+ titlePrincipal: {
202
+ type: String,
203
+ default: "Comunicación en linea para consultas",
204
+ },
205
+ toggleChat: { type: Function, required: true },
206
+ tokenAuth: { type: String, required: true },
207
+ user: {
208
+ type: Object as PropType<{
209
+ nombreCompleto: string;
210
+ ci: string;
211
+ msPersonaId: number;
212
+ }>,
213
+ required: true,
214
+ },
215
+ visible: { type: Boolean, required: true },
216
+ });
217
+
218
+ const messageContainerRef = ref<HTMLElement | null>(null);
219
+
220
+ watch(
221
+ () => props.visible,
222
+ async (current) => {
223
+ if (!current) return;
224
+
225
+ emit("clear-new-messages");
226
+ scrollToBottom();
227
+
228
+ if (notViewed.value > 0) {
229
+ setVistoToTrueApi(appChatId.value, props.tokenAuth);
230
+ }
231
+
232
+ if (appChatId.value) return;
233
+
234
+ const resp = await getInformationApi(props.tokenAuth);
235
+
236
+ if (resp) {
237
+ appChatId.value = resp.appChat.id;
238
+ }
239
+ }
240
+ );
241
+
242
+ function saltoDeLineaOEnviar(event: KeyboardEvent, messageTypeCode?: TypeMessageTypeCodes) {
243
+ if(isMobile.value) {
244
+ autoAdjustHeight();
245
+ return;
246
+ }
247
+
248
+ if (event.key === "Enter" && event.shiftKey) {
249
+ const val = (event.target as any)?.value || "";
250
+ (event.target as any).value = val + "\n";
251
+ autoAdjustHeight();
252
+ return;
253
+ }
254
+
255
+ submitMessage({ codigoTipoMensaje: messageTypeCode });
256
+ dialog.modelValue = false;
257
+ }
258
+
259
+ function createMessage(message: string, codigoTipoMensaje?: TypeMessageTypeCodes) {
260
+ if (message?.length > 300) {
261
+ emit("show-toast", {
262
+ severity: "warn",
263
+ summary: "Error",
264
+ detail: "El mensaje no puede superar los 300 caracteres",
265
+ life: 5000,
266
+ });
267
+ return;
268
+ }
269
+
270
+ if (!message.trim() && codigoTipoMensaje !== MESSAGE_TYPE_CODES.IMAGEN) {
271
+ emit("show-toast", {
272
+ severity: "warn",
273
+ summary: "Error",
274
+ detail: "Por favor ingrese un mensaje",
275
+ life: 5000,
276
+ });
277
+ return;
278
+ }
279
+
280
+ const messageType = codigoTipoMensaje
281
+ ? {
282
+ code: codigoTipoMensaje,
283
+ }
284
+ : undefined;
285
+
286
+ const newMessage: Message = {
287
+ id: uuidv4(),
288
+ chatId: information.value?.chat?.id,
289
+ message,
290
+ visto: true,
291
+ multimedia: false,
292
+ esCliente: true,
293
+ appChatId: appChatId.value,
294
+ createdAt: new Date().toISOString(),
295
+ updatedAt: new Date().toISOString(),
296
+ file: inputFiles.value?.[0],
297
+ urlFile: urlFiles.value?.[0],
298
+ messageType,
299
+ sender: {
300
+ nombreCompleto: props.user.nombreCompleto,
301
+ ci: props.user.ci,
302
+ msPersonaId: props.user.msPersonaId,
303
+ },
304
+ response: undefined
305
+ };
306
+
307
+ return newMessage
308
+ }
309
+
310
+ function updateMessageStatus(
311
+ newMessage: Message,
312
+ idxMessageToCommunicate?: number,
313
+ messageSaved?: Message,
314
+ tipoCalificacionId?: number
315
+ ) {
316
+ if (idxMessageToCommunicate == null) throw new Error('idx is required')
317
+
318
+ if (!messageSaved) {
319
+ if (tipoCalificacionId) {
320
+ const messageAResponder = messagesData.value.data[idxMessageToCommunicate]
321
+ messageAResponder.response = undefined
322
+ } else {
323
+ messagesData.value.data[idxMessageToCommunicate].error = {
324
+ error: true,
325
+ id: newMessage.id,
326
+ };
327
+ }
328
+
329
+ emit("show-toast", {
330
+ severity: "error",
331
+ summary: "Error",
332
+ detail: "Ocurrio un error al enviar el mensaje, intente nuevamente",
333
+ life: 5000,
334
+ });
335
+
336
+ return
337
+ }
338
+
339
+ let messageUpdated = { ...messageSaved, chatId: newMessage?.chatId }
340
+
341
+ if (tipoCalificacionId) {
342
+ const messageAResponder = messagesData.value.data[idxMessageToCommunicate]
343
+ messageAResponder.response = messageSaved
344
+
345
+ messageUpdated = { ...messageAResponder, chatId: newMessage?.chatId }
346
+ } else {
347
+ messagesData.value.data[idxMessageToCommunicate] = messageUpdated;
348
+ }
349
+
350
+ return messageUpdated;
351
+ }
352
+
353
+ const submitMessage = async (
354
+ messageExtraData?: { mensajeARespondeId?: string, tipoCalificacionId?: number, codigoTipoMensaje?: TypeMessageTypeCodes}
355
+ ) => {
356
+
357
+ const newMessage = createMessage(message.value, messageExtraData?.codigoTipoMensaje);
358
+ message.value = '';
359
+ if (!newMessage) return;
360
+
361
+ let idxMessageToCommunicate = -1;
362
+ if (messageExtraData?.tipoCalificacionId) {
363
+ idxMessageToCommunicate = messagesData.value.data.findIndex((val) => val.id === messageExtraData?.mensajeARespondeId )
364
+ const messageAResponder = messagesData.value.data[idxMessageToCommunicate]
365
+
366
+ if ( messageAResponder ) {
367
+ messageAResponder.response = newMessage
368
+ }
369
+ } else {
370
+ idxMessageToCommunicate = messagesData.value.data.push(newMessage) - 1;
371
+ }
372
+
373
+ const body = {
374
+ message: message.value,
375
+ appChatId: appChatId.value,
376
+ ...messageExtraData,
377
+ codigoTipoMensaje: messageExtraData?.codigoTipoMensaje,
378
+ files: inputFiles.value,
379
+ }
380
+ sendApi(body).then((messageSaved) => {
381
+ let messageUpdated = updateMessageStatus(
382
+ newMessage,
383
+ idxMessageToCommunicate,
384
+ messageSaved,
385
+ messageExtraData?.tipoCalificacionId
386
+ )
387
+
388
+ if(!messageUpdated) return
389
+
390
+ isDisabledBoxMessage.value = !!messagesData.value.data.find((msg) => msg.messageType?.code === MESSAGE_TYPE_CODES.PREGUNTA && msg.response == null);
391
+
392
+ socketService.value?.emit(
393
+ "sendMessage",
394
+ { roomId: information?.value?.appChat.id, message: messageUpdated },
395
+ (response: any) => {
396
+ console.log("🚀 ~ socketService.value.emit ~ response:", response);
397
+ }
398
+ );
399
+ });
400
+
401
+ message.value = "";
402
+ scrollToBottom();
403
+ textAreaRef.value && (textAreaRef.value.style.height = "20px");
404
+ };
405
+
406
+ const sendApi = async (bodyParam: Omit<SendMessageBody, 'esCliente'>) => {
407
+ const body: SendMessageBody = {
408
+ ...bodyParam,
409
+ esCliente: true,
410
+ };
411
+ return sendMessageApi(body, props.tokenAuth);
412
+ };
413
+
414
+ const getMessages = async () => {
415
+ const lastMessagesId = messagesData.value.data[0]?.id;
416
+ const body: ListMessageBody = {
417
+ lastMessagesId,
418
+ appChatId: appChatId.value,
419
+ limit: 10,
420
+ };
421
+
422
+ isLoading.value = true;
423
+ const resp = await getMessagesApi({ body, token: props.tokenAuth });
424
+ isLoading.value = false;
425
+
426
+ isDisabledBoxMessage.value = !!resp.data.find((msg) => msg.messageType?.code === MESSAGE_TYPE_CODES.PREGUNTA && msg.response == null);
427
+
428
+ messagesData.value.data.unshift(
429
+ ...resp.data.sort((a, b) => -b.createdAt.localeCompare(a.createdAt))
430
+ );
431
+
432
+ messagesData.value.canLoadMoreMessages =
433
+ resp.pagination.total > resp.pagination.size;
434
+
435
+ if (lastMessagesId && messageContainerRef.value?.scrollHeight) {
436
+ mantainElementsOnViewport(messageContainerRef.value?.scrollHeight);
437
+ }
438
+
439
+ if (!lastMessagesId) scrollToBottom();
440
+ };
441
+
442
+ const retryMessage = async (message: Message) => {
443
+ emit("show-confirm", async () => {
444
+ if (!message.error?.id) return;
445
+
446
+ const msg = await sendApi({
447
+ message: message.message,
448
+ appChatId: appChatId.value
449
+ });
450
+
451
+ if (!msg) {
452
+ emit("show-toast", {
453
+ severity: "error",
454
+ summary: "Error",
455
+ detail: "Ocurrio un error al enviar el mensaje, intente nuevamente",
456
+ life: 5000,
457
+ });
458
+ } else {
459
+ const idx = searchFromLast<Message>(
460
+ messagesData.value.data,
461
+ "id",
462
+ message.error.id
463
+ );
464
+
465
+ messagesData.value.data[idx] = { ...msg, error: undefined };
466
+
467
+ socketService.value?.emit("sendMessage", {
468
+ roomId: information?.value?.appChat.id,
469
+ message,
470
+ });
471
+ }
472
+
473
+ scrollToBottom();
474
+ });
475
+ };
476
+
477
+ const scrollToBottom = () => {
478
+ nextTick(() => {
479
+ if (messageContainerRef.value) {
480
+ messageContainerRef.value.scrollTop =
481
+ messageContainerRef.value.scrollHeight;
482
+ }
483
+ });
484
+ };
485
+
486
+ const mantainElementsOnViewport = (scrollHeightBeforeAdd: number) => {
487
+ nextTick(() => {
488
+ const objDiv = messageContainerRef.value;
489
+ if (objDiv) {
490
+ objDiv.scrollTop = objDiv.scrollHeight - scrollHeightBeforeAdd;
491
+ }
492
+ });
493
+ };
494
+
495
+ const textAreaRef = ref<HTMLTextAreaElement>();
496
+
497
+ const fontSpace = 14;
498
+
499
+ function autoAdjustHeight() {
500
+ if (!textAreaRef.value) return;
501
+
502
+ textAreaRef.value.style.height =
503
+ textAreaRef.value.scrollHeight - fontSpace + "px";
504
+
505
+ if (textAreaRef.value.scrollHeight === textAreaRef.value.clientHeight)
506
+ return;
507
+
508
+ textAreaRef.value.style.height = textAreaRef.value.scrollHeight + "px";
509
+ }
510
+
511
+ const socketService = ref<Socket>();
512
+ const information = ref<ChatInformation>();
513
+
514
+ function connectMsWebSocket(
515
+ userChat: ChatInformation,
516
+ app: APP_TYPE = APP_TYPE.WEBCHAT
517
+ ) {
518
+ if (!userChat) throw new Error("user chat is required");
519
+
520
+ socketService.value = io(window.VITE_SOCKET_URI, {
521
+ query: {
522
+ // TODO: confirmar si se quita o no
523
+ usuarioId: `${userChat?.chat?.persona?.funcionarioId}`,
524
+ aplicacion: app,
525
+ },
526
+ extraHeaders: { Authorization: props.tokenAuth },
527
+ });
528
+
529
+ socketService.value.removeAllListeners();
530
+
531
+ socketService.value.on("connect", () => {
532
+ console.log("Conectado al servidor de sockets");
533
+
534
+ socketService.value?.emit("joinRoom", `${userChat?.appChat?.id}`);
535
+ });
536
+
537
+ socketService.value.on("disconnect", () => {
538
+ console.log("Desconectado del servidor de sockets");
539
+ });
540
+
541
+ socketService.value.on("receiveMessage", (data: any) => {
542
+ console.log("Mensaje recibido:", data.message, userChat);
543
+ const indexMessage = messagesData.value.data.findIndex((msg) => msg.id === data.message.id);
544
+
545
+ if (
546
+ data.message.sender.msPersonaId === userChat?.chat?.persona?.msPersonaId && indexMessage === -1
547
+ )
548
+ return;
549
+
550
+ if (indexMessage !== -1) {
551
+ messagesData.value.data[indexMessage].response = data.message.response;
552
+ } else {
553
+ messagesData.value.data.push(data.message);
554
+ }
555
+
556
+ isDisabledBoxMessage.value = !!messagesData.value.data.find((msg) => msg.messageType?.code === MESSAGE_TYPE_CODES.PREGUNTA && msg.response == null);
557
+
558
+ setVistoToTrueApi(data.message.appChatId, props.tokenAuth);
559
+ scrollToBottom();
560
+ !props.visible && emit("new-message");
561
+ });
562
+ }
563
+
564
+ const { isMobile } = useMobile()
565
+
566
+ function onQualifying({ message: messageParam, emoji }: { message: Message, emoji: { iconUnicode: string, value: number } }) {
567
+ const callback = async () => {
568
+ if (!messageParam.id || !emoji) return;
569
+
570
+ message.value = emoji.iconUnicode
571
+
572
+ submitMessage({
573
+ tipoCalificacionId: emoji.value,
574
+ mensajeARespondeId: messageParam.id
575
+ }).then();
576
+ }
577
+
578
+ emit('onQualifying', { message: emoji.iconUnicode , callback})
579
+ };
580
+
581
+ const isDisabledBoxMessage = ref(false);
582
+
583
+ // Adjuntar
584
+ function onFileSelect() {
585
+ inputFiles.value = [];
586
+ urlFiles.value = [];
587
+
588
+ const filesParam = fileInputRef.value?.files ?? []
589
+
590
+ const file = filesParam?.[0];
591
+
592
+ inputFiles.value = filesParam;
593
+
594
+ if (!file) return;
595
+
596
+ if (!(['image/png','image/jpeg','image/jpg','image/webp'].includes(file.type))) {
597
+ emit('show-toast', {
598
+ severity: "warn",
599
+ summary: "Error",
600
+ detail: "El archivo debe ser una imagen permitida (png, jpeg, jpg, webp)",
601
+ life: 5000,
602
+ })
603
+ return
604
+ }
605
+
606
+ if (file.size > 2e+6) {
607
+ emit('show-toast', {
608
+ severity: "warn",
609
+ summary: "Error",
610
+ detail: "El archivo excede los 2MB",
611
+ life: 5000,
612
+ })
613
+ return
614
+ }
615
+
616
+ urlFiles.value.push(URL.createObjectURL(file))
617
+
618
+ currentDialogView.value = DIALOG_VIEWS.UPLOAD;
619
+ dialog.title = 'Preparar imagen';
620
+ dialog.modelValue = true;
621
+
622
+ nextTick(()=>{
623
+ textAreaRef.value?.focus()
624
+ })
625
+ }
626
+
627
+ // Dialog
628
+
629
+ const inputFiles = ref<(File & { objectURL: string })[]>([]);
630
+ const urlFiles = ref<Array<string>>([]);
631
+
632
+ const dialog = reactive<IPropsDialog>({
633
+ modelValue: false,
634
+ 'onUpdate:modelValue': (args) => {
635
+ dialog.modelValue = args;
636
+
637
+ if (args) return;
638
+
639
+ urlFiles.value = [];
640
+ inputFiles.value = [];
641
+ },
642
+ title: 'Preparar imagen'
643
+ });
644
+
645
+ //
646
+
647
+ onMounted(async () => {
648
+ if (appChatId.value) return;
649
+
650
+ const resp = await getInformationApi(props.tokenAuth);
651
+
652
+ if (!resp) return;
653
+
654
+ information.value = resp;
655
+ appChatId.value = resp.appChat.id;
656
+ notViewed.value = resp.appChat.totalNoVistosCliente;
657
+ connectMsWebSocket(resp);
658
+ getMessages();
659
+ emit("not-viewed-total", resp.appChat.totalNoVistosCliente);
660
+ });
661
+
662
+ onUnmounted(() => {
663
+ socketService.value?.off();
664
+ });
665
+ </script>
666
+
667
+ <style scoped>
668
+ .btn-primary {
669
+ padding: 10px 12px;
670
+ color: var(--primary-color);
671
+ &:hover {
672
+ background-color: rgb(0,0,0, 0.1);
673
+ }
674
+ }
675
+
676
+ .btn-close {
677
+ padding: 0;
678
+ background-color: transparent;
679
+ border: none;
680
+ display: flex;
681
+ align-items: center;
682
+ border-radius: 50%;
683
+ &:hover {
684
+ background-color: rgba(202, 202, 202, 0.534);
685
+ }
686
+ }
687
+
688
+ .messages-container {
689
+ position: relative;
690
+ }
691
+ .loader {
692
+ position: absolute;
693
+ top: 18px;
694
+ z-index: 5;
695
+ left: 50%;
696
+ transform: translate(-50%, -50%);
697
+ }
698
+
699
+ .fit {
700
+ width: 100%;
701
+ height: 100%;
702
+ position: relative;
703
+ }
704
+ .center {
705
+ position: absolute;
706
+ top: 50%;
707
+ left: 50%;
708
+ transform: translate(-50%, -50%);
709
+ }
710
+
711
+ .message-send-block {
712
+ display: block;
713
+ margin: 1.5rem;
714
+ text-align: center;
715
+ }
716
+ </style>