@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,312 @@
1
+ // chat/InboxPopup.tsx
2
+ "use client";
3
+
4
+ import { motion } from "framer-motion";
5
+ import React, { useCallback, useEffect, useState } from "react";
6
+
7
+ import ChatConfirmModal from "../modals/chat/ChatConfirmModal";
8
+ import { useChatUI } from "../contexts/ChatUIContext";
9
+ import { useGallery } from "../contexts/GalleryContext";
10
+ import ChatFooter from "../ui/chat/ChatFooter";
11
+ import ChatHeader from "../ui/chat/ChatHeader";
12
+ import ChatIdentity from "../ui/chat/ChatIdentity";
13
+ import ChatInquiryBar from "../ui/chat/ChatInquiryBar";
14
+ import ChatListHeader from "../ui/chat/ChatListHeader";
15
+ import ChatMessageItem from "../ui/chat/ChatMessageItem";
16
+ import ChatScroll from "../ui/chat/ChatScroll";
17
+ import type { ChatThreadStatus } from "../ui/chat/ChatThreadItem";
18
+ import ChatThreadItem from "../ui/chat/ChatThreadItem";
19
+ import TypingIndicator from "../ui/chat/TypingIndicator";
20
+ import ChatSpinner from "../ui/chat/ChatSpinner";
21
+ import ChatImagePreviewModal from "./ChatImagePreviewModal";
22
+
23
+ import type { Thread, Message, MessageRef } from "../types";
24
+ import type { ChatAdapter, ChatUICallbacks } from "../adapter/types";
25
+
26
+ /* =======================
27
+ Props
28
+ ======================= */
29
+ export type InboxPopupProps = {
30
+ /** The unified data adapter — provides threads, messages, and send */
31
+ adapter: ChatAdapter;
32
+ /** UI-level callbacks (toast, navigation, kebab menu) */
33
+ uiCallbacks?: ChatUICallbacks;
34
+ };
35
+
36
+ /* =======================
37
+ Constants
38
+ ======================= */
39
+ const avatarBgByInitial: Record<string, string> = {
40
+ K: "#FFE7DB", A: "#FFE5DA", F: "#E8F7FF", B: "#F0EDEB", b: "#F0EDEB",
41
+ };
42
+
43
+ /* =======================
44
+ Component
45
+ ======================= */
46
+ const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
47
+ const { close, selectThread, selectedThreadId, reference } = useChatUI();
48
+ const { isOpen: isGalleryOpen, closeGallery } = useGallery();
49
+
50
+ /* ─── Thread list ─── */
51
+ const [threads, setThreads] = useState<Thread[]>(() => adapter.threads.list(reference));
52
+ const refreshThreads = useCallback(
53
+ () => setThreads(adapter.threads.list(reference)),
54
+ [adapter, reference],
55
+ );
56
+
57
+ useEffect(() => {
58
+ // Immediate sync on mount / reference change
59
+ let rafId = 0;
60
+ rafId = requestAnimationFrame(refreshThreads);
61
+ const unsub = adapter.threads.subscribe(refreshThreads);
62
+ return () => { cancelAnimationFrame(rafId); unsub(); };
63
+ }, [adapter, reference, refreshThreads]);
64
+
65
+ /* ─── Active thread & messages ─── */
66
+ const [rev, setRev] = useState(0);
67
+ const [replyTo, setReplyTo] = useState<MessageRef | undefined>(undefined);
68
+ const [searchQuery, setSearchQuery] = useState("");
69
+
70
+ const activeId = selectedThreadId ?? threads[0]?.id;
71
+ const activeThread = threads.find((t) => t.id === activeId);
72
+
73
+ const [messages, setMessages] = useState<Message[]>(() =>
74
+ activeId ? adapter.messages.list(activeId) : [],
75
+ );
76
+
77
+ // Refresh messages when active thread changes or rev bumps
78
+ useEffect(() => {
79
+ if (activeId) setMessages(adapter.messages.list(activeId));
80
+ }, [activeId, rev, adapter]);
81
+
82
+ // Subscribe to real-time message updates for the active thread
83
+ useEffect(() => {
84
+ if (!activeId || !adapter.messages.subscribe) return;
85
+ const unsub = adapter.messages.subscribe(activeId, () => {
86
+ setMessages(adapter.messages.list(activeId));
87
+ });
88
+ return unsub;
89
+ }, [activeId, adapter]);
90
+
91
+ /* ─── Derived UI values ─── */
92
+ const initial = activeThread?.avatarText ?? "U";
93
+ const title = activeThread?.title ?? "Unknown";
94
+ const subtitle = activeThread?.subTitle ?? "";
95
+ const online = Boolean(activeThread?.online);
96
+ const isVerified = Boolean(activeThread?.badge);
97
+ const avatarBg = avatarBgByInitial[initial] ?? "#FFF1EC";
98
+
99
+ const idLabel = activeThread?.orderId ? "Order ID" : activeThread?.inquiryId ? "Inquiry ID" : undefined;
100
+ const idButtonLabel = activeThread?.orderId ? "View Order" : activeThread?.inquiryId ? "View Inquiry" : undefined;
101
+ const idValue = activeThread?.orderId ?? activeThread?.inquiryId ?? undefined;
102
+
103
+ /* ─── Loading state ─── */
104
+ const [showDelete, setShowDelete] = useState(false);
105
+ const [isLoading, setIsLoading] = useState(false);
106
+ const scrollKey = `${activeId}-${messages.length}-${rev}`;
107
+
108
+ const prevActiveIdRef = React.useRef(activeId);
109
+ useEffect(() => {
110
+ if (prevActiveIdRef.current !== activeId) {
111
+ prevActiveIdRef.current = activeId;
112
+ setIsLoading(true);
113
+ const t = setTimeout(() => setIsLoading(false), 300);
114
+ // Mark thread as read when switching to it
115
+ if (activeId) adapter.threads.markRead?.(activeId);
116
+ return () => clearTimeout(t);
117
+ }
118
+ }, [activeId, adapter]);
119
+
120
+ /* ─── Reply helper ─── */
121
+ const toRef = (m: Message): MessageRef => ({
122
+ id: m.id,
123
+ author: typeof m.author === "string" ? m.author : "U",
124
+ time: m.time,
125
+ text: m.text ?? m.content,
126
+ images: m.images,
127
+ files: m.files,
128
+ audio: m.audio,
129
+ });
130
+
131
+ /* ─── Delete ─── */
132
+ const handleConfirmDelete = () => {
133
+ if (!activeId) { setShowDelete(false); return; }
134
+ adapter.threads.delete(activeId);
135
+ const nextId = threads.filter((t) => t.id !== activeId)[0]?.id;
136
+ if (nextId) selectThread(nextId);
137
+ setReplyTo(undefined);
138
+ setShowDelete(false);
139
+ uiCallbacks?.showToast?.({
140
+ type: "success",
141
+ title: "Chat Deleted",
142
+ message: "The chat has been deleted successfully.",
143
+ });
144
+ };
145
+
146
+ return (
147
+ <div className="fixed bottom-4 right-[40px] z-50">
148
+ {/* Backdrop */}
149
+ <motion.button
150
+ aria-label="Close chat"
151
+ onClick={close}
152
+ className="fixed inset-0 bg-black/20"
153
+ initial={{ opacity: 0 }}
154
+ animate={{ opacity: 1 }}
155
+ exit={{ opacity: 0 }}
156
+ transition={{ duration: 0.3 }}
157
+ />
158
+
159
+ {/* Popup wrapper */}
160
+ <motion.div
161
+ role="dialog"
162
+ aria-modal="true"
163
+ className="relative rounded-[16px]"
164
+ style={{ width: 800, height: 650, boxShadow: "0px 2px 12px 0px rgba(59,51,51,0.1)" }}
165
+ initial={{ x: "100%", opacity: 0 }}
166
+ animate={{ x: 0, opacity: 1 }}
167
+ exit={{ x: "100%", opacity: 0 }}
168
+ transition={{ type: "tween", duration: 0.4, ease: "easeOut" }}
169
+ >
170
+ <div
171
+ className="relative h-full w-full overflow-hidden rounded-[16px]"
172
+ style={{
173
+ border: "2px solid transparent",
174
+ background: "linear-gradient(white, white) padding-box, 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%) border-box",
175
+ }}
176
+ >
177
+ <div className="grid h-full min-h-0 grid-cols-[1fr_350px]">
178
+ {/* LEFT — Message area */}
179
+ <div className="flex h-full min-h-0 flex-col border-r border-[#9BBCCF]">
180
+ <div className="h-[64px] shrink-0">
181
+ <ChatHeader
182
+ left={
183
+ activeThread?.avatarSrc ? (
184
+ <ChatIdentity variant="avatar" src={activeThread.avatarSrc} online={online} title={title} subtitle={subtitle} verified={isVerified} subtitleVariant="muted" />
185
+ ) : (
186
+ <ChatIdentity variant="initial" initial={initial} bg={avatarBg} online={online} title={title} subtitle={subtitle} verified={isVerified} subtitleVariant="muted" />
187
+ )
188
+ }
189
+ right={
190
+ uiCallbacks?.renderKebabMenu?.({
191
+ pinned: Boolean(activeThread?.pinned),
192
+ onPinToggle: () => { if (activeId) adapter.threads.pin(activeId, !activeThread?.pinned); },
193
+ onDelete: () => setShowDelete(true),
194
+ }) ?? null
195
+ }
196
+ />
197
+ </div>
198
+
199
+ {idValue && (
200
+ <div className="shrink-0">
201
+ <ChatInquiryBar
202
+ id={idValue}
203
+ label={idLabel}
204
+ buttonLabel={idButtonLabel}
205
+ onView={() => {
206
+ const type = activeThread?.orderId ? "order" : "inquiry";
207
+ uiCallbacks?.onNavigate?.({ type, id: idValue });
208
+ }}
209
+ />
210
+ </div>
211
+ )}
212
+
213
+ <div className="flex-1 min-h-0">
214
+ <div className="relative h-full min-h-0">
215
+ {isLoading ? (
216
+ <ChatSpinner className="h-full min-h-[200px]" />
217
+ ) : (
218
+ <ChatScroll className="h-full pb-10" bottomAlignWhenShort={false} scrollKey={scrollKey}>
219
+ {messages.map((m, idx) => {
220
+ const mine = m.author === "you";
221
+ const isLast = idx === messages.length - 1;
222
+ return (
223
+ <ChatMessageItem
224
+ key={m.id}
225
+ id={m.id}
226
+ mine={mine}
227
+ time={m.time ?? ""}
228
+ authorInitial={typeof m.author === "string" ? m.author : "U"}
229
+ avatarBg={avatarBg}
230
+ text={m.text ?? m.content}
231
+ businessCard={m.businessCard as Parameters<typeof ChatMessageItem>[0]["businessCard"]}
232
+ addressCard={m.addressCard as Parameters<typeof ChatMessageItem>[0]["addressCard"]}
233
+ images={m.images}
234
+ files={m.files}
235
+ audio={m.audio}
236
+ replyTo={m.replyTo}
237
+ showStatus={isLast}
238
+ status={activeThread?.status?.kind === "seen" ? "Seen" : "Delivered"}
239
+ onReply={() => setReplyTo(toRef(m))}
240
+ initialSrc={m.avatarSrc}
241
+ />
242
+ );
243
+ })}
244
+ </ChatScroll>
245
+ )}
246
+
247
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-center px-4 pb-2 pt-1 bg-white">
248
+ <TypingIndicator className="pointer-events-auto" />
249
+ </div>
250
+ </div>
251
+ </div>
252
+
253
+ <div className="shrink-0">
254
+ <ChatFooter
255
+ key={activeId}
256
+ replyTo={replyTo}
257
+ clearReply={() => setReplyTo(undefined)}
258
+ onAfterSend={() => setRev((v) => v + 1)}
259
+ onSend={(payload) => {
260
+ if (activeId) adapter.messages.send(activeId, payload);
261
+ }}
262
+ />
263
+ </div>
264
+ </div>
265
+
266
+ {/* RIGHT — Thread list */}
267
+ <div className="h-full min-h-0">
268
+ <ChatListHeader onClose={close} onSearchChange={(val) => setSearchQuery(val)} />
269
+ <div className="h-full overflow-y-auto custom-scroll">
270
+ {threads
271
+ .filter((t) => {
272
+ if (!searchQuery.trim()) return true;
273
+ const q = searchQuery.toLowerCase();
274
+ return (
275
+ t.title.toLowerCase().includes(q) ||
276
+ t.last?.toLowerCase().includes(q) ||
277
+ t.orderId?.toLowerCase().includes(q) ||
278
+ t.inquiryId?.toLowerCase().includes(q)
279
+ );
280
+ })
281
+ .map((t) => {
282
+ const status: ChatThreadStatus = t.status ?? (t.unread && t.unread > 0 ? { kind: "new", count: t.unread } : { kind: "seen" });
283
+ return (
284
+ <ChatThreadItem
285
+ key={t.id}
286
+ onClick={() => { setReplyTo(undefined); selectThread(t.id); }}
287
+ active={t.id === activeId}
288
+ pinned={Boolean(t.pinned)}
289
+ online={t.online}
290
+ verified={Boolean(t.badge)}
291
+ title={t.title}
292
+ preview={t.last ?? ""}
293
+ time={t.time ?? ""}
294
+ status={status}
295
+ avatarText={t.avatarText ?? ""}
296
+ avatarSrc={t.avatarSrc}
297
+ />
298
+ );
299
+ })}
300
+ </div>
301
+ </div>
302
+ </div>
303
+
304
+ <ChatConfirmModal open={showDelete} onClose={() => setShowDelete(false)} onConfirm={handleConfirmDelete} />
305
+ <ChatImagePreviewModal isOpen={isGalleryOpen} onClose={closeGallery} />
306
+ </div>
307
+ </motion.div>
308
+ </div>
309
+ );
310
+ };
311
+
312
+ export default InboxPopup;
@@ -0,0 +1,240 @@
1
+ // chat/SinglePopup.tsx
2
+ "use client";
3
+
4
+ import { motion } from "framer-motion";
5
+ import React from "react";
6
+
7
+ import { useChatUI } from "../contexts/ChatUIContext";
8
+ import { ChatXIcon } from "../icons";
9
+ import ChatFooter from "../ui/chat/ChatFooter";
10
+ import ChatHeader from "../ui/chat/ChatHeader";
11
+ import ChatIdentity from "../ui/chat/ChatIdentity";
12
+ import ChatMessageItem from "../ui/chat/ChatMessageItem";
13
+ import ChatScroll from "../ui/chat/ChatScroll";
14
+ import ChatSpinner from "../ui/chat/ChatSpinner";
15
+ import TypingIndicator from "../ui/chat/TypingIndicator";
16
+
17
+ import type { Thread, Message, MessageRef, Reference } from "../types";
18
+ import type { ChatAdapter, ChatUICallbacks } from "../adapter/types";
19
+
20
+ /* ─────────────────────────────────────────────────────────────
21
+ Helpers
22
+ ────────────────────────────────────────────────────────────── */
23
+
24
+ function coalesceThreadId(reference: Reference | undefined, threads: Thread[]): string {
25
+ const referenceId = reference?.id;
26
+ if (reference?.kind === "quotation") {
27
+ return threads.find((t) => t.id === "t4")?.id ?? (threads[0]?.id ?? "");
28
+ }
29
+ return (
30
+ (referenceId &&
31
+ (threads.find((t) => t.id === referenceId)?.id ||
32
+ threads.find((t) => t.inquiryId === referenceId)?.id)) ||
33
+ (threads.length ? threads[0].id : "")
34
+ );
35
+ }
36
+
37
+ function toRef(m: Message): MessageRef {
38
+ return {
39
+ id: m.id,
40
+ author: m.author,
41
+ time: m.time,
42
+ text: m.text ?? m.content,
43
+ images: m.images,
44
+ files: m.files,
45
+ audio: m.audio,
46
+ };
47
+ }
48
+
49
+ /* ─────────────────────────────────────────────────────────────
50
+ Props
51
+ ────────────────────────────────────────────────────────────── */
52
+
53
+ export type SinglePopupProps = {
54
+ /** The unified data adapter */
55
+ adapter: ChatAdapter;
56
+ /** UI-level callbacks (toast, navigation) */
57
+ uiCallbacks?: ChatUICallbacks;
58
+ };
59
+
60
+ /* ─────────────────────────────────────────────────────────────
61
+ Component
62
+ ────────────────────────────────────────────────────────────── */
63
+
64
+ const SinglePopup: React.FC<SinglePopupProps> = ({ adapter, uiCallbacks }) => {
65
+ const { close, reference } = useChatUI();
66
+
67
+ const threads = adapter.threads.list(reference);
68
+ const initialThreadId = React.useMemo(
69
+ () => coalesceThreadId(reference, threads),
70
+ // eslint-disable-next-line react-hooks/exhaustive-deps
71
+ [reference],
72
+ );
73
+ const [activeId] = React.useState<string>(initialThreadId);
74
+
75
+ const activeThread: Thread | undefined = threads.find((t) => t.id === activeId);
76
+ const isVerified = activeThread?.badge === true;
77
+
78
+ const meta = (reference?.meta ?? {}) as {
79
+ initial?: string;
80
+ title?: string;
81
+ online?: boolean;
82
+ subtitle?: string;
83
+ };
84
+
85
+ const initial = meta.initial ?? activeThread?.avatarText ?? "A";
86
+ const title = meta.title ?? activeThread?.title ?? "Unknown";
87
+ const online = meta.online ?? activeThread?.online ?? true;
88
+ const subtitle = meta.subtitle ?? "Customer";
89
+
90
+ /* ─── Messages ─── */
91
+ const [messages, setMessages] = React.useState<Message[]>(() =>
92
+ activeId ? adapter.messages.list(activeId) : [],
93
+ );
94
+ const [scrollKey, setScrollKey] = React.useState<number>(0);
95
+ const [replyTo, setReplyTo] = React.useState<MessageRef | undefined>(undefined);
96
+ const [isLoading, setIsLoading] = React.useState(true);
97
+
98
+ // Brief loading flash on initial open
99
+ React.useEffect(() => {
100
+ const t = setTimeout(() => setIsLoading(false), 300);
101
+ return () => clearTimeout(t);
102
+ }, []);
103
+
104
+ // Subscribe to real-time updates
105
+ React.useEffect(() => {
106
+ if (!activeId || !adapter.messages.subscribe) return;
107
+ const unsub = adapter.messages.subscribe(activeId, () => {
108
+ setMessages(adapter.messages.list(activeId));
109
+ setScrollKey(Date.now());
110
+ });
111
+ return unsub;
112
+ }, [activeId, adapter]);
113
+
114
+ const handleAfterSend = React.useCallback(() => {
115
+ setMessages(adapter.messages.list(activeId));
116
+ setScrollKey(Date.now());
117
+ setReplyTo(undefined);
118
+ }, [activeId, adapter]);
119
+
120
+ const statusText = activeThread?.status?.kind === "seen" ? "Seen" : "Delivered";
121
+
122
+ /* ─── Unused callbacks acknowledged ─── */
123
+ void uiCallbacks;
124
+
125
+ return (
126
+ <div className="fixed bottom-4 right-[40px] z-50">
127
+ {/* Backdrop */}
128
+ <motion.button
129
+ aria-label="Close chat"
130
+ onClick={close}
131
+ className="fixed inset-0 bg-black/20 cursor-auto!"
132
+ initial={{ opacity: 0 }}
133
+ animate={{ opacity: 1 }}
134
+ exit={{ opacity: 0 }}
135
+ transition={{ duration: 0.3 }}
136
+ />
137
+
138
+ {/* Outer gradient wrapper */}
139
+ <motion.div
140
+ role="dialog"
141
+ aria-modal="true"
142
+ className="relative h-[650px] w-[450px] rounded-[20px] p-[2px]"
143
+ style={{
144
+ boxShadow: "0px 2px 12px 0px #3B33331A",
145
+ background: "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%)",
146
+ }}
147
+ initial={{ x: "100%", opacity: 0 }}
148
+ animate={{ x: 0, opacity: 1 }}
149
+ exit={{ x: "100%", opacity: 0 }}
150
+ transition={{ type: "tween", duration: 0.4, ease: "easeOut" }}
151
+ >
152
+ {/* Inner card */}
153
+ <div className="flex h-full w-full flex-col overflow-hidden rounded-[18px] bg-white">
154
+ {/* Header */}
155
+ <div className="h-[64px] shrink-0">
156
+ <ChatHeader
157
+ left={
158
+ <ChatIdentity
159
+ variant="initial"
160
+ initial={initial}
161
+ bg="#FFE5DA"
162
+ online={online}
163
+ title={title}
164
+ subtitle={subtitle}
165
+ verified={isVerified}
166
+ subtitleVariant="muted"
167
+ />
168
+ }
169
+ right={
170
+ <button
171
+ type="button"
172
+ onClick={close}
173
+ className="flex h-[34px] w-[34px] items-center justify-center rounded-full bg-white text-black shadow-[0px_2px_4px_0px_#A5A3AE4D] hover:bg-black/5 hover:text-[#ff5301] cursor-pointer"
174
+ >
175
+ <ChatXIcon className="h-6 w-6" />
176
+ </button>
177
+ }
178
+ />
179
+ </div>
180
+
181
+ {/* Messages */}
182
+ <div className="relative flex-1 min-h-0">
183
+ {isLoading ? (
184
+ <ChatSpinner className="h-full" />
185
+ ) : (
186
+ <ChatScroll className="h-full" bottomAlignWhenShort={false} scrollKey={scrollKey}>
187
+ {messages.map((m, idx) => {
188
+ const mine = m.author === "you";
189
+ const isLast = idx === messages.length - 1;
190
+ return (
191
+ <ChatMessageItem
192
+ key={m.id}
193
+ id={m.id}
194
+ mine={mine}
195
+ time={m.time ?? ""}
196
+ authorInitial={typeof m.author === "string" ? m.author : "U"}
197
+ text={m.text ?? m.content}
198
+ businessCard={m.businessCard as Parameters<typeof ChatMessageItem>[0]["businessCard"]}
199
+ addressCard={m.addressCard as Parameters<typeof ChatMessageItem>[0]["addressCard"]}
200
+ images={m.images}
201
+ files={m.files}
202
+ audio={m.audio}
203
+ replyTo={m.replyTo}
204
+ initialSrc={m.avatarSrc}
205
+ showStatus={isLast}
206
+ status={statusText}
207
+ onReply={() => setReplyTo(toRef(m))}
208
+ />
209
+ );
210
+ })}
211
+ </ChatScroll>
212
+ )}
213
+
214
+ {/* Typing indicator */}
215
+ <div className="pointer-events-none absolute bottom-0 left-0 w-full bg-white px-4 pb-2">
216
+ <div className="pointer-events-auto flex items-center justify-start">
217
+ <TypingIndicator />
218
+ </div>
219
+ </div>
220
+ </div>
221
+
222
+ {/* Footer */}
223
+ <div className="shrink-0">
224
+ <ChatFooter
225
+ variant="single"
226
+ replyTo={replyTo}
227
+ clearReply={() => setReplyTo(undefined)}
228
+ onAfterSend={handleAfterSend}
229
+ onSend={(payload) => {
230
+ if (activeId) adapter.messages.send(activeId, payload);
231
+ }}
232
+ />
233
+ </div>
234
+ </div>
235
+ </motion.div>
236
+ </div>
237
+ );
238
+ };
239
+
240
+ export default SinglePopup;
@@ -0,0 +1,30 @@
1
+ "use client";
2
+ import { createContext, useContext } from "react";
3
+ import type { Reference } from "../types";
4
+
5
+ // Re-export Reference so consumers who import from this context still work.
6
+ export type { Reference };
7
+
8
+ export type ChatVariant = "inbox" | "single";
9
+
10
+ export type ChatUIState = {
11
+ isOpen: boolean;
12
+ variant: ChatVariant;
13
+ reference?: Reference;
14
+ selectedThreadId?: string | null;
15
+
16
+ openInbox: (opts?: { reference?: Reference; threadId?: string }) => void;
17
+ openSingle: (opts?: { reference?: Reference }) => void;
18
+ close: () => void;
19
+ selectThread: (id: string | null) => void;
20
+ };
21
+
22
+ export const ChatUIContext = createContext<ChatUIState | null>(null);
23
+
24
+ export function useChatUI() {
25
+ const ctx = useContext(ChatUIContext);
26
+ if (!ctx) {
27
+ throw new Error("useChatUI must be used within ChatUIProvider");
28
+ }
29
+ return ctx;
30
+ }
@@ -0,0 +1,38 @@
1
+ "use client";
2
+
3
+ import React, { useMemo, useState } from "react";
4
+ import type { ChatUIState, ChatVariant, Reference } from "./ChatUIContext";
5
+ import { ChatUIContext } from "./ChatUIContext";
6
+
7
+ export function ChatUIProvider({ children }: { children: React.ReactNode }) {
8
+ const [isOpen, setOpen] = useState(false);
9
+ const [variant, setVariant] = useState<ChatVariant>("inbox");
10
+ const [reference, setReference] = useState<Reference | undefined>();
11
+ const [selectedThreadId, setSelected] = useState<string | null>(null);
12
+
13
+ const api = useMemo<ChatUIState>(
14
+ () => ({
15
+ isOpen,
16
+ variant,
17
+ reference,
18
+ selectedThreadId,
19
+ openInbox: (opts) => {
20
+ setReference(opts?.reference);
21
+ setSelected(opts?.threadId ?? null);
22
+ setVariant("inbox");
23
+ setOpen(true);
24
+ },
25
+ openSingle: (opts) => {
26
+ setReference(opts?.reference);
27
+ setVariant("single");
28
+ setSelected(null);
29
+ setOpen(true);
30
+ },
31
+ close: () => setOpen(false),
32
+ selectThread: (id) => setSelected(id),
33
+ }),
34
+ [isOpen, variant, reference, selectedThreadId],
35
+ );
36
+
37
+ return <ChatUIContext.Provider value={api}>{children}</ChatUIContext.Provider>;
38
+ }
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext } from "react";
4
+
5
+ // New interface for media items
6
+ export interface GalleryMedia {
7
+ type: "image" | "video" | "pdf";
8
+ url: string;
9
+ altText?: string;
10
+ /** Per-item upload date shown in the gallery preview */
11
+ uploadDate?: string | null;
12
+ }
13
+
14
+ // Updated context type
15
+ export interface GalleryContextType {
16
+ images: GalleryMedia[];
17
+ currentIndex: number | null;
18
+ isOpen: boolean;
19
+ uploadDate?: string | null;
20
+ showDots?: boolean;
21
+ setImages: (imgs: GalleryMedia[]) => void;
22
+ setCurrentIndex: (index: number) => void;
23
+ setUploadDate: (date: string | null) => void;
24
+ openGallery: (imgs: GalleryMedia[], startIndex?: number, uploadDate?: string | null, showDots?: boolean) => void;
25
+ closeGallery: () => void;
26
+ next: () => void;
27
+ prev: () => void;
28
+ }
29
+
30
+ export const GalleryContext = createContext<GalleryContextType | undefined>(
31
+ undefined,
32
+ );
33
+
34
+ export const useGallery = (): GalleryContextType => {
35
+ const context = useContext(GalleryContext);
36
+ if (!context) {
37
+ throw new Error("useGallery must be used within a GalleryProvider");
38
+ }
39
+ return context;
40
+ };