@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 +78 -0
- package/dist/feedbackkit.js +669 -0
- package/dist/feedbackkit.min.js +557 -0
- package/package.json +30 -0
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
|
+
}
|