@banbox/chat 1.0.2 → 1.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banbox/chat",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Banbox Chat UI components — reusable across all Banbox React/Next.js projects",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -58,17 +58,20 @@
58
58
  ],
59
59
  "peerDependencies": {
60
60
  "framer-motion": ">=10",
61
+ "lottie-react": ">=2",
61
62
  "react": ">=18",
62
63
  "react-dom": ">=18"
63
64
  },
64
65
  "peerDependenciesMeta": {
65
66
  "framer-motion": {
66
67
  "optional": false
68
+ },
69
+ "lottie-react": {
70
+ "optional": false
67
71
  }
68
72
  },
69
73
  "dependencies": {
70
74
  "clsx": "^2.1.1",
71
- "lottie-react": "^2.4.1",
72
75
  "tailwind-merge": "^3.6.0"
73
76
  },
74
77
  "devDependencies": {
@@ -2,11 +2,11 @@
2
2
  "use client";
3
3
 
4
4
  import { motion } from "framer-motion";
5
- import React, { useCallback, useEffect, useState } from "react";
5
+ import React, { useCallback, useEffect, useRef, useState } from "react";
6
6
 
7
- import ChatConfirmModal from "../modals/chat/ChatConfirmModal";
8
7
  import { useChatUI } from "../contexts/ChatUIContext";
9
8
  import { useGallery } from "../contexts/GalleryContext";
9
+ import ChatConfirmModal from "../modals/chat/ChatConfirmModal";
10
10
  import ChatFooter from "../ui/chat/ChatFooter";
11
11
  import ChatHeader from "../ui/chat/ChatHeader";
12
12
  import ChatIdentity from "../ui/chat/ChatIdentity";
@@ -17,19 +17,16 @@ import ChatScroll from "../ui/chat/ChatScroll";
17
17
  import type { ChatThreadStatus } from "../ui/chat/ChatThreadItem";
18
18
  import ChatThreadItem from "../ui/chat/ChatThreadItem";
19
19
  import TypingIndicator from "../ui/chat/TypingIndicator";
20
- import ChatSpinner from "../ui/chat/ChatSpinner";
21
20
  import ChatImagePreviewModal from "./ChatImagePreviewModal";
22
21
 
23
- import type { Thread, Message, MessageRef } from "../types";
24
22
  import type { ChatAdapter, ChatUICallbacks } from "../adapter/types";
23
+ import type { Message, MessageRef, Thread } from "../types";
25
24
 
26
25
  /* =======================
27
26
  Props
28
27
  ======================= */
29
28
  export type InboxPopupProps = {
30
- /** The unified data adapter — provides threads, messages, and send */
31
29
  adapter: ChatAdapter;
32
- /** UI-level callbacks (toast, navigation, kebab menu) */
33
30
  uiCallbacks?: ChatUICallbacks;
34
31
  };
35
32
 
@@ -37,9 +34,16 @@ export type InboxPopupProps = {
37
34
  Constants
38
35
  ======================= */
39
36
  const avatarBgByInitial: Record<string, string> = {
40
- K: "#FFE7DB", A: "#FFE5DA", F: "#E8F7FF", B: "#F0EDEB", b: "#F0EDEB",
37
+ K: "#FFE7DB",
38
+ A: "#FFE5DA",
39
+ F: "#E8F7FF",
40
+ B: "#F0EDEB",
41
+ b: "#F0EDEB",
41
42
  };
42
43
 
44
+ const GRADIENT_BORDER =
45
+ "linear-gradient(236.83deg, rgba(51,201,212,0.3) 0.4%, rgba(39,83,251,0.3) 30.28%, rgba(39,83,251,0.3) 50.2%, rgba(39,83,251,0.3) 65.14%, rgba(235,67,255,0.3) 100%)";
46
+
43
47
  /* =======================
44
48
  Component
45
49
  ======================= */
@@ -47,7 +51,7 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
47
51
  const { close, selectThread, selectedThreadId, reference } = useChatUI();
48
52
  const { isOpen: isGalleryOpen, closeGallery } = useGallery();
49
53
 
50
- /* ─── Thread list ─── */
54
+ /* ─── Threads ─── */
51
55
  const [threads, setThreads] = useState<Thread[]>(() => adapter.threads.list(reference));
52
56
  const refreshThreads = useCallback(
53
57
  () => setThreads(adapter.threads.list(reference)),
@@ -55,11 +59,13 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
55
59
  );
56
60
 
57
61
  useEffect(() => {
58
- // Immediate sync on mount / reference change
59
62
  let rafId = 0;
60
63
  rafId = requestAnimationFrame(refreshThreads);
61
64
  const unsub = adapter.threads.subscribe(refreshThreads);
62
- return () => { cancelAnimationFrame(rafId); unsub(); };
65
+ return () => {
66
+ cancelAnimationFrame(rafId);
67
+ unsub();
68
+ };
63
69
  }, [adapter, reference, refreshThreads]);
64
70
 
65
71
  /* ─── Active thread & messages ─── */
@@ -74,12 +80,10 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
74
80
  activeId ? adapter.messages.list(activeId) : [],
75
81
  );
76
82
 
77
- // Refresh messages when active thread changes or rev bumps
78
83
  useEffect(() => {
79
84
  if (activeId) setMessages(adapter.messages.list(activeId));
80
85
  }, [activeId, rev, adapter]);
81
86
 
82
- // Subscribe to real-time message updates for the active thread
83
87
  useEffect(() => {
84
88
  if (!activeId || !adapter.messages.subscribe) return;
85
89
  const unsub = adapter.messages.subscribe(activeId, () => {
@@ -88,7 +92,7 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
88
92
  return unsub;
89
93
  }, [activeId, adapter]);
90
94
 
91
- /* ─── Derived UI values ─── */
95
+ /* ─── Derived ─── */
92
96
  const initial = activeThread?.avatarText ?? "U";
93
97
  const title = activeThread?.title ?? "Unknown";
94
98
  const subtitle = activeThread?.subTitle ?? "";
@@ -96,28 +100,31 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
96
100
  const isVerified = Boolean(activeThread?.badge);
97
101
  const avatarBg = avatarBgByInitial[initial] ?? "#FFF1EC";
98
102
 
99
- const idLabel = activeThread?.orderId ? "Order ID" : activeThread?.inquiryId ? "Inquiry ID" : undefined;
100
- const idButtonLabel = activeThread?.orderId ? "View Order" : activeThread?.inquiryId ? "View Inquiry" : undefined;
103
+ const idLabel = activeThread?.orderId
104
+ ? "Order ID"
105
+ : activeThread?.inquiryId
106
+ ? "Inquiry ID"
107
+ : undefined;
108
+ const idButtonLabel = activeThread?.orderId
109
+ ? "View Order"
110
+ : activeThread?.inquiryId
111
+ ? "View Inquiry"
112
+ : undefined;
101
113
  const idValue = activeThread?.orderId ?? activeThread?.inquiryId ?? undefined;
102
114
 
103
- /* ─── Loading state ─── */
104
115
  const [showDelete, setShowDelete] = useState(false);
105
- const [isLoading, setIsLoading] = useState(false);
106
116
  const scrollKey = `${activeId}-${messages.length}-${rev}`;
107
117
 
108
- const prevActiveIdRef = React.useRef(activeId);
118
+ /* mark read on switch */
119
+ const prevActiveIdRef = useRef(activeId);
109
120
  useEffect(() => {
110
121
  if (prevActiveIdRef.current !== activeId) {
111
122
  prevActiveIdRef.current = activeId;
112
- setIsLoading(true);
113
- const t = setTimeout(() => setIsLoading(false), 300);
114
- // Mark thread as read when switching to it
115
123
  if (activeId) adapter.threads.markRead?.(activeId);
116
- return () => clearTimeout(t);
117
124
  }
118
125
  }, [activeId, adapter]);
119
126
 
120
- /* ─── Reply helper ─── */
127
+ /* ─── Helpers ─── */
121
128
  const toRef = (m: Message): MessageRef => ({
122
129
  id: m.id,
123
130
  author: typeof m.author === "string" ? m.author : "U",
@@ -128,9 +135,11 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
128
135
  audio: m.audio,
129
136
  });
130
137
 
131
- /* ─── Delete ─── */
132
138
  const handleConfirmDelete = () => {
133
- if (!activeId) { setShowDelete(false); return; }
139
+ if (!activeId) {
140
+ setShowDelete(false);
141
+ return;
142
+ }
134
143
  adapter.threads.delete(activeId);
135
144
  const nextId = threads.filter((t) => t.id !== activeId)[0]?.id;
136
145
  if (nextId) selectThread(nextId);
@@ -143,66 +152,125 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
143
152
  });
144
153
  };
145
154
 
155
+ /* ─── Filtered threads ─── */
156
+ const filteredThreads = threads.filter((t) => {
157
+ if (!searchQuery.trim()) return true;
158
+ const q = searchQuery.toLowerCase();
159
+ return (
160
+ t.title.toLowerCase().includes(q) ||
161
+ t.last?.toLowerCase().includes(q) ||
162
+ t.orderId?.toLowerCase().includes(q) ||
163
+ t.inquiryId?.toLowerCase().includes(q)
164
+ );
165
+ });
166
+
167
+ /* ══════════════════════════════════════════════════
168
+ RENDER
169
+ ══════════════════════════════════════════════════ */
146
170
  return (
147
- <div className="fixed bottom-4 right-4 z-[10002]">
171
+ <div style={{ position: "fixed", bottom: 16, right: 16, zIndex: 10002 }}>
148
172
  {/* Backdrop */}
149
173
  <motion.button
150
174
  aria-label="Close chat"
151
175
  onClick={close}
152
- className="fixed inset-0"
176
+ style={{ position: "fixed", inset: 0, background: "transparent", border: "none", cursor: "auto" }}
153
177
  initial={{ opacity: 0 }}
154
178
  animate={{ opacity: 1 }}
155
179
  exit={{ opacity: 0 }}
156
180
  transition={{ type: "tween", duration: 0.25 }}
157
181
  />
158
182
 
159
- {/* Popup wrapper */}
183
+ {/* Outer gradient border wrapper */}
160
184
  <motion.div
161
185
  role="dialog"
162
186
  aria-modal="true"
163
- className="relative rounded-[20px] p-[3px]"
164
187
  style={{
188
+ position: "relative",
165
189
  width: 800,
166
190
  height: 650,
191
+ borderRadius: 20,
192
+ padding: 3,
167
193
  boxShadow: "0px 2px 12px 0px #3B33331A",
168
- background:
169
- "linear-gradient(236.83deg, rgba(51, 201, 212, 0.3) 0.4%, rgba(39, 83, 251, 0.3) 30.28%, rgba(39, 83, 251, 0.3) 50.2%, rgba(39, 83, 251, 0.3) 65.14%, rgba(235, 67, 255, 0.3) 100%)",
194
+ background: GRADIENT_BORDER,
170
195
  }}
171
196
  initial={{ x: "110%" }}
172
197
  animate={{ x: 0 }}
173
198
  exit={{ x: "110%" }}
174
199
  transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}
175
200
  >
201
+ {/* Inner white card */}
176
202
  <div
177
- className="relative h-full w-full overflow-hidden rounded-[18px] bg-white"
178
- style={{ overscrollBehavior: "contain" }}
203
+ style={{
204
+ position: "relative",
205
+ height: "100%",
206
+ width: "100%",
207
+ overflow: "hidden",
208
+ borderRadius: 18,
209
+ backgroundColor: "#fff",
210
+ overscrollBehavior: "contain",
211
+ }}
179
212
  >
180
- <div className="pointer-events-none absolute inset-0 rounded-[14px] ring-1 ring-[#2F80ED]/40" />
181
-
182
- <div className="grid h-full min-h-0 grid-cols-[1fr_350px]">
183
- {/* LEFT — Message area */}
184
- <div className="flex h-full min-h-0 flex-col border-r border-[#9BBCCF]">
185
- <div className="h-[64px] shrink-0">
213
+ {/* ── TWO-COLUMN GRID: LEFT chat | RIGHT thread list ── */}
214
+ <div
215
+ style={{
216
+ display: "grid",
217
+ gridTemplateColumns: "1fr 310px",
218
+ height: "100%",
219
+ minHeight: 0,
220
+ }}
221
+ >
222
+ {/* ════════ LEFT — chat area ════════ */}
223
+ <div
224
+ style={{
225
+ display: "flex",
226
+ flexDirection: "column",
227
+ height: "100%",
228
+ minHeight: 0,
229
+ borderRight: "1px solid #9BBCCF",
230
+ }}
231
+ >
232
+ {/* Chat header — 64px fixed */}
233
+ <div style={{ height: 64, flexShrink: 0 }}>
186
234
  <ChatHeader
187
235
  left={
188
236
  activeThread?.avatarSrc ? (
189
- <ChatIdentity variant="avatar" src={activeThread.avatarSrc} online={online} title={title} subtitle={subtitle} verified={isVerified} subtitleVariant="muted" />
237
+ <ChatIdentity
238
+ variant="avatar"
239
+ src={activeThread.avatarSrc}
240
+ online={online}
241
+ title={title}
242
+ subtitle={subtitle}
243
+ verified={isVerified}
244
+ subtitleVariant="muted"
245
+ />
190
246
  ) : (
191
- <ChatIdentity variant="initial" initial={initial} bg={avatarBg} online={online} title={title} subtitle={subtitle} verified={isVerified} subtitleVariant="muted" />
247
+ <ChatIdentity
248
+ variant="initial"
249
+ initial={initial}
250
+ bg={avatarBg}
251
+ online={online}
252
+ title={title}
253
+ subtitle={subtitle}
254
+ verified={isVerified}
255
+ subtitleVariant="muted"
256
+ />
192
257
  )
193
258
  }
194
259
  right={
195
260
  uiCallbacks?.renderKebabMenu?.({
196
261
  pinned: Boolean(activeThread?.pinned),
197
- onPinToggle: () => { if (activeId) adapter.threads.pin(activeId, !activeThread?.pinned); },
262
+ onPinToggle: () => {
263
+ if (activeId) adapter.threads.pin(activeId, !activeThread?.pinned);
264
+ },
198
265
  onDelete: () => setShowDelete(true),
199
266
  }) ?? null
200
267
  }
201
268
  />
202
269
  </div>
203
270
 
271
+ {/* Optional inquiry bar */}
204
272
  {idValue && (
205
- <div className="shrink-0">
273
+ <div style={{ flexShrink: 0 }}>
206
274
  <ChatInquiryBar
207
275
  id={idValue}
208
276
  label={idLabel}
@@ -215,47 +283,66 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
215
283
  </div>
216
284
  )}
217
285
 
218
- <div className="flex-1 min-h-0">
219
- <div className="relative h-full min-h-0">
220
- <ChatScroll
221
- className="h-full pb-10"
222
- bottomAlignWhenShort={false}
223
- scrollKey={scrollKey}
224
- >
225
- {messages.map((m, idx) => {
226
- const mine = m.author === "you";
227
- const isLast = idx === messages.length - 1;
228
- return (
229
- <ChatMessageItem
230
- key={m.id}
231
- id={m.id}
232
- mine={mine}
233
- time={m.time ?? ""}
234
- authorInitial={typeof m.author === "string" ? m.author : "U"}
235
- avatarBg={avatarBg}
236
- text={m.text ?? m.content}
237
- businessCard={m.businessCard as Parameters<typeof ChatMessageItem>[0]["businessCard"]}
238
- addressCard={m.addressCard as Parameters<typeof ChatMessageItem>[0]["addressCard"]}
239
- images={m.images}
240
- files={m.files}
241
- audio={m.audio}
242
- replyTo={m.replyTo}
243
- showStatus={isLast}
244
- status={activeThread?.status?.kind === "seen" ? "Seen" : "Delivered"}
245
- onReply={() => setReplyTo(toRef(m))}
246
- initialSrc={m.avatarSrc}
247
- />
248
- );
249
- })}
250
- </ChatScroll>
286
+ {/* Messages area — fills remaining space */}
287
+ <div style={{ flex: 1, minHeight: 0, position: "relative" }}>
288
+ <ChatScroll
289
+ className="h-full pb-10"
290
+ bottomAlignWhenShort={false}
291
+ scrollKey={scrollKey}
292
+ style={{ height: "100%", overflowY: "auto" }}
293
+ >
294
+ {messages.map((m, idx) => {
295
+ const mine = m.author === "you";
296
+ const isLast = idx === messages.length - 1;
297
+ return (
298
+ <ChatMessageItem
299
+ key={m.id}
300
+ id={m.id}
301
+ mine={mine}
302
+ time={m.time ?? ""}
303
+ authorInitial={typeof m.author === "string" ? m.author : "U"}
304
+ avatarBg={avatarBg}
305
+ text={m.text ?? m.content}
306
+ businessCard={
307
+ m.businessCard as Parameters<typeof ChatMessageItem>[0]["businessCard"]
308
+ }
309
+ addressCard={
310
+ m.addressCard as Parameters<typeof ChatMessageItem>[0]["addressCard"]
311
+ }
312
+ images={m.images}
313
+ files={m.files}
314
+ audio={m.audio}
315
+ replyTo={m.replyTo}
316
+ showStatus={isLast}
317
+ status={activeThread?.status?.kind === "seen" ? "Seen" : "Delivered"}
318
+ onReply={() => setReplyTo(toRef(m))}
319
+ initialSrc={m.avatarSrc}
320
+ />
321
+ );
322
+ })}
323
+ </ChatScroll>
251
324
 
252
- <div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-center justify-start px-4 pb-2 pt-1 bg-white">
253
- <TypingIndicator className="pointer-events-auto" />
254
- </div>
325
+ {/* Typing indicator pinned at bottom of messages */}
326
+ <div
327
+ style={{
328
+ position: "absolute",
329
+ left: 0,
330
+ right: 0,
331
+ bottom: 0,
332
+ display: "flex",
333
+ alignItems: "center",
334
+ justifyContent: "flex-start",
335
+ padding: "4px 16px 8px",
336
+ background: "#fff",
337
+ pointerEvents: "none",
338
+ }}
339
+ >
340
+ <TypingIndicator style={{ pointerEvents: "auto" }} />
255
341
  </div>
256
342
  </div>
257
343
 
258
- <div className="shrink-0">
344
+ {/* Chat footer — shrinks to content height */}
345
+ <div style={{ flexShrink: 0 }}>
259
346
  <ChatFooter
260
347
  key={activeId}
261
348
  replyTo={replyTo}
@@ -268,45 +355,51 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
268
355
  </div>
269
356
  </div>
270
357
 
271
- {/* RIGHT — Thread list */}
272
- <div className="h-full min-h-0">
273
- <ChatListHeader onClose={close} onSearchChange={(val) => setSearchQuery(val)} />
274
- <div className="h-full overflow-y-auto custom-scroll">
275
- {threads
276
- .filter((t) => {
277
- if (!searchQuery.trim()) return true;
278
- const q = searchQuery.toLowerCase();
279
- return (
280
- t.title.toLowerCase().includes(q) ||
281
- t.last?.toLowerCase().includes(q) ||
282
- t.orderId?.toLowerCase().includes(q) ||
283
- t.inquiryId?.toLowerCase().includes(q)
284
- );
285
- })
286
- .map((t) => {
287
- const status: ChatThreadStatus = t.status ?? (t.unread && t.unread > 0 ? { kind: "new", count: t.unread } : { kind: "seen" });
288
- return (
289
- <ChatThreadItem
290
- key={t.id}
291
- onClick={() => { setReplyTo(undefined); selectThread(t.id); }}
292
- active={t.id === activeId}
293
- pinned={Boolean(t.pinned)}
294
- online={t.online}
295
- verified={Boolean(t.badge)}
296
- title={t.title}
297
- preview={t.last ?? ""}
298
- time={t.time ?? ""}
299
- status={status}
300
- avatarText={t.avatarText ?? ""}
301
- avatarSrc={t.avatarSrc}
302
- />
303
- );
304
- })}
358
+ {/* ════════ RIGHT — thread list ════════ */}
359
+ <div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
360
+ {/* List header — 64px fixed */}
361
+ <div style={{ flexShrink: 0 }}>
362
+ <ChatListHeader onClose={close} onSearchChange={(val) => setSearchQuery(val)} />
363
+ </div>
364
+
365
+ {/* Scrollable thread list */}
366
+ <div style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
367
+ {filteredThreads.map((t) => {
368
+ const status: ChatThreadStatus =
369
+ t.status ??
370
+ (t.unread && t.unread > 0
371
+ ? { kind: "new", count: t.unread }
372
+ : { kind: "seen" });
373
+ return (
374
+ <ChatThreadItem
375
+ key={t.id}
376
+ onClick={() => {
377
+ setReplyTo(undefined);
378
+ selectThread(t.id);
379
+ }}
380
+ active={t.id === activeId}
381
+ pinned={Boolean(t.pinned)}
382
+ online={t.online}
383
+ verified={Boolean(t.badge)}
384
+ title={t.title}
385
+ preview={t.last ?? ""}
386
+ time={t.time ?? ""}
387
+ status={status}
388
+ avatarText={t.avatarText ?? ""}
389
+ avatarSrc={t.avatarSrc}
390
+ />
391
+ );
392
+ })}
305
393
  </div>
306
394
  </div>
307
395
  </div>
308
396
 
309
- <ChatConfirmModal open={showDelete} onClose={() => setShowDelete(false)} onConfirm={handleConfirmDelete} />
397
+ {/* Modals */}
398
+ <ChatConfirmModal
399
+ open={showDelete}
400
+ onClose={() => setShowDelete(false)}
401
+ onConfirm={handleConfirmDelete}
402
+ />
310
403
  <ChatImagePreviewModal isOpen={isGalleryOpen} onClose={closeGallery} />
311
404
  </div>
312
405
  </motion.div>