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