ss-support-widget 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.
package/index.html ADDED
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Widget test</title>
6
+ </head>
7
+ <body>
8
+ <script>
9
+ window.ChatBotConfig = {
10
+ clientId: "6944264ec246421953fae9f7",
11
+ apiBaseUrl: "http://apiservice.local",
12
+ };
13
+ </script>
14
+ <script type="module" src="/src/element.tsx"></script>
15
+ </body>
16
+ </html>
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "ss-support-widget",
3
+ "version": "1.0.0",
4
+ "description": "Chatbot widget for customer support",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1"
8
+ },
9
+ "keywords": [],
10
+ "author": "SS SRL",
11
+ "license": "ISC",
12
+ "type": "commonjs",
13
+ "devDependencies": {
14
+ "@types/react-dom": "^19.2.3",
15
+ "@vitejs/plugin-react": "^5.1.2",
16
+ "typescript": "^5.9.3",
17
+ "vite": "^7.2.7"
18
+ },
19
+ "dependencies": {
20
+ "@emotion/cache": "^11.14.0",
21
+ "@emotion/react": "^11.14.0",
22
+ "@emotion/styled": "^11.14.1",
23
+ "@mui/icons-material": "^7.3.6",
24
+ "@mui/material": "^7.3.6",
25
+ "react": "^19.2.3",
26
+ "react-dom": "^19.2.3",
27
+ "react-markdown": "^10.1.0"
28
+ }
29
+ }
package/readme.md ADDED
@@ -0,0 +1,7 @@
1
+ <script>
2
+ window.ChatBotConfig = {
3
+ clientId: "abc123",
4
+ apiBaseUrl: "https://api.firma.ro",
5
+ };
6
+ </script>
7
+ <script src="https://cdn.firma.ro/chat-bot-widget.js" async></script>
package/src/App.tsx ADDED
@@ -0,0 +1,94 @@
1
+ // App.tsx
2
+ import React, { useEffect, useMemo, useState } from "react";
3
+ import { Box, Button, IconButton, Paper, TextField, Typography } from "@mui/material";
4
+ import CloseIcon from "@mui/icons-material/Close";
5
+ import { streamChat } from "./sse";
6
+ import { Config } from "./element";
7
+ import { MsgDelta } from "./MsgDelta";
8
+ import ReactMarkdown from "react-markdown";
9
+ import { getHistoryMessages } from "./service";
10
+ import { getThreadId, saveThreadId } from "./session-storage";
11
+ import ChatWidget from "./ChatWidget";
12
+
13
+ export type Msg = { id: string; role: string | "user" | "assistant"; text: string, timestamp?: string; };
14
+ export default function App({ config }: { config: Config }) {
15
+ const [open, setOpen] = useState(false);
16
+ const [input, setInput] = useState("");
17
+ const [threadId, setThreadId] = useState(getThreadId() || config.threadId || "");
18
+ const [msgs, setMsgs] = useState<Msg[]>([]);
19
+ const [sending, setSending] = useState(false);
20
+
21
+ const anchorSx = useMemo(
22
+ () => ({
23
+ position: "fixed",
24
+ right: 16,
25
+ bottom: 16,
26
+ zIndex: 2147483647,
27
+ fontFamily: "system-ui",
28
+ }),
29
+ []
30
+ );
31
+
32
+ useEffect(() => {
33
+ getHistoryMessages(config,
34
+ getThreadId() || config.threadId || "",
35
+ (delta) => {
36
+ const msgs = delta.map((m: MsgDelta) => ({ id: m.id, role: m.role, text: m.text }))
37
+ setMsgs((prev) => [...prev, ...msgs]);
38
+ });
39
+ }, []);
40
+
41
+ function upsertAssistantDelta(delta: MsgDelta) {
42
+ setMsgs((prev) => {
43
+ const last = prev[prev.length - 1];
44
+ if (!last || last.role !== "assistant" || last.id !== delta.id) {
45
+ return [...prev, { id: delta.id, role: delta.role, text: delta.text }];
46
+ }
47
+ const updated = prev.slice();
48
+ updated[updated.length - 1] = { ...last, text: last.text + delta.text };
49
+ return updated;
50
+ });
51
+
52
+
53
+ }
54
+
55
+ async function send() {
56
+ const text = input.trim();
57
+ if (!text || sending) return;
58
+
59
+ setSending(true);
60
+ setInput("");
61
+ setMsgs((m) => [...m, { id: crypto.randomUUID(), role: "user", text }]);
62
+
63
+ try {
64
+ await streamChat({
65
+ url: config.apiBaseUrl + "/chat-support",
66
+ token: config.token,
67
+ clientId: config.clientId,
68
+ threadId: threadId,
69
+ body: text,
70
+ onDelta: (delta) => {
71
+ upsertAssistantDelta(delta)
72
+ setThreadId(delta.threadId);
73
+ saveThreadId(delta.threadId);
74
+ },
75
+ });
76
+ } catch {
77
+ setMsgs((m) => [...m, { id: crypto.randomUUID(), role: "assistant", text: "Eroare la trimitere." }]);
78
+ } finally {
79
+ setSending(false);
80
+ }
81
+ }
82
+
83
+ return (
84
+ <ChatWidget
85
+ anchorSx={anchorSx}
86
+ open={open}
87
+ setOpen={setOpen}
88
+ msgs={msgs}
89
+ input={input}
90
+ setInput={setInput}
91
+ send={send}
92
+ sending={sending} />
93
+ );
94
+ }
@@ -0,0 +1,395 @@
1
+ import React, { useEffect, useRef } from "react";
2
+ import {
3
+ Avatar,
4
+ Badge,
5
+ Box,
6
+ Button,
7
+ Chip,
8
+ IconButton,
9
+ Paper,
10
+ TextField,
11
+ Typography,
12
+ } from "@mui/material";
13
+ import { useTheme, useMediaQuery } from "@mui/material";
14
+ import CloseIcon from "@mui/icons-material/Close";
15
+ import SendRoundedIcon from "@mui/icons-material/SendRounded";
16
+ import ChatBubbleOutlineRoundedIcon from "@mui/icons-material/ChatBubbleOutlineRounded";
17
+ import SmartToyRoundedIcon from "@mui/icons-material/SmartToyRounded";
18
+ import PersonRoundedIcon from "@mui/icons-material/PersonRounded";
19
+ import ReactMarkdown from "react-markdown";
20
+ import { Msg } from "./App";
21
+
22
+
23
+ export function ChatWidget({
24
+ open,
25
+ setOpen,
26
+ msgs,
27
+ input,
28
+ setInput,
29
+ sending,
30
+ send,
31
+ anchorSx,
32
+ }: {
33
+ open: boolean;
34
+ setOpen: (v: boolean) => void;
35
+ msgs: Msg[];
36
+ input: string;
37
+ setInput: (v: string) => void;
38
+ sending: boolean;
39
+ send: () => void;
40
+ anchorSx?: any;
41
+ }) {
42
+ const endRef = useRef<HTMLDivElement | null>(null);
43
+ const theme = useTheme();
44
+ const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
45
+
46
+ useEffect(() => {
47
+ endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
48
+ }, [msgs.length, sending]);
49
+
50
+ useEffect(() => {
51
+ if (!open) return;
52
+ endRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
53
+ }, [open]);
54
+
55
+ return (
56
+ <Box sx={anchorSx}>
57
+ {!open && (
58
+ <Button
59
+ variant="contained"
60
+ onClick={() => setOpen(true)}
61
+ startIcon={<ChatBubbleOutlineRoundedIcon />}
62
+ sx={{
63
+ borderRadius: 999,
64
+ px: 2,
65
+ py: 1,
66
+ textTransform: "none",
67
+ boxShadow: "0 10px 30px rgba(0,0,0,.18)",
68
+ }}
69
+ >
70
+ Chat
71
+ </Button>
72
+ )}
73
+
74
+ {open && (
75
+ <Paper
76
+ elevation={isMobile ? 0 : 10}
77
+ sx={{
78
+ width: isMobile ? "100vw" : 380,
79
+ height: isMobile ? "100vh" : 560,
80
+ maxWidth: "100vw",
81
+ maxHeight: "100vh",
82
+ borderRadius: isMobile ? 0 : 4,
83
+ position: isMobile ? "fixed" : "relative",
84
+ inset: isMobile ? 0 : "auto",
85
+ overflow: "hidden",
86
+ border: isMobile ? "none" : "1px solid",
87
+ borderColor: "divider",
88
+ boxShadow: isMobile ? "none" : "0 18px 60px rgba(0,0,0,.28)",
89
+ }}
90
+ >
91
+ <Box sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
92
+
93
+ <Box
94
+ sx={{
95
+ px: 1.75,
96
+ py: 1.25,
97
+ display: "flex",
98
+ alignItems: "center",
99
+ gap: 1,
100
+ borderBottom: "1px solid",
101
+ borderColor: "divider",
102
+ background:
103
+ "linear-gradient(135deg, rgba(25,118,210,.18), rgba(156,39,176,.14))",
104
+ backdropFilter: "blur(10px)",
105
+ }}
106
+ >
107
+ <Badge
108
+ overlap="circular"
109
+ variant="dot"
110
+ color="success"
111
+ sx={{
112
+ "& .MuiBadge-badge": {
113
+ width: 10,
114
+ height: 10,
115
+ borderRadius: 999,
116
+ border: "2px solid",
117
+ borderColor: "background.paper",
118
+ },
119
+ }}
120
+ >
121
+ <Avatar sx={{ bgcolor: "primary.main" }}>
122
+ <SmartToyRoundedIcon />
123
+ </Avatar>
124
+ </Badge>
125
+
126
+ <Box sx={{ flex: 1, minWidth: 0 }}>
127
+ <Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.1 }}>
128
+ Asistent
129
+ </Typography>
130
+ <Typography variant="caption" sx={{ color: "text.secondary" }}>
131
+ Online
132
+ </Typography>
133
+ </Box>
134
+
135
+ <IconButton
136
+ size="small"
137
+ onClick={() => setOpen(false)}
138
+ sx={{
139
+ bgcolor: "rgba(255,255,255,.55)",
140
+ border: "1px solid",
141
+ borderColor: "divider",
142
+ "&:hover": { bgcolor: "rgba(255,255,255,.75)" },
143
+ }}
144
+ >
145
+ <CloseIcon fontSize="small" />
146
+ </IconButton>
147
+ </Box>
148
+
149
+ <Box
150
+ sx={{
151
+ flex: 1,
152
+ minHeight: 0,
153
+ position: "relative",
154
+ height: 400,
155
+ overflow: "auto",
156
+ px: 1.5,
157
+ py: 1.5,
158
+ bgcolor: "background.default",
159
+ backgroundImage:
160
+ "radial-gradient(circle at 20% 10%, rgba(25,118,210,.10), transparent 45%)," +
161
+ "radial-gradient(circle at 80% 20%, rgba(156,39,176,.10), transparent 50%)," +
162
+ "radial-gradient(circle at 40% 90%, rgba(0,200,83,.08), transparent 45%)",
163
+ "&::-webkit-scrollbar": { width: 10 },
164
+ "&::-webkit-scrollbar-thumb": {
165
+ backgroundColor: "rgba(0,0,0,.18)",
166
+ borderRadius: 999,
167
+ border: "3px solid transparent",
168
+ backgroundClip: "content-box",
169
+ },
170
+ }}
171
+ >
172
+ {msgs.map((m) => {
173
+ const isUser = m.role === "user";
174
+ return (
175
+ <Box
176
+ key={m.id}
177
+ sx={{
178
+ display: "flex",
179
+ justifyContent: isUser ? "flex-end" : "flex-start",
180
+ gap: 1,
181
+ mb: 1.2,
182
+ }}
183
+ >
184
+ {!isUser && (
185
+ <Avatar
186
+ sx={{
187
+ width: 30,
188
+ height: 30,
189
+ mt: 0.25,
190
+ bgcolor: "primary.main",
191
+ }}
192
+ >
193
+ <SmartToyRoundedIcon sx={{ fontSize: 18 }} />
194
+ </Avatar>
195
+ )}
196
+
197
+ <Box sx={{ maxWidth: "78%" }}>
198
+ {m.timestamp && (
199
+ <Box sx={{ display: "flex", justifyContent: isUser ? "flex-end" : "flex-start", mb: 0.5 }}>
200
+ <Chip size="small" label={m.timestamp} variant="outlined" sx={{ height: 22 }} />
201
+ </Box>
202
+ )}
203
+
204
+ <Box
205
+ sx={{
206
+ position: "relative",
207
+ px: 1.4,
208
+ py: 1.05,
209
+ borderRadius: 3,
210
+ border: "1px solid",
211
+ borderColor: "divider",
212
+ bgcolor: isUser ? "primary.main" : "background.paper",
213
+ color: isUser ? "primary.contrastText" : "text.primary",
214
+ boxShadow: isUser
215
+ ? "0 10px 30px rgba(25,118,210,.22)"
216
+ : "0 10px 30px rgba(0,0,0,.10)",
217
+ whiteSpace: "pre-wrap",
218
+ "&:before": {
219
+ content: '""',
220
+ position: "absolute",
221
+ top: 14,
222
+ width: 10,
223
+ height: 10,
224
+ transform: "rotate(45deg)",
225
+ bgcolor: isUser ? "primary.main" : "background.paper",
226
+ borderLeft: isUser ? "none" : "1px solid",
227
+ borderTop: isUser ? "none" : "1px solid",
228
+ borderColor: "divider",
229
+ right: isUser ? -5 : "auto",
230
+ left: isUser ? "auto" : -5,
231
+ },
232
+ "& p": { margin: 0 },
233
+ "& a": { color: "inherit", textDecoration: "underline" },
234
+ "& code": {
235
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
236
+ fontSize: "0.9em",
237
+ padding: "2px 6px",
238
+ borderRadius: 8,
239
+ backgroundColor: isUser ? "rgba(255,255,255,.14)" : "rgba(0,0,0,.06)",
240
+ },
241
+ "& pre": {
242
+ margin: 0,
243
+ marginTop: 8,
244
+ padding: 12,
245
+ overflow: "auto",
246
+ borderRadius: 12,
247
+ backgroundColor: isUser ? "rgba(255,255,255,.12)" : "rgba(0,0,0,.05)",
248
+ },
249
+ }}
250
+ >
251
+ <ReactMarkdown>{m.text}</ReactMarkdown>
252
+ </Box>
253
+ </Box>
254
+
255
+ {isUser && (
256
+ <Avatar
257
+ sx={{
258
+ width: 30,
259
+ height: 30,
260
+ mt: 0.25,
261
+ bgcolor: "grey.900",
262
+ }}
263
+ >
264
+ <PersonRoundedIcon sx={{ fontSize: 18 }} />
265
+ </Avatar>
266
+ )}
267
+ </Box>
268
+ );
269
+ })}
270
+
271
+ {sending && (
272
+ <Box sx={{ display: "flex", alignItems: "flex-end", gap: 1, mb: 1.2 }}>
273
+ <Avatar sx={{ width: 30, height: 30, bgcolor: "primary.main" }}>
274
+ <SmartToyRoundedIcon sx={{ fontSize: 18 }} />
275
+ </Avatar>
276
+
277
+ <Box
278
+ sx={{
279
+ px: 1.4,
280
+ py: 1.1,
281
+ borderRadius: 3,
282
+ border: "1px solid",
283
+ borderColor: "divider",
284
+ bgcolor: "background.paper",
285
+ boxShadow: "0 10px 30px rgba(0,0,0,.10)",
286
+ display: "flex",
287
+ alignItems: "center",
288
+ gap: 0.6,
289
+ }}
290
+ >
291
+ <Box
292
+ sx={{
293
+ width: 6,
294
+ height: 6,
295
+ borderRadius: 999,
296
+ bgcolor: "text.secondary",
297
+ animation: "dot 1.1s infinite ease-in-out",
298
+ "@keyframes dot": {
299
+ "0%, 80%, 100%": { transform: "scale(0.6)", opacity: 0.4 },
300
+ "40%": { transform: "scale(1)", opacity: 1 },
301
+ },
302
+ }}
303
+ />
304
+ <Box
305
+ sx={{
306
+ width: 6,
307
+ height: 6,
308
+ borderRadius: 999,
309
+ bgcolor: "text.secondary",
310
+ animation: "dot 1.1s infinite ease-in-out",
311
+ animationDelay: "0.15s",
312
+ "@keyframes dot": {
313
+ "0%, 80%, 100%": { transform: "scale(0.6)", opacity: 0.4 },
314
+ "40%": { transform: "scale(1)", opacity: 1 },
315
+ },
316
+ }}
317
+ />
318
+ <Box
319
+ sx={{
320
+ width: 6,
321
+ height: 6,
322
+ borderRadius: 999,
323
+ bgcolor: "text.secondary",
324
+ animation: "dot 1.1s infinite ease-in-out",
325
+ animationDelay: "0.3s",
326
+ "@keyframes dot": {
327
+ "0%, 80%, 100%": { transform: "scale(0.6)", opacity: 0.4 },
328
+ "40%": { transform: "scale(1)", opacity: 1 },
329
+ },
330
+ }}
331
+ />
332
+ </Box>
333
+ </Box>
334
+ )}
335
+
336
+ <div ref={endRef} />
337
+ </Box>
338
+
339
+ <Box
340
+ sx={{
341
+ p: 1.25,
342
+ borderTop: "1px solid",
343
+ borderColor: "divider",
344
+ display: "flex",
345
+ gap: 1,
346
+ bgcolor: "background.paper",
347
+ }}
348
+ >
349
+ <TextField
350
+ size="small"
351
+ fullWidth
352
+ value={input}
353
+ disabled={sending}
354
+ onChange={(e) => setInput(e.target.value)}
355
+ onKeyDown={(e) => {
356
+ if (e.key === "Enter" && !e.shiftKey) {
357
+ e.preventDefault();
358
+ send();
359
+ }
360
+ }}
361
+ placeholder="Scrie un mesaj"
362
+ multiline
363
+ maxRows={4}
364
+ sx={{
365
+ "& .MuiOutlinedInput-root": {
366
+ borderRadius: 999,
367
+ pr: 0.75,
368
+ backgroundColor: "rgba(0,0,0,.02)",
369
+ },
370
+ }}
371
+ InputProps={{
372
+ endAdornment: (
373
+ <IconButton
374
+ size="small"
375
+ onClick={send}
376
+ disabled={sending || !input.trim()}
377
+ sx={{
378
+ ml: 0.5,
379
+ borderRadius: 999,
380
+ }}
381
+ >
382
+ <SendRoundedIcon />
383
+ </IconButton>
384
+ ),
385
+ }}
386
+ />
387
+ </Box>
388
+
389
+ </Box>
390
+ </Paper>
391
+ )}
392
+ </Box>
393
+ );
394
+ }
395
+ export default ChatWidget;
@@ -0,0 +1,6 @@
1
+ export type MsgDelta = {
2
+ id: string;
3
+ threadId: string;
4
+ text: string;
5
+ role: string | "user" | "assistant";
6
+ };
@@ -0,0 +1,87 @@
1
+ import React from "react";
2
+ import { createRoot, Root } from "react-dom/client";
3
+ import createCache from "@emotion/cache";
4
+ import { CacheProvider } from "@emotion/react";
5
+ import { CssBaseline, ThemeProvider, createTheme } from "@mui/material";
6
+ import App from "./App";
7
+ import { ensureViewportMeta } from "./hooks";
8
+
9
+ export type Config = {
10
+ clientId: string;
11
+ apiBaseUrl: string;
12
+ threadId?: string;
13
+ token?: string;
14
+ userId?: string;
15
+ };
16
+
17
+ class ChatBotElement extends HTMLElement {
18
+ private root?: Root;
19
+ private mountEl?: HTMLDivElement;
20
+
21
+ connectedCallback() {
22
+ const cfg = this.readConfig();
23
+ if (!cfg) return;
24
+
25
+ const shadow = this.attachShadow({ mode: "open" });
26
+
27
+ const styleHost = document.createElement("style");
28
+ styleHost.textContent = `
29
+ :host { all: initial; }
30
+ `;
31
+ shadow.appendChild(styleHost);
32
+
33
+ this.mountEl = document.createElement("div");
34
+ shadow.appendChild(this.mountEl);
35
+
36
+ ensureViewportMeta();
37
+
38
+ const cache = createCache({
39
+ key: "chatbot",
40
+ container: shadow as unknown as HTMLElement,
41
+ });
42
+
43
+ const theme = createTheme();
44
+
45
+ this.root = createRoot(this.mountEl);
46
+ this.root.render(
47
+ <CacheProvider value={cache}>
48
+ <ThemeProvider theme={theme}>
49
+ <CssBaseline />
50
+ <App config={cfg} />
51
+ </ThemeProvider>
52
+ </CacheProvider>
53
+ );
54
+ }
55
+
56
+ disconnectedCallback() {
57
+ this.root?.unmount();
58
+ this.root = undefined;
59
+ }
60
+
61
+ private readConfig(): Config | null {
62
+ const raw = (window as any).ChatBotConfig as Partial<Config> | undefined;
63
+
64
+ const clientId = this.getAttribute("client-id") || raw?.clientId || "";
65
+ const apiBaseUrl = this.getAttribute("api-base-url") || raw?.apiBaseUrl || "";
66
+ const threadId = this.getAttribute("thread-id") || raw?.threadId || "";
67
+
68
+ const token = this.getAttribute("token") || raw?.token || undefined;
69
+ const userId = this.getAttribute("user-id") || raw?.userId || undefined;
70
+
71
+ if (!clientId || !apiBaseUrl) return null;
72
+ return { clientId, apiBaseUrl, threadId, token, userId };
73
+ }
74
+ }
75
+
76
+ if (!customElements.get("chat-bot")) {
77
+ customElements.define("chat-bot", ChatBotElement);
78
+ }
79
+
80
+ (function autoMount() {
81
+ const cfg = (window as any).ChatBotConfig;
82
+ if (!cfg) return;
83
+ if (document.querySelector("chat-bot")) return;
84
+
85
+ const el = document.createElement("chat-bot");
86
+ document.body.appendChild(el);
87
+ })();
package/src/hooks.ts ADDED
@@ -0,0 +1,12 @@
1
+ export function ensureViewportMeta() {
2
+ if (typeof document === "undefined") return;
3
+
4
+ const existing = document.querySelector('meta[name="viewport"]');
5
+ if (existing) return;
6
+
7
+ const meta = document.createElement("meta");
8
+ meta.name = "viewport";
9
+ meta.content = "width=device-width, initial-scale=1";
10
+ document.head.appendChild(meta);
11
+ }
12
+
package/src/service.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { Config } from "./element";
2
+ import { MsgDelta } from "./MsgDelta";
3
+
4
+ export async function getHistoryMessages(config: Config, threadId: string, onSucces: (delta: MsgDelta[]) => void): Promise<void> {
5
+ fetch(config.apiBaseUrl + "/chat-support-history", {
6
+ method: "GET",
7
+ headers: {
8
+ "Content-Type": "application/json",
9
+ ...(config.clientId ? { "X-Client-Id": config.clientId } : {}),
10
+ "X-Thread-Id": threadId,
11
+ },
12
+ })
13
+ .then(res => {
14
+ if (!res.ok) {
15
+ throw new Error("Request failed");
16
+ }
17
+ return res.json();
18
+ })
19
+ .then(data => {
20
+ onSucces(data.map((d: any) => JSON.parse(d) as MsgDelta));
21
+ })
22
+ .catch(err => {
23
+ console.error(err);
24
+ });
25
+
26
+ }