@greatapps/greatchat-ui 0.1.1 → 0.1.3
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 +290 -1
- package/dist/index.js +1534 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/components/contact-avatar.tsx +59 -0
- package/src/components/contact-info-panel.tsx +139 -0
- package/src/components/inbox-item.tsx +149 -0
- package/src/components/inbox-sidebar.tsx +148 -0
- package/src/components/index.ts +15 -0
- package/src/components/new-conversation-dialog.tsx +172 -0
- package/src/components/ui/avatar.tsx +51 -0
- package/src/components/ui/command.tsx +106 -0
- package/src/components/ui/dialog.tsx +133 -0
- package/src/components/ui/input.tsx +19 -0
- package/src/components/ui/scroll-area.tsx +50 -0
- package/src/components/ui/separator.tsx +26 -0
- package/src/components/ui/tabs.tsx +64 -0
- package/src/components/ui/tooltip.tsx +58 -0
- package/src/hooks/index.ts +14 -0
- package/src/hooks/types.ts +40 -0
- package/src/hooks/use-channels.ts +163 -0
- package/src/hooks/use-contacts.ts +94 -0
- package/src/hooks/use-inbox-messages.ts +405 -0
- package/src/hooks/use-inboxes.ts +127 -0
- package/src/index.ts +23 -2
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Avatar, AvatarFallback } from "./ui/avatar";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
const COLORS = [
|
|
5
|
+
"bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
|
6
|
+
"bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
|
|
7
|
+
"bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
|
8
|
+
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
|
|
9
|
+
"bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300",
|
|
10
|
+
"bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
|
|
11
|
+
"bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300",
|
|
12
|
+
"bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const SIZE_MAP = {
|
|
16
|
+
sm: "h-8 w-8 text-xs",
|
|
17
|
+
md: "h-10 w-10 text-xs",
|
|
18
|
+
lg: "h-16 w-16 text-lg",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
function hashCode(str: string): number {
|
|
22
|
+
let hash = 0;
|
|
23
|
+
for (let i = 0; i < str.length; i++) {
|
|
24
|
+
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
25
|
+
}
|
|
26
|
+
return Math.abs(hash);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getInitials(name: string): string {
|
|
30
|
+
const parts = name.trim().split(/\s+/);
|
|
31
|
+
if (parts.length >= 2) {
|
|
32
|
+
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
33
|
+
}
|
|
34
|
+
return (name[0] || "?").toUpperCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ContactAvatarProps {
|
|
38
|
+
name: string | null;
|
|
39
|
+
className?: string;
|
|
40
|
+
size?: "sm" | "md" | "lg";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ContactAvatar({
|
|
44
|
+
name,
|
|
45
|
+
className,
|
|
46
|
+
size = "md",
|
|
47
|
+
}: ContactAvatarProps) {
|
|
48
|
+
const displayName = name || "?";
|
|
49
|
+
const color = COLORS[hashCode(displayName) % COLORS.length];
|
|
50
|
+
const initials = getInitials(displayName);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Avatar className={cn(SIZE_MAP[size], className)}>
|
|
54
|
+
<AvatarFallback className={cn("font-medium", color)}>
|
|
55
|
+
{initials}
|
|
56
|
+
</AvatarFallback>
|
|
57
|
+
</Avatar>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Contact } from "../types";
|
|
2
|
+
import { ContactAvatar } from "./contact-avatar";
|
|
3
|
+
import { Button } from "./ui/button";
|
|
4
|
+
import { Skeleton } from "./ui/skeleton";
|
|
5
|
+
import { Separator } from "./ui/separator";
|
|
6
|
+
import {
|
|
7
|
+
X,
|
|
8
|
+
Phone,
|
|
9
|
+
Fingerprint,
|
|
10
|
+
Calendar,
|
|
11
|
+
CalendarClock,
|
|
12
|
+
Hash,
|
|
13
|
+
} from "lucide-react";
|
|
14
|
+
import { format } from "date-fns";
|
|
15
|
+
import { ptBR } from "date-fns/locale";
|
|
16
|
+
import { cn } from "../lib/utils";
|
|
17
|
+
|
|
18
|
+
function InfoRow({
|
|
19
|
+
icon: Icon,
|
|
20
|
+
label,
|
|
21
|
+
value,
|
|
22
|
+
}: {
|
|
23
|
+
icon: React.ElementType;
|
|
24
|
+
label: string;
|
|
25
|
+
value: string | null | undefined;
|
|
26
|
+
}) {
|
|
27
|
+
if (!value) return null;
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex items-start gap-3 py-2">
|
|
30
|
+
<Icon className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
|
31
|
+
<div className="min-w-0">
|
|
32
|
+
<p className="text-xs text-muted-foreground">{label}</p>
|
|
33
|
+
<p className="text-sm break-all">{value}</p>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ContactInfoPanelProps {
|
|
40
|
+
contact: Contact | null;
|
|
41
|
+
isLoading?: boolean;
|
|
42
|
+
onClose?: () => void;
|
|
43
|
+
className?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function ContactInfoPanel({
|
|
47
|
+
contact,
|
|
48
|
+
isLoading,
|
|
49
|
+
onClose,
|
|
50
|
+
className,
|
|
51
|
+
}: ContactInfoPanelProps) {
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
className={cn(
|
|
55
|
+
"flex h-full w-[320px] shrink-0 flex-col border-l",
|
|
56
|
+
className,
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
{/* Header */}
|
|
60
|
+
<div className="flex items-center justify-between border-b px-4 py-3">
|
|
61
|
+
<span className="text-sm font-medium">Informações do contato</span>
|
|
62
|
+
{onClose && (
|
|
63
|
+
<Button
|
|
64
|
+
variant="ghost"
|
|
65
|
+
size="icon"
|
|
66
|
+
className="h-7 w-7"
|
|
67
|
+
onClick={onClose}
|
|
68
|
+
>
|
|
69
|
+
<X className="h-4 w-4" />
|
|
70
|
+
</Button>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{/* Content */}
|
|
75
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
76
|
+
{isLoading ? (
|
|
77
|
+
<div className="flex flex-col items-center gap-3 pt-4">
|
|
78
|
+
<Skeleton className="h-16 w-16 rounded-full" />
|
|
79
|
+
<Skeleton className="h-5 w-32" />
|
|
80
|
+
<Skeleton className="h-4 w-24" />
|
|
81
|
+
</div>
|
|
82
|
+
) : contact ? (
|
|
83
|
+
<>
|
|
84
|
+
{/* Avatar + Name */}
|
|
85
|
+
<div className="flex flex-col items-center gap-2 pb-4">
|
|
86
|
+
<ContactAvatar name={contact.name} size="lg" />
|
|
87
|
+
<h3 className="text-base font-medium text-center">
|
|
88
|
+
{contact.name || "Desconhecido"}
|
|
89
|
+
</h3>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<Separator />
|
|
93
|
+
|
|
94
|
+
{/* Details */}
|
|
95
|
+
<div className="mt-3 space-y-1">
|
|
96
|
+
<InfoRow icon={Hash} label="ID" value={contact.id?.toString()} />
|
|
97
|
+
<InfoRow
|
|
98
|
+
icon={Phone}
|
|
99
|
+
label="Telefone"
|
|
100
|
+
value={contact.phone_number}
|
|
101
|
+
/>
|
|
102
|
+
<InfoRow
|
|
103
|
+
icon={Fingerprint}
|
|
104
|
+
label="Identificador"
|
|
105
|
+
value={contact.identifier}
|
|
106
|
+
/>
|
|
107
|
+
<InfoRow
|
|
108
|
+
icon={Calendar}
|
|
109
|
+
label="Criado em"
|
|
110
|
+
value={
|
|
111
|
+
contact.datetime_add
|
|
112
|
+
? format(
|
|
113
|
+
new Date(contact.datetime_add),
|
|
114
|
+
"dd/MM/yyyy 'às' HH:mm",
|
|
115
|
+
{ locale: ptBR },
|
|
116
|
+
)
|
|
117
|
+
: null
|
|
118
|
+
}
|
|
119
|
+
/>
|
|
120
|
+
<InfoRow
|
|
121
|
+
icon={CalendarClock}
|
|
122
|
+
label="Atualizado em"
|
|
123
|
+
value={
|
|
124
|
+
contact.datetime_alt
|
|
125
|
+
? format(
|
|
126
|
+
new Date(contact.datetime_alt),
|
|
127
|
+
"dd/MM/yyyy 'às' HH:mm",
|
|
128
|
+
{ locale: ptBR },
|
|
129
|
+
)
|
|
130
|
+
: null
|
|
131
|
+
}
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
</>
|
|
135
|
+
) : null}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Inbox } from "../types";
|
|
3
|
+
import { ContactAvatar } from "./contact-avatar";
|
|
4
|
+
import { Badge } from "./ui/badge";
|
|
5
|
+
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
|
|
6
|
+
import {
|
|
7
|
+
AlertDialog,
|
|
8
|
+
AlertDialogAction,
|
|
9
|
+
AlertDialogCancel,
|
|
10
|
+
AlertDialogContent,
|
|
11
|
+
AlertDialogDescription,
|
|
12
|
+
AlertDialogFooter,
|
|
13
|
+
AlertDialogHeader,
|
|
14
|
+
AlertDialogTitle,
|
|
15
|
+
} from "./ui/alert-dialog";
|
|
16
|
+
import { formatDistanceToNow } from "date-fns";
|
|
17
|
+
import { ptBR } from "date-fns/locale";
|
|
18
|
+
import { Trash2 } from "lucide-react";
|
|
19
|
+
import { cn } from "../lib/utils";
|
|
20
|
+
|
|
21
|
+
const statusColors: Record<string, string> = {
|
|
22
|
+
open: "bg-green-500/10 text-green-600 border-green-200",
|
|
23
|
+
pending: "bg-yellow-500/10 text-yellow-600 border-yellow-200",
|
|
24
|
+
resolved: "bg-zinc-500/10 text-zinc-500 border-zinc-200",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const statusLabels: Record<string, string> = {
|
|
28
|
+
open: "Aberta",
|
|
29
|
+
pending: "Pendente",
|
|
30
|
+
resolved: "Resolvida",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export interface InboxItemProps {
|
|
34
|
+
inbox: Inbox;
|
|
35
|
+
isSelected: boolean;
|
|
36
|
+
onClick: () => void;
|
|
37
|
+
onDelete?: (id: number) => void;
|
|
38
|
+
renderActions?: (inbox: Inbox) => React.ReactNode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function InboxItem({
|
|
42
|
+
inbox,
|
|
43
|
+
isSelected,
|
|
44
|
+
onClick,
|
|
45
|
+
onDelete,
|
|
46
|
+
renderActions,
|
|
47
|
+
}: InboxItemProps) {
|
|
48
|
+
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
49
|
+
|
|
50
|
+
const timestamp = inbox.last_message_at || inbox.datetime_add;
|
|
51
|
+
const timeAgo = formatDistanceToNow(new Date(timestamp), {
|
|
52
|
+
addSuffix: true,
|
|
53
|
+
locale: ptBR,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const lastMessage =
|
|
57
|
+
inbox.last_message_content_type === "text"
|
|
58
|
+
? inbox.last_message_content
|
|
59
|
+
: inbox.last_message_content_type
|
|
60
|
+
? `[${inbox.last_message_content_type}]`
|
|
61
|
+
: null;
|
|
62
|
+
|
|
63
|
+
const directionPrefix =
|
|
64
|
+
inbox.last_message_direction === "outbound" ? "Você: " : "";
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<>
|
|
68
|
+
<div
|
|
69
|
+
className={cn(
|
|
70
|
+
"flex w-full items-start gap-3 rounded-md p-3 text-left transition-colors hover:bg-accent group relative cursor-pointer",
|
|
71
|
+
isSelected && "bg-accent",
|
|
72
|
+
)}
|
|
73
|
+
onClick={onClick}
|
|
74
|
+
>
|
|
75
|
+
<ContactAvatar
|
|
76
|
+
name={inbox.contact_name}
|
|
77
|
+
className="mt-0.5"
|
|
78
|
+
size="md"
|
|
79
|
+
/>
|
|
80
|
+
|
|
81
|
+
<div className="flex-1 overflow-hidden">
|
|
82
|
+
<div className="flex items-center justify-between gap-2">
|
|
83
|
+
<span className="truncate font-medium text-sm">
|
|
84
|
+
{inbox.contact_name || "Desconhecido"}
|
|
85
|
+
</span>
|
|
86
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
87
|
+
<span className="text-xs text-muted-foreground">{timeAgo}</span>
|
|
88
|
+
{renderActions?.(inbox)}
|
|
89
|
+
{onDelete && (
|
|
90
|
+
<Tooltip>
|
|
91
|
+
<TooltipTrigger asChild>
|
|
92
|
+
<button
|
|
93
|
+
className="h-5 w-5 rounded flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-muted text-destructive"
|
|
94
|
+
onClick={(e) => {
|
|
95
|
+
e.stopPropagation();
|
|
96
|
+
setShowDeleteDialog(true);
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
100
|
+
</button>
|
|
101
|
+
</TooltipTrigger>
|
|
102
|
+
<TooltipContent>Excluir conversa</TooltipContent>
|
|
103
|
+
</Tooltip>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div className="flex items-center justify-between gap-2 mt-0.5">
|
|
109
|
+
<p className="truncate text-xs text-muted-foreground">
|
|
110
|
+
{lastMessage
|
|
111
|
+
? `${directionPrefix}${lastMessage}`
|
|
112
|
+
: "Sem mensagens"}
|
|
113
|
+
</p>
|
|
114
|
+
<Badge
|
|
115
|
+
variant="outline"
|
|
116
|
+
className={cn(
|
|
117
|
+
"shrink-0 text-[10px] px-1.5 py-0",
|
|
118
|
+
statusColors[inbox.status],
|
|
119
|
+
)}
|
|
120
|
+
>
|
|
121
|
+
{statusLabels[inbox.status] || inbox.status}
|
|
122
|
+
</Badge>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
|
128
|
+
<AlertDialogContent>
|
|
129
|
+
<AlertDialogHeader>
|
|
130
|
+
<AlertDialogTitle>Excluir conversa?</AlertDialogTitle>
|
|
131
|
+
<AlertDialogDescription>
|
|
132
|
+
A conversa com {inbox.contact_name || "este contato"} será removida
|
|
133
|
+
da lista. As mensagens não serão apagadas do WhatsApp.
|
|
134
|
+
</AlertDialogDescription>
|
|
135
|
+
</AlertDialogHeader>
|
|
136
|
+
<AlertDialogFooter>
|
|
137
|
+
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
|
138
|
+
<AlertDialogAction
|
|
139
|
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
140
|
+
onClick={() => onDelete?.(inbox.id)}
|
|
141
|
+
>
|
|
142
|
+
Excluir
|
|
143
|
+
</AlertDialogAction>
|
|
144
|
+
</AlertDialogFooter>
|
|
145
|
+
</AlertDialogContent>
|
|
146
|
+
</AlertDialog>
|
|
147
|
+
</>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useState, useMemo } from "react";
|
|
2
|
+
import type { Inbox } from "../types";
|
|
3
|
+
import { InboxItem } from "./inbox-item";
|
|
4
|
+
import { Input } from "./ui/input";
|
|
5
|
+
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
|
6
|
+
import { Button } from "./ui/button";
|
|
7
|
+
import { Skeleton } from "./ui/skeleton";
|
|
8
|
+
import { ScrollArea } from "./ui/scroll-area";
|
|
9
|
+
import { Search, Plus, Inbox as InboxIcon } from "lucide-react";
|
|
10
|
+
|
|
11
|
+
const STATUS_TABS = [
|
|
12
|
+
{ value: "all", label: "Todas" },
|
|
13
|
+
{ value: "open", label: "Abertas" },
|
|
14
|
+
{ value: "pending", label: "Pendentes" },
|
|
15
|
+
{ value: "resolved", label: "Resolvidas" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export interface InboxSidebarProps {
|
|
19
|
+
inboxes: Inbox[] | undefined;
|
|
20
|
+
isLoading: boolean;
|
|
21
|
+
selectedInboxId: number | null;
|
|
22
|
+
onSelectInbox: (inbox: Inbox) => void;
|
|
23
|
+
onDeleteInbox?: (id: number) => void;
|
|
24
|
+
onCreateInbox?: () => void;
|
|
25
|
+
filterChannelId?: number | null;
|
|
26
|
+
renderHeader?: React.ReactNode;
|
|
27
|
+
renderFooter?: React.ReactNode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function InboxSidebar({
|
|
31
|
+
inboxes,
|
|
32
|
+
isLoading,
|
|
33
|
+
selectedInboxId,
|
|
34
|
+
onSelectInbox,
|
|
35
|
+
onDeleteInbox,
|
|
36
|
+
onCreateInbox,
|
|
37
|
+
filterChannelId,
|
|
38
|
+
renderHeader,
|
|
39
|
+
renderFooter,
|
|
40
|
+
}: InboxSidebarProps) {
|
|
41
|
+
const [search, setSearch] = useState("");
|
|
42
|
+
const [statusFilter, setStatusFilter] = useState("all");
|
|
43
|
+
|
|
44
|
+
const filtered = useMemo(() => {
|
|
45
|
+
if (!inboxes) return [];
|
|
46
|
+
let list = inboxes;
|
|
47
|
+
|
|
48
|
+
if (filterChannelId != null) {
|
|
49
|
+
list = list.filter((i) => i.id_channel === filterChannelId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (statusFilter !== "all") {
|
|
53
|
+
list = list.filter((i) => i.status === statusFilter);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (search) {
|
|
57
|
+
const q = search.toLowerCase();
|
|
58
|
+
list = list.filter(
|
|
59
|
+
(i) =>
|
|
60
|
+
i.contact_name?.toLowerCase().includes(q) ||
|
|
61
|
+
i.contact_phone?.toLowerCase().includes(q) ||
|
|
62
|
+
i.contact_identifier?.toLowerCase().includes(q) ||
|
|
63
|
+
i.last_message_content?.toLowerCase().includes(q),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return [...list].sort((a, b) => {
|
|
68
|
+
const dateA = a.last_message_at || a.datetime_add;
|
|
69
|
+
const dateB = b.last_message_at || b.datetime_add;
|
|
70
|
+
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
|
71
|
+
});
|
|
72
|
+
}, [inboxes, statusFilter, search, filterChannelId]);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="flex h-full flex-col">
|
|
76
|
+
{renderHeader}
|
|
77
|
+
|
|
78
|
+
<div className="flex flex-col gap-3 p-3">
|
|
79
|
+
<div className="flex items-center justify-between gap-2">
|
|
80
|
+
<div className="relative flex-1">
|
|
81
|
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
82
|
+
<Input
|
|
83
|
+
placeholder="Buscar..."
|
|
84
|
+
value={search}
|
|
85
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
86
|
+
className="pl-9 h-8 text-sm"
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
{onCreateInbox && (
|
|
90
|
+
<Button
|
|
91
|
+
size="icon"
|
|
92
|
+
className="h-8 w-8 shrink-0"
|
|
93
|
+
onClick={onCreateInbox}
|
|
94
|
+
>
|
|
95
|
+
<Plus className="h-4 w-4" />
|
|
96
|
+
</Button>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
<Tabs value={statusFilter} onValueChange={setStatusFilter}>
|
|
100
|
+
<TabsList className="w-full">
|
|
101
|
+
{STATUS_TABS.map((tab) => (
|
|
102
|
+
<TabsTrigger
|
|
103
|
+
key={tab.value}
|
|
104
|
+
value={tab.value}
|
|
105
|
+
className="flex-1 text-xs"
|
|
106
|
+
>
|
|
107
|
+
{tab.label}
|
|
108
|
+
</TabsTrigger>
|
|
109
|
+
))}
|
|
110
|
+
</TabsList>
|
|
111
|
+
</Tabs>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<ScrollArea className="flex-1 px-2">
|
|
115
|
+
{isLoading ? (
|
|
116
|
+
<div className="space-y-2 p-2">
|
|
117
|
+
{Array.from({ length: 6 }).map((_, i) => (
|
|
118
|
+
<div key={i} className="flex items-center gap-3 p-3">
|
|
119
|
+
<Skeleton className="h-10 w-10 rounded-full shrink-0" />
|
|
120
|
+
<div className="flex-1 space-y-1.5">
|
|
121
|
+
<Skeleton className="h-4 w-28" />
|
|
122
|
+
<Skeleton className="h-3 w-40" />
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
) : filtered.length === 0 ? (
|
|
128
|
+
<div className="flex flex-col items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
|
|
129
|
+
<InboxIcon className="h-8 w-8 opacity-50" />
|
|
130
|
+
Nenhuma conversa encontrada
|
|
131
|
+
</div>
|
|
132
|
+
) : (
|
|
133
|
+
filtered.map((inbox) => (
|
|
134
|
+
<InboxItem
|
|
135
|
+
key={inbox.id}
|
|
136
|
+
inbox={inbox}
|
|
137
|
+
isSelected={inbox.id === selectedInboxId}
|
|
138
|
+
onClick={() => onSelectInbox(inbox)}
|
|
139
|
+
onDelete={onDeleteInbox}
|
|
140
|
+
/>
|
|
141
|
+
))
|
|
142
|
+
)}
|
|
143
|
+
</ScrollArea>
|
|
144
|
+
|
|
145
|
+
{renderFooter}
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -6,3 +6,18 @@ export type { ChatInputProps } from "./chat-input";
|
|
|
6
6
|
|
|
7
7
|
export { MessageBubble } from "./message-bubble";
|
|
8
8
|
export type { MessageBubbleProps } from "./message-bubble";
|
|
9
|
+
|
|
10
|
+
export { ContactAvatar } from "./contact-avatar";
|
|
11
|
+
export type { ContactAvatarProps } from "./contact-avatar";
|
|
12
|
+
|
|
13
|
+
export { InboxItem } from "./inbox-item";
|
|
14
|
+
export type { InboxItemProps } from "./inbox-item";
|
|
15
|
+
|
|
16
|
+
export { InboxSidebar } from "./inbox-sidebar";
|
|
17
|
+
export type { InboxSidebarProps } from "./inbox-sidebar";
|
|
18
|
+
|
|
19
|
+
export { ContactInfoPanel } from "./contact-info-panel";
|
|
20
|
+
export type { ContactInfoPanelProps } from "./contact-info-panel";
|
|
21
|
+
|
|
22
|
+
export { NewConversationDialog } from "./new-conversation-dialog";
|
|
23
|
+
export type { NewConversationDialogProps } from "./new-conversation-dialog";
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect } from "react";
|
|
2
|
+
import type { Contact, Channel, Inbox } from "../types";
|
|
3
|
+
import { ContactAvatar } from "./contact-avatar";
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
DialogDescription,
|
|
10
|
+
DialogFooter,
|
|
11
|
+
} from "./ui/dialog";
|
|
12
|
+
import {
|
|
13
|
+
Command,
|
|
14
|
+
CommandInput,
|
|
15
|
+
CommandList,
|
|
16
|
+
CommandEmpty,
|
|
17
|
+
CommandGroup,
|
|
18
|
+
CommandItem,
|
|
19
|
+
} from "./ui/command";
|
|
20
|
+
import {
|
|
21
|
+
Select,
|
|
22
|
+
SelectContent,
|
|
23
|
+
SelectItem,
|
|
24
|
+
SelectTrigger,
|
|
25
|
+
SelectValue,
|
|
26
|
+
} from "./ui/select";
|
|
27
|
+
import { Button } from "./ui/button";
|
|
28
|
+
import { Loader2 } from "lucide-react";
|
|
29
|
+
|
|
30
|
+
export interface NewConversationDialogProps {
|
|
31
|
+
open: boolean;
|
|
32
|
+
onOpenChange: (open: boolean) => void;
|
|
33
|
+
contacts: Contact[];
|
|
34
|
+
channels: Channel[];
|
|
35
|
+
existingInboxes?: Inbox[];
|
|
36
|
+
onCreateInbox: (contactId: number, channelId: number) => void;
|
|
37
|
+
isCreating?: boolean;
|
|
38
|
+
onCreated?: (inboxId: number) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function NewConversationDialog({
|
|
42
|
+
open,
|
|
43
|
+
onOpenChange,
|
|
44
|
+
contacts,
|
|
45
|
+
channels,
|
|
46
|
+
existingInboxes,
|
|
47
|
+
onCreateInbox,
|
|
48
|
+
isCreating,
|
|
49
|
+
onCreated,
|
|
50
|
+
}: NewConversationDialogProps) {
|
|
51
|
+
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
|
|
52
|
+
const [selectedChannelId, setSelectedChannelId] = useState<number | null>(
|
|
53
|
+
null,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Auto-select first channel if only one available
|
|
57
|
+
const effectiveChannelId =
|
|
58
|
+
selectedChannelId ?? (channels.length === 1 ? channels[0]?.id : null);
|
|
59
|
+
|
|
60
|
+
// Reset state when dialog closes
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!open) {
|
|
63
|
+
setSelectedContact(null);
|
|
64
|
+
setSelectedChannelId(null);
|
|
65
|
+
}
|
|
66
|
+
}, [open]);
|
|
67
|
+
|
|
68
|
+
const handleCreate = useCallback(() => {
|
|
69
|
+
if (!selectedContact || !effectiveChannelId) return;
|
|
70
|
+
|
|
71
|
+
// Dedup check
|
|
72
|
+
if (existingInboxes) {
|
|
73
|
+
const existing = existingInboxes.find(
|
|
74
|
+
(i) =>
|
|
75
|
+
i.id_contact === selectedContact.id &&
|
|
76
|
+
i.id_channel === effectiveChannelId,
|
|
77
|
+
);
|
|
78
|
+
if (existing) {
|
|
79
|
+
onCreated?.(existing.id);
|
|
80
|
+
onOpenChange(false);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
onCreateInbox(selectedContact.id, effectiveChannelId);
|
|
86
|
+
}, [
|
|
87
|
+
selectedContact,
|
|
88
|
+
effectiveChannelId,
|
|
89
|
+
existingInboxes,
|
|
90
|
+
onCreateInbox,
|
|
91
|
+
onCreated,
|
|
92
|
+
onOpenChange,
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
97
|
+
<DialogContent className="sm:max-w-md p-0 gap-0">
|
|
98
|
+
<DialogHeader className="px-4 pt-4 pb-2">
|
|
99
|
+
<DialogTitle>Nova conversa</DialogTitle>
|
|
100
|
+
<DialogDescription>
|
|
101
|
+
Selecione um contato para iniciar uma conversa
|
|
102
|
+
</DialogDescription>
|
|
103
|
+
</DialogHeader>
|
|
104
|
+
|
|
105
|
+
{channels.length > 1 && (
|
|
106
|
+
<div className="px-4 pb-2">
|
|
107
|
+
<Select
|
|
108
|
+
value={selectedChannelId?.toString() ?? ""}
|
|
109
|
+
onValueChange={(v) => setSelectedChannelId(Number(v))}
|
|
110
|
+
>
|
|
111
|
+
<SelectTrigger className="h-9">
|
|
112
|
+
<SelectValue placeholder="Selecione um canal" />
|
|
113
|
+
</SelectTrigger>
|
|
114
|
+
<SelectContent>
|
|
115
|
+
{channels.map((ch) => (
|
|
116
|
+
<SelectItem key={ch.id} value={ch.id.toString()}>
|
|
117
|
+
{ch.name}
|
|
118
|
+
</SelectItem>
|
|
119
|
+
))}
|
|
120
|
+
</SelectContent>
|
|
121
|
+
</Select>
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
<Command className="rounded-none border-none shadow-none">
|
|
126
|
+
<div className="px-2">
|
|
127
|
+
<CommandInput placeholder="Buscar contato..." />
|
|
128
|
+
</div>
|
|
129
|
+
<CommandList className="max-h-64 px-2">
|
|
130
|
+
<CommandEmpty>Nenhum contato encontrado</CommandEmpty>
|
|
131
|
+
<CommandGroup>
|
|
132
|
+
{contacts.map((contact) => (
|
|
133
|
+
<CommandItem
|
|
134
|
+
key={contact.id}
|
|
135
|
+
value={`${contact.name} ${contact.phone_number || ""}`}
|
|
136
|
+
onSelect={() => setSelectedContact(contact)}
|
|
137
|
+
data-checked={selectedContact?.id === contact.id}
|
|
138
|
+
className="gap-3"
|
|
139
|
+
>
|
|
140
|
+
<ContactAvatar name={contact.name} size="sm" />
|
|
141
|
+
<div className="min-w-0 flex-1">
|
|
142
|
+
<p className="truncate text-sm font-medium">
|
|
143
|
+
{contact.name}
|
|
144
|
+
</p>
|
|
145
|
+
<p className="truncate text-xs text-muted-foreground">
|
|
146
|
+
{contact.phone_number ||
|
|
147
|
+
contact.identifier ||
|
|
148
|
+
"Sem telefone"}
|
|
149
|
+
</p>
|
|
150
|
+
</div>
|
|
151
|
+
</CommandItem>
|
|
152
|
+
))}
|
|
153
|
+
</CommandGroup>
|
|
154
|
+
</CommandList>
|
|
155
|
+
</Command>
|
|
156
|
+
|
|
157
|
+
<DialogFooter className="px-4 py-3 border-t">
|
|
158
|
+
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
159
|
+
Cancelar
|
|
160
|
+
</Button>
|
|
161
|
+
<Button
|
|
162
|
+
disabled={!selectedContact || !effectiveChannelId || isCreating}
|
|
163
|
+
onClick={handleCreate}
|
|
164
|
+
>
|
|
165
|
+
{isCreating && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
166
|
+
Iniciar conversa
|
|
167
|
+
</Button>
|
|
168
|
+
</DialogFooter>
|
|
169
|
+
</DialogContent>
|
|
170
|
+
</Dialog>
|
|
171
|
+
);
|
|
172
|
+
}
|