@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/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