@wealthx/shadcn 1.5.24 → 1.5.25
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/.turbo/turbo-build.log +144 -144
- package/CHANGELOG.md +6 -0
- package/dist/{chunk-RYGZRDP6.mjs → chunk-4FJC64FV.mjs} +468 -410
- package/dist/{chunk-FGHM34AV.mjs → chunk-7JVKSZ4O.mjs} +1 -1
- package/dist/{chunk-6HTE24TP.mjs → chunk-EY5YPFKX.mjs} +1 -1
- package/dist/{chunk-3HFOSFOM.mjs → chunk-IG7DEIWU.mjs} +4 -4
- package/dist/{chunk-2VTOF7PW.mjs → chunk-K2KX3NX7.mjs} +1 -1
- package/dist/{chunk-NXZ2F4JA.mjs → chunk-KGBLORCQ.mjs} +1 -1
- package/dist/{chunk-2SDEURIQ.mjs → chunk-PX2B3Q3A.mjs} +7 -7
- package/dist/{chunk-66NM4AX2.mjs → chunk-QM7LU2BR.mjs} +10 -10
- package/dist/{chunk-4UCRTTVL.mjs → chunk-RNLIZRAK.mjs} +1 -1
- package/dist/{chunk-BWG7AX6X.mjs → chunk-SQ54W3JH.mjs} +1 -1
- package/dist/{chunk-RJHE3V4M.mjs → chunk-UMR3HVZF.mjs} +4 -4
- package/dist/{chunk-JGUC3KCA.mjs → chunk-VNB5E7SI.mjs} +5 -5
- package/dist/{chunk-ETT5JAXF.mjs → chunk-Z5QI7CW2.mjs} +1 -1
- package/dist/components/ui/about-you-form.mjs +4 -4
- package/dist/components/ui/ai-assistant-drawer.mjs +2 -2
- package/dist/components/ui/{ai-conversations.js → ai-conversations/index.js} +818 -763
- package/dist/components/ui/ai-conversations/index.mjs +42 -0
- package/dist/components/ui/appointment-action-dialogs.mjs +1 -1
- package/dist/components/ui/appointment-availability-settings.mjs +5 -5
- package/dist/components/ui/appointment-book-dialog.mjs +4 -4
- package/dist/components/ui/appointment-detail-sheet.mjs +3 -3
- package/dist/components/ui/appointment-upcoming-card.mjs +1 -1
- package/dist/components/ui/assets-liabilities-side-card.mjs +6 -6
- package/dist/components/ui/backoffice-signup-steps.mjs +4 -4
- package/dist/components/ui/bank-statement-generate-dialog.mjs +6 -6
- package/dist/components/ui/contact-alert-dialog/index.mjs +2 -2
- package/dist/components/ui/create-contact-modal.mjs +3 -3
- package/dist/components/ui/date-picker.mjs +2 -2
- package/dist/components/ui/expense-detail-item.mjs +3 -3
- package/dist/components/ui/expense-work-details.mjs +9 -9
- package/dist/components/ui/file-preview-dialog.mjs +2 -2
- package/dist/components/ui/financial-drawers.mjs +2 -2
- package/dist/components/ui/form-primitives.mjs +2 -2
- package/dist/components/ui/frontend-signup-steps.mjs +5 -5
- package/dist/components/ui/income-work-details.mjs +3 -3
- package/dist/components/ui/kanban-column.mjs +4 -4
- package/dist/components/ui/opportunity-card.mjs +1 -1
- package/dist/components/ui/opportunity-edit-modals.mjs +6 -6
- package/dist/components/ui/opportunity-summary-tab.mjs +8 -8
- package/dist/components/ui/pipeline-board.mjs +6 -6
- package/dist/components/ui/pipeline-dialogs.mjs +5 -5
- package/dist/components/ui/property-report-dialog.mjs +4 -4
- package/dist/components/ui/savings-goal-modal.mjs +4 -4
- package/dist/components/ui/share-details-dialog.mjs +2 -2
- package/dist/components/ui/signup-form-primitives.mjs +1 -1
- package/dist/index.js +4913 -4858
- package/dist/index.mjs +160 -160
- package/dist/styles.css +1 -1
- package/package.json +4 -4
- package/src/components/index.tsx +1 -0
- package/src/components/ui/ai-conversations/helpers.tsx +31 -0
- package/src/components/ui/ai-conversations/index.tsx +468 -0
- package/src/components/ui/ai-conversations/lead-panel.tsx +517 -0
- package/src/components/ui/ai-conversations/list.tsx +335 -0
- package/src/components/ui/ai-conversations/thread.tsx +919 -0
- package/src/components/ui/ai-conversations/types.ts +83 -0
- package/src/styles/styles-css.ts +1 -1
- package/tsup.config.ts +1 -1
- package/dist/components/ui/ai-conversations.mjs +0 -42
- package/src/components/ui/ai-conversations.tsx +0 -2270
- package/dist/{chunk-A43XIVO6.mjs → chunk-2PWTXE66.mjs} +3 -3
- package/dist/{chunk-PSBQ4I3M.mjs → chunk-53ZB2JWT.mjs} +6 -6
- package/dist/{chunk-G6RCC2SF.mjs → chunk-5ST6BK7R.mjs} +3 -3
- package/dist/{chunk-H5NI6ZIU.mjs → chunk-734FOOJC.mjs} +3 -3
- package/dist/{chunk-ONYADWSO.mjs → chunk-ABVCQWDY.mjs} +3 -3
- package/dist/{chunk-AADJ5IT6.mjs → chunk-ECC2LLZM.mjs} +3 -3
- package/dist/{chunk-DK55HZPN.mjs → chunk-KENLXFJ7.mjs} +6 -6
- package/dist/{chunk-ET4MTPIY.mjs → chunk-LDC6V6DJ.mjs} +3 -3
- package/dist/{chunk-3S4XQTAL.mjs → chunk-SO4RB3XB.mjs} +9 -9
- package/dist/{chunk-UMF6LLQK.mjs → chunk-TRM3KIHT.mjs} +3 -3
|
@@ -1,2270 +0,0 @@
|
|
|
1
|
-
import React, { useState } from "react";
|
|
2
|
-
import { useEditor, EditorContent } from "@tiptap/react";
|
|
3
|
-
import StarterKit from "@tiptap/starter-kit";
|
|
4
|
-
import TiptapUnderline from "@tiptap/extension-underline";
|
|
5
|
-
import TiptapLink from "@tiptap/extension-link";
|
|
6
|
-
import ReactMarkdown from "react-markdown";
|
|
7
|
-
import rehypeRaw from "rehype-raw";
|
|
8
|
-
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
9
|
-
import {
|
|
10
|
-
Archive,
|
|
11
|
-
ArrowLeft,
|
|
12
|
-
Bold,
|
|
13
|
-
Bot,
|
|
14
|
-
Briefcase,
|
|
15
|
-
Calendar,
|
|
16
|
-
CheckCircle2,
|
|
17
|
-
ChevronLeft,
|
|
18
|
-
ChevronRight,
|
|
19
|
-
ChevronDown,
|
|
20
|
-
Flag,
|
|
21
|
-
HelpCircle,
|
|
22
|
-
Italic,
|
|
23
|
-
Link2,
|
|
24
|
-
Lock,
|
|
25
|
-
Mail,
|
|
26
|
-
MapPin,
|
|
27
|
-
MessageSquare,
|
|
28
|
-
MoreHorizontal,
|
|
29
|
-
Navigation,
|
|
30
|
-
Paperclip,
|
|
31
|
-
Phone,
|
|
32
|
-
PhoneCall,
|
|
33
|
-
Plus,
|
|
34
|
-
Search,
|
|
35
|
-
Send,
|
|
36
|
-
Underline,
|
|
37
|
-
UserCheck,
|
|
38
|
-
UserPlus,
|
|
39
|
-
Video,
|
|
40
|
-
} from "lucide-react";
|
|
41
|
-
import { cn, getInitials } from "@/lib/utils";
|
|
42
|
-
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
43
|
-
import { Badge } from "@/components/ui/badge";
|
|
44
|
-
import { Button, buttonVariants } from "@/components/ui/button";
|
|
45
|
-
import { Input } from "@/components/ui/input";
|
|
46
|
-
import {
|
|
47
|
-
DropdownMenu,
|
|
48
|
-
DropdownMenuContent,
|
|
49
|
-
DropdownMenuItem,
|
|
50
|
-
DropdownMenuSeparator,
|
|
51
|
-
DropdownMenuTrigger,
|
|
52
|
-
} from "@/components/ui/dropdown-menu";
|
|
53
|
-
import {
|
|
54
|
-
Dialog,
|
|
55
|
-
DialogContent,
|
|
56
|
-
DialogDescription,
|
|
57
|
-
DialogFooter,
|
|
58
|
-
DialogHeader,
|
|
59
|
-
DialogTitle,
|
|
60
|
-
} from "@/components/ui/dialog";
|
|
61
|
-
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
62
|
-
import { Separator } from "@/components/ui/separator";
|
|
63
|
-
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
64
|
-
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|
65
|
-
import { Textarea } from "@/components/ui/textarea";
|
|
66
|
-
import {
|
|
67
|
-
Tooltip,
|
|
68
|
-
TooltipContent,
|
|
69
|
-
TooltipProvider,
|
|
70
|
-
TooltipTrigger,
|
|
71
|
-
} from "@/components/ui/tooltip";
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* AI Conversations — WealthX Backoffice
|
|
75
|
-
*
|
|
76
|
-
* 3-panel inbox for managing AI + advisor conversations from website chatbot leads.
|
|
77
|
-
*
|
|
78
|
-
* Component hierarchy:
|
|
79
|
-
* Atom → ConversationStatusChip
|
|
80
|
-
* Molecule → ConversationListItem, ChatBubble
|
|
81
|
-
* Organism → ConversationList, ChatThread, ChatComposer, AICollectedDataSection, LeadInfoPanel
|
|
82
|
-
* Template → ConversationsPage
|
|
83
|
-
*/
|
|
84
|
-
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
|
-
// Types
|
|
87
|
-
// ---------------------------------------------------------------------------
|
|
88
|
-
|
|
89
|
-
export type AiConvStatus =
|
|
90
|
-
| "ai-active"
|
|
91
|
-
| "manual"
|
|
92
|
-
| "needs-attention"
|
|
93
|
-
| "closed";
|
|
94
|
-
|
|
95
|
-
export type AiConvMessageRole = "bot" | "visitor" | "advisor" | "system";
|
|
96
|
-
|
|
97
|
-
export type AiConvFieldConfidence = "confirmed" | "estimated";
|
|
98
|
-
|
|
99
|
-
export type AiConvFilterTab =
|
|
100
|
-
| "all"
|
|
101
|
-
| "open"
|
|
102
|
-
| "ai-active"
|
|
103
|
-
| "needs-attention"
|
|
104
|
-
| "closed";
|
|
105
|
-
|
|
106
|
-
export type AiConvMode = "ai" | "manual";
|
|
107
|
-
|
|
108
|
-
export type AiConvChannel = "chat" | "email";
|
|
109
|
-
|
|
110
|
-
export type AiConvChannelFilter = "all" | AiConvChannel;
|
|
111
|
-
|
|
112
|
-
export type AiConvMeetingType = "video" | "phone" | "in-person";
|
|
113
|
-
|
|
114
|
-
export interface AiConvContact {
|
|
115
|
-
id: string;
|
|
116
|
-
name: string;
|
|
117
|
-
email?: string;
|
|
118
|
-
phone?: string;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export interface AiConvAdvisor {
|
|
122
|
-
id: string;
|
|
123
|
-
name: string;
|
|
124
|
-
initials: string;
|
|
125
|
-
role?: string;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export interface AiConvListItemData {
|
|
129
|
-
id: string;
|
|
130
|
-
contact: AiConvContact;
|
|
131
|
-
status: AiConvStatus;
|
|
132
|
-
lastMessage: string;
|
|
133
|
-
lastMessageRole?: AiConvMessageRole;
|
|
134
|
-
timestamp: string;
|
|
135
|
-
unreadCount?: number;
|
|
136
|
-
/** Assigned advisor display name. */
|
|
137
|
-
assignedTo?: string;
|
|
138
|
-
/**
|
|
139
|
-
* Conversation topic tag — e.g. "Buy a Home", "Refinance", "Investment".
|
|
140
|
-
* Shown as a small secondary badge so the broker can categorise conversations
|
|
141
|
-
* at a glance without opening each thread.
|
|
142
|
-
*/
|
|
143
|
-
topic?: string;
|
|
144
|
-
/** Channel this conversation is using — "chat" (default) or "email". */
|
|
145
|
-
channel?: AiConvChannel;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export interface AiConvMessage {
|
|
149
|
-
id: string;
|
|
150
|
-
role: AiConvMessageRole;
|
|
151
|
-
content: string;
|
|
152
|
-
timestamp?: string;
|
|
153
|
-
/** Display name shown above the bubble. */
|
|
154
|
-
senderName?: string;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export interface AiConvDataField {
|
|
158
|
-
label: string;
|
|
159
|
-
value: string;
|
|
160
|
-
confidence: AiConvFieldConfidence;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export interface AiConvAppointmentData {
|
|
164
|
-
datetime: string;
|
|
165
|
-
meetingType: AiConvMeetingType;
|
|
166
|
-
/** Meeting link (video), phone number (phone), or address (in-person). */
|
|
167
|
-
meetingDetail?: string;
|
|
168
|
-
status: "requested" | "confirmed" | "pending" | "cancelled";
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ---------------------------------------------------------------------------
|
|
172
|
-
// Internal helpers & shared constants
|
|
173
|
-
// ---------------------------------------------------------------------------
|
|
174
|
-
|
|
175
|
-
function displayContactName(name: string): string {
|
|
176
|
-
return name.trim() || "Website User";
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Shared fixed height for all three panel top-bar sections so bottom dividers align. */
|
|
180
|
-
const PANEL_HEADER_HEIGHT = "h-[94px]";
|
|
181
|
-
|
|
182
|
-
const APPOINTMENT_STATUS_LABEL: Record<
|
|
183
|
-
AiConvAppointmentData["status"],
|
|
184
|
-
string
|
|
185
|
-
> = {
|
|
186
|
-
requested: "Lead requested",
|
|
187
|
-
confirmed: "Confirmed",
|
|
188
|
-
pending: "Pending confirmation",
|
|
189
|
-
cancelled: "Cancelled",
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
// ---------------------------------------------------------------------------
|
|
193
|
-
// ConversationStatusChip
|
|
194
|
-
// ---------------------------------------------------------------------------
|
|
195
|
-
|
|
196
|
-
type BadgeVariant = "success" | "default" | "warning" | "secondary";
|
|
197
|
-
|
|
198
|
-
const STATUS_CONFIG: Record<
|
|
199
|
-
AiConvStatus,
|
|
200
|
-
{ label: string; variant: BadgeVariant; dotClass: string }
|
|
201
|
-
> = {
|
|
202
|
-
"ai-active": {
|
|
203
|
-
label: "AI Active",
|
|
204
|
-
variant: "success",
|
|
205
|
-
dotClass: "bg-success",
|
|
206
|
-
},
|
|
207
|
-
manual: {
|
|
208
|
-
label: "Manual",
|
|
209
|
-
variant: "default",
|
|
210
|
-
dotClass: "bg-primary",
|
|
211
|
-
},
|
|
212
|
-
"needs-attention": {
|
|
213
|
-
label: "Needs Attention",
|
|
214
|
-
variant: "warning",
|
|
215
|
-
dotClass: "bg-warning",
|
|
216
|
-
},
|
|
217
|
-
closed: {
|
|
218
|
-
label: "Closed",
|
|
219
|
-
variant: "secondary",
|
|
220
|
-
dotClass: "bg-muted-foreground/50",
|
|
221
|
-
},
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
export interface ConversationStatusChipProps {
|
|
225
|
-
status: AiConvStatus;
|
|
226
|
-
showDot?: boolean;
|
|
227
|
-
className?: string;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
export function ConversationStatusChip({
|
|
231
|
-
status,
|
|
232
|
-
showDot = false,
|
|
233
|
-
className,
|
|
234
|
-
}: ConversationStatusChipProps) {
|
|
235
|
-
const { label, variant, dotClass } = STATUS_CONFIG[status];
|
|
236
|
-
return (
|
|
237
|
-
<Badge variant={variant} className={className}>
|
|
238
|
-
{showDot && (
|
|
239
|
-
<span className={cn("size-1.5 shrink-0 rounded-full", dotClass)} />
|
|
240
|
-
)}
|
|
241
|
-
{label}
|
|
242
|
-
</Badge>
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// ---------------------------------------------------------------------------
|
|
247
|
-
// ContactAvatar — internal
|
|
248
|
-
// ---------------------------------------------------------------------------
|
|
249
|
-
|
|
250
|
-
interface ContactAvatarProps {
|
|
251
|
-
name: string;
|
|
252
|
-
size?: "sm" | "md" | "lg";
|
|
253
|
-
className?: string;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function ContactAvatar({ name, size = "md", className }: ContactAvatarProps) {
|
|
257
|
-
const avatarSize = size === "sm" ? "sm" : size === "lg" ? "lg" : "default";
|
|
258
|
-
return (
|
|
259
|
-
<Avatar size={avatarSize} className={className}>
|
|
260
|
-
<AvatarFallback className="font-semibold">
|
|
261
|
-
{getInitials(name)}
|
|
262
|
-
</AvatarFallback>
|
|
263
|
-
</Avatar>
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// ---------------------------------------------------------------------------
|
|
268
|
-
// ConversationListItem
|
|
269
|
-
// ---------------------------------------------------------------------------
|
|
270
|
-
|
|
271
|
-
export interface ConversationListItemProps {
|
|
272
|
-
data: AiConvListItemData;
|
|
273
|
-
isActive?: boolean;
|
|
274
|
-
onClick?: (id: string) => void;
|
|
275
|
-
onRead?: (id: string) => void;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
export function ConversationListItem({
|
|
279
|
-
data,
|
|
280
|
-
isActive,
|
|
281
|
-
onClick,
|
|
282
|
-
onRead,
|
|
283
|
-
}: ConversationListItemProps) {
|
|
284
|
-
return (
|
|
285
|
-
<button
|
|
286
|
-
type="button"
|
|
287
|
-
onClick={() => {
|
|
288
|
-
onClick?.(data.id);
|
|
289
|
-
onRead?.(data.id);
|
|
290
|
-
}}
|
|
291
|
-
className={cn(
|
|
292
|
-
"w-full flex items-start gap-3 px-3 py-3 text-left transition-colors",
|
|
293
|
-
"border-b border-border last:border-b-0",
|
|
294
|
-
isActive ? "bg-muted" : "hover:bg-muted/40",
|
|
295
|
-
)}
|
|
296
|
-
>
|
|
297
|
-
<ContactAvatar name={data.contact.name} />
|
|
298
|
-
<div className="min-w-0 flex-1">
|
|
299
|
-
{/* Row 1 — name + icons + timestamp */}
|
|
300
|
-
<div className="mb-1 flex items-center justify-between gap-2">
|
|
301
|
-
<span className="truncate text-sm font-semibold text-foreground">
|
|
302
|
-
{displayContactName(data.contact.name)}
|
|
303
|
-
</span>
|
|
304
|
-
<div className="flex shrink-0 items-center gap-1">
|
|
305
|
-
{data.status === "needs-attention" && (
|
|
306
|
-
<Flag className="size-3 text-warning-text" />
|
|
307
|
-
)}
|
|
308
|
-
{data.channel === "email" ? (
|
|
309
|
-
<Mail className="size-3 text-muted-foreground" />
|
|
310
|
-
) : (
|
|
311
|
-
<MessageSquare className="size-3 text-muted-foreground" />
|
|
312
|
-
)}
|
|
313
|
-
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
|
314
|
-
{data.timestamp}
|
|
315
|
-
</span>
|
|
316
|
-
</div>
|
|
317
|
-
</div>
|
|
318
|
-
{/* Row 2 — status chip + assigned advisor + unread badge */}
|
|
319
|
-
<div className="mb-1.5 flex items-center justify-between gap-2">
|
|
320
|
-
<div className="flex min-w-0 items-center gap-1.5">
|
|
321
|
-
<ConversationStatusChip status={data.status} showDot />
|
|
322
|
-
{data.assignedTo && (
|
|
323
|
-
<span className="truncate text-xs text-muted-foreground">
|
|
324
|
-
→ {data.assignedTo}
|
|
325
|
-
</span>
|
|
326
|
-
)}
|
|
327
|
-
</div>
|
|
328
|
-
{data.unreadCount ? (
|
|
329
|
-
<Badge
|
|
330
|
-
variant="default"
|
|
331
|
-
className="size-4 shrink-0 justify-center px-0 text-[10px]"
|
|
332
|
-
>
|
|
333
|
-
{data.unreadCount}
|
|
334
|
-
</Badge>
|
|
335
|
-
) : null}
|
|
336
|
-
</div>
|
|
337
|
-
{/* Row 3 — last message preview */}
|
|
338
|
-
<p className="line-clamp-2 text-sm leading-relaxed text-muted-foreground">
|
|
339
|
-
{data.lastMessageRole === "bot" ? (
|
|
340
|
-
<span className="font-medium text-foreground/60">AI: </span>
|
|
341
|
-
) : data.lastMessageRole === "advisor" ? (
|
|
342
|
-
<span className="font-medium text-foreground/60">You: </span>
|
|
343
|
-
) : data.lastMessageRole === "visitor" ? (
|
|
344
|
-
<span className="font-medium text-foreground/60">
|
|
345
|
-
{displayContactName(data.contact.name).split(" ")[0]}:{" "}
|
|
346
|
-
</span>
|
|
347
|
-
) : null}
|
|
348
|
-
{data.lastMessage}
|
|
349
|
-
</p>
|
|
350
|
-
</div>
|
|
351
|
-
</button>
|
|
352
|
-
);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// ---------------------------------------------------------------------------
|
|
356
|
-
// ConversationList
|
|
357
|
-
// ---------------------------------------------------------------------------
|
|
358
|
-
|
|
359
|
-
function filterConversations(
|
|
360
|
-
conversations: AiConvListItemData[],
|
|
361
|
-
query: string,
|
|
362
|
-
filter: AiConvFilterTab,
|
|
363
|
-
channelFilter: AiConvChannelFilter,
|
|
364
|
-
): AiConvListItemData[] {
|
|
365
|
-
const q = query.toLowerCase();
|
|
366
|
-
return conversations.filter((c) => {
|
|
367
|
-
const matchesFilter =
|
|
368
|
-
filter === "all" ||
|
|
369
|
-
(filter === "open" ? c.status !== "closed" : c.status === filter);
|
|
370
|
-
const matchesChannel =
|
|
371
|
-
channelFilter === "all" || (c.channel ?? "chat") === channelFilter;
|
|
372
|
-
const matchesSearch =
|
|
373
|
-
!q ||
|
|
374
|
-
c.contact.name.toLowerCase().includes(q) ||
|
|
375
|
-
c.lastMessage.toLowerCase().includes(q);
|
|
376
|
-
return matchesFilter && matchesChannel && matchesSearch;
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
const FILTER_TABS: { id: AiConvFilterTab; label: string }[] = [
|
|
381
|
-
{ id: "all", label: "All" },
|
|
382
|
-
{ id: "open", label: "Open" },
|
|
383
|
-
{ id: "ai-active", label: "AI Active" },
|
|
384
|
-
{ id: "needs-attention", label: "Urgent" },
|
|
385
|
-
{ id: "closed", label: "Archived" },
|
|
386
|
-
];
|
|
387
|
-
|
|
388
|
-
export interface ConversationListProps {
|
|
389
|
-
conversations: AiConvListItemData[];
|
|
390
|
-
activeId?: string;
|
|
391
|
-
searchQuery?: string;
|
|
392
|
-
activeFilter?: AiConvFilterTab;
|
|
393
|
-
channelFilter?: AiConvChannelFilter;
|
|
394
|
-
hasMore?: boolean;
|
|
395
|
-
isLoadingMore?: boolean;
|
|
396
|
-
onSearchChange?: (v: string) => void;
|
|
397
|
-
onFilterChange?: (f: AiConvFilterTab) => void;
|
|
398
|
-
onChannelFilterChange?: (f: AiConvChannelFilter) => void;
|
|
399
|
-
onSelect?: (id: string) => void;
|
|
400
|
-
onRead?: (id: string) => void;
|
|
401
|
-
onLoadMore?: () => void;
|
|
402
|
-
className?: string;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
export function ConversationList({
|
|
406
|
-
conversations,
|
|
407
|
-
activeId,
|
|
408
|
-
searchQuery = "",
|
|
409
|
-
activeFilter = "all",
|
|
410
|
-
channelFilter: channelFilterProp = "all",
|
|
411
|
-
hasMore,
|
|
412
|
-
isLoadingMore,
|
|
413
|
-
onSearchChange,
|
|
414
|
-
onFilterChange,
|
|
415
|
-
onChannelFilterChange,
|
|
416
|
-
onSelect,
|
|
417
|
-
onRead,
|
|
418
|
-
onLoadMore,
|
|
419
|
-
className,
|
|
420
|
-
}: ConversationListProps) {
|
|
421
|
-
const [channelFilter, setChannelFilter] =
|
|
422
|
-
useState<AiConvChannelFilter>(channelFilterProp);
|
|
423
|
-
|
|
424
|
-
function handleChannelFilterChange(vals: string[]) {
|
|
425
|
-
const v = vals[0] as AiConvChannelFilter | undefined;
|
|
426
|
-
if (v) {
|
|
427
|
-
setChannelFilter(v);
|
|
428
|
-
onChannelFilterChange?.(v);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
return (
|
|
433
|
-
<div
|
|
434
|
-
className={cn(
|
|
435
|
-
"flex flex-col border-r border-border bg-background",
|
|
436
|
-
className,
|
|
437
|
-
)}
|
|
438
|
-
>
|
|
439
|
-
<div className={cn(PANEL_HEADER_HEIGHT, "flex shrink-0 flex-col")}>
|
|
440
|
-
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
|
|
441
|
-
<div className="relative flex-1">
|
|
442
|
-
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
443
|
-
<Input
|
|
444
|
-
value={searchQuery}
|
|
445
|
-
onChange={(e) => onSearchChange?.(e.target.value)}
|
|
446
|
-
placeholder="Search conversations..."
|
|
447
|
-
className="h-8 pl-8 text-sm"
|
|
448
|
-
/>
|
|
449
|
-
</div>
|
|
450
|
-
<ToggleGroup
|
|
451
|
-
type="single"
|
|
452
|
-
variant="outline"
|
|
453
|
-
size="sm"
|
|
454
|
-
spacing={0}
|
|
455
|
-
value={[channelFilter]}
|
|
456
|
-
onValueChange={handleChannelFilterChange}
|
|
457
|
-
className="shrink-0"
|
|
458
|
-
>
|
|
459
|
-
<ToggleGroupItem value="all" aria-label="All channels">
|
|
460
|
-
All
|
|
461
|
-
</ToggleGroupItem>
|
|
462
|
-
<ToggleGroupItem value="chat" aria-label="Chat">
|
|
463
|
-
<MessageSquare className="size-3.5" />
|
|
464
|
-
</ToggleGroupItem>
|
|
465
|
-
<ToggleGroupItem value="email" aria-label="Email">
|
|
466
|
-
<Mail className="size-3.5" />
|
|
467
|
-
</ToggleGroupItem>
|
|
468
|
-
</ToggleGroup>
|
|
469
|
-
</div>
|
|
470
|
-
|
|
471
|
-
<div className="flex flex-1 items-center border-b border-border">
|
|
472
|
-
<Tabs
|
|
473
|
-
value={activeFilter}
|
|
474
|
-
onValueChange={(v) => v && onFilterChange?.(v as AiConvFilterTab)}
|
|
475
|
-
className="w-full"
|
|
476
|
-
>
|
|
477
|
-
<TabsList
|
|
478
|
-
variant="line"
|
|
479
|
-
className="w-full justify-start gap-0 h-auto"
|
|
480
|
-
>
|
|
481
|
-
{FILTER_TABS.map((tab) => (
|
|
482
|
-
<TabsTrigger
|
|
483
|
-
key={tab.id}
|
|
484
|
-
value={tab.id}
|
|
485
|
-
className="flex-none px-3 py-2 text-xs"
|
|
486
|
-
>
|
|
487
|
-
{tab.label}
|
|
488
|
-
</TabsTrigger>
|
|
489
|
-
))}
|
|
490
|
-
</TabsList>
|
|
491
|
-
</Tabs>
|
|
492
|
-
</div>
|
|
493
|
-
</div>
|
|
494
|
-
|
|
495
|
-
{/* List */}
|
|
496
|
-
<div className="flex-1 overflow-y-auto" tabIndex={0}>
|
|
497
|
-
{(() => {
|
|
498
|
-
const filtered = filterConversations(
|
|
499
|
-
conversations,
|
|
500
|
-
searchQuery,
|
|
501
|
-
activeFilter,
|
|
502
|
-
channelFilter,
|
|
503
|
-
);
|
|
504
|
-
return filtered.length === 0 ? (
|
|
505
|
-
<div className="flex flex-col items-center justify-center gap-2 p-8 text-muted-foreground">
|
|
506
|
-
<MessageSquare className="size-8 opacity-30" />
|
|
507
|
-
<p className="text-sm">No conversations</p>
|
|
508
|
-
{searchQuery && (
|
|
509
|
-
<Button
|
|
510
|
-
variant="outline"
|
|
511
|
-
size="sm"
|
|
512
|
-
onClick={() => onSearchChange?.("")}
|
|
513
|
-
>
|
|
514
|
-
Clear search
|
|
515
|
-
</Button>
|
|
516
|
-
)}
|
|
517
|
-
{!searchQuery && activeFilter !== "all" && (
|
|
518
|
-
<Button
|
|
519
|
-
variant="outline"
|
|
520
|
-
size="sm"
|
|
521
|
-
onClick={() => onFilterChange?.("all")}
|
|
522
|
-
>
|
|
523
|
-
Clear filter
|
|
524
|
-
</Button>
|
|
525
|
-
)}
|
|
526
|
-
</div>
|
|
527
|
-
) : (
|
|
528
|
-
<>
|
|
529
|
-
{filtered.map((item) => (
|
|
530
|
-
<ConversationListItem
|
|
531
|
-
key={item.id}
|
|
532
|
-
data={item}
|
|
533
|
-
isActive={activeId === item.id}
|
|
534
|
-
onClick={onSelect}
|
|
535
|
-
onRead={onRead}
|
|
536
|
-
/>
|
|
537
|
-
))}
|
|
538
|
-
{hasMore && (
|
|
539
|
-
<div className="border-t border-border p-3">
|
|
540
|
-
<Button
|
|
541
|
-
variant="outline"
|
|
542
|
-
size="sm"
|
|
543
|
-
className="w-full"
|
|
544
|
-
disabled={isLoadingMore}
|
|
545
|
-
onClick={onLoadMore}
|
|
546
|
-
>
|
|
547
|
-
{isLoadingMore ? "Loading..." : "Load more"}
|
|
548
|
-
</Button>
|
|
549
|
-
</div>
|
|
550
|
-
)}
|
|
551
|
-
</>
|
|
552
|
-
);
|
|
553
|
-
})()}
|
|
554
|
-
</div>
|
|
555
|
-
</div>
|
|
556
|
-
);
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// ---------------------------------------------------------------------------
|
|
560
|
-
// ChatBubble
|
|
561
|
-
// ---------------------------------------------------------------------------
|
|
562
|
-
|
|
563
|
-
export interface ChatBubbleProps {
|
|
564
|
-
message: AiConvMessage;
|
|
565
|
-
className?: string;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
function BubbleAvatar({
|
|
569
|
-
role,
|
|
570
|
-
senderName,
|
|
571
|
-
}: {
|
|
572
|
-
role: AiConvMessageRole;
|
|
573
|
-
senderName?: string;
|
|
574
|
-
}) {
|
|
575
|
-
if (role === "bot") {
|
|
576
|
-
return (
|
|
577
|
-
<Avatar size="sm">
|
|
578
|
-
<AvatarFallback className="border border-border bg-muted">
|
|
579
|
-
<Bot className="size-3.5 text-muted-foreground" />
|
|
580
|
-
</AvatarFallback>
|
|
581
|
-
</Avatar>
|
|
582
|
-
);
|
|
583
|
-
}
|
|
584
|
-
if (role === "advisor") {
|
|
585
|
-
return (
|
|
586
|
-
<Avatar size="sm">
|
|
587
|
-
<AvatarFallback className="font-semibold">
|
|
588
|
-
{getInitials(senderName ?? "Advisor")}
|
|
589
|
-
</AvatarFallback>
|
|
590
|
-
</Avatar>
|
|
591
|
-
);
|
|
592
|
-
}
|
|
593
|
-
return (
|
|
594
|
-
<Avatar size="sm">
|
|
595
|
-
<AvatarFallback>{getInitials(senderName ?? "?")}</AvatarFallback>
|
|
596
|
-
</Avatar>
|
|
597
|
-
);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
export function ChatBubble({ message, className }: ChatBubbleProps) {
|
|
601
|
-
const { role, content, timestamp, senderName } = message;
|
|
602
|
-
|
|
603
|
-
if (role === "system") {
|
|
604
|
-
return (
|
|
605
|
-
<div className={cn("my-2 flex items-center gap-3 px-2", className)}>
|
|
606
|
-
<Separator className="flex-1" />
|
|
607
|
-
<span className="shrink-0 text-caption text-muted-foreground">
|
|
608
|
-
{content}
|
|
609
|
-
</span>
|
|
610
|
-
<Separator className="flex-1" />
|
|
611
|
-
</div>
|
|
612
|
-
);
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
const isAdvisor = role === "advisor";
|
|
616
|
-
const isBot = role === "bot";
|
|
617
|
-
const isVisitor = role === "visitor";
|
|
618
|
-
|
|
619
|
-
const displayName = isBot
|
|
620
|
-
? "AI Assistant"
|
|
621
|
-
: isAdvisor
|
|
622
|
-
? (senderName ?? "Advisor")
|
|
623
|
-
: (senderName ?? "Lead");
|
|
624
|
-
|
|
625
|
-
return (
|
|
626
|
-
<div
|
|
627
|
-
className={cn(
|
|
628
|
-
"flex gap-2.5",
|
|
629
|
-
isAdvisor ? "flex-row-reverse" : "flex-row",
|
|
630
|
-
className,
|
|
631
|
-
)}
|
|
632
|
-
>
|
|
633
|
-
<BubbleAvatar role={role} senderName={senderName} />
|
|
634
|
-
|
|
635
|
-
{/* Bubble + label */}
|
|
636
|
-
<div
|
|
637
|
-
className={cn(
|
|
638
|
-
"flex max-w-[70%] flex-col gap-1",
|
|
639
|
-
isAdvisor && "items-end",
|
|
640
|
-
)}
|
|
641
|
-
>
|
|
642
|
-
{/* Sender label — always shown for context */}
|
|
643
|
-
<span className="text-caption text-muted-foreground">
|
|
644
|
-
{displayName}
|
|
645
|
-
</span>
|
|
646
|
-
|
|
647
|
-
{/* Bubble */}
|
|
648
|
-
<div
|
|
649
|
-
className={cn(
|
|
650
|
-
"px-3 py-2 text-sm leading-relaxed break-words [&_a]:underline [&_p]:m-0",
|
|
651
|
-
isBot && "border border-border bg-muted/60 text-foreground [&_a]:text-primary",
|
|
652
|
-
isVisitor && "border border-border bg-background text-foreground [&_a]:text-primary",
|
|
653
|
-
isAdvisor && "bg-primary text-primary-foreground [&_a]:text-primary-foreground",
|
|
654
|
-
)}
|
|
655
|
-
>
|
|
656
|
-
<ReactMarkdown rehypePlugins={[rehypeRaw, [rehypeSanitize, defaultSchema]]}>
|
|
657
|
-
{content}
|
|
658
|
-
</ReactMarkdown>
|
|
659
|
-
</div>
|
|
660
|
-
|
|
661
|
-
{timestamp && (
|
|
662
|
-
<span className="text-caption text-muted-foreground">
|
|
663
|
-
{timestamp}
|
|
664
|
-
</span>
|
|
665
|
-
)}
|
|
666
|
-
</div>
|
|
667
|
-
</div>
|
|
668
|
-
);
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
// ---------------------------------------------------------------------------
|
|
672
|
-
// ChatComposer
|
|
673
|
-
// ---------------------------------------------------------------------------
|
|
674
|
-
|
|
675
|
-
export interface AiConvEmailPayload {
|
|
676
|
-
content: string;
|
|
677
|
-
to: string;
|
|
678
|
-
cc: string;
|
|
679
|
-
subject: string;
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
export interface ChatComposerProps {
|
|
683
|
-
mode: AiConvMode;
|
|
684
|
-
/** Active reply channel. Defaults to "chat". */
|
|
685
|
-
channel?: AiConvChannel;
|
|
686
|
-
onChannelChange?: (channel: AiConvChannel) => void;
|
|
687
|
-
/**
|
|
688
|
-
* The channel this conversation belongs to.
|
|
689
|
-
*
|
|
690
|
-
* - `"chat"` (default) — chat composer shown normally.
|
|
691
|
-
* - `"email"` + `isEmailIntegrated=true` — email tab is active by default.
|
|
692
|
-
* - `"email"` + `isEmailIntegrated=false` — composer is replaced with a
|
|
693
|
-
* prompt to integrate email.
|
|
694
|
-
*/
|
|
695
|
-
channelType?: AiConvChannel;
|
|
696
|
-
/**
|
|
697
|
-
* When true, the Email tab is shown in the composer. Defaults to false —
|
|
698
|
-
* consumers must opt in once their tenant's email integration is wired up.
|
|
699
|
-
*/
|
|
700
|
-
isEmailIntegrated?: boolean;
|
|
701
|
-
/** Lead's email address — pre-fills the To field in email compose. */
|
|
702
|
-
contactEmail?: string;
|
|
703
|
-
inputValue?: string;
|
|
704
|
-
onInputChange?: (v: string) => void;
|
|
705
|
-
/** Fired when the user sends a chat message. */
|
|
706
|
-
onSend?: (content: string) => void;
|
|
707
|
-
/** Fired when the user sends an email. */
|
|
708
|
-
onSendEmail?: (payload: AiConvEmailPayload) => void;
|
|
709
|
-
onTakeOver?: () => void;
|
|
710
|
-
onLetAiHandle?: () => void;
|
|
711
|
-
className?: string;
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
function ComposerToolbarButton({
|
|
715
|
-
label,
|
|
716
|
-
icon: Icon,
|
|
717
|
-
pressed,
|
|
718
|
-
onToggle,
|
|
719
|
-
}: {
|
|
720
|
-
label: string;
|
|
721
|
-
icon: React.ElementType;
|
|
722
|
-
pressed?: boolean;
|
|
723
|
-
onToggle?: () => void;
|
|
724
|
-
}) {
|
|
725
|
-
return (
|
|
726
|
-
<button
|
|
727
|
-
type="button"
|
|
728
|
-
aria-label={label}
|
|
729
|
-
aria-pressed={pressed}
|
|
730
|
-
onClick={onToggle}
|
|
731
|
-
className={cn(
|
|
732
|
-
"flex size-7 items-center justify-center transition-colors",
|
|
733
|
-
pressed
|
|
734
|
-
? "bg-foreground text-background"
|
|
735
|
-
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
736
|
-
)}
|
|
737
|
-
>
|
|
738
|
-
<Icon className="size-3.5" />
|
|
739
|
-
</button>
|
|
740
|
-
);
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
function ComposerLinkPopover({ editor }: { editor: ReturnType<typeof useEditor> }) {
|
|
744
|
-
const [open, setOpen] = React.useState(false);
|
|
745
|
-
const [url, setUrl] = React.useState("");
|
|
746
|
-
|
|
747
|
-
const handleApply = () => {
|
|
748
|
-
if (url.trim()) {
|
|
749
|
-
editor?.chain().focus().setLink({ href: url.trim() }).run();
|
|
750
|
-
}
|
|
751
|
-
setOpen(false);
|
|
752
|
-
setUrl("");
|
|
753
|
-
};
|
|
754
|
-
|
|
755
|
-
return (
|
|
756
|
-
<Popover
|
|
757
|
-
open={open}
|
|
758
|
-
onOpenChange={(newOpen) => {
|
|
759
|
-
if (newOpen && editor?.isActive("link")) {
|
|
760
|
-
editor.chain().focus().unsetLink().run();
|
|
761
|
-
return;
|
|
762
|
-
}
|
|
763
|
-
if (newOpen) setUrl("");
|
|
764
|
-
setOpen(newOpen);
|
|
765
|
-
}}
|
|
766
|
-
>
|
|
767
|
-
<PopoverTrigger
|
|
768
|
-
aria-label="Insert link"
|
|
769
|
-
className={cn(
|
|
770
|
-
"flex size-7 items-center justify-center transition-colors",
|
|
771
|
-
editor?.isActive("link")
|
|
772
|
-
? "bg-foreground text-background"
|
|
773
|
-
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
774
|
-
)}
|
|
775
|
-
>
|
|
776
|
-
<Link2 className="size-3.5" />
|
|
777
|
-
</PopoverTrigger>
|
|
778
|
-
<PopoverContent className="w-72 p-2" align="start">
|
|
779
|
-
<div className="flex items-center gap-1.5">
|
|
780
|
-
<input
|
|
781
|
-
type="url"
|
|
782
|
-
value={url}
|
|
783
|
-
onChange={(e) => setUrl(e.target.value)}
|
|
784
|
-
onKeyDown={(e) => e.key === "Enter" && handleApply()}
|
|
785
|
-
placeholder="https://"
|
|
786
|
-
className="min-w-0 flex-1 border border-border bg-transparent px-2 py-1.5 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-primary"
|
|
787
|
-
autoFocus
|
|
788
|
-
/>
|
|
789
|
-
<Button size="sm" className="h-8 px-3" onClick={handleApply}>
|
|
790
|
-
Apply
|
|
791
|
-
</Button>
|
|
792
|
-
</div>
|
|
793
|
-
</PopoverContent>
|
|
794
|
-
</Popover>
|
|
795
|
-
);
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
function ComposerEmailFieldRow({
|
|
799
|
-
label,
|
|
800
|
-
children,
|
|
801
|
-
}: {
|
|
802
|
-
label: string;
|
|
803
|
-
children: React.ReactNode;
|
|
804
|
-
}) {
|
|
805
|
-
return (
|
|
806
|
-
<div className="flex items-center gap-3 border-b border-border px-4 py-2.5">
|
|
807
|
-
<span className="w-14 shrink-0 text-sm text-muted-foreground">
|
|
808
|
-
{label}
|
|
809
|
-
</span>
|
|
810
|
-
{children}
|
|
811
|
-
</div>
|
|
812
|
-
);
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
export function ChatComposer({
|
|
816
|
-
mode,
|
|
817
|
-
channel: channelProp = "chat",
|
|
818
|
-
onChannelChange,
|
|
819
|
-
channelType = "chat",
|
|
820
|
-
isEmailIntegrated = false,
|
|
821
|
-
contactEmail = "",
|
|
822
|
-
inputValue = "",
|
|
823
|
-
onInputChange,
|
|
824
|
-
onSend,
|
|
825
|
-
onSendEmail,
|
|
826
|
-
onTakeOver,
|
|
827
|
-
onLetAiHandle,
|
|
828
|
-
className,
|
|
829
|
-
}: ChatComposerProps) {
|
|
830
|
-
const showIntegrateEmailPrompt =
|
|
831
|
-
channelType === "email" && !isEmailIntegrated;
|
|
832
|
-
|
|
833
|
-
// Semi-controlled: owns channel state for uncontrolled usage, notifies parent on change.
|
|
834
|
-
// When channelType is email and integrated, default to email tab.
|
|
835
|
-
const initialChannel =
|
|
836
|
-
channelType === "email" && isEmailIntegrated ? "email" : channelProp;
|
|
837
|
-
// Force chat when email isn't integrated so the panel never lands on a hidden tab.
|
|
838
|
-
const [channel, setChannel] = React.useState<AiConvChannel>(
|
|
839
|
-
isEmailIntegrated ? initialChannel : "chat",
|
|
840
|
-
);
|
|
841
|
-
const [emailTo, setEmailTo] = React.useState(contactEmail);
|
|
842
|
-
const [emailCc, setEmailCc] = React.useState("");
|
|
843
|
-
const [showCc, setShowCc] = React.useState(false);
|
|
844
|
-
const [emailSubject, setEmailSubject] = React.useState("");
|
|
845
|
-
|
|
846
|
-
const [, forceUpdate] = React.useReducer((x: number) => x + 1, 0);
|
|
847
|
-
|
|
848
|
-
const editor = useEditor({
|
|
849
|
-
extensions: [
|
|
850
|
-
StarterKit,
|
|
851
|
-
TiptapUnderline,
|
|
852
|
-
TiptapLink.configure({ openOnClick: false }),
|
|
853
|
-
],
|
|
854
|
-
content: "",
|
|
855
|
-
onTransaction: () => forceUpdate(),
|
|
856
|
-
editorProps: {
|
|
857
|
-
attributes: {
|
|
858
|
-
class:
|
|
859
|
-
"min-h-[150px] px-4 py-3 text-base text-foreground outline-none prose prose-sm max-w-none [&_p]:m-0 [&_a]:text-primary [&_a]:underline [&_a]:cursor-pointer",
|
|
860
|
-
},
|
|
861
|
-
},
|
|
862
|
-
});
|
|
863
|
-
|
|
864
|
-
const handleChannelChange = (c: AiConvChannel) => {
|
|
865
|
-
setChannel(c);
|
|
866
|
-
onChannelChange?.(c);
|
|
867
|
-
};
|
|
868
|
-
|
|
869
|
-
if (showIntegrateEmailPrompt) {
|
|
870
|
-
return (
|
|
871
|
-
<div
|
|
872
|
-
className={cn(
|
|
873
|
-
"flex flex-col items-center justify-center gap-2 border-t border-border bg-muted/30 px-6 py-8 text-center",
|
|
874
|
-
className,
|
|
875
|
-
)}
|
|
876
|
-
>
|
|
877
|
-
<Mail className="h-8 w-8 text-muted-foreground" />
|
|
878
|
-
<p className="text-sm font-medium text-foreground">
|
|
879
|
-
Email integration required
|
|
880
|
-
</p>
|
|
881
|
-
<p className="text-xs text-muted-foreground">
|
|
882
|
-
Please integrate your email to reply to this conversation.
|
|
883
|
-
</p>
|
|
884
|
-
</div>
|
|
885
|
-
);
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
return (
|
|
889
|
-
<div
|
|
890
|
-
className={cn(
|
|
891
|
-
"flex flex-col border-t border-border bg-background",
|
|
892
|
-
className,
|
|
893
|
-
)}
|
|
894
|
-
>
|
|
895
|
-
{isEmailIntegrated && (
|
|
896
|
-
<div className="border-b border-border px-3 py-2">
|
|
897
|
-
<Tabs
|
|
898
|
-
value={channel}
|
|
899
|
-
onValueChange={(v) => v && handleChannelChange(v as AiConvChannel)}
|
|
900
|
-
>
|
|
901
|
-
<TabsList variant="default" className="w-full">
|
|
902
|
-
<TabsTrigger value="chat" className="flex-1 gap-1.5">
|
|
903
|
-
<MessageSquare className="size-3.5" />
|
|
904
|
-
Chat
|
|
905
|
-
</TabsTrigger>
|
|
906
|
-
<TabsTrigger value="email" className="flex-1 gap-1.5">
|
|
907
|
-
<Mail className="size-3.5" />
|
|
908
|
-
Email
|
|
909
|
-
</TabsTrigger>
|
|
910
|
-
</TabsList>
|
|
911
|
-
</Tabs>
|
|
912
|
-
</div>
|
|
913
|
-
)}
|
|
914
|
-
|
|
915
|
-
{mode === "ai" ? (
|
|
916
|
-
<div className="flex items-center gap-2 bg-muted/30 px-4 py-2.5 text-[12px] text-muted-foreground">
|
|
917
|
-
<Bot className="size-3.5 shrink-0 text-muted-foreground" />
|
|
918
|
-
<span>AI is handling this conversation.</span>
|
|
919
|
-
<Button
|
|
920
|
-
variant="link"
|
|
921
|
-
size="sm"
|
|
922
|
-
className="h-auto p-0 text-[12px] font-medium text-foreground"
|
|
923
|
-
onClick={onTakeOver}
|
|
924
|
-
>
|
|
925
|
-
Take Over
|
|
926
|
-
</Button>
|
|
927
|
-
<span>to reply directly.</span>
|
|
928
|
-
</div>
|
|
929
|
-
) : (
|
|
930
|
-
/* Email panel stays in normal flow to anchor container height;
|
|
931
|
-
chat panel is an absolute overlay so both tabs share identical dimensions */
|
|
932
|
-
<div className="relative">
|
|
933
|
-
<div
|
|
934
|
-
className={cn(
|
|
935
|
-
"flex flex-col",
|
|
936
|
-
channel !== "email" && "invisible pointer-events-none",
|
|
937
|
-
)}
|
|
938
|
-
aria-hidden={channel !== "email"}
|
|
939
|
-
>
|
|
940
|
-
<ComposerEmailFieldRow label="To">
|
|
941
|
-
<input
|
|
942
|
-
type="email"
|
|
943
|
-
value={emailTo}
|
|
944
|
-
onChange={(e) => setEmailTo(e.target.value)}
|
|
945
|
-
placeholder="Recipient email"
|
|
946
|
-
className="min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground"
|
|
947
|
-
/>
|
|
948
|
-
<button
|
|
949
|
-
type="button"
|
|
950
|
-
onClick={() => setShowCc((v) => !v)}
|
|
951
|
-
className="flex shrink-0 items-center gap-0.5 text-sm text-muted-foreground hover:text-foreground"
|
|
952
|
-
>
|
|
953
|
-
CC
|
|
954
|
-
<ChevronDown className="size-3.5" />
|
|
955
|
-
</button>
|
|
956
|
-
</ComposerEmailFieldRow>
|
|
957
|
-
{showCc && (
|
|
958
|
-
<ComposerEmailFieldRow label="CC">
|
|
959
|
-
<input
|
|
960
|
-
type="email"
|
|
961
|
-
value={emailCc}
|
|
962
|
-
onChange={(e) => setEmailCc(e.target.value)}
|
|
963
|
-
placeholder="CC email"
|
|
964
|
-
className="min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground"
|
|
965
|
-
/>
|
|
966
|
-
</ComposerEmailFieldRow>
|
|
967
|
-
)}
|
|
968
|
-
<ComposerEmailFieldRow label="Subject">
|
|
969
|
-
<input
|
|
970
|
-
type="text"
|
|
971
|
-
value={emailSubject}
|
|
972
|
-
onChange={(e) => setEmailSubject(e.target.value)}
|
|
973
|
-
placeholder="Email subject"
|
|
974
|
-
className="min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground"
|
|
975
|
-
/>
|
|
976
|
-
</ComposerEmailFieldRow>
|
|
977
|
-
<EditorContent editor={editor} />
|
|
978
|
-
<div className="flex items-center justify-between border-t border-border px-3 py-2">
|
|
979
|
-
<div className="flex items-center gap-0.5">
|
|
980
|
-
<ComposerToolbarButton
|
|
981
|
-
label="Bold"
|
|
982
|
-
icon={Bold}
|
|
983
|
-
pressed={editor?.isActive("bold")}
|
|
984
|
-
onToggle={() => editor?.chain().focus().toggleBold().run()}
|
|
985
|
-
/>
|
|
986
|
-
<ComposerToolbarButton
|
|
987
|
-
label="Italic"
|
|
988
|
-
icon={Italic}
|
|
989
|
-
pressed={editor?.isActive("italic")}
|
|
990
|
-
onToggle={() => editor?.chain().focus().toggleItalic().run()}
|
|
991
|
-
/>
|
|
992
|
-
<ComposerToolbarButton
|
|
993
|
-
label="Underline"
|
|
994
|
-
icon={Underline}
|
|
995
|
-
pressed={editor?.isActive("underline")}
|
|
996
|
-
onToggle={() =>
|
|
997
|
-
editor?.chain().focus().toggleUnderline().run()
|
|
998
|
-
}
|
|
999
|
-
/>
|
|
1000
|
-
<Separator orientation="vertical" className="mx-1.5 h-4" />
|
|
1001
|
-
<ComposerLinkPopover editor={editor} />
|
|
1002
|
-
<ComposerToolbarButton label="Attach file" icon={Paperclip} />
|
|
1003
|
-
</div>
|
|
1004
|
-
<Button
|
|
1005
|
-
size="sm"
|
|
1006
|
-
onClick={() => {
|
|
1007
|
-
const html = editor?.getHTML() ?? "";
|
|
1008
|
-
onSendEmail?.({
|
|
1009
|
-
content: html,
|
|
1010
|
-
to: emailTo,
|
|
1011
|
-
cc: emailCc,
|
|
1012
|
-
subject: emailSubject,
|
|
1013
|
-
});
|
|
1014
|
-
editor?.commands.clearContent();
|
|
1015
|
-
}}
|
|
1016
|
-
disabled={!editor || editor.isEmpty || !emailTo.trim()}
|
|
1017
|
-
>
|
|
1018
|
-
<Send className="mr-1.5 size-3.5" />
|
|
1019
|
-
Send Email
|
|
1020
|
-
</Button>
|
|
1021
|
-
</div>
|
|
1022
|
-
</div>
|
|
1023
|
-
|
|
1024
|
-
{/* Chat compose — absolute overlay, fills exact same height as email panel */}
|
|
1025
|
-
{channel === "chat" && (
|
|
1026
|
-
<div className="absolute inset-0 flex flex-col gap-3 p-4">
|
|
1027
|
-
<Textarea
|
|
1028
|
-
value={inputValue}
|
|
1029
|
-
onChange={(e) => onInputChange?.(e.target.value)}
|
|
1030
|
-
placeholder="Reply to lead..."
|
|
1031
|
-
className="min-h-0 flex-1 resize-none text-base"
|
|
1032
|
-
/>
|
|
1033
|
-
<div className="flex items-center justify-between">
|
|
1034
|
-
<Button variant="outline" size="sm" onClick={onLetAiHandle}>
|
|
1035
|
-
<Bot className="mr-1.5 size-3.5" />
|
|
1036
|
-
Let AI Handle
|
|
1037
|
-
</Button>
|
|
1038
|
-
<Button
|
|
1039
|
-
size="sm"
|
|
1040
|
-
onClick={() => onSend?.(inputValue)}
|
|
1041
|
-
disabled={!inputValue.trim()}
|
|
1042
|
-
>
|
|
1043
|
-
<Send className="mr-1.5 size-3.5" />
|
|
1044
|
-
Send
|
|
1045
|
-
</Button>
|
|
1046
|
-
</div>
|
|
1047
|
-
</div>
|
|
1048
|
-
)}
|
|
1049
|
-
</div>
|
|
1050
|
-
)}
|
|
1051
|
-
</div>
|
|
1052
|
-
);
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
// ---------------------------------------------------------------------------
|
|
1056
|
-
// ChatThread
|
|
1057
|
-
// ---------------------------------------------------------------------------
|
|
1058
|
-
|
|
1059
|
-
export interface ChatThreadProps {
|
|
1060
|
-
contact: AiConvContact;
|
|
1061
|
-
status: AiConvStatus;
|
|
1062
|
-
mode: AiConvMode;
|
|
1063
|
-
messages: AiConvMessage[];
|
|
1064
|
-
isAiTyping?: boolean;
|
|
1065
|
-
/** Active reply channel — "chat" (default) or "email". */
|
|
1066
|
-
channel?: AiConvChannel;
|
|
1067
|
-
onChannelChange?: (channel: AiConvChannel) => void;
|
|
1068
|
-
/**
|
|
1069
|
-
* The channel this conversation belongs to.
|
|
1070
|
-
*
|
|
1071
|
-
* - `"chat"` (default) — chat composer shown normally.
|
|
1072
|
-
* - `"email"` + `isEmailIntegrated=true` — email tab is active by default.
|
|
1073
|
-
* - `"email"` + `isEmailIntegrated=false` — composer is replaced with a
|
|
1074
|
-
* prompt to integrate email.
|
|
1075
|
-
*/
|
|
1076
|
-
channelType?: AiConvChannel;
|
|
1077
|
-
/**
|
|
1078
|
-
* When true, the Email tab is shown in the composer. Defaults to false.
|
|
1079
|
-
*/
|
|
1080
|
-
isEmailIntegrated?: boolean;
|
|
1081
|
-
inputValue?: string;
|
|
1082
|
-
onInputChange?: (v: string) => void;
|
|
1083
|
-
/** Fired when the user sends a chat message. */
|
|
1084
|
-
onSend?: (content: string) => void;
|
|
1085
|
-
/** Fired when the user sends an email. */
|
|
1086
|
-
onSendEmail?: (payload: AiConvEmailPayload) => void;
|
|
1087
|
-
onTakeOver?: () => void;
|
|
1088
|
-
onLetAiHandle?: () => void;
|
|
1089
|
-
onReopen?: () => void;
|
|
1090
|
-
onMarkUrgent?: () => void;
|
|
1091
|
-
onUnmarkUrgent?: () => void;
|
|
1092
|
-
onArchive?: () => void;
|
|
1093
|
-
onAssignToAdvisor?: () => void;
|
|
1094
|
-
/** True when older messages can be loaded (e.g. paginated history). */
|
|
1095
|
-
hasMoreMessages?: boolean;
|
|
1096
|
-
/** True while a `onLoadMoreMessages` request is in-flight. */
|
|
1097
|
-
isLoadingMoreMessages?: boolean;
|
|
1098
|
-
/** Fired when the consumer should fetch older messages. */
|
|
1099
|
-
onLoadMoreMessages?: () => void;
|
|
1100
|
-
/** Mobile only — back to conversation list. */
|
|
1101
|
-
onBack?: () => void;
|
|
1102
|
-
/** Mobile only — show lead info panel. */
|
|
1103
|
-
onShowLeadInfo?: () => void;
|
|
1104
|
-
className?: string;
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
export function ChatThread({
|
|
1108
|
-
contact,
|
|
1109
|
-
status,
|
|
1110
|
-
mode,
|
|
1111
|
-
messages,
|
|
1112
|
-
isAiTyping = false,
|
|
1113
|
-
channel,
|
|
1114
|
-
onChannelChange,
|
|
1115
|
-
channelType,
|
|
1116
|
-
isEmailIntegrated,
|
|
1117
|
-
inputValue,
|
|
1118
|
-
onInputChange,
|
|
1119
|
-
onSend,
|
|
1120
|
-
onSendEmail,
|
|
1121
|
-
onTakeOver,
|
|
1122
|
-
onLetAiHandle,
|
|
1123
|
-
onReopen,
|
|
1124
|
-
onMarkUrgent,
|
|
1125
|
-
onUnmarkUrgent,
|
|
1126
|
-
onArchive,
|
|
1127
|
-
onAssignToAdvisor,
|
|
1128
|
-
hasMoreMessages,
|
|
1129
|
-
isLoadingMoreMessages,
|
|
1130
|
-
onLoadMoreMessages,
|
|
1131
|
-
onBack,
|
|
1132
|
-
onShowLeadInfo,
|
|
1133
|
-
className,
|
|
1134
|
-
}: ChatThreadProps) {
|
|
1135
|
-
const aiIsHandling = mode === "ai";
|
|
1136
|
-
const isClosed = status === "closed";
|
|
1137
|
-
|
|
1138
|
-
const scrollRef = React.useRef<HTMLDivElement>(null);
|
|
1139
|
-
// Captures scrollHeight just before older messages are prepended, so we can
|
|
1140
|
-
// restore the user's visible scroll offset once the new nodes render.
|
|
1141
|
-
const preLoadScrollHeightRef = React.useRef<number | null>(null);
|
|
1142
|
-
|
|
1143
|
-
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
1144
|
-
if (!hasMoreMessages || isLoadingMoreMessages || !onLoadMoreMessages) {
|
|
1145
|
-
return;
|
|
1146
|
-
}
|
|
1147
|
-
if (e.currentTarget.scrollTop <= 80) {
|
|
1148
|
-
preLoadScrollHeightRef.current = e.currentTarget.scrollHeight;
|
|
1149
|
-
onLoadMoreMessages();
|
|
1150
|
-
}
|
|
1151
|
-
};
|
|
1152
|
-
|
|
1153
|
-
// Tracks the last "tail" message id so we can tell an append (new message,
|
|
1154
|
-
// tail changed) apart from a prepend (older history loaded, tail unchanged).
|
|
1155
|
-
const prevLastMessageIdRef = React.useRef<string | undefined>(undefined);
|
|
1156
|
-
const prevContactIdRef = React.useRef<string>(contact.id);
|
|
1157
|
-
|
|
1158
|
-
React.useLayoutEffect(() => {
|
|
1159
|
-
const el = scrollRef.current;
|
|
1160
|
-
if (!el) return;
|
|
1161
|
-
|
|
1162
|
-
// Prepend (older messages just loaded) — restore scroll so the user
|
|
1163
|
-
// stays anchored to the message they were reading.
|
|
1164
|
-
if (preLoadScrollHeightRef.current !== null) {
|
|
1165
|
-
el.scrollTop = el.scrollHeight - preLoadScrollHeightRef.current;
|
|
1166
|
-
preLoadScrollHeightRef.current = null;
|
|
1167
|
-
prevLastMessageIdRef.current = messages[messages.length - 1]?.id;
|
|
1168
|
-
prevContactIdRef.current = contact.id;
|
|
1169
|
-
return;
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
const currentLastId = messages[messages.length - 1]?.id;
|
|
1173
|
-
const contactChanged = prevContactIdRef.current !== contact.id;
|
|
1174
|
-
const tailChanged = prevLastMessageIdRef.current !== currentLastId;
|
|
1175
|
-
|
|
1176
|
-
// Opening a conversation or appending a new message (sent, received,
|
|
1177
|
-
// or system) — pin to the bottom.
|
|
1178
|
-
if (contactChanged || tailChanged) {
|
|
1179
|
-
el.scrollTop = el.scrollHeight;
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
prevLastMessageIdRef.current = currentLastId;
|
|
1183
|
-
prevContactIdRef.current = contact.id;
|
|
1184
|
-
}, [contact.id, messages]);
|
|
1185
|
-
|
|
1186
|
-
// Typing indicator adds DOM height — keep the view pinned to bottom.
|
|
1187
|
-
React.useLayoutEffect(() => {
|
|
1188
|
-
if (!isAiTyping) return;
|
|
1189
|
-
const el = scrollRef.current;
|
|
1190
|
-
if (el) el.scrollTop = el.scrollHeight;
|
|
1191
|
-
}, [isAiTyping]);
|
|
1192
|
-
|
|
1193
|
-
return (
|
|
1194
|
-
<div className={cn("flex flex-col bg-background", className)}>
|
|
1195
|
-
{/* Header */}
|
|
1196
|
-
<div
|
|
1197
|
-
className={cn(
|
|
1198
|
-
PANEL_HEADER_HEIGHT,
|
|
1199
|
-
"flex items-center gap-3 border-b border-border px-4",
|
|
1200
|
-
)}
|
|
1201
|
-
>
|
|
1202
|
-
{/* Mobile back button */}
|
|
1203
|
-
{onBack && (
|
|
1204
|
-
<Button
|
|
1205
|
-
variant="ghost"
|
|
1206
|
-
size="icon"
|
|
1207
|
-
className="size-8 shrink-0 md:hidden"
|
|
1208
|
-
onClick={onBack}
|
|
1209
|
-
aria-label="Back to conversations"
|
|
1210
|
-
>
|
|
1211
|
-
<ArrowLeft className="size-4" />
|
|
1212
|
-
</Button>
|
|
1213
|
-
)}
|
|
1214
|
-
|
|
1215
|
-
<ContactAvatar name={contact.name} size="md" />
|
|
1216
|
-
|
|
1217
|
-
<div className="min-w-0 flex-1">
|
|
1218
|
-
<div className="flex items-center gap-2">
|
|
1219
|
-
<span className="text-sm font-semibold text-foreground">
|
|
1220
|
-
{displayContactName(contact.name)}
|
|
1221
|
-
</span>
|
|
1222
|
-
<ConversationStatusChip status={status} showDot />
|
|
1223
|
-
</div>
|
|
1224
|
-
{contact.email && (
|
|
1225
|
-
<p className="text-sm text-muted-foreground">{contact.email}</p>
|
|
1226
|
-
)}
|
|
1227
|
-
</div>
|
|
1228
|
-
|
|
1229
|
-
<div className="flex shrink-0 items-center gap-2">
|
|
1230
|
-
{/* Action buttons — desktop only (mobile: already in bottom bar) */}
|
|
1231
|
-
<div className="hidden items-center gap-2 md:flex">
|
|
1232
|
-
{isClosed && (
|
|
1233
|
-
<Button variant="outline" size="sm" onClick={onReopen}>
|
|
1234
|
-
Reopen
|
|
1235
|
-
</Button>
|
|
1236
|
-
)}
|
|
1237
|
-
{!isClosed && aiIsHandling && (
|
|
1238
|
-
<Button size="sm" onClick={onTakeOver}>
|
|
1239
|
-
Take Over
|
|
1240
|
-
</Button>
|
|
1241
|
-
)}
|
|
1242
|
-
{!isClosed && !aiIsHandling && (
|
|
1243
|
-
<Button variant="outline" size="sm" onClick={onLetAiHandle}>
|
|
1244
|
-
<Bot className="mr-1.5 size-3.5" />
|
|
1245
|
-
Let AI Handle
|
|
1246
|
-
</Button>
|
|
1247
|
-
)}
|
|
1248
|
-
</div>
|
|
1249
|
-
|
|
1250
|
-
{/* More actions dropdown */}
|
|
1251
|
-
<DropdownMenu>
|
|
1252
|
-
<DropdownMenuTrigger
|
|
1253
|
-
className={cn(
|
|
1254
|
-
buttonVariants({ variant: "ghost", size: "icon" }),
|
|
1255
|
-
"size-8",
|
|
1256
|
-
)}
|
|
1257
|
-
aria-label="More actions"
|
|
1258
|
-
>
|
|
1259
|
-
<MoreHorizontal className="size-4" />
|
|
1260
|
-
</DropdownMenuTrigger>
|
|
1261
|
-
<DropdownMenuContent>
|
|
1262
|
-
{/* Lead Info — mobile only */}
|
|
1263
|
-
{onShowLeadInfo && (
|
|
1264
|
-
<>
|
|
1265
|
-
<DropdownMenuItem
|
|
1266
|
-
className="md:hidden"
|
|
1267
|
-
onClick={onShowLeadInfo}
|
|
1268
|
-
>
|
|
1269
|
-
<ChevronRight className="mr-2 size-4" />
|
|
1270
|
-
Lead Info
|
|
1271
|
-
</DropdownMenuItem>
|
|
1272
|
-
<DropdownMenuSeparator className="md:hidden" />
|
|
1273
|
-
</>
|
|
1274
|
-
)}
|
|
1275
|
-
{status === "needs-attention" ? (
|
|
1276
|
-
<DropdownMenuItem onClick={onUnmarkUrgent}>
|
|
1277
|
-
<Flag className="mr-2 size-4" />
|
|
1278
|
-
Unmark Urgent
|
|
1279
|
-
</DropdownMenuItem>
|
|
1280
|
-
) : (
|
|
1281
|
-
<DropdownMenuItem onClick={onMarkUrgent}>
|
|
1282
|
-
<Flag className="mr-2 size-4" />
|
|
1283
|
-
Mark as Urgent
|
|
1284
|
-
</DropdownMenuItem>
|
|
1285
|
-
)}
|
|
1286
|
-
<DropdownMenuItem onClick={onAssignToAdvisor}>
|
|
1287
|
-
<UserCheck className="mr-2 size-4" />
|
|
1288
|
-
Assign to advisor
|
|
1289
|
-
</DropdownMenuItem>
|
|
1290
|
-
<DropdownMenuSeparator />
|
|
1291
|
-
<DropdownMenuItem onClick={onArchive}>
|
|
1292
|
-
<Archive className="mr-2 size-4" />
|
|
1293
|
-
Archive
|
|
1294
|
-
</DropdownMenuItem>
|
|
1295
|
-
</DropdownMenuContent>
|
|
1296
|
-
</DropdownMenu>
|
|
1297
|
-
</div>
|
|
1298
|
-
</div>
|
|
1299
|
-
|
|
1300
|
-
{/* Messages */}
|
|
1301
|
-
<div
|
|
1302
|
-
ref={scrollRef}
|
|
1303
|
-
onScroll={handleScroll}
|
|
1304
|
-
className="flex flex-1 flex-col gap-4 overflow-y-auto p-4"
|
|
1305
|
-
tabIndex={0}
|
|
1306
|
-
>
|
|
1307
|
-
{isLoadingMoreMessages && (
|
|
1308
|
-
<div className="flex justify-center py-1 text-caption text-muted-foreground">
|
|
1309
|
-
Loading older messages...
|
|
1310
|
-
</div>
|
|
1311
|
-
)}
|
|
1312
|
-
{messages.length === 0 ? (
|
|
1313
|
-
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-muted-foreground">
|
|
1314
|
-
<MessageSquare className="size-8 opacity-30" />
|
|
1315
|
-
<p className="text-sm">No messages yet</p>
|
|
1316
|
-
</div>
|
|
1317
|
-
) : (
|
|
1318
|
-
messages.map((msg) => <ChatBubble key={msg.id} message={msg} />)
|
|
1319
|
-
)}
|
|
1320
|
-
|
|
1321
|
-
{/* AI typing indicator */}
|
|
1322
|
-
{isAiTyping && !isClosed && (
|
|
1323
|
-
<div className="flex gap-2.5">
|
|
1324
|
-
<BubbleAvatar role="bot" />
|
|
1325
|
-
<div className="flex flex-col gap-1">
|
|
1326
|
-
<span className="text-caption text-muted-foreground">
|
|
1327
|
-
AI Assistant
|
|
1328
|
-
</span>
|
|
1329
|
-
<div className="flex items-center gap-1 border border-border bg-muted/60 px-3 py-2.5">
|
|
1330
|
-
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
|
1331
|
-
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms]" />
|
|
1332
|
-
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:300ms]" />
|
|
1333
|
-
</div>
|
|
1334
|
-
</div>
|
|
1335
|
-
</div>
|
|
1336
|
-
)}
|
|
1337
|
-
</div>
|
|
1338
|
-
|
|
1339
|
-
{/* Composer / locked banner */}
|
|
1340
|
-
{isClosed ? (
|
|
1341
|
-
<div className="flex items-center gap-3 border-t border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
|
1342
|
-
<Lock className="size-3.5 shrink-0" />
|
|
1343
|
-
<span>This conversation is closed.</span>
|
|
1344
|
-
<Button
|
|
1345
|
-
variant="outline"
|
|
1346
|
-
size="sm"
|
|
1347
|
-
className="ml-auto"
|
|
1348
|
-
onClick={onReopen}
|
|
1349
|
-
>
|
|
1350
|
-
Reopen
|
|
1351
|
-
</Button>
|
|
1352
|
-
</div>
|
|
1353
|
-
) : (
|
|
1354
|
-
<ChatComposer
|
|
1355
|
-
mode={mode}
|
|
1356
|
-
channel={channel}
|
|
1357
|
-
onChannelChange={onChannelChange}
|
|
1358
|
-
channelType={channelType}
|
|
1359
|
-
isEmailIntegrated={isEmailIntegrated}
|
|
1360
|
-
contactEmail={contact.email}
|
|
1361
|
-
inputValue={inputValue}
|
|
1362
|
-
onInputChange={onInputChange}
|
|
1363
|
-
onSend={onSend}
|
|
1364
|
-
onSendEmail={onSendEmail}
|
|
1365
|
-
onTakeOver={onTakeOver}
|
|
1366
|
-
onLetAiHandle={onLetAiHandle}
|
|
1367
|
-
/>
|
|
1368
|
-
)}
|
|
1369
|
-
</div>
|
|
1370
|
-
);
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
// ---------------------------------------------------------------------------
|
|
1374
|
-
// AICollectedDataSection
|
|
1375
|
-
// ---------------------------------------------------------------------------
|
|
1376
|
-
|
|
1377
|
-
export interface AICollectedDataSectionProps {
|
|
1378
|
-
fields: AiConvDataField[];
|
|
1379
|
-
className?: string;
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
export function AICollectedDataSection({
|
|
1383
|
-
fields,
|
|
1384
|
-
className,
|
|
1385
|
-
}: AICollectedDataSectionProps) {
|
|
1386
|
-
return (
|
|
1387
|
-
<div className={cn("flex flex-col", className)}>
|
|
1388
|
-
{fields.map((field, i) => (
|
|
1389
|
-
<div
|
|
1390
|
-
key={i}
|
|
1391
|
-
className="flex items-center justify-between gap-2 border-b border-border/40 py-1.5 last:border-b-0"
|
|
1392
|
-
>
|
|
1393
|
-
<span className="shrink-0 text-sm text-muted-foreground">
|
|
1394
|
-
{field.label}
|
|
1395
|
-
</span>
|
|
1396
|
-
<div className="flex items-center gap-1.5">
|
|
1397
|
-
<span className="text-right text-sm font-medium text-foreground">
|
|
1398
|
-
{field.value}
|
|
1399
|
-
</span>
|
|
1400
|
-
{field.confidence === "confirmed" ? (
|
|
1401
|
-
<CheckCircle2 className="size-3 shrink-0 text-success-text" />
|
|
1402
|
-
) : (
|
|
1403
|
-
<HelpCircle className="size-3 shrink-0 text-warning-text" />
|
|
1404
|
-
)}
|
|
1405
|
-
</div>
|
|
1406
|
-
</div>
|
|
1407
|
-
))}
|
|
1408
|
-
</div>
|
|
1409
|
-
);
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
// ---------------------------------------------------------------------------
|
|
1413
|
-
// LeadInfoPanel
|
|
1414
|
-
// ---------------------------------------------------------------------------
|
|
1415
|
-
|
|
1416
|
-
const MEETING_ICON: Record<AiConvMeetingType, React.ElementType> = {
|
|
1417
|
-
video: Video,
|
|
1418
|
-
phone: Phone,
|
|
1419
|
-
"in-person": MapPin,
|
|
1420
|
-
};
|
|
1421
|
-
|
|
1422
|
-
const MEETING_LABEL: Record<AiConvMeetingType, string> = {
|
|
1423
|
-
video: "Video Call",
|
|
1424
|
-
phone: "Phone Call",
|
|
1425
|
-
"in-person": "In Person",
|
|
1426
|
-
};
|
|
1427
|
-
|
|
1428
|
-
const MEETING_DETAIL_ICON: Record<AiConvMeetingType, React.ElementType> = {
|
|
1429
|
-
video: Link2,
|
|
1430
|
-
phone: PhoneCall,
|
|
1431
|
-
"in-person": Navigation,
|
|
1432
|
-
};
|
|
1433
|
-
|
|
1434
|
-
interface AppointmentSectionProps {
|
|
1435
|
-
appointment: AiConvAppointmentData;
|
|
1436
|
-
contactId: string;
|
|
1437
|
-
isAnonymous: boolean;
|
|
1438
|
-
onApproveAppointment?: () => void;
|
|
1439
|
-
onDeclineAppointment?: () => void;
|
|
1440
|
-
onRescheduleAppointment?: (contactId: string) => void;
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
function MeetingDetailRow({
|
|
1444
|
-
meetingType,
|
|
1445
|
-
detail,
|
|
1446
|
-
}: {
|
|
1447
|
-
meetingType: AiConvMeetingType;
|
|
1448
|
-
detail: string;
|
|
1449
|
-
}) {
|
|
1450
|
-
const DetailIcon = MEETING_DETAIL_ICON[meetingType];
|
|
1451
|
-
const isLink = detail.startsWith("http");
|
|
1452
|
-
return (
|
|
1453
|
-
<div className="flex items-center gap-2">
|
|
1454
|
-
<DetailIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
1455
|
-
{isLink ? (
|
|
1456
|
-
<a
|
|
1457
|
-
href={detail}
|
|
1458
|
-
target="_blank"
|
|
1459
|
-
rel="noopener noreferrer"
|
|
1460
|
-
className="text-sm text-primary underline underline-offset-2 break-all hover:text-primary/80"
|
|
1461
|
-
>
|
|
1462
|
-
{detail}
|
|
1463
|
-
</a>
|
|
1464
|
-
) : (
|
|
1465
|
-
<span className="text-sm text-muted-foreground break-all">
|
|
1466
|
-
{detail}
|
|
1467
|
-
</span>
|
|
1468
|
-
)}
|
|
1469
|
-
</div>
|
|
1470
|
-
);
|
|
1471
|
-
}
|
|
1472
|
-
|
|
1473
|
-
function AppointmentSection({
|
|
1474
|
-
appointment,
|
|
1475
|
-
contactId,
|
|
1476
|
-
isAnonymous,
|
|
1477
|
-
onApproveAppointment,
|
|
1478
|
-
onDeclineAppointment,
|
|
1479
|
-
onRescheduleAppointment,
|
|
1480
|
-
}: AppointmentSectionProps) {
|
|
1481
|
-
const AppointmentIcon = MEETING_ICON[appointment.meetingType];
|
|
1482
|
-
const canReschedule = !isAnonymous && !!onRescheduleAppointment;
|
|
1483
|
-
|
|
1484
|
-
return (
|
|
1485
|
-
<div className="flex flex-col gap-2">
|
|
1486
|
-
<div className="flex items-center gap-2">
|
|
1487
|
-
<Calendar className="size-3.5 shrink-0 text-muted-foreground" />
|
|
1488
|
-
<span className="text-sm font-medium text-foreground">
|
|
1489
|
-
{appointment.datetime}
|
|
1490
|
-
</span>
|
|
1491
|
-
</div>
|
|
1492
|
-
<div className="flex items-center gap-2">
|
|
1493
|
-
<AppointmentIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
1494
|
-
<span className="text-sm text-muted-foreground">
|
|
1495
|
-
{MEETING_LABEL[appointment.meetingType]}
|
|
1496
|
-
</span>
|
|
1497
|
-
</div>
|
|
1498
|
-
{appointment.meetingDetail && (
|
|
1499
|
-
<MeetingDetailRow
|
|
1500
|
-
meetingType={appointment.meetingType}
|
|
1501
|
-
detail={appointment.meetingDetail}
|
|
1502
|
-
/>
|
|
1503
|
-
)}
|
|
1504
|
-
<span
|
|
1505
|
-
className={cn("text-sm font-medium", {
|
|
1506
|
-
"text-warning-text": appointment.status === "requested",
|
|
1507
|
-
"text-success-text": appointment.status === "confirmed",
|
|
1508
|
-
"text-muted-foreground": appointment.status === "pending",
|
|
1509
|
-
"text-destructive line-through": appointment.status === "cancelled",
|
|
1510
|
-
})}
|
|
1511
|
-
>
|
|
1512
|
-
{APPOINTMENT_STATUS_LABEL[appointment.status]}
|
|
1513
|
-
</span>
|
|
1514
|
-
|
|
1515
|
-
{appointment.status === "requested" && (
|
|
1516
|
-
<div className="flex gap-2 pt-1">
|
|
1517
|
-
<Button size="sm" className="flex-1" onClick={onApproveAppointment}>
|
|
1518
|
-
Approve
|
|
1519
|
-
</Button>
|
|
1520
|
-
<Button
|
|
1521
|
-
variant="outline"
|
|
1522
|
-
size="sm"
|
|
1523
|
-
className="flex-1"
|
|
1524
|
-
onClick={onDeclineAppointment}
|
|
1525
|
-
>
|
|
1526
|
-
Decline
|
|
1527
|
-
</Button>
|
|
1528
|
-
{canReschedule && (
|
|
1529
|
-
<Button
|
|
1530
|
-
variant="ghost"
|
|
1531
|
-
size="sm"
|
|
1532
|
-
className="flex-1"
|
|
1533
|
-
onClick={() => onRescheduleAppointment!(contactId)}
|
|
1534
|
-
>
|
|
1535
|
-
Reschedule
|
|
1536
|
-
</Button>
|
|
1537
|
-
)}
|
|
1538
|
-
</div>
|
|
1539
|
-
)}
|
|
1540
|
-
|
|
1541
|
-
{(appointment.status === "confirmed" ||
|
|
1542
|
-
appointment.status === "cancelled") &&
|
|
1543
|
-
canReschedule && (
|
|
1544
|
-
<Button
|
|
1545
|
-
variant="outline"
|
|
1546
|
-
size="sm"
|
|
1547
|
-
className="mt-1 w-full justify-start"
|
|
1548
|
-
onClick={() => onRescheduleAppointment!(contactId)}
|
|
1549
|
-
>
|
|
1550
|
-
Reschedule
|
|
1551
|
-
</Button>
|
|
1552
|
-
)}
|
|
1553
|
-
</div>
|
|
1554
|
-
);
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
function PanelSectionHeader({ children }: { children: React.ReactNode }) {
|
|
1558
|
-
return (
|
|
1559
|
-
<p className="mb-2.5 text-overline text-muted-foreground">{children}</p>
|
|
1560
|
-
);
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
function PanelSection({
|
|
1564
|
-
children,
|
|
1565
|
-
last = false,
|
|
1566
|
-
}: {
|
|
1567
|
-
children: React.ReactNode;
|
|
1568
|
-
last?: boolean;
|
|
1569
|
-
}) {
|
|
1570
|
-
return (
|
|
1571
|
-
<div className={cn("px-4 py-4", !last && "border-b border-border")}>
|
|
1572
|
-
{children}
|
|
1573
|
-
</div>
|
|
1574
|
-
);
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
export interface LeadInfoPanelProps {
|
|
1578
|
-
contact: AiConvContact;
|
|
1579
|
-
firstSeen?: string;
|
|
1580
|
-
source?: string;
|
|
1581
|
-
/** Conversation topic tag — e.g. "Buy a Home", "Refinance", "Investment". */
|
|
1582
|
-
topic?: string;
|
|
1583
|
-
aiFields?: AiConvDataField[];
|
|
1584
|
-
appointment?: AiConvAppointmentData;
|
|
1585
|
-
internalNotes?: string;
|
|
1586
|
-
notesSaveStatus?: "idle" | "saving" | "saved";
|
|
1587
|
-
isCollapsed?: boolean;
|
|
1588
|
-
/** True when this lead's contact info already exists in the system. Disables Add to Contacts. */
|
|
1589
|
-
isKnownContact?: boolean;
|
|
1590
|
-
onAddToContacts?: () => void;
|
|
1591
|
-
onCreateOpportunity?: () => void;
|
|
1592
|
-
onBookAppointment?: () => void;
|
|
1593
|
-
onApproveAppointment?: () => void;
|
|
1594
|
-
onDeclineAppointment?: () => void;
|
|
1595
|
-
onRescheduleAppointment?: (contactId: string) => void;
|
|
1596
|
-
onNotesChange?: (v: string) => void;
|
|
1597
|
-
onToggleCollapse?: () => void;
|
|
1598
|
-
/** Mobile only — back to chat thread. */
|
|
1599
|
-
onBack?: () => void;
|
|
1600
|
-
className?: string;
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
export function LeadInfoPanel({
|
|
1604
|
-
contact,
|
|
1605
|
-
firstSeen,
|
|
1606
|
-
source = "Website Chatbot",
|
|
1607
|
-
topic,
|
|
1608
|
-
aiFields = [],
|
|
1609
|
-
appointment,
|
|
1610
|
-
internalNotes = "",
|
|
1611
|
-
notesSaveStatus = "idle",
|
|
1612
|
-
isCollapsed = false,
|
|
1613
|
-
isKnownContact = false,
|
|
1614
|
-
onAddToContacts,
|
|
1615
|
-
onCreateOpportunity,
|
|
1616
|
-
onBookAppointment,
|
|
1617
|
-
onApproveAppointment,
|
|
1618
|
-
onDeclineAppointment,
|
|
1619
|
-
onRescheduleAppointment,
|
|
1620
|
-
onNotesChange,
|
|
1621
|
-
onToggleCollapse,
|
|
1622
|
-
onBack,
|
|
1623
|
-
className,
|
|
1624
|
-
}: LeadInfoPanelProps) {
|
|
1625
|
-
const isAnonymous = !contact.name.trim();
|
|
1626
|
-
const addToContactsDisabled = isAnonymous || isKnownContact;
|
|
1627
|
-
|
|
1628
|
-
return (
|
|
1629
|
-
<div className={cn("flex flex-col bg-background", className)}>
|
|
1630
|
-
{/* Panel header */}
|
|
1631
|
-
<div
|
|
1632
|
-
className={cn(
|
|
1633
|
-
PANEL_HEADER_HEIGHT,
|
|
1634
|
-
"flex items-center justify-between border-b border-border px-4",
|
|
1635
|
-
)}
|
|
1636
|
-
>
|
|
1637
|
-
{/* Mobile back button */}
|
|
1638
|
-
{onBack && (
|
|
1639
|
-
<Button
|
|
1640
|
-
variant="ghost"
|
|
1641
|
-
size="icon"
|
|
1642
|
-
className="size-8 shrink-0 md:hidden"
|
|
1643
|
-
onClick={onBack}
|
|
1644
|
-
aria-label="Back to conversation"
|
|
1645
|
-
>
|
|
1646
|
-
<ArrowLeft className="size-4" />
|
|
1647
|
-
</Button>
|
|
1648
|
-
)}
|
|
1649
|
-
<span className="text-sm font-semibold text-foreground">Lead Info</span>
|
|
1650
|
-
{onToggleCollapse && (
|
|
1651
|
-
<Tooltip>
|
|
1652
|
-
<TooltipTrigger
|
|
1653
|
-
render={
|
|
1654
|
-
<Button
|
|
1655
|
-
variant="ghost"
|
|
1656
|
-
size="icon"
|
|
1657
|
-
className="size-7"
|
|
1658
|
-
onClick={onToggleCollapse}
|
|
1659
|
-
aria-label={isCollapsed ? "Expand panel" : "Collapse panel"}
|
|
1660
|
-
>
|
|
1661
|
-
<ChevronRight
|
|
1662
|
-
className={cn(
|
|
1663
|
-
"size-4 transition-transform duration-150",
|
|
1664
|
-
isCollapsed && "-rotate-180",
|
|
1665
|
-
)}
|
|
1666
|
-
/>
|
|
1667
|
-
</Button>
|
|
1668
|
-
}
|
|
1669
|
-
/>
|
|
1670
|
-
<TooltipContent>
|
|
1671
|
-
{isCollapsed ? "Expand panel" : "Collapse panel"}
|
|
1672
|
-
</TooltipContent>
|
|
1673
|
-
</Tooltip>
|
|
1674
|
-
)}
|
|
1675
|
-
</div>
|
|
1676
|
-
|
|
1677
|
-
{!isCollapsed && (
|
|
1678
|
-
<div className="flex-1 overflow-y-auto" tabIndex={0}>
|
|
1679
|
-
<PanelSection>
|
|
1680
|
-
<PanelSectionHeader>Contact</PanelSectionHeader>
|
|
1681
|
-
<div className="flex items-center gap-3">
|
|
1682
|
-
<ContactAvatar name={contact.name} size="lg" />
|
|
1683
|
-
<div className="min-w-0 flex-1">
|
|
1684
|
-
<p className="truncate text-sm font-semibold text-foreground">
|
|
1685
|
-
{displayContactName(contact.name)}
|
|
1686
|
-
</p>
|
|
1687
|
-
<p className="text-sm text-muted-foreground">{source}</p>
|
|
1688
|
-
{topic && (
|
|
1689
|
-
<Badge variant="secondary" className="mt-1 text-xs">
|
|
1690
|
-
{topic}
|
|
1691
|
-
</Badge>
|
|
1692
|
-
)}
|
|
1693
|
-
</div>
|
|
1694
|
-
</div>
|
|
1695
|
-
{(contact.email || contact.phone || firstSeen) && (
|
|
1696
|
-
<div className="mt-3 flex flex-col gap-1.5">
|
|
1697
|
-
{contact.email && (
|
|
1698
|
-
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
1699
|
-
<Mail className="size-4 shrink-0" />
|
|
1700
|
-
<a
|
|
1701
|
-
href={`mailto:${contact.email}`}
|
|
1702
|
-
className="truncate hover:underline"
|
|
1703
|
-
>
|
|
1704
|
-
{contact.email}
|
|
1705
|
-
</a>
|
|
1706
|
-
</div>
|
|
1707
|
-
)}
|
|
1708
|
-
{contact.phone && (
|
|
1709
|
-
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
1710
|
-
<PhoneCall className="size-4 shrink-0" />
|
|
1711
|
-
<a
|
|
1712
|
-
href={`tel:${contact.phone}`}
|
|
1713
|
-
className="hover:underline"
|
|
1714
|
-
>
|
|
1715
|
-
{contact.phone}
|
|
1716
|
-
</a>
|
|
1717
|
-
</div>
|
|
1718
|
-
)}
|
|
1719
|
-
{firstSeen && (
|
|
1720
|
-
<p className="text-sm text-muted-foreground">
|
|
1721
|
-
First seen: {firstSeen}
|
|
1722
|
-
</p>
|
|
1723
|
-
)}
|
|
1724
|
-
</div>
|
|
1725
|
-
)}
|
|
1726
|
-
</PanelSection>
|
|
1727
|
-
|
|
1728
|
-
<PanelSection>
|
|
1729
|
-
<PanelSectionHeader>AI-Collected Data</PanelSectionHeader>
|
|
1730
|
-
{aiFields.length > 0 ? (
|
|
1731
|
-
<>
|
|
1732
|
-
<AICollectedDataSection fields={aiFields} />
|
|
1733
|
-
<div className="mt-2.5 flex items-center gap-3 text-xs text-muted-foreground">
|
|
1734
|
-
<span className="flex items-center gap-1">
|
|
1735
|
-
<CheckCircle2 className="size-3 text-success-text" />
|
|
1736
|
-
confirmed
|
|
1737
|
-
</span>
|
|
1738
|
-
<span className="flex items-center gap-1">
|
|
1739
|
-
<HelpCircle className="size-3 text-warning-text" />
|
|
1740
|
-
estimated
|
|
1741
|
-
</span>
|
|
1742
|
-
</div>
|
|
1743
|
-
</>
|
|
1744
|
-
) : (
|
|
1745
|
-
<p className="text-sm text-muted-foreground">
|
|
1746
|
-
AI is still gathering information...
|
|
1747
|
-
</p>
|
|
1748
|
-
)}
|
|
1749
|
-
</PanelSection>
|
|
1750
|
-
|
|
1751
|
-
<PanelSection>
|
|
1752
|
-
<PanelSectionHeader>Appointment</PanelSectionHeader>
|
|
1753
|
-
{appointment ? (
|
|
1754
|
-
<AppointmentSection
|
|
1755
|
-
appointment={appointment}
|
|
1756
|
-
contactId={contact.id}
|
|
1757
|
-
isAnonymous={isAnonymous}
|
|
1758
|
-
onApproveAppointment={onApproveAppointment}
|
|
1759
|
-
onDeclineAppointment={onDeclineAppointment}
|
|
1760
|
-
onRescheduleAppointment={onRescheduleAppointment}
|
|
1761
|
-
/>
|
|
1762
|
-
) : (
|
|
1763
|
-
<Button
|
|
1764
|
-
variant="outline"
|
|
1765
|
-
size="sm"
|
|
1766
|
-
className="w-full justify-start"
|
|
1767
|
-
disabled={isAnonymous}
|
|
1768
|
-
onClick={onBookAppointment}
|
|
1769
|
-
>
|
|
1770
|
-
<Plus className="mr-1.5 size-3.5" />
|
|
1771
|
-
Book Appointment
|
|
1772
|
-
</Button>
|
|
1773
|
-
)}
|
|
1774
|
-
</PanelSection>
|
|
1775
|
-
|
|
1776
|
-
<PanelSection>
|
|
1777
|
-
<PanelSectionHeader>CRM Actions</PanelSectionHeader>
|
|
1778
|
-
<div className="flex flex-col gap-2">
|
|
1779
|
-
<Tooltip>
|
|
1780
|
-
<TooltipTrigger
|
|
1781
|
-
render={
|
|
1782
|
-
<Button
|
|
1783
|
-
variant="outline"
|
|
1784
|
-
size="sm"
|
|
1785
|
-
className="w-full justify-start"
|
|
1786
|
-
disabled={addToContactsDisabled}
|
|
1787
|
-
onClick={onAddToContacts}
|
|
1788
|
-
>
|
|
1789
|
-
<UserPlus className="mr-1.5 size-3.5" />
|
|
1790
|
-
Add to Contacts
|
|
1791
|
-
</Button>
|
|
1792
|
-
}
|
|
1793
|
-
/>
|
|
1794
|
-
{isKnownContact && (
|
|
1795
|
-
<TooltipContent>Already in contacts</TooltipContent>
|
|
1796
|
-
)}
|
|
1797
|
-
</Tooltip>
|
|
1798
|
-
<Button
|
|
1799
|
-
variant="outline"
|
|
1800
|
-
size="sm"
|
|
1801
|
-
className="w-full justify-start"
|
|
1802
|
-
disabled={isAnonymous}
|
|
1803
|
-
onClick={onCreateOpportunity}
|
|
1804
|
-
>
|
|
1805
|
-
<Briefcase className="mr-1.5 size-3.5" />
|
|
1806
|
-
Add to CRM
|
|
1807
|
-
</Button>
|
|
1808
|
-
</div>
|
|
1809
|
-
</PanelSection>
|
|
1810
|
-
|
|
1811
|
-
<PanelSection last>
|
|
1812
|
-
<div className="mb-2.5 flex items-center justify-between">
|
|
1813
|
-
<p className="text-overline text-muted-foreground">
|
|
1814
|
-
Internal Notes
|
|
1815
|
-
</p>
|
|
1816
|
-
{notesSaveStatus === "saving" && (
|
|
1817
|
-
<span className="text-xs text-muted-foreground">Saving...</span>
|
|
1818
|
-
)}
|
|
1819
|
-
{notesSaveStatus === "saved" && (
|
|
1820
|
-
<span className="flex items-center gap-1 text-xs text-success-text">
|
|
1821
|
-
<CheckCircle2 className="size-3" />
|
|
1822
|
-
Saved
|
|
1823
|
-
</span>
|
|
1824
|
-
)}
|
|
1825
|
-
</div>
|
|
1826
|
-
<Textarea
|
|
1827
|
-
value={internalNotes}
|
|
1828
|
-
onChange={(e) => onNotesChange?.(e.target.value)}
|
|
1829
|
-
placeholder="Private notes — not visible to lead..."
|
|
1830
|
-
rows={4}
|
|
1831
|
-
className="resize-none text-sm"
|
|
1832
|
-
/>
|
|
1833
|
-
</PanelSection>
|
|
1834
|
-
</div>
|
|
1835
|
-
)}
|
|
1836
|
-
</div>
|
|
1837
|
-
);
|
|
1838
|
-
}
|
|
1839
|
-
|
|
1840
|
-
// ---------------------------------------------------------------------------
|
|
1841
|
-
// ConversationsPage
|
|
1842
|
-
// ---------------------------------------------------------------------------
|
|
1843
|
-
|
|
1844
|
-
export interface ConversationsPageProps {
|
|
1845
|
-
conversations: AiConvListItemData[];
|
|
1846
|
-
activeConversationId?: string;
|
|
1847
|
-
/** Contact for the currently-selected conversation. */
|
|
1848
|
-
contact?: AiConvContact;
|
|
1849
|
-
status?: AiConvStatus;
|
|
1850
|
-
mode?: AiConvMode;
|
|
1851
|
-
messages?: AiConvMessage[];
|
|
1852
|
-
aiFields?: AiConvDataField[];
|
|
1853
|
-
appointment?: AiConvAppointmentData;
|
|
1854
|
-
/** When the lead first contacted via the chatbot. */
|
|
1855
|
-
leadFirstSeen?: string;
|
|
1856
|
-
/** Traffic source label (e.g. "Website Chatbot"). */
|
|
1857
|
-
leadSource?: string;
|
|
1858
|
-
/** Conversation topic tag — e.g. "Buy a Home", "Refinance", "Investment". */
|
|
1859
|
-
leadTopic?: string;
|
|
1860
|
-
searchQuery?: string;
|
|
1861
|
-
activeFilter?: AiConvFilterTab;
|
|
1862
|
-
/** Filter conversation list by channel type. */
|
|
1863
|
-
channelFilter?: AiConvChannelFilter;
|
|
1864
|
-
/** Active reply channel — "chat" (default) or "email". */
|
|
1865
|
-
channel?: AiConvChannel;
|
|
1866
|
-
onChannelChange?: (channel: AiConvChannel) => void;
|
|
1867
|
-
/**
|
|
1868
|
-
* The channel this conversation belongs to.
|
|
1869
|
-
*
|
|
1870
|
-
* - `"chat"` (default) — chat composer shown normally.
|
|
1871
|
-
* - `"email"` + `isEmailIntegrated=true` — email tab is active by default.
|
|
1872
|
-
* - `"email"` + `isEmailIntegrated=false` — composer is replaced with a
|
|
1873
|
-
* prompt to integrate email.
|
|
1874
|
-
*/
|
|
1875
|
-
channelType?: AiConvChannel;
|
|
1876
|
-
/**
|
|
1877
|
-
* When true, the Email tab is shown in the composer. Defaults to false.
|
|
1878
|
-
*/
|
|
1879
|
-
isEmailIntegrated?: boolean;
|
|
1880
|
-
inputValue?: string;
|
|
1881
|
-
internalNotes?: string;
|
|
1882
|
-
showLeadPanel?: boolean;
|
|
1883
|
-
hasMore?: boolean;
|
|
1884
|
-
isLoadingMore?: boolean;
|
|
1885
|
-
/** True when older messages can be loaded for the active conversation. */
|
|
1886
|
-
hasMoreMessages?: boolean;
|
|
1887
|
-
/** True while a `onLoadMoreMessages` request is in-flight. */
|
|
1888
|
-
isLoadingMoreMessages?: boolean;
|
|
1889
|
-
/** Fired when the consumer should fetch older messages for the active conversation. */
|
|
1890
|
-
onLoadMoreMessages?: () => void;
|
|
1891
|
-
onSelectConversation?: (id: string) => void;
|
|
1892
|
-
onRead?: (id: string) => void;
|
|
1893
|
-
onSearchChange?: (v: string) => void;
|
|
1894
|
-
onFilterChange?: (f: AiConvFilterTab) => void;
|
|
1895
|
-
onChannelFilterChange?: (f: AiConvChannelFilter) => void;
|
|
1896
|
-
onInputChange?: (v: string) => void;
|
|
1897
|
-
/** Fired when the user sends a chat message. */
|
|
1898
|
-
onSend?: (content: string) => void;
|
|
1899
|
-
/** Fired when the user sends an email. */
|
|
1900
|
-
onSendEmail?: (payload: AiConvEmailPayload) => void;
|
|
1901
|
-
onTakeOver?: () => void;
|
|
1902
|
-
onLetAiHandle?: () => void;
|
|
1903
|
-
onReopen?: () => void;
|
|
1904
|
-
onMarkUrgent?: () => void;
|
|
1905
|
-
onUnmarkUrgent?: () => void;
|
|
1906
|
-
onArchive?: () => void;
|
|
1907
|
-
onAssignToAdvisor?: () => void;
|
|
1908
|
-
/** True when this lead is already a contact in the system. Disables Add to Contacts. */
|
|
1909
|
-
isKnownContact?: boolean;
|
|
1910
|
-
onAddToContacts?: () => void;
|
|
1911
|
-
onCreateOpportunity?: () => void;
|
|
1912
|
-
onBookAppointment?: () => void;
|
|
1913
|
-
onApproveAppointment?: () => void;
|
|
1914
|
-
onDeclineAppointment?: () => void;
|
|
1915
|
-
onRescheduleAppointment?: (contactId: string) => void;
|
|
1916
|
-
onNotesChange?: (v: string) => void;
|
|
1917
|
-
onToggleLeadPanel?: () => void;
|
|
1918
|
-
onLoadMore?: () => void;
|
|
1919
|
-
isAiTyping?: boolean;
|
|
1920
|
-
notesSaveStatus?: "idle" | "saving" | "saved";
|
|
1921
|
-
className?: string;
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
export function ConversationsPage({
|
|
1925
|
-
conversations,
|
|
1926
|
-
activeConversationId,
|
|
1927
|
-
contact,
|
|
1928
|
-
status = "ai-active",
|
|
1929
|
-
mode = "ai",
|
|
1930
|
-
messages = [],
|
|
1931
|
-
aiFields = [],
|
|
1932
|
-
appointment,
|
|
1933
|
-
leadFirstSeen,
|
|
1934
|
-
leadSource,
|
|
1935
|
-
leadTopic,
|
|
1936
|
-
searchQuery,
|
|
1937
|
-
activeFilter,
|
|
1938
|
-
channelFilter,
|
|
1939
|
-
onChannelFilterChange,
|
|
1940
|
-
channel,
|
|
1941
|
-
onChannelChange,
|
|
1942
|
-
channelType,
|
|
1943
|
-
isEmailIntegrated,
|
|
1944
|
-
inputValue,
|
|
1945
|
-
internalNotes,
|
|
1946
|
-
showLeadPanel = true,
|
|
1947
|
-
hasMore,
|
|
1948
|
-
isLoadingMore,
|
|
1949
|
-
hasMoreMessages,
|
|
1950
|
-
isLoadingMoreMessages,
|
|
1951
|
-
onLoadMoreMessages,
|
|
1952
|
-
isAiTyping,
|
|
1953
|
-
notesSaveStatus,
|
|
1954
|
-
onSelectConversation,
|
|
1955
|
-
onRead,
|
|
1956
|
-
onSearchChange,
|
|
1957
|
-
onFilterChange,
|
|
1958
|
-
onInputChange,
|
|
1959
|
-
onSend,
|
|
1960
|
-
onSendEmail,
|
|
1961
|
-
onTakeOver,
|
|
1962
|
-
onLetAiHandle,
|
|
1963
|
-
onReopen,
|
|
1964
|
-
onMarkUrgent,
|
|
1965
|
-
onUnmarkUrgent,
|
|
1966
|
-
onArchive,
|
|
1967
|
-
onAssignToAdvisor,
|
|
1968
|
-
isKnownContact,
|
|
1969
|
-
onAddToContacts,
|
|
1970
|
-
onCreateOpportunity,
|
|
1971
|
-
onBookAppointment,
|
|
1972
|
-
onApproveAppointment,
|
|
1973
|
-
onDeclineAppointment,
|
|
1974
|
-
onRescheduleAppointment,
|
|
1975
|
-
onNotesChange,
|
|
1976
|
-
onToggleLeadPanel,
|
|
1977
|
-
onLoadMore,
|
|
1978
|
-
className,
|
|
1979
|
-
}: ConversationsPageProps) {
|
|
1980
|
-
// Mobile panel state — "list" | "chat" | "lead"
|
|
1981
|
-
const [mobilePanel, setMobilePanel] = useState<"list" | "chat" | "lead">(
|
|
1982
|
-
"list",
|
|
1983
|
-
);
|
|
1984
|
-
|
|
1985
|
-
const handleSelectConversation = (id: string) => {
|
|
1986
|
-
onSelectConversation?.(id);
|
|
1987
|
-
setMobilePanel("chat");
|
|
1988
|
-
};
|
|
1989
|
-
|
|
1990
|
-
const handleToggleLeadPanel = () => {
|
|
1991
|
-
onToggleLeadPanel?.();
|
|
1992
|
-
// When expanding lead panel → go to lead on mobile; collapsing → back to chat
|
|
1993
|
-
setMobilePanel(showLeadPanel ? "chat" : "lead");
|
|
1994
|
-
};
|
|
1995
|
-
|
|
1996
|
-
return (
|
|
1997
|
-
<TooltipProvider>
|
|
1998
|
-
<div
|
|
1999
|
-
className={cn("flex h-full overflow-hidden bg-background", className)}
|
|
2000
|
-
>
|
|
2001
|
-
{/* Left — Conversation List */}
|
|
2002
|
-
<ConversationList
|
|
2003
|
-
conversations={conversations}
|
|
2004
|
-
activeId={activeConversationId}
|
|
2005
|
-
searchQuery={searchQuery}
|
|
2006
|
-
activeFilter={activeFilter}
|
|
2007
|
-
channelFilter={channelFilter}
|
|
2008
|
-
hasMore={hasMore}
|
|
2009
|
-
isLoadingMore={isLoadingMore}
|
|
2010
|
-
onSearchChange={onSearchChange}
|
|
2011
|
-
onFilterChange={onFilterChange}
|
|
2012
|
-
onChannelFilterChange={onChannelFilterChange}
|
|
2013
|
-
onSelect={handleSelectConversation}
|
|
2014
|
-
onRead={onRead}
|
|
2015
|
-
onLoadMore={onLoadMore}
|
|
2016
|
-
className={cn(
|
|
2017
|
-
"shrink-0 md:w-[320px]",
|
|
2018
|
-
// Mobile: full width, visible only on list panel
|
|
2019
|
-
mobilePanel === "list" ? "flex w-full md:flex" : "hidden md:flex",
|
|
2020
|
-
)}
|
|
2021
|
-
/>
|
|
2022
|
-
|
|
2023
|
-
{/* Center — Chat Thread */}
|
|
2024
|
-
{contact ? (
|
|
2025
|
-
<ChatThread
|
|
2026
|
-
key={contact.id}
|
|
2027
|
-
contact={contact}
|
|
2028
|
-
status={status}
|
|
2029
|
-
mode={mode}
|
|
2030
|
-
messages={messages}
|
|
2031
|
-
isAiTyping={isAiTyping}
|
|
2032
|
-
channel={channel}
|
|
2033
|
-
onChannelChange={onChannelChange}
|
|
2034
|
-
channelType={channelType}
|
|
2035
|
-
isEmailIntegrated={isEmailIntegrated}
|
|
2036
|
-
inputValue={inputValue}
|
|
2037
|
-
onInputChange={onInputChange}
|
|
2038
|
-
onSend={onSend}
|
|
2039
|
-
onSendEmail={onSendEmail}
|
|
2040
|
-
onTakeOver={onTakeOver}
|
|
2041
|
-
onLetAiHandle={onLetAiHandle}
|
|
2042
|
-
onReopen={onReopen}
|
|
2043
|
-
onMarkUrgent={onMarkUrgent}
|
|
2044
|
-
onUnmarkUrgent={onUnmarkUrgent}
|
|
2045
|
-
onArchive={onArchive}
|
|
2046
|
-
onAssignToAdvisor={onAssignToAdvisor}
|
|
2047
|
-
hasMoreMessages={hasMoreMessages}
|
|
2048
|
-
isLoadingMoreMessages={isLoadingMoreMessages}
|
|
2049
|
-
onLoadMoreMessages={onLoadMoreMessages}
|
|
2050
|
-
onBack={() => setMobilePanel("list")}
|
|
2051
|
-
onShowLeadInfo={() => setMobilePanel("lead")}
|
|
2052
|
-
className={cn(
|
|
2053
|
-
"min-w-0 flex-1 border-r border-border",
|
|
2054
|
-
mobilePanel === "chat"
|
|
2055
|
-
? "flex flex-col md:flex"
|
|
2056
|
-
: "hidden md:flex",
|
|
2057
|
-
)}
|
|
2058
|
-
/>
|
|
2059
|
-
) : (
|
|
2060
|
-
<div
|
|
2061
|
-
className={cn(
|
|
2062
|
-
"min-w-0 flex-1 items-center justify-center border-r border-border bg-muted/10",
|
|
2063
|
-
mobilePanel === "chat" ? "flex md:flex" : "hidden md:flex",
|
|
2064
|
-
)}
|
|
2065
|
-
>
|
|
2066
|
-
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
|
2067
|
-
<MessageSquare className="size-10 opacity-30" />
|
|
2068
|
-
<p className="text-sm">Select a conversation</p>
|
|
2069
|
-
</div>
|
|
2070
|
-
</div>
|
|
2071
|
-
)}
|
|
2072
|
-
|
|
2073
|
-
{/* Right — Lead Info Panel */}
|
|
2074
|
-
{contact && (
|
|
2075
|
-
<>
|
|
2076
|
-
{/* Desktop: always in DOM, width-animated. Mobile: show/hide by mobilePanel. */}
|
|
2077
|
-
<div
|
|
2078
|
-
className={cn(
|
|
2079
|
-
// Mobile: full-width, instant show/hide based on mobilePanel
|
|
2080
|
-
mobilePanel === "lead"
|
|
2081
|
-
? "flex w-full shrink-0 flex-col"
|
|
2082
|
-
: "hidden",
|
|
2083
|
-
// Desktop: always rendered, animate width open/close
|
|
2084
|
-
"md:block md:shrink-0 md:overflow-hidden md:transition-[width] md:duration-200 md:ease-in-out",
|
|
2085
|
-
showLeadPanel ? "md:w-[320px]" : "md:w-0",
|
|
2086
|
-
)}
|
|
2087
|
-
>
|
|
2088
|
-
<LeadInfoPanel
|
|
2089
|
-
contact={contact}
|
|
2090
|
-
firstSeen={leadFirstSeen}
|
|
2091
|
-
source={leadSource}
|
|
2092
|
-
topic={leadTopic}
|
|
2093
|
-
aiFields={aiFields}
|
|
2094
|
-
appointment={appointment}
|
|
2095
|
-
internalNotes={internalNotes}
|
|
2096
|
-
notesSaveStatus={notesSaveStatus}
|
|
2097
|
-
isKnownContact={isKnownContact}
|
|
2098
|
-
onAddToContacts={onAddToContacts}
|
|
2099
|
-
onCreateOpportunity={onCreateOpportunity}
|
|
2100
|
-
onBookAppointment={onBookAppointment}
|
|
2101
|
-
onApproveAppointment={onApproveAppointment}
|
|
2102
|
-
onDeclineAppointment={onDeclineAppointment}
|
|
2103
|
-
onRescheduleAppointment={onRescheduleAppointment}
|
|
2104
|
-
onNotesChange={onNotesChange}
|
|
2105
|
-
onToggleCollapse={handleToggleLeadPanel}
|
|
2106
|
-
onBack={() => setMobilePanel("chat")}
|
|
2107
|
-
className="flex h-full w-full flex-col border-l border-border md:w-[320px]"
|
|
2108
|
-
/>
|
|
2109
|
-
</div>
|
|
2110
|
-
|
|
2111
|
-
{/* Collapsed expand button — desktop only, shown when panel is closed */}
|
|
2112
|
-
{!showLeadPanel && onToggleLeadPanel && (
|
|
2113
|
-
<div className="hidden shrink-0 items-start border-l border-border pt-[29px] md:flex">
|
|
2114
|
-
<Tooltip>
|
|
2115
|
-
<TooltipTrigger
|
|
2116
|
-
render={
|
|
2117
|
-
<Button
|
|
2118
|
-
variant="ghost"
|
|
2119
|
-
size="icon"
|
|
2120
|
-
className="size-8"
|
|
2121
|
-
aria-label="Show lead info"
|
|
2122
|
-
onClick={handleToggleLeadPanel}
|
|
2123
|
-
>
|
|
2124
|
-
<ChevronLeft className="size-4" />
|
|
2125
|
-
</Button>
|
|
2126
|
-
}
|
|
2127
|
-
/>
|
|
2128
|
-
<TooltipContent>Show lead info</TooltipContent>
|
|
2129
|
-
</Tooltip>
|
|
2130
|
-
</div>
|
|
2131
|
-
)}
|
|
2132
|
-
</>
|
|
2133
|
-
)}
|
|
2134
|
-
</div>
|
|
2135
|
-
</TooltipProvider>
|
|
2136
|
-
);
|
|
2137
|
-
}
|
|
2138
|
-
|
|
2139
|
-
// ---------------------------------------------------------------------------
|
|
2140
|
-
// AiConvAssignAdvisorDialog
|
|
2141
|
-
// ---------------------------------------------------------------------------
|
|
2142
|
-
|
|
2143
|
-
export interface AiConvAssignAdvisorDialogProps {
|
|
2144
|
-
open: boolean;
|
|
2145
|
-
onOpenChange: (open: boolean) => void;
|
|
2146
|
-
advisors: AiConvAdvisor[];
|
|
2147
|
-
/** Currently selected advisor id */
|
|
2148
|
-
value: string;
|
|
2149
|
-
onValueChange: (id: string) => void;
|
|
2150
|
-
onConfirm: () => void;
|
|
2151
|
-
}
|
|
2152
|
-
|
|
2153
|
-
export function AiConvAssignAdvisorDialog({
|
|
2154
|
-
open,
|
|
2155
|
-
onOpenChange,
|
|
2156
|
-
advisors,
|
|
2157
|
-
value,
|
|
2158
|
-
onValueChange,
|
|
2159
|
-
onConfirm,
|
|
2160
|
-
}: AiConvAssignAdvisorDialogProps) {
|
|
2161
|
-
const [search, setSearch] = useState("");
|
|
2162
|
-
const [roleFilter, setRoleFilter] = useState("");
|
|
2163
|
-
|
|
2164
|
-
const roles = Array.from(
|
|
2165
|
-
new Set(advisors.map((a) => a.role).filter(Boolean)),
|
|
2166
|
-
) as string[];
|
|
2167
|
-
|
|
2168
|
-
const filtered = advisors.filter(
|
|
2169
|
-
(a) =>
|
|
2170
|
-
a.name.toLowerCase().includes(search.toLowerCase()) &&
|
|
2171
|
-
(!roleFilter || a.role === roleFilter),
|
|
2172
|
-
);
|
|
2173
|
-
|
|
2174
|
-
const handleOpenChange = (v: boolean) => {
|
|
2175
|
-
onOpenChange(v);
|
|
2176
|
-
if (!v) {
|
|
2177
|
-
setSearch("");
|
|
2178
|
-
setRoleFilter("");
|
|
2179
|
-
}
|
|
2180
|
-
};
|
|
2181
|
-
|
|
2182
|
-
return (
|
|
2183
|
-
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
2184
|
-
<DialogContent>
|
|
2185
|
-
<DialogHeader>
|
|
2186
|
-
<DialogTitle>Assign to advisor</DialogTitle>
|
|
2187
|
-
<DialogDescription>
|
|
2188
|
-
Choose an advisor to handle this conversation.
|
|
2189
|
-
</DialogDescription>
|
|
2190
|
-
</DialogHeader>
|
|
2191
|
-
<div className="flex flex-col gap-0">
|
|
2192
|
-
{/* Role filter */}
|
|
2193
|
-
{roles.length > 0 && (
|
|
2194
|
-
<div className="pb-3">
|
|
2195
|
-
<ToggleGroup
|
|
2196
|
-
type="single"
|
|
2197
|
-
variant="outline"
|
|
2198
|
-
spacing={1.5}
|
|
2199
|
-
size="sm"
|
|
2200
|
-
value={roleFilter ? [roleFilter] : ["__all__"]}
|
|
2201
|
-
onValueChange={(values) => {
|
|
2202
|
-
const v = values[0];
|
|
2203
|
-
setRoleFilter(!v || v === "__all__" ? "" : v);
|
|
2204
|
-
}}
|
|
2205
|
-
>
|
|
2206
|
-
<ToggleGroupItem value="__all__">All</ToggleGroupItem>
|
|
2207
|
-
{roles.map((role) => (
|
|
2208
|
-
<ToggleGroupItem key={role} value={role}>
|
|
2209
|
-
{role}
|
|
2210
|
-
</ToggleGroupItem>
|
|
2211
|
-
))}
|
|
2212
|
-
</ToggleGroup>
|
|
2213
|
-
</div>
|
|
2214
|
-
)}
|
|
2215
|
-
{/* Search */}
|
|
2216
|
-
<div className="flex items-center gap-2 border border-input px-3">
|
|
2217
|
-
<Search className="size-4 shrink-0 text-muted-foreground" />
|
|
2218
|
-
<input
|
|
2219
|
-
type="text"
|
|
2220
|
-
placeholder="Search advisors..."
|
|
2221
|
-
value={search}
|
|
2222
|
-
onChange={(e) => setSearch(e.target.value)}
|
|
2223
|
-
className="h-9 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
2224
|
-
/>
|
|
2225
|
-
</div>
|
|
2226
|
-
{/* List */}
|
|
2227
|
-
<div className="max-h-52 overflow-y-auto border border-t-0 border-input">
|
|
2228
|
-
{filtered.length === 0 ? (
|
|
2229
|
-
<p className="py-6 text-center text-sm text-muted-foreground">
|
|
2230
|
-
No advisors found.
|
|
2231
|
-
</p>
|
|
2232
|
-
) : (
|
|
2233
|
-
filtered.map((advisor) => (
|
|
2234
|
-
<button
|
|
2235
|
-
key={advisor.id}
|
|
2236
|
-
type="button"
|
|
2237
|
-
onClick={() => onValueChange(advisor.id)}
|
|
2238
|
-
className={cn(
|
|
2239
|
-
"flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted",
|
|
2240
|
-
value === advisor.id && "bg-muted font-medium",
|
|
2241
|
-
)}
|
|
2242
|
-
>
|
|
2243
|
-
<Avatar size="sm">
|
|
2244
|
-
<AvatarFallback className="font-semibold">
|
|
2245
|
-
{advisor.initials}
|
|
2246
|
-
</AvatarFallback>
|
|
2247
|
-
</Avatar>
|
|
2248
|
-
<div className="min-w-0 flex-1">
|
|
2249
|
-
<p className="truncate text-sm">{advisor.name}</p>
|
|
2250
|
-
{advisor.role && (
|
|
2251
|
-
<p className="truncate text-xs text-muted-foreground">
|
|
2252
|
-
{advisor.role}
|
|
2253
|
-
</p>
|
|
2254
|
-
)}
|
|
2255
|
-
</div>
|
|
2256
|
-
</button>
|
|
2257
|
-
))
|
|
2258
|
-
)}
|
|
2259
|
-
</div>
|
|
2260
|
-
</div>
|
|
2261
|
-
<DialogFooter>
|
|
2262
|
-
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
2263
|
-
Cancel
|
|
2264
|
-
</Button>
|
|
2265
|
-
<Button onClick={onConfirm}>Assign</Button>
|
|
2266
|
-
</DialogFooter>
|
|
2267
|
-
</DialogContent>
|
|
2268
|
-
</Dialog>
|
|
2269
|
-
);
|
|
2270
|
-
}
|