@tmls-ai/support 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/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @tmls/support
2
+
3
+ Embeddable Timeless support widget — bottom-right report overlay (type /
4
+ severity / screenshot) + ticket thread. Auto-captures context and talks to
5
+ `tmls-support-api`.
6
+
7
+ ## Usage (React)
8
+
9
+ ```tsx
10
+ import { SupportWidget } from '@tmls/support';
11
+
12
+ <SupportWidget
13
+ productId="drona"
14
+ apiUrl="https://support.timeless.app"
15
+ getToken={() => fetch('/api/support-token').then(r => r.text())}
16
+ user={{ id, email, name, plan }}
17
+ accent="#0a84ff"
18
+ appVersion={import.meta.env.VITE_BUILD}
19
+ getContext={() => ({ route: location.pathname, config, specs, conversationId })}
20
+ />
21
+ ```
22
+
23
+ ## Usage (any app, Shadow-DOM isolated)
24
+
25
+ ```ts
26
+ import { mountSupportWidget } from '@tmls/support';
27
+ const unmount = mountSupportWidget({ productId, apiUrl, getToken, getContext });
28
+ ```
29
+
30
+ ## Host backend contract
31
+ Expose an endpoint that signs a short-lived JWT with the product's
32
+ `JWT_SECRET_<PRODUCT>` (same secret the Worker holds):
33
+
34
+ ```ts
35
+ jwt.sign({ productId: 'drona', userId, email, plan }, JWT_SECRET_DRONA, { expiresIn: '10m' })
36
+ ```
37
+
38
+ The widget never holds a secret; the server trusts the signed token.
39
+
40
+ ## Build
41
+
42
+ ```bash
43
+ npm run build # → dist/ (ESM + types)
44
+ ```
45
+
46
+ > v1: `src/types.ts` mirrors `tmls-support-api/src/types.ts` — keep in sync.
@@ -0,0 +1,52 @@
1
+ import * as react from 'react';
2
+
3
+ type ReportType = 'bug' | 'question' | 'feedback';
4
+ type Severity = 'blocking' | 'annoying' | 'minor';
5
+ interface Conversation {
6
+ id: string;
7
+ ticket_number: number;
8
+ type: ReportType;
9
+ severity: Severity;
10
+ status: 'open' | 'resolved';
11
+ created_at: string;
12
+ last_message_at: string;
13
+ }
14
+ interface Message {
15
+ id: string;
16
+ author: 'user' | 'agent' | 'system';
17
+ body: string;
18
+ created_at: string;
19
+ }
20
+ /** Everything the host product passes to mount the widget. */
21
+ interface SupportWidgetProps {
22
+ /** Registered product id, e.g. 'drona'. */
23
+ productId: string;
24
+ /** tmls-support-api base URL. */
25
+ apiUrl: string;
26
+ /** Returns a short-lived signed JWT from the product's own backend. */
27
+ getToken: () => Promise<string>;
28
+ /** Identity (purely for display; the server trusts the token, not this). */
29
+ user?: {
30
+ id: string;
31
+ email?: string;
32
+ name?: string;
33
+ plan?: string;
34
+ };
35
+ /** Accent color for theming (defaults to a neutral blue). */
36
+ accent?: string;
37
+ /** Product-specific context attached to every report (DRONA: { config, specs }). */
38
+ getContext?: () => Record<string, unknown>;
39
+ /** App version string for the base context. */
40
+ appVersion?: string;
41
+ }
42
+
43
+ declare function SupportWidget(props: SupportWidgetProps): react.JSX.Element;
44
+
45
+ /**
46
+ * Vanilla mount for any app (React or not). Renders the widget into a Shadow
47
+ * DOM root so the host's CSS and the widget's CSS can never collide. React apps
48
+ * can also just render <SupportWidget /> directly.
49
+ */
50
+ declare function mountSupportWidget(props: SupportWidgetProps): () => void;
51
+
52
+ export { type Conversation, type Message, type ReportType, type Severity, SupportWidget, type SupportWidgetProps, mountSupportWidget };
package/dist/index.js ADDED
@@ -0,0 +1,342 @@
1
+ // src/index.ts
2
+ import { createRoot } from "react-dom/client";
3
+ import { createElement } from "react";
4
+
5
+ // src/SupportWidget.tsx
6
+ import { useCallback, useEffect, useMemo, useState } from "react";
7
+ import { domToBlob } from "modern-screenshot";
8
+
9
+ // src/context.ts
10
+ var MAX_ERRORS = 15;
11
+ var errorBuffer = [];
12
+ var installed = false;
13
+ var sessionId = Math.random().toString(36).slice(2);
14
+ function installErrorCapture() {
15
+ if (installed || typeof window === "undefined") return;
16
+ installed = true;
17
+ const push = (s) => {
18
+ errorBuffer.push(s.slice(0, 300));
19
+ if (errorBuffer.length > MAX_ERRORS) errorBuffer.shift();
20
+ };
21
+ const origError = console.error.bind(console);
22
+ console.error = (...args) => {
23
+ push(args.map(String).join(" "));
24
+ origError(...args);
25
+ };
26
+ window.addEventListener("error", (e) => push(`${e.message} @ ${e.filename}:${e.lineno}`));
27
+ window.addEventListener("unhandledrejection", (e) => push(`unhandledrejection: ${String(e.reason)}`));
28
+ }
29
+ function collectBaseContext(appVersion) {
30
+ return {
31
+ route: typeof location !== "undefined" ? location.pathname + location.search : void 0,
32
+ appVersion,
33
+ env: void 0,
34
+ device: typeof navigator !== "undefined" ? navigator.platform : void 0,
35
+ browser: typeof navigator !== "undefined" ? navigator.userAgent : void 0,
36
+ viewport: typeof window !== "undefined" ? { w: window.innerWidth, h: window.innerHeight } : void 0,
37
+ recentErrors: [...errorBuffer],
38
+ sessionId,
39
+ ts: (/* @__PURE__ */ new Date()).toISOString()
40
+ };
41
+ }
42
+
43
+ // src/api.ts
44
+ var SupportApi = class {
45
+ constructor(apiUrl, getToken) {
46
+ this.apiUrl = apiUrl;
47
+ this.getToken = getToken;
48
+ }
49
+ async auth() {
50
+ return { Authorization: `Bearer ${await this.getToken()}` };
51
+ }
52
+ async createReport(input) {
53
+ const res = await fetch(`${this.apiUrl}/v1/reports`, {
54
+ method: "POST",
55
+ headers: { ...await this.auth(), "Content-Type": "application/json" },
56
+ body: JSON.stringify(input)
57
+ });
58
+ if (!res.ok) throw new Error(`report failed: ${res.status}`);
59
+ return res.json();
60
+ }
61
+ async uploadScreenshot(blob) {
62
+ const res = await fetch(`${this.apiUrl}/v1/uploads`, {
63
+ method: "POST",
64
+ headers: { ...await this.auth(), "Content-Type": blob.type || "image/png" },
65
+ body: blob
66
+ });
67
+ if (!res.ok) throw new Error(`upload failed: ${res.status}`);
68
+ return (await res.json()).key;
69
+ }
70
+ async listConversations() {
71
+ const res = await fetch(`${this.apiUrl}/v1/conversations`, { headers: await this.auth() });
72
+ return res.ok ? res.json() : [];
73
+ }
74
+ async listMessages(conversationId) {
75
+ const res = await fetch(`${this.apiUrl}/v1/conversations/${conversationId}/messages`, { headers: await this.auth() });
76
+ return res.ok ? res.json() : [];
77
+ }
78
+ async reply(conversationId, body) {
79
+ await fetch(`${this.apiUrl}/v1/conversations/${conversationId}/messages`, {
80
+ method: "POST",
81
+ headers: { ...await this.auth(), "Content-Type": "application/json" },
82
+ body: JSON.stringify({ body })
83
+ });
84
+ }
85
+ };
86
+
87
+ // src/SupportWidget.tsx
88
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
89
+ var TYPES = [
90
+ { id: "bug", label: "Bug" },
91
+ { id: "question", label: "Question" },
92
+ { id: "feedback", label: "Feedback" }
93
+ ];
94
+ var SEVERITIES = [
95
+ { id: "blocking", label: "Blocking" },
96
+ { id: "annoying", label: "Annoying" },
97
+ { id: "minor", label: "Minor" }
98
+ ];
99
+ function SupportWidget(props) {
100
+ const { productId, apiUrl, getToken, accent = "#0a84ff", getContext, appVersion } = props;
101
+ const api = useMemo(() => new SupportApi(apiUrl, getToken), [apiUrl, getToken]);
102
+ const [open, setOpen] = useState(false);
103
+ const [view, setView] = useState("new");
104
+ const [type, setType] = useState("bug");
105
+ const [severity, setSeverity] = useState("annoying");
106
+ const [message, setMessage] = useState("");
107
+ const [withShot, setWithShot] = useState(true);
108
+ const [shotPreview, setShotPreview] = useState(null);
109
+ const [sending, setSending] = useState(false);
110
+ const [lastTicket, setLastTicket] = useState(null);
111
+ const [threads, setThreads] = useState([]);
112
+ const [activeId, setActiveId] = useState(null);
113
+ const [messages, setMessages] = useState([]);
114
+ const [reply, setReply] = useState("");
115
+ useEffect(() => {
116
+ installErrorCapture();
117
+ }, []);
118
+ const captureShot = useCallback(async () => {
119
+ try {
120
+ const blob = await domToBlob(document.body, { quality: 0.8, scale: 0.75 });
121
+ setShotPreview(URL.createObjectURL(blob));
122
+ captureShot._blob = blob;
123
+ } catch {
124
+ }
125
+ }, []);
126
+ const submit = useCallback(async () => {
127
+ if (!message.trim() || sending) return;
128
+ setSending(true);
129
+ try {
130
+ let attachmentKeys = [];
131
+ const blob = withShot ? captureShot._blob : void 0;
132
+ if (blob) {
133
+ try {
134
+ attachmentKeys = [await api.uploadScreenshot(blob)];
135
+ } catch {
136
+ }
137
+ }
138
+ const { ticketNumber } = await api.createReport({
139
+ type,
140
+ severity,
141
+ message: message.trim(),
142
+ context: { base: collectBaseContext(appVersion), product: getContext?.() },
143
+ attachmentKeys
144
+ });
145
+ setLastTicket(ticketNumber);
146
+ setMessage("");
147
+ setShotPreview(null);
148
+ setView("sent");
149
+ } catch {
150
+ setLastTicket(null);
151
+ setView("sent");
152
+ } finally {
153
+ setSending(false);
154
+ }
155
+ }, [api, type, severity, message, withShot, getContext, appVersion, sending, captureShot]);
156
+ const openThreads = useCallback(async () => {
157
+ setThreads(await api.listConversations());
158
+ setView("threads");
159
+ }, [api]);
160
+ const openThread = useCallback(async (id) => {
161
+ setActiveId(id);
162
+ setMessages(await api.listMessages(id));
163
+ setView("thread");
164
+ }, [api]);
165
+ const sendReply = useCallback(async () => {
166
+ if (!reply.trim() || !activeId) return;
167
+ await api.reply(activeId, reply.trim());
168
+ setReply("");
169
+ setMessages(await api.listMessages(activeId));
170
+ }, [api, reply, activeId]);
171
+ const chip = (active) => ({
172
+ padding: "6px 12px",
173
+ borderRadius: 999,
174
+ fontSize: 13,
175
+ cursor: "pointer",
176
+ border: "1px solid",
177
+ borderColor: active ? accent : "#3a3a42",
178
+ background: active ? accent : "transparent",
179
+ color: active ? "#fff" : "#c8c8d0"
180
+ });
181
+ return /* @__PURE__ */ jsxs("div", { style: { position: "fixed", bottom: 20, right: 20, zIndex: 2147483e3, fontFamily: "system-ui, sans-serif" }, children: [
182
+ open && /* @__PURE__ */ jsxs("div", { style: {
183
+ position: "absolute",
184
+ bottom: 60,
185
+ right: 0,
186
+ width: 360,
187
+ maxHeight: 560,
188
+ background: "#141418",
189
+ color: "#e8e8ea",
190
+ border: "0.5px solid rgba(255,255,255,0.12)",
191
+ borderRadius: 16,
192
+ boxShadow: "0 16px 48px rgba(0,0,0,0.5)",
193
+ overflow: "hidden",
194
+ display: "flex",
195
+ flexDirection: "column"
196
+ }, children: [
197
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "12px 14px", borderBottom: "0.5px solid rgba(255,255,255,0.08)" }, children: [
198
+ /* @__PURE__ */ jsx("strong", { style: { fontSize: 14 }, children: "Support" }),
199
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 8, fontSize: 12 }, children: [
200
+ /* @__PURE__ */ jsx("button", { onClick: () => setView("new"), style: tab(view === "new" || view === "sent", accent), children: "New" }),
201
+ /* @__PURE__ */ jsx("button", { onClick: openThreads, style: tab(view === "threads" || view === "thread", accent), children: "My tickets" })
202
+ ] })
203
+ ] }),
204
+ /* @__PURE__ */ jsxs("div", { style: { padding: 14, overflowY: "auto" }, children: [
205
+ view === "new" && /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [
206
+ /* @__PURE__ */ jsx("div", { style: { display: "flex", gap: 8 }, children: TYPES.map((t) => /* @__PURE__ */ jsx("button", { onClick: () => setType(t.id), style: chip(type === t.id), children: t.label }, t.id)) }),
207
+ /* @__PURE__ */ jsx("div", { style: { display: "flex", gap: 8 }, children: SEVERITIES.map((s) => /* @__PURE__ */ jsx("button", { onClick: () => setSeverity(s.id), style: chip(severity === s.id), children: s.label }, s.id)) }),
208
+ /* @__PURE__ */ jsx(
209
+ "textarea",
210
+ {
211
+ value: message,
212
+ onChange: (e) => setMessage(e.target.value),
213
+ placeholder: "What happened?",
214
+ rows: 4,
215
+ style: { width: "100%", background: "rgba(255,255,255,0.05)", color: "#fff", border: "0.5px solid #3a3a42", borderRadius: 10, padding: 10, fontSize: 14, resize: "vertical" }
216
+ }
217
+ ),
218
+ /* @__PURE__ */ jsxs("label", { style: { display: "flex", alignItems: "center", gap: 8, fontSize: 13, color: "#a0a0a8", cursor: "pointer" }, children: [
219
+ /* @__PURE__ */ jsx("input", { type: "checkbox", checked: withShot, onChange: (e) => {
220
+ setWithShot(e.target.checked);
221
+ if (e.target.checked) captureShot();
222
+ else setShotPreview(null);
223
+ } }),
224
+ "Attach a screenshot"
225
+ ] }),
226
+ shotPreview && /* @__PURE__ */ jsx("img", { src: shotPreview, alt: "", style: { width: "100%", borderRadius: 8, border: "0.5px solid #3a3a42" } }),
227
+ /* @__PURE__ */ jsx("button", { onClick: submit, disabled: !message.trim() || sending, style: { ...primary(accent), opacity: !message.trim() || sending ? 0.5 : 1 }, children: sending ? "Sending\u2026" : "Send report" })
228
+ ] }),
229
+ view === "sent" && /* @__PURE__ */ jsxs("div", { style: { textAlign: "center", padding: "20px 0" }, children: [
230
+ lastTicket ? /* @__PURE__ */ jsxs(Fragment, { children: [
231
+ /* @__PURE__ */ jsxs("div", { style: { fontSize: 15, fontWeight: 600 }, children: [
232
+ "Thanks \u2014 ticket #",
233
+ lastTicket
234
+ ] }),
235
+ /* @__PURE__ */ jsx("div", { style: { fontSize: 13, color: "#a0a0a8", marginTop: 6 }, children: "We'll reply here, and email you when we do." })
236
+ ] }) : /* @__PURE__ */ jsx("div", { style: { fontSize: 14, color: "#ff9f9f" }, children: "Couldn't send \u2014 please try again." }),
237
+ /* @__PURE__ */ jsx("button", { onClick: () => setView("new"), style: { ...primary(accent), marginTop: 14 }, children: "New report" })
238
+ ] }),
239
+ view === "threads" && /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [
240
+ threads.length === 0 && /* @__PURE__ */ jsx("div", { style: { fontSize: 13, color: "#a0a0a8" }, children: "No tickets yet." }),
241
+ threads.map((t) => /* @__PURE__ */ jsxs("button", { onClick: () => openThread(t.id), style: { textAlign: "left", background: "rgba(255,255,255,0.04)", border: "0.5px solid #2a2a32", borderRadius: 10, padding: 10, cursor: "pointer", color: "#e8e8ea" }, children: [
242
+ /* @__PURE__ */ jsxs("div", { style: { fontSize: 13, fontWeight: 600 }, children: [
243
+ "#",
244
+ t.ticket_number,
245
+ " \xB7 ",
246
+ t.type
247
+ ] }),
248
+ /* @__PURE__ */ jsxs("div", { style: { fontSize: 11, color: "#8a8a92", marginTop: 2 }, children: [
249
+ t.status,
250
+ " \xB7 ",
251
+ new Date(t.last_message_at).toLocaleString()
252
+ ] })
253
+ ] }, t.id))
254
+ ] }),
255
+ view === "thread" && /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 10 }, children: [
256
+ messages.map((m) => /* @__PURE__ */ jsx("div", { style: {
257
+ alignSelf: m.author === "user" ? "flex-end" : "flex-start",
258
+ maxWidth: "85%",
259
+ background: m.author === "user" ? accent : "rgba(255,255,255,0.06)",
260
+ color: m.author === "user" ? "#fff" : "#e8e8ea",
261
+ padding: "8px 11px",
262
+ borderRadius: 12,
263
+ fontSize: 13
264
+ }, children: m.body }, m.id)),
265
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 8 }, children: [
266
+ /* @__PURE__ */ jsx(
267
+ "input",
268
+ {
269
+ value: reply,
270
+ onChange: (e) => setReply(e.target.value),
271
+ placeholder: "Reply\u2026",
272
+ onKeyDown: (e) => e.key === "Enter" && sendReply(),
273
+ style: { flex: 1, background: "rgba(255,255,255,0.05)", color: "#fff", border: "0.5px solid #3a3a42", borderRadius: 10, padding: "8px 10px", fontSize: 13 }
274
+ }
275
+ ),
276
+ /* @__PURE__ */ jsx("button", { onClick: sendReply, style: primary(accent), children: "Send" })
277
+ ] })
278
+ ] })
279
+ ] })
280
+ ] }),
281
+ /* @__PURE__ */ jsx(
282
+ "button",
283
+ {
284
+ onClick: () => {
285
+ setOpen((o) => !o);
286
+ if (!open && withShot) captureShot();
287
+ },
288
+ "aria-label": "Support",
289
+ style: {
290
+ width: 48,
291
+ height: 48,
292
+ borderRadius: 999,
293
+ border: "none",
294
+ cursor: "pointer",
295
+ background: accent,
296
+ color: "#fff",
297
+ fontSize: 22,
298
+ boxShadow: "0 4px 16px rgba(0,0,0,0.35)"
299
+ },
300
+ children: open ? "\xD7" : "?"
301
+ }
302
+ )
303
+ ] });
304
+ }
305
+ var tab = (active, accent) => ({
306
+ background: "transparent",
307
+ border: "none",
308
+ cursor: "pointer",
309
+ fontSize: 12,
310
+ color: active ? accent : "#8a8a92",
311
+ fontWeight: active ? 700 : 500
312
+ });
313
+ var primary = (accent) => ({
314
+ background: accent,
315
+ color: "#fff",
316
+ border: "none",
317
+ borderRadius: 10,
318
+ padding: "10px 14px",
319
+ fontSize: 14,
320
+ fontWeight: 600,
321
+ cursor: "pointer"
322
+ });
323
+
324
+ // src/index.ts
325
+ function mountSupportWidget(props) {
326
+ const host = document.createElement("div");
327
+ host.setAttribute("data-tmls-support", props.productId);
328
+ document.body.appendChild(host);
329
+ const shadow = host.attachShadow({ mode: "open" });
330
+ const mount = document.createElement("div");
331
+ shadow.appendChild(mount);
332
+ const root = createRoot(mount);
333
+ root.render(createElement(SupportWidget, props));
334
+ return () => {
335
+ root.unmount();
336
+ host.remove();
337
+ };
338
+ }
339
+ export {
340
+ SupportWidget,
341
+ mountSupportWidget
342
+ };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@tmls-ai/support",
3
+ "version": "0.1.0",
4
+ "description": "Embeddable Timeless support widget — bottom-right report overlay (type / severity / screenshot) + ticket thread. Auto-captures context, talks to tmls-support-api.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "main": "./dist/index.js",
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
12
+ "files": ["dist"],
13
+ "repository": { "type": "git", "url": "git+https://github.com/tmls-ai/tmls-support-widget.git" },
14
+ "keywords": ["support", "widget", "timeless", "tmls"],
15
+ "publishConfig": { "access": "public" },
16
+ "engines": { "node": ">=18" },
17
+ "scripts": {
18
+ "build": "tsup src/index.ts --format esm --dts --clean",
19
+ "typecheck": "tsc --noEmit",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "peerDependencies": {
23
+ "react": ">=18",
24
+ "react-dom": ">=18"
25
+ },
26
+ "dependencies": {
27
+ "modern-screenshot": "^4.5.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/react": "^18.3.0",
31
+ "@types/react-dom": "^18.3.0",
32
+ "react": "^18.3.0",
33
+ "react-dom": "^18.3.0",
34
+ "tsup": "^8.3.0",
35
+ "typescript": "^5.6.0"
36
+ }
37
+ }