@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
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "@banbox/chat",
3
+ "version": "1.0.0",
4
+ "description": "Banbox Chat UI components — reusable across all Banbox React/Next.js projects",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "private": false,
8
+
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/index.d.cts",
17
+ "default": "./dist/index.cjs"
18
+ }
19
+ }
20
+ },
21
+
22
+ "main": "./dist/index.cjs",
23
+ "module": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+
26
+ "sideEffects": false,
27
+
28
+ "files": [
29
+ "dist",
30
+ "src",
31
+ "README.md"
32
+ ],
33
+
34
+ "scripts": {
35
+ "build": "tsup",
36
+ "build:watch": "tsup --watch",
37
+ "dev": "tsup --watch",
38
+ "typecheck": "tsc --noEmit",
39
+ "prepublishOnly": "npm run typecheck && npm run build"
40
+ },
41
+
42
+ "publishConfig": {
43
+ "registry": "https://registry.npmjs.org",
44
+ "access": "public"
45
+ },
46
+
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/OCEANGET/banbox-chat.git"
50
+ },
51
+
52
+ "bugs": {
53
+ "url": "https://github.com/OCEANGET/banbox-chat/issues"
54
+ },
55
+
56
+ "homepage": "https://github.com/OCEANGET/banbox-chat#readme",
57
+
58
+ "keywords": ["react", "nextjs", "chat", "ui", "banbox", "typescript"],
59
+
60
+ "peerDependencies": {
61
+ "react": ">=18",
62
+ "react-dom": ">=18",
63
+ "framer-motion": ">=10"
64
+ },
65
+
66
+ "peerDependenciesMeta": {
67
+ "framer-motion": {
68
+ "optional": false
69
+ }
70
+ },
71
+
72
+ "dependencies": {
73
+ "clsx": "^2.1.1",
74
+ "tailwind-merge": "^3.6.0"
75
+ },
76
+
77
+ "devDependencies": {
78
+ "@types/react": "^19.2.16",
79
+ "@types/react-dom": "^19.2.3",
80
+ "tsup": "^8.5.0",
81
+ "typescript": "~6.0.3"
82
+ }
83
+ }
@@ -0,0 +1,146 @@
1
+ // @banbox/chat — Adapter Interfaces
2
+ // ─────────────────────────────────────────────────────────────────────────────
3
+ // This file defines the contract between the chat package and the host app.
4
+ //
5
+ // The host app implements ChatAdapter with its own data source
6
+ // (demo data, REST API, WebSocket, etc.) and passes it to <ChatRoot />.
7
+ //
8
+ // To switch from demo → real API:
9
+ // const adapter = createApiAdapter({ baseUrl: "/api", token });
10
+ // <ChatRoot adapter={adapter} ... />
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+
13
+ import type React from "react";
14
+ import type { Thread, Message, SendPayload, Reference } from "../types";
15
+
16
+ /** Options for the kebab menu (⋮) rendered by the host app */
17
+ export type KebabMenuOpts = {
18
+ pinned: boolean;
19
+ onPinToggle: () => void;
20
+ onDelete: () => void;
21
+ };
22
+
23
+ /** Options for a toast notification triggered by the package */
24
+ export type ToastOpts = {
25
+ type?: "success" | "error" | "info" | "warning";
26
+ title: string;
27
+ message?: string;
28
+ };
29
+
30
+ /**
31
+ * The unified data adapter.
32
+ *
33
+ * Implement this interface in your host app and pass it to <ChatRoot adapter={...} />.
34
+ *
35
+ * ### Demo implementation (local state):
36
+ * ```ts
37
+ * const adapter: ChatAdapter = {
38
+ * threads: { list: getThreads, subscribe: subscribeThreads, pin, delete: deleteThread },
39
+ * messages: { list: getMessages, send: handleSend },
40
+ * };
41
+ * ```
42
+ *
43
+ * ### Real API implementation:
44
+ * ```ts
45
+ * const adapter: ChatAdapter = {
46
+ * threads: {
47
+ * list: (ref) => cachedThreads, // from polling/socket
48
+ * subscribe: (cb) => socket.on("threads", cb),
49
+ * pin: (id, p) => api.patch(`/threads/${id}`, { pinned: p }),
50
+ * delete: (id) => api.delete(`/threads/${id}`),
51
+ * },
52
+ * messages: {
53
+ * list: (tid) => cachedMessages[tid] ?? [],
54
+ * subscribe: (tid, cb) => socket.on(`messages:${tid}`, cb),
55
+ * send: (tid, payload) => api.post(`/threads/${tid}/messages`, payload),
56
+ * },
57
+ * };
58
+ * ```
59
+ */
60
+ export interface ChatAdapter {
61
+ threads: {
62
+ /**
63
+ * Returns the current list of threads.
64
+ * Called synchronously; keep a local cache if using async data.
65
+ */
66
+ list: (reference?: Reference) => Thread[];
67
+
68
+ /**
69
+ * Subscribe to thread list changes.
70
+ * Call the callback whenever threads change (new msg, pin, delete, etc.)
71
+ * Returns an unsubscribe function.
72
+ *
73
+ * For WebSocket: `(cb) => socket.on("threads:update", cb)`
74
+ */
75
+ subscribe: (cb: () => void) => () => void;
76
+
77
+ /**
78
+ * Pin or unpin a thread.
79
+ * @param id Thread ID
80
+ * @param pinned True to pin, false to unpin
81
+ */
82
+ pin: (id: string, pinned: boolean) => Promise<void> | void;
83
+
84
+ /**
85
+ * Delete a thread permanently.
86
+ */
87
+ delete: (id: string) => Promise<void> | void;
88
+
89
+ /**
90
+ * Mark a thread as read (optional).
91
+ * Called when the user opens a thread.
92
+ */
93
+ markRead?: (id: string) => Promise<void> | void;
94
+ };
95
+
96
+ messages: {
97
+ /**
98
+ * Returns the current messages for a thread.
99
+ * Called synchronously; keep a local cache if using async data.
100
+ */
101
+ list: (threadId: string) => Message[];
102
+
103
+ /**
104
+ * Subscribe to message changes for a specific thread.
105
+ * Returns an unsubscribe function.
106
+ *
107
+ * For WebSocket: `(tid, cb) => socket.on(`messages:${tid}`, cb)`
108
+ * For demo: optional (UI re-renders via onAfterSend)
109
+ */
110
+ subscribe?: (threadId: string, cb: () => void) => () => void;
111
+
112
+ /**
113
+ * Send a message.
114
+ * The payload is a discriminated union covering all message types.
115
+ * Mutate your local cache + emit to server here.
116
+ */
117
+ send: (threadId: string, payload: SendPayload) => Promise<void> | void;
118
+ };
119
+ }
120
+
121
+ /**
122
+ * UI-level callbacks — side-effects that the host app controls.
123
+ *
124
+ * These are NOT data operations; they are UI actions that the package
125
+ * delegates back to the host app (so the package stays decoupled from
126
+ * the host app's routing, notification, and menu systems).
127
+ */
128
+ export interface ChatUICallbacks {
129
+ /**
130
+ * Show a toast notification.
131
+ * Use your app's existing toast system (react-toastify, sonner, etc.)
132
+ */
133
+ showToast?: (opts: ToastOpts) => void;
134
+
135
+ /**
136
+ * Navigate to an order or inquiry page.
137
+ * Called when the user clicks "View Order" / "View Inquiry" in the chat.
138
+ */
139
+ onNavigate?: (route: { type: "order" | "inquiry"; id: string }) => void;
140
+
141
+ /**
142
+ * Render the kebab (⋮) menu in the chat header.
143
+ * Return your app's custom dropdown component here.
144
+ */
145
+ renderKebabMenu?: (opts: KebabMenuOpts) => React.ReactNode;
146
+ }
@@ -0,0 +1,194 @@
1
+ // components/modals/ChatImagePreviewModal.tsx
2
+ "use client";
3
+
4
+ import { useState, useEffect, useCallback } from "react";
5
+ import type { FC, JSX } from "react";
6
+ import { AnimatePresence, motion } from "framer-motion";
7
+ import { useGallery } from "../contexts/GalleryContext";
8
+ import Portal from "../ui/Portal";
9
+
10
+ /* =======================
11
+ Types
12
+ ======================= */
13
+ interface ChatImagePreviewModalProps {
14
+ isOpen: boolean;
15
+ onClose: () => void;
16
+ }
17
+
18
+ /* =======================
19
+ Slide direction variants
20
+ ======================= */
21
+ const slideVariants = {
22
+ enter: (dir: number) => ({
23
+ x: dir > 0 ? 300 : -300,
24
+ opacity: 0,
25
+ }),
26
+ center: { x: 0, opacity: 1 },
27
+ exit: (dir: number) => ({
28
+ x: dir > 0 ? -300 : 300,
29
+ opacity: 0,
30
+ }),
31
+ };
32
+
33
+ /* =======================
34
+ Component
35
+ ======================= */
36
+ const ChatImagePreviewModal: FC<ChatImagePreviewModalProps> = ({
37
+ isOpen,
38
+ onClose,
39
+ }): JSX.Element => {
40
+ const { images, currentIndex, setCurrentIndex } = useGallery();
41
+
42
+ const current = currentIndex ?? 0;
43
+ const total = images?.length ?? 0;
44
+ const hasPrev = current > 0;
45
+ const hasNext = current < total - 1;
46
+
47
+ /* Track slide direction for animation */
48
+ const [direction, setDirection] = useState(0);
49
+
50
+ const goPrev = useCallback(() => {
51
+ if (current > 0) {
52
+ setDirection(-1);
53
+ setCurrentIndex(current - 1);
54
+ }
55
+ }, [current, setCurrentIndex]);
56
+
57
+ const goNext = useCallback(() => {
58
+ if (images && current < images.length - 1) {
59
+ setDirection(1);
60
+ setCurrentIndex(current + 1);
61
+ }
62
+ }, [current, images, setCurrentIndex]);
63
+
64
+ /* Keyboard navigation */
65
+ useEffect(() => {
66
+ if (!isOpen) return;
67
+ const handleKey = (e: KeyboardEvent) => {
68
+ if (e.key === "Escape") onClose();
69
+ if (e.key === "ArrowLeft") goPrev();
70
+ if (e.key === "ArrowRight") goNext();
71
+ };
72
+ window.addEventListener("keydown", handleKey);
73
+ return () => window.removeEventListener("keydown", handleKey);
74
+ }, [isOpen, onClose, goPrev, goNext]);
75
+
76
+ const currentImage = images?.[current];
77
+
78
+ return (
79
+ <Portal>
80
+ <AnimatePresence>
81
+ {isOpen && total > 0 && (
82
+ <motion.div
83
+ className="fixed inset-0 z-999 flex items-center justify-center"
84
+ initial={{ opacity: 0, backgroundColor: "rgba(0,0,0,0)" }}
85
+ animate={{ opacity: 1, backgroundColor: "rgba(0,0,0,0.55)" }}
86
+ exit={{ opacity: 0, backgroundColor: "rgba(0,0,0,0)" }}
87
+ transition={{ duration: 0.25, ease: "easeInOut" }}
88
+ onClick={onClose}
89
+ >
90
+ {/* Content wrapper — prevents overlay click-through */}
91
+ <motion.div
92
+ className="relative flex items-center justify-center"
93
+ initial={{ opacity: 0, scale: 0.92 }}
94
+ animate={{ opacity: 1, scale: 1 }}
95
+ exit={{ opacity: 0, scale: 0.92 }}
96
+ transition={{ duration: 0.25, ease: "easeInOut" }}
97
+ onClick={(e) => e.stopPropagation()}
98
+ >
99
+ {/* Fixed 850×850 frame */}
100
+ <div
101
+ className="relative flex items-center justify-center overflow-hidden rounded-[6px] bg-white"
102
+ style={{ width: 850, height: 850 }}
103
+ >
104
+ {/* Sliding image */}
105
+ <AnimatePresence mode="wait" initial={false} custom={direction}>
106
+ <motion.img
107
+ key={current}
108
+ custom={direction}
109
+ variants={slideVariants}
110
+ initial="enter"
111
+ animate="center"
112
+ exit="exit"
113
+ transition={{ duration: 0.3, ease: "easeInOut" }}
114
+ src={currentImage?.url}
115
+ alt={currentImage?.altText ?? `Image ${current + 1}`}
116
+ className="w-full rounded-[6px] object-contain"
117
+ draggable={false}
118
+ />
119
+ </AnimatePresence>
120
+
121
+ {/* Left arrow — always visible, disabled when no prev */}
122
+ <button
123
+ type="button"
124
+ onClick={goPrev}
125
+ disabled={!hasPrev}
126
+ className={`absolute left-0 top-1/2 -translate-y-1/2 flex h-[100px] items-center rounded-tr-[3px] rounded-br-[3px] p-[7px] backdrop-blur-[2px] shadow-[3px_0px_6px_0px_rgba(0,0,0,0.1)] transition-opacity ${hasPrev ? "cursor-pointer opacity-100" : "cursor-default opacity-30"}`}
127
+ style={{ backgroundColor: "rgba(255,255,255,0.7)" }}
128
+ aria-label="Previous image"
129
+ >
130
+ <svg width="42" height="42" viewBox="0 0 24 24" fill="none">
131
+ <path
132
+ d="M15 18L9 12L15 6"
133
+ stroke="#2C2C2C"
134
+ strokeWidth="2"
135
+ strokeLinecap="round"
136
+ strokeLinejoin="round"
137
+ />
138
+ </svg>
139
+ </button>
140
+
141
+ {/* Right arrow — always visible, disabled when no next */}
142
+ <button
143
+ type="button"
144
+ onClick={goNext}
145
+ disabled={!hasNext}
146
+ className={`absolute right-0 top-1/2 -translate-y-1/2 flex h-[100px] items-center rounded-tl-[3px] rounded-bl-[3px] p-[7px] backdrop-blur-[2px] shadow-[-3px_0px_6px_0px_rgba(0,0,0,0.1)] transition-opacity ${hasNext ? "cursor-pointer opacity-100" : "cursor-default opacity-30"}`}
147
+ style={{ backgroundColor: "rgba(255,255,255,0.7)" }}
148
+ aria-label="Next image"
149
+ >
150
+ <svg width="42" height="42" viewBox="0 0 24 24" fill="none">
151
+ <path
152
+ d="M9 18L15 12L9 6"
153
+ stroke="#2C2C2C"
154
+ strokeWidth="2"
155
+ strokeLinecap="round"
156
+ strokeLinejoin="round"
157
+ />
158
+ </svg>
159
+ </button>
160
+ </div>
161
+
162
+ {/* Close button — top-right outside the image */}
163
+ <button
164
+ type="button"
165
+ onClick={onClose}
166
+ className="absolute top-[-4px] right-[-63px] flex h-[36px] w-[36px] cursor-pointer items-center justify-center rounded-full bg-white shadow-[0_2px_8px_rgba(0,0,0,0.15)] transition-colors hover:bg-[#f1f1f1]"
167
+ aria-label="Close preview"
168
+ >
169
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
170
+ <path
171
+ d="M18 6L6 18"
172
+ stroke="black"
173
+ strokeWidth="2"
174
+ strokeLinecap="round"
175
+ strokeLinejoin="round"
176
+ />
177
+ <path
178
+ d="M6 6L18 18"
179
+ stroke="black"
180
+ strokeWidth="2"
181
+ strokeLinecap="round"
182
+ strokeLinejoin="round"
183
+ />
184
+ </svg>
185
+ </button>
186
+ </motion.div>
187
+ </motion.div>
188
+ )}
189
+ </AnimatePresence>
190
+ </Portal>
191
+ );
192
+ };
193
+
194
+ export default ChatImagePreviewModal;
@@ -0,0 +1,67 @@
1
+ // chat/ChatRoot.tsx
2
+ "use client";
3
+ import { AnimatePresence } from "framer-motion";
4
+ import { createPortal } from "react-dom";
5
+ import { useChatUI } from "../contexts/ChatUIContext";
6
+ import { useDisableBodyScroll } from "../hooks/useDisableBodyScroll";
7
+ import { GalleryProvider } from "../contexts/GalleryProvider";
8
+ import type { ChatAdapter, ChatUICallbacks } from "../adapter/types";
9
+ import InboxPopup from "./InboxPopup";
10
+ import SinglePopup from "./SinglePopup";
11
+
12
+ export type ChatRootProps = {
13
+ /**
14
+ * 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
+ */
21
+ adapter: ChatAdapter;
22
+
23
+ /**
24
+ * Optional UI callbacks — controls toast notifications, navigation,
25
+ * 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
+ */
30
+ uiCallbacks?: ChatUICallbacks;
31
+ };
32
+
33
+ export default function ChatRoot({ adapter, uiCallbacks }: ChatRootProps) {
34
+ const { isOpen, variant } = useChatUI();
35
+
36
+ // Lock page scroll whenever the chat is open
37
+ useDisableBodyScroll(isOpen);
38
+
39
+ if (typeof window === "undefined") {
40
+ return null;
41
+ }
42
+
43
+ return createPortal(
44
+ // GalleryProvider is scoped to the chat only.
45
+ // It is completely separate from the host app's own gallery context.
46
+ <GalleryProvider>
47
+ <AnimatePresence mode="wait">
48
+ {isOpen && (
49
+ variant === "inbox" ? (
50
+ <InboxPopup
51
+ key="inbox"
52
+ adapter={adapter}
53
+ uiCallbacks={uiCallbacks}
54
+ />
55
+ ) : (
56
+ <SinglePopup
57
+ key="single"
58
+ adapter={adapter}
59
+ uiCallbacks={uiCallbacks}
60
+ />
61
+ )
62
+ )}
63
+ </AnimatePresence>
64
+ </GalleryProvider>,
65
+ document.body,
66
+ );
67
+ }