@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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +496 -0
  3. package/dist/index.cjs +704 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +4 -0
  6. package/dist/index.d.ts +4 -0
  7. package/dist/index.js +688 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/react/index.cjs +326 -0
  10. package/dist/react/index.cjs.map +1 -0
  11. package/dist/react/index.d.cts +118 -0
  12. package/dist/react/index.d.ts +118 -0
  13. package/dist/react/index.js +306 -0
  14. package/dist/react/index.js.map +1 -0
  15. package/dist/server/index.cjs +704 -0
  16. package/dist/server/index.cjs.map +1 -0
  17. package/dist/server/index.d.cts +98 -0
  18. package/dist/server/index.d.ts +98 -0
  19. package/dist/server/index.js +688 -0
  20. package/dist/server/index.js.map +1 -0
  21. package/dist/server/nextjs.cjs +10 -0
  22. package/dist/server/nextjs.cjs.map +1 -0
  23. package/dist/server/nextjs.d.cts +17 -0
  24. package/dist/server/nextjs.d.ts +17 -0
  25. package/dist/server/nextjs.js +8 -0
  26. package/dist/server/nextjs.js.map +1 -0
  27. package/dist/stores/index.cjs +244 -0
  28. package/dist/stores/index.cjs.map +1 -0
  29. package/dist/stores/index.d.cts +58 -0
  30. package/dist/stores/index.d.ts +58 -0
  31. package/dist/stores/index.js +241 -0
  32. package/dist/stores/index.js.map +1 -0
  33. package/dist/transports/index.cjs +156 -0
  34. package/dist/transports/index.cjs.map +1 -0
  35. package/dist/transports/index.d.cts +48 -0
  36. package/dist/transports/index.d.ts +48 -0
  37. package/dist/transports/index.js +151 -0
  38. package/dist/transports/index.js.map +1 -0
  39. package/dist/types-BcoqxSLg.d.cts +42 -0
  40. package/dist/types-BcoqxSLg.d.ts +42 -0
  41. package/dist/ui/index.cjs +609 -0
  42. package/dist/ui/index.cjs.map +1 -0
  43. package/dist/ui/index.d.cts +127 -0
  44. package/dist/ui/index.d.ts +127 -0
  45. package/dist/ui/index.js +604 -0
  46. package/dist/ui/index.js.map +1 -0
  47. package/package.json +146 -0
@@ -0,0 +1,118 @@
1
+ import React from 'react';
2
+ import * as jotai from 'jotai';
3
+ import { C as ChatMessage } from '../types-BcoqxSLg.js';
4
+
5
+ type ChatClientEvent = {
6
+ type: 'chat.open';
7
+ } | {
8
+ type: 'chat.close';
9
+ } | {
10
+ type: 'chat.send.attempt';
11
+ } | {
12
+ type: 'chat.send.success';
13
+ } | {
14
+ type: 'chat.send.error';
15
+ code: string;
16
+ };
17
+ interface ChatClientConfig {
18
+ /** Base path for the three route files. Default: '/api/chat'. */
19
+ basePath: string;
20
+ /** localStorage key for the session id. Default: 'contact-chat-session'. */
21
+ sessionStorageKey: string;
22
+ /** localStorage key for the visitor's name. Default: 'contact-chat-name'. */
23
+ nameStorageKey: string;
24
+ /** Play a chime when an owner reply arrives. Default: true. */
25
+ chime: boolean;
26
+ /** Client-side max message length; matches the server's `ChatLimits.maxMessageLength`. Default: DEFAULT_CHAT_LIMITS.maxMessageLength. */
27
+ maxMessageLength: number;
28
+ /** Optional analytics callback fired on lifecycle events. */
29
+ onEvent?: (event: ChatClientEvent) => void;
30
+ }
31
+ declare const DEFAULT_CHAT_CLIENT_CONFIG: ChatClientConfig;
32
+ declare function ChatClientProvider({ children, ...overrides }: Partial<ChatClientConfig> & {
33
+ children: React.ReactNode;
34
+ }): React.JSX.Element;
35
+ declare function useChatConfig(): ChatClientConfig;
36
+
37
+ type ChatStatus = 'idle' | 'sending' | 'error' | 'unavailable';
38
+ declare const chatOpenAtom: jotai.PrimitiveAtom<boolean> & {
39
+ init: boolean;
40
+ };
41
+ declare const chatSessionIdAtom: jotai.PrimitiveAtom<string | null> & {
42
+ init: string | null;
43
+ };
44
+ declare const chatNameAtom: jotai.PrimitiveAtom<string | null> & {
45
+ init: string | null;
46
+ };
47
+ declare const chatMessagesAtom: jotai.PrimitiveAtom<ChatMessage[]> & {
48
+ init: ChatMessage[];
49
+ };
50
+ declare const chatCursorAtom: jotai.PrimitiveAtom<number> & {
51
+ init: number;
52
+ };
53
+ declare const chatUnreadAtom: jotai.PrimitiveAtom<number> & {
54
+ init: number;
55
+ };
56
+ declare const chatStatusAtom: jotai.PrimitiveAtom<ChatStatus> & {
57
+ init: ChatStatus;
58
+ };
59
+ declare const chatErrorAtom: jotai.PrimitiveAtom<string | null> & {
60
+ init: string | null;
61
+ };
62
+
63
+ /**
64
+ * Read chat state and act on it. Contains no effects, so it is safe to call
65
+ * from any number of components. The polling lifecycle lives in useChatSync.
66
+ */
67
+ declare function useChatActions(): {
68
+ open: boolean;
69
+ setOpen: (v: boolean | ((prev: boolean) => boolean)) => void;
70
+ messages: ChatMessage[];
71
+ unread: number;
72
+ status: ChatStatus;
73
+ error: string | null;
74
+ name: string | null;
75
+ setName: (raw: string) => boolean;
76
+ send: (text: string) => Promise<void>;
77
+ canSend: boolean;
78
+ };
79
+
80
+ /**
81
+ * Owns the chat polling lifecycle. Mount this EXACTLY ONCE in the tree.
82
+ *
83
+ * Every effect here is instance-scoped, so mounting it from two components
84
+ * would run two polling loops racing on the same cursor. Components that only
85
+ * need to read state or send a message use useChatActions instead.
86
+ */
87
+ declare function useChatSync(): void;
88
+
89
+ declare function getStoredName(nameStorageKey: string): string | null;
90
+ declare function storeName(nameStorageKey: string, name: string): void;
91
+ declare function getStoredSessionId(sessionStorageKey: string): string | null;
92
+ declare function ensureSessionId(sessionStorageKey: string): string;
93
+ /**
94
+ * Merges polled messages into the local list, deduplicating by id.
95
+ *
96
+ * Deduplication is what lets send() append its message optimistically without
97
+ * touching the cursor. Advancing the cursor from the send response would skip
98
+ * anything already sitting at a lower index — an owner reply that arrived
99
+ * between polls would be lost. Instead the cursor advances only from polling,
100
+ * and the visitor's own message is recognised by id when it comes back.
101
+ *
102
+ * Sorted by server timestamp so an owner reply that arrives at a lower index
103
+ * still displays before the visitor's newer message.
104
+ */
105
+ declare function mergeMessages(current: ChatMessage[], incoming: ChatMessage[]): ChatMessage[];
106
+
107
+ /**
108
+ * Two-note arrival chime, synthesized rather than loaded from a file: no asset
109
+ * to host or license, no third-party request, and it works offline.
110
+ *
111
+ * Autoplay policy is satisfied in practice — the visitor must click, name
112
+ * themselves and send before any reply can arrive, so a gesture has occurred.
113
+ *
114
+ * Every failure is swallowed. Audio must never interfere with message delivery.
115
+ */
116
+ declare function playChime(): void;
117
+
118
+ export { type ChatClientConfig, type ChatClientEvent, ChatClientProvider, type ChatStatus, DEFAULT_CHAT_CLIENT_CONFIG, chatCursorAtom, chatErrorAtom, chatMessagesAtom, chatNameAtom, chatOpenAtom, chatSessionIdAtom, chatStatusAtom, chatUnreadAtom, ensureSessionId, getStoredName, getStoredSessionId, mergeMessages, playChime, storeName, useChatActions, useChatConfig, useChatSync };
@@ -0,0 +1,306 @@
1
+ import { createContext, useMemo, useContext, useCallback, useState, useRef, useEffect } from 'react';
2
+ import { jsx } from 'react/jsx-runtime';
3
+ import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
4
+
5
+ // src/react/config.tsx
6
+
7
+ // src/core/types.ts
8
+ var MAX_MESSAGE_LENGTH = 1e3;
9
+ var DEFAULT_CHAT_LIMITS = {
10
+ maxMessageLength: MAX_MESSAGE_LENGTH};
11
+ var DEFAULT_CHAT_CLIENT_CONFIG = {
12
+ basePath: "/api/chat",
13
+ sessionStorageKey: "contact-chat-session",
14
+ nameStorageKey: "contact-chat-name",
15
+ chime: true,
16
+ maxMessageLength: DEFAULT_CHAT_LIMITS.maxMessageLength
17
+ };
18
+ var Ctx = createContext(DEFAULT_CHAT_CLIENT_CONFIG);
19
+ function ChatClientProvider({
20
+ children,
21
+ ...overrides
22
+ }) {
23
+ const merged = useMemo(
24
+ () => ({ ...DEFAULT_CHAT_CLIENT_CONFIG, ...overrides }),
25
+ // eslint-disable-next-line react-hooks/exhaustive-deps
26
+ [
27
+ overrides.basePath,
28
+ overrides.sessionStorageKey,
29
+ overrides.nameStorageKey,
30
+ overrides.chime,
31
+ overrides.maxMessageLength,
32
+ overrides.onEvent
33
+ ]
34
+ );
35
+ return /* @__PURE__ */ jsx(Ctx.Provider, { value: merged, children });
36
+ }
37
+ function useChatConfig() {
38
+ return useContext(Ctx);
39
+ }
40
+ var chatOpenAtom = atom(false);
41
+ var chatSessionIdAtom = atom(null);
42
+ var chatNameAtom = atom(null);
43
+ var chatMessagesAtom = atom([]);
44
+ var chatCursorAtom = atom(0);
45
+ var chatUnreadAtom = atom(0);
46
+ var chatStatusAtom = atom("idle");
47
+ var chatErrorAtom = atom(null);
48
+
49
+ // src/core/name.ts
50
+ var MAX_NAME_LENGTH = 60;
51
+ function sanitizeName(raw) {
52
+ if (typeof raw !== "string") return "";
53
+ return raw.replace(/[\u0000-\u001F\u007F]/g, " ").replace(/\s+/g, " ").trim().slice(0, MAX_NAME_LENGTH);
54
+ }
55
+
56
+ // src/react/client.ts
57
+ function getStoredName(nameStorageKey) {
58
+ if (typeof window === "undefined") return null;
59
+ return window.localStorage.getItem(nameStorageKey);
60
+ }
61
+ function storeName(nameStorageKey, name) {
62
+ window.localStorage.setItem(nameStorageKey, name);
63
+ }
64
+ function getStoredSessionId(sessionStorageKey) {
65
+ if (typeof window === "undefined") return null;
66
+ return window.localStorage.getItem(sessionStorageKey);
67
+ }
68
+ function ensureSessionId(sessionStorageKey) {
69
+ const existing = getStoredSessionId(sessionStorageKey);
70
+ if (existing) return existing;
71
+ const created = window.crypto.randomUUID();
72
+ window.localStorage.setItem(sessionStorageKey, created);
73
+ return created;
74
+ }
75
+ function mergeMessages(current, incoming) {
76
+ const seen = new Set(current.map((message) => message.id));
77
+ const fresh = incoming.filter((message) => !seen.has(message.id));
78
+ if (fresh.length === 0) return current;
79
+ return [...current, ...fresh].sort((a, b) => a.ts - b.ts);
80
+ }
81
+
82
+ // src/react/use-chat-actions.ts
83
+ function useChatActions() {
84
+ const { basePath, sessionStorageKey, nameStorageKey, maxMessageLength, onEvent } = useChatConfig();
85
+ const [open, originalSetOpen] = useAtom(chatOpenAtom);
86
+ const messages = useAtomValue(chatMessagesAtom);
87
+ const setMessages = useSetAtom(chatMessagesAtom);
88
+ const unread = useAtomValue(chatUnreadAtom);
89
+ const [status, setStatus] = useAtom(chatStatusAtom);
90
+ const [error, setError] = useAtom(chatErrorAtom);
91
+ const [name, setNameAtomValue] = useAtom(chatNameAtom);
92
+ const setSessionId = useSetAtom(chatSessionIdAtom);
93
+ const setOpen = useCallback(
94
+ (v) => {
95
+ const next = typeof v === "function" ? v(open) : v;
96
+ originalSetOpen(next);
97
+ if (next === true) onEvent?.({ type: "chat.open" });
98
+ else if (next === false) onEvent?.({ type: "chat.close" });
99
+ },
100
+ [open, originalSetOpen, onEvent]
101
+ );
102
+ const setName = useCallback(
103
+ (raw) => {
104
+ const clean = sanitizeName(raw);
105
+ if (!clean) return false;
106
+ storeName(nameStorageKey, clean);
107
+ setNameAtomValue(clean);
108
+ return true;
109
+ },
110
+ [nameStorageKey, setNameAtomValue]
111
+ );
112
+ const send = useCallback(
113
+ async (text) => {
114
+ const trimmed = text.trim();
115
+ if (trimmed.length === 0 || trimmed.length > maxMessageLength) return;
116
+ const sessionId = ensureSessionId(sessionStorageKey);
117
+ setSessionId(sessionId);
118
+ setStatus("sending");
119
+ setError(null);
120
+ onEvent?.({ type: "chat.send.attempt" });
121
+ try {
122
+ const response = await fetch(`${basePath}/send`, {
123
+ method: "POST",
124
+ headers: { "Content-Type": "application/json" },
125
+ body: JSON.stringify({
126
+ sessionId,
127
+ text: trimmed,
128
+ name: getStoredName(nameStorageKey) ?? "",
129
+ honeypot: ""
130
+ })
131
+ });
132
+ if (!response.ok) {
133
+ const body = await response.json();
134
+ setError(body.error);
135
+ setStatus(
136
+ body.code === "store_unavailable" ? "unavailable" : "error"
137
+ );
138
+ onEvent?.({ type: "chat.send.error", code: body.code });
139
+ return;
140
+ }
141
+ const data = await response.json();
142
+ setMessages((current) => mergeMessages(current, [data.message]));
143
+ setStatus("idle");
144
+ onEvent?.({ type: "chat.send.success" });
145
+ } catch {
146
+ setError("Chat is unavailable right now.");
147
+ setStatus("unavailable");
148
+ onEvent?.({ type: "chat.send.error", code: "network" });
149
+ }
150
+ },
151
+ [
152
+ basePath,
153
+ maxMessageLength,
154
+ nameStorageKey,
155
+ onEvent,
156
+ sessionStorageKey,
157
+ setError,
158
+ setMessages,
159
+ setSessionId,
160
+ setStatus
161
+ ]
162
+ );
163
+ return {
164
+ open,
165
+ setOpen,
166
+ messages,
167
+ unread,
168
+ status,
169
+ error,
170
+ name,
171
+ setName,
172
+ send,
173
+ canSend: status !== "sending"
174
+ };
175
+ }
176
+
177
+ // src/react/chime.ts
178
+ var context = null;
179
+ function getContext() {
180
+ if (typeof window === "undefined") return null;
181
+ const Ctor = window.AudioContext ?? window.webkitAudioContext;
182
+ if (!Ctor) return null;
183
+ if (!context) context = new Ctor();
184
+ return context;
185
+ }
186
+ function playChime() {
187
+ try {
188
+ const audio = getContext();
189
+ if (!audio) return;
190
+ if (audio.state === "suspended") void audio.resume();
191
+ const startedAt = audio.currentTime;
192
+ const notes = [
193
+ { frequency: 880, offset: 0 },
194
+ { frequency: 1320, offset: 0.1 }
195
+ ];
196
+ for (const { frequency, offset } of notes) {
197
+ const oscillator = audio.createOscillator();
198
+ const gain = audio.createGain();
199
+ oscillator.type = "sine";
200
+ oscillator.frequency.value = frequency;
201
+ const start = startedAt + offset;
202
+ const end = start + 0.09;
203
+ gain.gain.setValueAtTime(0, start);
204
+ gain.gain.linearRampToValueAtTime(0.15, start + 0.01);
205
+ gain.gain.linearRampToValueAtTime(0, end);
206
+ oscillator.connect(gain).connect(audio.destination);
207
+ oscillator.start(start);
208
+ oscillator.stop(end + 0.02);
209
+ }
210
+ } catch {
211
+ }
212
+ }
213
+
214
+ // src/react/use-chat-sync.ts
215
+ var FAST_INTERVAL_MS = 4e3;
216
+ var SLOW_INTERVAL_MS = 15e3;
217
+ var IDLE_BEFORE_SLOW_MS = 12e4;
218
+ function useChatSync() {
219
+ const { basePath, sessionStorageKey, nameStorageKey, chime } = useChatConfig();
220
+ const open = useAtomValue(chatOpenAtom);
221
+ const setSessionId = useSetAtom(chatSessionIdAtom);
222
+ const setName = useSetAtom(chatNameAtom);
223
+ const setMessages = useSetAtom(chatMessagesAtom);
224
+ const [cursor, setCursor] = useAtom(chatCursorAtom);
225
+ const setUnread = useSetAtom(chatUnreadAtom);
226
+ const [active, setActive] = useState(true);
227
+ const cursorRef = useRef(cursor);
228
+ const lastMessageAtRef = useRef(0);
229
+ const hasHydratedRef = useRef(false);
230
+ useEffect(() => {
231
+ cursorRef.current = cursor;
232
+ }, [cursor]);
233
+ useEffect(() => {
234
+ const storedSession = getStoredSessionId(sessionStorageKey);
235
+ if (storedSession) setSessionId(storedSession);
236
+ const storedName = getStoredName(nameStorageKey);
237
+ if (storedName) setName(storedName);
238
+ }, [sessionStorageKey, nameStorageKey, setSessionId, setName]);
239
+ useEffect(() => {
240
+ const recompute = () => setActive(document.visibilityState === "visible" || document.hasFocus());
241
+ recompute();
242
+ document.addEventListener("visibilitychange", recompute);
243
+ window.addEventListener("focus", recompute);
244
+ window.addEventListener("blur", recompute);
245
+ return () => {
246
+ document.removeEventListener("visibilitychange", recompute);
247
+ window.removeEventListener("focus", recompute);
248
+ window.removeEventListener("blur", recompute);
249
+ };
250
+ }, []);
251
+ const poll = useCallback(async () => {
252
+ const sessionId = getStoredSessionId(sessionStorageKey);
253
+ if (!sessionId) return;
254
+ try {
255
+ const response = await fetch(
256
+ `${basePath}/poll?sessionId=${encodeURIComponent(sessionId)}&since=${cursorRef.current}`,
257
+ { cache: "no-store" }
258
+ );
259
+ if (!response.ok) return;
260
+ const data = await response.json();
261
+ if (data.messages.length === 0) return;
262
+ lastMessageAtRef.current = Date.now();
263
+ setMessages((current) => mergeMessages(current, data.messages));
264
+ cursorRef.current = data.cursor;
265
+ setCursor(data.cursor);
266
+ const fromOwner = data.messages.filter(
267
+ (message) => message.from === "owner"
268
+ ).length;
269
+ const isHydrating = !hasHydratedRef.current;
270
+ hasHydratedRef.current = true;
271
+ if (fromOwner > 0 && !isHydrating && chime) playChime();
272
+ if (fromOwner > 0 && !open) setUnread((count) => count + fromOwner);
273
+ } catch {
274
+ }
275
+ }, [basePath, chime, open, sessionStorageKey, setCursor, setMessages, setUnread]);
276
+ useEffect(() => {
277
+ if (!active) return;
278
+ if (!open) {
279
+ void poll();
280
+ return;
281
+ }
282
+ let timer = null;
283
+ let cancelled = false;
284
+ const tick = async () => {
285
+ if (cancelled) return;
286
+ await poll();
287
+ if (cancelled) return;
288
+ const since = lastMessageAtRef.current || Date.now();
289
+ const delay = Date.now() - since > IDLE_BEFORE_SLOW_MS ? SLOW_INTERVAL_MS : FAST_INTERVAL_MS;
290
+ timer = setTimeout(tick, delay);
291
+ };
292
+ lastMessageAtRef.current = Date.now();
293
+ void tick();
294
+ return () => {
295
+ cancelled = true;
296
+ if (timer) clearTimeout(timer);
297
+ };
298
+ }, [open, active, poll]);
299
+ useEffect(() => {
300
+ if (open) setUnread(0);
301
+ }, [open, setUnread]);
302
+ }
303
+
304
+ export { ChatClientProvider, DEFAULT_CHAT_CLIENT_CONFIG, chatCursorAtom, chatErrorAtom, chatMessagesAtom, chatNameAtom, chatOpenAtom, chatSessionIdAtom, chatStatusAtom, chatUnreadAtom, ensureSessionId, getStoredName, getStoredSessionId, mergeMessages, playChime, storeName, useChatActions, useChatConfig, useChatSync };
305
+ //# sourceMappingURL=index.js.map
306
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/core/types.ts","../../src/react/config.tsx","../../src/react/atoms.ts","../../src/core/name.ts","../../src/react/client.ts","../../src/react/use-chat-actions.ts","../../src/react/chime.ts","../../src/react/use-chat-sync.ts"],"names":["useAtomValue","useSetAtom","useAtom","useCallback"],"mappings":"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","file":"index.js","sourcesContent":["export type ChatAuthor = 'visitor' | 'owner';\n\nexport interface ChatMessage {\n id: string;\n from: ChatAuthor;\n text: string;\n ts: number;\n}\n\nexport interface ChatSession {\n topicId: number;\n createdAt: number;\n msgCount: number;\n // Optional because sessions created before the name gate shipped have none.\n name?: string;\n}\n\nexport interface SendRequestBody {\n sessionId: string;\n text: string;\n name?: string;\n honeypot?: string;\n}\n\nexport interface SendResponseBody {\n message: ChatMessage;\n cursor: number;\n}\n\nexport interface PollResponseBody {\n messages: ChatMessage[];\n cursor: number;\n}\n\nexport type ChatErrorCode =\n | 'validation'\n | 'rate_limited'\n | 'store_unavailable'\n | 'telegram_unavailable';\n\nexport interface ChatErrorBody {\n code: ChatErrorCode;\n error: string;\n}\n\n// @deprecated Use DEFAULT_CHAT_LIMITS.maxMessageLength instead.\nexport const MAX_MESSAGE_LENGTH = 1000;\n// @deprecated Use DEFAULT_CHAT_LIMITS.maxMessagesPerSession instead.\nexport const MAX_MESSAGES_PER_SESSION = 40;\n// @deprecated Use DEFAULT_CHAT_LIMITS.sessionTtlSeconds instead.\nexport const SESSION_TTL_SECONDS = 604800; // 7 days\n// @deprecated Use DEFAULT_CHAT_LIMITS.ratePerMinute instead.\nexport const RATE_PER_MINUTE = 5;\n// @deprecated Use DEFAULT_CHAT_LIMITS.ratePerDay instead.\nexport const RATE_PER_DAY = 30;\n\nexport interface ChatLimits {\n maxMessageLength: number;\n maxMessagesPerSession: number;\n sessionTtlSeconds: number;\n ratePerMinute: number;\n ratePerDay: number;\n}\n\nexport const DEFAULT_CHAT_LIMITS: ChatLimits = {\n maxMessageLength: MAX_MESSAGE_LENGTH,\n maxMessagesPerSession: MAX_MESSAGES_PER_SESSION,\n sessionTtlSeconds: SESSION_TTL_SECONDS,\n ratePerMinute: RATE_PER_MINUTE,\n ratePerDay: RATE_PER_DAY,\n};\n","'use client';\n\nimport React, { createContext, useContext, useMemo } from 'react';\nimport { DEFAULT_CHAT_LIMITS } from '../core/types.js';\n\nexport type ChatClientEvent =\n | { type: 'chat.open' }\n | { type: 'chat.close' }\n | { type: 'chat.send.attempt' }\n | { type: 'chat.send.success' }\n | { type: 'chat.send.error'; code: string };\n\nexport interface ChatClientConfig {\n /** Base path for the three route files. Default: '/api/chat'. */\n basePath: string;\n /** localStorage key for the session id. Default: 'contact-chat-session'. */\n sessionStorageKey: string;\n /** localStorage key for the visitor's name. Default: 'contact-chat-name'. */\n nameStorageKey: string;\n /** Play a chime when an owner reply arrives. Default: true. */\n chime: boolean;\n /** Client-side max message length; matches the server's `ChatLimits.maxMessageLength`. Default: DEFAULT_CHAT_LIMITS.maxMessageLength. */\n maxMessageLength: number;\n /** Optional analytics callback fired on lifecycle events. */\n onEvent?: (event: ChatClientEvent) => void;\n}\n\nexport const DEFAULT_CHAT_CLIENT_CONFIG: ChatClientConfig = {\n basePath: '/api/chat',\n sessionStorageKey: 'contact-chat-session',\n nameStorageKey: 'contact-chat-name',\n chime: true,\n maxMessageLength: DEFAULT_CHAT_LIMITS.maxMessageLength,\n};\n\nconst Ctx = createContext<ChatClientConfig>(DEFAULT_CHAT_CLIENT_CONFIG);\n\nexport function ChatClientProvider({\n children,\n ...overrides\n}: Partial<ChatClientConfig> & { children: React.ReactNode }): React.JSX.Element {\n const merged = useMemo<ChatClientConfig>(\n () => ({ ...DEFAULT_CHAT_CLIENT_CONFIG, ...overrides }),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n overrides.basePath,\n overrides.sessionStorageKey,\n overrides.nameStorageKey,\n overrides.chime,\n overrides.maxMessageLength,\n overrides.onEvent,\n ]\n );\n\n return <Ctx.Provider value={merged}>{children}</Ctx.Provider>;\n}\n\nexport function useChatConfig(): ChatClientConfig {\n return useContext(Ctx);\n}\n","import { atom } from 'jotai';\nimport type { ChatMessage } from '../core/types.js';\n\nexport type ChatStatus = 'idle' | 'sending' | 'error' | 'unavailable';\n\nexport const chatOpenAtom = atom(false);\nexport const chatSessionIdAtom = atom<string | null>(null);\nexport const chatNameAtom = atom<string | null>(null);\nexport const chatMessagesAtom = atom<ChatMessage[]>([]);\nexport const chatCursorAtom = atom(0);\nexport const chatUnreadAtom = atom(0);\nexport const chatStatusAtom = atom<ChatStatus>('idle');\nexport const chatErrorAtom = atom<string | null>(null);\n","export const MAX_NAME_LENGTH = 60;\n\n/**\n * Normalises a visitor-supplied name for display in the owner's Telegram.\n * Returns '' when nothing usable remains — emptiness is the only failure.\n *\n * Not an injection defence: Telegram does not parse topic names as markup.\n * This exists so control characters and runaway length cannot reach the\n * owner's topic list.\n */\nexport function sanitizeName(raw: unknown): string {\n if (typeof raw !== 'string') return '';\n return raw\n // Strip control characters, including newlines and tabs.\n .replace(/[\\u0000-\\u001F\\u007F]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim()\n .slice(0, MAX_NAME_LENGTH);\n}\n","import type { ChatMessage } from '../core/types.js';\n\nexport function getStoredName(nameStorageKey: string): string | null {\n if (typeof window === 'undefined') return null;\n return window.localStorage.getItem(nameStorageKey);\n}\n\nexport function storeName(nameStorageKey: string, name: string): void {\n window.localStorage.setItem(nameStorageKey, name);\n}\n\nexport function getStoredSessionId(sessionStorageKey: string): string | null {\n if (typeof window === 'undefined') return null;\n return window.localStorage.getItem(sessionStorageKey);\n}\n\nexport function ensureSessionId(sessionStorageKey: string): string {\n const existing = getStoredSessionId(sessionStorageKey);\n if (existing) return existing;\n const created = window.crypto.randomUUID();\n window.localStorage.setItem(sessionStorageKey, created);\n return created;\n}\n\n/**\n * Merges polled messages into the local list, deduplicating by id.\n *\n * Deduplication is what lets send() append its message optimistically without\n * touching the cursor. Advancing the cursor from the send response would skip\n * anything already sitting at a lower index — an owner reply that arrived\n * between polls would be lost. Instead the cursor advances only from polling,\n * and the visitor's own message is recognised by id when it comes back.\n *\n * Sorted by server timestamp so an owner reply that arrives at a lower index\n * still displays before the visitor's newer message.\n */\nexport function mergeMessages(\n current: ChatMessage[],\n incoming: ChatMessage[]\n): ChatMessage[] {\n const seen = new Set(current.map((message) => message.id));\n const fresh = incoming.filter((message) => !seen.has(message.id));\n if (fresh.length === 0) return current;\n return [...current, ...fresh].sort((a, b) => a.ts - b.ts);\n}\n","'use client';\n\nimport { useAtom, useAtomValue, useSetAtom } from 'jotai';\nimport { useCallback } from 'react';\nimport {\n chatErrorAtom,\n chatMessagesAtom,\n chatNameAtom,\n chatOpenAtom,\n chatSessionIdAtom,\n chatStatusAtom,\n chatUnreadAtom,\n} from './atoms.js';\nimport type { ChatErrorBody, SendResponseBody } from '../core/types.js';\nimport { sanitizeName } from '../core/name.js';\nimport {\n ensureSessionId,\n getStoredName,\n mergeMessages,\n storeName,\n} from './client.js';\nimport { useChatConfig } from './config.js';\n\n/**\n * Read chat state and act on it. Contains no effects, so it is safe to call\n * from any number of components. The polling lifecycle lives in useChatSync.\n */\nexport function useChatActions() {\n const { basePath, sessionStorageKey, nameStorageKey, maxMessageLength, onEvent } =\n useChatConfig();\n\n const [open, originalSetOpen] = useAtom(chatOpenAtom);\n const messages = useAtomValue(chatMessagesAtom);\n const setMessages = useSetAtom(chatMessagesAtom);\n const unread = useAtomValue(chatUnreadAtom);\n const [status, setStatus] = useAtom(chatStatusAtom);\n const [error, setError] = useAtom(chatErrorAtom);\n const [name, setNameAtomValue] = useAtom(chatNameAtom);\n const setSessionId = useSetAtom(chatSessionIdAtom);\n\n const setOpen = useCallback(\n (v: boolean | ((prev: boolean) => boolean)) => {\n const next = typeof v === 'function' ? v(open) : v;\n originalSetOpen(next);\n if (next === true) onEvent?.({ type: 'chat.open' });\n else if (next === false) onEvent?.({ type: 'chat.close' });\n },\n [open, originalSetOpen, onEvent]\n );\n\n /** Returns false when the name is unusable, so the gate can stay open. */\n const setName = useCallback(\n (raw: string): boolean => {\n const clean = sanitizeName(raw);\n if (!clean) return false;\n storeName(nameStorageKey, clean);\n setNameAtomValue(clean);\n return true;\n },\n [nameStorageKey, setNameAtomValue]\n );\n\n const send = useCallback(\n async (text: string) => {\n const trimmed = text.trim();\n if (trimmed.length === 0 || trimmed.length > maxMessageLength) return;\n\n const sessionId = ensureSessionId(sessionStorageKey);\n setSessionId(sessionId);\n setStatus('sending');\n setError(null);\n\n onEvent?.({ type: 'chat.send.attempt' });\n\n try {\n const response = await fetch(`${basePath}/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n sessionId,\n text: trimmed,\n name: getStoredName(nameStorageKey) ?? '',\n honeypot: '',\n }),\n });\n\n if (!response.ok) {\n const body = (await response.json()) as ChatErrorBody;\n setError(body.error);\n setStatus(\n body.code === 'store_unavailable' ? 'unavailable' : 'error'\n );\n onEvent?.({ type: 'chat.send.error', code: body.code });\n return;\n }\n\n const data = (await response.json()) as SendResponseBody;\n // Appended optimistically and deduplicated by id on the next poll. The\n // cursor is deliberately NOT advanced here — doing so would skip an\n // owner reply sitting at a lower index.\n setMessages((current) => mergeMessages(current, [data.message]));\n setStatus('idle');\n onEvent?.({ type: 'chat.send.success' });\n } catch {\n setError('Chat is unavailable right now.');\n setStatus('unavailable');\n onEvent?.({ type: 'chat.send.error', code: 'network' });\n }\n },\n [\n basePath,\n maxMessageLength,\n nameStorageKey,\n onEvent,\n sessionStorageKey,\n setError,\n setMessages,\n setSessionId,\n setStatus,\n ]\n );\n\n return {\n open,\n setOpen,\n messages,\n unread,\n status,\n error,\n name,\n setName,\n send,\n canSend: status !== 'sending',\n };\n}\n","let context: AudioContext | null = null;\n\nfunction getContext(): AudioContext | null {\n if (typeof window === 'undefined') return null;\n const Ctor =\n window.AudioContext ??\n (window as unknown as { webkitAudioContext?: typeof AudioContext })\n .webkitAudioContext;\n if (!Ctor) return null;\n if (!context) context = new Ctor();\n return context;\n}\n\n/**\n * Two-note arrival chime, synthesized rather than loaded from a file: no asset\n * to host or license, no third-party request, and it works offline.\n *\n * Autoplay policy is satisfied in practice — the visitor must click, name\n * themselves and send before any reply can arrive, so a gesture has occurred.\n *\n * Every failure is swallowed. Audio must never interfere with message delivery.\n */\nexport function playChime(): void {\n try {\n const audio = getContext();\n if (!audio) return;\n if (audio.state === 'suspended') void audio.resume();\n\n const startedAt = audio.currentTime;\n const notes = [\n { frequency: 880, offset: 0 },\n { frequency: 1320, offset: 0.1 },\n ];\n\n for (const { frequency, offset } of notes) {\n const oscillator = audio.createOscillator();\n const gain = audio.createGain();\n oscillator.type = 'sine';\n oscillator.frequency.value = frequency;\n\n const start = startedAt + offset;\n const end = start + 0.09;\n // Ramp from and to zero so neither edge clicks.\n gain.gain.setValueAtTime(0, start);\n gain.gain.linearRampToValueAtTime(0.15, start + 0.01);\n gain.gain.linearRampToValueAtTime(0, end);\n\n oscillator.connect(gain).connect(audio.destination);\n oscillator.start(start);\n oscillator.stop(end + 0.02);\n }\n } catch {\n // Deliberately silent.\n }\n}\n","'use client';\n\nimport { useAtom, useAtomValue, useSetAtom } from 'jotai';\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport {\n chatCursorAtom,\n chatMessagesAtom,\n chatNameAtom,\n chatOpenAtom,\n chatSessionIdAtom,\n chatUnreadAtom,\n} from './atoms.js';\nimport type { PollResponseBody } from '../core/types.js';\nimport { playChime } from './chime.js';\nimport { getStoredName, getStoredSessionId, mergeMessages } from './client.js';\nimport { useChatConfig } from './config.js';\n\nconst FAST_INTERVAL_MS = 4000;\nconst SLOW_INTERVAL_MS = 15000;\nconst IDLE_BEFORE_SLOW_MS = 120000;\n\n/**\n * Owns the chat polling lifecycle. Mount this EXACTLY ONCE in the tree.\n *\n * Every effect here is instance-scoped, so mounting it from two components\n * would run two polling loops racing on the same cursor. Components that only\n * need to read state or send a message use useChatActions instead.\n */\nexport function useChatSync() {\n const { basePath, sessionStorageKey, nameStorageKey, chime } = useChatConfig();\n\n const open = useAtomValue(chatOpenAtom);\n const setSessionId = useSetAtom(chatSessionIdAtom);\n const setName = useSetAtom(chatNameAtom);\n const setMessages = useSetAtom(chatMessagesAtom);\n const [cursor, setCursor] = useAtom(chatCursorAtom);\n const setUnread = useSetAtom(chatUnreadAtom);\n\n const [active, setActive] = useState(true);\n const cursorRef = useRef(cursor);\n const lastMessageAtRef = useRef(0);\n const hasHydratedRef = useRef(false);\n\n useEffect(() => {\n cursorRef.current = cursor;\n }, [cursor]);\n\n // Load any existing session id and name client-side only. Reading\n // localStorage during atom initialisation would desync server and client\n // renders.\n useEffect(() => {\n const storedSession = getStoredSessionId(sessionStorageKey);\n if (storedSession) setSessionId(storedSession);\n const storedName = getStoredName(nameStorageKey);\n if (storedName) setName(storedName);\n }, [sessionStorageKey, nameStorageKey, setSessionId, setName]);\n\n /**\n * Tracks whether the visitor is plausibly looking at the page.\n *\n * visibilityState alone is not sufficient: some embeddings report 'hidden'\n * for a tab that genuinely has focus, which silently disabled polling\n * entirely and meant owner replies never arrived. Treating a focused\n * document as active fixes that while still standing down for a truly\n * backgrounded tab. focus/blur are listened to as well because hasFocus()\n * changing does not emit visibilitychange.\n */\n useEffect(() => {\n const recompute = () =>\n setActive(document.visibilityState === 'visible' || document.hasFocus());\n recompute();\n document.addEventListener('visibilitychange', recompute);\n window.addEventListener('focus', recompute);\n window.addEventListener('blur', recompute);\n return () => {\n document.removeEventListener('visibilitychange', recompute);\n window.removeEventListener('focus', recompute);\n window.removeEventListener('blur', recompute);\n };\n }, []);\n\n const poll = useCallback(async () => {\n const sessionId = getStoredSessionId(sessionStorageKey);\n if (!sessionId) return;\n\n try {\n const response = await fetch(\n `${basePath}/poll?sessionId=${encodeURIComponent(sessionId)}&since=${cursorRef.current}`,\n { cache: 'no-store' }\n );\n if (!response.ok) return;\n\n const data = (await response.json()) as PollResponseBody;\n if (data.messages.length === 0) return;\n\n lastMessageAtRef.current = Date.now();\n setMessages((current) => mergeMessages(current, data.messages));\n cursorRef.current = data.cursor;\n setCursor(data.cursor);\n\n const fromOwner = data.messages.filter(\n (message) => message.from === 'owner'\n ).length;\n\n // The first poll of a session replays the whole transcript from cursor 0,\n // so chiming here would fire for replies received on a previous visit.\n const isHydrating = !hasHydratedRef.current;\n hasHydratedRef.current = true;\n\n if (fromOwner > 0 && !isHydrating && chime) playChime();\n if (fromOwner > 0 && !open) setUnread((count) => count + fromOwner);\n } catch {\n // Silent: transient poll failures must not surface as errors.\n }\n }, [basePath, chime, open, sessionStorageKey, setCursor, setMessages, setUnread]);\n\n useEffect(() => {\n // Backgrounded tab: poll nothing at all. Becoming active again re-runs this\n // effect, which polls immediately.\n if (!active) return;\n\n if (!open) {\n // Panel closed: a single fetch, enough to light the unread badge.\n void poll();\n return;\n }\n\n let timer: ReturnType<typeof setTimeout> | null = null;\n let cancelled = false;\n\n const tick = async () => {\n if (cancelled) return;\n await poll();\n if (cancelled) return;\n\n const since = lastMessageAtRef.current || Date.now();\n const delay =\n Date.now() - since > IDLE_BEFORE_SLOW_MS\n ? SLOW_INTERVAL_MS\n : FAST_INTERVAL_MS;\n timer = setTimeout(tick, delay);\n };\n\n lastMessageAtRef.current = Date.now();\n void tick();\n\n return () => {\n cancelled = true;\n if (timer) clearTimeout(timer);\n };\n }, [open, active, poll]);\n\n // Opening the panel means the visitor has seen the replies.\n useEffect(() => {\n if (open) setUnread(0);\n }, [open, setUnread]);\n}\n"]}