@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.
- package/README.md +215 -0
- package/dist/index.cjs +3408 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +556 -0
- package/dist/index.d.ts +556 -0
- package/dist/index.js +3385 -0
- package/dist/index.js.map +1 -0
- package/package.json +83 -0
- package/src/adapter/types.ts +146 -0
- package/src/chat/ChatImagePreviewModal.tsx +194 -0
- package/src/chat/ChatRoot.tsx +67 -0
- package/src/chat/InboxPopup.tsx +312 -0
- package/src/chat/SinglePopup.tsx +240 -0
- package/src/contexts/ChatUIContext.tsx +30 -0
- package/src/contexts/ChatUIProvider.tsx +38 -0
- package/src/contexts/GalleryContext.tsx +40 -0
- package/src/contexts/GalleryProvider.tsx +89 -0
- package/src/hooks/useDisableBodyScroll.ts +16 -0
- package/src/icons/index.tsx +248 -0
- package/src/index.ts +56 -0
- package/src/lottie/typingdotanimation2.json +1 -0
- package/src/modals/chat/ChatConfirmModal.tsx +104 -0
- package/src/modals/chat/ChatTranslateSettingsModal.tsx +180 -0
- package/src/types/index.ts +163 -0
- package/src/ui/Button.tsx +83 -0
- package/src/ui/Portal.tsx +40 -0
- package/src/ui/Select.tsx +74 -0
- package/src/ui/chat/AttachmentPreviewStrip.tsx +166 -0
- package/src/ui/chat/ChatComposerBar.tsx +231 -0
- package/src/ui/chat/ChatFooter.tsx +442 -0
- package/src/ui/chat/ChatHeader.tsx +24 -0
- package/src/ui/chat/ChatIdentity.tsx +145 -0
- package/src/ui/chat/ChatInquiryBar.tsx +57 -0
- package/src/ui/chat/ChatListHeader.tsx +179 -0
- package/src/ui/chat/ChatMessageItem.tsx +214 -0
- package/src/ui/chat/ChatScroll.tsx +64 -0
- package/src/ui/chat/ChatSpinner.tsx +49 -0
- package/src/ui/chat/ChatThreadItem.tsx +140 -0
- package/src/ui/chat/MessageHoverActions.tsx +120 -0
- package/src/ui/chat/ReplyCard.tsx +217 -0
- package/src/ui/chat/TypingIndicator.tsx +93 -0
- package/src/ui/chat/drop-up/BusinessCardDropup.tsx +253 -0
- package/src/ui/chat/drop-up/EmojiDropup.tsx +132 -0
- package/src/ui/chat/message-items/ChatAddressCard.tsx +130 -0
- package/src/ui/chat/message-items/ChatBubbleAudio.tsx +209 -0
- package/src/ui/chat/message-items/ChatBubbleFiles.tsx +80 -0
- package/src/ui/chat/message-items/ChatBubbleImages.tsx +120 -0
- package/src/ui/chat/message-items/ChatBubbleText.tsx +16 -0
- package/src/ui/chat/message-items/ChatBusinessCard.tsx +95 -0
- package/src/ui/chat/scrollToMessage.ts +61 -0
- package/src/ui/chat/types.ts +37 -0
- 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;
|