@adatechnology/conversations-ui 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channel/index.d.ts +2 -0
- package/dist/channel/index.js +0 -0
- package/dist/chunk-ZDURDZTM.js +199 -0
- package/dist/flows/index.d.ts +246 -0
- package/dist/flows/index.js +1110 -0
- package/dist/index.d.ts +542 -0
- package/dist/index.js +2077 -0
- package/dist/styles.css +11 -0
- package/dist/styles.d.ts +2 -0
- package/package.json +52 -0
- package/src/AudioPlayer.tsx +103 -0
- package/src/Avatar.tsx +63 -0
- package/src/ConversationListItem.tsx +147 -0
- package/src/ConversationLocalesProvider.tsx +76 -0
- package/src/DateDivider.tsx +32 -0
- package/src/EmojiPicker.tsx +77 -0
- package/src/FileIcon.tsx +33 -0
- package/src/Lightbox.tsx +17 -0
- package/src/MediaRenderer.tsx +158 -0
- package/src/MessageBubble.tsx +129 -0
- package/src/MessageComposer.tsx +211 -0
- package/src/MessageTail.tsx +18 -0
- package/src/MessageText.tsx +42 -0
- package/src/MessageTimestamp.tsx +22 -0
- package/src/SimpleEmojiPicker.tsx +72 -0
- package/src/StatusTicks.tsx +39 -0
- package/src/Toast.tsx +140 -0
- package/src/Wallpaper.tsx +13 -0
- package/src/WhatsAppMessageEditor.tsx +142 -0
- package/src/channel/index.ts +1 -0
- package/src/conversations/index.ts +1 -0
- package/src/flows/FlowGroupFrame.tsx +21 -0
- package/src/flows/FlowGroupHeader.tsx +31 -0
- package/src/flows/FlowMapCanvas.tsx +76 -0
- package/src/flows/FlowMapNode.tsx +39 -0
- package/src/flows/FlowNodeCard.tsx +150 -0
- package/src/flows/FlowNodePanel.tsx +356 -0
- package/src/flows/FlowPalette.tsx +137 -0
- package/src/flows/FlowPortalNode.tsx +30 -0
- package/src/flows/FlowWhatsAppPreview.tsx +67 -0
- package/src/flows/flowGraph.ts +391 -0
- package/src/flows/index.ts +55 -0
- package/src/flows/labels.ts +187 -0
- package/src/hooks/useAsyncResource.ts +38 -0
- package/src/hooks/useConversationContext.ts +23 -0
- package/src/hooks/useConversationDocuments.ts +33 -0
- package/src/hooks/useConversationList.ts +32 -0
- package/src/hooks/useConversationMessages.ts +64 -0
- package/src/hooks/useConversationRealtime.ts +50 -0
- package/src/index.ts +87 -0
- package/src/lib/format.ts +32 -0
- package/src/lib/phone.ts +26 -0
- package/src/lib/whatsapp-formatting.tsx +215 -0
- package/src/providers/ConversationsProvider.tsx +29 -0
- package/src/providers/types.ts +54 -0
- package/src/settings/TopicsForm.tsx +109 -0
- package/src/settings/WelcomeFarewellForm.tsx +118 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +309 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +264 -0
- package/src/styles.css +21 -0
- package/src/theme.ts +30 -0
- package/src/types.ts +44 -0
- package/src/useDarkMode.ts +48 -0
- package/src/useWaitingNotifications.ts +64 -0
- package/tsconfig.json +15 -0
package/dist/styles.css
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/* src/styles.css */
|
|
2
|
+
.cv-wallpaper {
|
|
3
|
+
background-color: #efeae2;
|
|
4
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Cg fill='none' stroke='%23d7cfc0' stroke-width='1.2' opacity='0.7'%3E%3Ccircle cx='15' cy='15' r='3'/%3E%3Cpath d='M40 10 q5 8 0 16 q-5 -8 0 -16z'/%3E%3Ccircle cx='70' cy='28' r='2'/%3E%3Cpath d='M18 55 l6 6 m-6 0 l6 -6'/%3E%3Ccircle cx='55' cy='68' r='2.5'/%3E%3Cpath d='M85 58 q6 6 0 12 q-6 -6 0 -12z'/%3E%3Ccircle cx='8' cy='85' r='2'/%3E%3Cpath d='M65 90 l5 5 m-5 0 l5 -5'/%3E%3C/g%3E%3C/svg%3E");
|
|
5
|
+
background-repeat: repeat;
|
|
6
|
+
background-size: 100px 100px;
|
|
7
|
+
}
|
|
8
|
+
.dark .cv-wallpaper {
|
|
9
|
+
background-color: #0b141a;
|
|
10
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Cg fill='none' stroke='%2319232a' stroke-width='1.2' opacity='0.9'%3E%3Ccircle cx='15' cy='15' r='3'/%3E%3Cpath d='M40 10 q5 8 0 16 q-5 -8 0 -16z'/%3E%3Ccircle cx='70' cy='28' r='2'/%3E%3Cpath d='M18 55 l6 6 m-6 0 l6 -6'/%3E%3Ccircle cx='55' cy='68' r='2.5'/%3E%3Cpath d='M85 58 q6 6 0 12 q-6 -6 0 -12z'/%3E%3Ccircle cx='8' cy='85' r='2'/%3E%3Cpath d='M65 90 l5 5 m-5 0 l5 -5'/%3E%3C/g%3E%3C/svg%3E");
|
|
11
|
+
}
|
package/dist/styles.d.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adatechnology/conversations-ui",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "WhatsApp conversation UI components β parametrizΓ‘vel por endpoint, tema e feature flags",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
},
|
|
14
|
+
"./flows": {
|
|
15
|
+
"import": "./dist/flows/index.js",
|
|
16
|
+
"types": "./dist/flows/index.d.ts"
|
|
17
|
+
},
|
|
18
|
+
"./whatsapp": {
|
|
19
|
+
"import": "./dist/channel/index.js",
|
|
20
|
+
"types": "./dist/channel/index.d.ts"
|
|
21
|
+
},
|
|
22
|
+
"./styles.css": "./dist/styles.css"
|
|
23
|
+
},
|
|
24
|
+
"main": "dist/index.js",
|
|
25
|
+
"types": "dist/index.d.ts",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"lucide-react": "^1.21.0",
|
|
28
|
+
"@adatechnology/meta-whatsapp-contracts": "0.1.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"react": "^18 || ^19",
|
|
32
|
+
"react-dom": "^18 || ^19",
|
|
33
|
+
"@xyflow/react": "^12"
|
|
34
|
+
},
|
|
35
|
+
"peerDependenciesMeta": {
|
|
36
|
+
"@xyflow/react": {
|
|
37
|
+
"optional": true
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"tsup": "^8.5.1",
|
|
42
|
+
"typescript": "^5.9.3",
|
|
43
|
+
"@types/react": "^18 || ^19",
|
|
44
|
+
"@types/react-dom": "^18 || ^19"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsup src/index.ts src/flows/index.ts src/channel/index.ts src/styles.css --dts --format esm --external react --external react-dom --external @xyflow/react",
|
|
48
|
+
"build:watch": "tsup src/index.ts src/flows/index.ts src/channel/index.ts src/styles.css --watch --dts --format esm --external react --external react-dom --external @xyflow/react",
|
|
49
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
50
|
+
"test": "bun test"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type MouseEvent } from 'react'
|
|
2
|
+
import { Pause, Play } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
export interface AudioPlayerProps {
|
|
5
|
+
src: string
|
|
6
|
+
isMine?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Waveform estΓ‘tico com 30 barras de altura pseudoaleatΓ³ria mas estΓ‘vel β paridade com
|
|
10
|
+
// financiamento-imobiliario-bot/apps/web/src/components/AudioPlayer.tsx. Waveform real
|
|
11
|
+
// (a partir do Γ‘udio decodificado), velocidade de reproduΓ§Γ£o, gravador e transcriΓ§Γ£o (STT)
|
|
12
|
+
// ficam como enhancement pΓ³s-paridade (fora do escopo desta task).
|
|
13
|
+
const BARS = Array.from({ length: 30 }, (_, i) => {
|
|
14
|
+
const heights = [3, 5, 8, 12, 7, 10, 14, 9, 6, 11, 15, 8, 4, 13, 7, 10, 6, 12, 9, 5, 14, 8, 11, 6, 13, 7, 10, 5, 9, 4]
|
|
15
|
+
return heights[i % heights.length]
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function fmt(sec: number): string {
|
|
19
|
+
if (!isFinite(sec)) return '0:00'
|
|
20
|
+
const m = Math.floor(sec / 60)
|
|
21
|
+
const s = Math.floor(sec % 60)
|
|
22
|
+
return `${m}:${s.toString().padStart(2, '0')}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function AudioPlayer({ src, isMine = false }: AudioPlayerProps) {
|
|
26
|
+
const audioRef = useRef<HTMLAudioElement>(null)
|
|
27
|
+
const [playing, setPlaying] = useState(false)
|
|
28
|
+
const [progress, setProgress] = useState(0) // 0-1
|
|
29
|
+
const [duration, setDuration] = useState(0)
|
|
30
|
+
const [current, setCurrent] = useState(0)
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const audio = audioRef.current
|
|
34
|
+
if (!audio) return
|
|
35
|
+
const onTime = () => {
|
|
36
|
+
setCurrent(audio.currentTime)
|
|
37
|
+
setProgress(audio.duration ? audio.currentTime / audio.duration : 0)
|
|
38
|
+
}
|
|
39
|
+
const onMeta = () => setDuration(audio.duration)
|
|
40
|
+
const onEnd = () => { setPlaying(false); setProgress(0); setCurrent(0) }
|
|
41
|
+
audio.addEventListener('timeupdate', onTime)
|
|
42
|
+
audio.addEventListener('loadedmetadata', onMeta)
|
|
43
|
+
audio.addEventListener('ended', onEnd)
|
|
44
|
+
return () => {
|
|
45
|
+
audio.removeEventListener('timeupdate', onTime)
|
|
46
|
+
audio.removeEventListener('loadedmetadata', onMeta)
|
|
47
|
+
audio.removeEventListener('ended', onEnd)
|
|
48
|
+
}
|
|
49
|
+
}, [])
|
|
50
|
+
|
|
51
|
+
const toggle = () => {
|
|
52
|
+
const audio = audioRef.current
|
|
53
|
+
if (!audio) return
|
|
54
|
+
if (playing) { audio.pause(); setPlaying(false) }
|
|
55
|
+
else { audio.play(); setPlaying(true) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const seek = (e: MouseEvent<HTMLDivElement>) => {
|
|
59
|
+
const audio = audioRef.current
|
|
60
|
+
if (!audio || !audio.duration) return
|
|
61
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
62
|
+
const ratio = (e.clientX - rect.left) / rect.width
|
|
63
|
+
audio.currentTime = ratio * audio.duration
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const activeBars = Math.round(progress * BARS.length)
|
|
67
|
+
|
|
68
|
+
const playBtn = isMine
|
|
69
|
+
? 'bg-green-600 hover:bg-green-700 text-white'
|
|
70
|
+
: 'bg-gray-600 hover:bg-gray-700 text-white'
|
|
71
|
+
|
|
72
|
+
const activeBar = isMine ? 'bg-green-700' : 'bg-gray-700'
|
|
73
|
+
const inactiveBar = isMine ? 'bg-green-300' : 'bg-gray-300'
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="flex items-center gap-2 w-full" style={{ minWidth: '200px', maxWidth: '260px' }}>
|
|
77
|
+
<audio ref={audioRef} src={src} preload="metadata" />
|
|
78
|
+
|
|
79
|
+
<button
|
|
80
|
+
onClick={toggle}
|
|
81
|
+
className={`flex-shrink-0 w-9 h-9 rounded-full flex items-center justify-center transition-colors ${playBtn}`}
|
|
82
|
+
>
|
|
83
|
+
{playing ? <Pause size={16} /> : <Play size={16} className="translate-x-0.5" />}
|
|
84
|
+
</button>
|
|
85
|
+
|
|
86
|
+
<div className="flex-1 flex flex-col gap-1">
|
|
87
|
+
<div className="flex items-end gap-0.5 h-8 cursor-pointer" onClick={seek}>
|
|
88
|
+
{BARS.map((h, i) => (
|
|
89
|
+
<div
|
|
90
|
+
key={i}
|
|
91
|
+
className={`flex-1 rounded-full transition-colors ${i < activeBars ? activeBar : inactiveBar}`}
|
|
92
|
+
style={{ height: `${h * 2}px` }}
|
|
93
|
+
/>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<span className="text-gray-400 tabular-nums text-xs">
|
|
98
|
+
{playing || current > 0 ? fmt(current) : fmt(duration)}
|
|
99
|
+
</span>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
}
|
package/src/Avatar.tsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export interface AvatarProps {
|
|
2
|
+
name?: string | null
|
|
3
|
+
avatarUrl?: string
|
|
4
|
+
size?: 'sm' | 'md' | 'lg'
|
|
5
|
+
className?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const sizeClasses = {
|
|
9
|
+
sm: 'w-6 h-6 text-xs',
|
|
10
|
+
md: 'w-8 h-8 text-sm',
|
|
11
|
+
lg: 'w-10 h-10 text-base',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const bgColors = [
|
|
15
|
+
'bg-blue-500',
|
|
16
|
+
'bg-green-500',
|
|
17
|
+
'bg-purple-500',
|
|
18
|
+
'bg-pink-500',
|
|
19
|
+
'bg-yellow-500',
|
|
20
|
+
'bg-red-500',
|
|
21
|
+
'bg-indigo-500',
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
function getInitials(name: string | undefined | null): string {
|
|
25
|
+
if (!name) return '?'
|
|
26
|
+
const parts = name.trim().split(' ')
|
|
27
|
+
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
|
28
|
+
return (parts[0]?.substring(0, 2) || '?').toUpperCase()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getBgColor(name: string | undefined | null): string {
|
|
32
|
+
if (!name) return 'bg-gray-400'
|
|
33
|
+
const hash = name.charCodeAt(0) + (name.charCodeAt(name.length - 1) || 0)
|
|
34
|
+
return bgColors[hash % bgColors.length]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Paridade com financiamento-imobiliario-bot/apps/web/src/components/Avatar.tsx β
|
|
38
|
+
// mesmas iniciais (primeira+ΓΊltima letra), mesmo hash de cor, mesmas classes de tamanho.
|
|
39
|
+
export function Avatar({ name, avatarUrl, size = 'md', className = '' }: AvatarProps) {
|
|
40
|
+
const sizeClass = sizeClasses[size]
|
|
41
|
+
|
|
42
|
+
if (avatarUrl) {
|
|
43
|
+
return (
|
|
44
|
+
<img
|
|
45
|
+
src={avatarUrl}
|
|
46
|
+
alt={name || undefined}
|
|
47
|
+
className={`${sizeClass} rounded-full object-cover flex-shrink-0 ${className}`}
|
|
48
|
+
/>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const initials = getInitials(name)
|
|
53
|
+
const bgColor = getBgColor(name)
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
className={`${sizeClass} ${bgColor} rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0 ${className}`}
|
|
58
|
+
title={name || undefined}
|
|
59
|
+
>
|
|
60
|
+
{initials}
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { Avatar } from './Avatar'
|
|
3
|
+
import { formatPhone } from './lib/phone'
|
|
4
|
+
import type { ConversationSummary } from './providers/types'
|
|
5
|
+
|
|
6
|
+
export interface ConversationListItemProps {
|
|
7
|
+
conversation: ConversationSummary
|
|
8
|
+
active?: boolean
|
|
9
|
+
selected?: boolean
|
|
10
|
+
onClick?: () => void
|
|
11
|
+
onSelect?: (id: string) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatRelativeTime(dateStr: string): string {
|
|
15
|
+
try {
|
|
16
|
+
const date = new Date(dateStr)
|
|
17
|
+
const now = new Date()
|
|
18
|
+
const diffMs = now.getTime() - date.getTime()
|
|
19
|
+
const diffMins = Math.floor(diffMs / 60_000)
|
|
20
|
+
const diffHours = Math.floor(diffMs / 3_600_000)
|
|
21
|
+
const diffDays = Math.floor(diffMs / 86_400_000)
|
|
22
|
+
|
|
23
|
+
if (diffMins < 1) return 'Agora'
|
|
24
|
+
if (diffMins < 60) return `${diffMins}m`
|
|
25
|
+
if (diffHours < 24) return `${diffHours}h`
|
|
26
|
+
if (diffDays < 7) return `${diffDays}d`
|
|
27
|
+
const day = date.getDate().toString().padStart(2, '0')
|
|
28
|
+
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
|
29
|
+
return `${day}/${month}`
|
|
30
|
+
} catch {
|
|
31
|
+
return ''
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getWindowStatus(lastInboundAt: string | null): { label: string; color: string } | null {
|
|
36
|
+
if (!lastInboundAt) return null
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const lastInbound = new Date(lastInboundAt).getTime()
|
|
40
|
+
const now = Date.now()
|
|
41
|
+
const hoursElapsed = (now - lastInbound) / 3_600_000
|
|
42
|
+
|
|
43
|
+
if (hoursElapsed <= 12) return { label: 'active', color: '#22c55e' }
|
|
44
|
+
if (hoursElapsed <= 21) return { label: 'approaching', color: '#eab308' }
|
|
45
|
+
if (hoursElapsed <= 24) return { label: 'warning', color: '#f97316' }
|
|
46
|
+
return { label: 'expired', color: '#ef4444' }
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function lastMessagePreview(conversation: ConversationSummary): { icon: string | null; preview: string } {
|
|
53
|
+
if (!conversation.lastContent) return { icon: null, preview: '' }
|
|
54
|
+
|
|
55
|
+
const content = conversation.lastContent
|
|
56
|
+
|
|
57
|
+
if (content.startsWith('[image]') || content.startsWith('[Image]')) {
|
|
58
|
+
return { icon: '\u{1F5BC}', preview: 'Imagem' }
|
|
59
|
+
}
|
|
60
|
+
if (content.startsWith('[video]') || content.startsWith('[Video]')) {
|
|
61
|
+
return { icon: '\u{1F3AC}', preview: 'VΓdeo' }
|
|
62
|
+
}
|
|
63
|
+
if (content.startsWith('[audio]') || content.startsWith('[Audio]')) {
|
|
64
|
+
return { icon: '\u{1F3A4}', preview: 'Γudio' }
|
|
65
|
+
}
|
|
66
|
+
if (content.startsWith('[document]') || content.startsWith('[Document]')) {
|
|
67
|
+
return { icon: '\u{1F4C4}', preview: 'Documento' }
|
|
68
|
+
}
|
|
69
|
+
if (content.startsWith('[sticker]') || content.startsWith('[Sticker]')) {
|
|
70
|
+
return { icon: '\u{1F3AF}', preview: 'Sticker' }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { icon: null, preview: content }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const ConversationListItem = ({
|
|
77
|
+
conversation,
|
|
78
|
+
active = false,
|
|
79
|
+
selected = false,
|
|
80
|
+
onClick,
|
|
81
|
+
onSelect,
|
|
82
|
+
}: ConversationListItemProps) => {
|
|
83
|
+
const isActive = active || selected
|
|
84
|
+
const windowStatus = useMemo(() => getWindowStatus(conversation.lastInboundAt), [conversation.lastInboundAt])
|
|
85
|
+
const preview = useMemo(() => lastMessagePreview(conversation), [conversation.lastContent])
|
|
86
|
+
const name = conversation.clientName || formatPhone(conversation.whatsappNumber) || conversation.whatsappNumber
|
|
87
|
+
const timestamp = formatRelativeTime(conversation.lastAt)
|
|
88
|
+
const isInbound = conversation.lastDirection === 'inbound'
|
|
89
|
+
|
|
90
|
+
const handleClick = () => {
|
|
91
|
+
onClick?.()
|
|
92
|
+
onSelect?.(conversation.id)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<button
|
|
97
|
+
onClick={handleClick}
|
|
98
|
+
className="w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-[#f0f2f5] border-b border-[#e9edef]"
|
|
99
|
+
style={{
|
|
100
|
+
backgroundColor: isActive ? '#f0f2f5' : 'transparent',
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<div className="relative flex-shrink-0">
|
|
104
|
+
<Avatar name={name} size="lg" />
|
|
105
|
+
{conversation.waitingHuman && (
|
|
106
|
+
<div className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 bg-amber-400 rounded-full border-2 border-white" />
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<div className="flex-1 min-w-0">
|
|
111
|
+
<div className="flex items-center justify-between gap-2">
|
|
112
|
+
<div className="flex items-center gap-1.5 min-w-0">
|
|
113
|
+
<span className="text-[16px] text-[#111b21] truncate">{name}</span>
|
|
114
|
+
{windowStatus && windowStatus.label === 'expired' && (
|
|
115
|
+
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title="Janela expirada" />
|
|
116
|
+
)}
|
|
117
|
+
{windowStatus && windowStatus.label === 'warning' && (
|
|
118
|
+
<span className="w-2 h-2 rounded-full bg-orange-500 flex-shrink-0" title="Janela prΓ³xima do fim" />
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
{timestamp && (
|
|
122
|
+
<span className="text-xs text-[#667781] flex-shrink-0">{timestamp}</span>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
<div className="flex items-center justify-between mt-0.5">
|
|
126
|
+
<span className="text-sm text-[#667781] truncate max-w-[180px]">
|
|
127
|
+
{preview.icon && <span className="mr-1">{preview.icon}</span>}
|
|
128
|
+
{preview.preview || 'Sem mensagens'}
|
|
129
|
+
</span>
|
|
130
|
+
{conversation.unread > 0 && (
|
|
131
|
+
<span className="bg-[#25d366] text-white text-xs font-semibold rounded-full min-w-[20px] h-5 px-1.5 flex items-center justify-center flex-shrink-0 ml-2">
|
|
132
|
+
{conversation.unread > 99 ? '99+' : conversation.unread}
|
|
133
|
+
</span>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
<div className="flex items-center gap-1.5 mt-1">
|
|
137
|
+
{conversation.mode === 'human' && (
|
|
138
|
+
<span className="text-[10px] px-1.5 py-0.5 rounded font-medium bg-purple-100 text-purple-700">Humano</span>
|
|
139
|
+
)}
|
|
140
|
+
{conversation.mode === 'bot' && conversation.waitingHuman && (
|
|
141
|
+
<span className="text-[10px] px-1.5 py-0.5 rounded font-medium bg-amber-100 text-amber-700">Aguardando</span>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</button>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import React, { createContext, useContext } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface ConversationLocales {
|
|
4
|
+
bubble: {
|
|
5
|
+
customer: string
|
|
6
|
+
bot: string
|
|
7
|
+
agent: string
|
|
8
|
+
document: string
|
|
9
|
+
media: string
|
|
10
|
+
templateLabel: string
|
|
11
|
+
readAt: string
|
|
12
|
+
windowExpired: string
|
|
13
|
+
viewImage: string
|
|
14
|
+
listenAudio: string
|
|
15
|
+
viewVideo: string
|
|
16
|
+
}
|
|
17
|
+
selection: {
|
|
18
|
+
select: string
|
|
19
|
+
}
|
|
20
|
+
dateDivider: {
|
|
21
|
+
today: string
|
|
22
|
+
yesterday: string
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_LOCALES: ConversationLocales = {
|
|
27
|
+
bubble: {
|
|
28
|
+
customer: 'Cliente',
|
|
29
|
+
bot: 'Bot',
|
|
30
|
+
agent: 'Atendente',
|
|
31
|
+
document: 'documento',
|
|
32
|
+
media: 'mΓdia',
|
|
33
|
+
templateLabel: 'Template',
|
|
34
|
+
readAt: 'Lida Γ s ',
|
|
35
|
+
windowExpired: 'Janela de 24h expirada',
|
|
36
|
+
viewImage: 'Ver imagem',
|
|
37
|
+
listenAudio: 'Ouvir Γ‘udio',
|
|
38
|
+
viewVideo: 'Ver vΓdeo',
|
|
39
|
+
},
|
|
40
|
+
selection: {
|
|
41
|
+
select: 'Selecionar',
|
|
42
|
+
},
|
|
43
|
+
dateDivider: {
|
|
44
|
+
today: 'Hoje',
|
|
45
|
+
yesterday: 'Ontem',
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type PartialLocales = {
|
|
50
|
+
[K in keyof ConversationLocales]?: Partial<ConversationLocales[K]>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ConversationLocalesContext = createContext<ConversationLocales>(DEFAULT_LOCALES)
|
|
54
|
+
|
|
55
|
+
export interface ConversationLocalesProviderProps {
|
|
56
|
+
children: React.ReactNode
|
|
57
|
+
locales?: PartialLocales
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function ConversationLocalesProvider({ children, locales }: ConversationLocalesProviderProps) {
|
|
61
|
+
const merged: ConversationLocales = {
|
|
62
|
+
bubble: { ...DEFAULT_LOCALES.bubble, ...locales?.bubble },
|
|
63
|
+
selection: { ...DEFAULT_LOCALES.selection, ...locales?.selection },
|
|
64
|
+
dateDivider: { ...DEFAULT_LOCALES.dateDivider, ...locales?.dateDivider },
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<ConversationLocalesContext.Provider value={merged}>
|
|
69
|
+
{children}
|
|
70
|
+
</ConversationLocalesContext.Provider>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function useConversationLocales(): ConversationLocales {
|
|
75
|
+
return useContext(ConversationLocalesContext)
|
|
76
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useConversationLocales } from './ConversationLocalesProvider'
|
|
2
|
+
import { isSameDay } from './lib/format'
|
|
3
|
+
|
|
4
|
+
export interface DateDividerProps {
|
|
5
|
+
iso: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Paridade com financiamento-imobiliario-bot/apps/web/src/components/DateDivider.tsx β
|
|
9
|
+
// sticky no topo do scroll, "Hoje"/"Ontem" localizados, senΓ£o data completa pt-BR.
|
|
10
|
+
export function DateDivider({ iso }: DateDividerProps) {
|
|
11
|
+
const { dateDivider } = useConversationLocales()
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="flex justify-center sticky top-0 z-10 my-2 pointer-events-none">
|
|
15
|
+
<span className="bg-white/95 dark:bg-gray-800/95 text-gray-600 dark:text-gray-300 text-xs font-medium px-3 py-1 rounded-lg shadow-sm">
|
|
16
|
+
{formatDividerLabel(iso, dateDivider)}
|
|
17
|
+
</span>
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatDividerLabel(iso: string, dateDivider: { today: string; yesterday: string }): string {
|
|
23
|
+
const date = new Date(iso)
|
|
24
|
+
const today = new Date()
|
|
25
|
+
const yesterday = new Date(today)
|
|
26
|
+
yesterday.setDate(today.getDate() - 1)
|
|
27
|
+
|
|
28
|
+
if (isSameDay(date, today)) return dateDivider.today
|
|
29
|
+
if (isSameDay(date, yesterday)) return dateDivider.yesterday
|
|
30
|
+
|
|
31
|
+
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' })
|
|
32
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface EmojiPickerProps {
|
|
4
|
+
onSelect: (emoji: string) => void
|
|
5
|
+
className?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const EMOJI_CATEGORIES: { name: string; emojis: string[] }[] = [
|
|
9
|
+
{
|
|
10
|
+
name: 'Smileys',
|
|
11
|
+
emojis: ['π', 'π', 'π', 'π', 'π
', 'π', 'π€£', 'π', 'π', 'π', 'π', 'π', 'π', 'π₯°', 'π', 'π', 'π', 'π', 'π', 'π€ͺ'],
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: 'Gestures',
|
|
15
|
+
emojis: ['π', 'π', 'π', 'βοΈ', 'π€', 'π€', 'π€', 'π€', 'π', 'π€', 'ποΈ', 'β', 'π', 'π', 'π', 'π€', 'π', 'βοΈ', 'π
', 'π€³'],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'Hearts',
|
|
19
|
+
emojis: ['β€οΈ', 'π§‘', 'π', 'π', 'π', 'π', 'π€', 'π€', 'π€', 'π', 'β£οΈ', 'π', 'π', 'π', 'π', 'π', 'π', 'π', 'π', 'β₯οΈ'],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'Food',
|
|
23
|
+
emojis: ['π', 'π', 'π', 'π', 'πΏ', 'π§', 'π₯', 'π₯', 'π³', 'π§', 'π₯', 'π§', 'π', 'π₯', 'π₯¨', 'π₯―', 'π₯', 'π§', 'π₯', 'π₯'],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'Drinks',
|
|
27
|
+
emojis: ['β', 'π΅', 'πΆ', 'πΎ', 'π·', 'πΈ', 'πΉ', 'πΊ', 'π»', 'π₯', 'π₯', 'π₯€', 'π§', 'π§', 'π§', 'π§', 'π₯’', 'π½οΈ', 'π΄', 'π₯'],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'Objects',
|
|
31
|
+
emojis: ['π', 'π', 'π', 'π', 'π', 'π', 'π±', 'π»', 'β', 'π·', 'π', 'π°', 'π³', 'π', 'π', 'π', 'βοΈ', 'π', 'π‘', 'π'],
|
|
32
|
+
},
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
export const EmojiPicker = ({ onSelect, className = '' }: EmojiPickerProps) => {
|
|
36
|
+
const [activeCategory, setActiveCategory] = useState(0)
|
|
37
|
+
|
|
38
|
+
const handleSelect = useCallback(
|
|
39
|
+
(emoji: string) => {
|
|
40
|
+
onSelect(emoji)
|
|
41
|
+
},
|
|
42
|
+
[onSelect],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className={`bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden ${className}`}>
|
|
47
|
+
<div className="flex border-b border-gray-200 overflow-x-auto">
|
|
48
|
+
{EMOJI_CATEGORIES.map((category, index) => (
|
|
49
|
+
<button
|
|
50
|
+
key={category.name}
|
|
51
|
+
onClick={() => setActiveCategory(index)}
|
|
52
|
+
className={`px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors ${
|
|
53
|
+
activeCategory === index
|
|
54
|
+
? 'border-blue-500 text-blue-600'
|
|
55
|
+
: 'border-transparent text-gray-500 hover:text-gray-700'
|
|
56
|
+
}`}
|
|
57
|
+
>
|
|
58
|
+
{category.emojis[0]} {category.name}
|
|
59
|
+
</button>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div className="grid grid-cols-10 gap-0.5 p-2 max-h-[240px] overflow-y-auto">
|
|
64
|
+
{EMOJI_CATEGORIES[activeCategory].emojis.map((emoji) => (
|
|
65
|
+
<button
|
|
66
|
+
key={emoji}
|
|
67
|
+
onClick={() => handleSelect(emoji)}
|
|
68
|
+
className="w-8 h-8 flex items-center justify-center text-lg hover:bg-gray-100 rounded transition-colors cursor-pointer"
|
|
69
|
+
aria-label={emoji}
|
|
70
|
+
>
|
|
71
|
+
{emoji}
|
|
72
|
+
</button>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
package/src/FileIcon.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { FileArchive, FileSpreadsheet, FileText, File as FileGeneric } from 'lucide-react'
|
|
2
|
+
|
|
3
|
+
export interface FileIconProps {
|
|
4
|
+
filename?: string
|
|
5
|
+
mimeType?: string
|
|
6
|
+
size?: number
|
|
7
|
+
className?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const EXTENSION_STYLE: Record<string, { Icon: typeof FileText; colorClass: string }> = {
|
|
11
|
+
pdf: { Icon: FileText, colorClass: 'text-red-500' },
|
|
12
|
+
doc: { Icon: FileText, colorClass: 'text-blue-500' },
|
|
13
|
+
docx: { Icon: FileText, colorClass: 'text-blue-500' },
|
|
14
|
+
xls: { Icon: FileSpreadsheet, colorClass: 'text-green-600' },
|
|
15
|
+
xlsx: { Icon: FileSpreadsheet, colorClass: 'text-green-600' },
|
|
16
|
+
zip: { Icon: FileArchive, colorClass: 'text-orange-500' },
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getExtension(filename?: string, mimeType?: string): string {
|
|
20
|
+
const fromFilename = filename?.split('.').pop()?.toLowerCase()
|
|
21
|
+
if (fromFilename && EXTENSION_STYLE[fromFilename]) return fromFilename
|
|
22
|
+
return mimeType?.split('/')[1]?.toLowerCase() ?? ''
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Melhoria sobre a paridade da bolha de documento (T6.7 usava um ΓΊnico Γcone genΓ©rico
|
|
26
|
+
// para qualquer tipo de arquivo) β Γcone e cor variam por extensΓ£o/mimeType.
|
|
27
|
+
export function FileIcon({ filename, mimeType, size = 20, className = '' }: FileIconProps) {
|
|
28
|
+
const extension = getExtension(filename, mimeType)
|
|
29
|
+
const style = EXTENSION_STYLE[extension] ?? { Icon: FileGeneric, colorClass: 'text-gray-500' }
|
|
30
|
+
const { Icon, colorClass } = style
|
|
31
|
+
|
|
32
|
+
return <Icon size={size} className={`${colorClass} ${className}`.trim()} />
|
|
33
|
+
}
|
package/src/Lightbox.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface LightboxProps {
|
|
2
|
+
imageUrl: string
|
|
3
|
+
caption?: string
|
|
4
|
+
onClose: () => void
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function Lightbox({ imageUrl, caption, onClose }: LightboxProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-4" onClick={onClose}>
|
|
10
|
+
<div className="max-w-[90vw] max-h-[90vh] flex flex-col items-center" onClick={(e) => e.stopPropagation()}>
|
|
11
|
+
<img src={imageUrl} alt={caption ?? 'Image'} className="max-w-full max-h-[80vh] object-contain rounded-lg" />
|
|
12
|
+
{caption && <p className="text-white text-sm mt-3 text-center">{caption}</p>}
|
|
13
|
+
<button onClick={onClose} className="mt-4 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition-colors">Fechar</button>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
)
|
|
17
|
+
}
|