@newsoftglobal/feedbackkit-js 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/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # @newsoftglobal/feedbackkit-js
2
+
3
+ Vanilla JavaScript SDK for [FeedbackKit](https://feedbackkit.dev) — zero dependencies.
4
+
5
+ ## Installation
6
+
7
+ ### Via CDN (recommended)
8
+
9
+ ```html
10
+ <script src="https://unpkg.com/@newsoftglobal/feedbackkit-js/dist/feedbackkit.min.js"></script>
11
+ ```
12
+
13
+ ### Via npm
14
+
15
+ ```bash
16
+ npm install @newsoftglobal/feedbackkit-js
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ### CDN / Script Tag
22
+
23
+ ```html
24
+ <script src="https://unpkg.com/@newsoftglobal/feedbackkit-js/dist/feedbackkit.min.js"></script>
25
+ <script>
26
+ FeedbackKit.init({
27
+ apiKey: 'your-api-key'
28
+ });
29
+ </script>
30
+ ```
31
+
32
+ ### With User Identity
33
+
34
+ ```html
35
+ <script>
36
+ FeedbackKit.init({ apiKey: 'your-api-key' });
37
+
38
+ // After user logs in
39
+ FeedbackKit.setUser({
40
+ id: 'user-123',
41
+ email: 'user@example.com',
42
+ name: 'John Doe'
43
+ });
44
+
45
+ // On logout
46
+ FeedbackKit.clearUser();
47
+ </script>
48
+ ```
49
+
50
+ ### Screenshot Support
51
+
52
+ For real screenshot capture, include [html2canvas](https://html2canvas.hertzen.com):
53
+
54
+ ```html
55
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
56
+ <script src="https://unpkg.com/@newsoftglobal/feedbackkit-js/dist/feedbackkit.min.js"></script>
57
+ ```
58
+
59
+ ## API
60
+
61
+ | Method | Description |
62
+ |--------|-------------|
63
+ | `FeedbackKit.init({ apiKey })` | Initialize the widget |
64
+ | `FeedbackKit.setUser({ id, email, name })` | Set user identity |
65
+ | `FeedbackKit.clearUser()` | Clear user identity |
66
+ | `FeedbackKit.destroy()` | Remove widget from page |
67
+
68
+ ## Features
69
+
70
+ - 🎯 Floating feedback button
71
+ - 📸 Area screenshot capture
72
+ - 📝 Feedback form (bug, feature, improvement, other)
73
+ - 📋 User feedback history
74
+ - 💬 Comment thread with admin replies
75
+ - 👤 User identification
76
+ - 🎨 Dark theme UI
77
+ - 📦 Zero dependencies
78
+ - ⚡ Single `<script>` tag setup
@@ -0,0 +1,669 @@
1
+ /**
2
+ * FeedbackKit JS SDK v1.0.0
3
+ * Vanilla JavaScript — no dependencies
4
+ * Usage: FeedbackKit.init({ apiKey: 'your-key' })
5
+ */
6
+ (function (root) {
7
+ 'use strict';
8
+
9
+ const SERVER_URL = 'https://backend.feedbackkit.dev';
10
+
11
+ // ==================== CSS ====================
12
+ const CSS = `
13
+ .fk-fab{position:fixed;bottom:24px;right:24px;width:52px;height:52px;border-radius:16px;border:none;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;cursor:pointer;box-shadow:0 4px 20px rgba(99,102,241,.4);display:flex;align-items:center;justify-content:center;z-index:99999;transition:all .2s}
14
+ .fk-fab:hover{transform:scale(1.06);box-shadow:0 6px 28px rgba(99,102,241,.55)}
15
+ .fk-panel{position:fixed;bottom:88px;right:24px;width:360px;max-height:480px;background:#1a1d2e;border:1px solid rgba(255,255,255,.08);border-radius:16px;box-shadow:0 16px 48px rgba(0,0,0,.5);z-index:99998;display:flex;flex-direction:column;overflow:hidden;font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#f0f0f5}
16
+ .fk-panel *{box-sizing:border-box;margin:0;padding:0}
17
+ .fk-panel-tabs{display:flex;border-bottom:1px solid rgba(255,255,255,.06)}
18
+ .fk-tab{flex:1;padding:12px;background:none;border:none;color:#a0a0b8;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;transition:all .2s}
19
+ .fk-tab:hover{color:#f0f0f5;background:rgba(255,255,255,.03)}
20
+ .fk-tab.active{color:#818cf8;border-bottom:2px solid #818cf8}
21
+ .fk-panel-body{padding:20px;display:flex;flex-direction:column;gap:12px}
22
+ .fk-panel-hint{font-size:13px;color:#a0a0b8;text-align:center}
23
+ .fk-btn{padding:10px 16px;border-radius:10px;border:none;font-family:inherit;font-size:13px;font-weight:600;cursor:pointer;transition:all .2s;text-align:center}
24
+ .fk-btn-primary{background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;box-shadow:0 2px 12px rgba(99,102,241,.3)}
25
+ .fk-btn-primary:hover{box-shadow:0 4px 20px rgba(99,102,241,.5);transform:translateY(-1px)}
26
+ .fk-btn-full{width:100%}
27
+ .fk-btn:disabled{opacity:.5;cursor:not-allowed;transform:none!important}
28
+ .fk-form{padding:20px;display:flex;flex-direction:column;gap:14px;overflow-y:auto;max-height:420px}
29
+ .fk-form label{font-size:12px;font-weight:600;color:#a0a0b8;display:block;margin-bottom:4px}
30
+ .fk-form input,.fk-form textarea,.fk-form select{width:100%;padding:10px 12px;border-radius:8px;border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.04);color:#f0f0f5;font-family:inherit;font-size:13px;outline:none;transition:border .2s}
31
+ .fk-form input:focus,.fk-form textarea:focus,.fk-form select:focus{border-color:#6366f1}
32
+ .fk-form textarea{resize:vertical;min-height:60px}
33
+ .fk-form select option{background:#1a1d2e;color:#f0f0f5}
34
+ .fk-screenshot-preview{position:relative;border-radius:8px;overflow:hidden;border:1px solid rgba(255,255,255,.08)}
35
+ .fk-screenshot-preview img{width:100%;display:block}
36
+ .fk-screenshot-remove{position:absolute;top:6px;right:6px;width:24px;height:24px;border-radius:50%;border:none;background:rgba(0,0,0,.6);color:#fff;font-size:14px;cursor:pointer;display:flex;align-items:center;justify-content:center}
37
+ .fk-success{text-align:center;padding:30px 20px}
38
+ .fk-success-icon{font-size:40px;margin-bottom:12px}
39
+ .fk-success h4{font-size:16px;font-weight:700;margin-bottom:4px}
40
+ .fk-success p{font-size:13px;color:#a0a0b8}
41
+ .fk-list{padding:12px;overflow-y:auto;max-height:380px;display:flex;flex-direction:column;gap:6px}
42
+ .fk-list-item{padding:12px;border-radius:10px;background:rgba(255,255,255,.03);cursor:pointer;transition:background .2s}
43
+ .fk-list-item:hover{background:rgba(255,255,255,.06)}
44
+ .fk-list-item-title{font-size:13px;font-weight:600;margin-bottom:4px}
45
+ .fk-list-item-meta{display:flex;gap:8px;align-items:center}
46
+ .fk-list-item-meta span{font-size:11px;color:#a0a0b8}
47
+ .fk-badge{display:inline-block;padding:2px 8px;border-radius:12px;font-size:10px;font-weight:600}
48
+ .fk-badge-bug{background:rgba(239,68,68,.12);color:#fca5a5}
49
+ .fk-badge-feature{background:rgba(99,102,241,.12);color:#818cf8}
50
+ .fk-badge-improvement{background:rgba(234,179,8,.12);color:#fde047}
51
+ .fk-badge-other{background:rgba(59,130,246,.12);color:#93c5fd}
52
+ .fk-badge-new{background:rgba(234,179,8,.12);color:#fde047}
53
+ .fk-badge-in-progress{background:rgba(59,130,246,.12);color:#93c5fd}
54
+ .fk-badge-resolved{background:rgba(34,197,94,.12);color:#86efac}
55
+ .fk-detail-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);backdrop-filter:blur(4px);z-index:100000;display:flex;align-items:center;justify-content:center}
56
+ .fk-detail-modal{background:#1a1d2e;border-radius:16px;width:90%;max-width:440px;max-height:80vh;border:1px solid rgba(255,255,255,.08);box-shadow:0 24px 64px rgba(0,0,0,.5);display:flex;flex-direction:column;overflow:hidden;font-family:'Inter',-apple-system,sans-serif;color:#f0f0f5}
57
+ .fk-detail-header{padding:16px 20px;border-bottom:1px solid rgba(255,255,255,.06);display:flex;justify-content:space-between;align-items:center}
58
+ .fk-detail-header h4{font-size:15px;font-weight:700}
59
+ .fk-detail-close{background:none;border:none;color:#a0a0b8;font-size:20px;cursor:pointer;padding:4px}
60
+ .fk-detail-body{flex:1;overflow-y:auto;padding:16px 20px}
61
+ .fk-detail-desc{font-size:13px;color:#a0a0b8;margin-bottom:16px;line-height:1.5}
62
+ .fk-comments{display:flex;flex-direction:column;gap:10px;margin-top:12px}
63
+ .fk-comment{padding:10px 14px;border-radius:10px;max-width:85%}
64
+ .fk-comment.user{background:rgba(99,102,241,.12);align-self:flex-end}
65
+ .fk-comment.admin{background:rgba(255,255,255,.06);align-self:flex-start}
66
+ .fk-comment-author{font-size:10px;font-weight:600;color:#a0a0b8;margin-bottom:4px}
67
+ .fk-comment-text{font-size:13px;line-height:1.4}
68
+ .fk-comment-time{font-size:10px;color:#555568;margin-top:4px}
69
+ .fk-detail-input{display:flex;gap:8px;padding:12px 20px;border-top:1px solid rgba(255,255,255,.06)}
70
+ .fk-detail-input input{flex:1;padding:10px 12px;border-radius:8px;border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.04);color:#f0f0f5;font-family:inherit;font-size:13px;outline:none}
71
+ .fk-detail-input button{padding:8px 16px;border-radius:8px;border:none;background:#6366f1;color:#fff;font-weight:600;font-size:13px;cursor:pointer}
72
+ .fk-empty{text-align:center;padding:30px 12px;color:#555568;font-size:13px}
73
+ .fk-capture-overlay{position:fixed;inset:0;z-index:100001;cursor:crosshair;background:rgba(0,0,0,.3)}
74
+ .fk-capture-selection{position:fixed;border:2px solid #6366f1;background:rgba(99,102,241,.08);z-index:100002}
75
+ .fk-capture-hint{position:fixed;top:20px;left:50%;transform:translateX(-50%);background:#1a1d2e;color:#f0f0f5;padding:10px 20px;border-radius:10px;font-size:13px;font-family:'Inter',sans-serif;z-index:100003;box-shadow:0 4px 16px rgba(0,0,0,.4)}
76
+ `;
77
+
78
+ // ==================== API ====================
79
+ class FeedbackKitAPI {
80
+ constructor(apiKey) {
81
+ this.apiKey = apiKey;
82
+ this.serverUrl = SERVER_URL;
83
+ this.user = { id: null, email: null, name: null };
84
+ }
85
+
86
+ setUser(info) {
87
+ if (!info?.id) return;
88
+ this.user.id = info.id;
89
+ this.user.email = info.email || null;
90
+ this.user.name = info.name || null;
91
+ }
92
+
93
+ clearUser() {
94
+ this.user = { id: null, email: null, name: null };
95
+ }
96
+
97
+ get hasUser() { return !!this.user.id; }
98
+
99
+ async submitFeedback({ title, description, type, screenshot, metadata, userEmail }) {
100
+ const fd = new FormData();
101
+ fd.append('title', title);
102
+ if (description) fd.append('description', description);
103
+ if (type) fd.append('type', type);
104
+ if (metadata) fd.append('metadata', JSON.stringify(metadata));
105
+ if (this.user.id) fd.append('userId', this.user.id);
106
+ if (this.user.name) fd.append('userName', this.user.name);
107
+ fd.append('userEmail', userEmail || this.user.email || '');
108
+
109
+ if (screenshot) {
110
+ let blob = screenshot;
111
+ if (typeof screenshot === 'string' && screenshot.startsWith('data:')) {
112
+ blob = await (await fetch(screenshot)).blob();
113
+ }
114
+ fd.append('screenshot', blob, 'screenshot.png');
115
+ }
116
+
117
+ const res = await fetch(`${this.serverUrl}/api/feedbacks`, {
118
+ method: 'POST',
119
+ headers: { 'x-api-key': this.apiKey },
120
+ body: fd
121
+ });
122
+ if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || `HTTP ${res.status}`); }
123
+ return res.json();
124
+ }
125
+
126
+ async getUserFeedbacks(page = 1) {
127
+ if (!this.user.id) return { feedbacks: [], pagination: {} };
128
+ const p = new URLSearchParams({ userId: this.user.id, page, limit: 20 });
129
+ const res = await fetch(`${this.serverUrl}/api/feedbacks/user?${p}`, { headers: { 'x-api-key': this.apiKey } });
130
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
131
+ return res.json();
132
+ }
133
+
134
+ async getFeedbackDetail(id) {
135
+ if (!this.user.id) return null;
136
+ const p = new URLSearchParams({ userId: this.user.id });
137
+ const res = await fetch(`${this.serverUrl}/api/feedbacks/user/${id}?${p}`, { headers: { 'x-api-key': this.apiKey } });
138
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
139
+ return (await res.json()).feedback;
140
+ }
141
+
142
+ async addComment(id, text) {
143
+ if (!this.user.id) throw new Error('User not set');
144
+ const res = await fetch(`${this.serverUrl}/api/feedbacks/user/${id}/comments`, {
145
+ method: 'POST',
146
+ headers: { 'x-api-key': this.apiKey, 'Content-Type': 'application/json' },
147
+ body: JSON.stringify({ userId: this.user.id, userName: this.user.name || 'User', text })
148
+ });
149
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
150
+ return res.json();
151
+ }
152
+ }
153
+
154
+ // ==================== Widget ====================
155
+ class FeedbackKitWidget {
156
+ constructor(api) {
157
+ this.api = api;
158
+ this.state = 'idle'; // idle, panel, capturing, form, success, detail
159
+ this.screenshot = null;
160
+ this.activeTab = 'new';
161
+ this.feedbacks = [];
162
+ this.detailFeedback = null;
163
+ this.captureData = {};
164
+ this._injectCSS();
165
+ this._createFAB();
166
+ }
167
+
168
+ _injectCSS() {
169
+ if (document.getElementById('fk-styles')) return;
170
+ const s = document.createElement('style');
171
+ s.id = 'fk-styles';
172
+ s.textContent = CSS;
173
+ document.head.appendChild(s);
174
+ }
175
+
176
+ _createFAB() {
177
+ this.fab = document.createElement('button');
178
+ this.fab.className = 'fk-fab';
179
+ this.fab.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><line x1="9" y1="9" x2="15" y2="9"/><line x1="12" y1="6" x2="12" y2="12"/></svg>';
180
+ this.fab.onclick = () => this._togglePanel();
181
+ document.body.appendChild(this.fab);
182
+ }
183
+
184
+ _togglePanel() {
185
+ if (this.state === 'panel') {
186
+ this._closePanel();
187
+ } else {
188
+ this._showPanel();
189
+ }
190
+ }
191
+
192
+ _showPanel() {
193
+ this.state = 'panel';
194
+ this._updateFABIcon('close');
195
+ this._removeEl('.fk-panel');
196
+ const panel = this._el('div', 'fk-panel');
197
+
198
+ // Tabs
199
+ const tabs = this._el('div', 'fk-panel-tabs');
200
+ const tabNew = this._el('button', `fk-tab ${this.activeTab === 'new' ? 'active' : ''}`);
201
+ tabNew.textContent = '✏️ New Feedback';
202
+ tabNew.onclick = () => { this.activeTab = 'new'; this._showPanel(); };
203
+
204
+ const tabList = this._el('button', `fk-tab ${this.activeTab === 'list' ? 'active' : ''}`);
205
+ tabList.textContent = '📋 My Feedbacks';
206
+ tabList.onclick = () => { this.activeTab = 'list'; this._loadFeedbacks(); this._showPanel(); };
207
+
208
+ tabs.append(tabNew, tabList);
209
+ panel.appendChild(tabs);
210
+
211
+ if (this.activeTab === 'new') {
212
+ const body = this._el('div', 'fk-panel-body');
213
+ const hint = this._el('p', 'fk-panel-hint');
214
+ hint.textContent = 'Capture a screenshot and send your feedback';
215
+ const btn = this._el('button', 'fk-btn fk-btn-primary fk-btn-full');
216
+ btn.textContent = '📸 Select Area & Capture';
217
+ btn.onclick = () => this._startCapture();
218
+ body.append(hint, btn);
219
+ panel.appendChild(body);
220
+ } else {
221
+ this._renderList(panel);
222
+ }
223
+
224
+ document.body.appendChild(panel);
225
+ }
226
+
227
+ _closePanel() {
228
+ this.state = 'idle';
229
+ this._updateFABIcon('open');
230
+ this._removeEl('.fk-panel');
231
+ }
232
+
233
+ _updateFABIcon(type) {
234
+ if (type === 'close') {
235
+ this.fab.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
236
+ } else {
237
+ this.fab.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><line x1="9" y1="9" x2="15" y2="9"/><line x1="12" y1="6" x2="12" y2="12"/></svg>';
238
+ }
239
+ }
240
+
241
+ // ==================== Screen Capture ====================
242
+ _startCapture() {
243
+ this._closePanel();
244
+ this.state = 'capturing';
245
+ this.fab.style.display = 'none';
246
+
247
+ const hint = this._el('div', 'fk-capture-hint');
248
+ hint.textContent = 'Click and drag to select area • Esc to cancel';
249
+ document.body.appendChild(hint);
250
+
251
+ const overlay = this._el('div', 'fk-capture-overlay');
252
+ let startX, startY, selection;
253
+
254
+ overlay.onmousedown = (e) => {
255
+ startX = e.clientX;
256
+ startY = e.clientY;
257
+ selection = this._el('div', 'fk-capture-selection');
258
+ selection.style.left = startX + 'px';
259
+ selection.style.top = startY + 'px';
260
+ document.body.appendChild(selection);
261
+ };
262
+
263
+ overlay.onmousemove = (e) => {
264
+ if (!selection) return;
265
+ const x = Math.min(e.clientX, startX);
266
+ const y = Math.min(e.clientY, startY);
267
+ const w = Math.abs(e.clientX - startX);
268
+ const h = Math.abs(e.clientY - startY);
269
+ Object.assign(selection.style, { left: x + 'px', top: y + 'px', width: w + 'px', height: h + 'px' });
270
+ };
271
+
272
+ overlay.onmouseup = (e) => {
273
+ const rect = {
274
+ x: Math.min(e.clientX, startX),
275
+ y: Math.min(e.clientY, startY),
276
+ w: Math.abs(e.clientX - startX),
277
+ h: Math.abs(e.clientY - startY)
278
+ };
279
+ this._removeEl('.fk-capture-overlay');
280
+ this._removeEl('.fk-capture-selection');
281
+ this._removeEl('.fk-capture-hint');
282
+ this.fab.style.display = '';
283
+
284
+ if (rect.w < 10 || rect.h < 10) {
285
+ this._showPanel();
286
+ return;
287
+ }
288
+
289
+ this._captureArea(rect);
290
+ };
291
+
292
+ const onEsc = (e) => {
293
+ if (e.key === 'Escape') {
294
+ this._removeEl('.fk-capture-overlay');
295
+ this._removeEl('.fk-capture-selection');
296
+ this._removeEl('.fk-capture-hint');
297
+ this.fab.style.display = '';
298
+ this._showPanel();
299
+ document.removeEventListener('keydown', onEsc);
300
+ }
301
+ };
302
+ document.addEventListener('keydown', onEsc);
303
+ document.body.appendChild(overlay);
304
+ }
305
+
306
+ async _captureArea(rect) {
307
+ try {
308
+ // Use html2canvas if available, otherwise capture via canvas
309
+ if (typeof html2canvas !== 'undefined') {
310
+ const canvas = await html2canvas(document.body, {
311
+ x: rect.x + window.scrollX,
312
+ y: rect.y + window.scrollY,
313
+ width: rect.w,
314
+ height: rect.h,
315
+ windowWidth: document.documentElement.scrollWidth,
316
+ windowHeight: document.documentElement.scrollHeight
317
+ });
318
+ this.screenshot = canvas.toDataURL('image/png');
319
+ } else {
320
+ // Fallback: full page screenshot via canvas (limited)
321
+ const canvas = document.createElement('canvas');
322
+ const dpr = window.devicePixelRatio || 1;
323
+ canvas.width = rect.w * dpr;
324
+ canvas.height = rect.h * dpr;
325
+ const ctx = canvas.getContext('2d');
326
+ ctx.scale(dpr, dpr);
327
+ ctx.fillStyle = '#f0f0f5';
328
+ ctx.fillRect(0, 0, rect.w, rect.h);
329
+ ctx.font = '14px Inter, sans-serif';
330
+ ctx.fillStyle = '#555';
331
+ ctx.fillText(`Screenshot area: ${rect.w}×${rect.h}`, 10, 24);
332
+ ctx.fillText('Add html2canvas for real capture', 10, 44);
333
+ this.screenshot = canvas.toDataURL('image/png');
334
+ }
335
+ this._showForm();
336
+ } catch (err) {
337
+ console.error('[FeedbackKit] Capture error:', err);
338
+ this._showPanel();
339
+ }
340
+ }
341
+
342
+ // ==================== Feedback Form ====================
343
+ _showForm() {
344
+ this.state = 'form';
345
+ this._removeEl('.fk-panel');
346
+ this._updateFABIcon('close');
347
+
348
+ const panel = this._el('div', 'fk-panel');
349
+ const form = this._el('div', 'fk-form');
350
+
351
+ // Screenshot preview
352
+ if (this.screenshot) {
353
+ const preview = this._el('div', 'fk-screenshot-preview');
354
+ const img = document.createElement('img');
355
+ img.src = this.screenshot;
356
+ const removeBtn = this._el('button', 'fk-screenshot-remove');
357
+ removeBtn.textContent = '×';
358
+ removeBtn.onclick = () => { this.screenshot = null; preview.remove(); };
359
+ preview.append(img, removeBtn);
360
+ form.appendChild(preview);
361
+ }
362
+
363
+ // Title
364
+ const titleGroup = this._el('div');
365
+ const titleLabel = this._el('label');
366
+ titleLabel.textContent = 'Title *';
367
+ const titleInput = document.createElement('input');
368
+ titleInput.type = 'text';
369
+ titleInput.placeholder = 'Brief summary...';
370
+ titleInput.required = true;
371
+ titleGroup.append(titleLabel, titleInput);
372
+
373
+ // Type
374
+ const typeGroup = this._el('div');
375
+ const typeLabel = this._el('label');
376
+ typeLabel.textContent = 'Type';
377
+ const typeSelect = document.createElement('select');
378
+ ['bug', 'feature', 'improvement', 'other'].forEach(t => {
379
+ const opt = document.createElement('option');
380
+ opt.value = t;
381
+ opt.textContent = t === 'bug' ? '🐛 Bug' : t === 'feature' ? '💡 Feature' : t === 'improvement' ? '⚡ Improvement' : '📝 Other';
382
+ typeSelect.appendChild(opt);
383
+ });
384
+ typeGroup.append(typeLabel, typeSelect);
385
+
386
+ // Description
387
+ const descGroup = this._el('div');
388
+ const descLabel = this._el('label');
389
+ descLabel.textContent = 'Description';
390
+ const descInput = document.createElement('textarea');
391
+ descInput.placeholder = 'More details...';
392
+ descInput.rows = 3;
393
+ descGroup.append(descLabel, descInput);
394
+
395
+ // Email
396
+ const emailGroup = this._el('div');
397
+ const emailLabel = this._el('label');
398
+ emailLabel.textContent = 'Email (optional)';
399
+ const emailInput = document.createElement('input');
400
+ emailInput.type = 'email';
401
+ emailInput.placeholder = 'your@email.com';
402
+ if (this.api.user.email) emailInput.value = this.api.user.email;
403
+ emailGroup.append(emailLabel, emailInput);
404
+
405
+ // Buttons
406
+ const actions = this._el('div');
407
+ actions.style.cssText = 'display:flex;gap:8px;margin-top:4px';
408
+
409
+ const cancelBtn = this._el('button', 'fk-btn');
410
+ cancelBtn.textContent = 'Cancel';
411
+ cancelBtn.style.cssText = 'flex:1;background:rgba(255,255,255,.06);color:#a0a0b8';
412
+ cancelBtn.onclick = () => { this.screenshot = null; this._showPanel(); };
413
+
414
+ const submitBtn = this._el('button', 'fk-btn fk-btn-primary');
415
+ submitBtn.textContent = 'Submit';
416
+ submitBtn.style.flex = '1';
417
+ submitBtn.onclick = async () => {
418
+ if (!titleInput.value.trim()) { titleInput.style.borderColor = '#ef4444'; return; }
419
+ submitBtn.disabled = true;
420
+ submitBtn.textContent = 'Sending...';
421
+ try {
422
+ await this.api.submitFeedback({
423
+ title: titleInput.value.trim(),
424
+ description: descInput.value.trim(),
425
+ type: typeSelect.value,
426
+ screenshot: this.screenshot,
427
+ userEmail: emailInput.value.trim(),
428
+ metadata: { url: window.location.href, userAgent: navigator.userAgent }
429
+ });
430
+ this.screenshot = null;
431
+ this._showSuccess();
432
+ } catch (err) {
433
+ submitBtn.disabled = false;
434
+ submitBtn.textContent = 'Submit';
435
+ alert('Failed: ' + err.message);
436
+ }
437
+ };
438
+
439
+ actions.append(cancelBtn, submitBtn);
440
+ form.append(titleGroup, typeGroup, descGroup, emailGroup, actions);
441
+ panel.appendChild(form);
442
+ document.body.appendChild(panel);
443
+ }
444
+
445
+ _showSuccess() {
446
+ this._removeEl('.fk-panel');
447
+ const panel = this._el('div', 'fk-panel');
448
+ const body = this._el('div', 'fk-success');
449
+ body.innerHTML = '<div class="fk-success-icon">✅</div><h4>Thank you!</h4><p>Your feedback has been submitted successfully.</p>';
450
+ panel.appendChild(body);
451
+ document.body.appendChild(panel);
452
+ this.state = 'panel';
453
+ setTimeout(() => { this.activeTab = 'list'; this._loadFeedbacks(); this._showPanel(); }, 2000);
454
+ }
455
+
456
+ // ==================== Feedback List ====================
457
+ async _loadFeedbacks() {
458
+ try {
459
+ const data = await this.api.getUserFeedbacks();
460
+ this.feedbacks = data.feedbacks || [];
461
+ } catch (e) {
462
+ this.feedbacks = [];
463
+ }
464
+ }
465
+
466
+ _renderList(panel) {
467
+ const list = this._el('div', 'fk-list');
468
+
469
+ if (!this.api.hasUser) {
470
+ list.innerHTML = '<div class="fk-empty">Login to view your feedbacks</div>';
471
+ panel.appendChild(list);
472
+ return;
473
+ }
474
+
475
+ if (!this.feedbacks.length) {
476
+ list.innerHTML = '<div class="fk-empty">No feedbacks yet</div>';
477
+ panel.appendChild(list);
478
+ return;
479
+ }
480
+
481
+ this.feedbacks.forEach(fb => {
482
+ const item = this._el('div', 'fk-list-item');
483
+ const title = this._el('div', 'fk-list-item-title');
484
+ title.textContent = fb.title;
485
+ const meta = this._el('div', 'fk-list-item-meta');
486
+ const typeBadge = this._el('span', `fk-badge fk-badge-${fb.type || 'other'}`);
487
+ typeBadge.textContent = fb.type || 'other';
488
+ const statusBadge = this._el('span', `fk-badge fk-badge-${(fb.status || 'new').replace(' ', '-')}`);
489
+ statusBadge.textContent = fb.status || 'new';
490
+ const date = this._el('span');
491
+ date.textContent = new Date(fb.createdAt).toLocaleDateString();
492
+ meta.append(typeBadge, statusBadge, date);
493
+ item.append(title, meta);
494
+ item.onclick = () => this._showDetail(fb._id);
495
+ list.appendChild(item);
496
+ });
497
+
498
+ panel.appendChild(list);
499
+ }
500
+
501
+ // ==================== Feedback Detail ====================
502
+ async _showDetail(id) {
503
+ try {
504
+ const fb = await this.api.getFeedbackDetail(id);
505
+ if (!fb) return;
506
+ this.detailFeedback = fb;
507
+ this._renderDetail();
508
+ } catch (e) {
509
+ console.error('[FeedbackKit] Detail error:', e);
510
+ }
511
+ }
512
+
513
+ _renderDetail() {
514
+ this._removeEl('.fk-detail-overlay');
515
+ const fb = this.detailFeedback;
516
+
517
+ const overlay = this._el('div', 'fk-detail-overlay');
518
+ const modal = this._el('div', 'fk-detail-modal');
519
+
520
+ // Header
521
+ const header = this._el('div', 'fk-detail-header');
522
+ const h4 = this._el('h4');
523
+ h4.textContent = fb.title;
524
+ const closeBtn = this._el('button', 'fk-detail-close');
525
+ closeBtn.textContent = '×';
526
+ closeBtn.onclick = () => this._removeEl('.fk-detail-overlay');
527
+ header.append(h4, closeBtn);
528
+
529
+ // Body
530
+ const body = this._el('div', 'fk-detail-body');
531
+ if (fb.description) {
532
+ const desc = this._el('p', 'fk-detail-desc');
533
+ desc.textContent = fb.description;
534
+ body.appendChild(desc);
535
+ }
536
+
537
+ // Meta badges
538
+ const metaDiv = this._el('div');
539
+ metaDiv.style.cssText = 'display:flex;gap:8px;margin-bottom:16px';
540
+ const typeBadge = this._el('span', `fk-badge fk-badge-${fb.type || 'other'}`);
541
+ typeBadge.textContent = fb.type || 'other';
542
+ const statusBadge = this._el('span', `fk-badge fk-badge-${(fb.status || 'new').replace(' ', '-')}`);
543
+ statusBadge.textContent = fb.status || 'new';
544
+ metaDiv.append(typeBadge, statusBadge);
545
+ body.appendChild(metaDiv);
546
+
547
+ // Comments
548
+ if (fb.comments && fb.comments.length) {
549
+ const cTitle = this._el('label');
550
+ cTitle.textContent = 'Conversation';
551
+ cTitle.style.cssText = 'font-size:12px;font-weight:600;color:#a0a0b8;margin-bottom:8px;display:block';
552
+ body.appendChild(cTitle);
553
+
554
+ const comments = this._el('div', 'fk-comments');
555
+ fb.comments.forEach(c => {
556
+ const cDiv = this._el('div', `fk-comment ${c.role}`);
557
+ const author = this._el('div', 'fk-comment-author');
558
+ author.textContent = c.authorName || c.role;
559
+ const text = this._el('div', 'fk-comment-text');
560
+ text.textContent = c.text;
561
+ const time = this._el('div', 'fk-comment-time');
562
+ time.textContent = new Date(c.createdAt).toLocaleString();
563
+ cDiv.append(author, text, time);
564
+ comments.appendChild(cDiv);
565
+ });
566
+ body.appendChild(comments);
567
+ }
568
+
569
+ // Input for new comment
570
+ const inputDiv = this._el('div', 'fk-detail-input');
571
+ const input = document.createElement('input');
572
+ input.placeholder = 'Write a reply...';
573
+ const sendBtn = this._el('button');
574
+ sendBtn.textContent = 'Send';
575
+ sendBtn.onclick = async () => {
576
+ if (!input.value.trim()) return;
577
+ sendBtn.disabled = true;
578
+ try {
579
+ await this.api.addComment(fb._id, input.value.trim());
580
+ input.value = '';
581
+ // Reload detail
582
+ this.detailFeedback = await this.api.getFeedbackDetail(fb._id);
583
+ this._renderDetail();
584
+ } catch (e) {
585
+ alert('Failed to send comment');
586
+ } finally {
587
+ sendBtn.disabled = false;
588
+ }
589
+ };
590
+ input.onkeydown = (e) => { if (e.key === 'Enter') sendBtn.click(); };
591
+ inputDiv.append(input, sendBtn);
592
+
593
+ modal.append(header, body, inputDiv);
594
+ overlay.appendChild(modal);
595
+ overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
596
+ document.body.appendChild(overlay);
597
+ }
598
+
599
+ // ==================== Helpers ====================
600
+ _el(tag, cls) {
601
+ const el = document.createElement(tag);
602
+ if (cls) el.className = cls;
603
+ return el;
604
+ }
605
+
606
+ _removeEl(selector) {
607
+ const el = document.querySelector(selector);
608
+ if (el) el.remove();
609
+ }
610
+
611
+ destroy() {
612
+ this._removeEl('.fk-fab');
613
+ this._removeEl('.fk-panel');
614
+ this._removeEl('.fk-detail-overlay');
615
+ this._removeEl('.fk-capture-overlay');
616
+ this._removeEl('.fk-capture-selection');
617
+ this._removeEl('.fk-capture-hint');
618
+ this._removeEl('#fk-styles');
619
+ }
620
+ }
621
+
622
+ // ==================== Public API ====================
623
+ const FeedbackKit = {
624
+ _api: null,
625
+ _widget: null,
626
+
627
+ /**
628
+ * Initialize FeedbackKit
629
+ * @param {{ apiKey: string }} options
630
+ */
631
+ init(options = {}) {
632
+ if (!options.apiKey) {
633
+ console.warn('[FeedbackKit] Missing apiKey');
634
+ return;
635
+ }
636
+ this._api = new FeedbackKitAPI(options.apiKey);
637
+ this._widget = new FeedbackKitWidget(this._api);
638
+ return this;
639
+ },
640
+
641
+ /**
642
+ * Set user identity
643
+ * @param {{ id: string, email?: string, name?: string }} userInfo
644
+ */
645
+ setUser(userInfo) {
646
+ if (this._api) this._api.setUser(userInfo);
647
+ },
648
+
649
+ /** Clear user identity */
650
+ clearUser() {
651
+ if (this._api) this._api.clearUser();
652
+ },
653
+
654
+ /** Destroy widget and clean up */
655
+ destroy() {
656
+ if (this._widget) this._widget.destroy();
657
+ this._widget = null;
658
+ this._api = null;
659
+ }
660
+ };
661
+
662
+ // Export
663
+ if (typeof module !== 'undefined' && module.exports) {
664
+ module.exports = FeedbackKit;
665
+ } else {
666
+ root.FeedbackKit = FeedbackKit;
667
+ }
668
+
669
+ })(typeof window !== 'undefined' ? window : this);
@@ -0,0 +1,557 @@
1
+ (function (root) {
2
+ 'use strict';
3
+ const SERVER_URL = 'https:
4
+ const CSS = `
5
+ .fk-fab{position:fixed;bottom:24px;right:24px;width:52px;height:52px;border-radius:16px;border:none;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;cursor:pointer;box-shadow:0 4px 20px rgba(99,102,241,.4);display:flex;align-items:center;justify-content:center;z-index:99999;transition:all .2s}
6
+ .fk-fab:hover{transform:scale(1.06);box-shadow:0 6px 28px rgba(99,102,241,.55)}
7
+ .fk-panel{position:fixed;bottom:88px;right:24px;width:360px;max-height:480px;background:#1a1d2e;border:1px solid rgba(255,255,255,.08);border-radius:16px;box-shadow:0 16px 48px rgba(0,0,0,.5);z-index:99998;display:flex;flex-direction:column;overflow:hidden;font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#f0f0f5}
8
+ .fk-panel *{box-sizing:border-box;margin:0;padding:0}
9
+ .fk-panel-tabs{display:flex;border-bottom:1px solid rgba(255,255,255,.06)}
10
+ .fk-tab{flex:1;padding:12px;background:none;border:none;color:#a0a0b8;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;transition:all .2s}
11
+ .fk-tab:hover{color:#f0f0f5;background:rgba(255,255,255,.03)}
12
+ .fk-tab.active{color:#818cf8;border-bottom:2px solid #818cf8}
13
+ .fk-panel-body{padding:20px;display:flex;flex-direction:column;gap:12px}
14
+ .fk-panel-hint{font-size:13px;color:#a0a0b8;text-align:center}
15
+ .fk-btn{padding:10px 16px;border-radius:10px;border:none;font-family:inherit;font-size:13px;font-weight:600;cursor:pointer;transition:all .2s;text-align:center}
16
+ .fk-btn-primary{background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;box-shadow:0 2px 12px rgba(99,102,241,.3)}
17
+ .fk-btn-primary:hover{box-shadow:0 4px 20px rgba(99,102,241,.5);transform:translateY(-1px)}
18
+ .fk-btn-full{width:100%}
19
+ .fk-btn:disabled{opacity:.5;cursor:not-allowed;transform:none!important}
20
+ .fk-form{padding:20px;display:flex;flex-direction:column;gap:14px;overflow-y:auto;max-height:420px}
21
+ .fk-form label{font-size:12px;font-weight:600;color:#a0a0b8;display:block;margin-bottom:4px}
22
+ .fk-form input,.fk-form textarea,.fk-form select{width:100%;padding:10px 12px;border-radius:8px;border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.04);color:#f0f0f5;font-family:inherit;font-size:13px;outline:none;transition:border .2s}
23
+ .fk-form input:focus,.fk-form textarea:focus,.fk-form select:focus{border-color:#6366f1}
24
+ .fk-form textarea{resize:vertical;min-height:60px}
25
+ .fk-form select option{background:#1a1d2e;color:#f0f0f5}
26
+ .fk-screenshot-preview{position:relative;border-radius:8px;overflow:hidden;border:1px solid rgba(255,255,255,.08)}
27
+ .fk-screenshot-preview img{width:100%;display:block}
28
+ .fk-screenshot-remove{position:absolute;top:6px;right:6px;width:24px;height:24px;border-radius:50%;border:none;background:rgba(0,0,0,.6);color:#fff;font-size:14px;cursor:pointer;display:flex;align-items:center;justify-content:center}
29
+ .fk-success{text-align:center;padding:30px 20px}
30
+ .fk-success-icon{font-size:40px;margin-bottom:12px}
31
+ .fk-success h4{font-size:16px;font-weight:700;margin-bottom:4px}
32
+ .fk-success p{font-size:13px;color:#a0a0b8}
33
+ .fk-list{padding:12px;overflow-y:auto;max-height:380px;display:flex;flex-direction:column;gap:6px}
34
+ .fk-list-item{padding:12px;border-radius:10px;background:rgba(255,255,255,.03);cursor:pointer;transition:background .2s}
35
+ .fk-list-item:hover{background:rgba(255,255,255,.06)}
36
+ .fk-list-item-title{font-size:13px;font-weight:600;margin-bottom:4px}
37
+ .fk-list-item-meta{display:flex;gap:8px;align-items:center}
38
+ .fk-list-item-meta span{font-size:11px;color:#a0a0b8}
39
+ .fk-badge{display:inline-block;padding:2px 8px;border-radius:12px;font-size:10px;font-weight:600}
40
+ .fk-badge-bug{background:rgba(239,68,68,.12);color:#fca5a5}
41
+ .fk-badge-feature{background:rgba(99,102,241,.12);color:#818cf8}
42
+ .fk-badge-improvement{background:rgba(234,179,8,.12);color:#fde047}
43
+ .fk-badge-other{background:rgba(59,130,246,.12);color:#93c5fd}
44
+ .fk-badge-new{background:rgba(234,179,8,.12);color:#fde047}
45
+ .fk-badge-in-progress{background:rgba(59,130,246,.12);color:#93c5fd}
46
+ .fk-badge-resolved{background:rgba(34,197,94,.12);color:#86efac}
47
+ .fk-detail-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);backdrop-filter:blur(4px);z-index:100000;display:flex;align-items:center;justify-content:center}
48
+ .fk-detail-modal{background:#1a1d2e;border-radius:16px;width:90%;max-width:440px;max-height:80vh;border:1px solid rgba(255,255,255,.08);box-shadow:0 24px 64px rgba(0,0,0,.5);display:flex;flex-direction:column;overflow:hidden;font-family:'Inter',-apple-system,sans-serif;color:#f0f0f5}
49
+ .fk-detail-header{padding:16px 20px;border-bottom:1px solid rgba(255,255,255,.06);display:flex;justify-content:space-between;align-items:center}
50
+ .fk-detail-header h4{font-size:15px;font-weight:700}
51
+ .fk-detail-close{background:none;border:none;color:#a0a0b8;font-size:20px;cursor:pointer;padding:4px}
52
+ .fk-detail-body{flex:1;overflow-y:auto;padding:16px 20px}
53
+ .fk-detail-desc{font-size:13px;color:#a0a0b8;margin-bottom:16px;line-height:1.5}
54
+ .fk-comments{display:flex;flex-direction:column;gap:10px;margin-top:12px}
55
+ .fk-comment{padding:10px 14px;border-radius:10px;max-width:85%}
56
+ .fk-comment.user{background:rgba(99,102,241,.12);align-self:flex-end}
57
+ .fk-comment.admin{background:rgba(255,255,255,.06);align-self:flex-start}
58
+ .fk-comment-author{font-size:10px;font-weight:600;color:#a0a0b8;margin-bottom:4px}
59
+ .fk-comment-text{font-size:13px;line-height:1.4}
60
+ .fk-comment-time{font-size:10px;color:#555568;margin-top:4px}
61
+ .fk-detail-input{display:flex;gap:8px;padding:12px 20px;border-top:1px solid rgba(255,255,255,.06)}
62
+ .fk-detail-input input{flex:1;padding:10px 12px;border-radius:8px;border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.04);color:#f0f0f5;font-family:inherit;font-size:13px;outline:none}
63
+ .fk-detail-input button{padding:8px 16px;border-radius:8px;border:none;background:#6366f1;color:#fff;font-weight:600;font-size:13px;cursor:pointer}
64
+ .fk-empty{text-align:center;padding:30px 12px;color:#555568;font-size:13px}
65
+ .fk-capture-overlay{position:fixed;inset:0;z-index:100001;cursor:crosshair;background:rgba(0,0,0,.3)}
66
+ .fk-capture-selection{position:fixed;border:2px solid #6366f1;background:rgba(99,102,241,.08);z-index:100002}
67
+ .fk-capture-hint{position:fixed;top:20px;left:50%;transform:translateX(-50%);background:#1a1d2e;color:#f0f0f5;padding:10px 20px;border-radius:10px;font-size:13px;font-family:'Inter',sans-serif;z-index:100003;box-shadow:0 4px 16px rgba(0,0,0,.4)}
68
+ `;
69
+ class FeedbackKitAPI {
70
+ constructor(apiKey) {
71
+ this.apiKey = apiKey;
72
+ this.serverUrl = SERVER_URL;
73
+ this.user = { id: null, email: null, name: null };
74
+ }
75
+ setUser(info) {
76
+ if (!info?.id) return;
77
+ this.user.id = info.id;
78
+ this.user.email = info.email || null;
79
+ this.user.name = info.name || null;
80
+ }
81
+ clearUser() {
82
+ this.user = { id: null, email: null, name: null };
83
+ }
84
+ get hasUser() { return !!this.user.id; }
85
+ async submitFeedback({ title, description, type, screenshot, metadata, userEmail }) {
86
+ const fd = new FormData();
87
+ fd.append('title', title);
88
+ if (description) fd.append('description', description);
89
+ if (type) fd.append('type', type);
90
+ if (metadata) fd.append('metadata', JSON.stringify(metadata));
91
+ if (this.user.id) fd.append('userId', this.user.id);
92
+ if (this.user.name) fd.append('userName', this.user.name);
93
+ fd.append('userEmail', userEmail || this.user.email || '');
94
+ if (screenshot) {
95
+ let blob = screenshot;
96
+ if (typeof screenshot === 'string' && screenshot.startsWith('data:')) {
97
+ blob = await (await fetch(screenshot)).blob();
98
+ }
99
+ fd.append('screenshot', blob, 'screenshot.png');
100
+ }
101
+ const res = await fetch(`${this.serverUrl}/api/feedbacks`, {
102
+ method: 'POST',
103
+ headers: { 'x-api-key': this.apiKey },
104
+ body: fd
105
+ });
106
+ if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || `HTTP ${res.status}`); }
107
+ return res.json();
108
+ }
109
+ async getUserFeedbacks(page = 1) {
110
+ if (!this.user.id) return { feedbacks: [], pagination: {} };
111
+ const p = new URLSearchParams({ userId: this.user.id, page, limit: 20 });
112
+ const res = await fetch(`${this.serverUrl}/api/feedbacks/user?${p}`, { headers: { 'x-api-key': this.apiKey } });
113
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
114
+ return res.json();
115
+ }
116
+ async getFeedbackDetail(id) {
117
+ if (!this.user.id) return null;
118
+ const p = new URLSearchParams({ userId: this.user.id });
119
+ const res = await fetch(`${this.serverUrl}/api/feedbacks/user/${id}?${p}`, { headers: { 'x-api-key': this.apiKey } });
120
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
121
+ return (await res.json()).feedback;
122
+ }
123
+ async addComment(id, text) {
124
+ if (!this.user.id) throw new Error('User not set');
125
+ const res = await fetch(`${this.serverUrl}/api/feedbacks/user/${id}/comments`, {
126
+ method: 'POST',
127
+ headers: { 'x-api-key': this.apiKey, 'Content-Type': 'application/json' },
128
+ body: JSON.stringify({ userId: this.user.id, userName: this.user.name || 'User', text })
129
+ });
130
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
131
+ return res.json();
132
+ }
133
+ }
134
+ class FeedbackKitWidget {
135
+ constructor(api) {
136
+ this.api = api;
137
+ this.state = 'idle';
138
+ this.screenshot = null;
139
+ this.activeTab = 'new';
140
+ this.feedbacks = [];
141
+ this.detailFeedback = null;
142
+ this.captureData = {};
143
+ this._injectCSS();
144
+ this._createFAB();
145
+ }
146
+ _injectCSS() {
147
+ if (document.getElementById('fk-styles')) return;
148
+ const s = document.createElement('style');
149
+ s.id = 'fk-styles';
150
+ s.textContent = CSS;
151
+ document.head.appendChild(s);
152
+ }
153
+ _createFAB() {
154
+ this.fab = document.createElement('button');
155
+ this.fab.className = 'fk-fab';
156
+ this.fab.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><line x1="9" y1="9" x2="15" y2="9"/><line x1="12" y1="6" x2="12" y2="12"/></svg>';
157
+ this.fab.onclick = () => this._togglePanel();
158
+ document.body.appendChild(this.fab);
159
+ }
160
+ _togglePanel() {
161
+ if (this.state === 'panel') {
162
+ this._closePanel();
163
+ } else {
164
+ this._showPanel();
165
+ }
166
+ }
167
+ _showPanel() {
168
+ this.state = 'panel';
169
+ this._updateFABIcon('close');
170
+ this._removeEl('.fk-panel');
171
+ const panel = this._el('div', 'fk-panel');
172
+ const tabs = this._el('div', 'fk-panel-tabs');
173
+ const tabNew = this._el('button', `fk-tab ${this.activeTab === 'new' ? 'active' : ''}`);
174
+ tabNew.textContent = '✏️ New Feedback';
175
+ tabNew.onclick = () => { this.activeTab = 'new'; this._showPanel(); };
176
+ const tabList = this._el('button', `fk-tab ${this.activeTab === 'list' ? 'active' : ''}`);
177
+ tabList.textContent = '📋 My Feedbacks';
178
+ tabList.onclick = () => { this.activeTab = 'list'; this._loadFeedbacks(); this._showPanel(); };
179
+ tabs.append(tabNew, tabList);
180
+ panel.appendChild(tabs);
181
+ if (this.activeTab === 'new') {
182
+ const body = this._el('div', 'fk-panel-body');
183
+ const hint = this._el('p', 'fk-panel-hint');
184
+ hint.textContent = 'Capture a screenshot and send your feedback';
185
+ const btn = this._el('button', 'fk-btn fk-btn-primary fk-btn-full');
186
+ btn.textContent = '📸 Select Area & Capture';
187
+ btn.onclick = () => this._startCapture();
188
+ body.append(hint, btn);
189
+ panel.appendChild(body);
190
+ } else {
191
+ this._renderList(panel);
192
+ }
193
+ document.body.appendChild(panel);
194
+ }
195
+ _closePanel() {
196
+ this.state = 'idle';
197
+ this._updateFABIcon('open');
198
+ this._removeEl('.fk-panel');
199
+ }
200
+ _updateFABIcon(type) {
201
+ if (type === 'close') {
202
+ this.fab.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
203
+ } else {
204
+ this.fab.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><line x1="9" y1="9" x2="15" y2="9"/><line x1="12" y1="6" x2="12" y2="12"/></svg>';
205
+ }
206
+ }
207
+ _startCapture() {
208
+ this._closePanel();
209
+ this.state = 'capturing';
210
+ this.fab.style.display = 'none';
211
+ const hint = this._el('div', 'fk-capture-hint');
212
+ hint.textContent = 'Click and drag to select area • Esc to cancel';
213
+ document.body.appendChild(hint);
214
+ const overlay = this._el('div', 'fk-capture-overlay');
215
+ let startX, startY, selection;
216
+ overlay.onmousedown = (e) => {
217
+ startX = e.clientX;
218
+ startY = e.clientY;
219
+ selection = this._el('div', 'fk-capture-selection');
220
+ selection.style.left = startX + 'px';
221
+ selection.style.top = startY + 'px';
222
+ document.body.appendChild(selection);
223
+ };
224
+ overlay.onmousemove = (e) => {
225
+ if (!selection) return;
226
+ const x = Math.min(e.clientX, startX);
227
+ const y = Math.min(e.clientY, startY);
228
+ const w = Math.abs(e.clientX - startX);
229
+ const h = Math.abs(e.clientY - startY);
230
+ Object.assign(selection.style, { left: x + 'px', top: y + 'px', width: w + 'px', height: h + 'px' });
231
+ };
232
+ overlay.onmouseup = (e) => {
233
+ const rect = {
234
+ x: Math.min(e.clientX, startX),
235
+ y: Math.min(e.clientY, startY),
236
+ w: Math.abs(e.clientX - startX),
237
+ h: Math.abs(e.clientY - startY)
238
+ };
239
+ this._removeEl('.fk-capture-overlay');
240
+ this._removeEl('.fk-capture-selection');
241
+ this._removeEl('.fk-capture-hint');
242
+ this.fab.style.display = '';
243
+ if (rect.w < 10 || rect.h < 10) {
244
+ this._showPanel();
245
+ return;
246
+ }
247
+ this._captureArea(rect);
248
+ };
249
+ const onEsc = (e) => {
250
+ if (e.key === 'Escape') {
251
+ this._removeEl('.fk-capture-overlay');
252
+ this._removeEl('.fk-capture-selection');
253
+ this._removeEl('.fk-capture-hint');
254
+ this.fab.style.display = '';
255
+ this._showPanel();
256
+ document.removeEventListener('keydown', onEsc);
257
+ }
258
+ };
259
+ document.addEventListener('keydown', onEsc);
260
+ document.body.appendChild(overlay);
261
+ }
262
+ async _captureArea(rect) {
263
+ try {
264
+ if (typeof html2canvas !== 'undefined') {
265
+ const canvas = await html2canvas(document.body, {
266
+ x: rect.x + window.scrollX,
267
+ y: rect.y + window.scrollY,
268
+ width: rect.w,
269
+ height: rect.h,
270
+ windowWidth: document.documentElement.scrollWidth,
271
+ windowHeight: document.documentElement.scrollHeight
272
+ });
273
+ this.screenshot = canvas.toDataURL('image/png');
274
+ } else {
275
+ const canvas = document.createElement('canvas');
276
+ const dpr = window.devicePixelRatio || 1;
277
+ canvas.width = rect.w * dpr;
278
+ canvas.height = rect.h * dpr;
279
+ const ctx = canvas.getContext('2d');
280
+ ctx.scale(dpr, dpr);
281
+ ctx.fillStyle = '#f0f0f5';
282
+ ctx.fillRect(0, 0, rect.w, rect.h);
283
+ ctx.font = '14px Inter, sans-serif';
284
+ ctx.fillStyle = '#555';
285
+ ctx.fillText(`Screenshot area: ${rect.w}×${rect.h}`, 10, 24);
286
+ ctx.fillText('Add html2canvas for real capture', 10, 44);
287
+ this.screenshot = canvas.toDataURL('image/png');
288
+ }
289
+ this._showForm();
290
+ } catch (err) {
291
+ console.error('[FeedbackKit] Capture error:', err);
292
+ this._showPanel();
293
+ }
294
+ }
295
+ _showForm() {
296
+ this.state = 'form';
297
+ this._removeEl('.fk-panel');
298
+ this._updateFABIcon('close');
299
+ const panel = this._el('div', 'fk-panel');
300
+ const form = this._el('div', 'fk-form');
301
+ if (this.screenshot) {
302
+ const preview = this._el('div', 'fk-screenshot-preview');
303
+ const img = document.createElement('img');
304
+ img.src = this.screenshot;
305
+ const removeBtn = this._el('button', 'fk-screenshot-remove');
306
+ removeBtn.textContent = '×';
307
+ removeBtn.onclick = () => { this.screenshot = null; preview.remove(); };
308
+ preview.append(img, removeBtn);
309
+ form.appendChild(preview);
310
+ }
311
+ const titleGroup = this._el('div');
312
+ const titleLabel = this._el('label');
313
+ titleLabel.textContent = 'Title *';
314
+ const titleInput = document.createElement('input');
315
+ titleInput.type = 'text';
316
+ titleInput.placeholder = 'Brief summary...';
317
+ titleInput.required = true;
318
+ titleGroup.append(titleLabel, titleInput);
319
+ const typeGroup = this._el('div');
320
+ const typeLabel = this._el('label');
321
+ typeLabel.textContent = 'Type';
322
+ const typeSelect = document.createElement('select');
323
+ ['bug', 'feature', 'improvement', 'other'].forEach(t => {
324
+ const opt = document.createElement('option');
325
+ opt.value = t;
326
+ opt.textContent = t === 'bug' ? '🐛 Bug' : t === 'feature' ? '💡 Feature' : t === 'improvement' ? '⚡ Improvement' : '📝 Other';
327
+ typeSelect.appendChild(opt);
328
+ });
329
+ typeGroup.append(typeLabel, typeSelect);
330
+ const descGroup = this._el('div');
331
+ const descLabel = this._el('label');
332
+ descLabel.textContent = 'Description';
333
+ const descInput = document.createElement('textarea');
334
+ descInput.placeholder = 'More details...';
335
+ descInput.rows = 3;
336
+ descGroup.append(descLabel, descInput);
337
+ const emailGroup = this._el('div');
338
+ const emailLabel = this._el('label');
339
+ emailLabel.textContent = 'Email (optional)';
340
+ const emailInput = document.createElement('input');
341
+ emailInput.type = 'email';
342
+ emailInput.placeholder = 'your@email.com';
343
+ if (this.api.user.email) emailInput.value = this.api.user.email;
344
+ emailGroup.append(emailLabel, emailInput);
345
+ const actions = this._el('div');
346
+ actions.style.cssText = 'display:flex;gap:8px;margin-top:4px';
347
+ const cancelBtn = this._el('button', 'fk-btn');
348
+ cancelBtn.textContent = 'Cancel';
349
+ cancelBtn.style.cssText = 'flex:1;background:rgba(255,255,255,.06);color:#a0a0b8';
350
+ cancelBtn.onclick = () => { this.screenshot = null; this._showPanel(); };
351
+ const submitBtn = this._el('button', 'fk-btn fk-btn-primary');
352
+ submitBtn.textContent = 'Submit';
353
+ submitBtn.style.flex = '1';
354
+ submitBtn.onclick = async () => {
355
+ if (!titleInput.value.trim()) { titleInput.style.borderColor = '#ef4444'; return; }
356
+ submitBtn.disabled = true;
357
+ submitBtn.textContent = 'Sending...';
358
+ try {
359
+ await this.api.submitFeedback({
360
+ title: titleInput.value.trim(),
361
+ description: descInput.value.trim(),
362
+ type: typeSelect.value,
363
+ screenshot: this.screenshot,
364
+ userEmail: emailInput.value.trim(),
365
+ metadata: { url: window.location.href, userAgent: navigator.userAgent }
366
+ });
367
+ this.screenshot = null;
368
+ this._showSuccess();
369
+ } catch (err) {
370
+ submitBtn.disabled = false;
371
+ submitBtn.textContent = 'Submit';
372
+ alert('Failed: ' + err.message);
373
+ }
374
+ };
375
+ actions.append(cancelBtn, submitBtn);
376
+ form.append(titleGroup, typeGroup, descGroup, emailGroup, actions);
377
+ panel.appendChild(form);
378
+ document.body.appendChild(panel);
379
+ }
380
+ _showSuccess() {
381
+ this._removeEl('.fk-panel');
382
+ const panel = this._el('div', 'fk-panel');
383
+ const body = this._el('div', 'fk-success');
384
+ body.innerHTML = '<div class="fk-success-icon">✅</div><h4>Thank you!</h4><p>Your feedback has been submitted successfully.</p>';
385
+ panel.appendChild(body);
386
+ document.body.appendChild(panel);
387
+ this.state = 'panel';
388
+ setTimeout(() => { this.activeTab = 'list'; this._loadFeedbacks(); this._showPanel(); }, 2000);
389
+ }
390
+ async _loadFeedbacks() {
391
+ try {
392
+ const data = await this.api.getUserFeedbacks();
393
+ this.feedbacks = data.feedbacks || [];
394
+ } catch (e) {
395
+ this.feedbacks = [];
396
+ }
397
+ }
398
+ _renderList(panel) {
399
+ const list = this._el('div', 'fk-list');
400
+ if (!this.api.hasUser) {
401
+ list.innerHTML = '<div class="fk-empty">Login to view your feedbacks</div>';
402
+ panel.appendChild(list);
403
+ return;
404
+ }
405
+ if (!this.feedbacks.length) {
406
+ list.innerHTML = '<div class="fk-empty">No feedbacks yet</div>';
407
+ panel.appendChild(list);
408
+ return;
409
+ }
410
+ this.feedbacks.forEach(fb => {
411
+ const item = this._el('div', 'fk-list-item');
412
+ const title = this._el('div', 'fk-list-item-title');
413
+ title.textContent = fb.title;
414
+ const meta = this._el('div', 'fk-list-item-meta');
415
+ const typeBadge = this._el('span', `fk-badge fk-badge-${fb.type || 'other'}`);
416
+ typeBadge.textContent = fb.type || 'other';
417
+ const statusBadge = this._el('span', `fk-badge fk-badge-${(fb.status || 'new').replace(' ', '-')}`);
418
+ statusBadge.textContent = fb.status || 'new';
419
+ const date = this._el('span');
420
+ date.textContent = new Date(fb.createdAt).toLocaleDateString();
421
+ meta.append(typeBadge, statusBadge, date);
422
+ item.append(title, meta);
423
+ item.onclick = () => this._showDetail(fb._id);
424
+ list.appendChild(item);
425
+ });
426
+ panel.appendChild(list);
427
+ }
428
+ async _showDetail(id) {
429
+ try {
430
+ const fb = await this.api.getFeedbackDetail(id);
431
+ if (!fb) return;
432
+ this.detailFeedback = fb;
433
+ this._renderDetail();
434
+ } catch (e) {
435
+ console.error('[FeedbackKit] Detail error:', e);
436
+ }
437
+ }
438
+ _renderDetail() {
439
+ this._removeEl('.fk-detail-overlay');
440
+ const fb = this.detailFeedback;
441
+ const overlay = this._el('div', 'fk-detail-overlay');
442
+ const modal = this._el('div', 'fk-detail-modal');
443
+ const header = this._el('div', 'fk-detail-header');
444
+ const h4 = this._el('h4');
445
+ h4.textContent = fb.title;
446
+ const closeBtn = this._el('button', 'fk-detail-close');
447
+ closeBtn.textContent = '×';
448
+ closeBtn.onclick = () => this._removeEl('.fk-detail-overlay');
449
+ header.append(h4, closeBtn);
450
+ const body = this._el('div', 'fk-detail-body');
451
+ if (fb.description) {
452
+ const desc = this._el('p', 'fk-detail-desc');
453
+ desc.textContent = fb.description;
454
+ body.appendChild(desc);
455
+ }
456
+ const metaDiv = this._el('div');
457
+ metaDiv.style.cssText = 'display:flex;gap:8px;margin-bottom:16px';
458
+ const typeBadge = this._el('span', `fk-badge fk-badge-${fb.type || 'other'}`);
459
+ typeBadge.textContent = fb.type || 'other';
460
+ const statusBadge = this._el('span', `fk-badge fk-badge-${(fb.status || 'new').replace(' ', '-')}`);
461
+ statusBadge.textContent = fb.status || 'new';
462
+ metaDiv.append(typeBadge, statusBadge);
463
+ body.appendChild(metaDiv);
464
+ if (fb.comments && fb.comments.length) {
465
+ const cTitle = this._el('label');
466
+ cTitle.textContent = 'Conversation';
467
+ cTitle.style.cssText = 'font-size:12px;font-weight:600;color:#a0a0b8;margin-bottom:8px;display:block';
468
+ body.appendChild(cTitle);
469
+ const comments = this._el('div', 'fk-comments');
470
+ fb.comments.forEach(c => {
471
+ const cDiv = this._el('div', `fk-comment ${c.role}`);
472
+ const author = this._el('div', 'fk-comment-author');
473
+ author.textContent = c.authorName || c.role;
474
+ const text = this._el('div', 'fk-comment-text');
475
+ text.textContent = c.text;
476
+ const time = this._el('div', 'fk-comment-time');
477
+ time.textContent = new Date(c.createdAt).toLocaleString();
478
+ cDiv.append(author, text, time);
479
+ comments.appendChild(cDiv);
480
+ });
481
+ body.appendChild(comments);
482
+ }
483
+ const inputDiv = this._el('div', 'fk-detail-input');
484
+ const input = document.createElement('input');
485
+ input.placeholder = 'Write a reply...';
486
+ const sendBtn = this._el('button');
487
+ sendBtn.textContent = 'Send';
488
+ sendBtn.onclick = async () => {
489
+ if (!input.value.trim()) return;
490
+ sendBtn.disabled = true;
491
+ try {
492
+ await this.api.addComment(fb._id, input.value.trim());
493
+ input.value = '';
494
+ this.detailFeedback = await this.api.getFeedbackDetail(fb._id);
495
+ this._renderDetail();
496
+ } catch (e) {
497
+ alert('Failed to send comment');
498
+ } finally {
499
+ sendBtn.disabled = false;
500
+ }
501
+ };
502
+ input.onkeydown = (e) => { if (e.key === 'Enter') sendBtn.click(); };
503
+ inputDiv.append(input, sendBtn);
504
+ modal.append(header, body, inputDiv);
505
+ overlay.appendChild(modal);
506
+ overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
507
+ document.body.appendChild(overlay);
508
+ }
509
+ _el(tag, cls) {
510
+ const el = document.createElement(tag);
511
+ if (cls) el.className = cls;
512
+ return el;
513
+ }
514
+ _removeEl(selector) {
515
+ const el = document.querySelector(selector);
516
+ if (el) el.remove();
517
+ }
518
+ destroy() {
519
+ this._removeEl('.fk-fab');
520
+ this._removeEl('.fk-panel');
521
+ this._removeEl('.fk-detail-overlay');
522
+ this._removeEl('.fk-capture-overlay');
523
+ this._removeEl('.fk-capture-selection');
524
+ this._removeEl('.fk-capture-hint');
525
+ this._removeEl('#fk-styles');
526
+ }
527
+ }
528
+ const FeedbackKit = {
529
+ _api: null,
530
+ _widget: null,
531
+ init(options = {}) {
532
+ if (!options.apiKey) {
533
+ console.warn('[FeedbackKit] Missing apiKey');
534
+ return;
535
+ }
536
+ this._api = new FeedbackKitAPI(options.apiKey);
537
+ this._widget = new FeedbackKitWidget(this._api);
538
+ return this;
539
+ },
540
+ setUser(userInfo) {
541
+ if (this._api) this._api.setUser(userInfo);
542
+ },
543
+ clearUser() {
544
+ if (this._api) this._api.clearUser();
545
+ },
546
+ destroy() {
547
+ if (this._widget) this._widget.destroy();
548
+ this._widget = null;
549
+ this._api = null;
550
+ }
551
+ };
552
+ if (typeof module !== 'undefined' && module.exports) {
553
+ module.exports = FeedbackKit;
554
+ } else {
555
+ root.FeedbackKit = FeedbackKit;
556
+ }
557
+ })(typeof window !== 'undefined' ? window : this);
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@newsoftglobal/feedbackkit-js",
3
+ "version": "1.0.0",
4
+ "description": "FeedbackKit Vanilla JS SDK — Collect user feedback with screenshots, comments, and tracking",
5
+ "author": "NewSoft Global",
6
+ "license": "MIT",
7
+ "main": "dist/feedbackkit.min.js",
8
+ "files": [
9
+ "dist/",
10
+ "README.md"
11
+ ],
12
+ "keywords": [
13
+ "feedback",
14
+ "widget",
15
+ "screenshot",
16
+ "bug-report",
17
+ "user-feedback",
18
+ "javascript",
19
+ "vanilla-js"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://bitbucket.org/newsoftglobal/feedbackkit"
24
+ },
25
+ "scripts": {
26
+ "build": "node build.js",
27
+ "prepublishOnly": "npm run build"
28
+ },
29
+ "devDependencies": {}
30
+ }