@feedvalue/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -0
- package/dist/index.cjs +1207 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +626 -0
- package/dist/index.d.ts +626 -0
- package/dist/index.js +1200 -0
- package/dist/index.js.map +1 -0
- package/dist/umd/index.min.js +138 -0
- package/dist/umd/index.min.js.map +1 -0
- package/package.json +70 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1200 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
3
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
4
|
+
|
|
5
|
+
// src/event-emitter.ts
|
|
6
|
+
var TypedEventEmitter = class {
|
|
7
|
+
constructor() {
|
|
8
|
+
// Using a Map with proper typing - the inner Function type is acceptable here
|
|
9
|
+
// because we handle type safety at the public API level (on/off/emit methods)
|
|
10
|
+
__publicField(this, "listeners", /* @__PURE__ */ new Map());
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Subscribe to an event
|
|
14
|
+
*
|
|
15
|
+
* @param event - Event name
|
|
16
|
+
* @param handler - Event handler function
|
|
17
|
+
*/
|
|
18
|
+
on(event, handler) {
|
|
19
|
+
if (!this.listeners.has(event)) {
|
|
20
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
21
|
+
}
|
|
22
|
+
this.listeners.get(event).add(handler);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Subscribe to an event for a single emission.
|
|
26
|
+
* The handler will be automatically removed after the first call.
|
|
27
|
+
*
|
|
28
|
+
* @param event - Event name
|
|
29
|
+
* @param handler - Event handler function
|
|
30
|
+
*/
|
|
31
|
+
once(event, handler) {
|
|
32
|
+
const wrappedHandler = ((...args) => {
|
|
33
|
+
this.off(event, wrappedHandler);
|
|
34
|
+
handler(...args);
|
|
35
|
+
});
|
|
36
|
+
this.on(event, wrappedHandler);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Unsubscribe from an event
|
|
40
|
+
*
|
|
41
|
+
* @param event - Event name
|
|
42
|
+
* @param handler - Optional handler to remove (removes all if not provided)
|
|
43
|
+
*/
|
|
44
|
+
off(event, handler) {
|
|
45
|
+
if (!handler) {
|
|
46
|
+
this.listeners.delete(event);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const handlers = this.listeners.get(event);
|
|
50
|
+
if (handlers) {
|
|
51
|
+
handlers.delete(handler);
|
|
52
|
+
if (handlers.size === 0) {
|
|
53
|
+
this.listeners.delete(event);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Emit an event to all subscribers
|
|
59
|
+
*
|
|
60
|
+
* @param event - Event name
|
|
61
|
+
* @param args - Arguments to pass to handlers
|
|
62
|
+
*/
|
|
63
|
+
emit(event, ...args) {
|
|
64
|
+
const handlers = this.listeners.get(event);
|
|
65
|
+
if (handlers) {
|
|
66
|
+
for (const handler of handlers) {
|
|
67
|
+
try {
|
|
68
|
+
handler(...args);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error(`[FeedValue] Error in ${event} handler:`, error);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Remove all event listeners
|
|
77
|
+
*/
|
|
78
|
+
removeAllListeners() {
|
|
79
|
+
this.listeners.clear();
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// src/api-client.ts
|
|
84
|
+
var DEFAULT_API_BASE_URL = "https://api.feedvalue.com";
|
|
85
|
+
var TOKEN_EXPIRY_BUFFER_SECONDS = 30;
|
|
86
|
+
var CONFIG_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
87
|
+
var ApiClient = class {
|
|
88
|
+
constructor(baseUrl = DEFAULT_API_BASE_URL, debug = false) {
|
|
89
|
+
__publicField(this, "baseUrl");
|
|
90
|
+
__publicField(this, "debug");
|
|
91
|
+
// Request deduplication
|
|
92
|
+
__publicField(this, "pendingRequests", /* @__PURE__ */ new Map());
|
|
93
|
+
// Config cache
|
|
94
|
+
__publicField(this, "configCache", /* @__PURE__ */ new Map());
|
|
95
|
+
// Anti-abuse tokens
|
|
96
|
+
__publicField(this, "submissionToken", null);
|
|
97
|
+
__publicField(this, "tokenExpiresAt", null);
|
|
98
|
+
__publicField(this, "fingerprint", null);
|
|
99
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
100
|
+
this.debug = debug;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Validate widget ID to prevent path injection attacks
|
|
104
|
+
* @throws Error if widget ID is invalid
|
|
105
|
+
*/
|
|
106
|
+
validateWidgetId(widgetId) {
|
|
107
|
+
if (!widgetId || typeof widgetId !== "string") {
|
|
108
|
+
throw new Error("Widget ID is required");
|
|
109
|
+
}
|
|
110
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(widgetId)) {
|
|
111
|
+
throw new Error("Invalid widget ID format: only alphanumeric characters, underscores, and hyphens are allowed");
|
|
112
|
+
}
|
|
113
|
+
if (widgetId.length > 64) {
|
|
114
|
+
throw new Error("Widget ID exceeds maximum length of 64 characters");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Set client fingerprint for anti-abuse protection
|
|
119
|
+
*/
|
|
120
|
+
setFingerprint(fingerprint) {
|
|
121
|
+
this.fingerprint = fingerprint;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Get client fingerprint
|
|
125
|
+
*/
|
|
126
|
+
getFingerprint() {
|
|
127
|
+
return this.fingerprint;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Check if submission token is valid
|
|
131
|
+
*/
|
|
132
|
+
hasValidToken() {
|
|
133
|
+
if (!this.submissionToken || !this.tokenExpiresAt) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
return Date.now() / 1e3 < this.tokenExpiresAt - TOKEN_EXPIRY_BUFFER_SECONDS;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Fetch widget configuration
|
|
140
|
+
* Uses caching and request deduplication
|
|
141
|
+
*/
|
|
142
|
+
async fetchConfig(widgetId) {
|
|
143
|
+
this.validateWidgetId(widgetId);
|
|
144
|
+
const cacheKey = `config:${widgetId}`;
|
|
145
|
+
const cached = this.configCache.get(cacheKey);
|
|
146
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
147
|
+
this.log("Config cache hit", { widgetId });
|
|
148
|
+
return cached.data;
|
|
149
|
+
}
|
|
150
|
+
const pendingKey = `fetchConfig:${widgetId}`;
|
|
151
|
+
const pending = this.pendingRequests.get(pendingKey);
|
|
152
|
+
if (pending) {
|
|
153
|
+
this.log("Deduplicating config request", { widgetId });
|
|
154
|
+
return pending;
|
|
155
|
+
}
|
|
156
|
+
const request = this.doFetchConfig(widgetId);
|
|
157
|
+
this.pendingRequests.set(pendingKey, request);
|
|
158
|
+
try {
|
|
159
|
+
const result = await request;
|
|
160
|
+
return result;
|
|
161
|
+
} finally {
|
|
162
|
+
this.pendingRequests.delete(pendingKey);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Actually fetch config from API
|
|
167
|
+
*/
|
|
168
|
+
async doFetchConfig(widgetId) {
|
|
169
|
+
const url = `${this.baseUrl}/api/v1/widgets/${widgetId}/config`;
|
|
170
|
+
const headers = {};
|
|
171
|
+
if (this.fingerprint) {
|
|
172
|
+
headers["X-Client-Fingerprint"] = this.fingerprint;
|
|
173
|
+
}
|
|
174
|
+
this.log("Fetching config", { widgetId, url });
|
|
175
|
+
const response = await fetch(url, {
|
|
176
|
+
method: "GET",
|
|
177
|
+
headers
|
|
178
|
+
});
|
|
179
|
+
if (!response.ok) {
|
|
180
|
+
const error = await this.parseError(response);
|
|
181
|
+
throw new Error(error);
|
|
182
|
+
}
|
|
183
|
+
const data = await response.json();
|
|
184
|
+
if (data.submission_token) {
|
|
185
|
+
this.submissionToken = data.submission_token;
|
|
186
|
+
this.tokenExpiresAt = data.token_expires_at ?? null;
|
|
187
|
+
this.log("Submission token stored", {
|
|
188
|
+
expiresAt: this.tokenExpiresAt ? new Date(this.tokenExpiresAt * 1e3).toISOString() : "unknown"
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
const cacheKey = `config:${widgetId}`;
|
|
192
|
+
this.configCache.set(cacheKey, {
|
|
193
|
+
data,
|
|
194
|
+
expiresAt: Date.now() + CONFIG_CACHE_TTL_MS
|
|
195
|
+
});
|
|
196
|
+
this.log("Config fetched", { widgetId });
|
|
197
|
+
return data;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Submit feedback with optional user data
|
|
201
|
+
*/
|
|
202
|
+
async submitFeedback(widgetId, feedback, userData) {
|
|
203
|
+
this.validateWidgetId(widgetId);
|
|
204
|
+
const url = `${this.baseUrl}/api/v1/widgets/${widgetId}/feedback`;
|
|
205
|
+
if (!this.hasValidToken()) {
|
|
206
|
+
this.log("Token expired, refreshing...");
|
|
207
|
+
await this.fetchConfig(widgetId);
|
|
208
|
+
}
|
|
209
|
+
if (!this.submissionToken) {
|
|
210
|
+
throw new Error("No submission token available");
|
|
211
|
+
}
|
|
212
|
+
const headers = {
|
|
213
|
+
"Content-Type": "application/json",
|
|
214
|
+
"X-Submission-Token": this.submissionToken
|
|
215
|
+
};
|
|
216
|
+
if (this.fingerprint) {
|
|
217
|
+
headers["X-Client-Fingerprint"] = this.fingerprint;
|
|
218
|
+
}
|
|
219
|
+
this.log("Submitting feedback", { widgetId });
|
|
220
|
+
const response = await fetch(url, {
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers,
|
|
223
|
+
body: JSON.stringify({
|
|
224
|
+
message: feedback.message,
|
|
225
|
+
metadata: feedback.metadata,
|
|
226
|
+
...feedback.customFieldValues && {
|
|
227
|
+
customFieldValues: feedback.customFieldValues
|
|
228
|
+
},
|
|
229
|
+
...userData && Object.keys(userData).length > 0 && {
|
|
230
|
+
user: userData
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
});
|
|
234
|
+
if (response.status === 429) {
|
|
235
|
+
const resetAt = response.headers.get("X-RateLimit-Reset");
|
|
236
|
+
const retryAfter = resetAt ? Math.ceil(parseInt(resetAt, 10) - Date.now() / 1e3) : 60;
|
|
237
|
+
throw new Error(`Rate limited. Try again in ${retryAfter} seconds.`);
|
|
238
|
+
}
|
|
239
|
+
if (response.status === 403) {
|
|
240
|
+
const errorData = await response.json().catch(() => ({ detail: "Access denied" }));
|
|
241
|
+
if (errorData.detail?.code && errorData.detail?.message) {
|
|
242
|
+
throw new Error(errorData.detail.message);
|
|
243
|
+
}
|
|
244
|
+
const errorMessage = typeof errorData.detail === "string" ? errorData.detail : "";
|
|
245
|
+
if (errorMessage.includes("token") || errorMessage.includes("expired")) {
|
|
246
|
+
this.log("Token rejected, refreshing...");
|
|
247
|
+
this.submissionToken = null;
|
|
248
|
+
await this.fetchConfig(widgetId);
|
|
249
|
+
if (this.submissionToken) {
|
|
250
|
+
headers["X-Submission-Token"] = this.submissionToken;
|
|
251
|
+
const retryResponse = await fetch(url, {
|
|
252
|
+
method: "POST",
|
|
253
|
+
headers,
|
|
254
|
+
body: JSON.stringify({
|
|
255
|
+
message: feedback.message,
|
|
256
|
+
metadata: feedback.metadata,
|
|
257
|
+
...feedback.customFieldValues && {
|
|
258
|
+
customFieldValues: feedback.customFieldValues
|
|
259
|
+
},
|
|
260
|
+
...userData && Object.keys(userData).length > 0 && {
|
|
261
|
+
user: userData
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
});
|
|
265
|
+
if (retryResponse.ok) {
|
|
266
|
+
return retryResponse.json();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
throw new Error(errorMessage || "Access denied");
|
|
271
|
+
}
|
|
272
|
+
if (!response.ok) {
|
|
273
|
+
const error = await this.parseError(response);
|
|
274
|
+
throw new Error(error);
|
|
275
|
+
}
|
|
276
|
+
const data = await response.json();
|
|
277
|
+
if (data.blocked) {
|
|
278
|
+
throw new Error(data.message || "Unable to submit feedback");
|
|
279
|
+
}
|
|
280
|
+
this.log("Feedback submitted", { feedbackId: data.feedback_id });
|
|
281
|
+
return data;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Parse error from response
|
|
285
|
+
*/
|
|
286
|
+
async parseError(response) {
|
|
287
|
+
try {
|
|
288
|
+
const data = await response.json();
|
|
289
|
+
return data.detail || data.message || data.error || `HTTP ${response.status}`;
|
|
290
|
+
} catch {
|
|
291
|
+
return `HTTP ${response.status}: ${response.statusText}`;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Clear all caches
|
|
296
|
+
*/
|
|
297
|
+
clearCache() {
|
|
298
|
+
this.configCache.clear();
|
|
299
|
+
this.submissionToken = null;
|
|
300
|
+
this.tokenExpiresAt = null;
|
|
301
|
+
this.log("Cache cleared");
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Debug logging
|
|
305
|
+
*/
|
|
306
|
+
log(message, data) {
|
|
307
|
+
if (this.debug) {
|
|
308
|
+
console.log(`[FeedValue API] ${message}`, data ?? "");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// src/fingerprint.ts
|
|
314
|
+
var FINGERPRINT_STORAGE_KEY = "fv_fingerprint";
|
|
315
|
+
function generateUUID() {
|
|
316
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
317
|
+
return crypto.randomUUID();
|
|
318
|
+
}
|
|
319
|
+
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
|
|
320
|
+
const bytes = new Uint8Array(16);
|
|
321
|
+
crypto.getRandomValues(bytes);
|
|
322
|
+
bytes[6] = bytes[6] & 15 | 64;
|
|
323
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
324
|
+
const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
325
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
326
|
+
}
|
|
327
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
328
|
+
const r = Math.random() * 16 | 0;
|
|
329
|
+
const v = c === "x" ? r : r & 3 | 8;
|
|
330
|
+
return v.toString(16);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
function generateFingerprint() {
|
|
334
|
+
if (typeof window === "undefined" || typeof sessionStorage === "undefined") {
|
|
335
|
+
return generateUUID();
|
|
336
|
+
}
|
|
337
|
+
const stored = sessionStorage.getItem(FINGERPRINT_STORAGE_KEY);
|
|
338
|
+
if (stored) {
|
|
339
|
+
return stored;
|
|
340
|
+
}
|
|
341
|
+
const fingerprint = generateUUID();
|
|
342
|
+
try {
|
|
343
|
+
sessionStorage.setItem(FINGERPRINT_STORAGE_KEY, fingerprint);
|
|
344
|
+
} catch {
|
|
345
|
+
}
|
|
346
|
+
return fingerprint;
|
|
347
|
+
}
|
|
348
|
+
function clearFingerprint() {
|
|
349
|
+
if (typeof sessionStorage !== "undefined") {
|
|
350
|
+
try {
|
|
351
|
+
sessionStorage.removeItem(FINGERPRINT_STORAGE_KEY);
|
|
352
|
+
} catch {
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/feedvalue.ts
|
|
358
|
+
var SUCCESS_AUTO_CLOSE_DELAY_MS = 3e3;
|
|
359
|
+
var VALID_SENTIMENTS = ["angry", "disappointed", "satisfied", "excited"];
|
|
360
|
+
var MAX_MESSAGE_LENGTH = 1e4;
|
|
361
|
+
var MAX_METADATA_VALUE_LENGTH = 1e3;
|
|
362
|
+
var DEFAULT_CONFIG = {
|
|
363
|
+
theme: "auto",
|
|
364
|
+
autoShow: true,
|
|
365
|
+
debug: false,
|
|
366
|
+
locale: "en"
|
|
367
|
+
};
|
|
368
|
+
var instances = /* @__PURE__ */ new Map();
|
|
369
|
+
var FeedValue = class _FeedValue {
|
|
370
|
+
/**
|
|
371
|
+
* Create a new FeedValue instance
|
|
372
|
+
* Use FeedValue.init() for public API
|
|
373
|
+
*/
|
|
374
|
+
constructor(options) {
|
|
375
|
+
__publicField(this, "widgetId");
|
|
376
|
+
__publicField(this, "apiClient");
|
|
377
|
+
__publicField(this, "emitter");
|
|
378
|
+
__publicField(this, "headless");
|
|
379
|
+
__publicField(this, "config");
|
|
380
|
+
__publicField(this, "widgetConfig", null);
|
|
381
|
+
// State
|
|
382
|
+
__publicField(this, "state", {
|
|
383
|
+
isReady: false,
|
|
384
|
+
isOpen: false,
|
|
385
|
+
isVisible: true,
|
|
386
|
+
error: null,
|
|
387
|
+
isSubmitting: false
|
|
388
|
+
});
|
|
389
|
+
// State subscribers (for React useSyncExternalStore)
|
|
390
|
+
__publicField(this, "stateSubscribers", /* @__PURE__ */ new Set());
|
|
391
|
+
__publicField(this, "stateSnapshot");
|
|
392
|
+
// User data (stored for future API submissions)
|
|
393
|
+
__publicField(this, "_userData", {});
|
|
394
|
+
__publicField(this, "_userId", null);
|
|
395
|
+
__publicField(this, "_userTraits", {});
|
|
396
|
+
// DOM elements (for vanilla usage)
|
|
397
|
+
__publicField(this, "triggerButton", null);
|
|
398
|
+
__publicField(this, "modal", null);
|
|
399
|
+
__publicField(this, "overlay", null);
|
|
400
|
+
__publicField(this, "stylesInjected", false);
|
|
401
|
+
// Auto-close timeout reference (for cleanup on destroy)
|
|
402
|
+
__publicField(this, "autoCloseTimeout", null);
|
|
403
|
+
this.widgetId = options.widgetId;
|
|
404
|
+
this.headless = options.headless ?? false;
|
|
405
|
+
this.config = { ...DEFAULT_CONFIG, ...options.config };
|
|
406
|
+
this.apiClient = new ApiClient(
|
|
407
|
+
options.apiBaseUrl ?? DEFAULT_API_BASE_URL,
|
|
408
|
+
this.config.debug
|
|
409
|
+
);
|
|
410
|
+
this.emitter = new TypedEventEmitter();
|
|
411
|
+
this.stateSnapshot = { ...this.state };
|
|
412
|
+
this.log("Instance created", { widgetId: this.widgetId, headless: this.headless });
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Initialize FeedValue
|
|
416
|
+
* Returns existing instance if already initialized for this widgetId
|
|
417
|
+
*/
|
|
418
|
+
static init(options) {
|
|
419
|
+
const existing = instances.get(options.widgetId);
|
|
420
|
+
if (existing) {
|
|
421
|
+
return existing;
|
|
422
|
+
}
|
|
423
|
+
const instance = new _FeedValue(options);
|
|
424
|
+
instances.set(options.widgetId, instance);
|
|
425
|
+
instance.init().catch((error) => {
|
|
426
|
+
console.error("[FeedValue] Initialization failed:", error);
|
|
427
|
+
});
|
|
428
|
+
return instance;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Get existing instance by widgetId
|
|
432
|
+
*/
|
|
433
|
+
static getInstance(widgetId) {
|
|
434
|
+
return instances.get(widgetId);
|
|
435
|
+
}
|
|
436
|
+
// ===========================================================================
|
|
437
|
+
// Lifecycle
|
|
438
|
+
// ===========================================================================
|
|
439
|
+
/**
|
|
440
|
+
* Initialize the widget
|
|
441
|
+
*/
|
|
442
|
+
async init() {
|
|
443
|
+
if (this.state.isReady) {
|
|
444
|
+
this.log("Already initialized");
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
this.log("Initializing...");
|
|
449
|
+
const fingerprint = generateFingerprint();
|
|
450
|
+
this.apiClient.setFingerprint(fingerprint);
|
|
451
|
+
const configResponse = await this.apiClient.fetchConfig(this.widgetId);
|
|
452
|
+
this.widgetConfig = {
|
|
453
|
+
widgetId: configResponse.widget_id,
|
|
454
|
+
widgetKey: configResponse.widget_key,
|
|
455
|
+
appId: "",
|
|
456
|
+
config: {
|
|
457
|
+
position: configResponse.config.position ?? "bottom-right",
|
|
458
|
+
triggerText: configResponse.config.triggerText ?? "Feedback",
|
|
459
|
+
triggerIcon: configResponse.config.triggerIcon ?? "none",
|
|
460
|
+
formTitle: configResponse.config.formTitle ?? "Share your feedback",
|
|
461
|
+
submitButtonText: configResponse.config.submitButtonText ?? "Submit",
|
|
462
|
+
thankYouMessage: configResponse.config.thankYouMessage ?? "Thank you for your feedback!",
|
|
463
|
+
showBranding: configResponse.config.showBranding ?? true,
|
|
464
|
+
customFields: configResponse.config.customFields
|
|
465
|
+
},
|
|
466
|
+
styling: {
|
|
467
|
+
primaryColor: configResponse.styling.primaryColor ?? "#3b82f6",
|
|
468
|
+
backgroundColor: configResponse.styling.backgroundColor ?? "#ffffff",
|
|
469
|
+
textColor: configResponse.styling.textColor ?? "#1f2937",
|
|
470
|
+
buttonTextColor: configResponse.styling.buttonTextColor ?? "#ffffff",
|
|
471
|
+
borderRadius: configResponse.styling.borderRadius ?? "8px",
|
|
472
|
+
customCSS: configResponse.styling.customCSS
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
if (!this.headless && typeof window !== "undefined" && typeof document !== "undefined") {
|
|
476
|
+
this.renderWidget();
|
|
477
|
+
}
|
|
478
|
+
this.updateState({ isReady: true, error: null });
|
|
479
|
+
this.emitter.emit("ready");
|
|
480
|
+
this.log("Initialized successfully");
|
|
481
|
+
} catch (error) {
|
|
482
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
483
|
+
this.updateState({ error: err });
|
|
484
|
+
this.emitter.emit("error", err);
|
|
485
|
+
throw err;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Destroy the widget
|
|
490
|
+
*/
|
|
491
|
+
destroy() {
|
|
492
|
+
this.log("Destroying...");
|
|
493
|
+
if (this.autoCloseTimeout) {
|
|
494
|
+
clearTimeout(this.autoCloseTimeout);
|
|
495
|
+
this.autoCloseTimeout = null;
|
|
496
|
+
}
|
|
497
|
+
this.triggerButton?.remove();
|
|
498
|
+
this.modal?.remove();
|
|
499
|
+
this.overlay?.remove();
|
|
500
|
+
document.getElementById("fv-widget-styles")?.remove();
|
|
501
|
+
document.getElementById("fv-widget-custom-styles")?.remove();
|
|
502
|
+
this.triggerButton = null;
|
|
503
|
+
this.modal = null;
|
|
504
|
+
this.overlay = null;
|
|
505
|
+
this.widgetConfig = null;
|
|
506
|
+
this.stateSubscribers.clear();
|
|
507
|
+
this.emitter.removeAllListeners();
|
|
508
|
+
this.apiClient.clearCache();
|
|
509
|
+
instances.delete(this.widgetId);
|
|
510
|
+
this.state = {
|
|
511
|
+
isReady: false,
|
|
512
|
+
isOpen: false,
|
|
513
|
+
isVisible: false,
|
|
514
|
+
error: null,
|
|
515
|
+
isSubmitting: false
|
|
516
|
+
};
|
|
517
|
+
this.log("Destroyed");
|
|
518
|
+
}
|
|
519
|
+
// ===========================================================================
|
|
520
|
+
// Widget Control
|
|
521
|
+
// ===========================================================================
|
|
522
|
+
open() {
|
|
523
|
+
if (!this.state.isReady) {
|
|
524
|
+
this.log("Cannot open: not ready");
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
this.updateState({ isOpen: true });
|
|
528
|
+
if (!this.headless) {
|
|
529
|
+
this.overlay?.classList.add("fv-widget-open");
|
|
530
|
+
this.modal?.classList.add("fv-widget-open");
|
|
531
|
+
}
|
|
532
|
+
this.emitter.emit("open");
|
|
533
|
+
this.log("Opened");
|
|
534
|
+
}
|
|
535
|
+
close() {
|
|
536
|
+
this.updateState({ isOpen: false });
|
|
537
|
+
if (!this.headless) {
|
|
538
|
+
this.overlay?.classList.remove("fv-widget-open");
|
|
539
|
+
this.modal?.classList.remove("fv-widget-open");
|
|
540
|
+
}
|
|
541
|
+
this.emitter.emit("close");
|
|
542
|
+
this.log("Closed");
|
|
543
|
+
}
|
|
544
|
+
toggle() {
|
|
545
|
+
if (this.state.isOpen) {
|
|
546
|
+
this.close();
|
|
547
|
+
} else {
|
|
548
|
+
this.open();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
show() {
|
|
552
|
+
this.updateState({ isVisible: true });
|
|
553
|
+
if (!this.headless && this.triggerButton) {
|
|
554
|
+
this.triggerButton.style.display = "";
|
|
555
|
+
}
|
|
556
|
+
this.log("Shown");
|
|
557
|
+
}
|
|
558
|
+
hide() {
|
|
559
|
+
this.updateState({ isVisible: false });
|
|
560
|
+
if (!this.headless && this.triggerButton) {
|
|
561
|
+
this.triggerButton.style.display = "none";
|
|
562
|
+
}
|
|
563
|
+
this.log("Hidden");
|
|
564
|
+
}
|
|
565
|
+
// ===========================================================================
|
|
566
|
+
// State Queries
|
|
567
|
+
// ===========================================================================
|
|
568
|
+
isOpen() {
|
|
569
|
+
return this.state.isOpen;
|
|
570
|
+
}
|
|
571
|
+
isVisible() {
|
|
572
|
+
return this.state.isVisible;
|
|
573
|
+
}
|
|
574
|
+
isReady() {
|
|
575
|
+
return this.state.isReady;
|
|
576
|
+
}
|
|
577
|
+
isHeadless() {
|
|
578
|
+
return this.headless;
|
|
579
|
+
}
|
|
580
|
+
// ===========================================================================
|
|
581
|
+
// User Data
|
|
582
|
+
// ===========================================================================
|
|
583
|
+
setData(data) {
|
|
584
|
+
this._userData = { ...this._userData, ...data };
|
|
585
|
+
this.log("User data set", data);
|
|
586
|
+
}
|
|
587
|
+
identify(userId, traits) {
|
|
588
|
+
this._userId = userId;
|
|
589
|
+
if (traits) {
|
|
590
|
+
this._userTraits = { ...this._userTraits, ...traits };
|
|
591
|
+
}
|
|
592
|
+
this.log("User identified", { userId, traits });
|
|
593
|
+
}
|
|
594
|
+
reset() {
|
|
595
|
+
this._userData = {};
|
|
596
|
+
this._userId = null;
|
|
597
|
+
this._userTraits = {};
|
|
598
|
+
this.log("User data reset");
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Get current user data (for debugging/testing)
|
|
602
|
+
*/
|
|
603
|
+
getUserData() {
|
|
604
|
+
return {
|
|
605
|
+
userId: this._userId,
|
|
606
|
+
data: { ...this._userData },
|
|
607
|
+
traits: { ...this._userTraits }
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
// ===========================================================================
|
|
611
|
+
// Feedback
|
|
612
|
+
// ===========================================================================
|
|
613
|
+
async submit(feedback) {
|
|
614
|
+
if (!this.state.isReady) {
|
|
615
|
+
throw new Error("Widget not ready");
|
|
616
|
+
}
|
|
617
|
+
this.validateFeedback(feedback);
|
|
618
|
+
this.updateState({ isSubmitting: true });
|
|
619
|
+
try {
|
|
620
|
+
const fullFeedback = {
|
|
621
|
+
message: feedback.message,
|
|
622
|
+
sentiment: feedback.sentiment,
|
|
623
|
+
customFieldValues: feedback.customFieldValues,
|
|
624
|
+
metadata: {
|
|
625
|
+
page_url: typeof window !== "undefined" ? window.location.href : "",
|
|
626
|
+
referrer: typeof document !== "undefined" ? document.referrer : void 0,
|
|
627
|
+
user_agent: typeof navigator !== "undefined" ? navigator.userAgent : void 0,
|
|
628
|
+
...feedback.metadata
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
const userData = this.buildSubmissionUserData();
|
|
632
|
+
await this.apiClient.submitFeedback(this.widgetId, fullFeedback, userData);
|
|
633
|
+
this.emitter.emit("submit", fullFeedback);
|
|
634
|
+
this.log("Feedback submitted", userData ? { withUserData: true } : void 0);
|
|
635
|
+
} catch (error) {
|
|
636
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
637
|
+
this.emitter.emit("error", err);
|
|
638
|
+
throw err;
|
|
639
|
+
} finally {
|
|
640
|
+
this.updateState({ isSubmitting: false });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Build user data object for API submission
|
|
645
|
+
* Combines data from identify() and setData() calls
|
|
646
|
+
*/
|
|
647
|
+
buildSubmissionUserData() {
|
|
648
|
+
const hasUserId = this._userId !== null;
|
|
649
|
+
const hasUserData = Object.keys(this._userData).length > 0;
|
|
650
|
+
const hasTraits = Object.keys(this._userTraits).length > 0;
|
|
651
|
+
if (!hasUserId && !hasUserData && !hasTraits) {
|
|
652
|
+
return void 0;
|
|
653
|
+
}
|
|
654
|
+
const result = {};
|
|
655
|
+
if (this._userId) {
|
|
656
|
+
result.user_id = this._userId;
|
|
657
|
+
}
|
|
658
|
+
const email = this._userData.email ?? this._userTraits.email;
|
|
659
|
+
const name = this._userData.name ?? this._userTraits.name;
|
|
660
|
+
if (email) result.email = email;
|
|
661
|
+
if (name) result.name = name;
|
|
662
|
+
if (hasTraits) {
|
|
663
|
+
const { email: _e, name: _n, ...otherTraits } = this._userTraits;
|
|
664
|
+
if (Object.keys(otherTraits).length > 0) {
|
|
665
|
+
result.traits = otherTraits;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (hasUserData) {
|
|
669
|
+
const { email: _e, name: _n, ...customData } = this._userData;
|
|
670
|
+
const filtered = {};
|
|
671
|
+
for (const [key, value] of Object.entries(customData)) {
|
|
672
|
+
if (value !== void 0) {
|
|
673
|
+
filtered[key] = value;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (Object.keys(filtered).length > 0) {
|
|
677
|
+
result.custom_data = filtered;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return result;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Validate feedback data before submission
|
|
684
|
+
* @throws Error if validation fails
|
|
685
|
+
*/
|
|
686
|
+
validateFeedback(feedback) {
|
|
687
|
+
if (!feedback.message?.trim()) {
|
|
688
|
+
throw new Error("Feedback message is required");
|
|
689
|
+
}
|
|
690
|
+
if (feedback.message.length > MAX_MESSAGE_LENGTH) {
|
|
691
|
+
throw new Error(`Feedback message exceeds maximum length of ${MAX_MESSAGE_LENGTH} characters`);
|
|
692
|
+
}
|
|
693
|
+
if (feedback.sentiment !== void 0 && !VALID_SENTIMENTS.includes(feedback.sentiment)) {
|
|
694
|
+
throw new Error(`Invalid sentiment value. Must be one of: ${VALID_SENTIMENTS.join(", ")}`);
|
|
695
|
+
}
|
|
696
|
+
if (feedback.customFieldValues) {
|
|
697
|
+
for (const [key, value] of Object.entries(feedback.customFieldValues)) {
|
|
698
|
+
if (typeof key !== "string" || typeof value !== "string") {
|
|
699
|
+
throw new Error("Custom field values must be strings");
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (feedback.metadata) {
|
|
704
|
+
for (const [key, value] of Object.entries(feedback.metadata)) {
|
|
705
|
+
if (typeof value === "string" && value.length > MAX_METADATA_VALUE_LENGTH) {
|
|
706
|
+
throw new Error(`Metadata field "${key}" exceeds maximum length of ${MAX_METADATA_VALUE_LENGTH} characters`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
// ===========================================================================
|
|
712
|
+
// Events
|
|
713
|
+
// ===========================================================================
|
|
714
|
+
on(event, callback) {
|
|
715
|
+
this.emitter.on(event, callback);
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Subscribe to an event for a single emission.
|
|
719
|
+
* The handler will be automatically removed after the first call.
|
|
720
|
+
*/
|
|
721
|
+
once(event, callback) {
|
|
722
|
+
this.emitter.once(event, callback);
|
|
723
|
+
}
|
|
724
|
+
off(event, callback) {
|
|
725
|
+
this.emitter.off(event, callback);
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Returns a promise that resolves when the widget is ready.
|
|
729
|
+
* Useful for programmatic initialization flows.
|
|
730
|
+
*
|
|
731
|
+
* @throws {Error} If initialization fails
|
|
732
|
+
*/
|
|
733
|
+
async waitUntilReady() {
|
|
734
|
+
if (this.state.isReady) {
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (this.state.error) {
|
|
738
|
+
throw this.state.error;
|
|
739
|
+
}
|
|
740
|
+
return new Promise((resolve, reject) => {
|
|
741
|
+
this.once("ready", () => resolve());
|
|
742
|
+
this.once("error", (error) => reject(error));
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
// ===========================================================================
|
|
746
|
+
// Configuration
|
|
747
|
+
// ===========================================================================
|
|
748
|
+
setConfig(config) {
|
|
749
|
+
this.config = { ...this.config, ...config };
|
|
750
|
+
this.log("Config updated", config);
|
|
751
|
+
}
|
|
752
|
+
getConfig() {
|
|
753
|
+
return { ...this.config };
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Get widget configuration (from API)
|
|
757
|
+
*/
|
|
758
|
+
getWidgetConfig() {
|
|
759
|
+
return this.widgetConfig;
|
|
760
|
+
}
|
|
761
|
+
// ===========================================================================
|
|
762
|
+
// Framework Integration
|
|
763
|
+
// ===========================================================================
|
|
764
|
+
/**
|
|
765
|
+
* Subscribe to state changes
|
|
766
|
+
* Used by React's useSyncExternalStore
|
|
767
|
+
*/
|
|
768
|
+
subscribe(callback) {
|
|
769
|
+
this.stateSubscribers.add(callback);
|
|
770
|
+
return () => {
|
|
771
|
+
this.stateSubscribers.delete(callback);
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Get current state snapshot
|
|
776
|
+
* Used by React's useSyncExternalStore
|
|
777
|
+
*/
|
|
778
|
+
getSnapshot() {
|
|
779
|
+
return this.stateSnapshot;
|
|
780
|
+
}
|
|
781
|
+
// ===========================================================================
|
|
782
|
+
// Internal Methods
|
|
783
|
+
// ===========================================================================
|
|
784
|
+
/**
|
|
785
|
+
* Update state and notify subscribers
|
|
786
|
+
*/
|
|
787
|
+
updateState(partial) {
|
|
788
|
+
this.state = { ...this.state, ...partial };
|
|
789
|
+
this.stateSnapshot = { ...this.state };
|
|
790
|
+
this.emitter.emit("stateChange", this.stateSnapshot);
|
|
791
|
+
for (const subscriber of this.stateSubscribers) {
|
|
792
|
+
subscriber();
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Render widget DOM elements (for vanilla usage)
|
|
797
|
+
*/
|
|
798
|
+
renderWidget() {
|
|
799
|
+
if (!this.widgetConfig) return;
|
|
800
|
+
if (!this.stylesInjected) {
|
|
801
|
+
this.injectStyles();
|
|
802
|
+
this.stylesInjected = true;
|
|
803
|
+
}
|
|
804
|
+
this.renderTrigger();
|
|
805
|
+
this.renderModal();
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Sanitize CSS to block potentially dangerous patterns
|
|
809
|
+
* Prevents CSS injection attacks via url(), @import, and other vectors
|
|
810
|
+
*/
|
|
811
|
+
sanitizeCSS(css) {
|
|
812
|
+
const BLOCKED_PATTERNS = [
|
|
813
|
+
/url\s*\(/gi,
|
|
814
|
+
// External resources
|
|
815
|
+
/@import/gi,
|
|
816
|
+
// External stylesheets
|
|
817
|
+
/expression\s*\(/gi,
|
|
818
|
+
// IE expressions
|
|
819
|
+
/javascript:/gi,
|
|
820
|
+
// JavaScript URLs
|
|
821
|
+
/behavior\s*:/gi,
|
|
822
|
+
// IE behaviors
|
|
823
|
+
/-moz-binding/gi
|
|
824
|
+
// Firefox XBL
|
|
825
|
+
];
|
|
826
|
+
for (const pattern of BLOCKED_PATTERNS) {
|
|
827
|
+
if (pattern.test(css)) {
|
|
828
|
+
console.warn("[FeedValue] Blocked potentially unsafe CSS pattern");
|
|
829
|
+
return "";
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return css;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Inject CSS styles
|
|
836
|
+
*/
|
|
837
|
+
injectStyles() {
|
|
838
|
+
if (!this.widgetConfig) return;
|
|
839
|
+
const { styling, config } = this.widgetConfig;
|
|
840
|
+
const styleEl = document.createElement("style");
|
|
841
|
+
styleEl.id = "fv-widget-styles";
|
|
842
|
+
styleEl.textContent = this.getBaseStyles(styling, config.position);
|
|
843
|
+
document.head.appendChild(styleEl);
|
|
844
|
+
if (styling.customCSS) {
|
|
845
|
+
const sanitizedCSS = this.sanitizeCSS(styling.customCSS);
|
|
846
|
+
if (sanitizedCSS) {
|
|
847
|
+
const customStyleEl = document.createElement("style");
|
|
848
|
+
customStyleEl.id = "fv-widget-custom-styles";
|
|
849
|
+
customStyleEl.textContent = sanitizedCSS;
|
|
850
|
+
document.head.appendChild(customStyleEl);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Get base CSS styles
|
|
856
|
+
*/
|
|
857
|
+
getBaseStyles(styling, position) {
|
|
858
|
+
const positionStyles = this.getPositionStyles(position);
|
|
859
|
+
const modalPositionStyles = this.getModalPositionStyles(position);
|
|
860
|
+
return `
|
|
861
|
+
.fv-widget-trigger {
|
|
862
|
+
position: fixed;
|
|
863
|
+
${positionStyles}
|
|
864
|
+
background-color: ${styling.primaryColor};
|
|
865
|
+
color: ${styling.buttonTextColor};
|
|
866
|
+
padding: 12px 24px;
|
|
867
|
+
border-radius: ${styling.borderRadius};
|
|
868
|
+
cursor: pointer;
|
|
869
|
+
z-index: 9998;
|
|
870
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
871
|
+
font-size: 14px;
|
|
872
|
+
font-weight: 500;
|
|
873
|
+
border: none;
|
|
874
|
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
875
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
876
|
+
}
|
|
877
|
+
.fv-widget-trigger:hover {
|
|
878
|
+
transform: translateY(-2px);
|
|
879
|
+
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
|
|
880
|
+
}
|
|
881
|
+
.fv-widget-overlay {
|
|
882
|
+
position: fixed;
|
|
883
|
+
top: 0;
|
|
884
|
+
left: 0;
|
|
885
|
+
width: 100%;
|
|
886
|
+
height: 100%;
|
|
887
|
+
background-color: rgba(0, 0, 0, 0.5);
|
|
888
|
+
z-index: 9998;
|
|
889
|
+
display: none;
|
|
890
|
+
backdrop-filter: blur(4px);
|
|
891
|
+
}
|
|
892
|
+
.fv-widget-overlay.fv-widget-open {
|
|
893
|
+
display: block;
|
|
894
|
+
}
|
|
895
|
+
.fv-widget-modal {
|
|
896
|
+
position: fixed;
|
|
897
|
+
${modalPositionStyles}
|
|
898
|
+
background-color: ${styling.backgroundColor};
|
|
899
|
+
color: ${styling.textColor};
|
|
900
|
+
border-radius: ${styling.borderRadius};
|
|
901
|
+
padding: 24px;
|
|
902
|
+
max-width: 500px;
|
|
903
|
+
width: 90%;
|
|
904
|
+
max-height: 90vh;
|
|
905
|
+
overflow-y: auto;
|
|
906
|
+
z-index: 9999;
|
|
907
|
+
display: none;
|
|
908
|
+
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
|
909
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
910
|
+
}
|
|
911
|
+
.fv-widget-modal.fv-widget-open {
|
|
912
|
+
display: block;
|
|
913
|
+
}
|
|
914
|
+
.fv-widget-header {
|
|
915
|
+
display: flex;
|
|
916
|
+
justify-content: space-between;
|
|
917
|
+
align-items: center;
|
|
918
|
+
margin-bottom: 20px;
|
|
919
|
+
}
|
|
920
|
+
.fv-widget-title {
|
|
921
|
+
font-size: 20px;
|
|
922
|
+
font-weight: 600;
|
|
923
|
+
margin: 0;
|
|
924
|
+
}
|
|
925
|
+
.fv-widget-close {
|
|
926
|
+
background: transparent;
|
|
927
|
+
border: none;
|
|
928
|
+
font-size: 24px;
|
|
929
|
+
cursor: pointer;
|
|
930
|
+
color: ${styling.textColor};
|
|
931
|
+
padding: 0;
|
|
932
|
+
width: 32px;
|
|
933
|
+
height: 32px;
|
|
934
|
+
display: flex;
|
|
935
|
+
align-items: center;
|
|
936
|
+
justify-content: center;
|
|
937
|
+
}
|
|
938
|
+
.fv-widget-form {
|
|
939
|
+
display: flex;
|
|
940
|
+
flex-direction: column;
|
|
941
|
+
gap: 16px;
|
|
942
|
+
}
|
|
943
|
+
.fv-widget-textarea {
|
|
944
|
+
width: 100%;
|
|
945
|
+
min-height: 120px;
|
|
946
|
+
padding: 12px;
|
|
947
|
+
border: 1px solid rgba(0, 0, 0, 0.2);
|
|
948
|
+
border-radius: ${styling.borderRadius};
|
|
949
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
950
|
+
font-size: 14px;
|
|
951
|
+
resize: vertical;
|
|
952
|
+
box-sizing: border-box;
|
|
953
|
+
background-color: ${styling.backgroundColor};
|
|
954
|
+
color: ${styling.textColor};
|
|
955
|
+
}
|
|
956
|
+
.fv-widget-textarea:focus {
|
|
957
|
+
outline: none;
|
|
958
|
+
border-color: ${styling.primaryColor};
|
|
959
|
+
box-shadow: 0 0 0 2px ${styling.primaryColor}33;
|
|
960
|
+
}
|
|
961
|
+
.fv-widget-submit {
|
|
962
|
+
background-color: ${styling.primaryColor};
|
|
963
|
+
color: ${styling.buttonTextColor};
|
|
964
|
+
padding: 12px 24px;
|
|
965
|
+
border: none;
|
|
966
|
+
border-radius: ${styling.borderRadius};
|
|
967
|
+
font-size: 14px;
|
|
968
|
+
font-weight: 500;
|
|
969
|
+
cursor: pointer;
|
|
970
|
+
transition: opacity 0.2s;
|
|
971
|
+
}
|
|
972
|
+
.fv-widget-submit:hover:not(:disabled) {
|
|
973
|
+
opacity: 0.9;
|
|
974
|
+
}
|
|
975
|
+
.fv-widget-submit:disabled {
|
|
976
|
+
opacity: 0.5;
|
|
977
|
+
cursor: not-allowed;
|
|
978
|
+
}
|
|
979
|
+
.fv-widget-error {
|
|
980
|
+
color: #ef4444;
|
|
981
|
+
font-size: 14px;
|
|
982
|
+
margin-top: 8px;
|
|
983
|
+
display: none;
|
|
984
|
+
}
|
|
985
|
+
.fv-widget-branding {
|
|
986
|
+
text-align: center;
|
|
987
|
+
font-size: 12px;
|
|
988
|
+
color: ${styling.textColor};
|
|
989
|
+
margin-top: 16px;
|
|
990
|
+
opacity: 0.7;
|
|
991
|
+
}
|
|
992
|
+
.fv-widget-branding a {
|
|
993
|
+
color: ${styling.primaryColor};
|
|
994
|
+
text-decoration: none;
|
|
995
|
+
}
|
|
996
|
+
`;
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Get trigger button position styles
|
|
1000
|
+
*/
|
|
1001
|
+
getPositionStyles(position) {
|
|
1002
|
+
switch (position) {
|
|
1003
|
+
case "bottom-left":
|
|
1004
|
+
return "bottom: 20px; left: 20px;";
|
|
1005
|
+
case "top-right":
|
|
1006
|
+
return "top: 20px; right: 20px;";
|
|
1007
|
+
case "top-left":
|
|
1008
|
+
return "top: 20px; left: 20px;";
|
|
1009
|
+
case "center":
|
|
1010
|
+
return "top: 50%; left: 50%; transform: translate(-50%, -50%);";
|
|
1011
|
+
case "bottom-right":
|
|
1012
|
+
default:
|
|
1013
|
+
return "bottom: 20px; right: 20px;";
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Get modal position styles
|
|
1018
|
+
*/
|
|
1019
|
+
getModalPositionStyles(position) {
|
|
1020
|
+
switch (position) {
|
|
1021
|
+
case "bottom-left":
|
|
1022
|
+
return "bottom: 20px; left: 20px;";
|
|
1023
|
+
case "bottom-right":
|
|
1024
|
+
return "bottom: 20px; right: 20px;";
|
|
1025
|
+
case "top-right":
|
|
1026
|
+
return "top: 20px; right: 20px;";
|
|
1027
|
+
case "top-left":
|
|
1028
|
+
return "top: 20px; left: 20px;";
|
|
1029
|
+
case "center":
|
|
1030
|
+
default:
|
|
1031
|
+
return "top: 50%; left: 50%; transform: translate(-50%, -50%);";
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Render trigger button using safe DOM methods
|
|
1036
|
+
*/
|
|
1037
|
+
renderTrigger() {
|
|
1038
|
+
if (!this.widgetConfig) return;
|
|
1039
|
+
this.triggerButton = document.createElement("button");
|
|
1040
|
+
this.triggerButton.className = "fv-widget-trigger";
|
|
1041
|
+
this.triggerButton.textContent = this.widgetConfig.config.triggerText;
|
|
1042
|
+
this.triggerButton.addEventListener("click", () => this.open());
|
|
1043
|
+
document.body.appendChild(this.triggerButton);
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Render modal using safe DOM methods (no innerHTML)
|
|
1047
|
+
*/
|
|
1048
|
+
renderModal() {
|
|
1049
|
+
if (!this.widgetConfig) return;
|
|
1050
|
+
const { config } = this.widgetConfig;
|
|
1051
|
+
this.overlay = document.createElement("div");
|
|
1052
|
+
this.overlay.className = "fv-widget-overlay";
|
|
1053
|
+
this.overlay.addEventListener("click", () => this.close());
|
|
1054
|
+
document.body.appendChild(this.overlay);
|
|
1055
|
+
this.modal = document.createElement("div");
|
|
1056
|
+
this.modal.className = "fv-widget-modal";
|
|
1057
|
+
const header = document.createElement("div");
|
|
1058
|
+
header.className = "fv-widget-header";
|
|
1059
|
+
const title = document.createElement("h2");
|
|
1060
|
+
title.className = "fv-widget-title";
|
|
1061
|
+
title.textContent = config.formTitle;
|
|
1062
|
+
header.appendChild(title);
|
|
1063
|
+
const closeBtn = document.createElement("button");
|
|
1064
|
+
closeBtn.className = "fv-widget-close";
|
|
1065
|
+
closeBtn.setAttribute("aria-label", "Close");
|
|
1066
|
+
closeBtn.textContent = "\xD7";
|
|
1067
|
+
closeBtn.addEventListener("click", () => this.close());
|
|
1068
|
+
header.appendChild(closeBtn);
|
|
1069
|
+
this.modal.appendChild(header);
|
|
1070
|
+
const form = document.createElement("form");
|
|
1071
|
+
form.className = "fv-widget-form";
|
|
1072
|
+
form.id = "fv-feedback-form";
|
|
1073
|
+
const textarea = document.createElement("textarea");
|
|
1074
|
+
textarea.className = "fv-widget-textarea";
|
|
1075
|
+
textarea.id = "fv-feedback-content";
|
|
1076
|
+
textarea.placeholder = "Tell us what you think...";
|
|
1077
|
+
textarea.required = true;
|
|
1078
|
+
form.appendChild(textarea);
|
|
1079
|
+
const submitBtn = document.createElement("button");
|
|
1080
|
+
submitBtn.type = "submit";
|
|
1081
|
+
submitBtn.className = "fv-widget-submit";
|
|
1082
|
+
submitBtn.textContent = config.submitButtonText;
|
|
1083
|
+
form.appendChild(submitBtn);
|
|
1084
|
+
const errorDiv = document.createElement("div");
|
|
1085
|
+
errorDiv.className = "fv-widget-error";
|
|
1086
|
+
errorDiv.id = "fv-error-message";
|
|
1087
|
+
form.appendChild(errorDiv);
|
|
1088
|
+
form.addEventListener("submit", (e) => this.handleFormSubmit(e));
|
|
1089
|
+
this.modal.appendChild(form);
|
|
1090
|
+
if (config.showBranding) {
|
|
1091
|
+
const branding = document.createElement("div");
|
|
1092
|
+
branding.className = "fv-widget-branding";
|
|
1093
|
+
const brandText = document.createTextNode("Powered by ");
|
|
1094
|
+
branding.appendChild(brandText);
|
|
1095
|
+
const link = document.createElement("a");
|
|
1096
|
+
link.href = "https://feedvalue.com";
|
|
1097
|
+
link.target = "_blank";
|
|
1098
|
+
link.rel = "noopener noreferrer";
|
|
1099
|
+
link.textContent = "FeedValue";
|
|
1100
|
+
branding.appendChild(link);
|
|
1101
|
+
this.modal.appendChild(branding);
|
|
1102
|
+
}
|
|
1103
|
+
document.body.appendChild(this.modal);
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* Handle form submission
|
|
1107
|
+
*/
|
|
1108
|
+
async handleFormSubmit(event) {
|
|
1109
|
+
event.preventDefault();
|
|
1110
|
+
const textarea = document.getElementById("fv-feedback-content");
|
|
1111
|
+
const submitBtn = this.modal?.querySelector(".fv-widget-submit");
|
|
1112
|
+
const errorEl = document.getElementById("fv-error-message");
|
|
1113
|
+
if (!textarea?.value.trim()) {
|
|
1114
|
+
this.showError("Please enter your feedback");
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
try {
|
|
1118
|
+
submitBtn.disabled = true;
|
|
1119
|
+
submitBtn.textContent = "Submitting...";
|
|
1120
|
+
if (errorEl) errorEl.style.display = "none";
|
|
1121
|
+
await this.submit({ message: textarea.value.trim() });
|
|
1122
|
+
this.showSuccess();
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
const message = error instanceof Error ? error.message : "Failed to submit";
|
|
1125
|
+
this.showError(message);
|
|
1126
|
+
} finally {
|
|
1127
|
+
submitBtn.disabled = false;
|
|
1128
|
+
submitBtn.textContent = this.widgetConfig?.config.submitButtonText ?? "Submit";
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Show error message (safe - uses textContent)
|
|
1133
|
+
*/
|
|
1134
|
+
showError(message) {
|
|
1135
|
+
const errorEl = document.getElementById("fv-error-message");
|
|
1136
|
+
if (errorEl) {
|
|
1137
|
+
errorEl.textContent = message;
|
|
1138
|
+
errorEl.style.display = "block";
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Show success message using safe DOM methods
|
|
1143
|
+
*/
|
|
1144
|
+
showSuccess() {
|
|
1145
|
+
if (!this.modal || !this.widgetConfig) return;
|
|
1146
|
+
if (this.autoCloseTimeout) {
|
|
1147
|
+
clearTimeout(this.autoCloseTimeout);
|
|
1148
|
+
this.autoCloseTimeout = null;
|
|
1149
|
+
}
|
|
1150
|
+
this.modal.textContent = "";
|
|
1151
|
+
const successDiv = document.createElement("div");
|
|
1152
|
+
successDiv.style.cssText = "text-align: center; padding: 40px 20px;";
|
|
1153
|
+
const iconDiv = document.createElement("div");
|
|
1154
|
+
iconDiv.style.cssText = "font-size: 48px; margin-bottom: 16px;";
|
|
1155
|
+
iconDiv.textContent = "\u2713";
|
|
1156
|
+
successDiv.appendChild(iconDiv);
|
|
1157
|
+
const messageDiv = document.createElement("div");
|
|
1158
|
+
messageDiv.style.cssText = "font-size: 16px; margin-bottom: 24px;";
|
|
1159
|
+
messageDiv.textContent = this.widgetConfig.config.thankYouMessage;
|
|
1160
|
+
successDiv.appendChild(messageDiv);
|
|
1161
|
+
const closeBtn = document.createElement("button");
|
|
1162
|
+
closeBtn.className = "fv-widget-submit";
|
|
1163
|
+
closeBtn.textContent = "Close";
|
|
1164
|
+
closeBtn.addEventListener("click", () => {
|
|
1165
|
+
this.close();
|
|
1166
|
+
this.resetForm();
|
|
1167
|
+
});
|
|
1168
|
+
successDiv.appendChild(closeBtn);
|
|
1169
|
+
this.modal.appendChild(successDiv);
|
|
1170
|
+
this.autoCloseTimeout = setTimeout(() => {
|
|
1171
|
+
if (this.state.isOpen) {
|
|
1172
|
+
this.close();
|
|
1173
|
+
this.resetForm();
|
|
1174
|
+
}
|
|
1175
|
+
this.autoCloseTimeout = null;
|
|
1176
|
+
}, SUCCESS_AUTO_CLOSE_DELAY_MS);
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Reset form to initial state
|
|
1180
|
+
*/
|
|
1181
|
+
resetForm() {
|
|
1182
|
+
if (!this.modal) return;
|
|
1183
|
+
this.modal.textContent = "";
|
|
1184
|
+
this.modal.remove();
|
|
1185
|
+
this.modal = null;
|
|
1186
|
+
this.renderModal();
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Debug logging
|
|
1190
|
+
*/
|
|
1191
|
+
log(message, data) {
|
|
1192
|
+
if (this.config.debug) {
|
|
1193
|
+
console.log(`[FeedValue] ${message}`, data ?? "");
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
|
|
1198
|
+
export { ApiClient, DEFAULT_API_BASE_URL, FeedValue, TypedEventEmitter, clearFingerprint, generateFingerprint };
|
|
1199
|
+
//# sourceMappingURL=index.js.map
|
|
1200
|
+
//# sourceMappingURL=index.js.map
|