@adatechnology/conversations-ui 0.1.0-rc.42 → 0.1.0-rc.44
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/dist/index.d.ts +40 -1
- package/dist/index.js +57 -6
- package/package.json +1 -1
- package/src/ConversationListItem.tsx +5 -4
- package/src/ConversationRow.tsx +22 -0
- package/src/MessageText.tsx +2 -1
- package/src/MessageTimestamp.tsx +2 -1
- package/src/index.ts +11 -0
- package/src/replyLatency.test.ts +71 -0
- package/src/replyLatency.ts +57 -0
- package/src/theme.ts +13 -0
package/dist/index.d.ts
CHANGED
|
@@ -545,6 +545,45 @@ type WindowOfParams = {
|
|
|
545
545
|
declare function windowOf(params: WindowOfParams): ConversationWindow;
|
|
546
546
|
declare function formatStalledFor(lastAt: string, now: number): string;
|
|
547
547
|
|
|
548
|
+
/**
|
|
549
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
550
|
+
*
|
|
551
|
+
* Há quanto tempo o cliente espera resposta.
|
|
552
|
+
*
|
|
553
|
+
* Não confundir com `conversationWindow`: aquilo é a janela de sessão da plataforma (o que o canal
|
|
554
|
+
* ainda deixa enviar), isto é serviço (quanto o cliente esperou). Os dois divergem justamente no
|
|
555
|
+
* caso que interessa — respondida a conversa, o relógio do SLA para e o da janela continua correndo.
|
|
556
|
+
*
|
|
557
|
+
* A conta sai de dados que a listagem já traz: se a última mensagem foi do cliente, ninguém
|
|
558
|
+
* respondeu ainda e a espera conta desde ela. Se a última foi nossa, não há espera pendente.
|
|
559
|
+
*/
|
|
560
|
+
declare const REPLY_LATENCY: {
|
|
561
|
+
/** Até 6h — dentro do combinado. */
|
|
562
|
+
readonly WITHIN: "within";
|
|
563
|
+
/** 6h a 12h — passou do combinado. */
|
|
564
|
+
readonly LATE: "late";
|
|
565
|
+
/** Acima de 12h. */
|
|
566
|
+
readonly CRITICAL: "critical";
|
|
567
|
+
};
|
|
568
|
+
type ReplyLatency = (typeof REPLY_LATENCY)[keyof typeof REPLY_LATENCY];
|
|
569
|
+
declare const REPLY_LATENCY_LATE_HOURS = 6;
|
|
570
|
+
declare const REPLY_LATENCY_CRITICAL_HOURS = 12;
|
|
571
|
+
type ReplyLatencyParams = {
|
|
572
|
+
/** Direção da última mensagem. Ausente = desconhecida, e aí não se afirma espera. */
|
|
573
|
+
readonly lastDirection?: 'inbound' | 'outbound' | undefined;
|
|
574
|
+
readonly lastInboundAt: string | null;
|
|
575
|
+
readonly now: number;
|
|
576
|
+
};
|
|
577
|
+
/**
|
|
578
|
+
* `null` quando não há espera a mostrar — conversa já respondida, ou cliente que nunca escreveu.
|
|
579
|
+
*
|
|
580
|
+
* Devolver uma faixa nesses casos encheria a lista de selos em conversa que não deve nada, e o
|
|
581
|
+
* alerta que aponta para todo lado não aponta para lugar nenhum.
|
|
582
|
+
*/
|
|
583
|
+
declare function replyLatencyOf(params: ReplyLatencyParams): ReplyLatency | null;
|
|
584
|
+
/** Só o que passou do combinado merece alerta — é o corte que a lista usa para destacar. */
|
|
585
|
+
declare function isReplyOverdue(latency: ReplyLatency | null): boolean;
|
|
586
|
+
|
|
548
587
|
type ConversationRowClassNames = {
|
|
549
588
|
root: string;
|
|
550
589
|
windowBar: string;
|
|
@@ -1880,4 +1919,4 @@ interface ConversationsInboxListProps {
|
|
|
1880
1919
|
}
|
|
1881
1920
|
declare function ConversationsInboxList({ inbox, labels, className, renderFilters, renderBulkActions, renderRow, onSendTemplateToSelected, }: ConversationsInboxListProps): react.JSX.Element;
|
|
1882
1921
|
|
|
1883
|
-
export { type AsyncResourceState, AudioPlayer, type AudioPlayerProps, AudioTranscription, type AudioTranscriptionProps, Avatar, type AvatarLabels, type AvatarProps, type BotMessages, type BuildTranscriptTextParams, BulkActionBar, type BulkActionBarProps, CHANNEL_BRAND_COLOR, CONVERSATIONS_PER_PAGE, CONVERSATION_WINDOW, ChannelFilter, ChannelFilterOption, ChannelIcon, type ChannelIconProps, ConversationChannel, type ConversationContextEntry, ConversationContextPanel, type ConversationContextPanelClassNames, type ConversationContextPanelLabels, type ConversationContextPanelProps, type ConversationContextStatus, ConversationDocument, ConversationDocumentsPanel, type ConversationDocumentsPanelClassNames, type ConversationDocumentsPanelLabels, type ConversationDocumentsPanelProps, ConversationHeader, type ConversationHeaderClassNames, type ConversationHeaderLabels, type ConversationHeaderProps, type ConversationHeaderUtility, ConversationListItem, type ConversationListItemLabels, type ConversationListItemProps, type ConversationLocales, ConversationLocalesProvider, type ConversationLocalesProviderProps, ConversationPane, type ConversationPaneProps, type ConversationRealtimeHandler, ConversationRow, type ConversationRowClassNames, type ConversationRowProps, ConversationSimulatorClient, ConversationSummary, ConversationTemplate, ConversationWallpaper, type ConversationWallpaperProps, type ConversationWindow, ConversationsApi, ConversationsFeatures, ConversationsInboxList, type ConversationsInboxListProps, ConversationsProvider, ConversationsWorkspace, type ConversationsWorkspaceLabels, type ConversationsWorkspaceProps, type ConversationsWorkspaceSimulator, DEFAULT_ACCEPTED_FILE_TYPES, DEFAULT_AVATAR_LABELS, DEFAULT_CONVERSATIONS_WORKSPACE_LABELS, DEFAULT_CONVERSATION_CONTEXT_LABELS, DEFAULT_CONVERSATION_DOCUMENTS_LABELS, DEFAULT_CONVERSATION_HEADER_LABELS, DEFAULT_CONVERSATION_LIST_ITEM_LABELS, DEFAULT_DARK_MODE_TOGGLE_LABELS, DEFAULT_DOCUMENTS_LIBRARY_LABELS, DEFAULT_DOCUMENTS_WORKSPACE_LABELS, DEFAULT_EMOJI_PICKER_LABELS, DEFAULT_INTERACTIVE_MESSAGE_LABELS, DEFAULT_LIGHTBOX_LABELS, DEFAULT_MESSAGE_COMPOSER_LABELS, DEFAULT_RICH_COMPOSER_TOOLTIPS, DEFAULT_TEMPLATES_SETTINGS_LABELS, DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS, DEFAULT_WINDOW_EXPIRED_LABELS, DOCUMENT_SOURCE_FILTER, DarkModeToggle, type DarkModeToggleLabels, type DarkModeToggleProps, DateDivider, type DateDividerClassNames, type DateDividerProps, type DocumentSourceFilter, type DocumentsFiltersContext, DocumentsLibrary, type DocumentsLibraryClassNames, type DocumentsLibraryLabels, type DocumentsLibraryProps, DocumentsWorkspace, type DocumentsWorkspaceClassNames, type DocumentsWorkspaceLabels, type DocumentsWorkspaceProps, EMOJI_CATEGORIES, type EmojiCategory, type EmojiEntry, EmojiPicker, type EmojiPickerLabels, type EmojiPickerProps, FileIcon, type FileIconProps, type FilterOption, InteractiveMessage, type InteractiveMessageLabels, type InteractiveMessageProps, InteractivePayload, InteractiveSelection, Lightbox, type LightboxLabels, type LightboxProps, ListConversationsParams, ListDocumentsParams, ListingPagination, type ListingPaginationProps, MessageBubble, type MessageBubbleProps, MessageComposer, type MessageComposerClassNames, type MessageComposerLabels, type MessageComposerProps, MessagePayload, MessageTail, MessageText, type MessageTextProps, MessageTimestamp, MessageTranscription, MessagesWorkspace, type MessagesWorkspaceApi, type MessagesWorkspaceLabels, type MessagesWorkspaceProps, type MessagesWorkspaceTemplateRole, MultiSelectFilter, type MultiSelectFilterProps, NARROW_MAX_WIDTH_PX, type QuickReply, RICH_COMPOSER_ACTION, ResolveMediaUrl, type RichComposerAction, type RichComposerQuickReply, type RichComposerTooltips, type RichComposerVariable, RichMessageComposer, type RichMessageComposerHandle, type RichMessageComposerProps, SSEProvider, SimpleEmojiPicker, type SimpleEmojiPickerProps, type SimulatorTransportFactory, type SimulatorTransportParams, type SortDirection, SortableHead, type SortableHeadProps, StatusTicks, type StatusTicksProps, TEMPLATE_SETTINGS_TAB, TOOLTIP_ATTRIBUTE, type TemplateSettings, type TemplateSettingsTab, ToastProvider, TooltipLayer, type TopicItem, TopicsForm, type TopicsFormLabels, type TopicsFormProps, TranscriptionMode, type TranscriptionSettings, TranscriptionSettingsForm, type TranscriptionSettingsFormLabels, type TranscriptionSettingsFormProps, type UrlStateOptions, type UseConversationActionsResult, type UseConversationContextResult, type UseConversationDocumentsParams, type UseConversationDocumentsResult, type UseConversationListParams, type UseConversationListResult, type UseConversationMessagesResult, type UseConversationsInboxParams, type UseConversationsInboxResult, type UseInboxActionsResult, type UseScrollToLatestMessageParams, type UseScrollToLatestMessageResult, type UseWaitingNotificationsLabels, type UseWaitingNotificationsParams, type UseWaitingNotificationsResult, WINDOW_FILTERS, WelcomeFarewellForm, type WelcomeFarewellFormLabels, type WelcomeFarewellFormProps, WhatsAppCreateTemplateForm, type WhatsAppCreateTemplateFormLabels, type WhatsAppCreateTemplateFormProps, type WhatsAppCreateTemplateResult, type WhatsAppCreateTemplateState, WhatsAppMessageEditor, type WhatsAppMessageEditorLabels, type WhatsAppMessageEditorProps, type WhatsAppTemplateHeaderType, WhatsAppTemplateSettingsForm, type WhatsAppTemplateSettingsFormLabels, type WhatsAppTemplateSettingsFormProps, type WhatsAppTemplateSummary, type WhatsAppTemplateVariableSuggestion, WhatsAppTemplatesSettings, type WhatsAppTemplatesSettingsLabels, type WhatsAppTemplatesSettingsProps, WindowExpiredNotice, type WindowExpiredNoticeLabels, type WindowExpiredNoticeProps, type WindowOfParams, applyQuickReplyVariables, buildTranscriptFilename, buildTranscriptText, createMediaUrlResolver, downloadTextFile, formatDateTime, formatFileSize, formatPhone, formatStalledFor, formatTimestamp, htmlToWA, isSameDay, isWindowBlocking, parseWhatsAppFormatting, phoneInitials, resolveQuickReply, searchEmojis, toast, useConversationActions, useConversationContext, useConversationDocuments, useConversationList, useConversationLocales, useConversationMessages, useConversationRealtime, useConversations, useConversationsInbox, useDarkMode, useDebouncedValue, useGlobalRealtime, useInboxActions, useIsDarkTheme, useIsNarrow, useScrollToLatestMessage, useToast, useUrlArrayState, useUrlNumberState, useUrlStringState, useWaitingNotifications, waToHTML, waToHTMLInline, windowOf };
|
|
1922
|
+
export { type AsyncResourceState, AudioPlayer, type AudioPlayerProps, AudioTranscription, type AudioTranscriptionProps, Avatar, type AvatarLabels, type AvatarProps, type BotMessages, type BuildTranscriptTextParams, BulkActionBar, type BulkActionBarProps, CHANNEL_BRAND_COLOR, CONVERSATIONS_PER_PAGE, CONVERSATION_WINDOW, ChannelFilter, ChannelFilterOption, ChannelIcon, type ChannelIconProps, ConversationChannel, type ConversationContextEntry, ConversationContextPanel, type ConversationContextPanelClassNames, type ConversationContextPanelLabels, type ConversationContextPanelProps, type ConversationContextStatus, ConversationDocument, ConversationDocumentsPanel, type ConversationDocumentsPanelClassNames, type ConversationDocumentsPanelLabels, type ConversationDocumentsPanelProps, ConversationHeader, type ConversationHeaderClassNames, type ConversationHeaderLabels, type ConversationHeaderProps, type ConversationHeaderUtility, ConversationListItem, type ConversationListItemLabels, type ConversationListItemProps, type ConversationLocales, ConversationLocalesProvider, type ConversationLocalesProviderProps, ConversationPane, type ConversationPaneProps, type ConversationRealtimeHandler, ConversationRow, type ConversationRowClassNames, type ConversationRowProps, ConversationSimulatorClient, ConversationSummary, ConversationTemplate, ConversationWallpaper, type ConversationWallpaperProps, type ConversationWindow, ConversationsApi, ConversationsFeatures, ConversationsInboxList, type ConversationsInboxListProps, ConversationsProvider, ConversationsWorkspace, type ConversationsWorkspaceLabels, type ConversationsWorkspaceProps, type ConversationsWorkspaceSimulator, DEFAULT_ACCEPTED_FILE_TYPES, DEFAULT_AVATAR_LABELS, DEFAULT_CONVERSATIONS_WORKSPACE_LABELS, DEFAULT_CONVERSATION_CONTEXT_LABELS, DEFAULT_CONVERSATION_DOCUMENTS_LABELS, DEFAULT_CONVERSATION_HEADER_LABELS, DEFAULT_CONVERSATION_LIST_ITEM_LABELS, DEFAULT_DARK_MODE_TOGGLE_LABELS, DEFAULT_DOCUMENTS_LIBRARY_LABELS, DEFAULT_DOCUMENTS_WORKSPACE_LABELS, DEFAULT_EMOJI_PICKER_LABELS, DEFAULT_INTERACTIVE_MESSAGE_LABELS, DEFAULT_LIGHTBOX_LABELS, DEFAULT_MESSAGE_COMPOSER_LABELS, DEFAULT_RICH_COMPOSER_TOOLTIPS, DEFAULT_TEMPLATES_SETTINGS_LABELS, DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS, DEFAULT_WINDOW_EXPIRED_LABELS, DOCUMENT_SOURCE_FILTER, DarkModeToggle, type DarkModeToggleLabels, type DarkModeToggleProps, DateDivider, type DateDividerClassNames, type DateDividerProps, type DocumentSourceFilter, type DocumentsFiltersContext, DocumentsLibrary, type DocumentsLibraryClassNames, type DocumentsLibraryLabels, type DocumentsLibraryProps, DocumentsWorkspace, type DocumentsWorkspaceClassNames, type DocumentsWorkspaceLabels, type DocumentsWorkspaceProps, EMOJI_CATEGORIES, type EmojiCategory, type EmojiEntry, EmojiPicker, type EmojiPickerLabels, type EmojiPickerProps, FileIcon, type FileIconProps, type FilterOption, InteractiveMessage, type InteractiveMessageLabels, type InteractiveMessageProps, InteractivePayload, InteractiveSelection, Lightbox, type LightboxLabels, type LightboxProps, ListConversationsParams, ListDocumentsParams, ListingPagination, type ListingPaginationProps, MessageBubble, type MessageBubbleProps, MessageComposer, type MessageComposerClassNames, type MessageComposerLabels, type MessageComposerProps, MessagePayload, MessageTail, MessageText, type MessageTextProps, MessageTimestamp, MessageTranscription, MessagesWorkspace, type MessagesWorkspaceApi, type MessagesWorkspaceLabels, type MessagesWorkspaceProps, type MessagesWorkspaceTemplateRole, MultiSelectFilter, type MultiSelectFilterProps, NARROW_MAX_WIDTH_PX, type QuickReply, REPLY_LATENCY, REPLY_LATENCY_CRITICAL_HOURS, REPLY_LATENCY_LATE_HOURS, RICH_COMPOSER_ACTION, type ReplyLatency, type ReplyLatencyParams, ResolveMediaUrl, type RichComposerAction, type RichComposerQuickReply, type RichComposerTooltips, type RichComposerVariable, RichMessageComposer, type RichMessageComposerHandle, type RichMessageComposerProps, SSEProvider, SimpleEmojiPicker, type SimpleEmojiPickerProps, type SimulatorTransportFactory, type SimulatorTransportParams, type SortDirection, SortableHead, type SortableHeadProps, StatusTicks, type StatusTicksProps, TEMPLATE_SETTINGS_TAB, TOOLTIP_ATTRIBUTE, type TemplateSettings, type TemplateSettingsTab, ToastProvider, TooltipLayer, type TopicItem, TopicsForm, type TopicsFormLabels, type TopicsFormProps, TranscriptionMode, type TranscriptionSettings, TranscriptionSettingsForm, type TranscriptionSettingsFormLabels, type TranscriptionSettingsFormProps, type UrlStateOptions, type UseConversationActionsResult, type UseConversationContextResult, type UseConversationDocumentsParams, type UseConversationDocumentsResult, type UseConversationListParams, type UseConversationListResult, type UseConversationMessagesResult, type UseConversationsInboxParams, type UseConversationsInboxResult, type UseInboxActionsResult, type UseScrollToLatestMessageParams, type UseScrollToLatestMessageResult, type UseWaitingNotificationsLabels, type UseWaitingNotificationsParams, type UseWaitingNotificationsResult, WINDOW_FILTERS, WelcomeFarewellForm, type WelcomeFarewellFormLabels, type WelcomeFarewellFormProps, WhatsAppCreateTemplateForm, type WhatsAppCreateTemplateFormLabels, type WhatsAppCreateTemplateFormProps, type WhatsAppCreateTemplateResult, type WhatsAppCreateTemplateState, WhatsAppMessageEditor, type WhatsAppMessageEditorLabels, type WhatsAppMessageEditorProps, type WhatsAppTemplateHeaderType, WhatsAppTemplateSettingsForm, type WhatsAppTemplateSettingsFormLabels, type WhatsAppTemplateSettingsFormProps, type WhatsAppTemplateSummary, type WhatsAppTemplateVariableSuggestion, WhatsAppTemplatesSettings, type WhatsAppTemplatesSettingsLabels, type WhatsAppTemplatesSettingsProps, WindowExpiredNotice, type WindowExpiredNoticeLabels, type WindowExpiredNoticeProps, type WindowOfParams, applyQuickReplyVariables, buildTranscriptFilename, buildTranscriptText, createMediaUrlResolver, downloadTextFile, formatDateTime, formatFileSize, formatPhone, formatStalledFor, formatTimestamp, htmlToWA, isReplyOverdue, isSameDay, isWindowBlocking, parseWhatsAppFormatting, phoneInitials, replyLatencyOf, resolveQuickReply, searchEmojis, toast, useConversationActions, useConversationContext, useConversationDocuments, useConversationList, useConversationLocales, useConversationMessages, useConversationRealtime, useConversations, useConversationsInbox, useDarkMode, useDebouncedValue, useGlobalRealtime, useInboxActions, useIsDarkTheme, useIsNarrow, useScrollToLatestMessage, useToast, useUrlArrayState, useUrlNumberState, useUrlStringState, useWaitingNotifications, waToHTML, waToHTMLInline, windowOf };
|
package/dist/index.js
CHANGED
|
@@ -847,6 +847,12 @@ function Avatar({ name, avatarUrl, size = "md", className = "", labels }) {
|
|
|
847
847
|
|
|
848
848
|
// src/ConversationListItem.tsx
|
|
849
849
|
import { useMemo } from "react";
|
|
850
|
+
|
|
851
|
+
// src/theme.ts
|
|
852
|
+
var CHAT_TEXT_PRIMARY_CLASS = "text-[#111b21] dark:text-[#e9edef]";
|
|
853
|
+
var CHAT_TEXT_SECONDARY_CLASS = "text-[#667781] dark:text-[#8696a0]";
|
|
854
|
+
|
|
855
|
+
// src/ConversationListItem.tsx
|
|
850
856
|
import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
851
857
|
var DEFAULT_CONVERSATION_LIST_ITEM_LABELS = {
|
|
852
858
|
expiredWindow: "Janela expirada",
|
|
@@ -949,18 +955,18 @@ var ConversationListItem = ({
|
|
|
949
955
|
/* @__PURE__ */ jsxs4("div", { className: "flex items-center justify-between gap-2", children: [
|
|
950
956
|
/* @__PURE__ */ jsxs4("div", { className: "flex items-center gap-1.5 min-w-0", children: [
|
|
951
957
|
!conversation.clientName && flag ? /* @__PURE__ */ jsx5("span", { "aria-hidden": true, children: flag }) : null,
|
|
952
|
-
/* @__PURE__ */ jsx5("span", { className:
|
|
958
|
+
/* @__PURE__ */ jsx5("span", { className: `text-[16px] ${CHAT_TEXT_PRIMARY_CLASS} truncate`, children: name }),
|
|
953
959
|
windowStatus && windowStatus.label === "expired" && /* @__PURE__ */ jsx5("span", { className: "w-2 h-2 rounded-full bg-red-500 flex-shrink-0", "data-cv-tooltip": expiredWindowLabel }),
|
|
954
960
|
windowStatus && windowStatus.label === "warning" && /* @__PURE__ */ jsx5("span", { className: "w-2 h-2 rounded-full bg-orange-500 flex-shrink-0", "data-cv-tooltip": warningWindowLabel })
|
|
955
961
|
] }),
|
|
956
|
-
timestamp && /* @__PURE__ */ jsx5("span", { className:
|
|
962
|
+
timestamp && /* @__PURE__ */ jsx5("span", { className: `text-xs ${CHAT_TEXT_SECONDARY_CLASS} flex-shrink-0`, children: timestamp })
|
|
957
963
|
] }),
|
|
958
|
-
conversation.clientName ? /* @__PURE__ */ jsxs4("div", { className:
|
|
964
|
+
conversation.clientName ? /* @__PURE__ */ jsxs4("div", { className: `flex items-center gap-1 text-xs ${CHAT_TEXT_SECONDARY_CLASS}`, children: [
|
|
959
965
|
flag ? /* @__PURE__ */ jsx5("span", { "aria-hidden": true, children: flag }) : null,
|
|
960
966
|
/* @__PURE__ */ jsx5("span", { className: "truncate", children: displayHandle })
|
|
961
967
|
] }) : null,
|
|
962
968
|
/* @__PURE__ */ jsxs4("div", { className: "flex items-center justify-between mt-0.5", children: [
|
|
963
|
-
/* @__PURE__ */ jsxs4("span", { className:
|
|
969
|
+
/* @__PURE__ */ jsxs4("span", { className: `text-sm ${CHAT_TEXT_SECONDARY_CLASS} truncate max-w-[180px]`, children: [
|
|
964
970
|
preview.icon && /* @__PURE__ */ jsx5("span", { className: "mr-1", children: preview.icon }),
|
|
965
971
|
preview.preview || "Sem mensagens"
|
|
966
972
|
] }),
|
|
@@ -1098,7 +1104,7 @@ function MessageText({ message }) {
|
|
|
1098
1104
|
}
|
|
1099
1105
|
}, [message.content]);
|
|
1100
1106
|
if (message.type === "template")
|
|
1101
|
-
return /* @__PURE__ */ jsx7("p", { className:
|
|
1107
|
+
return /* @__PURE__ */ jsx7("p", { className: `text-sm italic ${CHAT_TEXT_SECONDARY_CLASS}`, children: message.content ?? "Template message" });
|
|
1102
1108
|
return /* @__PURE__ */ jsxs6(
|
|
1103
1109
|
"div",
|
|
1104
1110
|
{
|
|
@@ -1116,7 +1122,7 @@ function MessageText({ message }) {
|
|
|
1116
1122
|
import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
1117
1123
|
function MessageTimestamp({ timestamp, status, isOutbound }) {
|
|
1118
1124
|
return /* @__PURE__ */ jsxs7("div", { className: "flex items-center justify-end gap-[3px]", children: [
|
|
1119
|
-
/* @__PURE__ */ jsx8("span", { className:
|
|
1125
|
+
/* @__PURE__ */ jsx8("span", { className: `text-[11px] ${CHAT_TEXT_SECONDARY_CLASS} leading-[15px] select-none`, children: timestamp }),
|
|
1120
1126
|
isOutbound && status && /* @__PURE__ */ jsx8("span", { className: "flex items-center -mr-[2px]", children: /* @__PURE__ */ jsx8(StatusTicks, { status }) })
|
|
1121
1127
|
] });
|
|
1122
1128
|
}
|
|
@@ -1170,6 +1176,30 @@ function formatStalledFor(lastAt, now) {
|
|
|
1170
1176
|
return `${minutes}m`;
|
|
1171
1177
|
}
|
|
1172
1178
|
|
|
1179
|
+
// src/replyLatency.ts
|
|
1180
|
+
var HOUR_MS2 = 60 * 60 * 1e3;
|
|
1181
|
+
var REPLY_LATENCY = {
|
|
1182
|
+
/** Até 6h — dentro do combinado. */
|
|
1183
|
+
WITHIN: "within",
|
|
1184
|
+
/** 6h a 12h — passou do combinado. */
|
|
1185
|
+
LATE: "late",
|
|
1186
|
+
/** Acima de 12h. */
|
|
1187
|
+
CRITICAL: "critical"
|
|
1188
|
+
};
|
|
1189
|
+
var REPLY_LATENCY_LATE_HOURS = 6;
|
|
1190
|
+
var REPLY_LATENCY_CRITICAL_HOURS = 12;
|
|
1191
|
+
function replyLatencyOf(params) {
|
|
1192
|
+
if (params.lastDirection !== "inbound") return null;
|
|
1193
|
+
if (!params.lastInboundAt) return null;
|
|
1194
|
+
const elapsedHours = (params.now - new Date(params.lastInboundAt).getTime()) / HOUR_MS2;
|
|
1195
|
+
if (elapsedHours < REPLY_LATENCY_LATE_HOURS) return REPLY_LATENCY.WITHIN;
|
|
1196
|
+
if (elapsedHours < REPLY_LATENCY_CRITICAL_HOURS) return REPLY_LATENCY.LATE;
|
|
1197
|
+
return REPLY_LATENCY.CRITICAL;
|
|
1198
|
+
}
|
|
1199
|
+
function isReplyOverdue(latency) {
|
|
1200
|
+
return latency === REPLY_LATENCY.LATE || latency === REPLY_LATENCY.CRITICAL;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1173
1203
|
// src/ConversationRow.tsx
|
|
1174
1204
|
import { AlarmClock, Bot, Hourglass, Play, UserRound } from "lucide-react";
|
|
1175
1205
|
|
|
@@ -1246,6 +1276,11 @@ var WINDOW_TITLE = {
|
|
|
1246
1276
|
[CONVERSATION_WINDOW.EXPIRED]: "Janela de 24h expirada \u2014 s\xF3 template"
|
|
1247
1277
|
};
|
|
1248
1278
|
var STALLED_THRESHOLD_MS = 60 * 60 * 1e3;
|
|
1279
|
+
var REPLY_LATENCY_PILL_CLASS = {
|
|
1280
|
+
[REPLY_LATENCY.WITHIN]: "cv-pill--success",
|
|
1281
|
+
[REPLY_LATENCY.LATE]: "cv-pill--warning",
|
|
1282
|
+
[REPLY_LATENCY.CRITICAL]: "cv-pill--danger"
|
|
1283
|
+
};
|
|
1249
1284
|
var TAKEOVER_LABEL = "Continuar Atendimento";
|
|
1250
1285
|
var WINDOW_BAR_CLASS = {
|
|
1251
1286
|
[CONVERSATION_WINDOW.ALL]: "bg-transparent",
|
|
@@ -1271,6 +1306,11 @@ function ConversationRow({
|
|
|
1271
1306
|
const stalledMs = now - new Date(conversation.lastAt).getTime();
|
|
1272
1307
|
const isStalled = stalledMs > STALLED_THRESHOLD_MS;
|
|
1273
1308
|
const isWaiting = conversation.mode === "bot" && conversation.waitingHuman;
|
|
1309
|
+
const replyLatency = replyLatencyOf({
|
|
1310
|
+
lastDirection: conversation.lastDirection,
|
|
1311
|
+
lastInboundAt: conversation.lastInboundAt,
|
|
1312
|
+
now
|
|
1313
|
+
});
|
|
1274
1314
|
return /* @__PURE__ */ jsxs9("div", { className: cn("cv-row flex border-b", active && "cv-row--active", classNames?.root, className), children: [
|
|
1275
1315
|
/* @__PURE__ */ jsx11(
|
|
1276
1316
|
"span",
|
|
@@ -1321,6 +1361,12 @@ function ConversationRow({
|
|
|
1321
1361
|
/* @__PURE__ */ jsx11(Bot, { size: ICON_SIZE_PILL, "aria-hidden": "true" }),
|
|
1322
1362
|
" bot ativo"
|
|
1323
1363
|
] }) : null,
|
|
1364
|
+
replyLatency ? /* @__PURE__ */ jsxs9("span", { className: cn("cv-pill", REPLY_LATENCY_PILL_CLASS[replyLatency]), children: [
|
|
1365
|
+
/* @__PURE__ */ jsx11(AlarmClock, { size: ICON_SIZE_PILL, "aria-hidden": "true" }),
|
|
1366
|
+
" sem resposta h\xE1",
|
|
1367
|
+
" ",
|
|
1368
|
+
formatStalledFor(conversation.lastInboundAt ?? conversation.lastAt, now)
|
|
1369
|
+
] }) : null,
|
|
1324
1370
|
isStalled ? /* @__PURE__ */ jsxs9("span", { className: "cv-pill cv-pill--danger", children: [
|
|
1325
1371
|
/* @__PURE__ */ jsx11(AlarmClock, { size: ICON_SIZE_PILL, "aria-hidden": "true" }),
|
|
1326
1372
|
" parada h\xE1",
|
|
@@ -5000,6 +5046,9 @@ export {
|
|
|
5000
5046
|
MultiSelectFilter,
|
|
5001
5047
|
NARROW_MAX_WIDTH_PX,
|
|
5002
5048
|
REOPEN_MECHANISM,
|
|
5049
|
+
REPLY_LATENCY,
|
|
5050
|
+
REPLY_LATENCY_CRITICAL_HOURS,
|
|
5051
|
+
REPLY_LATENCY_LATE_HOURS,
|
|
5003
5052
|
RICH_COMPOSER_ACTION,
|
|
5004
5053
|
RichMessageComposer,
|
|
5005
5054
|
SimpleEmojiPicker,
|
|
@@ -5033,10 +5082,12 @@ export {
|
|
|
5033
5082
|
formatStalledFor,
|
|
5034
5083
|
formatTimestamp,
|
|
5035
5084
|
htmlToWA,
|
|
5085
|
+
isReplyOverdue,
|
|
5036
5086
|
isSameDay,
|
|
5037
5087
|
isWindowBlocking,
|
|
5038
5088
|
parseWhatsAppFormatting,
|
|
5039
5089
|
phoneInitials,
|
|
5090
|
+
replyLatencyOf,
|
|
5040
5091
|
resolveQuickReply,
|
|
5041
5092
|
searchEmojis,
|
|
5042
5093
|
toast,
|
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useMemo } from 'react'
|
|
2
|
+
import { CHAT_TEXT_PRIMARY_CLASS, CHAT_TEXT_SECONDARY_CLASS } from './theme'
|
|
2
3
|
import { Avatar } from './Avatar'
|
|
3
4
|
import { contactFlag, formatContactHandle } from './conversationChannel'
|
|
4
5
|
import type { ConversationSummary } from './providers/types'
|
|
@@ -149,7 +150,7 @@ export const ConversationListItem = ({
|
|
|
149
150
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
150
151
|
{/* Sem nome, o título já é o telefone — a bandeira vai nele. */}
|
|
151
152
|
{!conversation.clientName && flag ? <span aria-hidden>{flag}</span> : null}
|
|
152
|
-
<span className=
|
|
153
|
+
<span className={`text-[16px] ${CHAT_TEXT_PRIMARY_CLASS} truncate`}>{name}</span>
|
|
153
154
|
{windowStatus && windowStatus.label === 'expired' && (
|
|
154
155
|
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" data-cv-tooltip={expiredWindowLabel} />
|
|
155
156
|
)}
|
|
@@ -158,20 +159,20 @@ export const ConversationListItem = ({
|
|
|
158
159
|
)}
|
|
159
160
|
</div>
|
|
160
161
|
{timestamp && (
|
|
161
|
-
<span className=
|
|
162
|
+
<span className={`text-xs ${CHAT_TEXT_SECONDARY_CLASS} flex-shrink-0`}>{timestamp}</span>
|
|
162
163
|
)}
|
|
163
164
|
</div>
|
|
164
165
|
{/* Telefone só como subtítulo quando o título é o nome — repetir o número embaixo dele
|
|
165
166
|
mesmo gastaria uma linha para dizer duas vezes a mesma coisa. */}
|
|
166
167
|
{conversation.clientName ? (
|
|
167
|
-
<div className=
|
|
168
|
+
<div className={`flex items-center gap-1 text-xs ${CHAT_TEXT_SECONDARY_CLASS}`}>
|
|
168
169
|
{flag ? <span aria-hidden>{flag}</span> : null}
|
|
169
170
|
<span className="truncate">{displayHandle}</span>
|
|
170
171
|
</div>
|
|
171
172
|
) : null}
|
|
172
173
|
|
|
173
174
|
<div className="flex items-center justify-between mt-0.5">
|
|
174
|
-
<span className=
|
|
175
|
+
<span className={`text-sm ${CHAT_TEXT_SECONDARY_CLASS} truncate max-w-[180px]`}>
|
|
175
176
|
{preview.icon && <span className="mr-1">{preview.icon}</span>}
|
|
176
177
|
{preview.preview || 'Sem mensagens'}
|
|
177
178
|
</span>
|
package/src/ConversationRow.tsx
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
windowOf,
|
|
22
22
|
type ConversationWindow,
|
|
23
23
|
} from './conversationWindow'
|
|
24
|
+
import { REPLY_LATENCY, replyLatencyOf } from './replyLatency'
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
const WINDOW_TITLE: Record<ConversationWindow, string> = {
|
|
@@ -34,6 +35,14 @@ const WINDOW_TITLE: Record<ConversationWindow, string> = {
|
|
|
34
35
|
// Abaixo disto a conversa é recente e o aviso de "parada" só faria ruído.
|
|
35
36
|
const STALLED_THRESHOLD_MS = 60 * 60 * 1000
|
|
36
37
|
|
|
38
|
+
// Verde só até 6h: a partir daí o selo perde a cor de "tudo certo", que é o sinal que o operador
|
|
39
|
+
// varre na lista. Crítico ganha vermelho para não se confundir com uma espera de 7h.
|
|
40
|
+
const REPLY_LATENCY_PILL_CLASS = {
|
|
41
|
+
[REPLY_LATENCY.WITHIN]: 'cv-pill--success',
|
|
42
|
+
[REPLY_LATENCY.LATE]: 'cv-pill--warning',
|
|
43
|
+
[REPLY_LATENCY.CRITICAL]: 'cv-pill--danger',
|
|
44
|
+
} as const
|
|
45
|
+
|
|
37
46
|
const TAKEOVER_LABEL = 'Continuar Atendimento'
|
|
38
47
|
|
|
39
48
|
const WINDOW_BAR_CLASS: Record<ConversationWindow, string> = {
|
|
@@ -81,6 +90,13 @@ export function ConversationRow({
|
|
|
81
90
|
// ao `waitingHuman` escondia justamente o caso ruim: conversa assumida e esquecida.
|
|
82
91
|
const isStalled = stalledMs > STALLED_THRESHOLD_MS
|
|
83
92
|
const isWaiting = conversation.mode === 'bot' && conversation.waitingHuman
|
|
93
|
+
// Espera do cliente por resposta — outra coisa que a janela de sessão acima: aqui o relógio para
|
|
94
|
+
// quando alguém responde. Ver `replyLatency.ts`.
|
|
95
|
+
const replyLatency = replyLatencyOf({
|
|
96
|
+
lastDirection: conversation.lastDirection,
|
|
97
|
+
lastInboundAt: conversation.lastInboundAt,
|
|
98
|
+
now,
|
|
99
|
+
})
|
|
84
100
|
|
|
85
101
|
// Realce e hover ficam na linha inteira: com eles no item, só o bloco dele ficava cinza enquanto
|
|
86
102
|
// checkbox, barra lateral e pills continuavam brancos — parecia meia linha selecionada.
|
|
@@ -137,6 +153,12 @@ export function ConversationRow({
|
|
|
137
153
|
<Bot size={ICON_SIZE_PILL} aria-hidden="true" /> bot ativo
|
|
138
154
|
</span>
|
|
139
155
|
) : null}
|
|
156
|
+
{replyLatency ? (
|
|
157
|
+
<span className={cn('cv-pill', REPLY_LATENCY_PILL_CLASS[replyLatency])}>
|
|
158
|
+
<AlarmClock size={ICON_SIZE_PILL} aria-hidden="true" /> sem resposta há{' '}
|
|
159
|
+
{formatStalledFor(conversation.lastInboundAt ?? conversation.lastAt, now)}
|
|
160
|
+
</span>
|
|
161
|
+
) : null}
|
|
140
162
|
{isStalled ? (
|
|
141
163
|
<span className="cv-pill cv-pill--danger">
|
|
142
164
|
<AlarmClock size={ICON_SIZE_PILL} aria-hidden="true" /> parada há{' '}
|
package/src/MessageText.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useState } from 'react'
|
|
2
|
+
import { CHAT_TEXT_SECONDARY_CLASS } from './theme'
|
|
2
3
|
import type { MessagePayload } from './types'
|
|
3
4
|
import { useConversations } from './providers/ConversationsProvider'
|
|
4
5
|
import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
|
|
@@ -22,7 +23,7 @@ export function MessageText({ message }: MessageTextProps) {
|
|
|
22
23
|
}, [message.content])
|
|
23
24
|
|
|
24
25
|
if (message.type === 'template')
|
|
25
|
-
return <p className=
|
|
26
|
+
return <p className={`text-sm italic ${CHAT_TEXT_SECONDARY_CLASS}`}>{message.content ?? 'Template message'}</p>
|
|
26
27
|
|
|
27
28
|
return (
|
|
28
29
|
<div
|
package/src/MessageTimestamp.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { StatusTicks } from './StatusTicks'
|
|
2
|
+
import { CHAT_TEXT_SECONDARY_CLASS } from './theme'
|
|
2
3
|
|
|
3
4
|
export interface MessageTimestampProps {
|
|
4
5
|
timestamp: string
|
|
@@ -9,7 +10,7 @@ export interface MessageTimestampProps {
|
|
|
9
10
|
export function MessageTimestamp({ timestamp, status, isOutbound }: MessageTimestampProps) {
|
|
10
11
|
return (
|
|
11
12
|
<div className="flex items-center justify-end gap-[3px]">
|
|
12
|
-
<span className=
|
|
13
|
+
<span className={`text-[11px] ${CHAT_TEXT_SECONDARY_CLASS} leading-[15px] select-none`}>
|
|
13
14
|
{timestamp}
|
|
14
15
|
</span>
|
|
15
16
|
{isOutbound && status && (
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,17 @@ export { MessageTail } from './MessageTail'
|
|
|
42
42
|
// plataforma e do ofício de atender, não de um produto — por isso moram aqui.
|
|
43
43
|
export { CONVERSATION_WINDOW, WINDOW_FILTERS, windowOf, formatStalledFor } from './conversationWindow'
|
|
44
44
|
export type { WindowOfParams } from './conversationWindow'
|
|
45
|
+
|
|
46
|
+
// Tempo sem resposta (SLA), distinto da janela de sessão acima: o host usa isto para o alerta e
|
|
47
|
+
// para filtrar quem está esperando demais.
|
|
48
|
+
export {
|
|
49
|
+
REPLY_LATENCY,
|
|
50
|
+
REPLY_LATENCY_LATE_HOURS,
|
|
51
|
+
REPLY_LATENCY_CRITICAL_HOURS,
|
|
52
|
+
replyLatencyOf,
|
|
53
|
+
isReplyOverdue,
|
|
54
|
+
} from './replyLatency'
|
|
55
|
+
export type { ReplyLatency, ReplyLatencyParams } from './replyLatency'
|
|
45
56
|
// Canal de origem: capacidades por plataforma (janela de sessão, reabertura, tipo de identificador).
|
|
46
57
|
export {
|
|
47
58
|
CONVERSATION_CHANNEL,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* A distinção que estes testes protegem: janela de sessão e tempo sem resposta medem coisas
|
|
5
|
+
* diferentes, e reaproveitar `windowOf` para o SLA marcaria como atrasada toda conversa já
|
|
6
|
+
* respondida — o alerta apontaria para tudo, ou seja, para nada.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'bun:test'
|
|
10
|
+
|
|
11
|
+
import { REPLY_LATENCY, isReplyOverdue, replyLatencyOf } from './replyLatency'
|
|
12
|
+
|
|
13
|
+
const NOW = new Date('2026-08-29T12:00:00Z').getTime()
|
|
14
|
+
const hoursAgo = (hours: number) => new Date(NOW - hours * 60 * 60 * 1000).toISOString()
|
|
15
|
+
|
|
16
|
+
describe('faixas de espera', () => {
|
|
17
|
+
it('até 6h está dentro do combinado', () => {
|
|
18
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(0), now: NOW })).toBe(
|
|
19
|
+
REPLY_LATENCY.WITHIN,
|
|
20
|
+
)
|
|
21
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(5.9), now: NOW })).toBe(
|
|
22
|
+
REPLY_LATENCY.WITHIN,
|
|
23
|
+
)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('de 6h a 12h já passou do combinado', () => {
|
|
27
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(6), now: NOW })).toBe(REPLY_LATENCY.LATE)
|
|
28
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(11.9), now: NOW })).toBe(
|
|
29
|
+
REPLY_LATENCY.LATE,
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('acima de 12h é crítico — não se confunde com uma espera de 7h', () => {
|
|
34
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(12), now: NOW })).toBe(
|
|
35
|
+
REPLY_LATENCY.CRITICAL,
|
|
36
|
+
)
|
|
37
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(72), now: NOW })).toBe(
|
|
38
|
+
REPLY_LATENCY.CRITICAL,
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('quando não há espera a mostrar', () => {
|
|
44
|
+
it('conversa já respondida não tem selo, por mais antiga que seja', () => {
|
|
45
|
+
// É aqui que o SLA se separa da janela de sessão: `windowOf` marcaria isto como crítico.
|
|
46
|
+
expect(replyLatencyOf({ lastDirection: 'outbound', lastInboundAt: hoursAgo(20), now: NOW })).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('cliente que nunca escreveu não tem espera', () => {
|
|
50
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: null, now: NOW })).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('direção desconhecida não afirma espera', () => {
|
|
54
|
+
expect(replyLatencyOf({ lastInboundAt: hoursAgo(20), now: NOW })).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('relógio adiantado não vira conversa crítica', () => {
|
|
58
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(-0.05), now: NOW })).toBe(
|
|
59
|
+
REPLY_LATENCY.WITHIN,
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('o que merece alerta', () => {
|
|
65
|
+
it('só o que passou de 6h', () => {
|
|
66
|
+
expect(isReplyOverdue(REPLY_LATENCY.WITHIN)).toBe(false)
|
|
67
|
+
expect(isReplyOverdue(REPLY_LATENCY.LATE)).toBe(true)
|
|
68
|
+
expect(isReplyOverdue(REPLY_LATENCY.CRITICAL)).toBe(true)
|
|
69
|
+
expect(isReplyOverdue(null)).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* Há quanto tempo o cliente espera resposta.
|
|
5
|
+
*
|
|
6
|
+
* Não confundir com `conversationWindow`: aquilo é a janela de sessão da plataforma (o que o canal
|
|
7
|
+
* ainda deixa enviar), isto é serviço (quanto o cliente esperou). Os dois divergem justamente no
|
|
8
|
+
* caso que interessa — respondida a conversa, o relógio do SLA para e o da janela continua correndo.
|
|
9
|
+
*
|
|
10
|
+
* A conta sai de dados que a listagem já traz: se a última mensagem foi do cliente, ninguém
|
|
11
|
+
* respondeu ainda e a espera conta desde ela. Se a última foi nossa, não há espera pendente.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const HOUR_MS = 60 * 60 * 1000
|
|
15
|
+
|
|
16
|
+
export const REPLY_LATENCY = {
|
|
17
|
+
/** Até 6h — dentro do combinado. */
|
|
18
|
+
WITHIN: 'within',
|
|
19
|
+
/** 6h a 12h — passou do combinado. */
|
|
20
|
+
LATE: 'late',
|
|
21
|
+
/** Acima de 12h. */
|
|
22
|
+
CRITICAL: 'critical',
|
|
23
|
+
} as const
|
|
24
|
+
export type ReplyLatency = (typeof REPLY_LATENCY)[keyof typeof REPLY_LATENCY]
|
|
25
|
+
|
|
26
|
+
export const REPLY_LATENCY_LATE_HOURS = 6
|
|
27
|
+
export const REPLY_LATENCY_CRITICAL_HOURS = 12
|
|
28
|
+
|
|
29
|
+
export type ReplyLatencyParams = {
|
|
30
|
+
/** Direção da última mensagem. Ausente = desconhecida, e aí não se afirma espera. */
|
|
31
|
+
readonly lastDirection?: 'inbound' | 'outbound' | undefined
|
|
32
|
+
readonly lastInboundAt: string | null
|
|
33
|
+
readonly now: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `null` quando não há espera a mostrar — conversa já respondida, ou cliente que nunca escreveu.
|
|
38
|
+
*
|
|
39
|
+
* Devolver uma faixa nesses casos encheria a lista de selos em conversa que não deve nada, e o
|
|
40
|
+
* alerta que aponta para todo lado não aponta para lugar nenhum.
|
|
41
|
+
*/
|
|
42
|
+
export function replyLatencyOf(params: ReplyLatencyParams): ReplyLatency | null {
|
|
43
|
+
if (params.lastDirection !== 'inbound') return null
|
|
44
|
+
if (!params.lastInboundAt) return null
|
|
45
|
+
|
|
46
|
+
const elapsedHours = (params.now - new Date(params.lastInboundAt).getTime()) / HOUR_MS
|
|
47
|
+
// Espera negativa é relógio fora de sincronia entre servidor e navegador, não conversa do futuro:
|
|
48
|
+
// tratar como recém-chegada evita um selo "crítico" nascido de alguns segundos de diferença.
|
|
49
|
+
if (elapsedHours < REPLY_LATENCY_LATE_HOURS) return REPLY_LATENCY.WITHIN
|
|
50
|
+
if (elapsedHours < REPLY_LATENCY_CRITICAL_HOURS) return REPLY_LATENCY.LATE
|
|
51
|
+
return REPLY_LATENCY.CRITICAL
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Só o que passou do combinado merece alerta — é o corte que a lista usa para destacar. */
|
|
55
|
+
export function isReplyOverdue(latency: ReplyLatency | null): boolean {
|
|
56
|
+
return latency === REPLY_LATENCY.LATE || latency === REPLY_LATENCY.CRITICAL
|
|
57
|
+
}
|
package/src/theme.ts
CHANGED
|
@@ -28,3 +28,16 @@ export function getCustomStyles(theme?: ConversationsTheme): Record<string, stri
|
|
|
28
28
|
|
|
29
29
|
return styles
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Texto do chat nos dois temas.
|
|
34
|
+
*
|
|
35
|
+
* Existe porque as cores viviam cravadas em hexadecimal na marcação, sem variante escura: nome do
|
|
36
|
+
* cliente em `#111b21` e número, horário e prévia em `#667781` — os cinzas do WhatsApp CLARO. Com o
|
|
37
|
+
* tema escuro ligado eles continuavam escuros sobre fundo escuro, e a lista virava texto quase
|
|
38
|
+
* ilegível. O mesmo par aparecia dentro do balão, que também escurece.
|
|
39
|
+
*
|
|
40
|
+
* Os valores escuros são os que o próprio WhatsApp usa, para a tela não inventar uma paleta própria.
|
|
41
|
+
*/
|
|
42
|
+
export const CHAT_TEXT_PRIMARY_CLASS = 'text-[#111b21] dark:text-[#e9edef]'
|
|
43
|
+
export const CHAT_TEXT_SECONDARY_CLASS = 'text-[#667781] dark:text-[#8696a0]'
|