@rizvanua/contact-chat 0.1.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.
- package/LICENSE +21 -0
- package/README.md +496 -0
- package/dist/index.cjs +704 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +688 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.cjs +326 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +118 -0
- package/dist/react/index.d.ts +118 -0
- package/dist/react/index.js +306 -0
- package/dist/react/index.js.map +1 -0
- package/dist/server/index.cjs +704 -0
- package/dist/server/index.cjs.map +1 -0
- package/dist/server/index.d.cts +98 -0
- package/dist/server/index.d.ts +98 -0
- package/dist/server/index.js +688 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/nextjs.cjs +10 -0
- package/dist/server/nextjs.cjs.map +1 -0
- package/dist/server/nextjs.d.cts +17 -0
- package/dist/server/nextjs.d.ts +17 -0
- package/dist/server/nextjs.js +8 -0
- package/dist/server/nextjs.js.map +1 -0
- package/dist/stores/index.cjs +244 -0
- package/dist/stores/index.cjs.map +1 -0
- package/dist/stores/index.d.cts +58 -0
- package/dist/stores/index.d.ts +58 -0
- package/dist/stores/index.js +241 -0
- package/dist/stores/index.js.map +1 -0
- package/dist/transports/index.cjs +156 -0
- package/dist/transports/index.cjs.map +1 -0
- package/dist/transports/index.d.cts +48 -0
- package/dist/transports/index.d.ts +48 -0
- package/dist/transports/index.js +151 -0
- package/dist/transports/index.js.map +1 -0
- package/dist/types-BcoqxSLg.d.cts +42 -0
- package/dist/types-BcoqxSLg.d.ts +42 -0
- package/dist/ui/index.cjs +609 -0
- package/dist/ui/index.cjs.map +1 -0
- package/dist/ui/index.d.cts +127 -0
- package/dist/ui/index.d.ts +127 -0
- package/dist/ui/index.js +604 -0
- package/dist/ui/index.js.map +1 -0
- package/package.json +146 -0
package/dist/ui/index.js
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import { twMerge } from 'tailwind-merge';
|
|
2
|
+
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
|
|
3
|
+
import { createContext, useState, useRef, useEffect, Fragment, useCallback, useContext } from 'react';
|
|
4
|
+
import { jsx, jsxs, Fragment as Fragment$1 } from 'react/jsx-runtime';
|
|
5
|
+
import { Transition, Dialog } from '@headlessui/react';
|
|
6
|
+
import { X, Send } from 'lucide-react';
|
|
7
|
+
|
|
8
|
+
// src/ui/ChatLauncher.tsx
|
|
9
|
+
var chatOpenAtom = atom(false);
|
|
10
|
+
var chatSessionIdAtom = atom(null);
|
|
11
|
+
var chatNameAtom = atom(null);
|
|
12
|
+
var chatMessagesAtom = atom([]);
|
|
13
|
+
var chatCursorAtom = atom(0);
|
|
14
|
+
var chatUnreadAtom = atom(0);
|
|
15
|
+
var chatStatusAtom = atom("idle");
|
|
16
|
+
var chatErrorAtom = atom(null);
|
|
17
|
+
|
|
18
|
+
// src/core/name.ts
|
|
19
|
+
var MAX_NAME_LENGTH = 60;
|
|
20
|
+
function sanitizeName(raw) {
|
|
21
|
+
if (typeof raw !== "string") return "";
|
|
22
|
+
return raw.replace(/[\u0000-\u001F\u007F]/g, " ").replace(/\s+/g, " ").trim().slice(0, MAX_NAME_LENGTH);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/react/client.ts
|
|
26
|
+
function getStoredName(nameStorageKey) {
|
|
27
|
+
if (typeof window === "undefined") return null;
|
|
28
|
+
return window.localStorage.getItem(nameStorageKey);
|
|
29
|
+
}
|
|
30
|
+
function storeName(nameStorageKey, name) {
|
|
31
|
+
window.localStorage.setItem(nameStorageKey, name);
|
|
32
|
+
}
|
|
33
|
+
function getStoredSessionId(sessionStorageKey) {
|
|
34
|
+
if (typeof window === "undefined") return null;
|
|
35
|
+
return window.localStorage.getItem(sessionStorageKey);
|
|
36
|
+
}
|
|
37
|
+
function ensureSessionId(sessionStorageKey) {
|
|
38
|
+
const existing = getStoredSessionId(sessionStorageKey);
|
|
39
|
+
if (existing) return existing;
|
|
40
|
+
const created = window.crypto.randomUUID();
|
|
41
|
+
window.localStorage.setItem(sessionStorageKey, created);
|
|
42
|
+
return created;
|
|
43
|
+
}
|
|
44
|
+
function mergeMessages(current, incoming) {
|
|
45
|
+
const seen = new Set(current.map((message) => message.id));
|
|
46
|
+
const fresh = incoming.filter((message) => !seen.has(message.id));
|
|
47
|
+
if (fresh.length === 0) return current;
|
|
48
|
+
return [...current, ...fresh].sort((a, b) => a.ts - b.ts);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/core/types.ts
|
|
52
|
+
var MAX_MESSAGE_LENGTH = 1e3;
|
|
53
|
+
var DEFAULT_CHAT_LIMITS = {
|
|
54
|
+
maxMessageLength: MAX_MESSAGE_LENGTH};
|
|
55
|
+
var DEFAULT_CHAT_CLIENT_CONFIG = {
|
|
56
|
+
basePath: "/api/chat",
|
|
57
|
+
sessionStorageKey: "contact-chat-session",
|
|
58
|
+
nameStorageKey: "contact-chat-name",
|
|
59
|
+
chime: true,
|
|
60
|
+
maxMessageLength: DEFAULT_CHAT_LIMITS.maxMessageLength
|
|
61
|
+
};
|
|
62
|
+
var Ctx = createContext(DEFAULT_CHAT_CLIENT_CONFIG);
|
|
63
|
+
function useChatConfig() {
|
|
64
|
+
return useContext(Ctx);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/react/use-chat-actions.ts
|
|
68
|
+
function useChatActions() {
|
|
69
|
+
const { basePath, sessionStorageKey, nameStorageKey, maxMessageLength, onEvent } = useChatConfig();
|
|
70
|
+
const [open, originalSetOpen] = useAtom(chatOpenAtom);
|
|
71
|
+
const messages = useAtomValue(chatMessagesAtom);
|
|
72
|
+
const setMessages = useSetAtom(chatMessagesAtom);
|
|
73
|
+
const unread = useAtomValue(chatUnreadAtom);
|
|
74
|
+
const [status, setStatus] = useAtom(chatStatusAtom);
|
|
75
|
+
const [error, setError] = useAtom(chatErrorAtom);
|
|
76
|
+
const [name, setNameAtomValue] = useAtom(chatNameAtom);
|
|
77
|
+
const setSessionId = useSetAtom(chatSessionIdAtom);
|
|
78
|
+
const setOpen = useCallback(
|
|
79
|
+
(v) => {
|
|
80
|
+
const next = typeof v === "function" ? v(open) : v;
|
|
81
|
+
originalSetOpen(next);
|
|
82
|
+
if (next === true) onEvent?.({ type: "chat.open" });
|
|
83
|
+
else if (next === false) onEvent?.({ type: "chat.close" });
|
|
84
|
+
},
|
|
85
|
+
[open, originalSetOpen, onEvent]
|
|
86
|
+
);
|
|
87
|
+
const setName = useCallback(
|
|
88
|
+
(raw) => {
|
|
89
|
+
const clean = sanitizeName(raw);
|
|
90
|
+
if (!clean) return false;
|
|
91
|
+
storeName(nameStorageKey, clean);
|
|
92
|
+
setNameAtomValue(clean);
|
|
93
|
+
return true;
|
|
94
|
+
},
|
|
95
|
+
[nameStorageKey, setNameAtomValue]
|
|
96
|
+
);
|
|
97
|
+
const send = useCallback(
|
|
98
|
+
async (text) => {
|
|
99
|
+
const trimmed = text.trim();
|
|
100
|
+
if (trimmed.length === 0 || trimmed.length > maxMessageLength) return;
|
|
101
|
+
const sessionId = ensureSessionId(sessionStorageKey);
|
|
102
|
+
setSessionId(sessionId);
|
|
103
|
+
setStatus("sending");
|
|
104
|
+
setError(null);
|
|
105
|
+
onEvent?.({ type: "chat.send.attempt" });
|
|
106
|
+
try {
|
|
107
|
+
const response = await fetch(`${basePath}/send`, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "Content-Type": "application/json" },
|
|
110
|
+
body: JSON.stringify({
|
|
111
|
+
sessionId,
|
|
112
|
+
text: trimmed,
|
|
113
|
+
name: getStoredName(nameStorageKey) ?? "",
|
|
114
|
+
honeypot: ""
|
|
115
|
+
})
|
|
116
|
+
});
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
const body = await response.json();
|
|
119
|
+
setError(body.error);
|
|
120
|
+
setStatus(
|
|
121
|
+
body.code === "store_unavailable" ? "unavailable" : "error"
|
|
122
|
+
);
|
|
123
|
+
onEvent?.({ type: "chat.send.error", code: body.code });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const data = await response.json();
|
|
127
|
+
setMessages((current) => mergeMessages(current, [data.message]));
|
|
128
|
+
setStatus("idle");
|
|
129
|
+
onEvent?.({ type: "chat.send.success" });
|
|
130
|
+
} catch {
|
|
131
|
+
setError("Chat is unavailable right now.");
|
|
132
|
+
setStatus("unavailable");
|
|
133
|
+
onEvent?.({ type: "chat.send.error", code: "network" });
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
[
|
|
137
|
+
basePath,
|
|
138
|
+
maxMessageLength,
|
|
139
|
+
nameStorageKey,
|
|
140
|
+
onEvent,
|
|
141
|
+
sessionStorageKey,
|
|
142
|
+
setError,
|
|
143
|
+
setMessages,
|
|
144
|
+
setSessionId,
|
|
145
|
+
setStatus
|
|
146
|
+
]
|
|
147
|
+
);
|
|
148
|
+
return {
|
|
149
|
+
open,
|
|
150
|
+
setOpen,
|
|
151
|
+
messages,
|
|
152
|
+
unread,
|
|
153
|
+
status,
|
|
154
|
+
error,
|
|
155
|
+
name,
|
|
156
|
+
setName,
|
|
157
|
+
send,
|
|
158
|
+
canSend: status !== "sending"
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// src/react/chime.ts
|
|
163
|
+
var context = null;
|
|
164
|
+
function getContext() {
|
|
165
|
+
if (typeof window === "undefined") return null;
|
|
166
|
+
const Ctor = window.AudioContext ?? window.webkitAudioContext;
|
|
167
|
+
if (!Ctor) return null;
|
|
168
|
+
if (!context) context = new Ctor();
|
|
169
|
+
return context;
|
|
170
|
+
}
|
|
171
|
+
function playChime() {
|
|
172
|
+
try {
|
|
173
|
+
const audio = getContext();
|
|
174
|
+
if (!audio) return;
|
|
175
|
+
if (audio.state === "suspended") void audio.resume();
|
|
176
|
+
const startedAt = audio.currentTime;
|
|
177
|
+
const notes = [
|
|
178
|
+
{ frequency: 880, offset: 0 },
|
|
179
|
+
{ frequency: 1320, offset: 0.1 }
|
|
180
|
+
];
|
|
181
|
+
for (const { frequency, offset } of notes) {
|
|
182
|
+
const oscillator = audio.createOscillator();
|
|
183
|
+
const gain = audio.createGain();
|
|
184
|
+
oscillator.type = "sine";
|
|
185
|
+
oscillator.frequency.value = frequency;
|
|
186
|
+
const start = startedAt + offset;
|
|
187
|
+
const end = start + 0.09;
|
|
188
|
+
gain.gain.setValueAtTime(0, start);
|
|
189
|
+
gain.gain.linearRampToValueAtTime(0.15, start + 0.01);
|
|
190
|
+
gain.gain.linearRampToValueAtTime(0, end);
|
|
191
|
+
oscillator.connect(gain).connect(audio.destination);
|
|
192
|
+
oscillator.start(start);
|
|
193
|
+
oscillator.stop(end + 0.02);
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// src/react/use-chat-sync.ts
|
|
200
|
+
var FAST_INTERVAL_MS = 4e3;
|
|
201
|
+
var SLOW_INTERVAL_MS = 15e3;
|
|
202
|
+
var IDLE_BEFORE_SLOW_MS = 12e4;
|
|
203
|
+
function useChatSync() {
|
|
204
|
+
const { basePath, sessionStorageKey, nameStorageKey, chime } = useChatConfig();
|
|
205
|
+
const open = useAtomValue(chatOpenAtom);
|
|
206
|
+
const setSessionId = useSetAtom(chatSessionIdAtom);
|
|
207
|
+
const setName = useSetAtom(chatNameAtom);
|
|
208
|
+
const setMessages = useSetAtom(chatMessagesAtom);
|
|
209
|
+
const [cursor, setCursor] = useAtom(chatCursorAtom);
|
|
210
|
+
const setUnread = useSetAtom(chatUnreadAtom);
|
|
211
|
+
const [active, setActive] = useState(true);
|
|
212
|
+
const cursorRef = useRef(cursor);
|
|
213
|
+
const lastMessageAtRef = useRef(0);
|
|
214
|
+
const hasHydratedRef = useRef(false);
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
cursorRef.current = cursor;
|
|
217
|
+
}, [cursor]);
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
const storedSession = getStoredSessionId(sessionStorageKey);
|
|
220
|
+
if (storedSession) setSessionId(storedSession);
|
|
221
|
+
const storedName = getStoredName(nameStorageKey);
|
|
222
|
+
if (storedName) setName(storedName);
|
|
223
|
+
}, [sessionStorageKey, nameStorageKey, setSessionId, setName]);
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
const recompute = () => setActive(document.visibilityState === "visible" || document.hasFocus());
|
|
226
|
+
recompute();
|
|
227
|
+
document.addEventListener("visibilitychange", recompute);
|
|
228
|
+
window.addEventListener("focus", recompute);
|
|
229
|
+
window.addEventListener("blur", recompute);
|
|
230
|
+
return () => {
|
|
231
|
+
document.removeEventListener("visibilitychange", recompute);
|
|
232
|
+
window.removeEventListener("focus", recompute);
|
|
233
|
+
window.removeEventListener("blur", recompute);
|
|
234
|
+
};
|
|
235
|
+
}, []);
|
|
236
|
+
const poll = useCallback(async () => {
|
|
237
|
+
const sessionId = getStoredSessionId(sessionStorageKey);
|
|
238
|
+
if (!sessionId) return;
|
|
239
|
+
try {
|
|
240
|
+
const response = await fetch(
|
|
241
|
+
`${basePath}/poll?sessionId=${encodeURIComponent(sessionId)}&since=${cursorRef.current}`,
|
|
242
|
+
{ cache: "no-store" }
|
|
243
|
+
);
|
|
244
|
+
if (!response.ok) return;
|
|
245
|
+
const data = await response.json();
|
|
246
|
+
if (data.messages.length === 0) return;
|
|
247
|
+
lastMessageAtRef.current = Date.now();
|
|
248
|
+
setMessages((current) => mergeMessages(current, data.messages));
|
|
249
|
+
cursorRef.current = data.cursor;
|
|
250
|
+
setCursor(data.cursor);
|
|
251
|
+
const fromOwner = data.messages.filter(
|
|
252
|
+
(message) => message.from === "owner"
|
|
253
|
+
).length;
|
|
254
|
+
const isHydrating = !hasHydratedRef.current;
|
|
255
|
+
hasHydratedRef.current = true;
|
|
256
|
+
if (fromOwner > 0 && !isHydrating && chime) playChime();
|
|
257
|
+
if (fromOwner > 0 && !open) setUnread((count) => count + fromOwner);
|
|
258
|
+
} catch {
|
|
259
|
+
}
|
|
260
|
+
}, [basePath, chime, open, sessionStorageKey, setCursor, setMessages, setUnread]);
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
if (!active) return;
|
|
263
|
+
if (!open) {
|
|
264
|
+
void poll();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
let timer = null;
|
|
268
|
+
let cancelled = false;
|
|
269
|
+
const tick = async () => {
|
|
270
|
+
if (cancelled) return;
|
|
271
|
+
await poll();
|
|
272
|
+
if (cancelled) return;
|
|
273
|
+
const since = lastMessageAtRef.current || Date.now();
|
|
274
|
+
const delay = Date.now() - since > IDLE_BEFORE_SLOW_MS ? SLOW_INTERVAL_MS : FAST_INTERVAL_MS;
|
|
275
|
+
timer = setTimeout(tick, delay);
|
|
276
|
+
};
|
|
277
|
+
lastMessageAtRef.current = Date.now();
|
|
278
|
+
void tick();
|
|
279
|
+
return () => {
|
|
280
|
+
cancelled = true;
|
|
281
|
+
if (timer) clearTimeout(timer);
|
|
282
|
+
};
|
|
283
|
+
}, [open, active, poll]);
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
if (open) setUnread(0);
|
|
286
|
+
}, [open, setUnread]);
|
|
287
|
+
}
|
|
288
|
+
var DEFAULT_CONTAINER_CLASSES = "flex";
|
|
289
|
+
var DEFAULT_BUBBLE_CLASSES = "max-w-[80%] whitespace-pre-wrap break-words rounded-lg px-3 py-2 text-sm";
|
|
290
|
+
var DEFAULT_VISITOR_BUBBLE_CLASSES = "rounded-br-sm bg-purple-600 text-white";
|
|
291
|
+
var DEFAULT_OWNER_BUBBLE_CLASSES = "rounded-bl-sm bg-gray-700 text-gray-100";
|
|
292
|
+
function MessageBubble({ message, classNames = {} }) {
|
|
293
|
+
const isVisitor = message.from === "visitor";
|
|
294
|
+
return /* @__PURE__ */ jsx(
|
|
295
|
+
"div",
|
|
296
|
+
{
|
|
297
|
+
className: twMerge(
|
|
298
|
+
DEFAULT_CONTAINER_CLASSES,
|
|
299
|
+
isVisitor ? "justify-end" : "justify-start",
|
|
300
|
+
classNames.container
|
|
301
|
+
),
|
|
302
|
+
children: /* @__PURE__ */ jsx(
|
|
303
|
+
"div",
|
|
304
|
+
{
|
|
305
|
+
className: twMerge(
|
|
306
|
+
DEFAULT_BUBBLE_CLASSES,
|
|
307
|
+
isVisitor ? twMerge(DEFAULT_VISITOR_BUBBLE_CLASSES, classNames.visitorBubble) : twMerge(DEFAULT_OWNER_BUBBLE_CLASSES, classNames.ownerBubble),
|
|
308
|
+
classNames.bubble
|
|
309
|
+
),
|
|
310
|
+
children: message.text
|
|
311
|
+
}
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
var DEFAULT_CONTAINER_CLASSES2 = "space-y-3 p-3 rounded-lg border border-gray-700 bg-gray-800 text-gray-300";
|
|
317
|
+
var DEFAULT_INPUT_CLASSES = "flex-1 rounded border border-gray-600 bg-gray-900 px-2 py-1 text-base text-white focus:outline-none focus:ring-1 focus:ring-purple-500 sm:text-sm";
|
|
318
|
+
var DEFAULT_SUBMIT_CLASSES = "whitespace-nowrap rounded bg-purple-600 px-2 py-1 text-sm font-semibold text-white hover:bg-purple-700";
|
|
319
|
+
var DEFAULT_ERROR_CLASSES = "text-xs text-amber-400";
|
|
320
|
+
function NameGate({ labels = {}, classNames = {} } = {}) {
|
|
321
|
+
const { setName } = useChatActions();
|
|
322
|
+
const [value, setValue] = useState("");
|
|
323
|
+
const [showError, setShowError] = useState(false);
|
|
324
|
+
const promptText = labels.prompt ?? "Before we start \u2014 what\u2019s your name?";
|
|
325
|
+
const placeholder = labels.placeholder ?? "Your name";
|
|
326
|
+
const submitLabel = labels.submitButton ?? "Start chat";
|
|
327
|
+
const inputAriaLabel = labels.inputAriaLabel ?? "Your name";
|
|
328
|
+
const errorText = labels.error ?? "Please enter your name.";
|
|
329
|
+
const submit = () => {
|
|
330
|
+
if (!setName(value)) {
|
|
331
|
+
setShowError(true);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
setShowError(false);
|
|
335
|
+
};
|
|
336
|
+
return /* @__PURE__ */ jsxs("div", { className: twMerge(DEFAULT_CONTAINER_CLASSES2, classNames.container), children: [
|
|
337
|
+
/* @__PURE__ */ jsx("p", { className: twMerge("text-sm", classNames.prompt), children: promptText }),
|
|
338
|
+
/* @__PURE__ */ jsxs("div", { className: twMerge("flex gap-2", classNames.inputRow), children: [
|
|
339
|
+
/* @__PURE__ */ jsx(
|
|
340
|
+
"input",
|
|
341
|
+
{
|
|
342
|
+
type: "text",
|
|
343
|
+
value,
|
|
344
|
+
autoFocus: true,
|
|
345
|
+
maxLength: MAX_NAME_LENGTH,
|
|
346
|
+
onChange: (e) => {
|
|
347
|
+
setValue(e.target.value);
|
|
348
|
+
if (showError) setShowError(false);
|
|
349
|
+
},
|
|
350
|
+
onKeyDown: (e) => {
|
|
351
|
+
if (e.key === "Enter") {
|
|
352
|
+
e.preventDefault();
|
|
353
|
+
submit();
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
placeholder,
|
|
357
|
+
"aria-label": inputAriaLabel,
|
|
358
|
+
className: twMerge(DEFAULT_INPUT_CLASSES, classNames.input)
|
|
359
|
+
}
|
|
360
|
+
),
|
|
361
|
+
/* @__PURE__ */ jsx(
|
|
362
|
+
"button",
|
|
363
|
+
{
|
|
364
|
+
type: "button",
|
|
365
|
+
onClick: submit,
|
|
366
|
+
className: twMerge(DEFAULT_SUBMIT_CLASSES, classNames.submitButton),
|
|
367
|
+
children: submitLabel
|
|
368
|
+
}
|
|
369
|
+
)
|
|
370
|
+
] }),
|
|
371
|
+
showError && /* @__PURE__ */ jsx("p", { className: twMerge(DEFAULT_ERROR_CLASSES, classNames.error), children: errorText })
|
|
372
|
+
] });
|
|
373
|
+
}
|
|
374
|
+
var DEFAULT_OVERLAY_CLASSES = "fixed inset-0 bg-black/50";
|
|
375
|
+
var DEFAULT_PANEL_CLASSES = "flex w-full max-w-md flex-col overflow-hidden rounded-lg border border-gray-700 bg-gray-900 text-left shadow-xl transition-all";
|
|
376
|
+
var DEFAULT_HEADER_CLASSES = "flex items-start justify-between px-4 py-3 bg-purple-600";
|
|
377
|
+
var DEFAULT_TITLE_CLASSES = "min-w-0 truncate text-base font-semibold text-white";
|
|
378
|
+
var DEFAULT_CLOSE_BUTTON_CLASSES = "shrink-0 rounded-md p-1 text-gray-300 hover:text-white focus:outline-none focus:ring-2 focus:ring-purple-500";
|
|
379
|
+
var DEFAULT_BODY_CLASSES = "flex-1 space-y-3 overflow-y-auto p-4";
|
|
380
|
+
var DEFAULT_GREETING_CLASSES = "text-xs font-bold text-gray-400";
|
|
381
|
+
var DEFAULT_UNAVAILABLE_CLASSES = "text-xs text-amber-400";
|
|
382
|
+
var DEFAULT_ERROR_TEXT_CLASSES = "text-xs text-amber-400";
|
|
383
|
+
var DEFAULT_COMPOSER_CLASSES = "flex items-end gap-2 border-t border-gray-700 p-3";
|
|
384
|
+
var DEFAULT_TEXTAREA_CLASSES = "flex-1 resize-none rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-base text-white focus:outline-none focus:ring-1 focus:ring-purple-500 sm:text-sm";
|
|
385
|
+
var DEFAULT_SEND_BUTTON_CLASSES = "rounded-full bg-purple-600 p-2 text-white hover:bg-purple-700 disabled:opacity-40";
|
|
386
|
+
var DEFAULT_HONEYPOT_CLASSES = "hidden";
|
|
387
|
+
function ChatDialog({
|
|
388
|
+
labels = {},
|
|
389
|
+
classNames = {},
|
|
390
|
+
renderMessage,
|
|
391
|
+
nameGate
|
|
392
|
+
} = {}) {
|
|
393
|
+
const { open, setOpen, messages, status, error, send, canSend, name } = useChatActions();
|
|
394
|
+
const { maxMessageLength } = useChatConfig();
|
|
395
|
+
const [draft, setDraft] = useState("");
|
|
396
|
+
const bottomRef = useRef(null);
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
399
|
+
}, [messages.length]);
|
|
400
|
+
const titleText = labels.title ?? "Chat";
|
|
401
|
+
const titleWithName = labels.titleWithName ?? ((n) => ` \xB7 ${n}`);
|
|
402
|
+
const greeting = labels.greeting ?? ((n) => `Hi ${n} \u2014 type your message here\u2026 I usually reply within a few minutes.`);
|
|
403
|
+
const composerPlaceholder = labels.composerPlaceholder ?? ((nameOk) => nameOk ? "Type a message\u2026" : "Enter your name first");
|
|
404
|
+
const messageAriaLabel = labels.messageAriaLabel ?? "Message";
|
|
405
|
+
const sendAriaLabel = labels.sendAriaLabel ?? "Send message";
|
|
406
|
+
const closeAriaLabel = labels.closeAriaLabel ?? "Close chat";
|
|
407
|
+
const unavailableFallback = labels.unavailableFallback ?? /* @__PURE__ */ jsx("p", { className: twMerge(DEFAULT_UNAVAILABLE_CLASSES, classNames.unavailable), children: "Chat is unreachable right now \u2014 please try again later." });
|
|
408
|
+
const submit = async () => {
|
|
409
|
+
if (!name || !canSend || draft.trim().length === 0) return;
|
|
410
|
+
const text = draft;
|
|
411
|
+
setDraft("");
|
|
412
|
+
await send(text);
|
|
413
|
+
};
|
|
414
|
+
return /* @__PURE__ */ jsx(Transition, { show: open, as: Fragment, children: /* @__PURE__ */ jsxs(Dialog, { as: "div", className: "relative z-50", onClose: () => setOpen(false), children: [
|
|
415
|
+
/* @__PURE__ */ jsx(
|
|
416
|
+
Transition.Child,
|
|
417
|
+
{
|
|
418
|
+
as: Fragment,
|
|
419
|
+
enter: "ease-out duration-200",
|
|
420
|
+
enterFrom: "opacity-0",
|
|
421
|
+
enterTo: "opacity-100",
|
|
422
|
+
leave: "ease-in duration-150",
|
|
423
|
+
leaveFrom: "opacity-100",
|
|
424
|
+
leaveTo: "opacity-0",
|
|
425
|
+
children: /* @__PURE__ */ jsx("div", { className: twMerge(DEFAULT_OVERLAY_CLASSES, classNames.overlay) })
|
|
426
|
+
}
|
|
427
|
+
),
|
|
428
|
+
/* @__PURE__ */ jsx("div", { className: "fixed inset-0 overflow-y-auto", children: /* @__PURE__ */ jsx("div", { className: "flex min-h-full items-center justify-center p-4", children: /* @__PURE__ */ jsx(
|
|
429
|
+
Transition.Child,
|
|
430
|
+
{
|
|
431
|
+
as: Fragment,
|
|
432
|
+
enter: "ease-out duration-200",
|
|
433
|
+
enterFrom: "opacity-0 scale-95",
|
|
434
|
+
enterTo: "opacity-100 scale-100",
|
|
435
|
+
leave: "ease-in duration-150",
|
|
436
|
+
leaveFrom: "opacity-100 scale-100",
|
|
437
|
+
leaveTo: "opacity-0 scale-95",
|
|
438
|
+
children: /* @__PURE__ */ jsxs(
|
|
439
|
+
Dialog.Panel,
|
|
440
|
+
{
|
|
441
|
+
className: twMerge(DEFAULT_PANEL_CLASSES, classNames.panel),
|
|
442
|
+
children: [
|
|
443
|
+
/* @__PURE__ */ jsxs("div", { className: twMerge(DEFAULT_HEADER_CLASSES, classNames.header), children: [
|
|
444
|
+
/* @__PURE__ */ jsxs(
|
|
445
|
+
Dialog.Title,
|
|
446
|
+
{
|
|
447
|
+
className: twMerge(DEFAULT_TITLE_CLASSES, classNames.title),
|
|
448
|
+
children: [
|
|
449
|
+
titleText,
|
|
450
|
+
name ? titleWithName(name) : ""
|
|
451
|
+
]
|
|
452
|
+
}
|
|
453
|
+
),
|
|
454
|
+
/* @__PURE__ */ jsx(
|
|
455
|
+
"button",
|
|
456
|
+
{
|
|
457
|
+
type: "button",
|
|
458
|
+
onClick: () => setOpen(false),
|
|
459
|
+
"aria-label": closeAriaLabel,
|
|
460
|
+
className: twMerge(
|
|
461
|
+
DEFAULT_CLOSE_BUTTON_CLASSES,
|
|
462
|
+
classNames.closeButton
|
|
463
|
+
),
|
|
464
|
+
children: /* @__PURE__ */ jsx(X, { size: 18 })
|
|
465
|
+
}
|
|
466
|
+
)
|
|
467
|
+
] }),
|
|
468
|
+
/* @__PURE__ */ jsxs("div", { className: twMerge(DEFAULT_BODY_CLASSES, classNames.body), children: [
|
|
469
|
+
name && /* @__PURE__ */ jsx(
|
|
470
|
+
"p",
|
|
471
|
+
{
|
|
472
|
+
className: twMerge(
|
|
473
|
+
DEFAULT_GREETING_CLASSES,
|
|
474
|
+
classNames.greeting
|
|
475
|
+
),
|
|
476
|
+
children: greeting(name)
|
|
477
|
+
}
|
|
478
|
+
),
|
|
479
|
+
messages.map(
|
|
480
|
+
(message) => renderMessage ? renderMessage(message) : /* @__PURE__ */ jsx(MessageBubble, { message }, message.id)
|
|
481
|
+
),
|
|
482
|
+
status === "unavailable" && unavailableFallback,
|
|
483
|
+
status === "error" && error && /* @__PURE__ */ jsx(
|
|
484
|
+
"p",
|
|
485
|
+
{
|
|
486
|
+
className: twMerge(
|
|
487
|
+
DEFAULT_ERROR_TEXT_CLASSES,
|
|
488
|
+
classNames.errorText
|
|
489
|
+
),
|
|
490
|
+
children: error
|
|
491
|
+
}
|
|
492
|
+
),
|
|
493
|
+
!name && (nameGate !== void 0 ? nameGate : /* @__PURE__ */ jsx(NameGate, {})),
|
|
494
|
+
/* @__PURE__ */ jsx("div", { ref: bottomRef })
|
|
495
|
+
] }),
|
|
496
|
+
/* @__PURE__ */ jsxs(
|
|
497
|
+
"div",
|
|
498
|
+
{
|
|
499
|
+
className: twMerge(DEFAULT_COMPOSER_CLASSES, classNames.composer),
|
|
500
|
+
children: [
|
|
501
|
+
/* @__PURE__ */ jsx(
|
|
502
|
+
"input",
|
|
503
|
+
{
|
|
504
|
+
type: "text",
|
|
505
|
+
name: "website",
|
|
506
|
+
tabIndex: -1,
|
|
507
|
+
autoComplete: "off",
|
|
508
|
+
"aria-hidden": "true",
|
|
509
|
+
className: twMerge(DEFAULT_HONEYPOT_CLASSES, classNames.honeypot)
|
|
510
|
+
}
|
|
511
|
+
),
|
|
512
|
+
/* @__PURE__ */ jsx(
|
|
513
|
+
"textarea",
|
|
514
|
+
{
|
|
515
|
+
value: draft,
|
|
516
|
+
onChange: (e) => setDraft(e.target.value),
|
|
517
|
+
onKeyDown: (e) => {
|
|
518
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
519
|
+
e.preventDefault();
|
|
520
|
+
void submit();
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
rows: 2,
|
|
524
|
+
maxLength: maxMessageLength,
|
|
525
|
+
disabled: !name,
|
|
526
|
+
placeholder: composerPlaceholder(!!name),
|
|
527
|
+
"aria-label": messageAriaLabel,
|
|
528
|
+
className: twMerge(
|
|
529
|
+
// text-base (16px) on mobile: iOS Safari auto-zooms the
|
|
530
|
+
// page when a focused field is smaller than 16px.
|
|
531
|
+
// sm:text-sm restores the desktop size.
|
|
532
|
+
DEFAULT_TEXTAREA_CLASSES,
|
|
533
|
+
classNames.textarea
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
),
|
|
537
|
+
/* @__PURE__ */ jsx(
|
|
538
|
+
"button",
|
|
539
|
+
{
|
|
540
|
+
type: "button",
|
|
541
|
+
onClick: () => void submit(),
|
|
542
|
+
disabled: !name || !canSend || draft.trim().length === 0,
|
|
543
|
+
"aria-label": sendAriaLabel,
|
|
544
|
+
className: twMerge(
|
|
545
|
+
DEFAULT_SEND_BUTTON_CLASSES,
|
|
546
|
+
classNames.sendButton
|
|
547
|
+
),
|
|
548
|
+
children: /* @__PURE__ */ jsx(Send, { size: 16 })
|
|
549
|
+
}
|
|
550
|
+
)
|
|
551
|
+
]
|
|
552
|
+
}
|
|
553
|
+
)
|
|
554
|
+
]
|
|
555
|
+
}
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
) }) })
|
|
559
|
+
] }) });
|
|
560
|
+
}
|
|
561
|
+
var DEFAULT_BUTTON_CLASSES = "relative flex items-center justify-center gap-2 rounded-lg bg-purple-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 print:hidden";
|
|
562
|
+
var DEFAULT_BADGE_CLASSES = "absolute right-2 top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full bg-white";
|
|
563
|
+
function ChatLauncher({
|
|
564
|
+
labels = {},
|
|
565
|
+
classNames = {},
|
|
566
|
+
icon,
|
|
567
|
+
onEvent,
|
|
568
|
+
children
|
|
569
|
+
} = {}) {
|
|
570
|
+
const { setOpen, unread } = useChatActions();
|
|
571
|
+
useChatSync();
|
|
572
|
+
const buttonText = labels.button ?? "Contact me";
|
|
573
|
+
const unreadA11y = labels.unreadAriaLabel ?? ((n) => `${n} unread ${n === 1 ? "reply" : "replies"}`);
|
|
574
|
+
const handleOpen = () => {
|
|
575
|
+
setOpen(true);
|
|
576
|
+
onEvent?.({ type: "ui.launcher.open" });
|
|
577
|
+
};
|
|
578
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
579
|
+
/* @__PURE__ */ jsxs(
|
|
580
|
+
"button",
|
|
581
|
+
{
|
|
582
|
+
type: "button",
|
|
583
|
+
onClick: handleOpen,
|
|
584
|
+
className: twMerge(DEFAULT_BUTTON_CLASSES, classNames.button),
|
|
585
|
+
children: [
|
|
586
|
+
icon,
|
|
587
|
+
buttonText,
|
|
588
|
+
unread > 0 && /* @__PURE__ */ jsx(
|
|
589
|
+
"span",
|
|
590
|
+
{
|
|
591
|
+
"aria-label": unreadA11y(unread),
|
|
592
|
+
className: twMerge(DEFAULT_BADGE_CLASSES, classNames.unreadBadge)
|
|
593
|
+
}
|
|
594
|
+
)
|
|
595
|
+
]
|
|
596
|
+
}
|
|
597
|
+
),
|
|
598
|
+
children === void 0 ? /* @__PURE__ */ jsx(ChatDialog, {}) : children
|
|
599
|
+
] });
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export { ChatDialog, ChatLauncher, MessageBubble, NameGate };
|
|
603
|
+
//# sourceMappingURL=index.js.map
|
|
604
|
+
//# sourceMappingURL=index.js.map
|