@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,442 @@
|
|
|
1
|
+
// ui/chat/ChatFooter.tsx
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import { AttachIcon, ChatInfoIcon, SmileIcon, ChatXIcon, ProfileCardIcon, NewLanguageIcon } from "../../icons";
|
|
5
|
+
import ChatTranslateSettingsModal from "../../modals/chat/ChatTranslateSettingsModal";
|
|
6
|
+
import type { TranslateSettings } from "../../modals/chat/ChatTranslateSettingsModal";
|
|
7
|
+
import clsx from "clsx";
|
|
8
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
9
|
+
import AttachmentPreviewStrip, { type FilePreview as PreviewFile } from "./AttachmentPreviewStrip";
|
|
10
|
+
import ChatComposerBar from "./ChatComposerBar";
|
|
11
|
+
import BusinessCardDropup from "./drop-up/BusinessCardDropup";
|
|
12
|
+
import EmojiDropup from "./drop-up/EmojiDropup";
|
|
13
|
+
import ReplyCard from "./ReplyCard";
|
|
14
|
+
import type { MessageRef } from "../../types";
|
|
15
|
+
import type { SendPayload } from "../../types";
|
|
16
|
+
|
|
17
|
+
/* Simple tooltip wrapper */
|
|
18
|
+
const Tooltip = ({ children, text }: { children: React.ReactNode; text?: string }) => (
|
|
19
|
+
<span title={text}>{children}</span>
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
/* ───────────────────────────── Types ───────────────────────────── */
|
|
23
|
+
|
|
24
|
+
export type FooterAction = {
|
|
25
|
+
key: "attachment" | "emoji" | "businessCard" | "addressCard" | "translate" | string;
|
|
26
|
+
title: string;
|
|
27
|
+
icon: React.ReactNode;
|
|
28
|
+
onClick?: () => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type Props = {
|
|
32
|
+
variant?: "single" | "group";
|
|
33
|
+
/**
|
|
34
|
+
* Called when the user sends any type of message.
|
|
35
|
+
* The adapter (host app) handles storage and server communication.
|
|
36
|
+
*/
|
|
37
|
+
onSend: (payload: SendPayload) => void;
|
|
38
|
+
/** Called after send completes — triggers parent re-render / scroll */
|
|
39
|
+
onAfterSend?: () => void;
|
|
40
|
+
|
|
41
|
+
actions?: FooterAction[];
|
|
42
|
+
className?: string;
|
|
43
|
+
maxRows?: number;
|
|
44
|
+
|
|
45
|
+
/** The message being replied to */
|
|
46
|
+
replyTo?: MessageRef;
|
|
47
|
+
clearReply?: () => void;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/* ────────────────────────── Utilities ─────────────────────────── */
|
|
51
|
+
|
|
52
|
+
const fmtTime = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
53
|
+
const mb = (bytes: number) => Math.max(0.1, bytes / (1024 * 1024));
|
|
54
|
+
const extFromName = (name: string) => {
|
|
55
|
+
const dot = name.lastIndexOf(".");
|
|
56
|
+
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
|
|
57
|
+
};
|
|
58
|
+
const ACCEPT =
|
|
59
|
+
"image/*,.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.txt,.csv,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
60
|
+
|
|
61
|
+
/* ───────────────────────── Component ──────────────────────────── */
|
|
62
|
+
|
|
63
|
+
const ChatFooter: React.FC<Props> = ({
|
|
64
|
+
variant = "group",
|
|
65
|
+
onSend,
|
|
66
|
+
onAfterSend,
|
|
67
|
+
className,
|
|
68
|
+
maxRows = 4,
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
70
|
+
actions: _actions,
|
|
71
|
+
replyTo,
|
|
72
|
+
clearReply,
|
|
73
|
+
}) => {
|
|
74
|
+
const actionData: FooterAction[] = [
|
|
75
|
+
{ key: "attachment", title: "Attach file", icon: <AttachIcon className="h-4 w-4" /> },
|
|
76
|
+
{ key: "emoji", title: "Add emoji", icon: <SmileIcon className="h-4 w-4" /> },
|
|
77
|
+
{
|
|
78
|
+
key: "businessCard",
|
|
79
|
+
title: "Share business card",
|
|
80
|
+
icon: <ProfileCardIcon className="h-4 w-4" />,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
key: "translate",
|
|
84
|
+
title: "Translation settings",
|
|
85
|
+
icon: <NewLanguageIcon className="h-4 w-4" />,
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const textRef = useRef<HTMLTextAreaElement>(null);
|
|
90
|
+
const emojiBtnRef = useRef<HTMLButtonElement | null>(null);
|
|
91
|
+
const [text, setText] = useState("");
|
|
92
|
+
const [showEmoji, setShowEmoji] = useState(false);
|
|
93
|
+
|
|
94
|
+
// recording (MediaRecorder)
|
|
95
|
+
const [recording, setRecording] = useState(false);
|
|
96
|
+
const [seconds, setSeconds] = useState(0);
|
|
97
|
+
const mediaRecRef = useRef<MediaRecorder | null>(null);
|
|
98
|
+
const streamRef = useRef<MediaStream | null>(null);
|
|
99
|
+
const chunksRef = useRef<BlobPart[]>([]);
|
|
100
|
+
const [micError, setMicError] = useState<string>("");
|
|
101
|
+
|
|
102
|
+
// attachments
|
|
103
|
+
const [imgPreviews, setImgPreviews] = useState<string[]>([]);
|
|
104
|
+
const [filePreviews, setFilePreviews] = useState<PreviewFile[]>([]);
|
|
105
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
106
|
+
|
|
107
|
+
const [showTranslate, setShowTranslate] = useState(false);
|
|
108
|
+
const [translateSettings, setTranslateSettings] = useState<TranslateSettings>({
|
|
109
|
+
incomingTarget: "",
|
|
110
|
+
autoIncoming: true,
|
|
111
|
+
enableOutgoing: false,
|
|
112
|
+
outgoingFrom: "English",
|
|
113
|
+
outgoingTo: "Bangla",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const bizBtnRef = useRef<HTMLButtonElement | null>(null);
|
|
117
|
+
const [showBiz, setShowBiz] = useState(false);
|
|
118
|
+
|
|
119
|
+
const addrBtnRef = useRef<HTMLButtonElement | null>(null);
|
|
120
|
+
const [, setShowAddress] = useState(false);
|
|
121
|
+
|
|
122
|
+
// insert emoji at caret
|
|
123
|
+
const insertEmoji = (emoji: string) => {
|
|
124
|
+
const el = textRef.current;
|
|
125
|
+
if (!el) { return; }
|
|
126
|
+
const start = el.selectionStart ?? el.value.length;
|
|
127
|
+
const end = el.selectionEnd ?? el.value.length;
|
|
128
|
+
const next = `${text.slice(0, start)}${emoji}${text.slice(end)}`;
|
|
129
|
+
setText(next);
|
|
130
|
+
requestAnimationFrame(() => {
|
|
131
|
+
el.focus();
|
|
132
|
+
const pos = start + emoji.length;
|
|
133
|
+
el.setSelectionRange(pos, pos);
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// auto-grow textarea
|
|
138
|
+
const handleAutoGrow = React.useCallback(() => {
|
|
139
|
+
const el = textRef.current;
|
|
140
|
+
if (!el) { return; }
|
|
141
|
+
el.style.height = "0px";
|
|
142
|
+
const styles = window.getComputedStyle(el);
|
|
143
|
+
const lh = parseFloat(styles.lineHeight || "20");
|
|
144
|
+
const max = lh * maxRows + parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
|
|
145
|
+
el.style.height = `${Math.min(el.scrollHeight, max)}px`;
|
|
146
|
+
}, [maxRows]);
|
|
147
|
+
|
|
148
|
+
useEffect(() => { handleAutoGrow(); }, [text, handleAutoGrow]);
|
|
149
|
+
|
|
150
|
+
// recording timer
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (!recording) { return; }
|
|
153
|
+
setSeconds(0);
|
|
154
|
+
const id = setInterval(() => setSeconds((s) => s + 1), 1000);
|
|
155
|
+
return () => clearInterval(id);
|
|
156
|
+
}, [recording]);
|
|
157
|
+
|
|
158
|
+
// cleanup blobs + streams
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
return () => {
|
|
161
|
+
imgPreviews.forEach((u) => u.startsWith("blob:") && URL.revokeObjectURL(u));
|
|
162
|
+
filePreviews.forEach((f) => f.href?.startsWith("blob:") && URL.revokeObjectURL(f.href));
|
|
163
|
+
if (streamRef.current) { streamRef.current.getTracks().forEach((t) => t.stop()); }
|
|
164
|
+
try { mediaRecRef.current?.stop(); } catch { /* empty */ }
|
|
165
|
+
};
|
|
166
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
const isTyping = text.length > 0;
|
|
170
|
+
const hasAttachments = imgPreviews.length > 0 || filePreviews.length > 0;
|
|
171
|
+
const canSendArrow = useMemo(() => hasAttachments || isTyping, [hasAttachments, isTyping]);
|
|
172
|
+
|
|
173
|
+
const onFilesPicked: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
174
|
+
const files = Array.from(e.target.files ?? []);
|
|
175
|
+
if (!files.length) { return; }
|
|
176
|
+
const imgs: string[] = [];
|
|
177
|
+
const docs: PreviewFile[] = [];
|
|
178
|
+
files.forEach((f) => {
|
|
179
|
+
const url = URL.createObjectURL(f);
|
|
180
|
+
if (f.type.startsWith("image/")) {
|
|
181
|
+
imgs.push(url);
|
|
182
|
+
} else {
|
|
183
|
+
docs.push({ name: f.name, sizeMB: mb(f.size), ext: extFromName(f.name), href: url, downloadName: f.name });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
setImgPreviews((p) => [...p, ...imgs]);
|
|
187
|
+
setFilePreviews((p) => [...p, ...docs]);
|
|
188
|
+
e.target.value = "";
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/* ────── Preview → payload helpers ────── */
|
|
192
|
+
|
|
193
|
+
const previewFilesToPayload = (files: PreviewFile[]) =>
|
|
194
|
+
files.map((f) => ({ name: f.name, sizeMB: f.sizeMB, ext: f.ext, href: f.href, downloadName: f.downloadName }));
|
|
195
|
+
|
|
196
|
+
const clearAttachments = () => {
|
|
197
|
+
imgPreviews.forEach((u) => u.startsWith("blob:") && URL.revokeObjectURL(u));
|
|
198
|
+
filePreviews.forEach((f) => f.href?.startsWith("blob:") && URL.revokeObjectURL(f.href));
|
|
199
|
+
setImgPreviews([]);
|
|
200
|
+
setFilePreviews([]);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/* ─────── Send handlers — all delegate to the single onSend prop ─────── */
|
|
204
|
+
|
|
205
|
+
const sendText = async () => {
|
|
206
|
+
const t = text.trim();
|
|
207
|
+
const hasAttachmentsNow = imgPreviews.length > 0 || filePreviews.length > 0;
|
|
208
|
+
if (!t && !hasAttachmentsNow) return;
|
|
209
|
+
|
|
210
|
+
if (hasAttachmentsNow) {
|
|
211
|
+
// Combined: text + files/images
|
|
212
|
+
onSend({
|
|
213
|
+
type: "combined",
|
|
214
|
+
text: t,
|
|
215
|
+
files: previewFilesToPayload(filePreviews),
|
|
216
|
+
images: imgPreviews,
|
|
217
|
+
replyTo,
|
|
218
|
+
});
|
|
219
|
+
clearAttachments();
|
|
220
|
+
} else {
|
|
221
|
+
onSend({ type: "text", text: t, replyTo });
|
|
222
|
+
}
|
|
223
|
+
setText("");
|
|
224
|
+
clearReply?.();
|
|
225
|
+
onAfterSend?.();
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const sendAttachments = async () => {
|
|
229
|
+
if (text.length > 0) { await sendText(); return; }
|
|
230
|
+
const hasAttachmentsNow = imgPreviews.length > 0 || filePreviews.length > 0;
|
|
231
|
+
if (!hasAttachmentsNow) return;
|
|
232
|
+
|
|
233
|
+
onSend({
|
|
234
|
+
type: "attachments",
|
|
235
|
+
images: imgPreviews,
|
|
236
|
+
files: previewFilesToPayload(filePreviews),
|
|
237
|
+
replyTo,
|
|
238
|
+
});
|
|
239
|
+
clearAttachments();
|
|
240
|
+
clearReply?.();
|
|
241
|
+
onAfterSend?.();
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/* ───── Recording control with MediaRecorder ───── */
|
|
245
|
+
const startRecording = async () => {
|
|
246
|
+
setMicError("");
|
|
247
|
+
try {
|
|
248
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false });
|
|
249
|
+
streamRef.current = stream;
|
|
250
|
+
const canWebm = typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported?.("audio/webm;codecs=opus");
|
|
251
|
+
const mime = canWebm ? "audio/webm;codecs=opus" : "audio/mp4";
|
|
252
|
+
const rec = new MediaRecorder(stream, { mimeType: mime });
|
|
253
|
+
mediaRecRef.current = rec;
|
|
254
|
+
chunksRef.current = [];
|
|
255
|
+
rec.ondataavailable = (e) => { if (e.data && e.data.size > 0) chunksRef.current.push(e.data); };
|
|
256
|
+
rec.start();
|
|
257
|
+
setRecording(true);
|
|
258
|
+
} catch (err: unknown) {
|
|
259
|
+
let name: string | undefined;
|
|
260
|
+
if (err instanceof DOMException || err instanceof Error) { ({ name } = err); }
|
|
261
|
+
const msg = name === "NotFoundError" ? "No audio input device found." : name === "NotAllowedError" ? "Microphone access was denied." : "Unable to access the microphone.";
|
|
262
|
+
setMicError(msg);
|
|
263
|
+
setRecording(false);
|
|
264
|
+
streamRef.current?.getTracks().forEach((t) => t.stop());
|
|
265
|
+
try { mediaRecRef.current?.stop(); } catch { /* empty */ }
|
|
266
|
+
streamRef.current = null;
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const finalizeBlob = (rec: MediaRecorder): Promise<Blob> =>
|
|
271
|
+
new Promise((resolve) => {
|
|
272
|
+
const finish = () => resolve(new Blob(chunksRef.current, { type: rec.mimeType || "audio/webm" }));
|
|
273
|
+
rec.onstop = finish;
|
|
274
|
+
try { rec.stop(); } catch { finish(); }
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const stopRecording = async (send = false) => {
|
|
278
|
+
const rec = mediaRecRef.current;
|
|
279
|
+
const stream = streamRef.current;
|
|
280
|
+
setRecording(false);
|
|
281
|
+
if (stream) { stream.getTracks().forEach((t) => t.stop()); streamRef.current = null; }
|
|
282
|
+
if (!rec) {
|
|
283
|
+
if (send) onSend({ type: "voice", durationSec: seconds, durationText: fmtTime(seconds), replyTo });
|
|
284
|
+
setSeconds(0);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const blob = await finalizeBlob(rec);
|
|
288
|
+
mediaRecRef.current = null;
|
|
289
|
+
if (send) {
|
|
290
|
+
const src = URL.createObjectURL(blob);
|
|
291
|
+
onSend({ type: "voice", src, durationSec: seconds, durationText: fmtTime(seconds), replyTo });
|
|
292
|
+
clearReply?.();
|
|
293
|
+
onAfterSend?.();
|
|
294
|
+
}
|
|
295
|
+
setSeconds(0);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
<div className={clsx("border-t border-[#ededed] bg-white p-3.5", className)}>
|
|
300
|
+
{replyTo && (
|
|
301
|
+
<div className="mb-2">
|
|
302
|
+
<ReplyCard refMsg={replyTo} onClose={clearReply} />
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
|
|
306
|
+
<AttachmentPreviewStrip
|
|
307
|
+
imgPreviews={imgPreviews}
|
|
308
|
+
filePreviews={filePreviews}
|
|
309
|
+
onRemoveFile={(i) =>
|
|
310
|
+
setFilePreviews((prev) => {
|
|
311
|
+
const cp = [...prev];
|
|
312
|
+
const [rm] = cp.splice(i, 1);
|
|
313
|
+
if (rm?.href?.startsWith("blob:")) URL.revokeObjectURL(rm.href);
|
|
314
|
+
return cp;
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
onRemoveImage={(i) =>
|
|
318
|
+
setImgPreviews((prev) => {
|
|
319
|
+
const cp = [...prev];
|
|
320
|
+
const [rm] = cp.splice(i, 1);
|
|
321
|
+
if (rm?.startsWith("blob:")) URL.revokeObjectURL(rm);
|
|
322
|
+
return cp;
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
/>
|
|
326
|
+
|
|
327
|
+
<div className="mb-2 flex items-center gap-3">
|
|
328
|
+
{(actionData ?? []).map((a) => {
|
|
329
|
+
const isAttach = a.key === "attachment";
|
|
330
|
+
const isEmoji = a.key === "emoji";
|
|
331
|
+
const isTranslate = a.key === "translate";
|
|
332
|
+
const isBiz = a.key === "businessCard";
|
|
333
|
+
const isAddress = a.key === "addressCard";
|
|
334
|
+
|
|
335
|
+
if (isEmoji) {
|
|
336
|
+
return (
|
|
337
|
+
<span key={a.key} className="relative inline-flex">
|
|
338
|
+
<Tooltip text={a.title}>
|
|
339
|
+
<button ref={emojiBtnRef} type="button" onClick={() => setShowEmoji((v) => !v)} className="flex h-6 w-6 items-center justify-center rounded-full text-[#0F0F0F] hover:bg-[#F4F6F8]">
|
|
340
|
+
<span>{a.icon}</span>
|
|
341
|
+
</button>
|
|
342
|
+
</Tooltip>
|
|
343
|
+
</span>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
if (isTranslate) {
|
|
347
|
+
return (
|
|
348
|
+
<Tooltip key={a.key} text={a.title}>
|
|
349
|
+
<button type="button" onClick={() => setShowTranslate(true)} className="flex h-6 w-6 items-center justify-center rounded-full text-[#0F0F0F] hover:bg-[#F4F6F8]">
|
|
350
|
+
<span>{a.icon}</span>
|
|
351
|
+
</button>
|
|
352
|
+
</Tooltip>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
if (isBiz) {
|
|
356
|
+
return (
|
|
357
|
+
<span key={a.key} className="relative inline-flex">
|
|
358
|
+
<Tooltip text={a.title}>
|
|
359
|
+
<button ref={bizBtnRef} type="button" onClick={() => setShowBiz((v) => !v)} className="flex h-6 w-6 items-center justify-center rounded-full text-[#0F0F0F] hover:bg-[#F4F6F8]">
|
|
360
|
+
<span>{a.icon}</span>
|
|
361
|
+
</button>
|
|
362
|
+
</Tooltip>
|
|
363
|
+
</span>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
if (isAddress) {
|
|
367
|
+
return (
|
|
368
|
+
<span key={a.key} className="relative inline-flex">
|
|
369
|
+
<Tooltip text={a.title}>
|
|
370
|
+
<button ref={addrBtnRef} type="button" onClick={() => setShowAddress(true)} className="flex h-6 w-6 items-center justify-center rounded-full text-[#0F0F0F] hover:bg-[#F4F6F8]">
|
|
371
|
+
<span>{a.icon}</span>
|
|
372
|
+
</button>
|
|
373
|
+
</Tooltip>
|
|
374
|
+
</span>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
return (
|
|
378
|
+
<Tooltip key={a.key} text={a.title}>
|
|
379
|
+
<button type="button" onClick={isAttach ? () => fileInputRef.current?.click() : a.onClick} className="flex h-6 w-6 items-center justify-center rounded-full text-[#0F0F0F] hover:bg-[#F4F6F8]">
|
|
380
|
+
<span>{a.icon}</span>
|
|
381
|
+
</button>
|
|
382
|
+
</Tooltip>
|
|
383
|
+
);
|
|
384
|
+
})}
|
|
385
|
+
|
|
386
|
+
<input ref={fileInputRef} type="file" multiple accept={ACCEPT} onChange={onFilesPicked} className="hidden" />
|
|
387
|
+
|
|
388
|
+
<EmojiDropup open={showEmoji} onClose={() => setShowEmoji(false)} onSelect={insertEmoji} anchorRef={emojiBtnRef} />
|
|
389
|
+
<BusinessCardDropup
|
|
390
|
+
open={showBiz}
|
|
391
|
+
onClose={() => setShowBiz(false)}
|
|
392
|
+
anchorRef={bizBtnRef}
|
|
393
|
+
onSend={async (card) => {
|
|
394
|
+
onSend({ type: "businessCard", card, replyTo });
|
|
395
|
+
clearReply?.();
|
|
396
|
+
onAfterSend?.();
|
|
397
|
+
}}
|
|
398
|
+
/>
|
|
399
|
+
</div>
|
|
400
|
+
|
|
401
|
+
{micError && (
|
|
402
|
+
<div className="mb-2 flex items-start gap-2 rounded-sm bg-[#f8f8f8] px-2 py-1.5 text-xs text-[#ff5301]">
|
|
403
|
+
<span><ChatInfoIcon className="mt-0.5 h-3.5 w-3.5" /></span>
|
|
404
|
+
<span>{micError}</span>
|
|
405
|
+
<span className="pointer flex cursor-pointer items-center justify-center rounded-full p-1 hover:bg-black/10" onClick={() => setMicError("")}>
|
|
406
|
+
<ChatXIcon className="h-3.5 w-3.5" />
|
|
407
|
+
</span>
|
|
408
|
+
</div>
|
|
409
|
+
)}
|
|
410
|
+
|
|
411
|
+
<ChatComposerBar
|
|
412
|
+
recording={recording}
|
|
413
|
+
seconds={seconds}
|
|
414
|
+
isTyping={isTyping}
|
|
415
|
+
textRef={textRef}
|
|
416
|
+
text={text}
|
|
417
|
+
onTextChange={setText}
|
|
418
|
+
onAutoGrow={handleAutoGrow}
|
|
419
|
+
hasAttachments={hasAttachments}
|
|
420
|
+
canSendArrow={canSendArrow}
|
|
421
|
+
startRecording={startRecording}
|
|
422
|
+
stopRecording={stopRecording}
|
|
423
|
+
sendText={sendText}
|
|
424
|
+
sendAttachments={sendAttachments}
|
|
425
|
+
fmtTime={fmtTime}
|
|
426
|
+
/>
|
|
427
|
+
|
|
428
|
+
<ChatTranslateSettingsModal
|
|
429
|
+
variant={variant}
|
|
430
|
+
open={showTranslate}
|
|
431
|
+
onClose={() => setShowTranslate(false)}
|
|
432
|
+
initial={translateSettings}
|
|
433
|
+
onSave={(s) => {
|
|
434
|
+
setTranslateSettings(s);
|
|
435
|
+
setShowTranslate(false);
|
|
436
|
+
}}
|
|
437
|
+
/>
|
|
438
|
+
</div>
|
|
439
|
+
);
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
export default ChatFooter;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import React from "react";
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
left: React.ReactNode; // header left content (avatar/title)
|
|
7
|
+
right?: React.ReactNode; // header right content (icons/actions)
|
|
8
|
+
below?: React.ReactNode; // optional bar below the header (e.g., Inquiry ID)
|
|
9
|
+
className?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default function ChatHeader({ left, right, below, className }: Props) {
|
|
13
|
+
return (
|
|
14
|
+
<div>
|
|
15
|
+
<div className={clsx("border-b border-[#e1e1e1] h-[64px]", className)}>
|
|
16
|
+
<div className="flex items-start justify-between px-4 pt-2.5">
|
|
17
|
+
<div className="flex items-start gap-3">{left}</div>
|
|
18
|
+
{right}
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
{below && <>{below}</>}
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { BlueBadgeIcon } from "../../icons";
|
|
4
|
+
import { cn } from "../../utils/cn";
|
|
5
|
+
|
|
6
|
+
/* =======================
|
|
7
|
+
Types
|
|
8
|
+
======================= */
|
|
9
|
+
|
|
10
|
+
type SubtitleVariant = "live" | "muted";
|
|
11
|
+
// Variant = "avatar" | "initial" | "logo" — inferred from union props below
|
|
12
|
+
|
|
13
|
+
type BaseProps = {
|
|
14
|
+
title: string;
|
|
15
|
+
subtitle?: string;
|
|
16
|
+
subtitleVariant?: SubtitleVariant;
|
|
17
|
+
online?: boolean;
|
|
18
|
+
verified?: boolean;
|
|
19
|
+
/** Circle size in px (default 46) */
|
|
20
|
+
size?: number;
|
|
21
|
+
className?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type AvatarVariant = BaseProps & {
|
|
25
|
+
variant: "avatar";
|
|
26
|
+
src: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type InitialVariant = BaseProps & {
|
|
30
|
+
variant: "initial";
|
|
31
|
+
initial: string;
|
|
32
|
+
initialSrc?: string;
|
|
33
|
+
bg?: string;
|
|
34
|
+
textClassName?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type LogoVariant = BaseProps & {
|
|
38
|
+
variant: "logo";
|
|
39
|
+
src: string;
|
|
40
|
+
ringColor?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type ChatIdentityProps = AvatarVariant | InitialVariant | LogoVariant;
|
|
44
|
+
|
|
45
|
+
/* =======================
|
|
46
|
+
Component
|
|
47
|
+
======================= */
|
|
48
|
+
|
|
49
|
+
const ChatIdentity = (props: ChatIdentityProps) => {
|
|
50
|
+
const {
|
|
51
|
+
title,
|
|
52
|
+
subtitle,
|
|
53
|
+
subtitleVariant = "muted",
|
|
54
|
+
online = false,
|
|
55
|
+
verified = false,
|
|
56
|
+
size = 46,
|
|
57
|
+
className,
|
|
58
|
+
} = props;
|
|
59
|
+
|
|
60
|
+
const subtitleClass = cn(
|
|
61
|
+
"text-[10px] font-medium",
|
|
62
|
+
subtitleVariant === "live" ? "text-[#1E9E6A]" : "text-[#929292]",
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className={cn("flex items-start gap-3", className)}>
|
|
67
|
+
<div className="relative" style={{ width: size, height: size }}>
|
|
68
|
+
{/* Avatar */}
|
|
69
|
+
{props.variant === "avatar" ? (
|
|
70
|
+
<img
|
|
71
|
+
src={props.src}
|
|
72
|
+
alt={title}
|
|
73
|
+
className="h-full w-full rounded-xs object-cover border border-[#f1f1f1]"
|
|
74
|
+
/>
|
|
75
|
+
) : null}
|
|
76
|
+
|
|
77
|
+
{/* Initial */}
|
|
78
|
+
{props.variant === "initial" ? (
|
|
79
|
+
<div
|
|
80
|
+
className={cn(
|
|
81
|
+
"grid h-full w-full place-items-center rounded-xs text-[15px] font-semibold text-[#2c2c2c]",
|
|
82
|
+
props.textClassName,
|
|
83
|
+
)}
|
|
84
|
+
style={{ backgroundColor: props.bg ?? "#FFE5DA" }}
|
|
85
|
+
>
|
|
86
|
+
{props.initial}
|
|
87
|
+
</div>
|
|
88
|
+
) : null}
|
|
89
|
+
|
|
90
|
+
{/* Logo */}
|
|
91
|
+
{props.variant === "logo" ? (
|
|
92
|
+
<div
|
|
93
|
+
className="grid h-full w-full place-items-center rounded-xs"
|
|
94
|
+
style={{ boxShadow: `0 0 0 1px ${props.ringColor ?? "#EDEDED"} inset` }}
|
|
95
|
+
>
|
|
96
|
+
<img src={props.src} alt={title} className="h-full w-full rounded-xs object-cover" />
|
|
97
|
+
</div>
|
|
98
|
+
) : null}
|
|
99
|
+
|
|
100
|
+
{/* Online / Offline dot */}
|
|
101
|
+
<span
|
|
102
|
+
className={cn(
|
|
103
|
+
"absolute rounded-full ring-1 ring-white bottom-[-2px] right-[-2px]",
|
|
104
|
+
online ? "bg-[#328545]" : "bg-[#eb2127]",
|
|
105
|
+
)}
|
|
106
|
+
style={{
|
|
107
|
+
width: "15px",
|
|
108
|
+
height: "15px",
|
|
109
|
+
right: 0,
|
|
110
|
+
bottom: 0,
|
|
111
|
+
}}
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div>
|
|
116
|
+
<div className="flex items-start gap-1 text-[14px] font-medium text-black">
|
|
117
|
+
<span className="max-w-[300px] truncate">{title}</span>
|
|
118
|
+
{verified ? (
|
|
119
|
+
<span>
|
|
120
|
+
<BlueBadgeIcon />
|
|
121
|
+
</span>
|
|
122
|
+
) : null}
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
{subtitle ? (
|
|
126
|
+
title.toLowerCase() === "banbox.com" ? (
|
|
127
|
+
<div className="flex items-center">
|
|
128
|
+
<img
|
|
129
|
+
src="/chat/globe.gif"
|
|
130
|
+
alt="globe"
|
|
131
|
+
className="h-[12px] w-auto shrink-0 object-contain"
|
|
132
|
+
style={{ mixBlendMode: "multiply" }}
|
|
133
|
+
/>
|
|
134
|
+
<div className={subtitleClass}>{subtitle}</div>
|
|
135
|
+
</div>
|
|
136
|
+
) : (
|
|
137
|
+
<div className={subtitleClass}>{subtitle}</div>
|
|
138
|
+
)
|
|
139
|
+
) : null}
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export default ChatIdentity;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { RightArrow } from "../../icons";
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
id?: string; // if undefined, don't render
|
|
8
|
+
onView?: () => void; // handler for "View Inquiry"
|
|
9
|
+
className?: string; // optional container classes
|
|
10
|
+
label?: string;
|
|
11
|
+
buttonLabel?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const ChatInquiryBar: React.FC<Props> = ({ id, onView, className, label, buttonLabel }) => {
|
|
15
|
+
if (!id) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className={clsx(
|
|
22
|
+
"flex items-center justify-between border-b border-[#ededed] bg-[#f8f8f8] ps-[16px] pe-[30px] h-[30px]",
|
|
23
|
+
className,
|
|
24
|
+
)}
|
|
25
|
+
>
|
|
26
|
+
<div className="flex items-center gap-1.5 text-xs">
|
|
27
|
+
<span className="font-semibold text-black">{label}</span>
|
|
28
|
+
<span className="h-5 w-px bg-[#e1e1e1]" />
|
|
29
|
+
<span className="text-[#636363]">{id}</span>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
{/* Button with 'static' + 'hover/animated' layers */}
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
onClick={onView}
|
|
36
|
+
className="group relative inline-flex w-fit items-center justify-end text-xs font-medium text-black"
|
|
37
|
+
>
|
|
38
|
+
<span className="flex items-center transition-opacity duration-300 ease-in-out group-hover:opacity-0">
|
|
39
|
+
<span>{buttonLabel}</span>
|
|
40
|
+
<span className="ml-[4px] flex h-4 w-4 items-center justify-center">
|
|
41
|
+
<RightArrow />
|
|
42
|
+
</span>
|
|
43
|
+
</span>
|
|
44
|
+
|
|
45
|
+
{/* hover layer (slides in) */}
|
|
46
|
+
<span className="pointer-events-none absolute inset-0 flex items-center opacity-0 transition-opacity duration-300 ease-in-out group-hover:opacity-100">
|
|
47
|
+
<span>{buttonLabel}</span>
|
|
48
|
+
<span className="ml-[2px] flex h-4 w-4 items-center justify-center animate-slide-right">
|
|
49
|
+
<RightArrow />
|
|
50
|
+
</span>
|
|
51
|
+
</span>
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export default ChatInquiryBar;
|