@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,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;