@banbox/chat 1.0.4 → 1.0.6

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.4",
3
+ "version": "1.0.6",
4
4
  "description": "Banbox Chat UI components — reusable across all Banbox React/Next.js projects",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -15,12 +15,15 @@
15
15
  "types": "./dist/index.d.cts",
16
16
  "default": "./dist/index.cjs"
17
17
  }
18
- }
18
+ },
19
+ "./dist/index.css": "./dist/index.css"
19
20
  },
20
21
  "main": "./dist/index.cjs",
21
22
  "module": "./dist/index.js",
22
23
  "types": "./dist/index.d.ts",
23
- "sideEffects": false,
24
+ "sideEffects": [
25
+ "**/*.css"
26
+ ],
24
27
  "files": [
25
28
  "dist",
26
29
  "src",
@@ -75,8 +78,11 @@
75
78
  "tailwind-merge": "^3.6.0"
76
79
  },
77
80
  "devDependencies": {
81
+ "@tailwindcss/postcss": "^4.3.0",
78
82
  "@types/react": "^19.2.16",
79
83
  "@types/react-dom": "^19.2.3",
84
+ "postcss": "^8.5.15",
85
+ "tailwindcss": "^4.3.0",
80
86
  "tsup": "^8.5.0",
81
87
  "typescript": "~6.0.3"
82
88
  }
@@ -8,29 +8,33 @@ import { GalleryProvider } from "../contexts/GalleryProvider";
8
8
  import type { ChatAdapter, ChatUICallbacks } from "../adapter/types";
9
9
  import InboxPopup from "./InboxPopup";
10
10
  import SinglePopup from "./SinglePopup";
11
+ import type { ChatTheme } from "./InboxPopup";
11
12
 
12
13
  export type ChatRootProps = {
13
14
  /**
14
15
  * The unified data adapter — provides all threads, messages, and send logic.
15
- *
16
- * Implement this in your host app:
17
- * ```ts
18
- * const adapter = createDemoChatAdapter(); // or createApiChatAdapter(...)
19
- * ```
20
16
  */
21
17
  adapter: ChatAdapter;
22
18
 
23
19
  /**
24
20
  * Optional UI callbacks — controls toast notifications, navigation,
25
21
  * and the kebab (⋮) menu renderer.
26
- *
27
- * These delegate UI side-effects back to the host app so the package
28
- * stays decoupled from the host's routing and notification systems.
29
22
  */
30
23
  uiCallbacks?: ChatUICallbacks;
24
+
25
+ /**
26
+ * Visual theme:
27
+ * - "marketplace" (default) — orange primary (#ff5300)
28
+ * - "admin" — black primary (#1a1a1a)
29
+ * - custom object — { primary, primaryActive, surfaceLow }
30
+ *
31
+ * @example
32
+ * <ChatRoot adapter={adapter} theme="admin" />
33
+ */
34
+ theme?: ChatTheme;
31
35
  };
32
36
 
33
- export default function ChatRoot({ adapter, uiCallbacks }: ChatRootProps) {
37
+ export default function ChatRoot({ adapter, uiCallbacks, theme }: ChatRootProps) {
34
38
  const { isOpen, variant } = useChatUI();
35
39
 
36
40
  // Lock page scroll whenever the chat is open
@@ -42,7 +46,6 @@ export default function ChatRoot({ adapter, uiCallbacks }: ChatRootProps) {
42
46
 
43
47
  return createPortal(
44
48
  // GalleryProvider is scoped to the chat only.
45
- // It is completely separate from the host app's own gallery context.
46
49
  <GalleryProvider>
47
50
  <AnimatePresence mode="wait">
48
51
  {isOpen && (
@@ -51,12 +54,14 @@ export default function ChatRoot({ adapter, uiCallbacks }: ChatRootProps) {
51
54
  key="inbox"
52
55
  adapter={adapter}
53
56
  uiCallbacks={uiCallbacks}
57
+ theme={theme}
54
58
  />
55
59
  ) : (
56
60
  <SinglePopup
57
61
  key="single"
58
62
  adapter={adapter}
59
63
  uiCallbacks={uiCallbacks}
64
+ theme={theme}
60
65
  />
61
66
  )
62
67
  )}
@@ -22,32 +22,47 @@ import ChatImagePreviewModal from "./ChatImagePreviewModal";
22
22
  import type { ChatAdapter, ChatUICallbacks } from "../adapter/types";
23
23
  import type { Message, MessageRef, Thread } from "../types";
24
24
 
25
- /* =======================
26
- Props
27
- ======================= */
25
+ /* ─── Types ─── */
26
+ export type ChatTheme =
27
+ | "marketplace"
28
+ | "admin"
29
+ | { primary?: string; primaryActive?: string; surfaceLow?: string };
30
+
28
31
  export type InboxPopupProps = {
29
32
  adapter: ChatAdapter;
30
33
  uiCallbacks?: ChatUICallbacks;
34
+ /** Dynamic theme: "marketplace" (orange), "admin" (black), or custom object */
35
+ theme?: ChatTheme;
31
36
  };
32
37
 
33
- /* =======================
34
- Constants
35
- ======================= */
38
+ /* ─── Helpers ─── */
36
39
  const avatarBgByInitial: Record<string, string> = {
37
- K: "#FFE7DB",
38
- A: "#FFE5DA",
39
- F: "#E8F7FF",
40
- B: "#F0EDEB",
41
- b: "#F0EDEB",
40
+ K: "#FFE7DB", A: "#FFE5DA", F: "#E8F7FF", B: "#F0EDEB", b: "#F0EDEB",
42
41
  };
43
42
 
44
43
  const GRADIENT_BORDER =
45
44
  "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
45
 
47
- /* =======================
46
+ function getThemeAttr(theme?: ChatTheme): string {
47
+ if (!theme || theme === "marketplace") return "marketplace";
48
+ if (theme === "admin") return "admin";
49
+ return "custom";
50
+ }
51
+
52
+ function getThemeVars(theme?: ChatTheme): React.CSSProperties {
53
+ if (!theme || theme === "marketplace" || theme === "admin") return {};
54
+ // Custom theme object — set CSS vars directly
55
+ const vars: Record<string, string> = {};
56
+ if (theme.primary) vars["--color-banbox-primary"] = theme.primary;
57
+ if (theme.primaryActive) vars["--color-banbox-primary-active"] = theme.primaryActive;
58
+ if (theme.surfaceLow) vars["--color-banbox-surface-container-low"] = theme.surfaceLow;
59
+ return vars as React.CSSProperties;
60
+ }
61
+
62
+ /* ══════════════════════════════════════════════════
48
63
  Component
49
- ======================= */
50
- const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
64
+ ══════════════════════════════════════════════════ */
65
+ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks, theme }) => {
51
66
  const { close, selectThread, selectedThreadId, reference } = useChatUI();
52
67
  const { isOpen: isGalleryOpen, closeGallery } = useGallery();
53
68
 
@@ -62,10 +77,7 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
62
77
  let rafId = 0;
63
78
  rafId = requestAnimationFrame(refreshThreads);
64
79
  const unsub = adapter.threads.subscribe(refreshThreads);
65
- return () => {
66
- cancelAnimationFrame(rafId);
67
- unsub();
68
- };
80
+ return () => { cancelAnimationFrame(rafId); unsub(); };
69
81
  }, [adapter, reference, refreshThreads]);
70
82
 
71
83
  /* ─── Active thread & messages ─── */
@@ -100,22 +112,13 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
100
112
  const isVerified = Boolean(activeThread?.badge);
101
113
  const avatarBg = avatarBgByInitial[initial] ?? "#FFF1EC";
102
114
 
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;
115
+ const idLabel = activeThread?.orderId ? "Order ID" : activeThread?.inquiryId ? "Inquiry ID" : undefined;
116
+ const idButtonLabel = activeThread?.orderId ? "View Order" : activeThread?.inquiryId ? "View Inquiry" : undefined;
113
117
  const idValue = activeThread?.orderId ?? activeThread?.inquiryId ?? undefined;
114
118
 
115
119
  const [showDelete, setShowDelete] = useState(false);
116
120
  const scrollKey = `${activeId}-${messages.length}-${rev}`;
117
121
 
118
- /* mark read on switch */
119
122
  const prevActiveIdRef = useRef(activeId);
120
123
  useEffect(() => {
121
124
  if (prevActiveIdRef.current !== activeId) {
@@ -124,7 +127,6 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
124
127
  }
125
128
  }, [activeId, adapter]);
126
129
 
127
- /* ─── Helpers ─── */
128
130
  const toRef = (m: Message): MessageRef => ({
129
131
  id: m.id,
130
132
  author: typeof m.author === "string" ? m.author : "U",
@@ -136,23 +138,15 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
136
138
  });
137
139
 
138
140
  const handleConfirmDelete = () => {
139
- if (!activeId) {
140
- setShowDelete(false);
141
- return;
142
- }
141
+ if (!activeId) { setShowDelete(false); return; }
143
142
  adapter.threads.delete(activeId);
144
143
  const nextId = threads.filter((t) => t.id !== activeId)[0]?.id;
145
144
  if (nextId) selectThread(nextId);
146
145
  setReplyTo(undefined);
147
146
  setShowDelete(false);
148
- uiCallbacks?.showToast?.({
149
- type: "success",
150
- title: "Chat Deleted",
151
- message: "The chat has been deleted successfully.",
152
- });
147
+ uiCallbacks?.showToast?.({ type: "success", title: "Chat Deleted", message: "The chat has been deleted successfully." });
153
148
  };
154
149
 
155
- /* ─── Filtered threads ─── */
156
150
  const filteredThreads = threads.filter((t) => {
157
151
  if (!searchQuery.trim()) return true;
158
152
  const q = searchQuery.toLowerCase();
@@ -168,30 +162,31 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
168
162
  RENDER
169
163
  ══════════════════════════════════════════════════ */
170
164
  return (
171
- <div style={{ position: "fixed", bottom: 16, right: 16, zIndex: 10002 }}>
165
+ <div className="fixed bottom-4 right-4 z-[10002]">
172
166
  {/* Backdrop */}
173
167
  <motion.button
174
168
  aria-label="Close chat"
175
169
  onClick={close}
176
- style={{ position: "fixed", inset: 0, background: "transparent", border: "none", cursor: "auto" }}
170
+ className="fixed inset-0 cursor-auto!"
171
+ style={{ background: "transparent", border: "none" }}
177
172
  initial={{ opacity: 0 }}
178
173
  animate={{ opacity: 1 }}
179
174
  exit={{ opacity: 0 }}
180
175
  transition={{ type: "tween", duration: 0.25 }}
181
176
  />
182
177
 
183
- {/* Outer gradient border wrapper */}
178
+ {/* Outer gradient border + theme root */}
184
179
  <motion.div
185
180
  role="dialog"
186
181
  aria-modal="true"
182
+ data-theme={getThemeAttr(theme)}
183
+ className="banbox-chat-root relative rounded-[20px] p-[3px]"
187
184
  style={{
188
- position: "relative",
189
185
  width: 800,
190
186
  height: 650,
191
- borderRadius: 20,
192
- padding: 3,
193
187
  boxShadow: "0px 2px 12px 0px #3B33331A",
194
188
  background: GRADIENT_BORDER,
189
+ ...getThemeVars(theme),
195
190
  }}
196
191
  initial={{ x: "110%" }}
197
192
  animate={{ x: 0 }}
@@ -200,68 +195,31 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
200
195
  >
201
196
  {/* Inner white card */}
202
197
  <div
203
- style={{
204
- position: "relative",
205
- height: "100%",
206
- width: "100%",
207
- overflow: "hidden",
208
- borderRadius: 18,
209
- backgroundColor: "#fff",
210
- overscrollBehavior: "contain",
211
- }}
198
+ className="relative h-full w-full overflow-hidden rounded-[18px] bg-white"
199
+ style={{ overscrollBehavior: "contain" }}
212
200
  >
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 }}>
201
+ <div className="pointer-events-none absolute inset-0 rounded-[14px] ring-1 ring-[#2F80ED]/40" />
202
+
203
+ {/* TWO-COLUMN GRID */}
204
+ <div className="grid h-full min-h-0 grid-cols-[1fr_310px]">
205
+
206
+ {/* ════ LEFT — chat ════ */}
207
+ <div className="flex h-full min-h-0 flex-col border-r border-[#9BBCCF]">
208
+
209
+ {/* Header */}
210
+ <div className="h-[64px] shrink-0">
234
211
  <ChatHeader
235
212
  left={
236
213
  activeThread?.avatarSrc ? (
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
- />
214
+ <ChatIdentity variant="avatar" src={activeThread.avatarSrc} online={online} title={title} subtitle={subtitle} verified={isVerified} subtitleVariant="muted" />
246
215
  ) : (
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
- />
216
+ <ChatIdentity variant="initial" initial={initial} bg={avatarBg} online={online} title={title} subtitle={subtitle} verified={isVerified} subtitleVariant="muted" />
257
217
  )
258
218
  }
259
219
  right={
260
220
  uiCallbacks?.renderKebabMenu?.({
261
221
  pinned: Boolean(activeThread?.pinned),
262
- onPinToggle: () => {
263
- if (activeId) adapter.threads.pin(activeId, !activeThread?.pinned);
264
- },
222
+ onPinToggle: () => { if (activeId) adapter.threads.pin(activeId, !activeThread?.pinned); },
265
223
  onDelete: () => setShowDelete(true),
266
224
  }) ?? null
267
225
  }
@@ -270,7 +228,7 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
270
228
 
271
229
  {/* Optional inquiry bar */}
272
230
  {idValue && (
273
- <div style={{ flexShrink: 0 }}>
231
+ <div className="shrink-0">
274
232
  <ChatInquiryBar
275
233
  id={idValue}
276
234
  label={idLabel}
@@ -283,66 +241,50 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
283
241
  </div>
284
242
  )}
285
243
 
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>
324
-
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" }} />
244
+ {/* Messages + typing */}
245
+ <div className="flex-1 min-h-0">
246
+ <div className="relative h-full min-h-0">
247
+ <ChatScroll
248
+ className="h-full pb-10"
249
+ bottomAlignWhenShort={false}
250
+ scrollKey={scrollKey}
251
+ >
252
+ {messages.map((m, idx) => {
253
+ const mine = m.author === "you";
254
+ const isLast = idx === messages.length - 1;
255
+ return (
256
+ <ChatMessageItem
257
+ key={m.id}
258
+ id={m.id}
259
+ mine={mine}
260
+ time={m.time ?? ""}
261
+ authorInitial={typeof m.author === "string" ? m.author : "U"}
262
+ avatarBg={avatarBg}
263
+ 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"]}
266
+ images={m.images}
267
+ files={m.files}
268
+ audio={m.audio}
269
+ replyTo={m.replyTo}
270
+ showStatus={isLast}
271
+ status={activeThread?.status?.kind === "seen" ? "Seen" : "Delivered"}
272
+ onReply={() => setReplyTo(toRef(m))}
273
+ initialSrc={m.avatarSrc}
274
+ />
275
+ );
276
+ })}
277
+ </ChatScroll>
278
+
279
+ {/* Typing indicator — pinned at bottom */}
280
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-center justify-start px-4 pb-2 pt-1 bg-white">
281
+ <TypingIndicator className="pointer-events-auto" />
282
+ </div>
341
283
  </div>
342
284
  </div>
343
285
 
344
- {/* Chat footer — shrinks to content height */}
345
- <div style={{ flexShrink: 0 }}>
286
+ {/* Footer */}
287
+ <div className="shrink-0">
346
288
  <ChatFooter
347
289
  key={activeId}
348
290
  replyTo={replyTo}
@@ -355,28 +297,19 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
355
297
  </div>
356
298
  </div>
357
299
 
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 }}>
300
+ {/* ════ RIGHT — thread list ════ */}
301
+ <div className="flex h-full min-h-0 flex-col">
302
+ <div className="shrink-0">
362
303
  <ChatListHeader onClose={close} onSearchChange={(val) => setSearchQuery(val)} />
363
304
  </div>
364
-
365
- {/* Scrollable thread list */}
366
- <div style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
305
+ <div className="flex-1 min-h-0 overflow-y-auto custom-scroll">
367
306
  {filteredThreads.map((t) => {
368
307
  const status: ChatThreadStatus =
369
- t.status ??
370
- (t.unread && t.unread > 0
371
- ? { kind: "new", count: t.unread }
372
- : { kind: "seen" });
308
+ t.status ?? (t.unread && t.unread > 0 ? { kind: "new", count: t.unread } : { kind: "seen" });
373
309
  return (
374
310
  <ChatThreadItem
375
311
  key={t.id}
376
- onClick={() => {
377
- setReplyTo(undefined);
378
- selectThread(t.id);
379
- }}
312
+ onClick={() => { setReplyTo(undefined); selectThread(t.id); }}
380
313
  active={t.id === activeId}
381
314
  pinned={Boolean(t.pinned)}
382
315
  online={t.online}
@@ -395,11 +328,7 @@ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
395
328
  </div>
396
329
 
397
330
  {/* Modals */}
398
- <ChatConfirmModal
399
- open={showDelete}
400
- onClose={() => setShowDelete(false)}
401
- onConfirm={handleConfirmDelete}
402
- />
331
+ <ChatConfirmModal open={showDelete} onClose={() => setShowDelete(false)} onConfirm={handleConfirmDelete} />
403
332
  <ChatImagePreviewModal isOpen={isGalleryOpen} onClose={closeGallery} />
404
333
  </div>
405
334
  </motion.div>