@banbox/chat 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +215 -0
  2. package/dist/index.cjs +3408 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +556 -0
  5. package/dist/index.d.ts +556 -0
  6. package/dist/index.js +3385 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +83 -0
  9. package/src/adapter/types.ts +146 -0
  10. package/src/chat/ChatImagePreviewModal.tsx +194 -0
  11. package/src/chat/ChatRoot.tsx +67 -0
  12. package/src/chat/InboxPopup.tsx +312 -0
  13. package/src/chat/SinglePopup.tsx +240 -0
  14. package/src/contexts/ChatUIContext.tsx +30 -0
  15. package/src/contexts/ChatUIProvider.tsx +38 -0
  16. package/src/contexts/GalleryContext.tsx +40 -0
  17. package/src/contexts/GalleryProvider.tsx +89 -0
  18. package/src/hooks/useDisableBodyScroll.ts +16 -0
  19. package/src/icons/index.tsx +248 -0
  20. package/src/index.ts +56 -0
  21. package/src/lottie/typingdotanimation2.json +1 -0
  22. package/src/modals/chat/ChatConfirmModal.tsx +104 -0
  23. package/src/modals/chat/ChatTranslateSettingsModal.tsx +180 -0
  24. package/src/types/index.ts +163 -0
  25. package/src/ui/Button.tsx +83 -0
  26. package/src/ui/Portal.tsx +40 -0
  27. package/src/ui/Select.tsx +74 -0
  28. package/src/ui/chat/AttachmentPreviewStrip.tsx +166 -0
  29. package/src/ui/chat/ChatComposerBar.tsx +231 -0
  30. package/src/ui/chat/ChatFooter.tsx +442 -0
  31. package/src/ui/chat/ChatHeader.tsx +24 -0
  32. package/src/ui/chat/ChatIdentity.tsx +145 -0
  33. package/src/ui/chat/ChatInquiryBar.tsx +57 -0
  34. package/src/ui/chat/ChatListHeader.tsx +179 -0
  35. package/src/ui/chat/ChatMessageItem.tsx +214 -0
  36. package/src/ui/chat/ChatScroll.tsx +64 -0
  37. package/src/ui/chat/ChatSpinner.tsx +49 -0
  38. package/src/ui/chat/ChatThreadItem.tsx +140 -0
  39. package/src/ui/chat/MessageHoverActions.tsx +120 -0
  40. package/src/ui/chat/ReplyCard.tsx +217 -0
  41. package/src/ui/chat/TypingIndicator.tsx +93 -0
  42. package/src/ui/chat/drop-up/BusinessCardDropup.tsx +253 -0
  43. package/src/ui/chat/drop-up/EmojiDropup.tsx +132 -0
  44. package/src/ui/chat/message-items/ChatAddressCard.tsx +130 -0
  45. package/src/ui/chat/message-items/ChatBubbleAudio.tsx +209 -0
  46. package/src/ui/chat/message-items/ChatBubbleFiles.tsx +80 -0
  47. package/src/ui/chat/message-items/ChatBubbleImages.tsx +120 -0
  48. package/src/ui/chat/message-items/ChatBubbleText.tsx +16 -0
  49. package/src/ui/chat/message-items/ChatBusinessCard.tsx +95 -0
  50. package/src/ui/chat/scrollToMessage.ts +61 -0
  51. package/src/ui/chat/types.ts +37 -0
  52. package/src/utils/cn.ts +6 -0
@@ -0,0 +1,120 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ import { MessageReplayIcon } from "../../icons";
6
+ import { NewLanguageIcon } from "../../icons";
7
+ import { cn } from "../../utils/cn";
8
+
9
+ /* =======================
10
+ Types
11
+ ======================= */
12
+
13
+ type ItemButton = "replay" | "translate";
14
+
15
+ type Props = {
16
+ mine: boolean;
17
+ onReply?: () => void;
18
+ onTranslate?: () => void;
19
+ children: React.ReactNode;
20
+ className?: string;
21
+ alwaysVisible?: boolean;
22
+
23
+ /** Which buttons to show (omit => show all) */
24
+ isItemButton?: ItemButton[];
25
+ /** Which buttons are “active/on” for styling */
26
+ activeButtons?: ItemButton[];
27
+ };
28
+
29
+ /* =======================
30
+ Component
31
+ ======================= */
32
+
33
+ const MessageHoverActions: React.FC<Props> = ({
34
+ mine,
35
+ onReply,
36
+ onTranslate,
37
+ children,
38
+ className,
39
+ alwaysVisible = false,
40
+ isItemButton,
41
+ activeButtons,
42
+ }) => {
43
+ const sidePos = mine ? "right-full" : "left-full";
44
+ const railNudge = mine ? "-translate-x-1.5" : "translate-x-1.5";
45
+
46
+ const showReplay = !isItemButton || isItemButton.includes("replay");
47
+ const showTranslate = !isItemButton || isItemButton.includes("translate");
48
+ const hasAny = showReplay || showTranslate;
49
+
50
+ const isActive = (k: ItemButton) => Boolean(activeButtons?.includes(k));
51
+
52
+ return (
53
+ <div className={cn("relative inline-flex group/message", className)}>
54
+ {children}
55
+
56
+ {hasAny ? (
57
+ <div
58
+ aria-hidden
59
+ className={cn(
60
+ "pointer-events-auto absolute inset-y-0 w-2",
61
+ mine ? "right-full" : "left-full",
62
+ )}
63
+ />
64
+ ) : null}
65
+
66
+ {hasAny ? (
67
+ <div
68
+ className={cn(
69
+ "pointer-events-auto absolute bottom-0 transition-opacity",
70
+ sidePos,
71
+ railNudge,
72
+ alwaysVisible ? "opacity-100" : "opacity-0 group-hover/message:opacity-100",
73
+ )}
74
+ >
75
+ <div className="flex gap-2 pb-[2px]">
76
+ {showReplay ? (
77
+ <button
78
+ type="button"
79
+ onClick={(e) => {
80
+ e.stopPropagation();
81
+ onReply?.();
82
+ }}
83
+ className={cn(
84
+ "inline-flex h-[22px] w-[22px] items-center justify-center rounded-xs bg-white ",
85
+ "shadow-[0_1px_3px_rgba(0,0,0,0.08)] hover:bg-[#f8f8f8]",
86
+ isActive("replay") ? "bg-[#636363] text-white" : "text-[#2c2c2c]",
87
+ )}
88
+ title="Reply"
89
+ aria-label="Reply"
90
+ >
91
+ <MessageReplayIcon className="h-[14px] w-[14px]" />
92
+ </button>
93
+ ) : null}
94
+
95
+ {showTranslate ? (
96
+ <button
97
+ type="button"
98
+ onClick={(e) => {
99
+ e.stopPropagation();
100
+ onTranslate?.();
101
+ }}
102
+ className={cn(
103
+ "inline-flex h-[22px] w-[22px] items-center justify-center rounded-xs bg-white ",
104
+ "shadow-banbox-card-secondary hover:bg-[#f8f8f8]",
105
+ isActive("translate") ? "bg-[#636363]! text-white" : "text-[#2c2c2c]",
106
+ )}
107
+ title="Translate"
108
+ aria-label="Translate"
109
+ >
110
+ <NewLanguageIcon className="h-[14px] w-[14px]" />
111
+ </button>
112
+ ) : null}
113
+ </div>
114
+ </div>
115
+ ) : null}
116
+ </div>
117
+ );
118
+ };
119
+
120
+ export default MessageHoverActions;
@@ -0,0 +1,217 @@
1
+ // components/chat/ui/chat/ReplyCard.tsx
2
+ "use client";
3
+
4
+ import clsx from "clsx";
5
+ import React from "react";
6
+ import { ArrowBackUpIcon, FileIcon, ChatXIcon } from "../../icons";
7
+ import { scrollToMessageById } from "./scrollToMessage";
8
+ import type { MessageRef } from "./types";
9
+
10
+ type Props = {
11
+ refMsg: MessageRef;
12
+ onClose?: () => void; // present in composer bar
13
+ compact?: boolean;
14
+ className?: string;
15
+ jumpOnClick?: boolean;
16
+ };
17
+
18
+ const fmt = (s: number) => {
19
+ if (!Number.isFinite(s) || s <= 0) {
20
+ return "0:00";
21
+ }
22
+ const m = Math.floor(s / 60);
23
+ const sec = Math.round(s % 60)
24
+ .toString()
25
+ .padStart(2, "0");
26
+ return `${m}:${sec}`;
27
+ };
28
+
29
+ const ReplyCard: React.FC<Props> = ({ refMsg, onClose, compact, className, jumpOnClick }) => {
30
+ const hasText = !!refMsg.text && refMsg.text.trim().length > 0;
31
+ const hasFiles = (refMsg.files?.length ?? 0) > 0;
32
+ const hasImages = (refMsg.images?.length ?? 0) > 0;
33
+ // const hasAudio = !!refMsg.audio;
34
+
35
+ // priority matches your examples
36
+ const mode: "text" | "files" | "images" | "audio" = hasText
37
+ ? "text"
38
+ : hasFiles
39
+ ? "files"
40
+ : hasImages
41
+ ? "images"
42
+ : "audio";
43
+
44
+ const widthClamp = onClose ? "w-full" : "max-w-[200px] w-full";
45
+ const heightClamp = compact ? "py-1.5" : "py-1.5";
46
+
47
+ // ── Dynamic audio duration (no playback) ────────────────────────────────
48
+ const [durTxt, setDurTxt] = React.useState<string>(refMsg.audio?.duration ?? "0:06");
49
+
50
+ React.useEffect(() => {
51
+ if (mode !== "audio" || !refMsg.audio?.src) {
52
+ setDurTxt(refMsg.audio?.duration ?? "0:06");
53
+ return;
54
+ }
55
+
56
+ let disposed = false;
57
+ const a = new Audio();
58
+ a.preload = "metadata";
59
+ a.src = refMsg.audio.src;
60
+
61
+ const onLoaded = () => {
62
+ if (disposed) {
63
+ return;
64
+ }
65
+ const d = Number(a.duration);
66
+ setDurTxt(fmt(d));
67
+ };
68
+
69
+ const onError = () => {
70
+ if (disposed) {
71
+ return;
72
+ }
73
+ // keep any provided duration or fallback
74
+ setDurTxt(refMsg.audio?.duration ?? "0:06");
75
+ };
76
+
77
+ a.addEventListener("loadedmetadata", onLoaded);
78
+ a.addEventListener("error", onError);
79
+
80
+ // force metadata fetch: some browsers need load() trigger
81
+ try {
82
+ a.load?.();
83
+ } catch {
84
+ /* empty */
85
+ }
86
+
87
+ return () => {
88
+ disposed = true;
89
+ a.pause?.();
90
+ // help GC
91
+ a.src = "";
92
+ a.removeAttribute?.("src");
93
+ a.load?.();
94
+ a.removeEventListener("loadedmetadata", onLoaded);
95
+ a.removeEventListener("error", onError);
96
+ };
97
+ }, [mode, refMsg.audio?.src, refMsg.audio?.duration]);
98
+
99
+ const clickToJump = React.useCallback(() => {
100
+ if (!jumpOnClick) {
101
+ return;
102
+ }
103
+ // Use refMsg.id – make sure MessageRef includes `id: string`
104
+ if (refMsg.id) {
105
+ scrollToMessageById(refMsg.id);
106
+ }
107
+ }, [jumpOnClick, refMsg.id]);
108
+
109
+ return (
110
+ <div
111
+ onClick={clickToJump}
112
+ className={clsx(
113
+ widthClamp,
114
+ "relative rounded-md bg-[#f8f8f8] px-3",
115
+ "border-l-2 border-[#FF5300]",
116
+ "shadow-[0_1px_2px_rgba(0,0,0,0.04)]",
117
+ heightClamp,
118
+ className,
119
+ )}
120
+ >
121
+ {/* header row */}
122
+ <div className="mb-1.5 flex items-center justify-between">
123
+ <div className="flex items-center gap-2">
124
+ <ArrowBackUpIcon className="h-[18px] w-[18px]" />
125
+ <div className="text-[13px] font-normal text-[#2c2c2c]">Reply</div>
126
+ </div>
127
+
128
+ {onClose && (
129
+ <button
130
+ type="button"
131
+ onClick={onClose}
132
+ className="absolute right-3 top-1/2 -translate-y-1/2 grid h-8 w-8 place-items-center rounded-full bg-white text-[#3D3D3D] shadow-[0px_2px_4px_0px_#A5A3AE4D] hover:text-[#FF5300]"
133
+ title="Remove"
134
+ aria-label="Remove"
135
+ >
136
+ <ChatXIcon className="h-[18px] w-[18px]" />
137
+ </button>
138
+ )}
139
+ </div>
140
+
141
+ {/* content preview */}
142
+ <div className="pb-1">
143
+ {mode === "text" && (
144
+ <div className="truncate text-[13px] text-[#636363]">
145
+ {refMsg.text!.replace(/\s+/g, " ")}
146
+ </div>
147
+ )}
148
+
149
+ {mode === "files" && (
150
+ <div className="flex items-center gap-2 h-[21px]">
151
+ <span className="inline-flex items-center gap-1 rounded-sm border-[0.7px] border-[#e1e1e1] bg-white px-2.5 py-[2px]">
152
+ <FileIcon className="h-4 w-4 text-[#6B7280]" />
153
+ <span className="max-w-[110px] truncate text-[10px] text-[#2c2c2c]">
154
+ {refMsg.files![0].name}
155
+ </span>
156
+ </span>
157
+ {refMsg.files!.length > 1 && (
158
+ <span className=" text-xs text-[#636363]">
159
+ +{refMsg.files!.length - 1}
160
+ </span>
161
+ )}
162
+ </div>
163
+ )}
164
+
165
+ {mode === "images" && (
166
+ <div className="flex items-center gap-1.5">
167
+ {refMsg.images!.slice(0, 3).map((src, i) => {
168
+ const extra = refMsg.images!.length - 3;
169
+ const isLast = i === 2 && extra > 0;
170
+ return (
171
+ <div
172
+ key={i}
173
+ className="relative h-[21px] w-[26px] overflow-hidden rounded-sm border border-[#E5E5E5] bg-[#F5F7FA]"
174
+ >
175
+ <img src={src} alt="" className="h-full w-full object-cover" />
176
+ {isLast && (
177
+ <div className="absolute inset-0 grid place-items-center bg-black/40">
178
+ <span className=" text-xs font-semibold text-white">+{extra}</span>
179
+ </div>
180
+ )}
181
+ </div>
182
+ );
183
+ })}
184
+ </div>
185
+ )}
186
+
187
+ {mode === "audio" && (
188
+ <div className="flex items-center gap-2 h-[21px] border border-[#D8D8D8] rounded-[3.5px] p-[1.75px] max-w-[151px]">
189
+ {/* static play pill (no handlers) */}
190
+ <span className="grid h-[17.5px] w-[18.08px] place-items-center rounded-[2.33px] bg-[#f1f1f1] text-[#00486F]">
191
+ <svg viewBox="0 0 20 20" className="h-3 w-3" fill="currentColor" aria-hidden>
192
+ <path d="M6 4.5v11l9-5.5-9-5.5Z" />
193
+ </svg>
194
+ </span>
195
+
196
+ {/* mini progress bar (visual only) */}
197
+ <div className="relative h-[1.17px] w-[130px] rounded-full bg-[#BDBDBD]">
198
+ <span
199
+ className="absolute left-0 top-0 h-full rounded-full bg-[#747474]"
200
+ style={{ width: "0%" }}
201
+ />
202
+ <span
203
+ className="absolute top-1/2 -translate-y-1/2 h-[5.5px] w-[5.5px] rounded-full bg-[#747474]"
204
+ style={{ left: "calc(0% - 4px)" }}
205
+ />
206
+ </div>
207
+
208
+ {/* dynamic duration */}
209
+ <span className="text-[10px] text-[#747474] me-1">{durTxt}</span>
210
+ </div>
211
+ )}
212
+ </div>
213
+ </div>
214
+ );
215
+ };
216
+
217
+ export default ReplyCard;
@@ -0,0 +1,93 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { cn } from "../../utils/cn";
5
+
6
+ /* =======================
7
+ Types
8
+ ======================= */
9
+
10
+ type Props = {
11
+ /** Pixel box for the animation area */
12
+ size?: number;
13
+ loop?: boolean;
14
+ autoplay?: boolean;
15
+ className?: string;
16
+ ariaLabel?: string;
17
+ /** Avatar size in px */
18
+ avatarSize?: number;
19
+ };
20
+
21
+ /* =======================
22
+ CSS Typing Dots (no external dependency)
23
+ ======================= */
24
+
25
+ const TypingDots: React.FC = () => (
26
+ <>
27
+ <style>{`
28
+ @keyframes banbox-typing-bounce {
29
+ 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30
+ 30% { transform: translateY(-5px); opacity: 1; }
31
+ }
32
+ .banbox-typing-dot {
33
+ width: 7px;
34
+ height: 7px;
35
+ border-radius: 50%;
36
+ background: #888;
37
+ display: inline-block;
38
+ animation: banbox-typing-bounce 1.2s infinite ease-in-out;
39
+ }
40
+ .banbox-typing-dot:nth-child(1) { animation-delay: 0s; }
41
+ .banbox-typing-dot:nth-child(2) { animation-delay: 0.2s; }
42
+ .banbox-typing-dot:nth-child(3) { animation-delay: 0.4s; }
43
+ `}</style>
44
+ <span style={{ display: "inline-flex", gap: "4px", alignItems: "center", height: "28px", padding: "0 8px" }}>
45
+ <span className="banbox-typing-dot" />
46
+ <span className="banbox-typing-dot" />
47
+ <span className="banbox-typing-dot" />
48
+ </span>
49
+ </>
50
+ );
51
+
52
+ /* =======================
53
+ Component
54
+ ======================= */
55
+
56
+ const TypingIndicator: React.FC<Props> = ({
57
+ className,
58
+ ariaLabel = "Typing…",
59
+ avatarSize = 40,
60
+ }) => {
61
+ const isOnline = true;
62
+
63
+ return (
64
+ <div
65
+ className={cn("relative flex items-end gap-[6px]", className)}
66
+ role="status"
67
+ aria-label={ariaLabel}
68
+ >
69
+ {/* Avatar */}
70
+ <div
71
+ className="relative shrink-0 rounded-full border border-[#F1F1F1]"
72
+ style={{ width: avatarSize, height: avatarSize }}
73
+ >
74
+ <img
75
+ src="/chat/img/girl_support.png"
76
+ alt="avatar image"
77
+ className="h-full w-full rounded-full object-cover"
78
+ />
79
+
80
+ {isOnline ? (
81
+ <span className="absolute bottom-0 right-0 h-[11.25px] w-[11.25px] rounded-full bg-[#328545] ring-1 ring-white" />
82
+ ) : null}
83
+ </div>
84
+
85
+ {/* Typing dots */}
86
+ <span className="absolute bottom-[-13px] left-[30px]">
87
+ <TypingDots />
88
+ </span>
89
+ </div>
90
+ );
91
+ };
92
+
93
+ export default TypingIndicator;
@@ -0,0 +1,253 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { createPortal } from "react-dom";
5
+
6
+ import { BusinessInfoIcon, ChatMailIcon, ChatPhoneCallIcon, ChatXIcon } from "../../../icons";
7
+
8
+ import { cn } from "../../../utils/cn";
9
+ import type { BusinessCard } from "../types";
10
+
11
+ /* =======================
12
+ Types
13
+ ======================= */
14
+
15
+ type BusinessCardDropupProps = {
16
+ open: boolean;
17
+ onClose: () => void;
18
+ onSend: (card: BusinessCard) => void;
19
+ /** Position against this button (like EmojiDropup) */
20
+ anchorRef?: React.RefObject<HTMLElement | null>;
21
+ className?: string;
22
+ };
23
+
24
+ /* =======================
25
+ Constants
26
+ ======================= */
27
+
28
+ const WIDTH = 380;
29
+ const GAP_Y = 0;
30
+ const PADDING = 0;
31
+
32
+ const defaultCard: BusinessCard = {
33
+ avatarSrc: "/chat/img/demo-a.jpg",
34
+ name: "Arman Hossain",
35
+ country: "Bangladesh",
36
+ flag: "🇧🇩",
37
+ company: "Easy Fashion",
38
+ email: "aminul@oceanget.com",
39
+ phone: "+880 1712 345678",
40
+ };
41
+
42
+ /* =======================
43
+ Component
44
+ ======================= */
45
+
46
+ const BusinessCardDropup = ({
47
+ open,
48
+ onClose,
49
+ onSend,
50
+ anchorRef,
51
+ className,
52
+ }: BusinessCardDropupProps) => {
53
+ const panelRef = useRef<HTMLDivElement | null>(null);
54
+ const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
55
+ const [form] = useState<BusinessCard>(defaultCard);
56
+
57
+ const getPosition = useCallback(() => {
58
+ const anchor = anchorRef?.current;
59
+ const panel = panelRef.current;
60
+
61
+ if (!anchor || !panel) {
62
+ return null;
63
+ }
64
+
65
+ const ar = anchor.getBoundingClientRect();
66
+ const ph = panel.offsetHeight + 12;
67
+
68
+ let left = Math.min(ar.left, window.innerWidth - PADDING - WIDTH);
69
+ left = Math.max(left, PADDING);
70
+
71
+ let top = ar.top - GAP_Y - ph;
72
+ if (top < PADDING) {
73
+ top = Math.min(ar.bottom + GAP_Y, window.innerHeight - ph - PADDING);
74
+ }
75
+
76
+ return { left: Math.round(left - 18), top: Math.round(top) };
77
+ }, [anchorRef]);
78
+
79
+ useEffect(() => {
80
+ if (!open) {
81
+ return;
82
+ }
83
+
84
+ let rafId = 0;
85
+ const update = () => {
86
+ const next = getPosition();
87
+ if (next) {
88
+ setPos(next);
89
+ }
90
+ };
91
+
92
+ rafId = requestAnimationFrame(update);
93
+
94
+ const onScroll = () => {
95
+ cancelAnimationFrame(rafId);
96
+ rafId = requestAnimationFrame(update);
97
+ };
98
+ const onResize = () => {
99
+ cancelAnimationFrame(rafId);
100
+ rafId = requestAnimationFrame(update);
101
+ };
102
+
103
+ window.addEventListener("scroll", onScroll, true);
104
+ window.addEventListener("resize", onResize);
105
+
106
+ return () => {
107
+ cancelAnimationFrame(rafId);
108
+ window.removeEventListener("scroll", onScroll, true);
109
+ window.removeEventListener("resize", onResize);
110
+ };
111
+ }, [open, getPosition]);
112
+
113
+ // Outside click & ESC
114
+ useEffect(() => {
115
+ if (!open) {
116
+ return;
117
+ }
118
+
119
+ const onDoc = (e: MouseEvent) => {
120
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
121
+ onClose();
122
+ }
123
+ };
124
+
125
+ const onEsc = (e: KeyboardEvent) => {
126
+ if (e.key === "Escape") {
127
+ onClose();
128
+ }
129
+ };
130
+
131
+ document.addEventListener("mousedown", onDoc);
132
+ document.addEventListener("keydown", onEsc);
133
+
134
+ return () => {
135
+ document.removeEventListener("mousedown", onDoc);
136
+ document.removeEventListener("keydown", onEsc);
137
+ };
138
+ }, [open, onClose]);
139
+
140
+ const disabled = useMemo(() => !form.name || !form.company || !form.email || !form.phone, [form]);
141
+
142
+ if (!open) {
143
+ return null;
144
+ }
145
+
146
+ const body = (
147
+ <div
148
+ ref={panelRef}
149
+ role="dialog"
150
+ aria-label="Business card"
151
+ className={cn(
152
+ "z-9999 relative rounded-[12px] border border-[#EFEFEF] bg-white",
153
+ "p-3 shadow-[0_8px_24px_rgba(0,0,0,0.12)]",
154
+ className,
155
+ )}
156
+ style={{
157
+ width: WIDTH,
158
+ left: pos?.left ?? -9999,
159
+ top: pos?.top ?? -9999,
160
+ position: "fixed",
161
+ }}
162
+ >
163
+ {/* Header */}
164
+ <div className="mb-2 flex items-center justify-between">
165
+ <div className="text-lg font-semibold text-black">Business Card</div>
166
+ <button
167
+ type="button"
168
+ onClick={onClose}
169
+ aria-label="Close"
170
+ className="grid h-8 w-8 place-items-center rounded-full hover:bg-black/5"
171
+ >
172
+ <ChatXIcon className="h-6 w-6" />
173
+ </button>
174
+ </div>
175
+
176
+ {/* Card preview */}
177
+ <div className="flex justify-center">
178
+ <div
179
+ className={cn(
180
+ "relative h-[208px] w-[355px] overflow-hidden rounded-[12px] bg-white",
181
+ "bg-cover bg-no-repeat shadow-[0_2px_12px_rgba(59,51,51,0.1)]",
182
+ )}
183
+ style={{ backgroundImage: "url('/chat/img/card_bg_raw.svg')" }}
184
+ >
185
+ <div className="flex h-full justify-between gap-4 px-6 py-6">
186
+ <div className="flex min-w-0 flex-1 flex-col justify-between">
187
+ <div>
188
+ <h3 className="text-xl font-semibold text-[#004F4F]">{form.name}</h3>
189
+
190
+ <div className="h-px w-[105px] bg-black" />
191
+
192
+ <div className="mt-[6px] flex items-center gap-2">
193
+ <span className="text-xs font-medium text-[#636363]">
194
+ {form.country}
195
+ </span>
196
+ </div>
197
+ </div>
198
+
199
+ <div className="mt-4 mb-10 space-y-1.5 text-xs text-black">
200
+ <div className="flex items-center gap-3">
201
+ <div className="flex h-5 w-5 items-center justify-center rounded-full bg-[#FFE9DB]">
202
+ <BusinessInfoIcon className="h-3 w-3 text-[#EA580C]" />
203
+ </div>
204
+ <span className="truncate">{form.company}</span>
205
+ </div>
206
+
207
+ <div className="flex items-center gap-3">
208
+ <div className="flex h-5 w-5 items-center justify-center rounded-full bg-[#FFE9DB]">
209
+ <ChatMailIcon className="h-3 w-3 text-[#EA580C]" />
210
+ </div>
211
+ <span className="truncate">{form.email}</span>
212
+ </div>
213
+
214
+ <div className="flex items-center gap-3">
215
+ <div className="flex h-5 w-5 items-center justify-center rounded-full bg-[#FFE9DB]">
216
+ <ChatPhoneCallIcon className="h-3 w-3 text-[#EA580C]" />
217
+ </div>
218
+ <span className="truncate">{form.phone}</span>
219
+ </div>
220
+ </div>
221
+ </div>
222
+ </div>
223
+ </div>
224
+ </div>
225
+
226
+ {/* Footer actions */}
227
+ <div className="mt-3 flex justify-end gap-2">
228
+
229
+ <button
230
+ type="button"
231
+ disabled={disabled}
232
+ className="h-[34px] cursor-pointer rounded-[4px] border-none bg-[#ff5200] px-4 text-[13px] font-medium text-white hover:bg-[#e64a00] disabled:opacity-50"
233
+ onClick={() => {
234
+ onSend(form);
235
+ onClose();
236
+ }}
237
+ >
238
+ Send
239
+ </button>
240
+ </div>
241
+
242
+ {/* Speech arrow */}
243
+ <div
244
+ aria-hidden
245
+ className="pointer-events-none absolute -bottom-2 left-6 h-4 w-4 rotate-45 rounded-[3px] border border-[#EFEFEF] border-l-transparent border-t-transparent bg-white"
246
+ />
247
+ </div>
248
+ );
249
+
250
+ return createPortal(body, document.body);
251
+ };
252
+
253
+ export default BusinessCardDropup;