@tmls-ai/support 0.1.5 → 0.1.7

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/dist/index.d.ts CHANGED
@@ -38,10 +38,19 @@ interface SupportWidgetProps {
38
38
  getContext?: () => Record<string, unknown>;
39
39
  /** App version string for the base context. */
40
40
  appVersion?: string;
41
+ /** Show the built-in floating launcher (FAB on desktop, peek-tab on mobile).
42
+ * Default true. Set false to drive the panel entirely from your own trigger
43
+ * (a menu entry, a settings row) via the exported openSupport(). */
44
+ showLauncher?: boolean;
41
45
  }
42
46
 
43
47
  declare function SupportWidget(props: SupportWidgetProps): react.JSX.Element;
44
48
 
49
+ /** Open the support panel. No-op if no <SupportWidget> is mounted. */
50
+ declare function openSupport(): void;
51
+ /** Close the support panel. */
52
+ declare function closeSupport(): void;
53
+
45
54
  /**
46
55
  * Vanilla mount for any app (React or not). Renders the widget into a Shadow
47
56
  * DOM root so the host's CSS and the widget's CSS can never collide. React apps
@@ -49,4 +58,4 @@ declare function SupportWidget(props: SupportWidgetProps): react.JSX.Element;
49
58
  */
50
59
  declare function mountSupportWidget(props: SupportWidgetProps): () => void;
51
60
 
52
- export { type Conversation, type Message, type ReportType, type Severity, SupportWidget, type SupportWidgetProps, mountSupportWidget };
61
+ export { type Conversation, type Message, type ReportType, type Severity, SupportWidget, type SupportWidgetProps, closeSupport, mountSupportWidget, openSupport };
package/dist/index.js CHANGED
@@ -94,6 +94,21 @@ var SupportApi = class {
94
94
  }
95
95
  };
96
96
 
97
+ // src/controller.ts
98
+ var listeners = /* @__PURE__ */ new Set();
99
+ function openSupport() {
100
+ listeners.forEach((l) => l("open"));
101
+ }
102
+ function closeSupport() {
103
+ listeners.forEach((l) => l("close"));
104
+ }
105
+ function subscribeSupport(listener) {
106
+ listeners.add(listener);
107
+ return () => {
108
+ listeners.delete(listener);
109
+ };
110
+ }
111
+
97
112
  // src/SupportWidget.tsx
98
113
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
99
114
  var TYPES = [
@@ -117,15 +132,16 @@ var deeplinkTicket = (() => {
117
132
  return tn;
118
133
  })();
119
134
  function SupportWidget(props) {
120
- const { productId, apiUrl, getToken, accent = "#0a84ff", getContext, appVersion } = props;
135
+ const { productId, apiUrl, getToken, accent = "#0a84ff", getContext, appVersion, showLauncher = true } = props;
121
136
  const api = useMemo(() => new SupportApi(apiUrl, getToken), [apiUrl, getToken]);
122
137
  const [open, setOpen] = useState(false);
123
138
  const [view, setView] = useState("new");
124
139
  const [type, setType] = useState("bug");
125
140
  const [severity, setSeverity] = useState("annoying");
126
141
  const [message, setMessage] = useState("");
127
- const [withShot, setWithShot] = useState(true);
142
+ const [withShot, setWithShot] = useState(false);
128
143
  const [shotPreview, setShotPreview] = useState(null);
144
+ const [files, setFiles] = useState([]);
129
145
  const [capturing, setCapturing] = useState(false);
130
146
  const [sending, setSending] = useState(false);
131
147
  const [lastTicket, setLastTicket] = useState(null);
@@ -135,6 +151,17 @@ function SupportWidget(props) {
135
151
  const [messages, setMessages] = useState([]);
136
152
  const [shotUrls, setShotUrls] = useState({});
137
153
  const [reply, setReply] = useState("");
154
+ const [isMobile, setIsMobile] = useState(
155
+ () => typeof window !== "undefined" && window.matchMedia("(max-width: 767px)").matches
156
+ );
157
+ useEffect(() => {
158
+ if (typeof window === "undefined") return;
159
+ const mq = window.matchMedia("(max-width: 767px)");
160
+ const onChange = () => setIsMobile(mq.matches);
161
+ onChange();
162
+ mq.addEventListener("change", onChange);
163
+ return () => mq.removeEventListener("change", onChange);
164
+ }, []);
138
165
  useEffect(() => {
139
166
  installErrorCapture();
140
167
  }, []);
@@ -169,16 +196,31 @@ function SupportWidget(props) {
169
196
  setCapturing(false);
170
197
  }
171
198
  }, []);
199
+ const filePreviews = useMemo(() => files.map((f) => URL.createObjectURL(f)), [files]);
200
+ useEffect(() => () => {
201
+ filePreviews.forEach(URL.revokeObjectURL);
202
+ }, [filePreviews]);
203
+ const addFiles = (picked) => {
204
+ const imgs = Array.from(picked || []).filter((f) => f.type.startsWith("image/"));
205
+ if (imgs.length) setFiles((prev) => [...prev, ...imgs]);
206
+ };
207
+ const removeFile = (idx) => setFiles((prev) => prev.filter((_, i) => i !== idx));
172
208
  const submit = useCallback(async () => {
173
209
  if (!message.trim() || sending) return;
174
210
  setSending(true);
175
211
  try {
176
- let attachmentKeys = [];
212
+ const attachmentKeys = [];
213
+ for (const f of files) {
214
+ try {
215
+ attachmentKeys.push(await api.uploadScreenshot(f));
216
+ } catch {
217
+ }
218
+ }
177
219
  if (withShot && !captureShot._blob) await captureShot();
178
220
  const blob = withShot ? captureShot._blob : void 0;
179
221
  if (blob) {
180
222
  try {
181
- attachmentKeys = [await api.uploadScreenshot(blob)];
223
+ attachmentKeys.push(await api.uploadScreenshot(blob));
182
224
  } catch {
183
225
  }
184
226
  }
@@ -192,6 +234,7 @@ function SupportWidget(props) {
192
234
  setLastTicket(ticketNumber);
193
235
  setMessage("");
194
236
  setShotPreview(null);
237
+ setFiles([]);
195
238
  setView("sent");
196
239
  } catch {
197
240
  setLastTicket(null);
@@ -199,7 +242,7 @@ function SupportWidget(props) {
199
242
  } finally {
200
243
  setSending(false);
201
244
  }
202
- }, [api, type, severity, message, withShot, getContext, appVersion, sending, captureShot]);
245
+ }, [api, type, severity, message, withShot, files, getContext, appVersion, sending, captureShot]);
203
246
  const openThreads = useCallback(async () => {
204
247
  setThreads(await api.listConversations());
205
248
  setView("threads");
@@ -251,27 +294,103 @@ function SupportWidget(props) {
251
294
  background: active ? accent : "transparent",
252
295
  color: active ? "#fff" : "#c8c8d0"
253
296
  });
297
+ const openPanel = () => {
298
+ setOpen(true);
299
+ if (withShot) captureShot();
300
+ };
301
+ const closePanel = () => {
302
+ deeplinkTicket = null;
303
+ setOpen(false);
304
+ };
305
+ const toggle = () => {
306
+ if (open) closePanel();
307
+ else openPanel();
308
+ };
309
+ useEffect(() => subscribeSupport((action) => {
310
+ if (action === "open") openPanel();
311
+ else closePanel();
312
+ }), [withShot]);
313
+ const panelStyle = isMobile ? {
314
+ position: "fixed",
315
+ left: 0,
316
+ right: 0,
317
+ bottom: 0,
318
+ width: "auto",
319
+ maxHeight: "85vh",
320
+ background: "#141418",
321
+ color: "#e8e8ea",
322
+ borderTop: "0.5px solid rgba(255,255,255,0.12)",
323
+ borderRadius: "16px 16px 0 0",
324
+ boxShadow: "0 -16px 48px rgba(0,0,0,0.5)",
325
+ overflow: "hidden",
326
+ display: "flex",
327
+ flexDirection: "column",
328
+ zIndex: 2147483e3,
329
+ paddingBottom: "env(safe-area-inset-bottom, 0px)"
330
+ } : {
331
+ position: "absolute",
332
+ bottom: 60,
333
+ right: 0,
334
+ width: 360,
335
+ maxHeight: 560,
336
+ background: "#141418",
337
+ color: "#e8e8ea",
338
+ border: "0.5px solid rgba(255,255,255,0.12)",
339
+ borderRadius: 16,
340
+ boxShadow: "0 16px 48px rgba(0,0,0,0.5)",
341
+ overflow: "hidden",
342
+ display: "flex",
343
+ flexDirection: "column"
344
+ };
345
+ const launcherStyle = isMobile ? {
346
+ position: "fixed",
347
+ right: 0,
348
+ top: "50%",
349
+ transform: "translateY(-50%)",
350
+ width: 30,
351
+ height: 56,
352
+ borderRadius: "14px 0 0 14px",
353
+ border: "none",
354
+ cursor: "pointer",
355
+ background: accent,
356
+ color: "#fff",
357
+ fontSize: 20,
358
+ boxShadow: "-3px 0 14px rgba(0,0,0,0.4)",
359
+ display: "flex",
360
+ alignItems: "center",
361
+ justifyContent: "center",
362
+ paddingRight: 4,
363
+ zIndex: 2147483e3
364
+ } : {
365
+ width: 48,
366
+ height: 48,
367
+ borderRadius: 999,
368
+ border: "none",
369
+ cursor: "pointer",
370
+ background: accent,
371
+ color: "#fff",
372
+ fontSize: 22,
373
+ boxShadow: "0 4px 16px rgba(0,0,0,0.35)"
374
+ };
254
375
  return /* @__PURE__ */ jsxs("div", { "data-tmls-support-root": "true", style: { position: "fixed", bottom: 20, right: 20, zIndex: 2147483e3, fontFamily: "system-ui, sans-serif" }, children: [
255
- open && /* @__PURE__ */ jsxs("div", { style: {
256
- position: "absolute",
257
- bottom: 60,
258
- right: 0,
259
- width: 360,
260
- maxHeight: 560,
261
- background: "#141418",
262
- color: "#e8e8ea",
263
- border: "0.5px solid rgba(255,255,255,0.12)",
264
- borderRadius: 16,
265
- boxShadow: "0 16px 48px rgba(0,0,0,0.5)",
266
- overflow: "hidden",
267
- display: "flex",
268
- flexDirection: "column"
269
- }, children: [
376
+ open && /* @__PURE__ */ jsxs("div", { style: panelStyle, children: [
270
377
  /* @__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: [
271
378
  /* @__PURE__ */ jsx("strong", { style: { fontSize: 14 }, children: "Support" }),
272
- /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 8, fontSize: 12 }, children: [
379
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 8, fontSize: 12, alignItems: "center" }, children: [
273
380
  /* @__PURE__ */ jsx("button", { onClick: () => setView("new"), style: tab(view === "new" || view === "sent", accent), children: "New" }),
274
- /* @__PURE__ */ jsx("button", { onClick: openThreads, style: tab(view === "threads" || view === "thread", accent), children: "My tickets" })
381
+ /* @__PURE__ */ jsx("button", { onClick: openThreads, style: tab(view === "threads" || view === "thread", accent), children: "My tickets" }),
382
+ isMobile && /* @__PURE__ */ jsx(
383
+ "button",
384
+ {
385
+ onClick: () => {
386
+ deeplinkTicket = null;
387
+ setOpen(false);
388
+ },
389
+ "aria-label": "Close",
390
+ style: { background: "transparent", border: "none", cursor: "pointer", color: "#8a8a92", fontSize: 20, lineHeight: 1, padding: "0 2px" },
391
+ children: "\xD7"
392
+ }
393
+ )
275
394
  ] })
276
395
  ] }),
277
396
  /* @__PURE__ */ jsxs("div", { style: { padding: 14, overflowY: "auto" }, children: [
@@ -288,16 +407,45 @@ function SupportWidget(props) {
288
407
  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" }
289
408
  }
290
409
  ),
410
+ /* @__PURE__ */ jsxs("label", { style: { display: "inline-flex", alignItems: "center", gap: 8, alignSelf: "flex-start", padding: "8px 12px", fontSize: 13, color: "#c8c8d0", cursor: "pointer", border: "0.5px dashed #4a4a52", borderRadius: 10, background: "rgba(255,255,255,0.03)" }, children: [
411
+ /* @__PURE__ */ jsx(
412
+ "input",
413
+ {
414
+ type: "file",
415
+ accept: "image/*",
416
+ multiple: true,
417
+ style: { display: "none" },
418
+ onChange: (e) => {
419
+ addFiles(e.target.files);
420
+ e.currentTarget.value = "";
421
+ }
422
+ }
423
+ ),
424
+ /* @__PURE__ */ jsx("span", { style: { fontSize: 15, lineHeight: 1 }, children: "+" }),
425
+ files.length ? `Add more images (${files.length})` : "Attach image(s)"
426
+ ] }),
427
+ filePreviews.length > 0 && /* @__PURE__ */ jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 8 }, children: filePreviews.map((src, i) => /* @__PURE__ */ jsxs("div", { style: { position: "relative" }, children: [
428
+ /* @__PURE__ */ jsx("img", { src, alt: "", style: { width: 64, height: 64, objectFit: "cover", borderRadius: 8, border: "0.5px solid #3a3a42", display: "block" } }),
429
+ /* @__PURE__ */ jsx(
430
+ "button",
431
+ {
432
+ onClick: () => removeFile(i),
433
+ "aria-label": "Remove image",
434
+ style: { position: "absolute", top: -6, right: -6, width: 20, height: 20, borderRadius: 999, border: "none", cursor: "pointer", background: "#1a1a20", color: "#fff", fontSize: 13, lineHeight: "20px", padding: 0, boxShadow: "0 1px 4px rgba(0,0,0,0.5)" },
435
+ children: "\xD7"
436
+ }
437
+ )
438
+ ] }, i)) }),
291
439
  /* @__PURE__ */ jsxs("label", { style: { display: "flex", alignItems: "center", gap: 8, fontSize: 13, color: "#a0a0a8", cursor: "pointer" }, children: [
292
440
  /* @__PURE__ */ jsx("input", { type: "checkbox", checked: withShot, onChange: (e) => {
293
441
  setWithShot(e.target.checked);
294
442
  if (e.target.checked) captureShot();
295
443
  else setShotPreview(null);
296
444
  } }),
297
- "Attach a screenshot"
445
+ "Also include a screenshot of the app"
298
446
  ] }),
299
447
  withShot && capturing && !shotPreview && /* @__PURE__ */ jsx("div", { style: { fontSize: 12, color: "#8a8a92", padding: "10px 0", textAlign: "center" }, children: "Capturing screenshot\u2026" }),
300
- shotPreview && /* @__PURE__ */ jsx("img", { src: shotPreview, alt: "", style: { width: "100%", borderRadius: 8, border: "0.5px solid #3a3a42" } }),
448
+ withShot && shotPreview && /* @__PURE__ */ jsx("img", { src: shotPreview, alt: "", style: { width: "100%", borderRadius: 8, border: "0.5px solid #3a3a42" } }),
301
449
  /* @__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" })
302
450
  ] }),
303
451
  view === "sent" && /* @__PURE__ */ jsxs("div", { style: { textAlign: "center", padding: "20px 0" }, children: [
@@ -362,29 +510,7 @@ function SupportWidget(props) {
362
510
  ] })
363
511
  ] })
364
512
  ] }),
365
- /* @__PURE__ */ jsx(
366
- "button",
367
- {
368
- onClick: () => {
369
- if (open) deeplinkTicket = null;
370
- setOpen((o) => !o);
371
- if (!open && withShot) captureShot();
372
- },
373
- "aria-label": "Support",
374
- style: {
375
- width: 48,
376
- height: 48,
377
- borderRadius: 999,
378
- border: "none",
379
- cursor: "pointer",
380
- background: accent,
381
- color: "#fff",
382
- fontSize: 22,
383
- boxShadow: "0 4px 16px rgba(0,0,0,0.35)"
384
- },
385
- children: open ? "\xD7" : "?"
386
- }
387
- )
513
+ showLauncher && (!isMobile || !open) && /* @__PURE__ */ jsx("button", { onClick: toggle, "aria-label": "Support", style: launcherStyle, children: open && !isMobile ? "\xD7" : "?" })
388
514
  ] });
389
515
  }
390
516
  var tab = (active, accent) => ({
@@ -435,5 +561,7 @@ function mountSupportWidget(props) {
435
561
  }
436
562
  export {
437
563
  SupportWidget,
438
- mountSupportWidget
564
+ closeSupport,
565
+ mountSupportWidget,
566
+ openSupport
439
567
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmls-ai/support",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Embeddable Timeless support widget — bottom-right report overlay (type / severity / screenshot) + ticket thread. Auto-captures context, talks to tmls-support-api.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",