@banbox/chat 1.0.6 → 1.0.8

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.6",
3
+ "version": "1.0.8",
4
4
  "description": "Banbox Chat UI components — reusable across all Banbox React/Next.js projects",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -31,13 +31,16 @@ export type ChatTheme =
31
31
  export type InboxPopupProps = {
32
32
  adapter: ChatAdapter;
33
33
  uiCallbacks?: ChatUICallbacks;
34
- /** Dynamic theme: "marketplace" (orange), "admin" (black), or custom object */
35
34
  theme?: ChatTheme;
36
35
  };
37
36
 
38
37
  /* ─── Helpers ─── */
39
38
  const avatarBgByInitial: Record<string, string> = {
40
- K: "#FFE7DB", A: "#FFE5DA", F: "#E8F7FF", B: "#F0EDEB", b: "#F0EDEB",
39
+ K: "#FFE7DB",
40
+ A: "#FFF1EC",
41
+ F: "#E8F7FF",
42
+ B: "#F0EDEB",
43
+ b: "#F0EDEB",
41
44
  };
42
45
 
43
46
  const GRADIENT_BORDER =
@@ -51,7 +54,6 @@ function getThemeAttr(theme?: ChatTheme): string {
51
54
 
52
55
  function getThemeVars(theme?: ChatTheme): React.CSSProperties {
53
56
  if (!theme || theme === "marketplace" || theme === "admin") return {};
54
- // Custom theme object — set CSS vars directly
55
57
  const vars: Record<string, string> = {};
56
58
  if (theme.primary) vars["--color-banbox-primary"] = theme.primary;
57
59
  if (theme.primaryActive) vars["--color-banbox-primary-active"] = theme.primaryActive;
@@ -77,7 +79,10 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks, theme })
77
79
  let rafId = 0;
78
80
  rafId = requestAnimationFrame(refreshThreads);
79
81
  const unsub = adapter.threads.subscribe(refreshThreads);
80
- return () => { cancelAnimationFrame(rafId); unsub(); };
82
+ return () => {
83
+ cancelAnimationFrame(rafId);
84
+ unsub();
85
+ };
81
86
  }, [adapter, reference, refreshThreads]);
82
87
 
83
88
  /* ─── Active thread & messages ─── */
@@ -111,9 +116,18 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks, theme })
111
116
  const online = Boolean(activeThread?.online);
112
117
  const isVerified = Boolean(activeThread?.badge);
113
118
  const avatarBg = avatarBgByInitial[initial] ?? "#FFF1EC";
114
-
115
- const idLabel = activeThread?.orderId ? "Order ID" : activeThread?.inquiryId ? "Inquiry ID" : undefined;
116
- const idButtonLabel = activeThread?.orderId ? "View Order" : activeThread?.inquiryId ? "View Inquiry" : undefined;
119
+ const initialSrc = activeThread?.avatarSrc ?? "/chat/banbox_chat_logo.png";
120
+
121
+ const idLabel = activeThread?.orderId
122
+ ? "Order ID"
123
+ : activeThread?.inquiryId
124
+ ? "Inquiry ID"
125
+ : undefined;
126
+ const idButtonLabel = activeThread?.orderId
127
+ ? "View Order"
128
+ : activeThread?.inquiryId
129
+ ? "View Inquiry"
130
+ : undefined;
117
131
  const idValue = activeThread?.orderId ?? activeThread?.inquiryId ?? undefined;
118
132
 
119
133
  const [showDelete, setShowDelete] = useState(false);
@@ -138,13 +152,20 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks, theme })
138
152
  });
139
153
 
140
154
  const handleConfirmDelete = () => {
141
- if (!activeId) { setShowDelete(false); return; }
155
+ if (!activeId) {
156
+ setShowDelete(false);
157
+ return;
158
+ }
142
159
  adapter.threads.delete(activeId);
143
160
  const nextId = threads.filter((t) => t.id !== activeId)[0]?.id;
144
161
  if (nextId) selectThread(nextId);
145
162
  setReplyTo(undefined);
146
163
  setShowDelete(false);
147
- uiCallbacks?.showToast?.({ type: "success", title: "Chat Deleted", message: "The chat has been deleted successfully." });
164
+ uiCallbacks?.showToast?.({
165
+ type: "success",
166
+ title: "Chat Deleted",
167
+ message: "The chat has been deleted successfully.",
168
+ });
148
169
  };
149
170
 
150
171
  const filteredThreads = threads.filter((t) => {
@@ -167,7 +188,7 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks, theme })
167
188
  <motion.button
168
189
  aria-label="Close chat"
169
190
  onClick={close}
170
- className="fixed inset-0 cursor-auto!"
191
+ className="fixed inset-0"
171
192
  style={{ background: "transparent", border: "none" }}
172
193
  initial={{ opacity: 0 }}
173
194
  animate={{ opacity: 1 }}
@@ -200,8 +221,8 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks, theme })
200
221
  >
201
222
  <div className="pointer-events-none absolute inset-0 rounded-[14px] ring-1 ring-[#2F80ED]/40" />
202
223
 
203
- {/* TWO-COLUMN GRID */}
204
- <div className="grid h-full min-h-0 grid-cols-[1fr_310px]">
224
+ {/* TWO-COLUMN GRID — exact marketplace: grid-cols-[1fr_350px] */}
225
+ <div className="grid h-full min-h-0 grid-cols-[1fr_350px]">
205
226
 
206
227
  {/* ════ LEFT — chat ════ */}
207
228
  <div className="flex h-full min-h-0 flex-col border-r border-[#9BBCCF]">
@@ -211,15 +232,34 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks, theme })
211
232
  <ChatHeader
212
233
  left={
213
234
  activeThread?.avatarSrc ? (
214
- <ChatIdentity variant="avatar" src={activeThread.avatarSrc} online={online} title={title} subtitle={subtitle} verified={isVerified} subtitleVariant="muted" />
235
+ <ChatIdentity
236
+ variant="avatar"
237
+ src={initialSrc}
238
+ online={online}
239
+ title={title}
240
+ subtitle={subtitle}
241
+ verified={isVerified}
242
+ subtitleVariant="muted"
243
+ />
215
244
  ) : (
216
- <ChatIdentity variant="initial" initial={initial} bg={avatarBg} online={online} title={title} subtitle={subtitle} verified={isVerified} subtitleVariant="muted" />
245
+ <ChatIdentity
246
+ variant="initial"
247
+ initial={initial}
248
+ bg={avatarBg}
249
+ online={online}
250
+ title={title}
251
+ subtitle={subtitle}
252
+ verified={isVerified}
253
+ subtitleVariant="muted"
254
+ />
217
255
  )
218
256
  }
219
257
  right={
220
258
  uiCallbacks?.renderKebabMenu?.({
221
259
  pinned: Boolean(activeThread?.pinned),
222
- onPinToggle: () => { if (activeId) adapter.threads.pin(activeId, !activeThread?.pinned); },
260
+ onPinToggle: () => {
261
+ if (activeId) adapter.threads.pin(activeId, !activeThread?.pinned);
262
+ },
223
263
  onDelete: () => setShowDelete(true),
224
264
  }) ?? null
225
265
  }
@@ -261,8 +301,12 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks, theme })
261
301
  authorInitial={typeof m.author === "string" ? m.author : "U"}
262
302
  avatarBg={avatarBg}
263
303
  text={m.text ?? m.content}
264
- businessCard={m.businessCard as Parameters<typeof ChatMessageItem>[0]["businessCard"]}
265
- addressCard={m.addressCard as Parameters<typeof ChatMessageItem>[0]["addressCard"]}
304
+ businessCard={
305
+ m.businessCard as Parameters<typeof ChatMessageItem>[0]["businessCard"]
306
+ }
307
+ addressCard={
308
+ m.addressCard as Parameters<typeof ChatMessageItem>[0]["addressCard"]
309
+ }
266
310
  images={m.images}
267
311
  files={m.files}
268
312
  audio={m.audio}
@@ -297,19 +341,23 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks, theme })
297
341
  </div>
298
342
  </div>
299
343
 
300
- {/* ════ RIGHT — thread list ════ */}
301
- <div className="flex h-full min-h-0 flex-col">
302
- <div className="shrink-0">
303
- <ChatListHeader onClose={close} onSearchChange={(val) => setSearchQuery(val)} />
304
- </div>
305
- <div className="flex-1 min-h-0 overflow-y-auto custom-scroll">
344
+ {/* ════ RIGHT — thread list (exact marketplace: h-full min-h-0 only) ════ */}
345
+ <div className="h-full min-h-0">
346
+ <ChatListHeader onClose={close} onSearchChange={(val) => setSearchQuery(val)} />
347
+ <div className="h-full overflow-y-auto custom-scroll py-0">
306
348
  {filteredThreads.map((t) => {
307
349
  const status: ChatThreadStatus =
308
- t.status ?? (t.unread && t.unread > 0 ? { kind: "new", count: t.unread } : { kind: "seen" });
350
+ t.status ??
351
+ (t.unread && t.unread > 0
352
+ ? { kind: "new", count: t.unread }
353
+ : { kind: "seen" });
309
354
  return (
310
355
  <ChatThreadItem
311
356
  key={t.id}
312
- onClick={() => { setReplyTo(undefined); selectThread(t.id); }}
357
+ onClick={() => {
358
+ setReplyTo(undefined);
359
+ selectThread(t.id);
360
+ }}
313
361
  active={t.id === activeId}
314
362
  pinned={Boolean(t.pinned)}
315
363
  online={t.online}
@@ -328,7 +376,11 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks, theme })
328
376
  </div>
329
377
 
330
378
  {/* Modals */}
331
- <ChatConfirmModal open={showDelete} onClose={() => setShowDelete(false)} onConfirm={handleConfirmDelete} />
379
+ <ChatConfirmModal
380
+ open={showDelete}
381
+ onClose={() => setShowDelete(false)}
382
+ onConfirm={handleConfirmDelete}
383
+ />
332
384
  <ChatImagePreviewModal isOpen={isGalleryOpen} onClose={closeGallery} />
333
385
  </div>
334
386
  </motion.div>
@@ -1,4 +1,5 @@
1
1
  "use client";
2
+ import clsx from "clsx";
2
3
  import React from "react";
3
4
 
4
5
  type Props = {
@@ -11,9 +12,11 @@ type Props = {
11
12
  export default function ChatHeader({ left, right, below, className }: Props) {
12
13
  return (
13
14
  <div>
14
- <div className={`border-b border-[#e1e1e1] h-[64px] flex items-start justify-between px-4 pt-2.5${className ? ` ${className}` : ""}`}>
15
- <div className="flex items-start gap-3">{left}</div>
16
- {right}
15
+ <div className={clsx("border-b border-[#ededed] h-[64px]", className)}>
16
+ <div className="flex items-center justify-between px-4 pt-2.5">
17
+ <div className="flex items-center gap-3">{left}</div>
18
+ {right}
19
+ </div>
17
20
  </div>
18
21
  {below && <>{below}</>}
19
22
  </div>
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import clsx from "clsx";
3
4
  import type { Variants } from "framer-motion";
4
5
  import { AnimatePresence, motion } from "framer-motion";
5
6
  import React from "react";
@@ -9,22 +10,34 @@ type Props = {
9
10
  className?: string;
10
11
  onClose?: () => void;
11
12
  onSearchChange?: (value: string) => void;
13
+ hideSearch?: boolean;
12
14
  };
13
15
 
14
- const ChatListHeader: React.FC<Props> = ({ className, onClose, onSearchChange }) => {
16
+ const ChatListHeader: React.FC<Props> = ({
17
+ className,
18
+ onClose,
19
+ onSearchChange,
20
+ hideSearch = false,
21
+ }) => {
15
22
  const [searching, setSearching] = React.useState(false);
16
23
  const [q, setQ] = React.useState("");
17
24
  const inputRef = React.useRef<HTMLInputElement>(null);
18
25
 
19
26
  React.useEffect(() => {
20
27
  const timer = searching
21
- ? setTimeout(() => { inputRef.current?.focus(); }, 220)
28
+ ? setTimeout(() => {
29
+ inputRef.current?.focus();
30
+ }, 220)
22
31
  : undefined;
23
- return () => { clearTimeout(timer); };
32
+ return () => {
33
+ clearTimeout(timer);
34
+ };
24
35
  }, [searching]);
25
36
 
26
37
  React.useEffect(() => {
27
- if (!searching) return;
38
+ if (!searching) {
39
+ return;
40
+ }
28
41
  const onKey = (e: KeyboardEvent) => {
29
42
  if (e.key === "Escape") {
30
43
  setSearching(false);
@@ -33,18 +46,20 @@ const ChatListHeader: React.FC<Props> = ({ className, onClose, onSearchChange })
33
46
  }
34
47
  };
35
48
  window.addEventListener("keydown", onKey);
36
- return () => { window.removeEventListener("keydown", onKey); };
49
+ return () => {
50
+ window.removeEventListener("keydown", onKey);
51
+ };
37
52
  }, [searching, onSearchChange]);
38
53
 
39
54
  const variants: Variants = {
40
55
  inFromRight: { opacity: 1, x: 0, transition: { duration: 0.18, ease: [0.16, 1, 0.3, 1] } },
41
- outToLeft: { opacity: 0, x: -24, transition: { duration: 0.16, ease: [0.4, 0, 1, 1] } },
42
- inFromLeft: { opacity: 1, x: 0, transition: { duration: 0.18, ease: [0.16, 1, 0.3, 1] } },
43
- outToRight: { opacity: 0, x: 24, transition: { duration: 0.16, ease: [0.4, 0, 1, 1] } },
56
+ outToLeft: { opacity: 0, x: -24, transition: { duration: 0.16, ease: [0.4, 0, 1, 1] } },
57
+ inFromLeft: { opacity: 1, x: 0, transition: { duration: 0.18, ease: [0.16, 1, 0.3, 1] } },
58
+ outToRight: { opacity: 0, x: 24, transition: { duration: 0.16, ease: [0.4, 0, 1, 1] } },
44
59
  };
45
60
 
46
61
  return (
47
- <div className={`h-[64px] border-b border-[#ededed]${className ? ` ${className}` : ""}`}>
62
+ <div className={clsx("h-[64px] border-b border-[#ededed]", className)}>
48
63
  <div className="flex h-full items-center px-[20px]">
49
64
  <AnimatePresence initial={false} mode="wait">
50
65
  {!searching ? (
@@ -64,13 +79,16 @@ const ChatListHeader: React.FC<Props> = ({ className, onClose, onSearchChange })
64
79
  </div>
65
80
 
66
81
  <div className="flex items-center gap-2">
67
- <button
68
- title="Search"
69
- onClick={() => setSearching(true)}
70
- className="h-9 w-9 place-items-center rounded-full hover:bg-black/5 flex items-center justify-center cursor-pointer border-none bg-transparent"
71
- >
72
- <ChatSearchIcon className="w-5 h-5" />
73
- </button>
82
+ {!hideSearch && (
83
+ <button
84
+ title="Search"
85
+ onClick={() => setSearching(true)}
86
+ className="h-9 w-9 place-items-center rounded-full hover:bg-black/5 flex items-center justify-center cursor-pointer border-none bg-transparent"
87
+ >
88
+ <ChatSearchIcon className="w-5 h-5" />
89
+ </button>
90
+ )}
91
+
74
92
  <button
75
93
  title="Close"
76
94
  onClick={onClose}
@@ -95,7 +113,7 @@ const ChatListHeader: React.FC<Props> = ({ className, onClose, onSearchChange })
95
113
  <span className="mr-2 grid h-6 w-6 shrink-0 place-items-center text-[#929292]">
96
114
  <ChatSearchIcon className="w-5 h-5" />
97
115
  </span>
98
- <span className="mr-2 h-6 w-px shrink-0 bg-[#e1e1e1]" />
116
+
99
117
  <input
100
118
  ref={inputRef}
101
119
  value={q}
@@ -108,13 +126,19 @@ const ChatListHeader: React.FC<Props> = ({ className, onClose, onSearchChange })
108
126
  />
109
127
  </div>
110
128
 
111
- <button
112
- title="Close search"
113
- onClick={() => { setSearching(false); setQ(""); onSearchChange?.(""); }}
114
- className="grid h-8 w-8 place-items-center rounded-full text-xl hover:bg-black/5 cursor-pointer border-none bg-transparent"
115
- >
116
- <ChatXIcon className="w-5 h-5" />
117
- </button>
129
+ <div>
130
+ <button
131
+ title="Close search"
132
+ onClick={() => {
133
+ setSearching(false);
134
+ setQ("");
135
+ onSearchChange?.("");
136
+ }}
137
+ className="grid h-8 w-8 place-items-center rounded-full text-xl hover:bg-black/5 cursor-pointer border-none bg-transparent"
138
+ >
139
+ <ChatXIcon className="w-5 h-5" />
140
+ </button>
141
+ </div>
118
142
  </div>
119
143
  </div>
120
144
  </motion.div>
@@ -1,4 +1,6 @@
1
+ // components/ui/chat/ChatScroll.tsx
1
2
  "use client";
3
+ import clsx from "clsx";
2
4
  import React from "react";
3
5
 
4
6
  type Props = {
@@ -6,47 +8,52 @@ type Props = {
6
8
  children: React.ReactNode;
7
9
  className?: string;
8
10
  style?: React.CSSProperties;
11
+ /** set true if you want short threads anchored at the bottom */
9
12
  bottomAlignWhenShort?: boolean;
13
+ /** when this value changes, we auto-scroll to the bottom */
10
14
  scrollKey?: string | number;
11
15
  };
12
16
 
13
- const ChatScroll: React.FC<Props> = ({
14
- top,
15
- children,
16
- className,
17
- style,
18
- bottomAlignWhenShort = false,
19
- scrollKey,
20
- }) => {
17
+ const ChatScroll = React.forwardRef<HTMLDivElement, Props>(function ChatScroll(
18
+ { top, children, className, bottomAlignWhenShort = false, scrollKey },
19
+ _,
20
+ ) {
21
21
  const ref = React.useRef<HTMLDivElement>(null);
22
22
 
23
23
  const scrollToBottom = React.useCallback(() => {
24
24
  const el = ref.current;
25
- if (!el) return;
25
+ if (!el) {
26
+ return;
27
+ }
26
28
  el.scrollTop = el.scrollHeight;
27
29
  }, []);
28
30
 
29
- React.useEffect(() => {
31
+ // On mount & when scrollKey changes — useLayoutEffect guarantees synchronous scroll before paint!
32
+ React.useLayoutEffect(() => {
30
33
  scrollToBottom();
31
- const id = window.setTimeout(scrollToBottom, 0);
32
- return () => window.clearTimeout(id);
33
34
  }, [scrollKey, scrollToBottom]);
34
35
 
35
36
  return (
36
37
  <div
37
38
  ref={ref}
38
39
  data-chat-scroll
39
- className={`h-full min-h-0 overflow-y-auto bg-white p-4 custom-scroll-hidden${className ? ` ${className}` : ""}`}
40
- style={style}
40
+ className={clsx(
41
+ "h-full min-h-0 overflow-y-auto bg-white p-4 custom-scroll-hidden",
42
+ className,
43
+ )}
41
44
  >
45
+ {/* This wrapper ensures content is at least as tall as the scroll area */}
42
46
  <div
43
- className={`min-h-full flex flex-col${bottomAlignWhenShort ? " justify-end" : " justify-start"}`}
47
+ className={clsx(
48
+ "min-h-full flex flex-col",
49
+ bottomAlignWhenShort ? "justify-end" : "justify-between",
50
+ )}
44
51
  >
45
52
  {top}
46
53
  {children}
47
54
  </div>
48
55
  </div>
49
56
  );
50
- };
57
+ });
51
58
 
52
59
  export default ChatScroll;
@@ -16,14 +16,13 @@ type Props = {
16
16
  verified?: boolean;
17
17
 
18
18
  title: string;
19
- preview: string; // last message snippet
20
- time: string; // "29 Jul 2025 16:51"
19
+ preview: string;
20
+ time: string;
21
21
  status: ChatThreadStatus;
22
22
 
23
- avatarText: string; // e.g. "A"
24
- avatarSrc?: string; // e.g. "A"
25
- _size?: number;
26
- avatarBg?: string; // default soft peach
23
+ avatarText: string;
24
+ avatarSrc?: string;
25
+ avatarBg?: string;
27
26
  className?: string;
28
27
  onClick?: () => void;
29
28
  };
@@ -39,28 +38,22 @@ const ChatThreadItem: React.FC<Props> = ({
39
38
  status,
40
39
  avatarText,
41
40
  avatarSrc,
42
- _size = 46,
43
41
  avatarBg = "#FFF1EC",
44
42
  className,
45
43
  onClick,
46
44
  }) => {
47
- const count = status.kind === "new" ? String(Math.max(0, status.count)).padStart(2, "0") : "";
48
-
49
45
  const statusEl = (() => {
50
46
  switch (status.kind) {
51
47
  case "seen":
52
48
  return <span className="text-[#0D5EA8]">Seen</span>;
53
-
54
49
  case "delivered":
55
50
  return <span className="text-[#B7B7B7]">Delivered</span>;
56
-
57
51
  case "new":
58
52
  return (
59
53
  <span className="text-[#E63946]">
60
- {count} New
54
+ {String(Math.max(0, status.count)).padStart(2, "0")} New
61
55
  </span>
62
56
  );
63
-
64
57
  default:
65
58
  return null;
66
59
  }
@@ -81,10 +74,10 @@ const ChatThreadItem: React.FC<Props> = ({
81
74
  )}
82
75
 
83
76
  <div className="flex items-start gap-3 border-b border-[#f8f8f8] pb-2">
84
- {/* Avatar + online */}
77
+ {/* Avatar + online dot */}
85
78
  <div className="relative mt-[2px]">
86
79
  {avatarSrc ? (
87
- <div className="grid h-9 w-9 place-items-center rounded-xs font-semibold text-2xl border border-[#f1f1f1] relative overflow-hidden">
80
+ <div className="grid h-9 w-9 place-items-center rounded-xs font-semibold text-2xl border border-[#f1f1f1]">
88
81
  <img
89
82
  src={avatarSrc}
90
83
  alt={title}