@fxl-business/support-widget 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.
@@ -0,0 +1,37 @@
1
+ type WidgetUser = {
2
+ name: string | null;
3
+ email: string | null;
4
+ };
5
+ type WidgetLabels = {
6
+ sendFeedback: string;
7
+ bugReport: string;
8
+ featureRequest: string;
9
+ titleLabel: string;
10
+ titlePlaceholder: string;
11
+ descriptionLabel: string;
12
+ descriptionPlaceholder: string;
13
+ attachmentsLabel: string;
14
+ attachmentsDropzone: string;
15
+ send: string;
16
+ sending: string;
17
+ cancel: string;
18
+ close: string;
19
+ submittedTitle: string;
20
+ submittedMessage: string;
21
+ openWidget: string;
22
+ fileNotSupported: (name: string) => string;
23
+ fileTooLarge: (name: string) => string;
24
+ submissionFailed: string;
25
+ };
26
+ declare const defaultLabels: WidgetLabels;
27
+ type WidgetProps = {
28
+ projectApiKey: string;
29
+ apiUrl: string;
30
+ user?: WidgetUser;
31
+ appVersion?: string;
32
+ labels?: Partial<WidgetLabels>;
33
+ };
34
+
35
+ declare function SupportWidget(props: WidgetProps): JSX.Element;
36
+
37
+ export { SupportWidget, type WidgetLabels, type WidgetProps, type WidgetUser, defaultLabels };
@@ -0,0 +1,37 @@
1
+ type WidgetUser = {
2
+ name: string | null;
3
+ email: string | null;
4
+ };
5
+ type WidgetLabels = {
6
+ sendFeedback: string;
7
+ bugReport: string;
8
+ featureRequest: string;
9
+ titleLabel: string;
10
+ titlePlaceholder: string;
11
+ descriptionLabel: string;
12
+ descriptionPlaceholder: string;
13
+ attachmentsLabel: string;
14
+ attachmentsDropzone: string;
15
+ send: string;
16
+ sending: string;
17
+ cancel: string;
18
+ close: string;
19
+ submittedTitle: string;
20
+ submittedMessage: string;
21
+ openWidget: string;
22
+ fileNotSupported: (name: string) => string;
23
+ fileTooLarge: (name: string) => string;
24
+ submissionFailed: string;
25
+ };
26
+ declare const defaultLabels: WidgetLabels;
27
+ type WidgetProps = {
28
+ projectApiKey: string;
29
+ apiUrl: string;
30
+ user?: WidgetUser;
31
+ appVersion?: string;
32
+ labels?: Partial<WidgetLabels>;
33
+ };
34
+
35
+ declare function SupportWidget(props: WidgetProps): JSX.Element;
36
+
37
+ export { SupportWidget, type WidgetLabels, type WidgetProps, type WidgetUser, defaultLabels };
package/dist/index.js ADDED
@@ -0,0 +1,528 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ SupportWidget: () => SupportWidget,
24
+ defaultLabels: () => defaultLabels
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+
28
+ // src/support-widget.tsx
29
+ var import_react2 = require("react");
30
+
31
+ // src/types.ts
32
+ var defaultLabels = {
33
+ sendFeedback: "Send Feedback",
34
+ bugReport: "BUG REPORT",
35
+ featureRequest: "FEATURE REQUEST",
36
+ titleLabel: "Title *",
37
+ titlePlaceholder: "Brief summary",
38
+ descriptionLabel: "Description *",
39
+ descriptionPlaceholder: "Describe the issue or request in detail",
40
+ attachmentsLabel: "Attachments (optional)",
41
+ attachmentsDropzone: "Drag & drop or click to attach images/videos",
42
+ send: "Send",
43
+ sending: "Sending...",
44
+ cancel: "Cancel",
45
+ close: "Close",
46
+ submittedTitle: "Submitted successfully",
47
+ submittedMessage: "We'll look into it soon.",
48
+ openWidget: "Open support widget",
49
+ fileNotSupported: (name) => `"${name}" is not supported. Only images and videos are allowed.`,
50
+ fileTooLarge: (name) => `"${name}" exceeds the 10MB limit.`,
51
+ submissionFailed: "Submission failed"
52
+ };
53
+
54
+ // src/support-form.tsx
55
+ var import_react = require("react");
56
+
57
+ // src/api.ts
58
+ async function submitTicket(config, submission) {
59
+ const res = await fetch(`${config.apiUrl}/api/v1/tickets`, {
60
+ method: "POST",
61
+ headers: {
62
+ "Content-Type": "application/json",
63
+ "X-API-Key": config.projectApiKey
64
+ },
65
+ body: JSON.stringify(submission)
66
+ });
67
+ if (!res.ok) {
68
+ const body = await res.json().catch(() => ({ error: res.statusText }));
69
+ throw new Error(body.error ?? `Submit failed: ${res.status}`);
70
+ }
71
+ return res.json();
72
+ }
73
+ async function getPresignedUploadUrl(config, ticketId, filename, contentType) {
74
+ const res = await fetch(`${config.apiUrl}/api/v1/tickets/${ticketId}/upload`, {
75
+ method: "POST",
76
+ headers: {
77
+ "Content-Type": "application/json",
78
+ "X-API-Key": config.projectApiKey
79
+ },
80
+ body: JSON.stringify({ filename, contentType })
81
+ });
82
+ if (!res.ok) {
83
+ throw new Error(`Upload URL request failed: ${res.status}`);
84
+ }
85
+ return res.json();
86
+ }
87
+ async function uploadFileToR2(presignedUrl, file) {
88
+ const res = await fetch(presignedUrl, {
89
+ method: "PUT",
90
+ headers: { "Content-Type": file.type },
91
+ body: file
92
+ });
93
+ if (!res.ok) {
94
+ throw new Error(`File upload failed: ${res.status}`);
95
+ }
96
+ }
97
+
98
+ // src/support-form.tsx
99
+ var import_jsx_runtime = require("react/jsx-runtime");
100
+ function SupportForm({ config, labels, onSuccess, onCancel }) {
101
+ const [type, setType] = (0, import_react.useState)("bug");
102
+ const [title, setTitle] = (0, import_react.useState)("");
103
+ const [description, setDescription] = (0, import_react.useState)("");
104
+ const [files, setFiles] = (0, import_react.useState)([]);
105
+ const [isSubmitting, setIsSubmitting] = (0, import_react.useState)(false);
106
+ const [error, setError] = (0, import_react.useState)(null);
107
+ const [fileError, setFileError] = (0, import_react.useState)(null);
108
+ const fileInputRef = (0, import_react.useRef)(null);
109
+ const MAX_FILE_SIZE = 10 * 1024 * 1024;
110
+ const ALLOWED_TYPES = /^(image|video)\//;
111
+ function validateAndAddFiles(newFiles) {
112
+ setFileError(null);
113
+ const valid = [];
114
+ for (const file of newFiles) {
115
+ if (!ALLOWED_TYPES.test(file.type)) {
116
+ setFileError(labels.fileNotSupported(file.name));
117
+ return;
118
+ }
119
+ if (file.size > MAX_FILE_SIZE) {
120
+ setFileError(labels.fileTooLarge(file.name));
121
+ return;
122
+ }
123
+ valid.push(file);
124
+ }
125
+ setFiles((prev) => [...prev, ...valid]);
126
+ }
127
+ function removeFile(index) {
128
+ setFiles((prev) => prev.filter((_, i) => i !== index));
129
+ setFileError(null);
130
+ }
131
+ async function handleSubmit(e) {
132
+ e.preventDefault();
133
+ setIsSubmitting(true);
134
+ setError(null);
135
+ try {
136
+ const submission = {
137
+ type,
138
+ title,
139
+ description,
140
+ reporterName: config.user?.name ?? void 0,
141
+ reporterEmail: config.user?.email ?? void 0,
142
+ metadata: {
143
+ browser: navigator.userAgent,
144
+ os: navigator.platform,
145
+ currentUrl: window.location.href,
146
+ appVersion: config.appVersion
147
+ }
148
+ };
149
+ const { id: ticketId } = await submitTicket(
150
+ { apiUrl: config.apiUrl, projectApiKey: config.projectApiKey },
151
+ submission
152
+ );
153
+ for (const file of files) {
154
+ const { presignedUrl } = await getPresignedUploadUrl(
155
+ { apiUrl: config.apiUrl, projectApiKey: config.projectApiKey },
156
+ ticketId,
157
+ file.name,
158
+ file.type
159
+ );
160
+ await uploadFileToR2(presignedUrl, file);
161
+ }
162
+ onSuccess();
163
+ } catch (err) {
164
+ setError(err instanceof Error ? err.message : labels.submissionFailed);
165
+ } finally {
166
+ setIsSubmitting(false);
167
+ }
168
+ }
169
+ function handleFileDrop(e) {
170
+ e.preventDefault();
171
+ const dropped = Array.from(e.dataTransfer.files);
172
+ validateAndAddFiles(dropped);
173
+ }
174
+ const isFormValid = title.trim().length > 0 && description.trim().length > 0;
175
+ const inputStyle = {
176
+ width: "100%",
177
+ background: "#1a1a1a",
178
+ border: "1px solid #2a2a2a",
179
+ borderRadius: 4,
180
+ color: "#e8e8e8",
181
+ padding: "7px 10px",
182
+ fontSize: 12,
183
+ fontFamily: "inherit",
184
+ outline: "none",
185
+ boxSizing: "border-box"
186
+ };
187
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("form", { onSubmit: handleSubmit, style: { display: "flex", flexDirection: "column", gap: 12 }, children: [
188
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", gap: 4 }, children: [
189
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
190
+ "button",
191
+ {
192
+ type: "button",
193
+ onClick: () => setType("bug"),
194
+ style: {
195
+ flex: 1,
196
+ padding: "6px 0",
197
+ borderRadius: 4,
198
+ border: `1px solid ${type === "bug" ? "#ff4d4d" : "#2a2a2a"}`,
199
+ background: type === "bug" ? "rgba(255,77,77,0.15)" : "#1a1a1a",
200
+ color: type === "bug" ? "#ff6b6b" : "#888",
201
+ fontSize: 11,
202
+ fontFamily: "inherit",
203
+ cursor: "pointer",
204
+ fontWeight: 600,
205
+ letterSpacing: 0.5
206
+ },
207
+ children: labels.bugReport
208
+ }
209
+ ),
210
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
211
+ "button",
212
+ {
213
+ type: "button",
214
+ onClick: () => setType("feature_request"),
215
+ style: {
216
+ flex: 1,
217
+ padding: "6px 0",
218
+ borderRadius: 4,
219
+ border: `1px solid ${type === "feature_request" ? "#4d9fff" : "#2a2a2a"}`,
220
+ background: type === "feature_request" ? "rgba(77,159,255,0.15)" : "#1a1a1a",
221
+ color: type === "feature_request" ? "#6bb5ff" : "#888",
222
+ fontSize: 11,
223
+ fontFamily: "inherit",
224
+ cursor: "pointer",
225
+ fontWeight: 600,
226
+ letterSpacing: 0.5
227
+ },
228
+ children: labels.featureRequest
229
+ }
230
+ )
231
+ ] }),
232
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [
233
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { style: { display: "block", fontSize: 11, color: "#888", marginBottom: 4 }, children: labels.titleLabel }),
234
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
235
+ "input",
236
+ {
237
+ value: title,
238
+ onChange: (e) => setTitle(e.target.value),
239
+ placeholder: labels.titlePlaceholder,
240
+ required: true,
241
+ style: inputStyle
242
+ }
243
+ )
244
+ ] }),
245
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [
246
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { style: { display: "block", fontSize: 11, color: "#888", marginBottom: 4 }, children: labels.descriptionLabel }),
247
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
248
+ "textarea",
249
+ {
250
+ value: description,
251
+ onChange: (e) => setDescription(e.target.value),
252
+ placeholder: labels.descriptionPlaceholder,
253
+ required: true,
254
+ rows: 4,
255
+ style: { ...inputStyle, resize: "vertical", minHeight: 80 }
256
+ }
257
+ )
258
+ ] }),
259
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [
260
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { style: { display: "block", fontSize: 11, color: "#888", marginBottom: 4 }, children: labels.attachmentsLabel }),
261
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
262
+ "div",
263
+ {
264
+ onDrop: handleFileDrop,
265
+ onDragOver: (e) => e.preventDefault(),
266
+ onClick: () => fileInputRef.current?.click(),
267
+ style: {
268
+ border: "1px dashed #2a2a2a",
269
+ borderRadius: 4,
270
+ padding: "10px",
271
+ textAlign: "center",
272
+ color: "#555",
273
+ fontSize: 11,
274
+ cursor: "pointer",
275
+ background: "#1a1a1a"
276
+ },
277
+ children: files.length > 0 ? files.map((f, i) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", color: "#888", fontSize: 11, padding: "2px 0" }, children: [
278
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }, children: f.name }),
279
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
280
+ "button",
281
+ {
282
+ type: "button",
283
+ onClick: (e) => {
284
+ e.stopPropagation();
285
+ removeFile(i);
286
+ },
287
+ style: {
288
+ background: "none",
289
+ border: "none",
290
+ color: "#ff4d4d",
291
+ cursor: "pointer",
292
+ fontSize: 14,
293
+ padding: "0 4px",
294
+ lineHeight: 1,
295
+ fontFamily: "inherit",
296
+ flexShrink: 0
297
+ },
298
+ "aria-label": `Remove ${f.name}`,
299
+ children: "x"
300
+ }
301
+ )
302
+ ] }, `${f.name}-${i}`)) : labels.attachmentsDropzone
303
+ }
304
+ ),
305
+ fileError && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { color: "#ff4d4d", fontSize: 11, margin: "4px 0 0" }, children: fileError }),
306
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
307
+ "input",
308
+ {
309
+ ref: fileInputRef,
310
+ type: "file",
311
+ multiple: true,
312
+ accept: "image/*,video/*",
313
+ style: { display: "none" },
314
+ onChange: (e) => {
315
+ validateAndAddFiles(Array.from(e.target.files ?? []));
316
+ e.target.value = "";
317
+ }
318
+ }
319
+ )
320
+ ] }),
321
+ error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { color: "#ff4d4d", fontSize: 11, margin: 0 }, children: error }),
322
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", gap: 8 }, children: [
323
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
324
+ "button",
325
+ {
326
+ type: "submit",
327
+ disabled: isSubmitting || !isFormValid,
328
+ style: {
329
+ flex: 1,
330
+ padding: "8px",
331
+ borderRadius: 4,
332
+ border: "none",
333
+ background: "#b4e62e",
334
+ color: "#000",
335
+ fontWeight: 600,
336
+ fontSize: 12,
337
+ fontFamily: "inherit",
338
+ cursor: isSubmitting || !isFormValid ? "not-allowed" : "pointer",
339
+ opacity: isSubmitting || !isFormValid ? 0.7 : 1
340
+ },
341
+ children: isSubmitting ? labels.sending : labels.send
342
+ }
343
+ ),
344
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
345
+ "button",
346
+ {
347
+ type: "button",
348
+ onClick: onCancel,
349
+ style: {
350
+ padding: "8px 16px",
351
+ borderRadius: 4,
352
+ border: "1px solid #2a2a2a",
353
+ background: "transparent",
354
+ color: "#888",
355
+ fontSize: 12,
356
+ fontFamily: "inherit",
357
+ cursor: "pointer"
358
+ },
359
+ children: labels.cancel
360
+ }
361
+ )
362
+ ] })
363
+ ] });
364
+ }
365
+
366
+ // src/support-widget.tsx
367
+ var import_jsx_runtime2 = require("react/jsx-runtime");
368
+ function SupportWidget(props) {
369
+ const labels = { ...defaultLabels, ...props.labels };
370
+ const [panelState, setPanelState] = (0, import_react2.useState)("closed");
371
+ const [openCount, setOpenCount] = (0, import_react2.useState)(0);
372
+ const panelRef = (0, import_react2.useRef)(null);
373
+ (0, import_react2.useEffect)(() => {
374
+ function handleKeyDown(e) {
375
+ if (e.key === "Escape") setPanelState("closed");
376
+ }
377
+ window.addEventListener("keydown", handleKeyDown);
378
+ return () => window.removeEventListener("keydown", handleKeyDown);
379
+ }, []);
380
+ (0, import_react2.useEffect)(() => {
381
+ if (panelState === "success") {
382
+ const timer = setTimeout(() => setPanelState("closed"), 2e3);
383
+ return () => clearTimeout(timer);
384
+ }
385
+ }, [panelState]);
386
+ (0, import_react2.useEffect)(() => {
387
+ function handleClickOutside(e) {
388
+ if (panelRef.current && !panelRef.current.contains(e.target)) {
389
+ setPanelState("closed");
390
+ }
391
+ }
392
+ if (panelState !== "closed") {
393
+ document.addEventListener("mousedown", handleClickOutside);
394
+ }
395
+ return () => document.removeEventListener("mousedown", handleClickOutside);
396
+ }, [panelState]);
397
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
398
+ "div",
399
+ {
400
+ ref: panelRef,
401
+ "data-fxl-widget": "",
402
+ style: {
403
+ position: "fixed",
404
+ bottom: 24,
405
+ right: 24,
406
+ zIndex: 9999,
407
+ fontFamily: "'Space Grotesk', system-ui, sans-serif"
408
+ },
409
+ children: [
410
+ panelState !== "closed" && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
411
+ "div",
412
+ {
413
+ className: "fxl-panel",
414
+ style: {
415
+ position: "absolute",
416
+ bottom: 56,
417
+ right: 0,
418
+ width: 300,
419
+ background: "#111111",
420
+ border: "1px solid #2a2a2a",
421
+ borderRadius: 8,
422
+ padding: 16,
423
+ boxShadow: "0 8px 32px rgba(0,0,0,0.6)"
424
+ },
425
+ children: panelState === "success" ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: { textAlign: "center", padding: "16px 0" }, children: [
426
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: { fontSize: 24, marginBottom: 8 }, children: "\u2713" }),
427
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { style: { color: "#b4e62e", fontWeight: 600, margin: 0, fontSize: 14 }, children: labels.submittedTitle }),
428
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { style: { color: "#888", fontSize: 12, marginTop: 4, marginBottom: 16 }, children: labels.submittedMessage }),
429
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
430
+ "button",
431
+ {
432
+ onClick: () => setPanelState("closed"),
433
+ style: {
434
+ padding: "6px 16px",
435
+ borderRadius: 4,
436
+ border: "1px solid #2a2a2a",
437
+ background: "transparent",
438
+ color: "#888",
439
+ fontSize: 12,
440
+ cursor: "pointer",
441
+ fontFamily: "inherit"
442
+ },
443
+ children: labels.close
444
+ }
445
+ )
446
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
447
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
448
+ "p",
449
+ {
450
+ style: {
451
+ margin: "0 0 12px",
452
+ fontSize: 13,
453
+ fontWeight: 600,
454
+ color: "#e8e8e8"
455
+ },
456
+ children: labels.sendFeedback
457
+ }
458
+ ),
459
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
460
+ SupportForm,
461
+ {
462
+ config: props,
463
+ labels,
464
+ onSuccess: () => setPanelState("success"),
465
+ onCancel: () => setPanelState("closed")
466
+ },
467
+ openCount
468
+ )
469
+ ] })
470
+ }
471
+ ),
472
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
473
+ "button",
474
+ {
475
+ onClick: () => {
476
+ setPanelState((s) => {
477
+ if (s === "closed") {
478
+ setOpenCount((c) => c + 1);
479
+ return "open";
480
+ }
481
+ return "closed";
482
+ });
483
+ },
484
+ "aria-label": labels.openWidget,
485
+ style: {
486
+ width: 44,
487
+ height: 44,
488
+ borderRadius: "50%",
489
+ background: "#b4e62e",
490
+ border: "none",
491
+ cursor: "pointer",
492
+ display: "flex",
493
+ alignItems: "center",
494
+ justifyContent: "center",
495
+ boxShadow: "0 2px 12px rgba(180,230,46,0.4)",
496
+ transition: "transform 0.15s, box-shadow 0.15s"
497
+ },
498
+ onMouseEnter: (e) => {
499
+ ;
500
+ e.currentTarget.style.transform = "scale(1.1)";
501
+ e.currentTarget.style.boxShadow = "0 4px 20px rgba(180,230,46,0.5)";
502
+ },
503
+ onMouseLeave: (e) => {
504
+ ;
505
+ e.currentTarget.style.transform = "scale(1)";
506
+ e.currentTarget.style.boxShadow = "0 2px 12px rgba(180,230,46,0.4)";
507
+ },
508
+ children: panelState === "closed" ? (
509
+ // Chat bubble icon
510
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "#000", strokeWidth: "2.5", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" }) })
511
+ ) : (
512
+ // X icon
513
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "#000", strokeWidth: "2.5", children: [
514
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
515
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("line", { x1: "6", y1: "6", x2: "18", y2: "18" })
516
+ ] })
517
+ )
518
+ }
519
+ )
520
+ ]
521
+ }
522
+ );
523
+ }
524
+ // Annotate the CommonJS export names for ESM import in node:
525
+ 0 && (module.exports = {
526
+ SupportWidget,
527
+ defaultLabels
528
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,500 @@
1
+ // src/support-widget.tsx
2
+ import { useState as useState2, useEffect, useRef as useRef2 } from "react";
3
+
4
+ // src/types.ts
5
+ var defaultLabels = {
6
+ sendFeedback: "Send Feedback",
7
+ bugReport: "BUG REPORT",
8
+ featureRequest: "FEATURE REQUEST",
9
+ titleLabel: "Title *",
10
+ titlePlaceholder: "Brief summary",
11
+ descriptionLabel: "Description *",
12
+ descriptionPlaceholder: "Describe the issue or request in detail",
13
+ attachmentsLabel: "Attachments (optional)",
14
+ attachmentsDropzone: "Drag & drop or click to attach images/videos",
15
+ send: "Send",
16
+ sending: "Sending...",
17
+ cancel: "Cancel",
18
+ close: "Close",
19
+ submittedTitle: "Submitted successfully",
20
+ submittedMessage: "We'll look into it soon.",
21
+ openWidget: "Open support widget",
22
+ fileNotSupported: (name) => `"${name}" is not supported. Only images and videos are allowed.`,
23
+ fileTooLarge: (name) => `"${name}" exceeds the 10MB limit.`,
24
+ submissionFailed: "Submission failed"
25
+ };
26
+
27
+ // src/support-form.tsx
28
+ import { useState, useRef } from "react";
29
+
30
+ // src/api.ts
31
+ async function submitTicket(config, submission) {
32
+ const res = await fetch(`${config.apiUrl}/api/v1/tickets`, {
33
+ method: "POST",
34
+ headers: {
35
+ "Content-Type": "application/json",
36
+ "X-API-Key": config.projectApiKey
37
+ },
38
+ body: JSON.stringify(submission)
39
+ });
40
+ if (!res.ok) {
41
+ const body = await res.json().catch(() => ({ error: res.statusText }));
42
+ throw new Error(body.error ?? `Submit failed: ${res.status}`);
43
+ }
44
+ return res.json();
45
+ }
46
+ async function getPresignedUploadUrl(config, ticketId, filename, contentType) {
47
+ const res = await fetch(`${config.apiUrl}/api/v1/tickets/${ticketId}/upload`, {
48
+ method: "POST",
49
+ headers: {
50
+ "Content-Type": "application/json",
51
+ "X-API-Key": config.projectApiKey
52
+ },
53
+ body: JSON.stringify({ filename, contentType })
54
+ });
55
+ if (!res.ok) {
56
+ throw new Error(`Upload URL request failed: ${res.status}`);
57
+ }
58
+ return res.json();
59
+ }
60
+ async function uploadFileToR2(presignedUrl, file) {
61
+ const res = await fetch(presignedUrl, {
62
+ method: "PUT",
63
+ headers: { "Content-Type": file.type },
64
+ body: file
65
+ });
66
+ if (!res.ok) {
67
+ throw new Error(`File upload failed: ${res.status}`);
68
+ }
69
+ }
70
+
71
+ // src/support-form.tsx
72
+ import { jsx, jsxs } from "react/jsx-runtime";
73
+ function SupportForm({ config, labels, onSuccess, onCancel }) {
74
+ const [type, setType] = useState("bug");
75
+ const [title, setTitle] = useState("");
76
+ const [description, setDescription] = useState("");
77
+ const [files, setFiles] = useState([]);
78
+ const [isSubmitting, setIsSubmitting] = useState(false);
79
+ const [error, setError] = useState(null);
80
+ const [fileError, setFileError] = useState(null);
81
+ const fileInputRef = useRef(null);
82
+ const MAX_FILE_SIZE = 10 * 1024 * 1024;
83
+ const ALLOWED_TYPES = /^(image|video)\//;
84
+ function validateAndAddFiles(newFiles) {
85
+ setFileError(null);
86
+ const valid = [];
87
+ for (const file of newFiles) {
88
+ if (!ALLOWED_TYPES.test(file.type)) {
89
+ setFileError(labels.fileNotSupported(file.name));
90
+ return;
91
+ }
92
+ if (file.size > MAX_FILE_SIZE) {
93
+ setFileError(labels.fileTooLarge(file.name));
94
+ return;
95
+ }
96
+ valid.push(file);
97
+ }
98
+ setFiles((prev) => [...prev, ...valid]);
99
+ }
100
+ function removeFile(index) {
101
+ setFiles((prev) => prev.filter((_, i) => i !== index));
102
+ setFileError(null);
103
+ }
104
+ async function handleSubmit(e) {
105
+ e.preventDefault();
106
+ setIsSubmitting(true);
107
+ setError(null);
108
+ try {
109
+ const submission = {
110
+ type,
111
+ title,
112
+ description,
113
+ reporterName: config.user?.name ?? void 0,
114
+ reporterEmail: config.user?.email ?? void 0,
115
+ metadata: {
116
+ browser: navigator.userAgent,
117
+ os: navigator.platform,
118
+ currentUrl: window.location.href,
119
+ appVersion: config.appVersion
120
+ }
121
+ };
122
+ const { id: ticketId } = await submitTicket(
123
+ { apiUrl: config.apiUrl, projectApiKey: config.projectApiKey },
124
+ submission
125
+ );
126
+ for (const file of files) {
127
+ const { presignedUrl } = await getPresignedUploadUrl(
128
+ { apiUrl: config.apiUrl, projectApiKey: config.projectApiKey },
129
+ ticketId,
130
+ file.name,
131
+ file.type
132
+ );
133
+ await uploadFileToR2(presignedUrl, file);
134
+ }
135
+ onSuccess();
136
+ } catch (err) {
137
+ setError(err instanceof Error ? err.message : labels.submissionFailed);
138
+ } finally {
139
+ setIsSubmitting(false);
140
+ }
141
+ }
142
+ function handleFileDrop(e) {
143
+ e.preventDefault();
144
+ const dropped = Array.from(e.dataTransfer.files);
145
+ validateAndAddFiles(dropped);
146
+ }
147
+ const isFormValid = title.trim().length > 0 && description.trim().length > 0;
148
+ const inputStyle = {
149
+ width: "100%",
150
+ background: "#1a1a1a",
151
+ border: "1px solid #2a2a2a",
152
+ borderRadius: 4,
153
+ color: "#e8e8e8",
154
+ padding: "7px 10px",
155
+ fontSize: 12,
156
+ fontFamily: "inherit",
157
+ outline: "none",
158
+ boxSizing: "border-box"
159
+ };
160
+ return /* @__PURE__ */ jsxs("form", { onSubmit: handleSubmit, style: { display: "flex", flexDirection: "column", gap: 12 }, children: [
161
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 4 }, children: [
162
+ /* @__PURE__ */ jsx(
163
+ "button",
164
+ {
165
+ type: "button",
166
+ onClick: () => setType("bug"),
167
+ style: {
168
+ flex: 1,
169
+ padding: "6px 0",
170
+ borderRadius: 4,
171
+ border: `1px solid ${type === "bug" ? "#ff4d4d" : "#2a2a2a"}`,
172
+ background: type === "bug" ? "rgba(255,77,77,0.15)" : "#1a1a1a",
173
+ color: type === "bug" ? "#ff6b6b" : "#888",
174
+ fontSize: 11,
175
+ fontFamily: "inherit",
176
+ cursor: "pointer",
177
+ fontWeight: 600,
178
+ letterSpacing: 0.5
179
+ },
180
+ children: labels.bugReport
181
+ }
182
+ ),
183
+ /* @__PURE__ */ jsx(
184
+ "button",
185
+ {
186
+ type: "button",
187
+ onClick: () => setType("feature_request"),
188
+ style: {
189
+ flex: 1,
190
+ padding: "6px 0",
191
+ borderRadius: 4,
192
+ border: `1px solid ${type === "feature_request" ? "#4d9fff" : "#2a2a2a"}`,
193
+ background: type === "feature_request" ? "rgba(77,159,255,0.15)" : "#1a1a1a",
194
+ color: type === "feature_request" ? "#6bb5ff" : "#888",
195
+ fontSize: 11,
196
+ fontFamily: "inherit",
197
+ cursor: "pointer",
198
+ fontWeight: 600,
199
+ letterSpacing: 0.5
200
+ },
201
+ children: labels.featureRequest
202
+ }
203
+ )
204
+ ] }),
205
+ /* @__PURE__ */ jsxs("div", { children: [
206
+ /* @__PURE__ */ jsx("label", { style: { display: "block", fontSize: 11, color: "#888", marginBottom: 4 }, children: labels.titleLabel }),
207
+ /* @__PURE__ */ jsx(
208
+ "input",
209
+ {
210
+ value: title,
211
+ onChange: (e) => setTitle(e.target.value),
212
+ placeholder: labels.titlePlaceholder,
213
+ required: true,
214
+ style: inputStyle
215
+ }
216
+ )
217
+ ] }),
218
+ /* @__PURE__ */ jsxs("div", { children: [
219
+ /* @__PURE__ */ jsx("label", { style: { display: "block", fontSize: 11, color: "#888", marginBottom: 4 }, children: labels.descriptionLabel }),
220
+ /* @__PURE__ */ jsx(
221
+ "textarea",
222
+ {
223
+ value: description,
224
+ onChange: (e) => setDescription(e.target.value),
225
+ placeholder: labels.descriptionPlaceholder,
226
+ required: true,
227
+ rows: 4,
228
+ style: { ...inputStyle, resize: "vertical", minHeight: 80 }
229
+ }
230
+ )
231
+ ] }),
232
+ /* @__PURE__ */ jsxs("div", { children: [
233
+ /* @__PURE__ */ jsx("label", { style: { display: "block", fontSize: 11, color: "#888", marginBottom: 4 }, children: labels.attachmentsLabel }),
234
+ /* @__PURE__ */ jsx(
235
+ "div",
236
+ {
237
+ onDrop: handleFileDrop,
238
+ onDragOver: (e) => e.preventDefault(),
239
+ onClick: () => fileInputRef.current?.click(),
240
+ style: {
241
+ border: "1px dashed #2a2a2a",
242
+ borderRadius: 4,
243
+ padding: "10px",
244
+ textAlign: "center",
245
+ color: "#555",
246
+ fontSize: 11,
247
+ cursor: "pointer",
248
+ background: "#1a1a1a"
249
+ },
250
+ children: files.length > 0 ? files.map((f, i) => /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", color: "#888", fontSize: 11, padding: "2px 0" }, children: [
251
+ /* @__PURE__ */ jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }, children: f.name }),
252
+ /* @__PURE__ */ jsx(
253
+ "button",
254
+ {
255
+ type: "button",
256
+ onClick: (e) => {
257
+ e.stopPropagation();
258
+ removeFile(i);
259
+ },
260
+ style: {
261
+ background: "none",
262
+ border: "none",
263
+ color: "#ff4d4d",
264
+ cursor: "pointer",
265
+ fontSize: 14,
266
+ padding: "0 4px",
267
+ lineHeight: 1,
268
+ fontFamily: "inherit",
269
+ flexShrink: 0
270
+ },
271
+ "aria-label": `Remove ${f.name}`,
272
+ children: "x"
273
+ }
274
+ )
275
+ ] }, `${f.name}-${i}`)) : labels.attachmentsDropzone
276
+ }
277
+ ),
278
+ fileError && /* @__PURE__ */ jsx("p", { style: { color: "#ff4d4d", fontSize: 11, margin: "4px 0 0" }, children: fileError }),
279
+ /* @__PURE__ */ jsx(
280
+ "input",
281
+ {
282
+ ref: fileInputRef,
283
+ type: "file",
284
+ multiple: true,
285
+ accept: "image/*,video/*",
286
+ style: { display: "none" },
287
+ onChange: (e) => {
288
+ validateAndAddFiles(Array.from(e.target.files ?? []));
289
+ e.target.value = "";
290
+ }
291
+ }
292
+ )
293
+ ] }),
294
+ error && /* @__PURE__ */ jsx("p", { style: { color: "#ff4d4d", fontSize: 11, margin: 0 }, children: error }),
295
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 8 }, children: [
296
+ /* @__PURE__ */ jsx(
297
+ "button",
298
+ {
299
+ type: "submit",
300
+ disabled: isSubmitting || !isFormValid,
301
+ style: {
302
+ flex: 1,
303
+ padding: "8px",
304
+ borderRadius: 4,
305
+ border: "none",
306
+ background: "#b4e62e",
307
+ color: "#000",
308
+ fontWeight: 600,
309
+ fontSize: 12,
310
+ fontFamily: "inherit",
311
+ cursor: isSubmitting || !isFormValid ? "not-allowed" : "pointer",
312
+ opacity: isSubmitting || !isFormValid ? 0.7 : 1
313
+ },
314
+ children: isSubmitting ? labels.sending : labels.send
315
+ }
316
+ ),
317
+ /* @__PURE__ */ jsx(
318
+ "button",
319
+ {
320
+ type: "button",
321
+ onClick: onCancel,
322
+ style: {
323
+ padding: "8px 16px",
324
+ borderRadius: 4,
325
+ border: "1px solid #2a2a2a",
326
+ background: "transparent",
327
+ color: "#888",
328
+ fontSize: 12,
329
+ fontFamily: "inherit",
330
+ cursor: "pointer"
331
+ },
332
+ children: labels.cancel
333
+ }
334
+ )
335
+ ] })
336
+ ] });
337
+ }
338
+
339
+ // src/support-widget.tsx
340
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
341
+ function SupportWidget(props) {
342
+ const labels = { ...defaultLabels, ...props.labels };
343
+ const [panelState, setPanelState] = useState2("closed");
344
+ const [openCount, setOpenCount] = useState2(0);
345
+ const panelRef = useRef2(null);
346
+ useEffect(() => {
347
+ function handleKeyDown(e) {
348
+ if (e.key === "Escape") setPanelState("closed");
349
+ }
350
+ window.addEventListener("keydown", handleKeyDown);
351
+ return () => window.removeEventListener("keydown", handleKeyDown);
352
+ }, []);
353
+ useEffect(() => {
354
+ if (panelState === "success") {
355
+ const timer = setTimeout(() => setPanelState("closed"), 2e3);
356
+ return () => clearTimeout(timer);
357
+ }
358
+ }, [panelState]);
359
+ useEffect(() => {
360
+ function handleClickOutside(e) {
361
+ if (panelRef.current && !panelRef.current.contains(e.target)) {
362
+ setPanelState("closed");
363
+ }
364
+ }
365
+ if (panelState !== "closed") {
366
+ document.addEventListener("mousedown", handleClickOutside);
367
+ }
368
+ return () => document.removeEventListener("mousedown", handleClickOutside);
369
+ }, [panelState]);
370
+ return /* @__PURE__ */ jsxs2(
371
+ "div",
372
+ {
373
+ ref: panelRef,
374
+ "data-fxl-widget": "",
375
+ style: {
376
+ position: "fixed",
377
+ bottom: 24,
378
+ right: 24,
379
+ zIndex: 9999,
380
+ fontFamily: "'Space Grotesk', system-ui, sans-serif"
381
+ },
382
+ children: [
383
+ panelState !== "closed" && /* @__PURE__ */ jsx2(
384
+ "div",
385
+ {
386
+ className: "fxl-panel",
387
+ style: {
388
+ position: "absolute",
389
+ bottom: 56,
390
+ right: 0,
391
+ width: 300,
392
+ background: "#111111",
393
+ border: "1px solid #2a2a2a",
394
+ borderRadius: 8,
395
+ padding: 16,
396
+ boxShadow: "0 8px 32px rgba(0,0,0,0.6)"
397
+ },
398
+ children: panelState === "success" ? /* @__PURE__ */ jsxs2("div", { style: { textAlign: "center", padding: "16px 0" }, children: [
399
+ /* @__PURE__ */ jsx2("div", { style: { fontSize: 24, marginBottom: 8 }, children: "\u2713" }),
400
+ /* @__PURE__ */ jsx2("p", { style: { color: "#b4e62e", fontWeight: 600, margin: 0, fontSize: 14 }, children: labels.submittedTitle }),
401
+ /* @__PURE__ */ jsx2("p", { style: { color: "#888", fontSize: 12, marginTop: 4, marginBottom: 16 }, children: labels.submittedMessage }),
402
+ /* @__PURE__ */ jsx2(
403
+ "button",
404
+ {
405
+ onClick: () => setPanelState("closed"),
406
+ style: {
407
+ padding: "6px 16px",
408
+ borderRadius: 4,
409
+ border: "1px solid #2a2a2a",
410
+ background: "transparent",
411
+ color: "#888",
412
+ fontSize: 12,
413
+ cursor: "pointer",
414
+ fontFamily: "inherit"
415
+ },
416
+ children: labels.close
417
+ }
418
+ )
419
+ ] }) : /* @__PURE__ */ jsxs2(Fragment, { children: [
420
+ /* @__PURE__ */ jsx2(
421
+ "p",
422
+ {
423
+ style: {
424
+ margin: "0 0 12px",
425
+ fontSize: 13,
426
+ fontWeight: 600,
427
+ color: "#e8e8e8"
428
+ },
429
+ children: labels.sendFeedback
430
+ }
431
+ ),
432
+ /* @__PURE__ */ jsx2(
433
+ SupportForm,
434
+ {
435
+ config: props,
436
+ labels,
437
+ onSuccess: () => setPanelState("success"),
438
+ onCancel: () => setPanelState("closed")
439
+ },
440
+ openCount
441
+ )
442
+ ] })
443
+ }
444
+ ),
445
+ /* @__PURE__ */ jsx2(
446
+ "button",
447
+ {
448
+ onClick: () => {
449
+ setPanelState((s) => {
450
+ if (s === "closed") {
451
+ setOpenCount((c) => c + 1);
452
+ return "open";
453
+ }
454
+ return "closed";
455
+ });
456
+ },
457
+ "aria-label": labels.openWidget,
458
+ style: {
459
+ width: 44,
460
+ height: 44,
461
+ borderRadius: "50%",
462
+ background: "#b4e62e",
463
+ border: "none",
464
+ cursor: "pointer",
465
+ display: "flex",
466
+ alignItems: "center",
467
+ justifyContent: "center",
468
+ boxShadow: "0 2px 12px rgba(180,230,46,0.4)",
469
+ transition: "transform 0.15s, box-shadow 0.15s"
470
+ },
471
+ onMouseEnter: (e) => {
472
+ ;
473
+ e.currentTarget.style.transform = "scale(1.1)";
474
+ e.currentTarget.style.boxShadow = "0 4px 20px rgba(180,230,46,0.5)";
475
+ },
476
+ onMouseLeave: (e) => {
477
+ ;
478
+ e.currentTarget.style.transform = "scale(1)";
479
+ e.currentTarget.style.boxShadow = "0 2px 12px rgba(180,230,46,0.4)";
480
+ },
481
+ children: panelState === "closed" ? (
482
+ // Chat bubble icon
483
+ /* @__PURE__ */ jsx2("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "#000", strokeWidth: "2.5", children: /* @__PURE__ */ jsx2("path", { d: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" }) })
484
+ ) : (
485
+ // X icon
486
+ /* @__PURE__ */ jsxs2("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "#000", strokeWidth: "2.5", children: [
487
+ /* @__PURE__ */ jsx2("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
488
+ /* @__PURE__ */ jsx2("line", { x1: "6", y1: "6", x2: "18", y2: "18" })
489
+ ] })
490
+ )
491
+ }
492
+ )
493
+ ]
494
+ }
495
+ );
496
+ }
497
+ export {
498
+ SupportWidget,
499
+ defaultLabels
500
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@fxl-business/support-widget",
3
+ "version": "0.1.0",
4
+ "description": "Embeddable support widget for FXL Support — bug reports and feature requests",
5
+ "license": "MIT",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.mjs",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.mjs",
13
+ "require": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsup",
21
+ "type-check": "tsc --noEmit",
22
+ "prepublishOnly": "pnpm build"
23
+ },
24
+ "peerDependencies": {
25
+ "react": "^18.0.0",
26
+ "react-dom": "^18.0.0"
27
+ },
28
+ "dependencies": {
29
+ "lucide-react": "^0.475.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/react": "^18.3.0",
33
+ "@types/react-dom": "^18.3.0",
34
+ "typescript": "^5.7.0",
35
+ "tsup": "^8.5.0"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
40
+ }