@adatechnology/conversations-ui 0.1.0-rc.4 → 0.1.0-rc.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/chunk-BJNRLLDO.js +2708 -0
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2389 -678
  7. package/dist/index.d.ts +1171 -42
  8. package/dist/index.js +3755 -843
  9. package/dist/preview/index.d.ts +157 -42
  10. package/dist/preview/index.js +772 -191
  11. package/dist/styles.css +893 -0
  12. package/package.json +9 -8
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +14 -3
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +347 -24
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +163 -45
  23. package/src/ConversationListItem.tsx +19 -2
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +31 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +382 -0
  29. package/src/EmojiPicker.tsx +70 -55
  30. package/src/FileIcon.test.ts +83 -0
  31. package/src/FileIcon.tsx +88 -11
  32. package/src/InteractiveMessage.test.tsx +41 -0
  33. package/src/InteractiveMessage.tsx +146 -0
  34. package/src/Lightbox.tsx +18 -3
  35. package/src/MediaRenderer.tsx +92 -16
  36. package/src/MessageBubble.test.tsx +41 -0
  37. package/src/MessageBubble.tsx +75 -6
  38. package/src/MessageComposer.test.tsx +35 -0
  39. package/src/MessageComposer.tsx +155 -19
  40. package/src/RichMessageComposer.test.tsx +113 -0
  41. package/src/RichMessageComposer.tsx +551 -0
  42. package/src/SimpleEmojiPicker.tsx +5 -3
  43. package/src/StatusTicks.tsx +1 -1
  44. package/src/Toast.tsx +4 -0
  45. package/src/Tooltip.test.ts +42 -0
  46. package/src/Tooltip.tsx +167 -0
  47. package/src/Wallpaper.test.tsx +21 -0
  48. package/src/Wallpaper.tsx +67 -7
  49. package/src/WhatsAppMessageEditor.tsx +34 -7
  50. package/src/WindowExpiredNotice.tsx +12 -4
  51. package/src/audioRecorderFormat.test.ts +67 -0
  52. package/src/buildOutput.test.ts +79 -0
  53. package/src/composer.constant.ts +33 -0
  54. package/src/conversationTranscript.test.ts +57 -0
  55. package/src/conversationTranscript.ts +29 -4
  56. package/src/conversationWindow.ts +7 -5
  57. package/src/documentTypeLabel.test.ts +57 -0
  58. package/src/documents/DocumentsWorkspace.tsx +550 -0
  59. package/src/documents/index.ts +8 -0
  60. package/src/documents/labels.ts +92 -0
  61. package/src/emojiCatalog.test.ts +35 -0
  62. package/src/emojiCatalog.ts +189 -0
  63. package/src/flows/FlowConnectionEdge.tsx +104 -0
  64. package/src/flows/FlowGroupHeader.tsx +12 -2
  65. package/src/flows/FlowLegend.tsx +125 -0
  66. package/src/flows/FlowMapCanvas.tsx +15 -12
  67. package/src/flows/FlowMapNode.tsx +4 -1
  68. package/src/flows/FlowNodeCard.tsx +219 -34
  69. package/src/flows/FlowNodePanel.tsx +153 -39
  70. package/src/flows/FlowPalette.tsx +106 -69
  71. package/src/flows/FlowPortalNode.tsx +1 -1
  72. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  73. package/src/flows/FlowsWorkspace.tsx +1219 -0
  74. package/src/flows/flowCanvasModel.test.ts +456 -0
  75. package/src/flows/flowCanvasModel.ts +378 -0
  76. package/src/flows/flowEditorOps.test.ts +276 -0
  77. package/src/flows/flowEditorOps.ts +202 -0
  78. package/src/flows/flowGraph.ts +78 -53
  79. package/src/flows/index.ts +51 -2
  80. package/src/flows/labels.ts +180 -0
  81. package/src/flows/workspaceContract.test.ts +126 -0
  82. package/src/hooks/useContainerWidth.ts +35 -0
  83. package/src/hooks/useConversationActions.ts +56 -0
  84. package/src/hooks/useConversationDocuments.ts +11 -7
  85. package/src/hooks/useConversationList.ts +15 -9
  86. package/src/hooks/useConversationMessages.ts +2 -2
  87. package/src/hooks/useConversationRealtime.ts +10 -8
  88. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  89. package/src/hooks/useUrlFilterState.ts +107 -0
  90. package/src/icon.constant.ts +12 -0
  91. package/src/index.ts +129 -13
  92. package/src/lib/cn.test.ts +29 -0
  93. package/src/lib/composer-formatting.test.ts +78 -0
  94. package/src/lib/composer-formatting.ts +145 -0
  95. package/src/lib/createMediaUrlResolver.ts +33 -0
  96. package/src/lib/paginated.test.ts +33 -0
  97. package/src/lib/paginated.ts +26 -0
  98. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  99. package/src/lib/whatsapp-formatting.tsx +28 -3
  100. package/src/listing/index.tsx +202 -0
  101. package/src/pagination.constant.ts +10 -0
  102. package/src/preview/ConversationPreview.tsx +225 -17
  103. package/src/preview/ConversationSimulatorClient.ts +143 -0
  104. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  105. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  106. package/src/preview/MediaTypesPreview.tsx +87 -0
  107. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  108. package/src/preview/createMockConversationsApi.ts +175 -15
  109. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  110. package/src/preview/createPreviewBridgeClient.ts +124 -0
  111. package/src/preview/createPreviewMediaUploader.ts +82 -0
  112. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  113. package/src/preview/createPreviewWebhookClient.ts +127 -4
  114. package/src/preview/index.ts +51 -3
  115. package/src/preview/mediaTypeOf.test.ts +15 -0
  116. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  117. package/src/preview/preview.test.ts +5 -3
  118. package/src/preview/previewFileSamples.test.ts +151 -0
  119. package/src/preview/previewFileSamples.ts +74 -0
  120. package/src/preview/previewFixtures.ts +288 -1
  121. package/src/preview/previewMediaSource.test.ts +62 -0
  122. package/src/preview/previewMediaSource.ts +91 -0
  123. package/src/preview/previewMediaUploader.test.ts +61 -0
  124. package/src/providers/ConversationsProvider.tsx +8 -6
  125. package/src/providers/types.ts +185 -10
  126. package/src/quickReply.test.ts +58 -0
  127. package/src/settings/MessagesWorkspace.tsx +571 -0
  128. package/src/settings/TopicsForm.tsx +2 -0
  129. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  130. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  131. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  132. package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
  133. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  134. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  135. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  136. package/src/styles.css +858 -0
  137. package/src/types.ts +64 -1
  138. package/src/useWaitingNotifications.ts +74 -29
  139. package/src/workspace/BulkTemplateModal.tsx +132 -0
  140. package/src/workspace/ConversationPane.tsx +432 -0
  141. package/src/workspace/ConversationsInboxList.tsx +194 -0
  142. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  143. package/src/workspace/index.ts +17 -0
  144. package/src/workspace/labels.test.ts +17 -0
  145. package/src/workspace/labels.ts +85 -0
  146. package/src/workspace/useConversationsInbox.ts +332 -0
  147. package/dist/chunk-4R6Y43DQ.js +0 -726
  148. package/dist/chunk-NV2RZ5KT.js +0 -56
  149. package/dist/types-C0PtaO7S.d.ts +0 -207
package/src/types.ts CHANGED
@@ -20,9 +20,46 @@ export interface ConversationsFeatures {
20
20
  darkMode?: boolean
21
21
  }
22
22
 
23
+ /**
24
+ * Recorte do bloco `interactive` da Meta que a UI precisa para desenhar o menu. Fica solto (e não
25
+ * espelhando o contrato inteiro) porque o que chega do banco é o payload cru já enviado ao
26
+ * WhatsApp: qualquer campo que a UI não conheça é ignorado, nunca causa erro de render.
27
+ */
28
+ export interface InteractiveOption {
29
+ id: string
30
+ title: string
31
+ description?: string
32
+ }
33
+
34
+ export interface InteractiveSection {
35
+ title?: string
36
+ rows?: InteractiveOption[]
37
+ }
38
+
39
+ export interface InteractivePayload {
40
+ type?: 'button' | 'list' | string
41
+ header?: { text?: string }
42
+ body?: { text?: string }
43
+ footer?: { text?: string }
44
+ action?: {
45
+ /** Rótulo do botão que abre a lista — só existe em `type: 'list'`. */
46
+ button?: string
47
+ sections?: InteractiveSection[]
48
+ buttons?: { reply?: InteractiveOption }[]
49
+ }
50
+ }
51
+
52
+ /** Como o cliente respondeu a um menu: por botão ou por item de lista. */
53
+ export type InteractiveSelection = {
54
+ readonly kind: 'button' | 'list'
55
+ readonly option: InteractiveOption
56
+ }
57
+
23
58
  export interface MessagePayload {
24
59
  id: string
25
- type: 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'template'
60
+ type: 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'template' | 'interactive'
61
+ /** Payload cru da mensagem. Em `type: 'interactive'`, carrega o menu que o cliente vê. */
62
+ payload?: InteractivePayload | null
26
63
  content?: string
27
64
  caption?: string
28
65
  mediaUrl?: string
@@ -47,6 +84,32 @@ export interface MessagePayload {
47
84
  * diferente de avaliado e limpo.
48
85
  */
49
86
  moderation?: { isOffensive: boolean; terms: string[] } | null
87
+ /**
88
+ * Transcrição do áudio, vinda do backend — a UI só exibe, nunca transcreve. Rodar STT no browser
89
+ * exigiria baixar modelo por aba e daria resultado diferente por versão de cliente.
90
+ *
91
+ * `null`/ausente = não avaliado, que é diferente de `'done'` com texto vazio (áudio em silêncio,
92
+ * já processado). É essa distinção que decide se o balão oferece "transcrever" ou "sem fala
93
+ * detectada".
94
+ */
95
+ transcription?: MessageTranscription | null
50
96
  isFirstInGroup?: boolean
51
97
  isLastInGroup?: boolean
52
98
  }
99
+
100
+ export type TranscriptionStatus = 'pending' | 'done' | 'failed' | 'unsupported'
101
+
102
+ /**
103
+ * Quando transcrever, escolhido nas configurações da empresa. Espelha o
104
+ * `TranscriptionMode` de `@adatechnology/meta-whatsapp-contracts`; declarado aqui para o pacote de
105
+ * UI não obrigar quem só desenha telas a instalar os contratos do backend.
106
+ */
107
+ export type TranscriptionMode = 'auto' | 'onDemand'
108
+
109
+ export interface MessageTranscription {
110
+ status: TranscriptionStatus
111
+ text?: string | null
112
+ /** ISO 639-1 ou nome do idioma, conforme o engine. Exibido como dica, não interpretado. */
113
+ language?: string | null
114
+ engine?: string | null
115
+ }
@@ -1,64 +1,109 @@
1
1
  import { useState, useEffect, useRef, useCallback } from 'react'
2
2
  import { useConversations } from './providers/ConversationsProvider'
3
- import type { ConversationSummary } from './providers/types'
3
+ import { conversationsOf } from './lib/paginated'
4
+ import type { ConversationSummary, ListConversationsParams } from './providers/types'
4
5
 
5
- interface UseWaitingNotificationsResult {
6
+ const DEFAULT_POLL_INTERVAL_MS = 10_000
7
+ const DEFAULT_LIMIT = 50
8
+
9
+ export interface UseWaitingNotificationsLabels {
10
+ /** Título da notificação do sistema. Recebe a conversa para o host escolher nome × número. */
11
+ title: (conversation: ConversationSummary) => string
12
+ body: (conversation: ConversationSummary) => string
13
+ }
14
+
15
+ export interface UseWaitingNotificationsParams {
16
+ /**
17
+ * Repassado cru ao `fetchConversations`. É o que permite filtrar não lidas **no servidor** em
18
+ * vez de baixar a lista inteira e contar no cliente: um painel com milhares de conversas não
19
+ * pode paginar 50 por vez atrás de quem tem `unread > 0`.
20
+ */
21
+ readonly params?: ListConversationsParams
22
+ readonly intervalMs?: number
23
+ /** Desliga o polling sem desmontar quem chama — útil com a aba em segundo plano. */
24
+ readonly enabled?: boolean
25
+ readonly icon?: string
26
+ readonly labels?: Partial<UseWaitingNotificationsLabels>
27
+ }
28
+
29
+ export interface UseWaitingNotificationsResult {
6
30
  unreadCount: number
7
31
  conversations: ConversationSummary[]
32
+ /**
33
+ * Releitura sob demanda. Existe porque o polling é o piso, não o mecanismo: quem já recebe SSE
34
+ * ou acabou de marcar tudo como lido sabe da mudança antes do próximo tick, e esperar 10s para
35
+ * o contador acompanhar faz a interface parecer travada.
36
+ */
37
+ refresh: () => Promise<void>
38
+ }
39
+
40
+ const DEFAULT_LABELS: UseWaitingNotificationsLabels = {
41
+ title: (conversation) => conversation.clientName ?? conversation.whatsappNumber,
42
+ body: (conversation) => conversation.lastContent ?? 'Nova mensagem',
8
43
  }
9
44
 
10
- export function useWaitingNotifications(): UseWaitingNotificationsResult {
11
- const conversationsCtx = useConversations()
45
+ export function useWaitingNotifications(params?: UseWaitingNotificationsParams): UseWaitingNotificationsResult {
46
+ const conversationsContext = useConversations()
12
47
  const [conversations, setConversations] = useState<ConversationSummary[]>([])
13
48
  const previousUnreadMap = useRef<Map<string, number>>(new Map())
14
49
  const notifiedIds = useRef<Set<string>>(new Set())
50
+ // Trava de reentrância: com a rede lenta, o tick de 10s dispara sobre a busca anterior ainda em
51
+ // voo, e duas respostas fora de ordem fazem o contador oscilar e a notificação repetir.
52
+ const isPollingRef = useRef(false)
53
+
54
+ const isEnabled = params?.enabled ?? true
55
+ const intervalMs = params?.intervalMs ?? DEFAULT_POLL_INTERVAL_MS
56
+ const icon = params?.icon
57
+ const listParams = params?.params
58
+ const labelTitle = params?.labels?.title ?? DEFAULT_LABELS.title
59
+ const labelBody = params?.labels?.body ?? DEFAULT_LABELS.body
15
60
 
16
- const pollConversations = useCallback(async () => {
17
- if (!conversationsCtx) return
61
+ const refresh = useCallback(async () => {
62
+ if (!conversationsContext || isPollingRef.current) return
63
+ isPollingRef.current = true
18
64
 
19
65
  try {
20
- const result = await conversationsCtx.api.fetchConversations({ limit: 50 })
66
+ const result = conversationsOf(
67
+ await conversationsContext.api.fetchConversations({ limit: DEFAULT_LIMIT, ...listParams }),
68
+ )
21
69
  setConversations(result)
22
70
 
23
71
  const currentMap = new Map<string, number>()
24
- for (const conv of result) {
25
- currentMap.set(conv.id, conv.unread)
26
- }
72
+ for (const conversation of result) currentMap.set(conversation.id, conversation.unread)
27
73
 
28
- for (const conv of result) {
29
- if (conv.unread <= 0) continue
74
+ for (const conversation of result) {
75
+ if (conversation.unread <= 0) continue
30
76
 
31
- const prev = previousUnreadMap.current.get(conv.id) ?? 0
77
+ const previousUnread = previousUnreadMap.current.get(conversation.id) ?? 0
78
+ if (conversation.unread <= previousUnread || notifiedIds.current.has(conversation.id)) continue
32
79
 
33
- if (conv.unread > prev && !notifiedIds.current.has(conv.id)) {
34
- notifiedIds.current.add(conv.id)
35
-
36
- if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'granted') {
37
- const title = conv.clientName ?? conv.whatsappNumber
38
- const body = conv.lastContent ?? 'Nova mensagem'
39
- new Notification(title, { body, icon: '/favicon.ico' })
40
- }
80
+ notifiedIds.current.add(conversation.id)
81
+ if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'granted') {
82
+ new Notification(labelTitle(conversation), { body: labelBody(conversation), ...(icon ? { icon } : {}) })
41
83
  }
42
84
  }
43
85
 
44
86
  previousUnreadMap.current = currentMap
45
87
  } catch {
46
- // Silently ignore polling errors
88
+ // Contador de badge não interrompe o atendimento: uma falha de rede some no próximo tick.
89
+ } finally {
90
+ isPollingRef.current = false
47
91
  }
48
- }, [conversationsCtx])
92
+ }, [conversationsContext, listParams, icon, labelTitle, labelBody])
49
93
 
50
94
  useEffect(() => {
95
+ if (!isEnabled) return
96
+
51
97
  if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'default') {
52
98
  Notification.requestPermission()
53
99
  }
54
100
 
55
- pollConversations()
56
-
57
- const interval = setInterval(pollConversations, 10_000)
101
+ void refresh()
102
+ const interval = setInterval(() => void refresh(), intervalMs)
58
103
  return () => clearInterval(interval)
59
- }, [pollConversations])
104
+ }, [refresh, isEnabled, intervalMs])
60
105
 
61
- const unreadCount = conversations.reduce((sum, conv) => sum + conv.unread, 0)
106
+ const unreadCount = conversations.reduce((sum, conversation) => sum + conversation.unread, 0)
62
107
 
63
- return { unreadCount, conversations }
108
+ return { unreadCount, conversations, refresh }
64
109
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Escolha do template para o envio em lote.
3
+ *
4
+ * Fora da janela de 24h só template abre conversa, e a lista aprovada muda por conta da Meta — daí
5
+ * a busca: quem tem trinta templates não acha o certo rolando. Só aparece quando o host implementa
6
+ * `listTemplates`; sem a listagem não há o que escolher, e o envio cai no template padrão.
7
+ *
8
+ * O aviso de "nenhuma selecionada está fora da janela" existe porque o envio parecia falhar em
9
+ * silêncio: dentro da janela a Meta recusa o template, e a tela não dizia por quê.
10
+ */
11
+
12
+ import { useEffect, useMemo, useState } from 'react'
13
+
14
+ import { useConversations } from '../providers/ConversationsProvider'
15
+ import type { ConversationTemplate } from '../providers/types'
16
+ import type { ConversationsWorkspaceLabels } from './labels'
17
+
18
+ export interface BulkTemplateModalProps {
19
+ readonly labels: ConversationsWorkspaceLabels
20
+ /** Quantas das selecionadas estão fora da janela — as únicas que o template atinge. */
21
+ readonly expiredCount: number
22
+ readonly sending: boolean
23
+ readonly onClose: () => void
24
+ readonly onSend: (templateName: string | undefined) => void
25
+ }
26
+
27
+ export function BulkTemplateModal({
28
+ labels,
29
+ expiredCount,
30
+ sending,
31
+ onClose,
32
+ onSend,
33
+ }: BulkTemplateModalProps) {
34
+ const context = useConversations()
35
+ if (!context) {
36
+ throw new Error('BulkTemplateModal requires an ancestor <ConversationsProvider>')
37
+ }
38
+ const { api } = context
39
+
40
+ const [templates, setTemplates] = useState<readonly ConversationTemplate[]>([])
41
+ const [search, setSearch] = useState('')
42
+ const [selected, setSelected] = useState('')
43
+
44
+ useEffect(() => {
45
+ let active = true
46
+ void api
47
+ .listTemplates?.()
48
+ .then((loaded) => {
49
+ if (active) setTemplates(loaded)
50
+ })
51
+ .catch(() => {
52
+ // Lista vazia já comunica o que houve; um alerta a mais competiria com o atendimento.
53
+ })
54
+ return () => {
55
+ active = false
56
+ }
57
+ }, [api])
58
+
59
+ const filtered = useMemo(() => {
60
+ const term = search.trim().toLowerCase()
61
+ if (!term) return templates
62
+ return templates.filter((template) => template.name.toLowerCase().includes(term))
63
+ }, [templates, search])
64
+
65
+ return (
66
+ <div className="cv-workspace-modal" role="dialog" aria-modal="true" aria-label={labels.templateModalTitle}>
67
+ <div className="cv-workspace-modal__box">
68
+ <header className="cv-workspace-modal__header">
69
+ <div>
70
+ <h3>{labels.templateModalTitle}</h3>
71
+ <p>{labels.templateModalAvailable(templates.length)}</p>
72
+ </div>
73
+ <button data-cv-tooltip={labels.templateModalCancel} type="button" onClick={onClose} aria-label={labels.templateModalCancel}>
74
+
75
+ </button>
76
+ </header>
77
+
78
+ <div className="cv-workspace-modal__search">
79
+ <input
80
+ type="search"
81
+ value={search}
82
+ onChange={(event) => setSearch(event.target.value)}
83
+ placeholder={labels.templateModalSearch}
84
+ autoFocus
85
+ />
86
+ </div>
87
+
88
+ <div className="cv-workspace-modal__list">
89
+ {filtered.length === 0 ? <p className="cv-workspace-empty">{labels.templateModalEmpty}</p> : null}
90
+ {filtered.map((template) => (
91
+ <button
92
+ data-cv-tooltip={template.name} aria-label={template.name}
93
+ key={template.name}
94
+ type="button"
95
+ // Reclicar desmarca: sem isso não havia como voltar ao template padrão depois de
96
+ // escolher um por engano.
97
+ onClick={() => setSelected(template.name === selected ? '' : template.name)}
98
+ aria-pressed={template.name === selected}
99
+ className={`cv-workspace-modal__item${
100
+ template.name === selected ? ' cv-workspace-modal__item--on' : ''
101
+ }`}
102
+ >
103
+ <span className="cv-workspace-modal__item-name">{template.name}</span>
104
+ <span className="cv-workspace-modal__item-meta">
105
+ {template.language}
106
+ {template.category ? ` · ${template.category.toLowerCase()}` : ''}
107
+ </span>
108
+ </button>
109
+ ))}
110
+ </div>
111
+
112
+ {expiredCount === 0 ? (
113
+ <p className="cv-workspace-modal__warning">{labels.templateModalNoneExpired}</p>
114
+ ) : null}
115
+
116
+ <footer className="cv-workspace-modal__footer">
117
+ <button data-cv-tooltip={labels.templateModalCancel} aria-label={labels.templateModalCancel} type="button" onClick={onClose}>
118
+ {labels.templateModalCancel}
119
+ </button>
120
+ <button
121
+ data-cv-tooltip={sending ? labels.templateModalSending : labels.templateModalSend(expiredCount)} aria-label={sending ? labels.templateModalSending : labels.templateModalSend(expiredCount)}
122
+ type="button"
123
+ disabled={sending || expiredCount === 0}
124
+ onClick={() => onSend(selected || undefined)}
125
+ >
126
+ {sending ? labels.templateModalSending : labels.templateModalSend(expiredCount)}
127
+ </button>
128
+ </footer>
129
+ </div>
130
+ </div>
131
+ )
132
+ }